@bitkyc08/opencodex 2.36.0-preview.20260829 → 2.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
- package/gui/dist/assets/index-DO8liQVL.js +112 -0
- package/gui/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/AGENTS.md +2 -1
- package/src/adapters/agentrouter.ts +50 -0
- package/src/adapters/anthropic.ts +1 -51
- package/src/adapters/cursor/call-id.ts +76 -8
- package/src/adapters/cursor/checkpoint-store.ts +6 -1
- package/src/adapters/cursor/cursor-errors.ts +44 -0
- package/src/adapters/cursor/native-exec.ts +13 -0
- package/src/adapters/cursor/protobuf-request.ts +651 -29
- package/src/adapters/cursor/tool-result-normalize.ts +3 -3
- package/src/adapters/cursor/transport-retry.ts +5 -1
- package/src/adapters/cursor.ts +15 -1
- package/src/adapters/empty-tool-output-annotation.ts +43 -0
- package/src/adapters/exec-tool-result-normalize.ts +70 -5
- package/src/adapters/google.ts +22 -2
- package/src/adapters/kiro.ts +26 -2
- package/src/adapters/ollama-native-url.ts +111 -0
- package/src/adapters/ollama-native.ts +1131 -0
- package/src/adapters/openai-chat.ts +30 -7
- package/src/adapters/openai-responses.ts +72 -4
- package/src/adapters/registry.ts +7 -0
- package/src/adapters/xai-web-search.ts +58 -0
- package/src/claude/desktop-3p.ts +21 -1
- package/src/claude/desktop-policy.ts +149 -0
- package/src/cli/account.ts +16 -2
- package/src/cli/claude-desktop.ts +13 -3
- package/src/cli/combo.ts +8 -5
- package/src/cli/doctor.ts +77 -11
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +16 -0
- package/src/cli/models.ts +20 -3
- package/src/cli/registry.ts +2 -1
- package/src/cli/status.ts +140 -2
- package/src/cli/storage.ts +10 -1
- package/src/codex/account-runtime-state.ts +39 -5
- package/src/codex/account-store.ts +393 -13
- package/src/codex/account-usability.ts +11 -4
- package/src/codex/app-server-processes.ts +46 -5
- package/src/codex/auth-context.ts +160 -32
- package/src/codex/catalog/bundled.ts +7 -5
- package/src/codex/catalog/metadata.ts +1 -1
- package/src/codex/catalog/parsing.ts +57 -1
- package/src/codex/catalog/provider-fetch.ts +61 -4
- package/src/codex/catalog/sync.ts +4 -3
- package/src/codex/convergence.ts +3 -2
- package/src/codex/data/upstream-models.json +40 -8
- package/src/codex/inject-coordination.ts +111 -14
- package/src/codex/integration-record.ts +12 -2
- package/src/codex/main-account.ts +225 -1
- package/src/codex/model-entitlements.ts +339 -27
- package/src/codex/prompt-layers.ts +346 -7
- package/src/codex/prompt-text-probe.ts +272 -21
- package/src/codex/routing.ts +693 -132
- package/src/codex/runtime.ts +12 -0
- package/src/codex/subagent-model-fallback.ts +62 -24
- package/src/codex/user-identity.ts +33 -25
- package/src/combos/index.ts +1 -0
- package/src/combos/reset-window.ts +46 -0
- package/src/combos/resolve.ts +84 -2
- package/src/combos/types.ts +5 -2
- package/src/config/atomic-write.ts +104 -22
- package/src/config/provider-validation.ts +11 -0
- package/src/config.ts +75 -3
- package/src/generated/compatibility-version.json +207 -131
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/catalog.ts +71 -0
- package/src/grok/effort.ts +83 -0
- package/src/grok/inject.ts +952 -127
- package/src/grok/models.ts +56 -0
- package/src/grok/status.ts +21 -8
- package/src/grok/sync.ts +10 -18
- package/src/images/loop.ts +6 -3
- package/src/integrations/native/ownership-preflight.ts +4 -1
- package/src/lab/fabric/producer-isolate.ts +36 -3
- package/src/lib/destination-policy.ts +93 -7
- package/src/lib/redact.ts +6 -1
- package/src/lib/shadow-call.ts +38 -3
- package/src/lib/test-home-guard.ts +18 -3
- package/src/lib/upstream-retry.ts +43 -6
- package/src/lib/windows-secret-acl.ts +66 -0
- package/src/lib/windows-text.ts +28 -2
- package/src/lib/windows-user-principal.ts +35 -23
- package/src/oauth/account-quota-rank.ts +107 -0
- package/src/oauth/anthropic-routing.ts +125 -30
- package/src/oauth/chatgpt.ts +5 -1
- package/src/oauth/generic-account-failover.ts +114 -7
- package/src/oauth/index.ts +15 -8
- package/src/oauth/store.ts +16 -0
- package/src/providers/account-quota-disk.ts +79 -0
- package/src/providers/command-code-efforts.ts +24 -0
- package/src/providers/derive.ts +6 -0
- package/src/providers/key-failover.ts +33 -1
- package/src/providers/kiro-usage.ts +272 -0
- package/src/providers/ollama-show.ts +311 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota-routing-cache.ts +32 -0
- package/src/providers/quota-types.ts +36 -0
- package/src/providers/quota-wire.ts +102 -0
- package/src/providers/quota.ts +208 -147
- package/src/providers/registry.ts +68 -8
- package/src/providers/slug-codec.ts +12 -4
- package/src/providers/vercel-gateway-routing.ts +108 -0
- package/src/router.ts +22 -12
- package/src/server/auth-cors.ts +26 -0
- package/src/server/catalog-download.ts +73 -0
- package/src/server/chat-native.ts +12 -2
- package/src/server/gui-static.ts +4 -1
- package/src/server/index.ts +132 -9
- package/src/server/management/agent-settings-routes.ts +38 -5
- package/src/server/management/codex-prompt-routes.ts +7 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/management/config-routes.ts +9 -1
- package/src/server/management/context.ts +5 -0
- package/src/server/management/model-routes.ts +16 -6
- package/src/server/management/native-integration-routes.ts +12 -17
- package/src/server/management/oauth-account-routes.ts +13 -0
- package/src/server/management/provider-routes.ts +32 -5
- package/src/server/management/routing-profile-routes.ts +15 -0
- package/src/server/management/shadow-call-validation.ts +29 -0
- package/src/server/management-api.ts +7 -3
- package/src/server/request-log.ts +3 -5
- package/src/server/responses/agent-task-recovery-cache.ts +8 -0
- package/src/server/responses/agent-task-recovery.ts +52 -20
- package/src/server/responses/codex-auth-error.ts +26 -0
- package/src/server/responses/compact.ts +345 -10
- package/src/server/responses/core.ts +736 -108
- package/src/server/responses/empty-completion-guard.ts +16 -0
- package/src/server/responses/fetch-helpers.ts +42 -0
- package/src/server/responses/policy-fallback.ts +11 -6
- package/src/server/responses-undeclared-tool-guard.ts +16 -3
- package/src/server/startup-health-cache.ts +59 -13
- package/src/service-manager-probe.ts +115 -9
- package/src/service.ts +139 -40
- package/src/storage/cleanup.ts +10 -0
- package/src/storage/storage-mutation-coordinator.ts +14 -3
- package/src/tray/windows-tray.ps1 +10 -4
- package/src/tray/windows.ts +30 -2
- package/src/types/config.ts +27 -14
- package/src/types/provider.ts +54 -0
- package/src/types/tools.ts +13 -3
- package/src/types.ts +4 -0
- package/src/usage/summary.ts +421 -177
- package/src/vision/anthropic-describe.ts +3 -3
- package/src/vision/describe.ts +5 -3
- package/src/web-search/anthropic-executor.ts +9 -2
- package/src/web-search/exa-executor.ts +3 -3
- package/src/web-search/executor.ts +8 -3
- package/src/web-search/gemini-executor.ts +3 -3
- package/src/web-search/loop.ts +11 -3
- package/src/web-search/xai-executor.ts +3 -3
- package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
- package/gui/dist/assets/index-DX0RGj3e.js +0 -112
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { getAccountSet } from "./store";
|
|
18
18
|
import { getValidAccessSnapshotForAccount, type OAuthAccessSnapshot } from "./index";
|
|
19
|
+
import { exhaustedCooldownMs, hasHeadroomEvidence, rankAccountsByHeadroom } from "./account-quota-rank";
|
|
19
20
|
import { parseRetryAfterMs } from "../combos/failover";
|
|
20
21
|
import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
|
|
21
22
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
@@ -60,12 +61,29 @@ interface PresenceEntry {
|
|
|
60
61
|
readAt: number;
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
/**
|
|
65
|
+
* Ordered roster plus the active id, for the pre-dispatch preference.
|
|
66
|
+
*
|
|
67
|
+
* Same reasoning as the presence cache: `getAccountSet` reads through `loadAuthStore`,
|
|
68
|
+
* which chmods and re-parses the whole credential file on every call. Selection needs the
|
|
69
|
+
* ORDER and the active id, which the presence count cannot supply, so it gets its own
|
|
70
|
+
* TTL-bounded row. Ids and an active pointer only — never a credential.
|
|
71
|
+
*/
|
|
72
|
+
interface RosterEntry {
|
|
73
|
+
ids: string[];
|
|
74
|
+
activeId: string | null;
|
|
75
|
+
readAt: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
63
78
|
/** Process-local, like the Anthropic pool's: a restart is allowed to forget a cooldown. */
|
|
64
79
|
const health = new Map<string, AccountHealth>();
|
|
65
80
|
|
|
66
81
|
/** Provider -> recent eligible-account count. TTL-bounded; never holds credential material. */
|
|
67
82
|
const presence = new Map<string, PresenceEntry>();
|
|
68
83
|
|
|
84
|
+
/** Provider -> recently read roster. TTL-bounded; never holds credential material. */
|
|
85
|
+
const roster = new Map<string, RosterEntry>();
|
|
86
|
+
|
|
69
87
|
const healthKey = (provider: string, accountId: string) => `${provider}\u0000${accountId}`;
|
|
70
88
|
|
|
71
89
|
function isCooled(provider: string, accountId: string, now: number): boolean {
|
|
@@ -100,6 +118,24 @@ function eligibleAccountCount(providerName: string, now: number): number {
|
|
|
100
118
|
return eligible;
|
|
101
119
|
}
|
|
102
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Roster ids and the active pointer, read at most once per TTL window.
|
|
123
|
+
*
|
|
124
|
+
* `needsReauth` accounts are excluded for the same reason the presence count excludes
|
|
125
|
+
* them: a revoked credential cannot serve the request we are about to send.
|
|
126
|
+
*/
|
|
127
|
+
function cachedRoster(providerName: string, now: number): { ids: string[]; activeId: string | null } {
|
|
128
|
+
const cached = roster.get(providerName);
|
|
129
|
+
if (cached && now >= cached.readAt && now - cached.readAt < PRESENCE_CACHE_TTL_MS) {
|
|
130
|
+
return { ids: cached.ids, activeId: cached.activeId };
|
|
131
|
+
}
|
|
132
|
+
const set = getAccountSet(providerName);
|
|
133
|
+
const ids = set ? set.accounts.filter(a => a.needsReauth !== true).map(a => a.id) : [];
|
|
134
|
+
const activeId = set?.activeAccountId ?? null;
|
|
135
|
+
roster.set(providerName, { ids, activeId, readAt: now });
|
|
136
|
+
return { ids, activeId };
|
|
137
|
+
}
|
|
138
|
+
|
|
103
139
|
/**
|
|
104
140
|
* Presence IS consent (#2568d).
|
|
105
141
|
*
|
|
@@ -167,7 +203,11 @@ export function rotateGenericOAuthAccountOn429(
|
|
|
167
203
|
if (!set || set.accounts.length < 2) return null;
|
|
168
204
|
|
|
169
205
|
const parsed = parseRetryAfterMs(retryAfterHeader, now);
|
|
170
|
-
|
|
206
|
+
// An account whose allowance is provably spent gets a reset-aligned cooldown instead of
|
|
207
|
+
// the default minute: retrying it every 60s until the window rolls over is pure waste.
|
|
208
|
+
// A Retry-After from upstream still wins — it is the server's own instruction.
|
|
209
|
+
const exhausted = parsed === null ? exhaustedCooldownMs(providerName, failedAccountId, now) : null;
|
|
210
|
+
const cooldownMs = exhausted ?? Math.min(parsed ?? DEFAULT_COOLDOWN_MS, MAX_COOLDOWN_MS);
|
|
171
211
|
health.set(healthKey(providerName, failedAccountId), {
|
|
172
212
|
cooldownUntil: now + cooldownMs,
|
|
173
213
|
cooldownSource: parsed ? "retry-after" : "default",
|
|
@@ -179,15 +219,19 @@ export function rotateGenericOAuthAccountOn429(
|
|
|
179
219
|
// A rotation means the roster in use just changed; do not answer the next activation question
|
|
180
220
|
// from a count read before the failure.
|
|
181
221
|
presence.delete(providerName);
|
|
222
|
+
// Same for the selection roster: the next request must not pick from a pre-failure read.
|
|
223
|
+
roster.delete(providerName);
|
|
182
224
|
// Deterministic: start after the failed account so repeated 429s walk the roster instead of
|
|
183
|
-
// hammering whichever id happens to sort first.
|
|
225
|
+
// hammering whichever id happens to sort first. The ring is built BEFORE ranking — ranking
|
|
226
|
+
// the store's own order would change which account a quota-less provider rotates to.
|
|
184
227
|
const order = set.accounts.map(account => account.id);
|
|
185
228
|
const start = order.indexOf(failedAccountId);
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
229
|
+
const ring = start >= 0 ? [...order.slice(start + 1), ...order.slice(0, start)] : order;
|
|
230
|
+
const candidates = ring.filter(id => id !== failedAccountId && eligible.includes(id));
|
|
231
|
+
if (candidates.length === 0) return null;
|
|
232
|
+
// With no quota evidence this returns the ring untouched, so providers without
|
|
233
|
+
// per-account quota keep exactly the traversal they have today.
|
|
234
|
+
return rankAccountsByHeadroom(providerName, candidates)[0] ?? null;
|
|
191
235
|
}
|
|
192
236
|
|
|
193
237
|
/**
|
|
@@ -204,6 +248,61 @@ export async function failoverAccountSnapshot(
|
|
|
204
248
|
return getValidAccessSnapshotForAccount(providerName, accountId);
|
|
205
249
|
}
|
|
206
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Which account should serve the FIRST attempt of a request.
|
|
253
|
+
*
|
|
254
|
+
* Rotation only ever ran after a 429, so a turn still opened on whichever account happened
|
|
255
|
+
* to be active — including one a previous probe already measured as spent. That costs a
|
|
256
|
+
* full upstream round trip and one of three rotations to rediscover what the cache knew.
|
|
257
|
+
*
|
|
258
|
+
* Returns null whenever the ordinary active-account path should be used unchanged: no
|
|
259
|
+
* quorum, rotation disabled, a single account, or no quota evidence to act on. This is a
|
|
260
|
+
* preference, never a gate — a cooled or unmeasured account is still perfectly usable, so
|
|
261
|
+
* an empty answer means "carry on", not "refuse".
|
|
262
|
+
*/
|
|
263
|
+
export function preferredInitialAccount(
|
|
264
|
+
config: OcxConfig,
|
|
265
|
+
providerName: string,
|
|
266
|
+
now = Date.now(),
|
|
267
|
+
): string | null {
|
|
268
|
+
if (!isGenericOAuthFailoverEnabled(config, providerName)) return null;
|
|
269
|
+
// This runs on the initial resolution of EVERY request, and `loadAuthStore` has no
|
|
270
|
+
// cache: each call chmods the config dir, chmods the secret, reads the whole file and
|
|
271
|
+
// normalizes it (store.ts:136-151). So the store is consulted at most ONCE here, behind
|
|
272
|
+
// the same TTL the presence check uses, and never at all for a single-account provider.
|
|
273
|
+
const { ids: order, activeId: active } = cachedRoster(providerName, now);
|
|
274
|
+
if (order.length < 2) return null;
|
|
275
|
+
|
|
276
|
+
// Evidence is required BEFORE eligibility narrows the field. Without this, a provider
|
|
277
|
+
// with no quota data at all could still be redirected: cool the active account with a
|
|
278
|
+
// 429 and the eligible list collapses to one candidate, which any ranking returns
|
|
279
|
+
// unchanged — an answer that looks ranked but was never measured. The no-op guarantee
|
|
280
|
+
// for quota-less providers has to be checked on the full roster.
|
|
281
|
+
if (!hasHeadroomEvidence(providerName, order)) return null;
|
|
282
|
+
|
|
283
|
+
// Cooldowns are respected here, unlike in the presence count: this picks the account to
|
|
284
|
+
// send to right now, and one inside its 429 window is the single candidate we hold
|
|
285
|
+
// positive evidence against.
|
|
286
|
+
const eligible = order.filter(id => !isCooled(providerName, id, now));
|
|
287
|
+
if (eligible.length === 0) return null;
|
|
288
|
+
|
|
289
|
+
// Start the ring at the active account so an unranked outcome reproduces today's choice.
|
|
290
|
+
const start = active ? order.indexOf(active) : -1;
|
|
291
|
+
const ring = start >= 0 ? [...order.slice(start), ...order.slice(0, start)] : order;
|
|
292
|
+
const candidates = ring.filter(id => eligible.includes(id));
|
|
293
|
+
if (candidates.length === 0) return null;
|
|
294
|
+
|
|
295
|
+
const best = rankAccountsByHeadroom(providerName, candidates)[0] ?? null;
|
|
296
|
+
// Nothing to do when the ranking agrees with the account we would have used anyway.
|
|
297
|
+
//
|
|
298
|
+
// The roster may be up to PRESENCE_CACHE_TTL_MS old, so this answer is a PREFERENCE the
|
|
299
|
+
// caller must be able to abandon: it resolves the account with `requireUsableAccount`,
|
|
300
|
+
// which rejects a removed or reauth-flagged account inside the store read it was already
|
|
301
|
+
// performing, and falls back to the active account. Validating here instead would mean a
|
|
302
|
+
// second uncached read of the credential file on every redirected request.
|
|
303
|
+
return best && best !== active ? best : null;
|
|
304
|
+
}
|
|
305
|
+
|
|
207
306
|
/** Earliest remaining cooldown, for a client-facing Retry-After when every account is cooled. */
|
|
208
307
|
export function genericFailoverRetryAfterSeconds(providerName: string, now = Date.now()): number | null {
|
|
209
308
|
const set = getAccountSet(providerName);
|
|
@@ -217,14 +316,22 @@ export function genericFailoverRetryAfterSeconds(providerName: string, now = Dat
|
|
|
217
316
|
return earliest === null ? null : Math.max(1, Math.ceil((earliest - now) / 1000));
|
|
218
317
|
}
|
|
219
318
|
|
|
319
|
+
/** Test seam and manual-recovery hook. */
|
|
320
|
+
export function forgetGenericFailoverRoster(providerName: string): void {
|
|
321
|
+
roster.delete(providerName);
|
|
322
|
+
presence.delete(providerName);
|
|
323
|
+
}
|
|
324
|
+
|
|
220
325
|
/** Test seam and manual-recovery hook. */
|
|
221
326
|
export function clearGenericFailoverHealth(providerName?: string): void {
|
|
222
327
|
if (!providerName) {
|
|
223
328
|
health.clear();
|
|
224
329
|
presence.clear();
|
|
330
|
+
roster.clear();
|
|
225
331
|
return;
|
|
226
332
|
}
|
|
227
333
|
presence.delete(providerName);
|
|
334
|
+
roster.delete(providerName);
|
|
228
335
|
for (const key of [...health.keys()]) {
|
|
229
336
|
if (key.startsWith(`${providerName}\u0000`)) health.delete(key);
|
|
230
337
|
}
|
package/src/oauth/index.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
|
|
|
4
4
|
import { loadConfig, resolveEnvValue, saveConfig } from "../config";
|
|
5
5
|
import { maskEmail } from "../lib/privacy";
|
|
6
6
|
import { KiroTokenRefreshError, environmentKiroRoutingMetadata, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro";
|
|
7
|
-
import { getAccountCredential, getAccountSet, removeAccount, saveAccountCredential, saveCredential, setActiveAccount, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, markOAuthRefreshIntentStaleOwner, clearOAuthRefreshIntent, normalizeAuthStoreBuffer, OAuthMutationBusyError } from "./store";
|
|
7
|
+
import { getAccountCredential, getAccountCredentialWithStatus, getAccountSet, removeAccount, saveAccountCredential, saveCredential, setActiveAccount, getCredential, credentialGeneration, createOAuthRefreshIntentLock, mergeAccountCredential, markAccountNeedsReauthIfGeneration, readOAuthRefreshIntent, writeOAuthRefreshIntent, markOAuthRefreshIntentStaleOwner, clearOAuthRefreshIntent, normalizeAuthStoreBuffer, OAuthMutationBusyError } from "./store";
|
|
8
8
|
import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenRequestError } from "./xai";
|
|
9
9
|
import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic";
|
|
10
10
|
import { loginKimi, refreshKimiToken } from "./kimi";
|
|
@@ -62,10 +62,9 @@ export interface OAuthAccessSnapshot {
|
|
|
62
62
|
/**
|
|
63
63
|
* Allowlisted GitHub Copilot API origin belonging to THIS account.
|
|
64
64
|
*
|
|
65
|
-
* Copilot pins its bearer to an account-scoped regional host
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
* account A's origin (#2568d).
|
|
65
|
+
* Copilot pins its bearer to an account-scoped regional host. Initial routing, 401 refresh, and
|
|
66
|
+
* account failover must resolve transport from this same snapshot; rereading the active account
|
|
67
|
+
* can pair account A's token with account B's origin during a concurrent switch (#2568d).
|
|
69
68
|
*/
|
|
70
69
|
apiBaseUrl?: string;
|
|
71
70
|
}
|
|
@@ -437,11 +436,18 @@ async function resolveAccessSnapshotForAccount(
|
|
|
437
436
|
provider: string,
|
|
438
437
|
accountId: string,
|
|
439
438
|
rejectedGeneration?: string,
|
|
439
|
+
requireUsableAccount = false,
|
|
440
440
|
): Promise<OAuthAccessSnapshot> {
|
|
441
441
|
const def = OAUTH_PROVIDERS[provider];
|
|
442
442
|
if (!def) throw new UnsupportedOAuthProviderError(provider);
|
|
443
|
-
|
|
444
|
-
|
|
443
|
+
// One store read answers both questions. A caller that opts in gets the account REJECTED
|
|
444
|
+
// when it needs reauthentication, which a bare credential read cannot detect: a revoked
|
|
445
|
+
// account keeps a readable credential, so resolution would otherwise succeed and the
|
|
446
|
+
// request would dispatch on an account already known to need a fresh login.
|
|
447
|
+
const row = getAccountCredentialWithStatus(provider, accountId);
|
|
448
|
+
if (!row) throw new OAuthLoginRequiredError(provider);
|
|
449
|
+
if (requireUsableAccount && row.needsReauth) throw new OAuthLoginRequiredError(provider);
|
|
450
|
+
const cred = row.credential;
|
|
445
451
|
const current = accessSnapshot(provider, accountId, cred);
|
|
446
452
|
if (rejectedGeneration !== undefined && current.generation !== rejectedGeneration) return current;
|
|
447
453
|
if (rejectedGeneration === undefined && cred.expires > Date.now() + REFRESH_SKEW_MS) return current;
|
|
@@ -530,8 +536,9 @@ export async function getValidAccessTokenForAccount(provider: string, accountId:
|
|
|
530
536
|
export async function getValidAccessSnapshotForAccount(
|
|
531
537
|
provider: string,
|
|
532
538
|
accountId: string,
|
|
539
|
+
opts: { requireUsableAccount?: boolean } = {},
|
|
533
540
|
): Promise<OAuthAccessSnapshot> {
|
|
534
|
-
return resolveAccessSnapshotForAccount(provider, accountId);
|
|
541
|
+
return resolveAccessSnapshotForAccount(provider, accountId, undefined, opts.requireUsableAccount === true);
|
|
535
542
|
}
|
|
536
543
|
|
|
537
544
|
/** Terminal refresh failures (revoked/rotated-away grants) — retrying cannot succeed. */
|
package/src/oauth/store.ts
CHANGED
|
@@ -641,6 +641,22 @@ export function getAccountCredential(provider: string, accountId: string): OAuth
|
|
|
641
641
|
return loadAuthStore()[provider]?.accounts.find(a => a.id === accountId)?.credential ?? null;
|
|
642
642
|
}
|
|
643
643
|
|
|
644
|
+
/**
|
|
645
|
+
* Credential plus the account's reauth flag, from ONE store read.
|
|
646
|
+
*
|
|
647
|
+
* A caller that checks `needsReauth` separately pays a second `loadAuthStore`, which
|
|
648
|
+
* chmods and re-parses the whole credential file. Returning both together lets an
|
|
649
|
+
* account-scoped resolver reject a revoked account without that extra read.
|
|
650
|
+
*/
|
|
651
|
+
export function getAccountCredentialWithStatus(
|
|
652
|
+
provider: string,
|
|
653
|
+
accountId: string,
|
|
654
|
+
): { credential: OAuthCredentials; needsReauth: boolean } | null {
|
|
655
|
+
const account = loadAuthStore()[provider]?.accounts.find(a => a.id === accountId);
|
|
656
|
+
if (!account?.credential) return null;
|
|
657
|
+
return { credential: account.credential, needsReauth: account.needsReauth === true };
|
|
658
|
+
}
|
|
659
|
+
|
|
644
660
|
/** Persist a refreshed credential for a SPECIFIC account without touching activeAccountId. */
|
|
645
661
|
export async function saveAccountCredential(
|
|
646
662
|
provider: string,
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Last-known per-account provider quota, kept across restarts.
|
|
3
|
+
*
|
|
4
|
+
* The in-memory cache is process-local, so a restart forgets every measurement and the
|
|
5
|
+
* pool opens the next turn with no idea which account has room — exactly the state
|
|
6
|
+
* pre-dispatch selection exists to avoid. Codex solved this for its own pool with a small
|
|
7
|
+
* disk snapshot (`codex/quota.ts`), and this is the same shape for provider accounts.
|
|
8
|
+
*
|
|
9
|
+
* What is written is percentages and reset timestamps. No token, no email, no account
|
|
10
|
+
* label — the account id is the store's own opaque id, which is already what keys the
|
|
11
|
+
* in-memory cache.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { atomicWriteFile, getConfigDir } from "../config";
|
|
16
|
+
import type { ProviderQuota } from "./quota-types";
|
|
17
|
+
|
|
18
|
+
const FILENAME = "provider-account-quota-cache.json";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Older than this and the snapshot is discarded on load.
|
|
22
|
+
*
|
|
23
|
+
* Six hours matches the Codex cache. A stale bar is still better than none for ORDERING —
|
|
24
|
+
* it decides which account to try first, and a wrong guess costs one 429 that rotation
|
|
25
|
+
* already handles — but a day-old reading of a monthly window has drifted far enough that
|
|
26
|
+
* it should not outrank a fresh probe.
|
|
27
|
+
*/
|
|
28
|
+
const DISK_MAX_AGE_MS = 6 * 60 * 60_000;
|
|
29
|
+
const PERSIST_DEBOUNCE_MS = 250;
|
|
30
|
+
|
|
31
|
+
type DiskFile = {
|
|
32
|
+
version: 1;
|
|
33
|
+
/** provider\u0000accountId -> quota, the same key the in-memory cache uses. */
|
|
34
|
+
rows: Record<string, ProviderQuota>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
|
38
|
+
|
|
39
|
+
/** Read the snapshot. Returns an empty map for a missing, corrupt or stale file. */
|
|
40
|
+
export function readPersistedAccountQuotas(now = Date.now()): Map<string, ProviderQuota> {
|
|
41
|
+
const rows = new Map<string, ProviderQuota>();
|
|
42
|
+
try {
|
|
43
|
+
const path = join(getConfigDir(), FILENAME);
|
|
44
|
+
if (!existsSync(path)) return rows;
|
|
45
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as DiskFile;
|
|
46
|
+
if (!parsed || parsed.version !== 1 || !parsed.rows || typeof parsed.rows !== "object") return rows;
|
|
47
|
+
for (const [key, quota] of Object.entries(parsed.rows)) {
|
|
48
|
+
if (!quota || typeof quota !== "object" || typeof quota.updatedAt !== "number") continue;
|
|
49
|
+
if (now - quota.updatedAt > DISK_MAX_AGE_MS) continue;
|
|
50
|
+
rows.set(key, quota);
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// A corrupt cache must never block routing or the dashboard.
|
|
54
|
+
}
|
|
55
|
+
return rows;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Write the snapshot, debounced. Best-effort: a failed write is not an error. */
|
|
59
|
+
export function schedulePersistAccountQuotas(rows: () => Iterable<[string, ProviderQuota]>): void {
|
|
60
|
+
if (persistTimer) clearTimeout(persistTimer);
|
|
61
|
+
persistTimer = setTimeout(() => {
|
|
62
|
+
persistTimer = null;
|
|
63
|
+
try {
|
|
64
|
+
const out: Record<string, ProviderQuota> = {};
|
|
65
|
+
for (const [key, quota] of rows()) out[key] = quota;
|
|
66
|
+
const body: DiskFile = { version: 1, rows: out };
|
|
67
|
+
atomicWriteFile(join(getConfigDir(), FILENAME), `${JSON.stringify(body)}\n`);
|
|
68
|
+
} catch {
|
|
69
|
+
// Best-effort persistence only.
|
|
70
|
+
}
|
|
71
|
+
}, PERSIST_DEBOUNCE_MS);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Test seam: drop any pending write so a suite cannot leak one into the next file. */
|
|
75
|
+
export function cancelPendingAccountQuotaPersist(): void {
|
|
76
|
+
if (!persistTimer) return;
|
|
77
|
+
clearTimeout(persistTimer);
|
|
78
|
+
persistTimer = null;
|
|
79
|
+
}
|
|
@@ -73,6 +73,30 @@ const COMMAND_CODE_MODEL_EFFORTS = {
|
|
|
73
73
|
efforts: ["low", "high", "max"],
|
|
74
74
|
profileUrl: "https://commandcode.ai/models/glm-5-3",
|
|
75
75
|
},
|
|
76
|
+
/*
|
|
77
|
+
* GLM-5.3-Flash (#2883). Reported as advertising NO efforts at all: the live
|
|
78
|
+
* route is `z-ai/glm-5.3-flash`, which shares neither vendor prefix nor model
|
|
79
|
+
* id with `zai-org/GLM-5.3` above, so `modelRecordValue` cannot bridge them
|
|
80
|
+
* (exact / colon-family / case-folded only — by design; a substring match here
|
|
81
|
+
* would merge two genuinely different models across two vendor namespaces).
|
|
82
|
+
*
|
|
83
|
+
* PROVENANCE: unlike the #2647 rows above, this ladder is MEASURED, not
|
|
84
|
+
* reported. commandcode.ai renders the profile client-side, but the delivered
|
|
85
|
+
* HTML ships a serialized React payload whose string table can be read
|
|
86
|
+
* directly: in the 2026-08-29 fetch of /models/glm-5-3-flash (HTTP 200,
|
|
87
|
+
* 228749 bytes) the indices resolve as 224=low, 225=medium, 226=high,
|
|
88
|
+
* 227=xhigh, 569=max, and this model's array is [224,226,569].
|
|
89
|
+
*
|
|
90
|
+
* The index map was cross-validated against every row in this table that the
|
|
91
|
+
* same page carries: deepseek-v4-pro and -flash [226,569], gpt-5.6-luna
|
|
92
|
+
* [224,225,226,227,569], gemini-3.7-flash [224,225,226], GLM-5.2 [226,569],
|
|
93
|
+
* GLM-5.3 [224,226,569] — six for six against the values already committed
|
|
94
|
+
* here. No authenticated upstream generate probe was performed.
|
|
95
|
+
*/
|
|
96
|
+
"z-ai/glm-5.3-flash": {
|
|
97
|
+
efforts: ["low", "high", "max"],
|
|
98
|
+
profileUrl: "https://commandcode.ai/models/glm-5-3-flash",
|
|
99
|
+
},
|
|
76
100
|
// Muse Spark: CLI currently prints "has no adjustable reasoning effort" and
|
|
77
101
|
// blocks --effort locally, but the upstream /alpha/generate endpoint accepts
|
|
78
102
|
// reasoning_effort low..max for meta/muse-spark-1.2-contributor (verified
|
package/src/providers/derive.ts
CHANGED
|
@@ -254,6 +254,9 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
|
|
|
254
254
|
...(entry.requiresAdjacentResponsesToolResults !== undefined
|
|
255
255
|
? { requiresAdjacentResponsesToolResults: entry.requiresAdjacentResponsesToolResults }
|
|
256
256
|
: {}),
|
|
257
|
+
...(entry.annotateEmptyToolOutputs !== undefined
|
|
258
|
+
? { annotateEmptyToolOutputs: entry.annotateEmptyToolOutputs }
|
|
259
|
+
: {}),
|
|
257
260
|
...(entry.autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels: [...entry.autoToolChoiceOnlyModels] } : {}),
|
|
258
261
|
...(entry.preserveReasoningContentModels ? { preserveReasoningContentModels: [...entry.preserveReasoningContentModels] } : {}),
|
|
259
262
|
...(entry.requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels: [...entry.requiresReasoningPlaceholderModels] } : {}),
|
|
@@ -501,6 +504,9 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
|
|
|
501
504
|
if (prov.requiresAdjacentResponsesToolResults === undefined && seed.requiresAdjacentResponsesToolResults !== undefined) {
|
|
502
505
|
prov.requiresAdjacentResponsesToolResults = seed.requiresAdjacentResponsesToolResults;
|
|
503
506
|
}
|
|
507
|
+
if (prov.annotateEmptyToolOutputs === undefined && seed.annotateEmptyToolOutputs !== undefined) {
|
|
508
|
+
prov.annotateEmptyToolOutputs = seed.annotateEmptyToolOutputs;
|
|
509
|
+
}
|
|
504
510
|
// Registry-only metadata (never seeded into saved config): backfill straight from
|
|
505
511
|
// the entry so an explicit user value stays distinguishable from the default.
|
|
506
512
|
if (prov.fastWire === undefined && entry.fastWire !== undefined) {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* Modelled after src/codex/routing.ts cooldown logic but scoped to plain API-key pools.
|
|
10
10
|
*/
|
|
11
11
|
import { saveConfigPreservingClaudeCode } from "../config";
|
|
12
|
-
import type { OcxConfig, OcxProviderConfig, RateLimitRetryPolicy } from "../types";
|
|
12
|
+
import type { OcxConfig, OcxProviderConfig, RateLimitRetryPolicy, TransientRetryPolicy } from "../types";
|
|
13
13
|
import { resolveProviderTransport, type OcxProviderTransport } from "./xai-transport";
|
|
14
14
|
import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
|
|
15
15
|
|
|
@@ -34,6 +34,15 @@ const DEFAULT_RATE_LIMIT_RETRY = {
|
|
|
34
34
|
respectRetryAfter: true,
|
|
35
35
|
} as const satisfies Required<RateLimitRetryPolicy>;
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Default transient-5xx retry used when a provider opts in with a bare
|
|
39
|
+
* `transientRetryOn5xx: {}`. `attempts` is a TOTAL send budget, not extra retries.
|
|
40
|
+
*/
|
|
41
|
+
const DEFAULT_TRANSIENT_RETRY = {
|
|
42
|
+
enabled: true,
|
|
43
|
+
attempts: 3,
|
|
44
|
+
} as const satisfies Required<TransientRetryPolicy>;
|
|
45
|
+
|
|
37
46
|
/** Map<`${providerName}\0${keyId}`, KeyCooldown> */
|
|
38
47
|
const keyCooldowns = new Map<string, KeyCooldown>();
|
|
39
48
|
|
|
@@ -114,6 +123,29 @@ export function rateLimitRetryPolicyFor(
|
|
|
114
123
|
};
|
|
115
124
|
}
|
|
116
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Normalize a provider's `transientRetryOn5xx` policy, or return null when it is absent,
|
|
128
|
+
* explicitly disabled, not key-auth, or not the `openai-chat` adapter.
|
|
129
|
+
*
|
|
130
|
+
* The adapter gate is part of the accepted scope, not incidental: this first version covers
|
|
131
|
+
* key-auth `openai-chat` only, and without an explicit check any generic key-auth adapter
|
|
132
|
+
* could opt in. Auth mode follows the same fail-closed rule as `rateLimitRetryPolicyFor` —
|
|
133
|
+
* explicit `key` or the documented omitted default, never OAuth, forward, local, or an
|
|
134
|
+
* unknown value.
|
|
135
|
+
*/
|
|
136
|
+
export function transientRetryPolicyFor(
|
|
137
|
+
provider: Pick<OcxProviderConfig, "transientRetryOn5xx" | "authMode" | "adapter">,
|
|
138
|
+
): Required<TransientRetryPolicy> | null {
|
|
139
|
+
const policy = provider.transientRetryOn5xx;
|
|
140
|
+
if (!policy || policy.enabled === false) return null;
|
|
141
|
+
if (provider.adapter !== "openai-chat") return null;
|
|
142
|
+
if (provider.authMode !== undefined && provider.authMode !== "key") return null;
|
|
143
|
+
return {
|
|
144
|
+
enabled: policy.enabled ?? DEFAULT_TRANSIENT_RETRY.enabled,
|
|
145
|
+
attempts: policy.attempts ?? DEFAULT_TRANSIENT_RETRY.attempts,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
117
149
|
/**
|
|
118
150
|
* Wait before the next same-target replay: upstream Retry-After (seconds or HTTP-date) when
|
|
119
151
|
* `respectRetryAfter` is on and the header parses, capped at `maxIntervalMs`; otherwise the
|