Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 49 additions & 24 deletions src/agent/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -316,7 +341,7 @@ export async function runAgentLoop(
thinking: response.message.content || "",
toolCalls: [],
tokenUsage: response.usage,
costCents: routerResult.costCents,
costCents: 0,
};

// ── Execute Tool Calls ──
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export function createConfig(params: {
apiKey: string;
openaiApiKey?: string;
anthropicApiKey?: string;
openrouterApiKey?: string;
parentAddress?: Address;
treasuryPolicy?: TreasuryPolicy;
}): AutomatonConfig {
Expand All @@ -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:
Expand Down
43 changes: 29 additions & 14 deletions src/conway/inference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -76,6 +77,7 @@ export function createInferenceClient(
const backend = resolveInferenceBackend(model, {
openaiApiKey,
anthropicApiKey,
openrouterApiKey,
});

if (backend === "anthropic") {
Expand All @@ -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,
Expand Down Expand Up @@ -155,6 +165,7 @@ function resolveInferenceBackend(
keys: {
openaiApiKey?: string;
anthropicApiKey?: string;
openrouterApiKey?: string;
},
): InferenceBackend {
// Anthropic models: claude-*
Expand All @@ -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";
}
Expand All @@ -174,18 +189,18 @@ async function chatViaOpenAiCompatible(params: {
body: Record<string, unknown>;
apiUrl: string;
apiKey: string;
backend: "conway" | "openai";
backend: "conway" | "openai" | "openrouter";
httpClient: ResilientHttpClient;
}): Promise<InferenceResponse> {
const usesBearer = params.backend === "openai" || params.backend === "openrouter";
const headers: Record<string, string> = {
"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,
});
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ async function run(): Promise<void> {
maxTokens: config.maxTokensPerTurn,
openaiApiKey: config.openaiApiKey,
anthropicApiKey: config.anthropicApiKey,
openrouterApiKey: config.openrouterApiKey,
});

// Create social client
Expand Down
9 changes: 7 additions & 2 deletions src/inference/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,13 @@ export class InferenceRouter {
): Promise<InferenceResult> {
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: "",
Expand Down
9 changes: 8 additions & 1 deletion src/setup/wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,16 @@ export async function runSetupWizard(): Promise<AutomatonConfig> {
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 {
Expand Down Expand Up @@ -149,6 +155,7 @@ export async function runSetupWizard(): Promise<AutomatonConfig> {
apiKey,
openaiApiKey: openaiApiKey || undefined,
anthropicApiKey: anthropicApiKey || undefined,
openrouterApiKey: openrouterApiKey || undefined,
treasuryPolicy,
});

Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export interface AutomatonConfig {
conwayApiKey: string;
openaiApiKey?: string;
anthropicApiKey?: string;
openrouterApiKey?: string;
inferenceModel: string;
maxTokensPerTurn: number;
heartbeatConfigPath: string;
Expand Down Expand Up @@ -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 {
Expand Down