mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-02 00:25:24 +00:00
feat: ship the AI assistant as a first-party in-process plugin
Moves src/backend/ai and its routes into plugins/ai, mounted through a small /ai dispatcher instead of its own port. Moves the ai-enabled and ai-private-endpoints settings routes out of user-settings-routes.ts into the plugin too, so it has no leftover core dependents.
This commit is contained in:
1 parent
c58ccbfc3f
commit
504710b065
43 files changed
+568
-262
No files matched your search
@@ -0,0 +1,162 @@
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { providerFetch } from "./http.js";
|
||||
import type {
|
||||
ChatChunk,
|
||||
ChatRequest,
|
||||
ProviderAdapter,
|
||||
ProviderConfig,
|
||||
} from "./types.js";
|
||||
import { AiProviderError } from "./types.js";
|
||||
|
||||
/**
|
||||
* Model ids offered in the picker. Users can type any other id; this is a
|
||||
* convenience list, not a restriction.
|
||||
*/
|
||||
export const ANTHROPIC_MODELS = [
|
||||
"claude-opus-5",
|
||||
"claude-sonnet-5",
|
||||
"claude-haiku-4-5",
|
||||
];
|
||||
|
||||
function createClient(config: ProviderConfig): Anthropic {
|
||||
if (!config.apiKey) {
|
||||
throw new AiProviderError("This provider needs an API key");
|
||||
}
|
||||
return new Anthropic({
|
||||
apiKey: config.apiKey,
|
||||
...(config.baseUrl?.trim() ? { baseURL: config.baseUrl.trim() } : {}),
|
||||
// Routes the SDK's HTTP through the shared egress guard.
|
||||
fetch: providerFetch as unknown as typeof fetch,
|
||||
});
|
||||
}
|
||||
|
||||
function toAnthropicMessages(request: ChatRequest): Anthropic.MessageParam[] {
|
||||
const messages: Anthropic.MessageParam[] = [];
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === "tool") {
|
||||
messages.push({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: message.toolCallId ?? "",
|
||||
content: message.content,
|
||||
},
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "assistant" && message.toolCalls?.length) {
|
||||
const content: Anthropic.ContentBlockParam[] = [];
|
||||
if (message.content)
|
||||
content.push({ type: "text", text: message.content });
|
||||
for (const call of message.toolCalls) {
|
||||
content.push({
|
||||
type: "tool_use",
|
||||
id: call.id,
|
||||
name: call.name,
|
||||
input: call.arguments,
|
||||
});
|
||||
}
|
||||
messages.push({ role: "assistant", content });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role === "system") continue;
|
||||
messages.push({ role: message.role, content: message.content });
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK throws its own typed errors rather than going through assertOk, so
|
||||
* they are translated here to match what every other provider reports.
|
||||
*/
|
||||
function translateSdkError(error: unknown): never {
|
||||
const status =
|
||||
typeof (error as { status?: unknown })?.status === "number"
|
||||
? (error as { status: number }).status
|
||||
: undefined;
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (status === 429) {
|
||||
throw new AiProviderError(
|
||||
`Anthropic rate limit reached. Wait a moment and try again, or check your plan's quota. (${detail})`,
|
||||
429,
|
||||
);
|
||||
}
|
||||
if (status === 401 || status === 403) {
|
||||
throw new AiProviderError(
|
||||
`Anthropic rejected the API key. Check that it is correct and still active. (${detail})`,
|
||||
status,
|
||||
);
|
||||
}
|
||||
throw new AiProviderError(
|
||||
status ? `Anthropic request failed (${status}): ${detail}` : detail,
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
export const anthropicAdapter: ProviderAdapter = {
|
||||
async *streamChat(
|
||||
config: ProviderConfig,
|
||||
request: ChatRequest,
|
||||
): AsyncIterable<ChatChunk> {
|
||||
const client = createClient(config);
|
||||
|
||||
const stream = client.messages.stream({
|
||||
model: request.model,
|
||||
max_tokens: 16000,
|
||||
system: request.system,
|
||||
messages: toAnthropicMessages(request),
|
||||
thinking: { type: "adaptive" },
|
||||
...(request.tools.length
|
||||
? {
|
||||
tools: request.tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
input_schema: tool.parameters as Anthropic.Tool.InputSchema,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
...(request.signal ? { signal: request.signal } : {}),
|
||||
});
|
||||
|
||||
let final: Anthropic.Message;
|
||||
try {
|
||||
for await (const event of stream) {
|
||||
if (
|
||||
event.type === "content_block_delta" &&
|
||||
event.delta.type === "text_delta"
|
||||
) {
|
||||
yield { type: "text", text: event.delta.text };
|
||||
}
|
||||
}
|
||||
final = await stream.finalMessage();
|
||||
} catch (error) {
|
||||
translateSdkError(error);
|
||||
}
|
||||
|
||||
for (const block of final.content) {
|
||||
if (block.type === "tool_use") {
|
||||
yield {
|
||||
type: "tool_call",
|
||||
call: {
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
arguments: (block.input ?? {}) as Record<string, unknown>,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
yield { type: "done", stopReason: final.stop_reason ?? undefined };
|
||||
},
|
||||
|
||||
async listModels(): Promise<string[]> {
|
||||
return [...ANTHROPIC_MODELS];
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user