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:
parent
89bde4c7ff
commit
5cc0d91f2d
4 changed files with 283 additions and 0 deletions
30
src/lib/chat/index.test.ts
Normal file
30
src/lib/chat/index.test.ts
Normal 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
23
src/lib/chat/index.ts
Normal 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();
|
||||
}
|
||||
107
src/lib/chat/provider-anthropic.ts
Normal file
107
src/lib/chat/provider-anthropic.ts
Normal 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",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
123
src/lib/chat/provider-openai.ts
Normal file
123
src/lib/chat/provider-openai.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue