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
12 changes: 7 additions & 5 deletions src/codex/catalog/antigravity-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ import { materializeCapturedHeaders, type CapturedModelsRequest } from "./gather
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 +30,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;
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
2 changes: 1 addition & 1 deletion src/codex/catalog/provider-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -796,7 +796,7 @@ export async function fetchProviderModelsWithAuth(
if (getConfigDir() !== metadataConfigDir) return observed(withConfiguredRetention(configured), "degraded");
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, isCurrentCacheGeneration)
: () => [];
if (!isCurrentCacheGeneration() || getConfigDir() !== metadataConfigDir) return observed(withConfiguredRetention(configured), "degraded");
const activeAccount = auth.oauthAccountId ? getAccountCredentialWithStatus(name, auth.oauthAccountId) : null;
Expand Down
19 changes: 14 additions & 5 deletions src/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ export class OAuthTokenRefreshStaleError extends Error {
readonly retryable = true;
constructor() { super("OAuth token refresh owner became stale"); this.name = "OAuthTokenRefreshStaleError"; }
}
/** Known pre-dispatch cancellation; independent waiters may acquire a new owner. */
class OAuthDiscoveryRefreshCancelledError extends OAuthTokenRefreshStaleError {}

/** Focused owner-identity tests only. Synthetic owners retain no account data. */
export function seedOAuthTokenRefreshFlightsForTests(rows: Array<{ key: string; startedAt?: number; flightId?: string; dispatched?: boolean }>): {
Expand Down Expand Up @@ -188,7 +190,7 @@ const XAI_PERMANENT_FAILURE_TTL_MS=30_000;
const permanentRefreshFailures=new Map<string,number>();
interface XaiRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; now?:()=>number; afterPrePersistRead?:()=>void|Promise<void>; signal?: AbortSignal }
interface AnthropicRefreshDeps { resolveIdentity?: AnthropicIdentityResolver; intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; now?:()=>number; afterPrePersistRead?:()=>void|Promise<void>; signal?: AbortSignal; flight?: OAuthRefreshFlightEvidence; replacedStaleFlight?: OAuthRefreshFlightEvidence }
interface GenericRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; afterPrePersistRead?:()=>void|Promise<void>; signal?: AbortSignal }
interface GenericRefreshDeps { intentLock?:ReturnType<typeof createOAuthRefreshIntentLock>; afterPrePersistRead?:()=>void|Promise<void>; signal?: AbortSignal; authorityCurrent?: () => boolean }
function verdictKey(p:string,a:string,c:OAuthCredentials){return `${p}\0${a}\0${credentialGeneration(c)}`;}
function cached(p:string,a:string,c:OAuthCredentials,now:()=>number){const k=verdictKey(p,a,c),u=permanentRefreshFailures.get(k);if(u===undefined)return false;if(u<=now()){permanentRefreshFailures.delete(k);return false;}return true;}
export function sweepExpiredXaiPermanentFailureVerdicts(now=Date.now()):number{let removed=0;for(const[key,until]of permanentRefreshFailures){if(until>now)continue;permanentRefreshFailures.delete(key);removed+=1;}return removed;}
Expand Down Expand Up @@ -631,6 +633,8 @@ async function resolveAccessSnapshotForAccount(
rejectedGeneration?: string,
requireUsableAccount = false,
oauthProvider = provider,
authorityCurrent?: () => boolean,
retryCancelledDiscovery = true,
): Promise<OAuthAccessSnapshot> {
const def = OAUTH_PROVIDERS[oauthProvider];
if (!def) throw new UnsupportedOAuthProviderError(oauthProvider);
Expand Down Expand Up @@ -692,7 +696,7 @@ async function resolveAccessSnapshotForAccount(
dispatched: false,
};
const refresh = (async (): Promise<OAuthAccessSnapshot> => {
const accessToken = await refreshAndPersistAccessToken(provider, accountId, def, cred, abort.signal, flight, replacedStaleFlight);
const accessToken = await refreshAndPersistAccessToken(provider, accountId, def, cred, abort.signal, flight, replacedStaleFlight, authorityCurrent);
const persistedRow = getAccountCredentialWithStatus(provider, accountId);
if (!persistedRow) throw new OAuthLoginRequiredError(provider);
if (persistedRow.paused === true) throw new OAuthAccountPausedError();
Expand All @@ -714,7 +718,10 @@ async function resolveAccessSnapshotForAccount(
flight.promise = refresh;
tokenRefreshes.set(key, flight);
return await refresh;
} catch (error) {
if (authorityCurrent || !retryCancelledDiscovery || !(error instanceof OAuthDiscoveryRefreshCancelledError)) throw error;
} finally { waiter.release(); }
return resolveAccessSnapshotForAccount(provider, accountId, rejectedGeneration, requireUsableAccount, oauthProvider, undefined, false);
}

export async function getValidAccessTokenSnapshot(
Expand Down Expand Up @@ -772,9 +779,9 @@ export async function getValidAccessTokenForAccount(provider: string, accountId:
export async function getValidAccessSnapshotForAccount(
provider: string,
accountId: string,
opts: { requireUsableAccount?: boolean } = {},
opts: { requireUsableAccount?: boolean; authorityCurrent?: () => boolean } = {},
): Promise<OAuthAccessSnapshot> {
return resolveAccessSnapshotForAccount(provider, accountId, undefined, opts.requireUsableAccount === true);
return resolveAccessSnapshotForAccount(provider, accountId, undefined, opts.requireUsableAccount === true, provider, opts.authorityCurrent);
}

/** Terminal refresh failures (revoked/rotated-away grants) — retrying cannot succeed. */
Expand Down Expand Up @@ -1039,6 +1046,7 @@ export async function refreshGenericAccountWithLock(
}
const generation = credentialGeneration(stored);
try {
if (deps.authorityCurrent && !deps.authorityCurrent()) throw new OAuthDiscoveryRefreshCancelledError();
const refreshed = await def.refresh(stored.refresh, deps.signal, stored, accountId);
const fresh = merged(refreshed, stored);
const outcome = await mergeAccountCredential(provider, accountId, fresh, {
Expand Down Expand Up @@ -1137,10 +1145,11 @@ async function refreshAndPersistAccessToken(
signal?: AbortSignal,
flight?: OAuthRefreshFlightEvidence,
replacedStaleFlight?: OAuthRefreshFlightEvidence,
authorityCurrent?: () => boolean,
): Promise<string> {
if (provider === "xai") return refreshXaiAccountWithLock(provider, accountId, def, cred, { signal });
if (isAnthropicOAuthInstance(provider)) return refreshAnthropicAccountWithLock(provider, accountId, def, cred, { signal, flight, replacedStaleFlight });
return refreshGenericAccountWithLock(provider, accountId, def, cred, { signal });
return refreshGenericAccountWithLock(provider, accountId, def, cred, { signal, authorityCurrent });
}

/** Resolve discovery credentials only for the caller-owned configured OAuth row. */
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. 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. 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