Files
curriculum-project-hub/hub/src/agent/openrouter-provider.ts
T
hongjr03 3bca137b48 feat(hub): provider-agnostic agent seam 骨架(ADR-0001/0003/0017)
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 通过。
2026-07-06 22:38:04 +08:00

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";
}