forked from EduCraft/curriculum-project-hub
3bca137b48
hub/ TS 新部件,平级于 spec/。本仓从零 TS 重写(非迁移旧服务),栈: Fastify+Prisma 待补;本提交落 provider-agnostic agent runtime 骨架: - provider.ts: AgentProvider seam,OpenAI-compatible messages+tools+tool_calls 形状(语言通解,非 OpenAI 承诺);@Claude 仅为触发品牌(ADR-0017) - openrouter-provider.ts: openai SDK 指向 OpenRouter 的具体适配器,仅做 Message↔SDK 翻译;agent loop 不 import SDK,换 provider 不改 loop - runner.ts: 自有 agent loop(dispatch tools/循环到 stop/budget),per-run model(ADR-0017 role-based routing,run 带其 model) - tools.ts: 窄工具面 + ADR-0003 McpReadRequest.Authorized 落代码: feishu_read_context 拒绝非本项目绑定 chat 的读取(实测错 chat 抛 UnauthorizedChatRead,对 chat 放行) - models.ts: ModelRegistry seam(role→default 映射策略 OPEN) - server.ts: 接线入口(stub Feishu read,无活凭证即可编译) tsc --noEmit rc=0;ADR-0003 不变式 smoke 通过。
158 lines
4.8 KiB
TypeScript
158 lines
4.8 KiB
TypeScript
/**
|
|
* OpenRouter provider adapter.
|
|
*
|
|
* Points an OpenAI-compatible client at OpenRouter (or any compatible baseURL).
|
|
* A single API key + one baseURL routes to any model id OpenRouter serves; the
|
|
* model is chosen per run (ADR-0017: role-based routing, run carries its model).
|
|
*
|
|
* This adapter only translates between our {@link Message} shape and the `openai`
|
|
* SDK's `ChatCompletionMessageParam` union. The agent loop itself never imports
|
|
* the SDK — swapping providers means implementing {@link AgentProvider}, not
|
|
* rewriting the loop.
|
|
*/
|
|
import OpenAI from "openai";
|
|
import type {
|
|
AgentProvider,
|
|
ChatRequest,
|
|
ChatResponse,
|
|
Message,
|
|
MessagePart,
|
|
ToolSpec,
|
|
} from "./provider.js";
|
|
|
|
export interface OpenRouterOptions {
|
|
readonly apiKey: string;
|
|
/** Defaults to OpenRouter. Override for direct vendor APIs or local gateways. */
|
|
readonly baseURL?: string;
|
|
readonly id?: string;
|
|
/** Extra headers (e.g. `HTTP-Referer`, `X-Title` for OpenRouter rankings). */
|
|
readonly defaultHeaders?: Record<string, string>;
|
|
}
|
|
|
|
export class OpenRouterProvider implements AgentProvider {
|
|
readonly id: string;
|
|
private readonly client: OpenAI;
|
|
|
|
constructor(opts: OpenRouterOptions) {
|
|
this.id = opts.id ?? "openrouter";
|
|
const params: ConstructorParameters<typeof OpenAI>[0] = {
|
|
apiKey: opts.apiKey,
|
|
baseURL: opts.baseURL ?? "https://openrouter.ai/api/v1",
|
|
};
|
|
if (opts.defaultHeaders !== undefined) {
|
|
params.defaultHeaders = opts.defaultHeaders;
|
|
}
|
|
this.client = new OpenAI(params);
|
|
}
|
|
|
|
async chat(req: ChatRequest): Promise<ChatResponse> {
|
|
const completion = (await this.client.chat.completions.create({
|
|
model: req.model,
|
|
messages: req.messages.map(toSDKMessage),
|
|
tools: req.tools.map(toSDKTool),
|
|
})) as OpenAI.Chat.Completions.ChatCompletion;
|
|
|
|
const choice = completion.choices[0];
|
|
if (choice === undefined) {
|
|
throw new Error(`${this.id}: provider returned no choices`);
|
|
}
|
|
const base: Pick<ChatResponse, "finishReason" | "message"> = {
|
|
finishReason: normalizeFinish(choice.finish_reason),
|
|
message: fromSDKMessage(choice.message),
|
|
};
|
|
if (completion.usage !== undefined) {
|
|
return {
|
|
...base,
|
|
usage: {
|
|
inputTokens: completion.usage.prompt_tokens,
|
|
outputTokens: completion.usage.completion_tokens,
|
|
},
|
|
};
|
|
}
|
|
return base;
|
|
}
|
|
}
|
|
|
|
function normalizeFinish(r: string | null): ChatResponse["finishReason"] {
|
|
switch (r) {
|
|
case "stop":
|
|
return "stop";
|
|
case "tool_calls":
|
|
return "tool_calls";
|
|
case "length":
|
|
return "length";
|
|
default:
|
|
return "error";
|
|
}
|
|
}
|
|
|
|
function toSDKMessage(m: Message): OpenAI.Chat.Completions.ChatCompletionMessageParam {
|
|
switch (m.role) {
|
|
case "system":
|
|
return { role: "system", content: joinText(m.parts) };
|
|
case "user":
|
|
return { role: "user", content: joinText(m.parts) };
|
|
case "assistant": {
|
|
const text = joinText(m.parts.filter(isText));
|
|
const calls = m.parts.filter(isToolCall).map((c) => ({
|
|
id: c.id,
|
|
type: "function" as const,
|
|
function: { name: c.name, arguments: c.arguments },
|
|
}));
|
|
if (calls.length > 0) {
|
|
return { role: "assistant", content: text, tool_calls: calls };
|
|
}
|
|
return { role: "assistant", content: text };
|
|
}
|
|
case "tool": {
|
|
const result = m.parts.find(isToolResult);
|
|
if (result === undefined) {
|
|
throw new Error("tool message without a tool_result part");
|
|
}
|
|
return {
|
|
role: "tool",
|
|
tool_call_id: result.tool_call_id,
|
|
content: result.content,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
function fromSDKMessage(m: OpenAI.Chat.Completions.ChatCompletion.Choice["message"]): Message {
|
|
const parts: MessagePart[] = [];
|
|
if (typeof m.content === "string" && m.content.length > 0) {
|
|
parts.push({ type: "text", text: m.content });
|
|
}
|
|
if (m.tool_calls !== undefined) {
|
|
for (const c of m.tool_calls) {
|
|
parts.push({
|
|
type: "tool_call",
|
|
id: c.id,
|
|
name: c.function.name,
|
|
arguments: c.function.arguments,
|
|
});
|
|
}
|
|
}
|
|
return { role: "assistant", parts };
|
|
}
|
|
|
|
function toSDKTool(t: ToolSpec): OpenAI.Chat.Completions.ChatCompletionTool {
|
|
return {
|
|
type: "function",
|
|
function: { name: t.name, description: t.description, parameters: t.parameters as object },
|
|
} as OpenAI.Chat.Completions.ChatCompletionTool;
|
|
}
|
|
|
|
function joinText(parts: readonly MessagePart[]): string {
|
|
return parts.filter(isText).map((p) => p.text).join("");
|
|
}
|
|
function isText(p: MessagePart): p is Extract<MessagePart, { type: "text" }> {
|
|
return p.type === "text";
|
|
}
|
|
function isToolCall(p: MessagePart): p is Extract<MessagePart, { type: "tool_call" }> {
|
|
return p.type === "tool_call";
|
|
}
|
|
function isToolResult(p: MessagePart): p is Extract<MessagePart, { type: "tool_result" }> {
|
|
return p.type === "tool_result";
|
|
}
|