diff --git a/mcp/src/aieo/src/provider.ts b/mcp/src/aieo/src/provider.ts index 744ddcaeb..7dd7d5df9 100644 --- a/mcp/src/aieo/src/provider.ts +++ b/mcp/src/aieo/src/provider.ts @@ -284,8 +284,35 @@ function lookupApiKeyForProvider( } } +// OpenRouter issues keys as `sk-or-v1-...`. Anything else — a placeholder like +// "test", a key pasted from another provider — is rejected before OpenRouter +// even looks it up, with the misleading `401 Missing Authentication header` +// (the header is sent; it just isn't a parseable OpenRouter key). Recognizing +// the shape lets us treat such a value as "no key" and fall back to a provider +// we can actually reach, instead of 401ing once per node. +const OPENROUTER_KEY_PREFIX = "sk-or-"; + +/** + * Whether a key can plausibly authenticate against `provider`. + * + * Shape checks apply only to keys we send to the provider directly. Behind an + * LLM gateway the token is the gateway's, not the provider's, so any non-blank + * value is accepted there. + */ +export function isUsableApiKey( + provider: Provider | string, + key?: string | null, +): boolean { + const apiKey = normalizeApiKey(key); + if (!apiKey) return false; + if (provider === "openrouter" && !LLM_GATEWAY_URL) { + return apiKey.startsWith(OPENROUTER_KEY_PREFIX); + } + return true; +} + export function hasApiKeyForProvider(provider: Provider | string): boolean { - return !!lookupApiKeyForProvider(provider); + return isUsableApiKey(provider, lookupApiKeyForProvider(provider)); } export function getApiKeyForProvider(provider: Provider | string): string { @@ -293,9 +320,35 @@ export function getApiKeyForProvider(provider: Provider | string): string { if (!apiKey) { throw new Error(`API key not found for provider: ${provider}`); } + if (!isUsableApiKey(provider, apiKey)) { + throw new Error( + `API key for provider ${provider} is not a valid ${provider} key ` + + `(got "${apiKey.slice(0, 8)}...", expected a key starting with "${OPENROUTER_KEY_PREFIX}"). ` + + `Set a real key or unset the variable to fall back to another provider.`, + ); + } return apiKey; } +/** + * Return `modelName` only if its provider has a usable key, otherwise + * `undefined` so the caller falls back to the env default provider. + * + * For hardcoded default models (descriptions, learnings) that name a provider + * the deployment may not have credentials for. + */ +export function usableModelOrDefault( + modelName?: string, +): string | undefined { + if (!modelName) return undefined; + const provider = getProviderForModel(modelName); + if (hasApiKeyForProvider(provider)) return modelName; + console.log( + `[provider] no usable ${provider} key; ignoring default model ${modelName} and falling back to the env default provider`, + ); + return undefined; +} + export interface GetModelOptions { apiKey?: string; baseUrl?: string; diff --git a/mcp/src/graph/learnings.ts b/mcp/src/graph/learnings.ts index 53d1b56e2..01c973bd2 100644 --- a/mcp/src/graph/learnings.ts +++ b/mcp/src/graph/learnings.ts @@ -2,7 +2,11 @@ import { Request, Response } from "express"; import { db } from "./neo4j.js"; import { vectorizeQuery } from "../vector/index.js"; import { generateObject, jsonSchema } from "ai"; -import { getProviderOptions, resolveLLMConfig } from "../aieo/src/provider.js"; +import { + getProviderOptions, + resolveLLMConfig, + usableModelOrDefault, +} from "../aieo/src/provider.js"; import { addUsage, normalizeUsage, withLegacyUsage } from "../aieo/src/index.js"; // === Learning + Scope routes === @@ -91,7 +95,13 @@ export async function post_relevant_learnings(req: Request, res: Response) { } try { - const llm = resolveLLMConfig({ model: req.body.model || LEARNINGS_MODEL, apiKey: req.body.apiKey, light: true }); + // Fall back to the env default provider when there's no usable openrouter + // key, rather than 401ing against the hardcoded openrouter default. + const llm = resolveLLMConfig({ + model: req.body.model || usableModelOrDefault(LEARNINGS_MODEL), + apiKey: req.body.apiKey, + light: true, + }); const model = llm.model; const providerOptions = getProviderOptions(llm.provider, undefined, llm.modelName); diff --git a/mcp/src/repo/descriptions.ts b/mcp/src/repo/descriptions.ts index 6e132e5aa..b8c4676e5 100644 --- a/mcp/src/repo/descriptions.ts +++ b/mcp/src/repo/descriptions.ts @@ -7,9 +7,10 @@ import { computeSessionCost, emptyUsage, getProviderOptions, - hasApiKeyForProvider, + isUsableApiKey, normalizeApiKey, normalizeUsage, + usableModelOrDefault, resolveLLMConfig, withLegacyUsage, } from "../aieo/src/index.js"; @@ -70,14 +71,15 @@ export const describe_nodes_agent = async (req: Request, res: Response) => { // would both pick the openrouter model below and shadow a working env key, // sending `Authorization: Bearer ` on every node (401 per node). const reqApiKey = normalizeApiKey(req.body.apiKey); - // Only default to the openrouter model when a key for it is available; - // otherwise leave the model unset so resolveLLMConfig picks the env - // default provider (with its light model). + // Only default to the openrouter model when a key that can actually reach + // openrouter is available; otherwise leave the model unset so + // resolveLLMConfig picks the env default provider (with its light model). + // An explicit `model` in the body is always honored. const reqModel = (req.body.model as string | undefined) || - (reqApiKey || hasApiKeyForProvider("openrouter") + (isUsableApiKey("openrouter", reqApiKey) ? DESCRIBE_MODEL - : undefined); + : usableModelOrDefault(DESCRIBE_MODEL)); if (isNaN(cost_limit) || cost_limit <= 0) { res @@ -184,6 +186,10 @@ export const describe_nodes_agent = async (req: Request, res: Response) => { cost: number; }; const results: NodeResult[] = []; + // A rejected key fails identically for every node, so the job would + // otherwise churn through the whole graph and report success with 0 + // descriptions written. Bail on the first auth failure instead. + let authFailure: string | undefined; await queue.addAll( nodes @@ -229,12 +235,23 @@ ${content.slice(0, 2000)}`; usage, cost, }); - } catch (e) { + } catch (e: any) { + const status = e?.statusCode ?? e?.cause?.statusCode; + if (status === 401 || status === 403) { + authFailure = + authFailure || + `${llm.provider} rejected the API key (HTTP ${status}) for model ${llm.modelName || "(default)"}: ${e?.responseBody || e?.message}`; + queue.clear(); + } console.error(`[describe_nodes] Error on node ${name}:`, e); } }), ); + if (authFailure) { + throw new Error(authFailure); + } + // Accumulate costs for (const r of results) { totalCost += r.cost;