forked from EduCraft/curriculum-project-hub
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 通过。
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 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";
|
||||
}
|
||||
Reference in New Issue
Block a user