Skip to content
Merged
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
21 changes: 16 additions & 5 deletions src/codex/catalog/antigravity-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,28 @@ import { captureAntigravityModelObservation, recordAntigravityAccountRoster } fr
import type { OcxProviderConfig } from "../../types";
import { materializeCapturedHeaders, type CapturedModelsRequest } from "./gather-capture";

/** True while the active account still holds the exact usable credential discovery sent. */
export function antigravityActiveAccountCurrent(
name: string, accountId: string, accessToken: string | undefined, generation: string | undefined,
): boolean {
const row = getAccountCredentialWithStatus(name, accountId);
return !!row && !row.paused && !row.needsReauth && row.credential.expires > Date.now()
&& row.credential.access === accessToken && credentialGeneration(row.credential) === generation;
}

/** A sibling contributes only while the exact snapshot sent is still usable. */
export async function discoverAntigravitySiblings(
name: string, provider: OcxProviderConfig, activeAccountId: string,
request: CapturedModelsRequest, maxBytes: number, maxModels: number,
root: string,
root: string, authorityCurrent: () => boolean,
): Promise<() => AntigravityAvailableModel[]> {
if (root !== getConfigDir()) return () => [];
if (root !== getConfigDir() || !authorityCurrent()) return () => [];
const results: Array<{
snapshot: OAuthAccessSnapshot; models: AntigravityAvailableModel[];
observation: ReturnType<typeof captureAntigravityModelObservation>;
}> = [];
const current = (snapshot: OAuthAccessSnapshot): boolean => {
if (getConfigDir() !== root) return false;
if (getConfigDir() !== root || !authorityCurrent()) return false;
const row = getAccountCredentialWithStatus(name, snapshot.accountId);
return !!row && !row.paused && !row.needsReauth && row.credential.expires > Date.now()
&& credentialGeneration(row.credential) === snapshot.generation
Expand All @@ -30,8 +39,10 @@ export async function discoverAntigravitySiblings(
for (const sibling of getAccountSet(name)?.accounts ?? []) {
if (sibling.id === activeAccountId || sibling.paused || sibling.needsReauth) continue;
try {
if (getConfigDir() !== root) break;
const snapshot = await getValidAccessSnapshotForAccount(name, sibling.id, { requireUsableAccount: true });
if (getConfigDir() !== root || !authorityCurrent()) break;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const snapshot = await getValidAccessSnapshotForAccount(name, sibling.id, {
requireUsableAccount: true, authorityCurrent: () => getConfigDir() === root && authorityCurrent(),
});
const project = provider.project ?? snapshot.projectId;
if (!project || !current(snapshot)) continue;
const observation = captureAntigravityModelObservation();
Expand Down
14 changes: 6 additions & 8 deletions src/codex/catalog/provider-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ import type { FastPolicyAuthority } from "../../providers/fastwire";
import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../../providers/registry";
import { parseAntigravityAvailableModels, registerAntigravityDiscoveredWireModels } from "../../providers/antigravity-models";
import { captureAntigravityModelObservation, recordAntigravityAccountRoster } from "../../providers/antigravity-account-roster";
import { discoverAntigravitySiblings } from "./antigravity-discovery";
import { antigravityActiveAccountCurrent, discoverAntigravitySiblings } from "./antigravity-discovery";
import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../../providers/context-cap";
import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget";
import { effectiveModelAliases } from "../../providers/default-aliases";
Expand Down Expand Up @@ -794,16 +794,14 @@ export async function fetchProviderModelsWithAuth(
}
if (antigravity) {
if (getConfigDir() !== metadataConfigDir) return observed(withConfiguredRetention(configured), "degraded");
// A needsReauth/replacement of the active account stops every later sibling refresh and send.
const activeCurrent = () => isCurrentCacheGeneration() && (!auth.oauthAccountId
|| antigravityActiveAccountCurrent(name, auth.oauthAccountId, apiKey, auth.oauthGeneration));
const admitSiblings = resolveAuth.kind === "refreshing" && name === "google-antigravity"
&& prov.authMode === "oauth" && auth.oauthAccountId
? await discoverAntigravitySiblings(name, prov, auth.oauthAccountId, request, discovery.maxResponseBytes, discovery.maxModels, metadataConfigDir)
? await discoverAntigravitySiblings(name, prov, auth.oauthAccountId, request, discovery.maxResponseBytes, discovery.maxModels, metadataConfigDir, activeCurrent)
: () => [];
if (!isCurrentCacheGeneration() || getConfigDir() !== metadataConfigDir) return observed(withConfiguredRetention(configured), "degraded");
const activeAccount = auth.oauthAccountId ? getAccountCredentialWithStatus(name, auth.oauthAccountId) : null;
if (auth.oauthAccountId && (!activeAccount || activeAccount.paused || activeAccount.needsReauth
|| activeAccount.credential.expires <= Date.now() || activeAccount.credential.access !== apiKey
|| credentialGeneration(activeAccount.credential) !== auth.oauthGeneration))
return observed(withConfiguredRetention(configured), "degraded");
if (!activeCurrent() || getConfigDir() !== metadataConfigDir) return observed(withConfiguredRetention(configured), "degraded");
const byId = new Map(antigravity.map(model => [model.id, model]));
for (const model of admitSiblings()) if (!byId.has(model.id)) byId.set(model.id, model);
const merged = [...byId.values()];
Expand Down
110 changes: 36 additions & 74 deletions src/oauth/index.ts

Large diffs are not rendered by default.

67 changes: 67 additions & 0 deletions src/oauth/oauth-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { errorDestinationText, providerErrorDestinationLabel } from "../lib/error-destination";

export class UnsupportedOAuthProviderError extends Error {
constructor(provider: string) {
super(`Unsupported OAuth provider in config: ${errorDestinationText(providerErrorDestinationLabel(provider))}`);
this.name = "UnsupportedOAuthProviderError";
}
}

export class OAuthLoginRequiredError extends Error {
readonly provider: string;

constructor(provider: string) {
super(`Not logged in to ${errorDestinationText(providerErrorDestinationLabel(provider))}. Run: ocx login ${errorDestinationText(providerErrorDestinationLabel(provider))}`);
this.name = "OAuthLoginRequiredError";
this.provider = provider;
}
}

/** A usable-account caller found needsReauth under the refresh lock; unrestricted joiners retry alone. */
export class OAuthAccountUnusableError extends OAuthLoginRequiredError {}

/** An operator-paused account is temporarily unavailable, not an invalid login. */
export class OAuthAccountPausedError extends Error {
constructor() {
super("OAuth account is paused. Resume it in account settings and retry.");
this.name = "OAuthAccountPausedError";
}
}

export class OAuthProviderPublicationError extends Error {
constructor(provider?: string, credentialWritten = true) {
super(provider !== "anthropic2" ? "OAuth credential was saved, but the provider entry was not written. Resolve the account namespace collision, then retry login."
: credentialWritten ? "Anthropic Pool 2 credential was saved as an orphan auth row, but the provider entry was not written. Repair the persisted config or resolve the provider/account namespace collision, then retry login."
: "Anthropic Pool 2 login did not start because the config file is missing or could not be written; no credential was saved. Repair the persisted config, then retry login.");
this.name = "OAuthProviderPublicationError";
}
}

/** A local configuration conflict found before any credential is written; its text is fixed. */
export class OAuthProviderNamespaceCollisionError extends Error {
constructor(message: string) {
super(message);
this.name = "OAuthProviderNamespaceCollisionError";
}
}

export class OAuthReauthIdentityMismatchError extends Error {
constructor() {
super("Signed-in account does not match the selected account. Sign in with the same account.");
this.name = "OAuthReauthIdentityMismatchError";
}
}

export class OAuthReauthIdentityUnverifiedError extends Error {
constructor() {
super("Could not verify signed-in account identity for reauth.");
this.name = "OAuthReauthIdentityUnverifiedError";
}
}

export class OAuthLoginSupersededError extends Error {
constructor() {
super("OAuth login was superseded before credential persistence");
this.name = "OAuthLoginSupersededError";
}
}
2 changes: 1 addition & 1 deletion src/oauth/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1610,7 +1610,7 @@ export async function markAccountNeedsReauth(
}, [provider, accountId]);
}

export async function mergeAccountCredential(provider:string,accountId:string,credential:OAuthCredentials,opts:{expectedGeneration?:string;afterPrePersistRead?:()=>void|Promise<void>;assertOwnership?: (store: AuthStore) => void}={}):Promise<{superseded:false}|{superseded:true;stored:OAuthCredentials}>{const safe=normalizeCredential(credential);if(!safe)throw new Error("Refusing to persist invalid OAuth credential");if(isAnthropicInstanceId(provider))assertAnthropicCredentialSource(provider,safe);return await mutateStore(async store=>{await opts.afterPrePersistRead?.();const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account)throw new Error(`OAuth account disappeared before persist: ${provider}`);if(opts.expectedGeneration!==undefined&&credentialGeneration(account.credential)!==opts.expectedGeneration)return{superseded:true,stored:account.credential};opts.assertOwnership?.(store);account.credential=safe;delete account.refreshAttentionGeneration;if(account.needsReauthReason!=="verify_account"){delete account.needsReauth;delete account.needsReauthReason;}return{superseded:false};},[provider,accountId,safe,opts.expectedGeneration]);}
export async function mergeAccountCredential(provider:string,accountId:string,credential:OAuthCredentials,opts:{expectedGeneration?:string;afterPrePersistRead?:()=>void|Promise<void>;assertOwnership?: (store: AuthStore) => void;keepReauthMarkedAfterDispatch?:boolean}={}):Promise<{superseded:false}|{superseded:true;stored:OAuthCredentials}>{const safe=normalizeCredential(credential);if(!safe)throw new Error("Refusing to persist invalid OAuth credential");if(isAnthropicInstanceId(provider))assertAnthropicCredentialSource(provider,safe);return await mutateStore(async store=>{await opts.afterPrePersistRead?.();const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account)throw new Error(`OAuth account disappeared before persist: ${provider}`);if(opts.expectedGeneration!==undefined&&credentialGeneration(account.credential)!==opts.expectedGeneration)return{superseded:true,stored:account.credential};opts.assertOwnership?.(store);account.credential=safe;delete account.refreshAttentionGeneration;if(account.needsReauthReason!=="verify_account"&&!(opts.keepReauthMarkedAfterDispatch&&account.needsReauth)){delete account.needsReauth;delete account.needsReauthReason;}return{superseded:false};},[provider,accountId,safe,opts.expectedGeneration]);}
// A late refresh failure must not change an operator-paused account's health. Check under
// the mutation lock, not before awaiting it, so a concurrent pause cannot be overwritten.
export async function markAccountNeedsReauthIfGeneration(provider:string,accountId:string,generation:string,writerGeneration=captureConfigGeneration(),reason?:"verify_account",isCurrent?:(store:AuthStore)=>boolean):Promise<boolean>{const key=oauthAccountKey(provider,accountId);if(writerGeneration<lastReconciledGeneration&&!liveOAuthAccountKeys.has(key))return false;return await mutateStore(store=>{const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account?.credential||account.paused||credentialGeneration(account.credential)!==generation)return false;if(writerGeneration<lastReconciledGeneration&&!liveOAuthAccountKeys.has(key))return false;if(isCurrent&&!isCurrent(store))return false;account.needsReauth=true;if(reason==="verify_account")account.needsReauthReason=reason;return true;},[provider,accountId,generation]);}
Expand Down
2 changes: 1 addition & 1 deletion structure/catalog.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ validation-pending. Import alone supplies no entitlement evidence for the model
`src/server/models-capabilities.ts` preserves the legacy `/v1/models` negative reasoning signal for an explicit empty effort ladder when no reasoning, thinking-toggle or effort capability is declared. Explicit capability axes take precedence; a declared thinking toggle or reasoning support remains available with no effort rungs. This compatibility projection does not change Codex ladders, Claude thinking discovery, client exports or request wire policy.
Vision-sidecar attachment metadata is projected after provider discovery/cache reads in `src/codex/catalog/routed-gather.ts`. The gather captures `visionSidecar.enabled !== false` before awaiting discovery, and `src/codex/catalog/gather-capture.ts` includes that effective flag in the flight key. Provider TTL/stale rows exclude synthetic sidecar image input; custom rows and synthesized combo members use the same captured switch. Explicit/native image modalities remain intact, and enabled advertisement retains the existing config-based consumer predicate without probing credential availability. An effective Vision switch change through `src/server/management/config-routes.ts` invokes the catalog-only management funnel after persistence to publish both catalog and cache; incomplete work is exposed as `catalogRefreshPending` without claiming client reload. Coverage: `tests/codex-integration/catalog-vision-sidecar-disabled.test.ts` and `tests/vision/sidecar-settings-vision-catalog.test.ts`.
Custom-provider missing windows use [source-labelled vendor estimates](catalog-vendor-context.md) only when the provider opts in with `inferContextWindow: true`; these remain distinct from asserted routing capacity. Registry context seed capture preserves [configured window precedence](catalog-vendor-context.md): provider-wide limits bound seed defaults, explicit per-model windows win, and smaller observed limits remain authoritative.
Static policy and observed catalog evidence are separate authorities. Antigravity grouping and selection projection follow the [discovered effort-family contract](providers-and-adapters.md#antigravity-effort-families). Refreshing Antigravity discovery unions enabled models from usable accounts through `src/codex/catalog/antigravity-discovery.ts`; final-send and publication checks retain each sibling's config-root and full credential snapshot authority. Each account publishes its own roster; active and sibling discovery capture observation order before sending, so delayed publication cannot erase a later inference denial. Active publication also rechecks the captured credential generation. An observed-only gather does not probe siblings.
Static policy and observed catalog evidence are separate authorities. Antigravity grouping and selection projection follow the [discovered effort-family contract](providers-and-adapters.md#antigravity-effort-families). Refreshing Antigravity discovery unions enabled models from usable accounts through `src/codex/catalog/antigravity-discovery.ts`; credential resolution, final-send, and publication checks retain each sibling's config-root, provider cache-generation, and full credential snapshot authority. That authority also requires the active account to remain usable with the credential discovery sent, so a needsReauth, pause, expiry or replacement of the active account stops later sibling refreshes and sends (`tests/adapters/google/antigravity-routing-carry.test.ts`). A sibling waiting for its OAuth refresh lock rechecks root/cache authority before token dispatch; an already-dispatched rotation still persists under the credential CAS. Because discovery resolves siblings with `requireUsableAccount`, the generic and xAI refresh paths in `src/oauth/index.ts` also recheck `needsReauth` under that lock, and the resolver rechecks it before adopting a refresh result, so a sibling marked for reauthentication while it waited sends neither its refresh token nor a bearer. Callers that do not require a usable account (explicit refresh, login, the token guardian) keep refreshing such an account; one that joined a usable-only refresh retries as its own owner. Each account publishes its own roster; active and sibling discovery capture observation order before sending, so delayed publication cannot erase a later inference denial. Active publication also rechecks the captured credential generation. An observed-only gather does not probe siblings.
`src/providers/resolved-model-policy.ts` resolves and freezes only registry/operator static facts,
hard wire pins, aliases, and explicit false/empty declarations. Discovery responses, generated
metadata, cache freshness, availability, credentials, account state, quota and health never enter
Expand Down
Loading
Loading