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
55 changes: 54 additions & 1 deletion mcp/src/aieo/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,18 +284,71 @@ 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 {
const apiKey = lookupApiKeyForProvider(provider);
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;
Expand Down
14 changes: 12 additions & 2 deletions mcp/src/graph/learnings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ===
Expand Down Expand Up @@ -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);

Expand Down
31 changes: 24 additions & 7 deletions mcp/src/repo/descriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import {
computeSessionCost,
emptyUsage,
getProviderOptions,
hasApiKeyForProvider,
isUsableApiKey,
normalizeApiKey,
normalizeUsage,
usableModelOrDefault,
resolveLLMConfig,
withLegacyUsage,
} from "../aieo/src/index.js";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
Loading