diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index d9502640531..56920a4af43 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,7 @@ "version": 1, "root": "tests", "explicitShards": ["layout-clients.json"], - "explicit": { "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", + "explicit": { "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "owner-registry-isolation.test.ts": "config", "responses-auto-pause-replay-owner.test.ts": "responses", "responses-account-auto-pause.test.ts": "responses", "oauth-account-auto-pause.test.ts": "oauth", "responses-account-auto-pause-wire-owner.test.ts": "responses", "oauth-upsert-paused-api-key.test.ts": "oauth", "management-account-auto-pause.test.ts": "server", "cli-account-key-pause.test.ts": "cli", "oauth-account-auto-pause-revision.test.ts": "oauth", "config-account-auto-pause-revisions.test.ts": "config", "codex-history-pending-wal.test.ts": "codex-integration", "responses-anthropic-billing-send-budget.test.ts": "responses", "anthropic-billing-ownership.test.ts": "adapters/anthropic", "server-anthropic-billing.test.ts": "server", "server-anthropic-billing-boundaries.test.ts": "server", "router-anthropic-billing-failover.test.ts": "routing", "anthropic-billing-refusal.test.ts": "adapters/anthropic", "messages-request-id-headers.test.ts": "claude-integration", "bounded-file-read.test.ts": "lib", "responses-undeclared-tool-guard-inbound.test.ts": "responses", "explicit-empty-tool-catalog.test.ts": "responses", "explicit-empty-tool-catalog-history.test.ts": "responses", "messages-request-id-endpoint.test.ts": "claude-integration", diff --git a/src/providers/initial-model-selection.ts b/src/providers/initial-model-selection.ts index d00ae9c19f0..01cf3b00507 100644 --- a/src/providers/initial-model-selection.ts +++ b/src/providers/initial-model-selection.ts @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "./registry"; import { routedSlug, slugEquivalenceKey } from "./slug-codec"; import { comboDisabledModelSelectors } from "../combos/types"; -import { providerUsesKeyAuthOverride, resolveProviderApiKey } from "./key-store"; +import { providerUsesKeyAuthOverride } from "./key-store"; export const INITIAL_MODEL_SELECTION_THRESHOLD = 20; type Selection = NonNullable; @@ -31,7 +31,7 @@ function loginConnection(name: string, provider: OcxProviderConfig): boolean { if (entry && providerMatchesRegistryTransport(name, provider)) { if (entry.authKind === "forward") return true; if (entry.authKind === "oauth") { - return !providerUsesKeyAuthOverride(entry, provider, resolveProviderApiKey(provider.apiKey)); + return !providerUsesKeyAuthOverride(entry, provider); } } return provider.authMode === "oauth" || provider.authMode === "forward"; diff --git a/src/providers/key-store.ts b/src/providers/key-store.ts index e714dc9634e..6b3eac3502c 100644 --- a/src/providers/key-store.ts +++ b/src/providers/key-store.ts @@ -32,14 +32,19 @@ export type { ProviderKeyStoreKind, } from "./api-key-resolve"; -/** Shared with routing: a key-mode override is effective only while its key resolves. */ +/** + * Shared with routing: a key-mode override is operator intent, not a credential probe. + * The configured authMode alone decides the lane; a key that cannot be resolved right now + * (paused last key, missing env var, disabled pool) fails closed at the transport and must + * never implicitly reclassify the provider to the registry's OAuth lane, where a stored + * OAuth account would be billed instead of the operator's key. + */ export function providerUsesKeyAuthOverride( entry: Pick, provider: Pick, - resolvedKey: string | undefined, ): boolean { return entry.authKind === "oauth" && entry.allowKeyAuthOverride === true - && provider.authMode === "key" && typeof resolvedKey === "string" && resolvedKey.trim().length > 0; + && provider.authMode === "key"; } /** diff --git a/src/providers/quota-routing-cache.ts b/src/providers/quota-routing-cache.ts index 7c26bfd635e..e52d091405f 100644 --- a/src/providers/quota-routing-cache.ts +++ b/src/providers/quota-routing-cache.ts @@ -29,7 +29,7 @@ export function providerQuotaRoutingBinding( // A key probe must not constrain that later account selection. const entry = getProviderRegistryEntry(name); if (entry && (entry.authKind === "oauth" || entry.authKind === "forward") - && !providerUsesKeyAuthOverride(entry, provider, credential)) return null; + && !providerUsesKeyAuthOverride(entry, provider)) return null; // Static auth headers can replace or combine with the probed API-key header. // Its semantics belong to the adapter, so it is not provider-wide quota evidence. if (Object.keys(provider.headers ?? {}).some(header => diff --git a/src/providers/routed-config.ts b/src/providers/routed-config.ts index 07fde43897e..df6f20cd0cc 100644 --- a/src/providers/routed-config.ts +++ b/src/providers/routed-config.ts @@ -134,7 +134,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider const repairLegacyMimoFreeAuth = providerName === "mimo-free" && staticModelCatalog && (provider.authMode === undefined || provider.authMode === "local"); - const explicitKeyOverride = providerUsesKeyAuthOverride(registryEntry, provider, resolvedApiKey); + const explicitKeyOverride = providerUsesKeyAuthOverride(registryEntry, provider); const canonicalAuthMode = explicitKeyOverride ? "key" : repairLegacyMimoFreeAuth diff --git a/structure/subagents.md b/structure/subagents.md index b965522e299..a1616cfea65 100644 --- a/structure/subagents.md +++ b/structure/subagents.md @@ -235,7 +235,10 @@ catalogs and model candidates withhold those providers' models; the provider its stays active. At 20 or more canonical Models switch rows, initialization appends all corresponding disabled selectors once. Existing registrations and later manual choices are not reinitialized. OAuth/ChatGPT forwarding is exempt using the same -usable-key override predicate as routing. Display aliases do not add switch rows. +key-mode override predicate as routing: a persisted `authMode: "key"` on an +opted-in OAuth preset is honored even while its credential cannot be resolved +(paused last key, missing env reference, disabled pool), so that row registers +as pending rather than as an OAuth login. Display aliases do not add switch rows. `src/providers/initial-model-selection-runtime.ts` commits the decision against a matching registration/inventory snapshot before catalog authority is captured. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 3a82672e452..74532551d49 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -13,7 +13,7 @@ "codex-provider-routing.test.ts": "codex-integration", "claude-picker-trust-windows.test.ts": "claude-integration", "claude-picker-windows.test.ts": "claude-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", - "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", + "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "messages-native-empty-inline.test.ts": "claude-integration", "explicit-empty-tool-catalog.test.ts": "responses", diff --git a/tests/oauth/oauth-upsert-preserves-api-key.test.ts b/tests/oauth/oauth-upsert-preserves-api-key.test.ts index b4c979ab935..44256d17799 100644 --- a/tests/oauth/oauth-upsert-preserves-api-key.test.ts +++ b/tests/oauth/oauth-upsert-preserves-api-key.test.ts @@ -149,7 +149,7 @@ describe("upsertOAuthProvider credential preservation", () => { } }); - test("falls back to OAuth at routing time when the proxy cannot resolve the active key", () => { + test("preserves explicit key mode at routing time when the proxy cannot resolve the active key", () => { const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); config.providers.xai!.apiKey = "${OCX_TEST_XAI_API_KEY_MISSING}"; config.providers.xai!.apiKeyPool = [{ id: "env-key", key: "${OCX_TEST_XAI_API_KEY_MISSING}" }]; @@ -160,7 +160,7 @@ describe("upsertOAuthProvider credential preservation", () => { delete process.env.OCX_TEST_XAI_API_KEY_MISSING; try { const routed = routeModel(config, "xai/grok-4.5").provider; - expect(routed.authMode).toBe("oauth"); + expect(routed.authMode).toBe("key"); expect(routed.apiKey).toBeUndefined(); expect(config.providers.xai!.authMode).toBe("key"); } finally { @@ -189,33 +189,35 @@ describe("upsertOAuthProvider credential preservation", () => { } }); - test("CLI and proxy env visibility can diverge without rewriting stored authMode", () => { + test("CLI and proxy env visibility can diverge without changing explicit key mode", () => { const config = configWithKey("xai", "openai-chat", "https://api.x.ai/v1"); config.providers.xai!.apiKey = "${OCX_TEST_XAI_SPLIT_ENV}"; config.providers.xai!.apiKeyPool = [{ id: "env-key", key: "${OCX_TEST_XAI_SPLIT_ENV}" }]; const previous = process.env.OCX_TEST_XAI_SPLIT_ENV; - // Login CLI sees the secret; upsert still records key intent, not CLI resolution. - process.env.OCX_TEST_XAI_SPLIT_ENV = "cli-only-secret"; - upsertOAuthProvider(config, "xai"); - expect(config.providers.xai!.authMode).toBe("key"); - - // Running proxy lacks the env var: route on OAuth without touching stored mode. - delete process.env.OCX_TEST_XAI_SPLIT_ENV; - const oauthFallback = routeModel(config, "xai/grok-4.5").provider; - expect(oauthFallback.authMode).toBe("oauth"); - expect(oauthFallback.apiKey).toBeUndefined(); - expect(config.providers.xai!.authMode).toBe("key"); + try { + // Login CLI sees the secret; upsert still records key intent, not CLI resolution. + process.env.OCX_TEST_XAI_SPLIT_ENV = "cli-only-secret"; + upsertOAuthProvider(config, "xai"); + expect(config.providers.xai!.authMode).toBe("key"); - // Proxy later gains the env var: key billing resumes without a config rewrite. - process.env.OCX_TEST_XAI_SPLIT_ENV = "proxy-secret"; - const keyRoute = routeModel(config, "xai/grok-4.5").provider; - expect(keyRoute.authMode).toBe("key"); - expect(keyRoute.apiKey).toBe("proxy-secret"); - expect(config.providers.xai!.authMode).toBe("key"); + // A missing proxy env var leaves the explicit key lane without a credential. + delete process.env.OCX_TEST_XAI_SPLIT_ENV; + const unresolvedKeyRoute = routeModel(config, "xai/grok-4.5").provider; + expect(unresolvedKeyRoute.authMode).toBe("key"); + expect(unresolvedKeyRoute.apiKey).toBeUndefined(); + expect(config.providers.xai!.authMode).toBe("key"); - if (previous === undefined) delete process.env.OCX_TEST_XAI_SPLIT_ENV; - else process.env.OCX_TEST_XAI_SPLIT_ENV = previous; + // Proxy later gains the env var: key billing resumes without a config rewrite. + process.env.OCX_TEST_XAI_SPLIT_ENV = "proxy-secret"; + const keyRoute = routeModel(config, "xai/grok-4.5").provider; + expect(keyRoute.authMode).toBe("key"); + expect(keyRoute.apiKey).toBe("proxy-secret"); + expect(config.providers.xai!.authMode).toBe("key"); + } finally { + if (previous === undefined) delete process.env.OCX_TEST_XAI_SPLIT_ENV; + else process.env.OCX_TEST_XAI_SPLIT_ENV = previous; + } }); test("inserts a missing active key into the pool so listing matches routing", () => { diff --git a/tests/providers/explicit-key-pause-oauth-boundary.test.ts b/tests/providers/explicit-key-pause-oauth-boundary.test.ts new file mode 100644 index 00000000000..c1ba18bbfba --- /dev/null +++ b/tests/providers/explicit-key-pause-oauth-boundary.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getDefaultConfig, loadConfig, saveConfig } from "../../src/config"; +import { routedProviderConfig } from "../../src/router"; +import { providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import { setProviderApiKeyPaused } from "../../src/providers/api-key-pause"; +import { captureAccountAutoPauseAttempt } from "../../src/oauth/account-auto-pause"; +import { getAccountSet, saveCredential, setActiveAccount } from "../../src/oauth/store"; +import { openAIChatTransport } from "../../src/adapters/openai-chat/wire"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; + +const KEY_ID = "key-1"; +const KEY_VALUE = "synthetic-key-1"; +const refusal = () => Response.json( + { error: { type: "permission_error", message: "Your subscription has expired." } }, + { status: 403 }, +); + +const oldHome = process.env.OPENCODEX_HOME; +let home: string; +beforeEach(() => { home = mkdtempSync(join(tmpdir(), "ocx-key-pause-oauth-")); process.env.OPENCODEX_HOME = home; }); +afterEach(() => { + if (oldHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = oldHome; + removeTreeWithRetry(home); +}); + +function keyModeProvider(name: "xai" | "github-copilot", keys: Array<{ id: string; key: string }> = [{ id: KEY_ID, key: KEY_VALUE }]): OcxProviderConfig { + const provider = providerConfigSeed(getProviderRegistryEntry(name)!); + provider.authMode = "key"; + provider.apiKey = keys[0]!.key; + provider.apiKeyPool = keys; + return provider; +} + +function configFor(provider: OcxProviderConfig, name: "xai" | "github-copilot"): OcxConfig { + const config = getDefaultConfig(); + config.providers = { [name]: provider }; + config.defaultProvider = name; + return config; +} + +/** A stored OAuth account must exist so the regression cannot pass by having nothing to leak. */ +async function storeOAuthAccount(name: string): Promise { + await saveCredential(name, { + access: "synthetic-access", refresh: "synthetic-refresh", + expires: Date.now() + 3_600_000, accountId: "synthetic-account", source: "oauth", + }); + const set = getAccountSet(name)!; + const id = set.accounts[0]!.id; + await setActiveAccount(name, id); + expect(set.accounts.length).toBe(1); + return id; +} + +describe("explicit key mode with stored OAuth and no usable key", () => { + for (const name of ["xai", "github-copilot"] as const) { + test(name + ": manual last-key pause stays key auth and the transport refuses", async () => { + const provider = keyModeProvider(name); + const config = configFor(provider, name); + saveConfig(config); + await storeOAuthAccount(name); + + expect(setProviderApiKeyPaused(config, name, KEY_ID, true)).toBe(true); + const stored = config.providers[name]!; + expect(stored.apiKey).toBeUndefined(); + expect(stored.apiKeyPool![0]!.disabled).toBe(true); + expect(loadConfig().providers[name]!.apiKeyPool![0]!.disabled).toBe(true); + + // authMode "key" is operator intent: an unavailable credential fails closed and must + // never be reclassified to registry OAuth, which would attach the stored account. + const routed = routedProviderConfig(name, stored); + expect(routed.authMode).toBe("key"); + expect(routed.apiKey).toBeUndefined(); + expect(() => openAIChatTransport(routed)).toThrow(/requires a non-empty credential/); + }); + + test(name + ": automatic last-key pause stays key auth and the transport refuses", async () => { + const provider = keyModeProvider(name); + provider.accountAutoPause = { enabled: true, consecutiveFailures: 1 }; + const config = configFor(provider, name); + saveConfig(config); + await storeOAuthAccount(name); + + const routed = routedProviderConfig(name, config.providers[name]!); + const observe = captureAccountAutoPauseAttempt(name, routed, { config }, + new Headers({ authorization: "Bearer " + routed.apiKey }))!; + await observe(refusal()); + + const stored = config.providers[name]!; + expect(stored.apiKeyPool![0]!.disabled).toBe(true); + expect(stored.apiKey).toBeUndefined(); + const after = routedProviderConfig(name, stored); + expect(after.authMode).toBe("key"); + expect(after.apiKey).toBeUndefined(); + expect(() => openAIChatTransport(after)).toThrow(/requires a non-empty credential/); + }); + + test(name + ": unpausing the key restores key auth and its Bearer credential", async () => { + const provider = keyModeProvider(name); + const config = configFor(provider, name); + saveConfig(config); + await storeOAuthAccount(name); + + expect(setProviderApiKeyPaused(config, name, KEY_ID, true)).toBe(true); + expect(setProviderApiKeyPaused(config, name, KEY_ID, false)).toBe(true); + const routed = routedProviderConfig(name, config.providers[name]!); + expect(routed.authMode).toBe("key"); + expect(routed.apiKey).toBe(KEY_VALUE); + const transport = openAIChatTransport(routed); + expect(transport.hasCredential).toBe(true); + expect(transport.headers.Authorization).toBe("Bearer " + KEY_VALUE); + }); + + test(name + ": an unresolved env key reference stays key mode and fails closed", async () => { + const envName = "OCX_KEY_PAUSE_BOUNDARY_" + name.replace(/-/g, "_").toUpperCase(); + const previous = process.env[envName]; + delete process.env[envName]; + try { + const provider = keyModeProvider(name, [{ id: KEY_ID, key: "$" + "{" + envName + "}" }]); + const config = configFor(provider, name); + saveConfig(config); + await storeOAuthAccount(name); + + const routed = routedProviderConfig(name, config.providers[name]!); + expect(routed.authMode).toBe("key"); + expect(routed.apiKey).toBeUndefined(); + expect(() => openAIChatTransport(routed)).toThrow(/requires a non-empty credential/); + } finally { + if (previous === undefined) delete process.env[envName]; + else process.env[envName] = previous; + } + }); + + test(name + ": pausing one pool key rotates to the next key", async () => { + const provider = keyModeProvider(name, [ + { id: "key-1", key: "synthetic-key-1" }, + { id: "key-2", key: "synthetic-key-2" }, + ]); + const config = configFor(provider, name); + saveConfig(config); + + expect(setProviderApiKeyPaused(config, name, "key-1", true)).toBe(true); + const routed = routedProviderConfig(name, config.providers[name]!); + expect(routed.authMode).toBe("key"); + expect(routed.apiKey).toBe("synthetic-key-2"); + expect(openAIChatTransport(routed).headers.Authorization).toBe("Bearer synthetic-key-2"); + }); + + test(name + ": an explicit oauth authMode still routes through OAuth", () => { + const provider = keyModeProvider(name); + provider.authMode = "oauth"; + const config = configFor(provider, name); + const routed = routedProviderConfig(name, config.providers[name]!); + expect(routed.authMode).toBe("oauth"); + }); + + test(name + ": an unset authMode keeps the registry OAuth lane", () => { + const provider = providerConfigSeed(getProviderRegistryEntry(name)!); + delete provider.authMode; + provider.apiKey = KEY_VALUE; + const config = configFor(provider, name); + const routed = routedProviderConfig(name, config.providers[name]!); + expect(routed.authMode).toBe("oauth"); + }); + } +}); diff --git a/tests/providers/initial-model-selection.test.ts b/tests/providers/initial-model-selection.test.ts index dd9b0469e09..0573d029807 100644 --- a/tests/providers/initial-model-selection.test.ts +++ b/tests/providers/initial-model-selection.test.ts @@ -129,7 +129,7 @@ describe("initial provider model switches", () => { expect(config.providers.xai.initialModelSelection).toBeUndefined(); }); - test("an unresolved mixed-auth key follows the router's OAuth exemption", () => { + test("an unresolved mixed-auth key stays a key connection pending selection", () => { const env = "OCX_INITIAL_SELECTION_KEY_FIXTURE"; const previous = process.env[env]; delete process.env[env]; @@ -138,7 +138,9 @@ describe("initial provider model switches", () => { provider.authMode = "key"; provider.apiKey = `\${${env}}`; initializeProviderModelSelection("xai", provider); - expect(provider.initialModelSelection).toBeUndefined(); + // Explicit key mode is a key connection even while its reference cannot resolve: + // the provider registers as pending instead of being treated as an OAuth login. + expect(provider.initialModelSelection?.status).toBe("pending"); process.env[env] = "fixture-key"; initializeProviderModelSelection("xai", provider); expect(provider.initialModelSelection?.status).toBe("pending"); diff --git a/tests/providers/resolved-model-policy.test.ts b/tests/providers/resolved-model-policy.test.ts index 44d91277bcb..d186f870754 100644 --- a/tests/providers/resolved-model-policy.test.ts +++ b/tests/providers/resolved-model-policy.test.ts @@ -300,7 +300,7 @@ describe("resolved static model policy parity", () => { expect(JSON.stringify(policy)).not.toContain("usable-private-key"); }); - test("captured unresolved key authority falls back to registry OAuth", () => { + test("captured unresolved key authority stays explicit key mode", () => { const entry = PROVIDER_REGISTRY.find(candidate => ( candidate.authKind === "oauth" && candidate.allowKeyAuthOverride === true ))!; @@ -316,7 +316,7 @@ describe("resolved static model policy parity", () => { apiKey: reference, }); const authority = routedProviderConfig(entry.id, configured); - expect(authority.authMode).toBe("oauth"); + expect(authority.authMode).toBe("key"); const policy = resolveModelPolicy({ providerName: entry.id, modelId: entry.defaultModel ?? MODEL, diff --git a/tests/routing/router.test.ts b/tests/routing/router.test.ts index d8b92dfdc72..239461bc01c 100644 --- a/tests/routing/router.test.ts +++ b/tests/routing/router.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { openAIChatTransport } from "../../src/adapters/openai-chat/wire"; import { mapReasoningEffort } from "../../src/reasoning-effort"; import { NoEnabledOpenAiProviderError, routeCompactionModel, routeModel } from "../../src/router"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; @@ -39,7 +40,7 @@ describe("routeModel registry effort defaults", () => { expect(routeModel(cursorKeyAttempt, "cursor/auto").provider.authMode).toBe("oauth"); }); - test("falls back to OAuth routing for allowKeyAuthOverride providers when the active key is unresolved", () => { + test("keeps explicit key auth for allowKeyAuthOverride providers when the active key is unresolved", () => { const envName = "OCX_TEST_XAI_ROUTER_ENV"; const config: OcxConfig = { port: 10100, @@ -57,8 +58,11 @@ describe("routeModel registry effort defaults", () => { delete process.env[envName]; try { const routed = routeModel(config, "xai/grok-4.5").provider; - expect(routed.authMode).toBe("oauth"); + // authMode "key" is operator intent: an unresolved reference fails closed at the + // transport instead of silently reclassifying the provider to registry OAuth. + expect(routed.authMode).toBe("key"); expect(routed.apiKey).toBeUndefined(); + expect(() => openAIChatTransport(routed)).toThrow(/requires a non-empty credential/); expect(config.providers.xai!.authMode).toBe("key"); } finally { if (previous === undefined) delete process.env[envName];