Skip to content
Closed
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
2 changes: 1 addition & 1 deletion scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/providers/initial-model-selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OcxProviderConfig["initialModelSelection"]>;
Expand Down Expand Up @@ -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";
Expand Down
11 changes: 8 additions & 3 deletions src/providers/key-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProviderRegistryEntry, "authKind" | "allowKeyAuthOverride">,
provider: Pick<OcxProviderConfig, "authMode">,
resolvedKey: string | undefined,
): boolean {
return entry.authKind === "oauth" && entry.allowKeyAuthOverride === true
&& provider.authMode === "key" && typeof resolvedKey === "string" && resolvedKey.trim().length > 0;
&& provider.authMode === "key";
}

/**
Expand Down
2 changes: 1 addition & 1 deletion src/providers/quota-routing-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down
2 changes: 1 addition & 1 deletion src/providers/routed-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion structure/subagents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/test-layout-expected.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
46 changes: 24 additions & 22 deletions tests/oauth/oauth-upsert-preserves-api-key.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}" }];
Expand All @@ -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 {
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading
Loading