/** * 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; } export class OpenRouterProvider implements AgentProvider { readonly id: string; private readonly client: OpenAI; constructor(opts: OpenRouterOptions) { this.id = opts.id ?? "openrouter"; const params: ConstructorParameters[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 { 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 = { 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 { return p.type === "text"; } function isToolCall(p: MessagePart): p is Extract { return p.type === "tool_call"; } function isToolResult(p: MessagePart): p is Extract { return p.type === "tool_result"; }