diff --git a/src/agent/loop.ts b/src/agent/loop.ts index f9940ed9..65b1feac 100644 --- a/src/agent/loop.ts +++ b/src/agent/loop.ts @@ -280,33 +280,58 @@ export async function runAgentLoop( // ── Inference Call (via router when available) ── const survivalTier = getSurvivalTier(financial.creditsCents); - log(config, `[THINK] Routing inference (tier: ${survivalTier}, model: ${inference.getDefaultModel()})...`); - const inferenceTools = toolsToInferenceFormat(tools); - const routerResult = await inferenceRouter.route( - { - messages: messages, - taskType: "agent_turn", - tier: survivalTier, - sessionId: db.getKV("session_id") || "default", - turnId: ulid(), - tools: inferenceTools, - }, - (msgs, opts) => inference.chat(msgs, { ...opts, tools: inferenceTools }), - ); - // Build a compatible response for the rest of the loop - const response = { - message: { content: routerResult.content, role: "assistant" as const }, - toolCalls: routerResult.toolCalls as any[] | undefined, - usage: { - promptTokens: routerResult.inputTokens, - completionTokens: routerResult.outputTokens, - totalTokens: routerResult.inputTokens + routerResult.outputTokens, - }, - finishReason: routerResult.finishReason, + // ── Inference Call ── + // When openrouterApiKey is set in automaton.json, bypass the InferenceRouter + // (which only knows about baseline OpenAI models) and call inference.chat() + // directly. inference.chat() already has the routing logic: if the model ID + // contains "/" and openrouterApiKey is set, it sends the request to OpenRouter. + let response: { + message: { content: string; role: "assistant" }; + toolCalls: any[] | undefined; + usage: { promptTokens: number; completionTokens: number; totalTokens: number }; + finishReason: string; }; + if (config.openrouterApiKey) { + log(config, `[THINK] Sending to OpenRouter (model: ${config.inferenceModel})...`); + const directResp = await inference.chat(messages, { + model: config.inferenceModel, + maxTokens: config.maxTokensPerTurn, + tools: inferenceTools, + }); + response = { + message: { content: directResp.message?.content || "", role: "assistant" }, + toolCalls: directResp.toolCalls, + usage: directResp.usage, + finishReason: directResp.finishReason || "stop", + }; + } else { + log(config, `[THINK] Routing inference (tier: ${survivalTier}, model: ${inference.getDefaultModel()})...`); + const routerResult = await inferenceRouter.route( + { + messages: messages, + taskType: "agent_turn", + tier: survivalTier, + sessionId: db.getKV("session_id") || "default", + turnId: ulid(), + tools: inferenceTools, + }, + (msgs, opts) => inference.chat(msgs, { ...opts, tools: inferenceTools }), + ); + response = { + message: { content: routerResult.content, role: "assistant" }, + toolCalls: routerResult.toolCalls as any[] | undefined, + usage: { + promptTokens: routerResult.inputTokens, + completionTokens: routerResult.outputTokens, + totalTokens: routerResult.inputTokens + routerResult.outputTokens, + }, + finishReason: routerResult.finishReason, + }; + } + const turn: AgentTurn = { id: ulid(), timestamp: new Date().toISOString(), @@ -316,7 +341,7 @@ export async function runAgentLoop( thinking: response.message.content || "", toolCalls: [], tokenUsage: response.usage, - costCents: routerResult.costCents, + costCents: 0, }; // ── Execute Tool Calls ── diff --git a/src/config.ts b/src/config.ts index 0083e697..c525ad2b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -120,6 +120,7 @@ export function createConfig(params: { apiKey: string; openaiApiKey?: string; anthropicApiKey?: string; + openrouterApiKey?: string; parentAddress?: Address; treasuryPolicy?: TreasuryPolicy; }): AutomatonConfig { @@ -135,6 +136,7 @@ export function createConfig(params: { conwayApiKey: params.apiKey, openaiApiKey: params.openaiApiKey, anthropicApiKey: params.anthropicApiKey, + openrouterApiKey: params.openrouterApiKey, inferenceModel: DEFAULT_CONFIG.inferenceModel || "gpt-5.2", maxTokensPerTurn: DEFAULT_CONFIG.maxTokensPerTurn || 4096, heartbeatConfigPath: diff --git a/src/conway/inference.ts b/src/conway/inference.ts index 39fcf1af..db4fe134 100644 --- a/src/conway/inference.ts +++ b/src/conway/inference.ts @@ -26,14 +26,15 @@ interface InferenceClientOptions { lowComputeModel?: string; openaiApiKey?: string; anthropicApiKey?: string; + openrouterApiKey?: string; } -type InferenceBackend = "conway" | "openai" | "anthropic"; +type InferenceBackend = "conway" | "openai" | "anthropic" | "openrouter"; export function createInferenceClient( options: InferenceClientOptions, ): InferenceClient { - const { apiUrl, apiKey, openaiApiKey, anthropicApiKey } = options; + const { apiUrl, apiKey, openaiApiKey, anthropicApiKey, openrouterApiKey } = options; const httpClient = new ResilientHttpClient({ baseTimeout: INFERENCE_TIMEOUT_MS, retryableStatuses: [429, 500, 502, 503, 504], @@ -76,6 +77,7 @@ export function createInferenceClient( const backend = resolveInferenceBackend(model, { openaiApiKey, anthropicApiKey, + openrouterApiKey, }); if (backend === "anthropic") { @@ -90,10 +92,18 @@ export function createInferenceClient( }); } - const openAiLikeApiUrl = - backend === "openai" ? "https://api.openai.com" : apiUrl; - const openAiLikeApiKey = - backend === "openai" ? (openaiApiKey as string) : apiKey; + let openAiLikeApiUrl: string; + let openAiLikeApiKey: string; + if (backend === "openai") { + openAiLikeApiUrl = "https://api.openai.com"; + openAiLikeApiKey = openaiApiKey as string; + } else if (backend === "openrouter") { + openAiLikeApiUrl = "https://openrouter.ai/api"; + openAiLikeApiKey = openrouterApiKey as string; + } else { + openAiLikeApiUrl = apiUrl; + openAiLikeApiKey = apiKey; + } return chatViaOpenAiCompatible({ model, @@ -155,6 +165,7 @@ function resolveInferenceBackend( keys: { openaiApiKey?: string; anthropicApiKey?: string; + openrouterApiKey?: string; }, ): InferenceBackend { // Anthropic models: claude-* @@ -165,6 +176,10 @@ function resolveInferenceBackend( if (keys.openaiApiKey && /^(gpt|o[1-9]|chatgpt)/i.test(model)) { return "openai"; } + // OpenRouter model IDs use "provider/model" format (e.g. "openai/gpt-4o") + if (keys.openrouterApiKey && model.includes("/")) { + return "openrouter"; + } // Default: Conway proxy (handles all models including unknown ones) return "conway"; } @@ -174,18 +189,18 @@ async function chatViaOpenAiCompatible(params: { body: Record; apiUrl: string; apiKey: string; - backend: "conway" | "openai"; + backend: "conway" | "openai" | "openrouter"; httpClient: ResilientHttpClient; }): Promise { + const usesBearer = params.backend === "openai" || params.backend === "openrouter"; + const headers: Record = { + "Content-Type": "application/json", + Authorization: usesBearer ? `Bearer ${params.apiKey}` : params.apiKey, + }; + const resp = await params.httpClient.request(`${params.apiUrl}/v1/chat/completions`, { method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: - params.backend === "openai" - ? `Bearer ${params.apiKey}` - : params.apiKey, - }, + headers, body: JSON.stringify(params.body), timeout: INFERENCE_TIMEOUT_MS, }); diff --git a/src/index.ts b/src/index.ts index 91d3c39a..a0ccd20b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -213,6 +213,7 @@ async function run(): Promise { maxTokens: config.maxTokensPerTurn, openaiApiKey: config.openaiApiKey, anthropicApiKey: config.anthropicApiKey, + openrouterApiKey: config.openrouterApiKey, }); // Create social client diff --git a/src/inference/router.ts b/src/inference/router.ts index 842b1870..4cc822fd 100644 --- a/src/inference/router.ts +++ b/src/inference/router.ts @@ -45,8 +45,13 @@ export class InferenceRouter { ): Promise { const { messages, taskType, tier, sessionId, turnId, tools } = request; - // 1. Select model from routing matrix - const model = this.selectModel(tier, taskType); + // 1. Select model: use overrideModel if set and available in registry, else routing matrix + let model = request.overrideModel + ? (this.registry.get(request.overrideModel) ?? null) + : null; + if (!model || !model.enabled) { + model = this.selectModel(tier, taskType); + } if (!model) { return { content: "", diff --git a/src/setup/wizard.ts b/src/setup/wizard.ts index 1dab2e99..f0c1e217 100644 --- a/src/setup/wizard.ts +++ b/src/setup/wizard.ts @@ -91,10 +91,16 @@ export async function runSetupWizard(): Promise { console.log(chalk.yellow(" Warning: Anthropic keys usually start with sk-ant-. Saving anyway.")); } - if (openaiApiKey || anthropicApiKey) { + const openrouterApiKey = await promptOptional("OpenRouter API key (sk-or-..., optional)"); + if (openrouterApiKey && !openrouterApiKey.startsWith("sk-or-")) { + console.log(chalk.yellow(" Warning: OpenRouter keys usually start with sk-or-. Saving anyway.")); + } + + if (openaiApiKey || anthropicApiKey || openrouterApiKey) { const providers = [ openaiApiKey ? "OpenAI" : null, anthropicApiKey ? "Anthropic" : null, + openrouterApiKey ? "OpenRouter" : null, ].filter(Boolean).join(", "); console.log(chalk.green(` Provider keys saved: ${providers}\n`)); } else { @@ -149,6 +155,7 @@ export async function runSetupWizard(): Promise { apiKey, openaiApiKey: openaiApiKey || undefined, anthropicApiKey: anthropicApiKey || undefined, + openrouterApiKey: openrouterApiKey || undefined, treasuryPolicy, }); diff --git a/src/types.ts b/src/types.ts index 07670154..4fd74b29 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,6 +42,7 @@ export interface AutomatonConfig { conwayApiKey: string; openaiApiKey?: string; anthropicApiKey?: string; + openrouterApiKey?: string; inferenceModel: string; maxTokensPerTurn: number; heartbeatConfigPath: string; @@ -1144,6 +1145,7 @@ export interface InferenceRequest { turnId?: string; maxTokens?: number; // override tools?: unknown[]; + overrideModel?: string; // bypass routing matrix and use this model directly } export interface InferenceResult {