diff --git a/src/model/config/parseModelConfig.ts b/src/model/config/parseModelConfig.ts index 48bb287cf..6a31a963e 100644 --- a/src/model/config/parseModelConfig.ts +++ b/src/model/config/parseModelConfig.ts @@ -104,7 +104,7 @@ function parseProvider(providerId: string, rawProvider: unknown, env?: Credentia id: providerId, protocol, url: rawUrl, - apiKey: resolveProviderApiKey(providerId, provider.apiKey, env), + apiKey: resolveProviderApiKey(providerId, provider.apiKey, catalogProvider?.apiKeyEnvVar, env), timeoutMs: readOptionalPositiveNumber(provider.timeoutMs, "timeoutMs"), headers: readStringRecord(provider.headers, "headers"), extraBody: isRecord(provider.extraBody) ? (provider.extraBody as Record) : undefined, @@ -113,11 +113,19 @@ function parseProvider(providerId: string, rawProvider: unknown, env?: Credentia }; } -function resolveProviderApiKey(providerId: string, value: unknown, env?: CredentialEnv): string { +function resolveProviderApiKey( + providerId: string, + value: unknown, + catalogApiKeyEnvVar: string | undefined, + env?: CredentialEnv, +): string { if (providerId === "ollama" && value === undefined) { return "ollama"; } - return resolveApiKey(value, env); + const effectiveValue = value === undefined ? ( + catalogApiKeyEnvVar ? `\${${catalogApiKeyEnvVar}}` : undefined + ) : value; + return resolveApiKey(effectiveValue, env); } function resolveDefaultProviderUrl( diff --git a/tests/model/parseModelConfig.spec.ts b/tests/model/parseModelConfig.spec.ts new file mode 100644 index 000000000..8906317c9 --- /dev/null +++ b/tests/model/parseModelConfig.spec.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ModelConfigError, parseModelConfig } from "../../src/model/index.js"; + +const anthropicModelId = "claude-sonnet-4-5-20250929"; + +function catalogAnthropicProvider(extra: Record = {}): Record { + return { + providers: { + anthropic: { + ...extra, + models: { + [anthropicModelId]: {}, + }, + }, + }, + }; +} + +function assertMissingApiKey(fn: () => void, messagePattern?: RegExp): void { + assert.throws(fn, (error: unknown) => { + assert.equal(error instanceof ModelConfigError, true); + assert.equal((error as ModelConfigError).code, "missing_api_key"); + if (messagePattern) { + assert.match((error as Error).message, messagePattern); + } + return true; + }); +} + +test("parseModelConfig resolves catalog apiKeyEnvVar when provider apiKey is absent", () => { + const config = parseModelConfig(catalogAnthropicProvider(), { + env: { ANTHROPIC_API_KEY: " sk-test-from-env \n" }, + }); + + assert.equal(config.providers.anthropic.protocol, "anthropic"); + assert.equal(config.providers.anthropic.url, "https://api.anthropic.com"); + assert.equal(config.providers.anthropic.apiKey, "sk-test-from-env"); +}); + +test("parseModelConfig keeps explicit env apiKey resolution ahead of catalog apiKeyEnvVar", () => { + const config = parseModelConfig(catalogAnthropicProvider({ + apiKey: "${CUSTOM_ANTHROPIC_KEY}", + }), { + env: { + ANTHROPIC_API_KEY: "catalog-key", + CUSTOM_ANTHROPIC_KEY: "explicit-key", + }, + }); + + assert.equal(config.providers.anthropic.apiKey, "explicit-key"); +}); + +test("parseModelConfig reports missing catalog env apiKey as missing_api_key", () => { + assertMissingApiKey(() => { + parseModelConfig(catalogAnthropicProvider(), { env: {} }); + }, /ANTHROPIC_API_KEY/); +}); + +test("parseModelConfig still requires apiKey for non-catalog providers", () => { + assertMissingApiKey(() => { + parseModelConfig({ + providers: { + custom: { + protocol: "openai", + url: "https://example.test/v1", + models: { "custom-model": {} }, + }, + }, + }, { env: { OPENAI_API_KEY: "sk-test-from-env" } }); + }); +});