From 5cc0d91f2dbfda5e9a0c162990f4abf59911fbb4 Mon Sep 17 00:00:00 2001 From: DEls Date: Sat, 29 Aug 2026 20:41:31 +0200 Subject: [PATCH] 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 --- src/lib/chat/index.test.ts | 30 +++++++ src/lib/chat/index.ts | 23 ++++++ src/lib/chat/provider-anthropic.ts | 107 +++++++++++++++++++++++++ src/lib/chat/provider-openai.ts | 123 +++++++++++++++++++++++++++++ 4 files changed, 283 insertions(+) create mode 100644 src/lib/chat/index.test.ts create mode 100644 src/lib/chat/index.ts create mode 100644 src/lib/chat/provider-anthropic.ts create mode 100644 src/lib/chat/provider-openai.ts diff --git a/src/lib/chat/index.test.ts b/src/lib/chat/index.test.ts new file mode 100644 index 0000000..2a11123 --- /dev/null +++ b/src/lib/chat/index.test.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/src/lib/chat/index.ts b/src/lib/chat/index.ts new file mode 100644 index 0000000..6116f11 --- /dev/null +++ b/src/lib/chat/index.ts @@ -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(); +} \ No newline at end of file diff --git a/src/lib/chat/provider-anthropic.ts b/src/lib/chat/provider-anthropic.ts new file mode 100644 index 0000000..66f1692 --- /dev/null +++ b/src/lib/chat/provider-anthropic.ts @@ -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 { + 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, + })); + + return { + text, + toolCalls, + done: message.stop_reason !== "tool_use", + }; + }, + }; +} \ No newline at end of file diff --git a/src/lib/chat/provider-openai.ts b/src/lib/chat/provider-openai.ts new file mode 100644 index 0000000..a06aaa1 --- /dev/null +++ b/src/lib/chat/provider-openai.ts @@ -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 { + 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(); + + 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 = {}; + try { + input = c.args ? (JSON.parse(c.args) as Record) : {}; + } 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 }; + }, + }; +} \ No newline at end of file