diff --git a/src/providers/api-key-resolve.ts b/src/providers/api-key-resolve.ts new file mode 100644 index 00000000000..3b78418f98f --- /dev/null +++ b/src/providers/api-key-resolve.ts @@ -0,0 +1,133 @@ +/** + * Read-path for provider key material: env references, keychain references, or the literal + * value. + * + * Leaf module on purpose: reasoning-metadata.ts imports resolveProviderApiKey() here so a + * learned-refusal identity can hash the same credential the wire sends. key-store.ts needs + * the ../config barrel for the write path (saveConfigPreservingClaudeCode), and importing it + * from reasoning-metadata would close the cycle the reasoning-metadata header warns about. + * resolveEnvValue comes from ../config/proxy-env for the same reason. + * + * `config.json` keeps only a reference (`keychain:` for the active key, + * `keychain:/` for pool entries); the secret lives in the OS credential store + * under one service name. Reads are synchronous on purpose: `routedProviderConfig` and the + * quota/compaction/catalog callers are all sync, and `@napi-rs/keyring` ships a sync `Entry`. + * + * Policy: a reference that cannot be resolved fails closed (no key) and is warned once per + * account; nothing ever rewrites plaintext into config or its backups. + */ +import { createRequire } from "node:module"; +import { resolveEnvValue } from "../config/proxy-env"; +import type { OcxProviderConfig } from "../types"; + +export const KEYCHAIN_REFERENCE_PREFIX = "keychain:"; +export const PROVIDER_KEYCHAIN_SERVICE = "opencodex.provider-api-key.v1"; + +export interface ProviderKeychainEntry { + getPassword(): string | null; + setPassword(password: string): void; + deletePassword(): boolean; +} + +export type ProviderKeychainEntryFactory = (service: string, account: string) => ProviderKeychainEntry; + +const nodeRequire = createRequire(import.meta.url); + +function defaultEntryFactory(service: string, account: string): ProviderKeychainEntry { + const { Entry } = nodeRequire("@napi-rs/keyring") as { Entry: new (s: string, a: string) => ProviderKeychainEntry }; + return new Entry(service, account); +} + +let entryFactory: ProviderKeychainEntryFactory = defaultEntryFactory; +const resolvedCache = new Map(); +const warnedAccounts = new Set(); + +/** Test seam: swap the OS entry for an in-memory one and drop caches. */ +export function setProviderKeychainEntryFactoryForTests(factory: ProviderKeychainEntryFactory | null): void { + entryFactory = factory ?? defaultEntryFactory; + resolvedCache.clear(); + warnedAccounts.clear(); +} + +/** Write-path seam: a store/restore mutated secrets, so cached reads and warnings are stale. */ +export function invalidateResolvedProviderKeyCache(): void { + resolvedCache.clear(); + warnedAccounts.clear(); +} + +export function isKeychainReference(value: string | undefined): value is string { + return typeof value === "string" && value.startsWith(KEYCHAIN_REFERENCE_PREFIX) && value.length > KEYCHAIN_REFERENCE_PREFIX.length; +} + +export function keychainAccount(reference: string): string { + return reference.slice(KEYCHAIN_REFERENCE_PREFIX.length); +} + +/** + * A reference belongs to `name` only when its account is that provider's own active account + * or one of its pool accounts. `storeProviderKeyInKeychain` writes exactly those two shapes, + * so anything else in a provider's config names another provider's secret. + */ +export function keychainReferenceBelongsToProvider(reference: string, name: string): boolean { + const account = keychainAccount(reference); + return account === name || account.startsWith(`${name}/`); +} + +/** Entry for `account` under the provider-key service; read and write paths share the factory. */ +export function providerKeychainEntry(account: string): ProviderKeychainEntry { + return entryFactory(PROVIDER_KEYCHAIN_SERVICE, account); +} + +function readKeychain(account: string): string | undefined { + const cached = resolvedCache.get(account); + if (cached !== undefined) return cached; + try { + const value = providerKeychainEntry(account).getPassword(); + if (typeof value === "string" && value.trim()) { + resolvedCache.set(account, value); + return value; + } + } catch { + // fall through to the single warning below + } + if (!warnedAccounts.has(account)) { + warnedAccounts.add(account); + console.warn(`[opencodex] provider key reference keychain:${account} could not be read from the OS keychain; requests for this provider have no credential until the keychain is available (no plaintext fallback)`); + } + return undefined; +} + +/** + * Single resolver for provider key material: env references, keychain references, or the + * literal value. Every request-time read of `apiKey` goes through here. + */ +export function resolveProviderApiKey(value: string | undefined): string | undefined { + if (!value) return undefined; + if (isKeychainReference(value)) return readKeychain(keychainAccount(value)); + return resolveEnvValue(value); +} + +export type ProviderKeyStoreKind = "keychain" | "env" | "file" | "none"; + +export function providerKeyStoreKind(provider: Pick | undefined): ProviderKeyStoreKind { + const key = provider?.apiKey; + if (!key) return "none"; + if (isKeychainReference(key)) return "keychain"; + if (/^\$\{?\w+\}?$/.test(key)) return "env"; + return "file"; +} + +/** Probe the OS keychain with a throwaway account: write, read back, delete. */ +export function probeProviderKeychain(): { available: true } | { available: false; reason: string } { + const account = `probe-${process.pid}-${Date.now()}`; + try { + const entry = providerKeychainEntry(account); + entry.setPassword("ok"); + const back = entry.getPassword(); + try { entry.deletePassword(); } catch { /* best effort */ } + if (back !== "ok") return { available: false, reason: "keychain read-back did not match" }; + return { available: true }; + } catch (error) { + return { available: false, reason: error instanceof Error ? error.message : "keychain unavailable" }; + } +} diff --git a/src/providers/key-store.ts b/src/providers/key-store.ts index 614fd3372fa..428b018213f 100644 --- a/src/providers/key-store.ts +++ b/src/providers/key-store.ts @@ -1,7 +1,34 @@ -import { createRequire } from "node:module"; import { resolveEnvValue, saveConfigPreservingClaudeCode } from "../config"; import type { OcxConfig, OcxProviderConfig } from "../types"; import type { ProviderRegistryEntry } from "./registry"; +import { + KEYCHAIN_REFERENCE_PREFIX, + invalidateResolvedProviderKeyCache, + isKeychainReference, + keychainAccount, + keychainReferenceBelongsToProvider, + probeProviderKeychain, + providerKeychainEntry, +} from "./api-key-resolve"; + +// The read-path (reference predicates, the OS entry factory, resolveProviderApiKey, the probe +// and the kind classification) lives in ./api-key-resolve -- a leaf module that +// reasoning-metadata can also import without pulling the ../config barrel into its +// cycle-sensitive graph. Re-exported here so existing key-store consumers keep working. +export { + KEYCHAIN_REFERENCE_PREFIX, + PROVIDER_KEYCHAIN_SERVICE, + isKeychainReference, + probeProviderKeychain, + providerKeyStoreKind, + resolveProviderApiKey, + setProviderKeychainEntryFactoryForTests, +} from "./api-key-resolve"; +export type { + ProviderKeychainEntry, + ProviderKeychainEntryFactory, + ProviderKeyStoreKind, +} from "./api-key-resolve"; /** Shared with routing: a key-mode override is effective only while its key resolves. */ export function providerUsesKeyAuthOverride( @@ -27,109 +54,8 @@ export function providerUsesKeyAuthOverride( * (headless service, locked session) refuses rather than half-migrating. */ -export const KEYCHAIN_REFERENCE_PREFIX = "keychain:"; -export const PROVIDER_KEYCHAIN_SERVICE = "opencodex.provider-api-key.v1"; - -export interface ProviderKeychainEntry { - getPassword(): string | null; - setPassword(password: string): void; - deletePassword(): boolean; -} - -export type ProviderKeychainEntryFactory = (service: string, account: string) => ProviderKeychainEntry; - -const nodeRequire = createRequire(import.meta.url); - -function defaultEntryFactory(service: string, account: string): ProviderKeychainEntry { - const { Entry } = nodeRequire("@napi-rs/keyring") as { Entry: new (s: string, a: string) => ProviderKeychainEntry }; - return new Entry(service, account); -} - -let entryFactory: ProviderKeychainEntryFactory = defaultEntryFactory; -const resolvedCache = new Map(); -const warnedAccounts = new Set(); - -/** Test seam: swap the OS entry for an in-memory one and drop caches. */ -export function setProviderKeychainEntryFactoryForTests(factory: ProviderKeychainEntryFactory | null): void { - entryFactory = factory ?? defaultEntryFactory; - resolvedCache.clear(); - warnedAccounts.clear(); -} - -export function isKeychainReference(value: string | undefined): value is string { - return typeof value === "string" && value.startsWith(KEYCHAIN_REFERENCE_PREFIX) && value.length > KEYCHAIN_REFERENCE_PREFIX.length; -} - -function keychainAccount(reference: string): string { - return reference.slice(KEYCHAIN_REFERENCE_PREFIX.length); -} - -/** - * A reference belongs to `name` only when its account is that provider's own active account - * or one of its pool accounts. `storeProviderKeyInKeychain` writes exactly those two shapes, - * so anything else in a provider's config names another provider's secret. - */ -function keychainReferenceBelongsToProvider(reference: string, name: string): boolean { - const account = keychainAccount(reference); - return account === name || account.startsWith(`${name}/`); -} - -function readKeychain(account: string): string | undefined { - const cached = resolvedCache.get(account); - if (cached !== undefined) return cached; - try { - const value = entryFactory(PROVIDER_KEYCHAIN_SERVICE, account).getPassword(); - if (typeof value === "string" && value.trim()) { - resolvedCache.set(account, value); - return value; - } - } catch { - // fall through to the single warning below - } - if (!warnedAccounts.has(account)) { - warnedAccounts.add(account); - console.warn(`[opencodex] provider key reference keychain:${account} could not be read from the OS keychain; requests for this provider have no credential until the keychain is available (no plaintext fallback)`); - } - return undefined; -} - -/** - * Single resolver for provider key material: env references, keychain references, or the - * literal value. Every request-time read of `apiKey` goes through here. - */ -export function resolveProviderApiKey(value: string | undefined): string | undefined { - if (!value) return undefined; - if (isKeychainReference(value)) return readKeychain(keychainAccount(value)); - return resolveEnvValue(value); -} - -export type ProviderKeyStoreKind = "keychain" | "env" | "file" | "none"; - -export function providerKeyStoreKind(provider: Pick | undefined): ProviderKeyStoreKind { - const key = provider?.apiKey; - if (!key) return "none"; - if (isKeychainReference(key)) return "keychain"; - if (/^\$\{?\w+\}?$/.test(key)) return "env"; - return "file"; -} - -/** Probe the OS keychain with a throwaway account: write, read back, delete. */ -export function probeProviderKeychain(): { available: true } | { available: false; reason: string } { - const account = `probe-${process.pid}-${Date.now()}`; - try { - const entry = entryFactory(PROVIDER_KEYCHAIN_SERVICE, account); - entry.setPassword("ok"); - const back = entry.getPassword(); - try { entry.deletePassword(); } catch { /* best effort */ } - if (back !== "ok") return { available: false, reason: "keychain read-back did not match" }; - return { available: true }; - } catch (error) { - return { available: false, reason: error instanceof Error ? error.message : "keychain unavailable" }; - } -} - function writeVerified(account: string, secret: string): void { - const entry = entryFactory(PROVIDER_KEYCHAIN_SERVICE, account); + const entry = providerKeychainEntry(account); entry.setPassword(secret); if (entry.getPassword() !== secret) throw new Error(`keychain read-back mismatch for ${account}`); } @@ -177,13 +103,12 @@ export function storeProviderKeyInKeychain(config: OcxConfig, name: string): { o } } catch (error) { for (const account of written) { - try { entryFactory(PROVIDER_KEYCHAIN_SERVICE, account).deletePassword(); } catch { /* best effort */ } + try { providerKeychainEntry(account).deletePassword(); } catch { /* best effort */ } } return { ok: false, error: `OS keychain write failed: ${error instanceof Error ? error.message : "unknown"}`, status: 503 }; } for (const apply of planned) apply(); - resolvedCache.clear(); - warnedAccounts.clear(); + invalidateResolvedProviderKeyCache(); saveConfigPreservingClaudeCode(config); return { ok: true, moved: written.length }; } @@ -211,7 +136,7 @@ export function restoreProviderKeyFromKeychain(config: OcxConfig, name: string): const account = keychainAccount(ref); if (resolved.has(account)) continue; let value: string | null = null; - try { value = entryFactory(PROVIDER_KEYCHAIN_SERVICE, account).getPassword(); } catch { value = null; } + try { value = providerKeychainEntry(account).getPassword(); } catch { value = null; } if (!value) return { ok: false, error: `OS keychain has no readable secret for ${ref}; config left unchanged`, status: 503 }; resolved.set(account, value); } @@ -220,10 +145,9 @@ export function restoreProviderKeyFromKeychain(config: OcxConfig, name: string): } if (isKeychainReference(provider.apiKey)) provider.apiKey = resolved.get(keychainAccount(provider.apiKey))!; for (const account of resolved.keys()) { - try { entryFactory(PROVIDER_KEYCHAIN_SERVICE, account).deletePassword(); } catch { /* best effort */ } + try { providerKeychainEntry(account).deletePassword(); } catch { /* best effort */ } } - resolvedCache.clear(); - warnedAccounts.clear(); + invalidateResolvedProviderKeyCache(); saveConfigPreservingClaudeCode(config); return { ok: true, restored: resolved.size }; } diff --git a/src/providers/reasoning-metadata.ts b/src/providers/reasoning-metadata.ts index 25b312eced8..f22ef8363ea 100644 --- a/src/providers/reasoning-metadata.ts +++ b/src/providers/reasoning-metadata.ts @@ -18,6 +18,7 @@ * entitlement gap (muse-spark max needs an active Muse Code subscription) costs one rejected * request instead of failing every turn that selects that rung. */ +import { createHash } from "node:crypto"; import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; // Leaf modules on purpose: this file is imported from reasoning-effort.ts, which combos/types.ts @@ -26,6 +27,7 @@ import { join } from "node:path"; import { atomicWriteFile } from "../config/atomic-write"; import { getConfigDir } from "../config/paths"; import type { OcxProviderConfig } from "../types"; +import { resolveProviderApiKey } from "./api-key-resolve"; const FILENAME = "reasoning-metadata-cache.json"; const SUPPORT_FILENAME = "reasoning-support-cache.json"; @@ -87,7 +89,7 @@ interface MetadataSnapshot { } interface SupportSnapshot { - version: 1; + version: 2; rows: Record; } @@ -159,11 +161,31 @@ function modelLadderValue( return undefined; } -/** Opaque row key: providerKey|modelId|effort. None of the three may contain a pipe. */ -const KEY_SEP = "|"; +/** + * Bind learned capability to the credential that supplied the evidence. A digest keeps the + * credential itself out of the persisted cache while remaining stable across restarts and key + * selection. Providers without key-auth identity may use metadata, but cannot teach the cache. + * + * The hash input is the resolved wire credential, not the configured expression: the catalog + * path carries the raw config string in apiKey (a keychain:/env reference stays unresolved + * there), while the request path carries the resolved secret in apiKey and the configured + * expression in _apiKeyAttempt.reference. The request path hashes apiKey exactly as routed -- + * the reference is only provenance, and re-resolving it at record time could read a credential + * rotated since the request was served. The catalog path resolves the configured expression, + * so both sides still bind learned refusals to the same wire credential, and a rotation behind + * a stable reference starts clean instead of inheriting the previous credential's refusals. + */ +function credentialIdentity(provider: OcxProviderConfig): string | undefined { + const resolved = provider._apiKeyAttempt?.reference !== undefined + ? provider.apiKey + : resolveProviderApiKey(provider.apiKey); + if (typeof resolved !== "string" || resolved.length === 0) return undefined; + return createHash("sha256").update(resolved).digest("hex"); +} -function supportKey(providerKey: string, modelId: string, effort: string): string { - return providerKey + KEY_SEP + modelId + KEY_SEP + effort; +/** JSON encoding avoids delimiter ambiguity in provider, model, and effort identifiers. */ +function supportKey(providerKey: string, credential: string, modelId: string, effort: string): string { + return JSON.stringify([providerKey, credential, modelId, effort]); } function loadSnapshot(): MetadataSnapshot | null { @@ -220,7 +242,9 @@ function loadSupport(): Map { } const rows = new Map(); const parsed = readJsonFile(SUPPORT_FILENAME); - if (parsed && parsed.version === 1 && parsed.rows && typeof parsed.rows === "object") { + // Version 1 rows had no credential identity and are deliberately invalidated: accepting them + // would preserve destination-wide refusals written by a lower-entitlement account. + if (parsed && parsed.version === 2 && parsed.rows && typeof parsed.rows === "object") { for (const [key, row] of Object.entries(parsed.rows)) { if (!row || typeof row.at !== "number") continue; if (nowMs - row.at > SUPPORT_TTL_MS) continue; @@ -273,8 +297,9 @@ export function metadataDeclaresType(provider: OcxProviderConfig, modelId: strin export function isReasoningEffortLearnedUnsupported(provider: OcxProviderConfig, modelId: string, effort: string): boolean { const key = metadataProviderKey(provider); - if (!key) return false; - return loadSupport().has(supportKey(key, modelId, effort)); + const credential = credentialIdentity(provider); + if (!key || !credential) return false; + return loadSupport().has(supportKey(key, credential, modelId, effort)); } /** @@ -291,10 +316,11 @@ export function dropLearnedUnsupportedReasoningEfforts( ): string[] { if (efforts.length === 0) return [...efforts]; const key = metadataProviderKey(provider); - if (!key) return [...efforts]; + const credential = credentialIdentity(provider); + if (!key || !credential) return [...efforts]; const support = loadSupport(); if (support.size === 0) return [...efforts]; - const kept = efforts.filter(effort => !support.has(supportKey(key, modelId, effort))); + const kept = efforts.filter(effort => !support.has(supportKey(key, credential, modelId, effort))); return kept.length === 0 ? [...efforts] : kept; } @@ -334,8 +360,9 @@ export function recordUnsupportedReasoningEffort( evidence?: string, ): boolean { const key = metadataProviderKey(provider); - if (!key || !effort) return false; - const rowKey = supportKey(key, modelId, effort); + const credential = credentialIdentity(provider); + if (!key || !credential || !effort) return false; + const rowKey = supportKey(key, credential, modelId, effort); const rows = loadSupport(); if (rows.has(rowKey)) return false; rows.set(rowKey, Date.now()); @@ -346,15 +373,14 @@ export function recordUnsupportedReasoningEffort( try { const out: SupportSnapshot["rows"] = {}; for (const [rowKey, at] of rows) { - const parts = rowKey.split(KEY_SEP); const evidenceText = supportEvidence.get(rowKey); out[rowKey] = { - effort: parts[2] ?? "", + effort: JSON.parse(rowKey)[3] ?? "", at, ...(evidenceText ? { evidence: evidenceText } : {}), }; } - atomicWriteFile(join(getConfigDir(), SUPPORT_FILENAME), JSON.stringify({ version: 1, rows: out }) + "\n"); + atomicWriteFile(join(getConfigDir(), SUPPORT_FILENAME), JSON.stringify({ version: 2, rows: out }) + "\n"); } catch { // Best-effort persistence only. } @@ -371,11 +397,10 @@ export function flushReasoningSupportCache(): void { const rows = loadSupport(); const out: SupportSnapshot["rows"] = {}; for (const [rowKey, at] of rows) { - const parts = rowKey.split(KEY_SEP); const evidenceText = supportEvidence.get(rowKey); - out[rowKey] = { effort: parts[2] ?? "", at, ...(evidenceText ? { evidence: evidenceText } : {}) }; + out[rowKey] = { effort: JSON.parse(rowKey)[3] ?? "", at, ...(evidenceText ? { evidence: evidenceText } : {}) }; } - atomicWriteFile(join(getConfigDir(), SUPPORT_FILENAME), JSON.stringify({ version: 1, rows: out }) + "\n"); + atomicWriteFile(join(getConfigDir(), SUPPORT_FILENAME), JSON.stringify({ version: 2, rows: out }) + "\n"); } catch { // Best-effort persistence only. } diff --git a/tests/codex-integration/reasoning-metadata.test.ts b/tests/codex-integration/reasoning-metadata.test.ts index 02e8d16c5ed..5c702cd71e1 100644 --- a/tests/codex-integration/reasoning-metadata.test.ts +++ b/tests/codex-integration/reasoning-metadata.test.ts @@ -16,6 +16,7 @@ import type { OcxProviderConfig } from "../../src/types"; const ZEN_GO: OcxProviderConfig = { adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", + apiKey: "test-zen-go-key", } as OcxProviderConfig; const MUSE_SPARK = "muse-spark-1.3-contributor"; @@ -63,8 +64,8 @@ function metadataFileV2(providers: Record, apis: Record): Record { - return { "reasoning-support-cache.json": JSON.stringify({ version: 1, rows }) }; +function supportFile(rows: Record, version = 2): Record { + return { "reasoning-support-cache.json": JSON.stringify({ version, rows }) }; } afterEach(() => { @@ -130,25 +131,136 @@ describe("models.dev reasoning metadata", () => { }); describe("learned rung refusals", () => { - const refused = () => ({ - ["opencode-go|" + DEEPSEEK_FLASH + "|max"]: { effort: "max", at: Date.now() }, - }); - test("drops a refused rung from a metadata-derived ladder", async () => { - const { effort } = await load({ + const { effort, metadata } = await load({ ...metadataFile({ "opencode-go": { [DEEPSEEK_FLASH]: { reasoning: true, options: [{ type: "effort", values: ["low", "high", "max"] }] } } }), - ...supportFile(refused()), }); + expect(metadata.recordUnsupportedReasoningEffort(ZEN_GO, DEEPSEEK_FLASH, "max")).toBe(true); expect(effort.configuredReasoningEfforts(ZEN_GO, DEEPSEEK_FLASH)).toEqual(["low", "high"]); expect(effort.mapReasoningEffort(ZEN_GO, DEEPSEEK_FLASH, "max")).toBe("high"); }); test("drops a refused rung from a ladder pinned in the registry too", async () => { - const { effort } = await load(supportFile(refused())); + const { effort, metadata } = await load(); + expect(metadata.recordUnsupportedReasoningEffort(ZEN_GO, DEEPSEEK_FLASH, "max")).toBe(true); const pinned = { ...ZEN_GO, modelReasoningEfforts: { [DEEPSEEK_FLASH]: ["low", "high", "max"] } } as OcxProviderConfig; expect(effort.configuredReasoningEfforts(pinned, DEEPSEEK_FLASH)).toEqual(["low", "high"]); }); + test("keeps learned refusals isolated between credentials at the same destination", async () => { + const { effort, metadata } = await load(metadataFile({ + "opencode-go": { [DEEPSEEK_FLASH]: { reasoning: true, options: [{ type: "effort", values: ["low", "high", "max"] }] } }, + })); + const lowEntitlement = { ...ZEN_GO, apiKey: "low-entitlement-key" } as OcxProviderConfig; + const highEntitlement = { ...ZEN_GO, apiKey: "high-entitlement-key" } as OcxProviderConfig; + expect(metadata.recordUnsupportedReasoningEffort(lowEntitlement, DEEPSEEK_FLASH, "max")).toBe(true); + expect(effort.configuredReasoningEfforts(lowEntitlement, DEEPSEEK_FLASH)).toEqual(["low", "high"]); + expect(effort.configuredReasoningEfforts(highEntitlement, DEEPSEEK_FLASH)).toEqual(["low", "high", "max"]); + }); + + // The catalog path carries the configured expression in apiKey; the request path carries the + // resolved secret in apiKey and the configured expression in _apiKeyAttempt.reference. Both + // must hash the same wire credential, or a refusal learned at request time never clamps the + // advertised ladder for env/keychain users. + test("a refusal learned under the resolved request key applies to the catalog's env reference", async () => { + const { effort, metadata } = await load(metadataFile({ + "opencode-go": { [DEEPSEEK_FLASH]: { reasoning: true, options: [{ type: "effort", values: ["low", "high", "max"] }] } }, + })); + process.env["OCX_REASONING_METADATA_TEST_KEY"] = "resolved-env-secret"; + try { + const catalogSide = { ...ZEN_GO, apiKey: "${OCX_REASONING_METADATA_TEST_KEY}" } as OcxProviderConfig; + const requestSide = { + ...ZEN_GO, + apiKey: "resolved-env-secret", + _apiKeyAttempt: { reference: "${OCX_REASONING_METADATA_TEST_KEY}" }, + } as OcxProviderConfig; + expect(metadata.recordUnsupportedReasoningEffort(requestSide, DEEPSEEK_FLASH, "max")).toBe(true); + expect(effort.configuredReasoningEfforts(catalogSide, DEEPSEEK_FLASH)).toEqual(["low", "high"]); + } finally { + delete process.env["OCX_REASONING_METADATA_TEST_KEY"]; + } + }); + + test("a refusal learned under the catalog's env reference applies to the resolved request key", async () => { + const { effort, metadata } = await load(metadataFile({ + "opencode-go": { [DEEPSEEK_FLASH]: { reasoning: true, options: [{ type: "effort", values: ["low", "high", "max"] }] } }, + })); + process.env["OCX_REASONING_METADATA_TEST_KEY"] = "resolved-env-secret"; + try { + const catalogSide = { ...ZEN_GO, apiKey: "${OCX_REASONING_METADATA_TEST_KEY}" } as OcxProviderConfig; + const requestSide = { + ...ZEN_GO, + apiKey: "resolved-env-secret", + _apiKeyAttempt: { reference: "${OCX_REASONING_METADATA_TEST_KEY}" }, + } as OcxProviderConfig; + expect(metadata.recordUnsupportedReasoningEffort(catalogSide, DEEPSEEK_FLASH, "max")).toBe(true); + expect(effort.configuredReasoningEfforts(requestSide, DEEPSEEK_FLASH)).toEqual(["low", "high"]); + } finally { + delete process.env["OCX_REASONING_METADATA_TEST_KEY"]; + } + }); + + test("a keychain-referenced key scopes refusals to the resolved secret on both paths", async () => { + const { effort, metadata } = await load(metadataFile({ + "opencode-go": { [DEEPSEEK_FLASH]: { reasoning: true, options: [{ type: "effort", values: ["low", "high", "max"] }] } }, + })); + const keyStore = await import("../../src/providers/api-key-resolve"); + const store = new Map(); + store.set("opencodex.provider-api-key.v1 zen-go", "resolved-keychain-secret"); + keyStore.setProviderKeychainEntryFactoryForTests((service, account) => ({ + getPassword: () => store.get(service + " " + account) ?? null, + setPassword: (password) => { store.set(service + " " + account, password); }, + deletePassword: () => store.delete(service + " " + account), + })); + try { + const catalogSide = { ...ZEN_GO, apiKey: "keychain:zen-go" } as OcxProviderConfig; + const requestSide = { + ...ZEN_GO, + apiKey: "resolved-keychain-secret", + _apiKeyAttempt: { reference: "keychain:zen-go" }, + } as OcxProviderConfig; + expect(metadata.recordUnsupportedReasoningEffort(requestSide, DEEPSEEK_FLASH, "max")).toBe(true); + expect(effort.configuredReasoningEfforts(catalogSide, DEEPSEEK_FLASH)).toEqual(["low", "high"]); + } finally { + keyStore.setProviderKeychainEntryFactoryForTests(null); + } + }); + + // The request path must hash the credential that served the request, not a live re-read of + // the reference: a rotation between routing and refusal recording would otherwise bind the + // learned refusal to the rotated credential and leave the refused one unclamped. + test("a refusal learned at request time stays bound to the serving credential after rotation", async () => { + const { effort, metadata } = await load(metadataFile({ + "opencode-go": { [DEEPSEEK_FLASH]: { reasoning: true, options: [{ type: "effort", values: ["low", "high", "max"] }] } }, + })); + process.env["OCX_REASONING_METADATA_TEST_KEY"] = "serving-secret"; + const requestSide = { + ...ZEN_GO, + apiKey: "serving-secret", + _apiKeyAttempt: { reference: "${OCX_REASONING_METADATA_TEST_KEY}" }, + } as OcxProviderConfig; + // Rotate behind the stable reference after routing but before the refusal is recorded. + process.env["OCX_REASONING_METADATA_TEST_KEY"] = "rotated-secret"; + try { + expect(metadata.recordUnsupportedReasoningEffort(requestSide, DEEPSEEK_FLASH, "max")).toBe(true); + const served = { ...ZEN_GO, apiKey: "serving-secret" } as OcxProviderConfig; + const rotated = { ...ZEN_GO, apiKey: "rotated-secret" } as OcxProviderConfig; + expect(effort.configuredReasoningEfforts(served, DEEPSEEK_FLASH)).toEqual(["low", "high"]); + expect(effort.configuredReasoningEfforts(rotated, DEEPSEEK_FLASH)).toEqual(["low", "high", "max"]); + } finally { + delete process.env["OCX_REASONING_METADATA_TEST_KEY"]; + } + }); + + test("ignores legacy destination-wide support rows", async () => { + const legacyRows = { ["opencode-go|" + DEEPSEEK_FLASH + "|max"]: { effort: "max", at: Date.now() } }; + const { effort } = await load({ + ...metadataFile({ "opencode-go": { [DEEPSEEK_FLASH]: { reasoning: true, options: [{ type: "effort", values: ["low", "high", "max"] }] } } }), + ...supportFile(legacyRows, 1), + }); + expect(effort.configuredReasoningEfforts(ZEN_GO, DEEPSEEK_FLASH)).toEqual(["low", "high", "max"]); + }); + test("records the refusal and plans the next lower published rung once", async () => { const { metadata } = await load({ ...metadataFile({ "opencode-go": { [DEEPSEEK_FLASH]: { reasoning: true, options: [{ type: "effort", values: ["low", "high", "max"] }] } } }),