feat(chat): add Anthropic and OpenAI-compatible adapters

Each adapter imports exactly one SDK. index.test.ts pins the env gate as
fail-closed: only the exact string 'true' enables chat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DEls 2026-08-29 20:41:31 +02:00
parent 89bde4c7ff
commit 5cc0d91f2d
4 changed files with 283 additions and 0 deletions

View file

@ -0,0 +1,30 @@
import { describe, it, expect, afterEach } from "vitest";
import { chatEnabled } from "./index";
const ORIGINAL = process.env.ENABLE_CHAT;
afterEach(() => {
if (ORIGINAL === undefined) delete process.env.ENABLE_CHAT;
else process.env.ENABLE_CHAT = ORIGINAL;
});
describe("chatEnabled", () => {
it("is false when ENABLE_CHAT is unset", () => {
delete process.env.ENABLE_CHAT;
expect(chatEnabled()).toBe(false);
});
it("is false for any value other than exactly 'true'", () => {
process.env.ENABLE_CHAT = "TRUE";
expect(chatEnabled()).toBe(false);
process.env.ENABLE_CHAT = "1";
expect(chatEnabled()).toBe(false);
process.env.ENABLE_CHAT = "yes";
expect(chatEnabled()).toBe(false);
});
it("is true only when ENABLE_CHAT is exactly 'true'", () => {
process.env.ENABLE_CHAT = "true";
expect(chatEnabled()).toBe(true);
});
});

23
src/lib/chat/index.ts Normal file
View file

@ -0,0 +1,23 @@
/**
* Provider selection. The only place that reads CHAT_PROVIDER.
*/
import type { ChatProvider } from "./types";
import { createAnthropicProvider } from "./provider-anthropic";
import { createOpenAiCompatibleProvider } from "./provider-openai";
export * from "./types";
export { runChatLoop } from "./loop";
export { toChatToolDefs } from "./tool-schemas";
export { SYSTEM_PROMPT } from "./system-prompt";
export function chatEnabled(): boolean {
return process.env.ENABLE_CHAT === "true";
}
export function getProvider(): ChatProvider {
const choice = (process.env.CHAT_PROVIDER || "anthropic").toLowerCase();
if (choice === "minimax" || choice === "openai") {
return createOpenAiCompatibleProvider();
}
return createAnthropicProvider();
}

View file

@ -0,0 +1,107 @@
/**
* Anthropic adapter. Uses the official SDK only.
*
* API specifics verified against the bundled claude-api skill, not
* recalled: model claude-opus-5; adaptive thinking (budget_tokens is
* removed and 400s); no assistant prefill; stream + finalMessage()
* rather than hand-rolled event promises.
*/
import Anthropic from "@anthropic-ai/sdk";
import type {
AssistantTurn,
ChatEntry,
ChatProvider,
RunTurnArgs,
} from "./types";
const MODEL = "claude-opus-5";
function toAnthropicMessages(history: ChatEntry[]): Anthropic.MessageParam[] {
const out: Anthropic.MessageParam[] = [];
for (const entry of history) {
if (entry.role === "user") {
out.push({ role: "user", content: entry.text });
} else if (entry.role === "assistant") {
const content: Anthropic.ContentBlockParam[] = [];
if (entry.text) content.push({ type: "text", text: entry.text });
for (const c of entry.toolCalls) {
content.push({
type: "tool_use",
id: c.id,
name: c.name,
input: c.input,
});
}
// An assistant turn with neither text nor tools is not a legal
// message; skip rather than send an empty content array.
if (content.length > 0) out.push({ role: "assistant", content });
} else {
const content: Anthropic.ToolResultBlockParam[] = entry.results.map((r) => ({
type: "tool_result",
tool_use_id: r.id,
content: r.content,
...(r.isError ? { is_error: true } : {}),
}));
out.push({ role: "user", content });
}
}
return out;
}
export function createAnthropicProvider(): ChatProvider {
// Zero-arg constructor resolves ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN,
// or an `ant auth login` profile — do not hardcode a key.
const client = new Anthropic();
return {
name: "anthropic",
model: MODEL,
async runTurn(args: RunTurnArgs): Promise<AssistantTurn> {
const stream = client.messages.stream(
{
model: MODEL,
max_tokens: 64000,
thinking: { type: "adaptive" },
system: args.system,
messages: toAnthropicMessages(args.history),
tools: args.tools.map((t) => ({
name: t.name,
description: t.description,
input_schema: t.inputSchema as Anthropic.Tool["input_schema"],
})),
},
{ signal: args.signal },
);
stream.on("text", (delta) => args.onTextDelta(delta));
const message = await stream.finalMessage();
// pause_turn means a server-side tool hit its iteration limit. We
// declare no server tools, so this should not occur — surface it
// rather than silently returning a truncated answer.
if (message.stop_reason === "pause_turn") {
return { text: "", toolCalls: [], done: false };
}
const text = message.content
.filter((b): b is Anthropic.TextBlock => b.type === "text")
.map((b) => b.text)
.join("");
const toolCalls = message.content
.filter((b): b is Anthropic.ToolUseBlock => b.type === "tool_use")
.map((b) => ({
id: b.id,
name: b.name,
input: (b.input ?? {}) as Record<string, unknown>,
}));
return {
text,
toolCalls,
done: message.stop_reason !== "tool_use",
};
},
};
}

View file

@ -0,0 +1,123 @@
/**
* OpenAI-compatible adapter — used for MiniMax.
*
* MiniMax notes that shaped this file:
* - Use the native OpenAI-compatible route (https://api.minimax.io/v1).
* Their /anthropic shim does not reliably re-parse stringified
* tool-call arguments, causing intermittent tool-call failures.
* - Use `tools` / `tool_choice`; the deprecated `function_call` is
* rejected outright.
* - `n` must be 1; presence_penalty / frequency_penalty are ignored.
* - The API key must be an Open Platform key, not a Token Plan
* Subscription Key — they are not interchangeable.
*/
import OpenAI from "openai";
import type {
AssistantTurn,
ChatEntry,
ChatProvider,
RunTurnArgs,
} from "./types";
type ChatMessage = OpenAI.Chat.Completions.ChatCompletionMessageParam;
function toOpenAiMessages(system: string, history: ChatEntry[]): ChatMessage[] {
const out: ChatMessage[] = [{ role: "system", content: system }];
for (const entry of history) {
if (entry.role === "user") {
out.push({ role: "user", content: entry.text });
} else if (entry.role === "assistant") {
out.push({
role: "assistant",
content: entry.text || null,
...(entry.toolCalls.length > 0
? {
tool_calls: entry.toolCalls.map((c) => ({
id: c.id,
type: "function" as const,
function: {
name: c.name,
arguments: JSON.stringify(c.input),
},
})),
}
: {}),
});
} else {
// One tool message per result, each keyed to its call id.
for (const r of entry.results) {
out.push({ role: "tool", tool_call_id: r.id, content: r.content });
}
}
}
return out;
}
export function createOpenAiCompatibleProvider(): ChatProvider {
const model = process.env.MINIMAX_MODEL || "MiniMax-M2";
const client = new OpenAI({
apiKey: process.env.MINIMAX_API_KEY,
baseURL: process.env.MINIMAX_BASE_URL || "https://api.minimax.io/v1",
});
return {
name: "minimax",
model,
async runTurn(args: RunTurnArgs): Promise<AssistantTurn> {
const stream = await client.chat.completions.create(
{
model,
stream: true,
temperature: 1.0, // MiniMax range is [0,2]; 1.0 is recommended
messages: toOpenAiMessages(args.system, args.history),
tools: args.tools.map((t) => ({
type: "function" as const,
function: {
name: t.name,
description: t.description,
parameters: t.inputSchema,
},
})),
},
{ signal: args.signal },
);
let text = "";
// Tool-call arguments arrive as string fragments across deltas and
// must be concatenated per index before parsing.
const partial = new Map<number, { id: string; name: string; args: string }>();
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta;
if (!delta) continue;
if (delta.content) {
text += delta.content;
args.onTextDelta(delta.content);
}
for (const tc of delta.tool_calls ?? []) {
const cur = partial.get(tc.index) ?? { id: "", name: "", args: "" };
if (tc.id) cur.id = tc.id;
if (tc.function?.name) cur.name = tc.function.name;
if (tc.function?.arguments) cur.args += tc.function.arguments;
partial.set(tc.index, cur);
}
}
const toolCalls = Array.from(partial.values()).map((c) => {
let input: Record<string, unknown> = {};
try {
input = c.args ? (JSON.parse(c.args) as Record<string, unknown>) : {};
} catch {
// Malformed arguments: pass an empty object through so the tool
// returns its own validation error rather than crashing the turn.
input = {};
}
return { id: c.id, name: c.name, input };
});
return { text, toolCalls, done: toolCalls.length === 0 };
},
};
}