@bitkyc08/opencodex 2.48.0 → 2.50.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/AGENTS_INSTALL.md +9 -1
- package/README.md +11 -5
- package/SPONSORS.md +1 -1
- package/assets/sponsors/orcarouter.png +0 -0
- package/assets/sponsors/packycode.png +0 -0
- package/gui/dist/assets/index-BoBRSehJ.css +1 -0
- package/gui/dist/assets/index-C39tnjXO.js +115 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/packycode.svg +19 -0
- package/gui/dist/provider-icons/qoder.svg +5 -0
- package/package.json +5 -3
- package/src/adapters/anthropic.ts +31 -16
- package/src/adapters/codebuddy/adapter.ts +85 -0
- package/src/adapters/codebuddy/profiles.ts +52 -0
- package/src/adapters/coding-agent/profile.ts +100 -0
- package/src/adapters/coding-agent/protocol.ts +463 -0
- package/src/adapters/coding-agent/turn.ts +353 -0
- package/src/adapters/google.ts +15 -11
- package/src/adapters/mimo-free.ts +3 -0
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses.ts +18 -11
- package/src/adapters/qoder/adapter.ts +70 -0
- package/src/adapters/qoder/live-models.ts +89 -0
- package/src/adapters/qoder/profiles.ts +36 -0
- package/src/adapters/registry.ts +12 -0
- package/src/adapters/responses-tool-schema.ts +113 -8
- package/src/claude/inbound.ts +17 -5
- package/src/cli/account-api.ts +18 -3
- package/src/cli/account-auth.ts +8 -1
- package/src/cli/account-extended.ts +2 -1
- package/src/cli/account.ts +1 -0
- package/src/cli/capabilities.ts +15 -1
- package/src/cli/dispatch.ts +2 -0
- package/src/cli/doctor.ts +40 -0
- package/src/cli/effort.ts +24 -8
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +29 -2
- package/src/cli/models-runtime.ts +8 -3
- package/src/cli/observe.ts +13 -3
- package/src/cli/provider-runtime.ts +2 -1
- package/src/cli/registry.ts +2 -2
- package/src/cli/system-command.ts +10 -3
- package/src/cli/usage-report.ts +9 -5
- package/src/clients/config-export/zcode.ts +24 -0
- package/src/codex/account-lifecycle.ts +35 -2
- package/src/codex/account-runtime-state.ts +6 -1
- package/src/codex/account-store.ts +72 -9
- package/src/codex/account-usability.ts +3 -2
- package/src/codex/auth-api.ts +113 -26
- package/src/codex/auth-collision.ts +12 -2
- package/src/codex/auth-context.ts +96 -7
- package/src/codex/catalog/parsing.ts +23 -0
- package/src/codex/catalog/provider-fetch.ts +144 -11
- package/src/codex/catalog/sync.ts +14 -0
- package/src/codex/inject.ts +128 -30
- package/src/codex/internal/catalog-writer.ts +3 -0
- package/src/codex/journal.ts +61 -12
- package/src/codex/model-cache.ts +11 -4
- package/src/codex/native-profile-startup.ts +72 -5
- package/src/codex/native-profile-store.ts +2 -2
- package/src/codex/ocx-compaction-history.ts +226 -0
- package/src/codex/project-config-warnings.ts +3 -1
- package/src/codex/quota-auto-refresh.ts +6 -1
- package/src/codex/quota.ts +71 -15
- package/src/codex/reserve-availability.ts +21 -5
- package/src/codex/runtime.ts +45 -1
- package/src/codex/sync.ts +5 -0
- package/src/combos/index.ts +2 -0
- package/src/combos/resolve.ts +52 -0
- package/src/config.ts +59 -0
- package/src/generated/compatibility-version.json +178 -114
- package/src/images/loop.ts +1 -0
- package/src/images/xai-video-client.ts +2 -0
- package/src/integrations/registry.ts +1 -0
- package/src/lib/errors.ts +8 -0
- package/src/lib/privacy.ts +25 -0
- package/src/lib/process-control.ts +52 -8
- package/src/lib/upstream-retry.ts +1 -0
- package/src/oauth/chatgpt.ts +83 -0
- package/src/oauth/health.ts +47 -12
- package/src/oauth/index.ts +46 -8
- package/src/oauth/token-guardian.ts +32 -6
- package/src/oauth/xai.ts +151 -8
- package/src/providers/api-key-selection-capture.ts +10 -0
- package/src/providers/api-key-selection.ts +2 -7
- package/src/providers/caller-authorization.ts +36 -0
- package/src/providers/codebuddy-models.ts +184 -0
- package/src/providers/derive.ts +5 -0
- package/src/providers/free-directory.ts +26 -2
- package/src/providers/google-ai-studio-model-discovery.ts +74 -0
- package/src/providers/openai-sidecar.ts +35 -11
- package/src/providers/opencode-zen-rate-limit.ts +75 -0
- package/src/providers/qoder-models.ts +25 -0
- package/src/providers/quota.ts +15 -0
- package/src/providers/registry.ts +140 -1
- package/src/responses/compaction.ts +4 -0
- package/src/responses/task-input.ts +21 -1
- package/src/router.ts +1 -1
- package/src/server/auth-cors.ts +6 -0
- package/src/server/chat-completions.ts +30 -13
- package/src/server/chat-native.ts +10 -1
- package/src/server/claude-messages.ts +17 -7
- package/src/server/images.ts +3 -2
- package/src/server/index.ts +25 -2
- package/src/server/management/account-selection-stream.ts +13 -4
- package/src/server/management/config-routes.ts +24 -5
- package/src/server/management/logs-usage-routes.ts +5 -1
- package/src/server/management/model-rows.ts +16 -1
- package/src/server/management/native-integration-routes.ts +2 -1
- package/src/server/management/oauth-account-routes.ts +6 -2
- package/src/server/management/provider-routes.ts +33 -2
- package/src/server/management/request-history-routes.ts +4 -2
- package/src/server/management/route-registry.ts +5 -4
- package/src/server/management/shared.ts +66 -3
- package/src/server/management-api.ts +15 -1
- package/src/server/port-reclaim.ts +11 -26
- package/src/server/request-decompress.ts +91 -3
- package/src/server/request-log.ts +16 -0
- package/src/server/responses/codex-ws-wire.ts +1 -1
- package/src/server/responses/collaboration.ts +4 -9
- package/src/server/responses/compact.ts +8 -2
- package/src/server/responses/context-overflow.ts +11 -0
- package/src/server/responses/core.ts +285 -57
- package/src/server/responses/fetch-helpers.ts +18 -7
- package/src/server/responses/policy-fallback.ts +18 -2
- package/src/server/search.ts +2 -2
- package/src/service.ts +128 -9
- package/src/storage/cleanup.ts +77 -45
- package/src/types/accounts.ts +18 -0
- package/src/types/config.ts +43 -1
- package/src/types/provider.ts +56 -0
- package/src/types.ts +4 -0
- package/src/usage/log.ts +24 -0
- package/src/vision/anthropic-describe.ts +1 -0
- package/src/web-search/anthropic-executor.ts +1 -0
- package/src/web-search/loop.ts +1 -0
- package/src/web-search/ollama-executor.ts +127 -0
- package/src/web-search/passthrough-bridge.ts +761 -0
- package/src/web-search/progress-stream.ts +4 -0
- package/gui/dist/assets/index-B5r7LNHN.js +0 -115
- package/gui/dist/assets/index-D5SiRo8X.css +0 -1
package/src/codex/quota.ts
CHANGED
|
@@ -336,6 +336,9 @@ function mergeAccountQuota(
|
|
|
336
336
|
}
|
|
337
337
|
|
|
338
338
|
if (snapshotHasCustom(quota)) next.customWindows = quota.customWindows;
|
|
339
|
+
// Ordinary response headers omit model-specific windows reported by WHAM.
|
|
340
|
+
// Absence is a partial update; an explicit list (including []) still replaces it.
|
|
341
|
+
else if (existing?.customWindows !== undefined) next.customWindows = existing.customWindows;
|
|
339
342
|
|
|
340
343
|
if (quota.resetCredits !== undefined) next.resetCredits = quota.resetCredits;
|
|
341
344
|
else if (existing?.resetCredits !== undefined) next.resetCredits = existing.resetCredits;
|
|
@@ -404,7 +407,20 @@ export function flushQuotaObservationsForTests(): Promise<void> {
|
|
|
404
407
|
return pendingObservation;
|
|
405
408
|
}
|
|
406
409
|
|
|
407
|
-
|
|
410
|
+
/** Wire marker shared by Spark-family models, whose upstream limit family is model-specific. */
|
|
411
|
+
const SPARK_MODEL_MARKER = "codex-spark";
|
|
412
|
+
/**
|
|
413
|
+
* Custom-window label for the Spark 5h window. The WHAM parser and the response-header path
|
|
414
|
+
* must write the SAME label so a header refresh replaces the WHAM reading instead of doubling it.
|
|
415
|
+
*/
|
|
416
|
+
const SPARK_SHORT_WINDOW_LABEL = "GPT-5.3-Codex-Spark 5h";
|
|
417
|
+
|
|
418
|
+
/** True when the routed model belongs to the Spark family, which carries its own rate limit. */
|
|
419
|
+
function isCodexSparkModel(modelId: string | undefined): boolean {
|
|
420
|
+
return typeof modelId === "string" && modelId.includes(SPARK_MODEL_MARKER);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export function parseUpstreamQuotaHeaders(headers: Headers, options?: { modelId?: string }): Omit<StoredAccountQuota, "updatedAt"> | null {
|
|
408
424
|
const primaryRaw = headers.get("x-codex-primary-used-percent");
|
|
409
425
|
const secondaryRaw = headers.get("x-codex-secondary-used-percent");
|
|
410
426
|
const tertiaryRaw = headers.get("x-codex-tertiary-used-percent");
|
|
@@ -427,6 +443,10 @@ export function parseUpstreamQuotaHeaders(headers: Headers): Omit<StoredAccountQ
|
|
|
427
443
|
// it into weeklyPercent both discards the real weekly reading and leaves the account looking
|
|
428
444
|
// exhausted long after the burst window resets. Duration decides, exactly as parseUsageQuota
|
|
429
445
|
// already does for the WHAM payload — the two parsers must not disagree about the same data.
|
|
446
|
+
// One more attribution layer (#4122): on a Spark-family model response the sub-day primary is
|
|
447
|
+
// the MODEL-SPECIFIC limit, not an account window. Filing it as the account short tuple made
|
|
448
|
+
// one pool account display a 5h bar its identically-limited peers did not have, and fed a
|
|
449
|
+
// model limit to the account-policy readers (main-account hard lock, five-hour auto-refresh).
|
|
430
450
|
const primaryIsShort = isExplicitShortWindowMinutes(primaryWindowMinutes);
|
|
431
451
|
|
|
432
452
|
if (primaryIsMonthly) {
|
|
@@ -443,10 +463,21 @@ export function parseUpstreamQuotaHeaders(headers: Headers): Omit<StoredAccountQ
|
|
|
443
463
|
if (secondaryResetAt !== undefined) quota.weeklyResetAt = secondaryResetAt;
|
|
444
464
|
}
|
|
445
465
|
} else if (primaryIsShort) {
|
|
446
|
-
if (
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
466
|
+
if (isCodexSparkModel(options?.modelId)) {
|
|
467
|
+
if (primaryPercent !== undefined) {
|
|
468
|
+
const sparkWindow: { label: string; percent: number; resetAt?: number } = {
|
|
469
|
+
label: SPARK_SHORT_WINDOW_LABEL,
|
|
470
|
+
percent: primaryPercent,
|
|
471
|
+
};
|
|
472
|
+
if (primaryResetAt !== undefined) sparkWindow.resetAt = primaryResetAt;
|
|
473
|
+
quota.customWindows = [sparkWindow];
|
|
474
|
+
}
|
|
475
|
+
} else {
|
|
476
|
+
if (primaryPercent !== undefined) quota.shortPercent = primaryPercent;
|
|
477
|
+
if (primaryResetAt !== undefined) quota.shortResetAt = primaryResetAt;
|
|
478
|
+
const minutes = windowMinutes_(primaryWindowMinutes);
|
|
479
|
+
if (minutes !== undefined) quota.shortWindowSeconds = Math.round(minutes * 60);
|
|
480
|
+
}
|
|
450
481
|
// The burst window vacates the primary slot, so the weekly reading is the secondary — which
|
|
451
482
|
// is where it was all along. Without this the true weekly value is silently dropped.
|
|
452
483
|
if (secondaryPercent !== undefined) {
|
|
@@ -477,13 +508,31 @@ export function applyAccountQuotaFromUpstreamHeaders(
|
|
|
477
508
|
headers: Headers,
|
|
478
509
|
writerGeneration = captureConfigGeneration(),
|
|
479
510
|
mainWriter?: MainQuotaWriter,
|
|
511
|
+
options?: { modelId?: string },
|
|
480
512
|
): void {
|
|
481
|
-
const quota = parseUpstreamQuotaHeaders(headers);
|
|
513
|
+
const quota = parseUpstreamQuotaHeaders(headers, options);
|
|
482
514
|
if (!quota) return;
|
|
483
515
|
const policyQuota = [
|
|
484
516
|
"x-codex-primary-used-percent", "x-codex-secondary-used-percent", "x-codex-tertiary-used-percent",
|
|
485
517
|
].some(name => isInvalidPolicyUsagePercent(headers.get(name))) ? null : filterMainPolicyMonthlyQuota(quota);
|
|
486
|
-
|
|
518
|
+
// A header-observed Spark window is a partial update against the WHAM-recorded custom windows:
|
|
519
|
+
// merge by label so the weekly Spark entry survives, and hydrate first so the first call in a
|
|
520
|
+
// process does not merge against an empty map. The merged list goes only to the legacy
|
|
521
|
+
// snapshot — the identity-bound policy evidence keeps exactly what this response said.
|
|
522
|
+
let legacyQuota = quota;
|
|
523
|
+
if (quota.customWindows !== undefined) {
|
|
524
|
+
hydrateAccountQuotasFromDisk();
|
|
525
|
+
const existing = accountQuota.get(accountId)?.customWindows;
|
|
526
|
+
if (existing !== undefined) {
|
|
527
|
+
const incoming = new Map(quota.customWindows.map(window => [window.label, window]));
|
|
528
|
+
const merged = existing.map(window => incoming.get(window.label) ?? window);
|
|
529
|
+
for (const window of quota.customWindows) {
|
|
530
|
+
if (!existing.some(entry => entry.label === window.label)) merged.push(window);
|
|
531
|
+
}
|
|
532
|
+
legacyQuota = { ...quota, customWindows: merged };
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
setAccountQuotaFromParsed(accountId, legacyQuota, writerGeneration, mainWriter, policyQuota);
|
|
487
536
|
}
|
|
488
537
|
|
|
489
538
|
export function updateAccountQuota(
|
|
@@ -795,6 +844,10 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
|
|
|
795
844
|
});
|
|
796
845
|
const sparkWindows = [spark?.rate_limit?.primary_window, spark?.rate_limit?.secondary_window]
|
|
797
846
|
.filter((window): window is WhamUsageWindow => !!window);
|
|
847
|
+
const sparkShort = sparkWindows.find(window => {
|
|
848
|
+
const percent = normalizeUsagePercent(window.used_percent);
|
|
849
|
+
return percent !== undefined && isExplicitShortWindow(window);
|
|
850
|
+
});
|
|
798
851
|
const sparkWeekly = sparkWindows.find(window => {
|
|
799
852
|
const percent = normalizeUsagePercent(window.used_percent);
|
|
800
853
|
const seconds = window.limit_window_seconds;
|
|
@@ -803,16 +856,19 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
|
|
|
803
856
|
&& !isExplicitMonthlyWindow(window)
|
|
804
857
|
&& (seconds === undefined || seconds >= WEEKLY_WINDOW_MIN_SECONDS);
|
|
805
858
|
});
|
|
806
|
-
const
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
859
|
+
const sparkCustomWindows: Array<{ label: string; percent: number; resetAt?: number }> = [];
|
|
860
|
+
for (const [label, window] of [
|
|
861
|
+
[SPARK_SHORT_WINDOW_LABEL, sparkShort],
|
|
862
|
+
["GPT-5.3-Codex-Spark Weekly", sparkWeekly],
|
|
863
|
+
] as const) {
|
|
864
|
+
const percent = normalizeUsagePercent(window?.used_percent);
|
|
865
|
+
if (percent === undefined) continue;
|
|
866
|
+
const sparkWindow: { label: string; percent: number; resetAt?: number } = { label, percent };
|
|
867
|
+
const resetAt = normalizeResetAt(window?.reset_at);
|
|
813
868
|
if (resetAt !== undefined) sparkWindow.resetAt = resetAt;
|
|
814
|
-
|
|
869
|
+
sparkCustomWindows.push(sparkWindow);
|
|
815
870
|
}
|
|
871
|
+
if (sparkCustomWindows.length > 0) quota.customWindows = sparkCustomWindows;
|
|
816
872
|
if (resetCredits !== undefined) quota.resetCredits = resetCredits;
|
|
817
873
|
|
|
818
874
|
return hasKnownQuotaValue(quota) || resetCredits !== undefined ? quota : null;
|
|
@@ -44,14 +44,30 @@ function owned(token: Token, writer: MainQuotaWriter): boolean {
|
|
|
44
44
|
function record(value: unknown): value is Record<string, unknown> {
|
|
45
45
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
46
46
|
}
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* The ChatGPT per-user identity carried by a native credential, plus whether the token's own two
|
|
49
|
+
* encodings of it disagree. Precedence stays on the RAW claims, so an empty or non-string
|
|
50
|
+
* `chatgpt_user_id` still blocks the `user_id` fallback exactly as before; `conflict` is a
|
|
51
|
+
* separate observation for callers that must fail closed on an ambiguous identity.
|
|
52
|
+
*/
|
|
53
|
+
export function nativeUserIdClaims(token: string): { userId: string | undefined; conflict: boolean } {
|
|
54
|
+
const none = { userId: undefined, conflict: false };
|
|
48
55
|
try {
|
|
49
56
|
const payload: unknown = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
|
|
50
57
|
const auth = record(payload) ? payload["https://api.openai.com/auth"] : undefined;
|
|
51
|
-
if (!record(auth)) return;
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
if (!record(auth)) return none;
|
|
59
|
+
const named = (value: unknown): string | undefined =>
|
|
60
|
+
typeof value === "string" && value.length > 0 ? value : undefined;
|
|
61
|
+
const primary = named(auth.chatgpt_user_id);
|
|
62
|
+
const secondary = named(auth.user_id);
|
|
63
|
+
return {
|
|
64
|
+
userId: named(auth.chatgpt_user_id ?? auth.user_id),
|
|
65
|
+
conflict: primary !== undefined && secondary !== undefined && primary !== secondary,
|
|
66
|
+
};
|
|
67
|
+
} catch { return none; }
|
|
68
|
+
}
|
|
69
|
+
function userId(token: string): string | undefined {
|
|
70
|
+
return nativeUserIdClaims(token).userId;
|
|
55
71
|
}
|
|
56
72
|
function identityMatches(data: WhamUsageResponse, token: Token): boolean {
|
|
57
73
|
if (data.account_id != null && data.account_id !== token.chatgptAccountId) return false;
|
package/src/codex/runtime.ts
CHANGED
|
@@ -86,6 +86,8 @@ export interface PersistedCodexRuntimeState {
|
|
|
86
86
|
|
|
87
87
|
const PERSIST_FILE = "codex-runtime.json";
|
|
88
88
|
const CLAMP_PERSIST_FILE = "codex-runtime-clamp.json";
|
|
89
|
+
/** Probe rejection for an absolute candidate whose file is gone. Matched when retiring a dead pin (#4035). */
|
|
90
|
+
const PATH_MISSING_REASON = "path does not exist";
|
|
89
91
|
|
|
90
92
|
function cloneAndDeepFreeze<T>(value: T): DeepReadonly<T> {
|
|
91
93
|
const clone = (current: unknown): unknown => {
|
|
@@ -283,6 +285,31 @@ export function persistCodexRuntime(
|
|
|
283
285
|
atomicWriteFile(codexRuntimeStatePath(configDir), `${JSON.stringify(payload, null, 2)}\n`);
|
|
284
286
|
}
|
|
285
287
|
|
|
288
|
+
/**
|
|
289
|
+
* Delete `codex-runtime.json`. Used to retire a pin whose path no longer exists, so a
|
|
290
|
+
* later resolve stops re-probing it (#4035).
|
|
291
|
+
*
|
|
292
|
+
* Invalidates the process resolve memo the same way `persistCodexRuntime` does: the memo
|
|
293
|
+
* folds the persisted `updatedAt` into its key, and a removed file has no stamp to fold.
|
|
294
|
+
*/
|
|
295
|
+
export function clearPersistedCodexRuntime(deps: ResolveCodexRuntimeDeps = {}): void {
|
|
296
|
+
const configDir = deps.configDir ?? getConfigDir();
|
|
297
|
+
clearCodexRuntimeResolveCache();
|
|
298
|
+
try {
|
|
299
|
+
unlinkSync(codexRuntimeStatePath(configDir));
|
|
300
|
+
} catch (error) {
|
|
301
|
+
// An already-missing file is the success case: the pin is gone, which is the point.
|
|
302
|
+
// Anything else means the pin SURVIVES and stays authoritative, so every later
|
|
303
|
+
// resolve re-probes the same dead path — #4035 unfixed, silently. Say so once.
|
|
304
|
+
const code = (error as NodeJS.ErrnoException | null)?.code;
|
|
305
|
+
if (code === "ENOENT") return;
|
|
306
|
+
console.warn(
|
|
307
|
+
`[opencodex] Could not remove the stale Codex runtime pin at ${displayCodexRuntimePath(codexRuntimeStatePath(configDir))}`
|
|
308
|
+
+ ` (${code ?? "unknown error"}). It will be re-probed until the file is removed.`,
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
286
313
|
function probeVersion(
|
|
287
314
|
command: string,
|
|
288
315
|
deps: ResolveCodexRuntimeDeps,
|
|
@@ -290,7 +317,7 @@ function probeVersion(
|
|
|
290
317
|
const platform = deps.platform ?? process.platform;
|
|
291
318
|
if (command.includes("/") || command.includes("\\") || /^[A-Za-z]:/.test(command)) {
|
|
292
319
|
const exists = deps.existsSync ?? existsSync;
|
|
293
|
-
if (!exists(command)) return { ok: false, reason:
|
|
320
|
+
if (!exists(command)) return { ok: false, reason: PATH_MISSING_REASON };
|
|
294
321
|
if (!isSpawnableCodexCandidate(command, platform)) {
|
|
295
322
|
return { ok: false, reason: "not a spawnable Codex launcher on this platform" };
|
|
296
323
|
}
|
|
@@ -654,6 +681,23 @@ export function resolveAndPersistCodexRuntime(
|
|
|
654
681
|
return cloneAndDeepFreeze({ ...result, persistError });
|
|
655
682
|
}
|
|
656
683
|
}
|
|
684
|
+
// A pin whose path has vanished must be RETIRED, not merely skipped. A Codex App update
|
|
685
|
+
// replaces the hashed plugin directory the pin names, the probe rejects it with
|
|
686
|
+
// "path does not exist", nothing else resolves, and the selection degrades to `fallback` —
|
|
687
|
+
// which the write guard above declines. The dead entry then survived every later resolve
|
|
688
|
+
// and each one re-probed a path that cannot exist (#4035). Bound narrowly: only when the
|
|
689
|
+
// degraded result is `fallback`, only for the persisted command, and only for the
|
|
690
|
+
// path-does-not-exist rejection, so a present-but-unusable binary is left for the operator.
|
|
691
|
+
else if (result.runtime.source === "fallback" && persistedRuntime?.command) {
|
|
692
|
+
const pinVanished = result.failures.some(
|
|
693
|
+
// Exact comparison, not `sameRuntimeCommand`: that helper lowercases, and on a
|
|
694
|
+
// case-sensitive filesystem `/plugins/Codex` and `/plugins/codex` are different
|
|
695
|
+
// files. A missing lowercase path must not retire a live uppercase pin.
|
|
696
|
+
failure => failure.command.trim() === persistedRuntime.command.trim()
|
|
697
|
+
&& failure.reason === PATH_MISSING_REASON,
|
|
698
|
+
);
|
|
699
|
+
if (pinVanished) clearPersistedCodexRuntime(deps);
|
|
700
|
+
}
|
|
657
701
|
return result;
|
|
658
702
|
}
|
|
659
703
|
|
package/src/codex/sync.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { summarizeComboCatalogOmissions, type ComboCatalogOmission } from "./cat
|
|
|
8
8
|
import { shouldSyncCodexOnStart } from "./desired-state";
|
|
9
9
|
import { admitCodexWrite, type CodexAdmission } from "./admission";
|
|
10
10
|
import type { CodexCatalogSyncOptions } from "./catalog/sync";
|
|
11
|
+
import { resetCodexAppServerCatalogStateCache } from "./app-server-processes";
|
|
11
12
|
|
|
12
13
|
export interface CodexSyncResult {
|
|
13
14
|
/**
|
|
@@ -116,6 +117,10 @@ export async function syncModelsToCodex(
|
|
|
116
117
|
message: admission.message,
|
|
117
118
|
};
|
|
118
119
|
}
|
|
120
|
+
// Config injection is a relevant Codex write even when the catalog bytes are unchanged.
|
|
121
|
+
// Drop cached process evidence before async discovery so a process that appeared since the
|
|
122
|
+
// last read cannot make native-default guidance report active after this sync.
|
|
123
|
+
resetCodexAppServerCatalogStateCache();
|
|
119
124
|
const p = port ?? config.port ?? 10100;
|
|
120
125
|
const externalProvider = (deps.currentExternalCodexModelProvider ?? currentExternalCodexModelProvider)();
|
|
121
126
|
|
package/src/combos/index.ts
CHANGED
|
@@ -27,9 +27,11 @@ export {
|
|
|
27
27
|
noteComboSuccess,
|
|
28
28
|
pickComboTarget,
|
|
29
29
|
pickComboTargetWithWait,
|
|
30
|
+
quotaInactiveReason,
|
|
30
31
|
tryPickComboModel,
|
|
31
32
|
UnknownComboError,
|
|
32
33
|
type ComboPick,
|
|
34
|
+
type QuotaInactiveReason,
|
|
33
35
|
} from "./resolve";
|
|
34
36
|
export {
|
|
35
37
|
clearComboTargetCooldowns,
|
package/src/combos/resolve.ts
CHANGED
|
@@ -92,6 +92,58 @@ export function cachedProviderQuotaIsExhausted(
|
|
|
92
92
|
return false;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Why a catalog row is offered but cannot currently serve a request (#1711).
|
|
97
|
+
*
|
|
98
|
+
* Only one reason exists today. It is a string rather than a boolean so a later cause — a
|
|
99
|
+
* cooldown, a revoked key — can be told apart by a consumer that already reads the field.
|
|
100
|
+
*/
|
|
101
|
+
export type QuotaInactiveReason = "no_credit";
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* `"no_credit"` when every USABLE target of a catalog row has positive exhaustion evidence
|
|
105
|
+
* (#1711), otherwise undefined.
|
|
106
|
+
*
|
|
107
|
+
* This deliberately reuses the runtime rules in `targetProviderIsUsable` above rather than the
|
|
108
|
+
* Dashboard's `quotaStateFromReport`, which is harsher: it treats `remaining <= 0` as exhausted
|
|
109
|
+
* without requiring `percent >= 100` and ignores an elapsed `resetAt`. A catalog row marked
|
|
110
|
+
* inactive on the harsher rule would contradict the router, which would still happily send the
|
|
111
|
+
* request.
|
|
112
|
+
*
|
|
113
|
+
* Three rules carry the correctness, all inherited rather than restated:
|
|
114
|
+
*
|
|
115
|
+
* - A target the operator has removed or disabled is not usable and is not evidence either way;
|
|
116
|
+
* it drops out before the vote. If nothing is left, the row is unavailable for an operator
|
|
117
|
+
* reason rather than a quota one, so this returns undefined.
|
|
118
|
+
* - The canonical ChatGPT forward provider is exempt. Native account selection owns model-scoped
|
|
119
|
+
* quota, and a provider-level summary cannot veto it.
|
|
120
|
+
* - A stale cache is NOT exhaustion. `getCachedProviderQuota` returns null past its 30-minute
|
|
121
|
+
* window, and a null reading ends the vote rather than counting as evidence, so an unprobed
|
|
122
|
+
* provider is never marked inactive.
|
|
123
|
+
*
|
|
124
|
+
* "Every" is the bar on purpose: one target that can still serve makes the row serviceable, which
|
|
125
|
+
* is exactly what the combo loop concludes at request time.
|
|
126
|
+
*/
|
|
127
|
+
export function quotaInactiveReason(
|
|
128
|
+
config: OcxConfig,
|
|
129
|
+
targets: readonly { provider: string }[],
|
|
130
|
+
now = Date.now(),
|
|
131
|
+
): QuotaInactiveReason | undefined {
|
|
132
|
+
const usable = targets.filter(target => {
|
|
133
|
+
if (!Object.hasOwn(config.providers, target.provider)) return false;
|
|
134
|
+
const provider = config.providers[target.provider];
|
|
135
|
+
return !!provider && provider.disabled !== true;
|
|
136
|
+
});
|
|
137
|
+
if (usable.length === 0) return undefined;
|
|
138
|
+
for (const target of usable) {
|
|
139
|
+
const provider = config.providers[target.provider]!;
|
|
140
|
+
if (isCanonicalOpenAiForwardProvider(provider)) return undefined;
|
|
141
|
+
const quota = getCachedProviderQuota(target.provider, now);
|
|
142
|
+
if (!quota || !cachedProviderQuotaIsExhausted(quota, now)) return undefined;
|
|
143
|
+
}
|
|
144
|
+
return "no_credit";
|
|
145
|
+
}
|
|
146
|
+
|
|
95
147
|
function smoothWeightedIndex(
|
|
96
148
|
targets: Required<OcxComboTarget>[],
|
|
97
149
|
state: SelectionState,
|
package/src/config.ts
CHANGED
|
@@ -73,6 +73,7 @@ import {
|
|
|
73
73
|
MODEL_ADAPTER_OVERRIDE_ALLOWED,
|
|
74
74
|
OPENAI_PROVIDER_TIER_VERSION,
|
|
75
75
|
pinnedWireAdapter,
|
|
76
|
+
PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS,
|
|
76
77
|
UPSTREAM_HTTP_VERSION_VALUES,
|
|
77
78
|
type OcxClaudeCodeConfig,
|
|
78
79
|
type OcxConfig,
|
|
@@ -497,6 +498,47 @@ export function requestPacingConfigError(value: unknown): string | null {
|
|
|
497
498
|
return "requestPacing must contain enabled and a valid requestsPerMinute/minIntervalMs provider rule or model overrides";
|
|
498
499
|
}
|
|
499
500
|
|
|
501
|
+
/**
|
|
502
|
+
* Bounds for the opt-in passthrough web-search bridge (`providers.<name>.webSearchBridge`,
|
|
503
|
+
* #3761). Strict for the same reason `retryOn429` is: a misspelled key here would silently
|
|
504
|
+
* leave the bridge disarmed while the operator believes they enabled it. `endpoint` is only
|
|
505
|
+
* shape-checked here; `planPassthroughWebSearchBridge` re-validates the origin before any key
|
|
506
|
+
* is sent to it, because config validation is not an authorization boundary.
|
|
507
|
+
*/
|
|
508
|
+
const providerWebSearchBridgeSchema = z.object({
|
|
509
|
+
enabled: z.boolean().optional(),
|
|
510
|
+
backend: z.enum(PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS).optional(),
|
|
511
|
+
maxSearches: z.number().int().min(1).max(10).optional(),
|
|
512
|
+
timeoutMs: z.number().int().min(1_000).max(600_000).optional(),
|
|
513
|
+
endpoint: z.string().min(1).optional(),
|
|
514
|
+
}).strict();
|
|
515
|
+
|
|
516
|
+
export function providerWebSearchBridgeConfigError(value: unknown): string | null {
|
|
517
|
+
if (value === undefined) return null;
|
|
518
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
519
|
+
return "webSearchBridge must be a plain object";
|
|
520
|
+
}
|
|
521
|
+
const parsed = providerWebSearchBridgeSchema.safeParse(value);
|
|
522
|
+
if (!parsed.success) {
|
|
523
|
+
return "webSearchBridge accepts only enabled (boolean), backend "
|
|
524
|
+
+ `(${PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS.join("|")}), maxSearches (1..10), `
|
|
525
|
+
+ "timeoutMs (1000..600000), and endpoint (absolute http(s) URL)";
|
|
526
|
+
}
|
|
527
|
+
const endpoint = parsed.data.endpoint;
|
|
528
|
+
if (endpoint !== undefined) {
|
|
529
|
+
let url: URL;
|
|
530
|
+
try {
|
|
531
|
+
url = new URL(endpoint);
|
|
532
|
+
} catch {
|
|
533
|
+
return "webSearchBridge.endpoint must be an absolute http(s) URL";
|
|
534
|
+
}
|
|
535
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
536
|
+
return "webSearchBridge.endpoint must be an absolute http(s) URL";
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
|
|
500
542
|
const fastWireSchema = z.object({
|
|
501
543
|
kind: z.string(),
|
|
502
544
|
canonicalToWire: z.record(z.string().trim(), z.string().trim()),
|
|
@@ -600,6 +642,10 @@ const providerConfigSchema = z.object({
|
|
|
600
642
|
repairInvalidIds: z.boolean().optional(),
|
|
601
643
|
}).strict().optional(),
|
|
602
644
|
responsesSnapshotRepair: z.boolean().optional(),
|
|
645
|
+
// Invalid blocks degrade to "absent" rather than failing the whole config load: an unusable
|
|
646
|
+
// bridge block must never send an operator through invalid-config recovery for an opt-in
|
|
647
|
+
// feature that is off by default. The management write boundary still rejects it loudly.
|
|
648
|
+
webSearchBridge: providerWebSearchBridgeSchema.optional().catch(undefined),
|
|
603
649
|
xaiResponsesXSearch: z.boolean().optional(),
|
|
604
650
|
xaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined),
|
|
605
651
|
}).passthrough();
|
|
@@ -1100,6 +1146,9 @@ const configSchema = z.object({
|
|
|
1100
1146
|
// candidates are rejected explicitly by remoteGuiConfigError below.
|
|
1101
1147
|
hub: hubConfigSchema.optional().catch(undefined),
|
|
1102
1148
|
remoteGui: remoteGuiConfigSchema.optional().catch(undefined),
|
|
1149
|
+
// A malformed privacy block must never be read as "unmask": .catch(undefined) drops it and
|
|
1150
|
+
// emailMaskingEnabled then falls back to masked, which is also what an absent block means.
|
|
1151
|
+
privacy: z.object({ maskEmails: z.boolean().optional() }).strict().optional().catch(undefined),
|
|
1103
1152
|
// A malformed present client block must remain diagnosable from raw config and
|
|
1104
1153
|
// fail closed through src/client/state.ts; unrelated provider state still loads.
|
|
1105
1154
|
client: clientConnectionSchema.optional().catch(undefined),
|
|
@@ -1118,6 +1167,15 @@ const configSchema = z.object({
|
|
|
1118
1167
|
.min(0)
|
|
1119
1168
|
.optional()
|
|
1120
1169
|
.catch(undefined),
|
|
1170
|
+
// Opt-in inbound body ceiling (#3573). An invalid hand edit degrades to the 256 MiB default
|
|
1171
|
+
// rather than failing the parse, matching the outbound guard above: a malformed number must
|
|
1172
|
+
// not change what the proxy admits. The hard ceiling is NOT enforced here — because of that
|
|
1173
|
+
// `.catch`, and because a config object can be built without this schema at all — but in
|
|
1174
|
+
// `resolveInboundBodyLimitBytes()`, which every reader goes through.
|
|
1175
|
+
maxInboundBodyBytes: z.number().int()
|
|
1176
|
+
.min(0)
|
|
1177
|
+
.optional()
|
|
1178
|
+
.catch(undefined),
|
|
1121
1179
|
appOwnedMemoryBudgetMb: z.number().int()
|
|
1122
1180
|
.min(MIN_APP_OWNED_MEMORY_BUDGET_MB)
|
|
1123
1181
|
.max(MAX_APP_OWNED_MEMORY_BUDGET_MB)
|
|
@@ -1189,6 +1247,7 @@ const configSchema = z.object({
|
|
|
1189
1247
|
).optional().catch(undefined),
|
|
1190
1248
|
codexShimAutoRestore: z.boolean().optional(),
|
|
1191
1249
|
codexDesktopAuthless: z.boolean().optional().catch(undefined),
|
|
1250
|
+
codexClientCompaction: z.boolean().optional().catch(undefined),
|
|
1192
1251
|
pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(),
|
|
1193
1252
|
codexQuotaAutoRefresh: codexQuotaAutoRefreshSchema.optional().catch(undefined),
|
|
1194
1253
|
codexAccountNamespaces: codexAccountNamespacesSchema.optional(),
|