@bitkyc08/opencodex 2.22.0 → 2.23.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-ClEcVlFO.js → index-rFWrIE11.js} +19 -19
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/anthropic.ts +39 -7
- package/src/adapters/cursor/tool-definitions.ts +48 -0
- package/src/adapters/google.ts +18 -12
- package/src/adapters/openai-chat.ts +106 -13
- package/src/adapters/tool-call-id.ts +119 -0
- package/src/adapters/tool-catalog-nudge.ts +3 -0
- package/src/bridge.ts +16 -5
- package/src/chat/inbound.ts +5 -11
- package/src/claude/context-windows.ts +5 -1
- package/src/claude/desktop-3p.ts +11 -6
- package/src/claude/inbound.ts +39 -1
- package/src/claude/model-info.ts +28 -8
- package/src/cli/account-api.ts +5 -1
- package/src/cli/claude-desktop.ts +3 -0
- package/src/cli/config-command.ts +37 -14
- package/src/codex/app-server-restart-service.ts +1 -1
- package/src/codex/auth-api.ts +5 -0
- package/src/codex/auth-context.ts +43 -2
- package/src/codex/catalog/metadata.ts +55 -8
- package/src/codex/catalog/native-models.ts +32 -2
- package/src/codex/catalog/parsing.ts +21 -7
- package/src/codex/catalog/provider-fetch.ts +35 -7
- package/src/codex/catalog/sync.ts +40 -18
- package/src/codex/catalog-refresh-status.ts +21 -3
- package/src/codex/catalog.ts +1 -1
- package/src/codex/convergence-types.ts +23 -2
- package/src/codex/desired-state.ts +1 -1
- package/src/codex/inject.ts +38 -7
- package/src/codex/injected-marker.ts +28 -0
- package/src/codex/journal.ts +40 -1
- package/src/codex/management-convergence.ts +55 -2
- package/src/codex/quota-rejection.ts +61 -1
- package/src/codex/quota.ts +60 -6
- package/src/codex/routing.ts +30 -3
- package/src/combos/failover.ts +20 -0
- package/src/config.ts +271 -4
- package/src/generated/compatibility-version.json +86 -74
- package/src/grok/sync.ts +3 -1
- package/src/lab/artifacts/sanitize.ts +1 -1
- package/src/lab/live/manifest.ts +1 -1
- package/src/lib/codex-restart-contract.ts +1 -1
- package/src/lib/config-ownership.ts +1 -0
- package/src/lib/errors.ts +9 -0
- package/src/lib/lab-activation.ts +1 -1
- package/src/lib/optional-shutdown-hooks.ts +1 -1
- package/src/providers/quota.ts +10 -4
- package/src/providers/registry.ts +2 -2
- package/src/responses/parser.ts +42 -7
- package/src/responses/provider-opaque-metadata.ts +1 -1
- package/src/responses/thought-signature-replay.ts +261 -0
- package/src/router.ts +6 -1
- package/src/routing/compatibility/provider-slot.ts +1 -1
- package/src/routing/evaluator.ts +12 -2
- package/src/routing/health.ts +16 -5
- package/src/routing/history/schema.ts +1 -1
- package/src/routing/trace.ts +1 -1
- package/src/server/auth-cors.ts +56 -21
- package/src/server/chat-completions.ts +6 -2
- package/src/server/index.ts +5 -3
- package/src/server/management/agent-settings-routes.ts +26 -4
- package/src/server/management/context.ts +1 -1
- package/src/server/management/model-rows.ts +5 -0
- package/src/server/management/native-integration-routes.ts +4 -1
- package/src/server/management/provider-routes.ts +19 -0
- package/src/server/management/shared.ts +3 -3
- package/src/server/management-api.ts +13 -6
- package/src/server/passive-route-linker.ts +1 -1
- package/src/server/relay.ts +16 -0
- package/src/server/responses/compact.ts +10 -3
- package/src/server/responses/core.ts +160 -33
- package/src/server/responses/fetch-helpers.ts +34 -2
- package/src/server/responses/input-admission.ts +17 -9
- package/src/server/responses-undeclared-tool-guard.ts +153 -0
- package/src/server/system-env.ts +4 -2
- package/src/service.ts +14 -7
- package/src/types.ts +34 -0
|
@@ -3,6 +3,7 @@ import { captureCatalogAdmissionSnapshot } from "./catalog-admission";
|
|
|
3
3
|
import { convergeCodexCatalog } from "./convergence";
|
|
4
4
|
import type {
|
|
5
5
|
CatalogDisposition,
|
|
6
|
+
CatalogFailureCause,
|
|
6
7
|
CatalogOnlyOutcome,
|
|
7
8
|
CodexHistoryState,
|
|
8
9
|
CodexObservedState,
|
|
@@ -49,6 +50,58 @@ function notEvaluatedObserved(history: CodexHistoryState): CodexObservedState {
|
|
|
49
50
|
};
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/** Recognized errno/code tokens. Anything else is dropped rather than echoed. */
|
|
54
|
+
const RECOGNIZED_FAILURE_CODES: ReadonlySet<string> = new Set([
|
|
55
|
+
"ENOSPC", "EACCES", "EPERM", "EROFS", "ENOENT", "SQLITE_BUSY",
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Reduce a caught error to an allowlisted cause (#1784).
|
|
60
|
+
*
|
|
61
|
+
* Nothing from the error text reaches the caller: `kind` is chosen from a closed set and
|
|
62
|
+
* `code` is only emitted when it is a recognized token. An `Error.message` routinely carries
|
|
63
|
+
* paths, home directories and account ids, and `redactSecretString` masks token shapes but
|
|
64
|
+
* none of those, so the message is never a safe thing to forward from here.
|
|
65
|
+
*/
|
|
66
|
+
function catalogFailureCause(error: unknown): CatalogFailureCause {
|
|
67
|
+
const raw = (error as { code?: unknown } | null)?.code;
|
|
68
|
+
const code = typeof raw === "string" && RECOGNIZED_FAILURE_CODES.has(raw)
|
|
69
|
+
? raw as CatalogFailureCause["code"]
|
|
70
|
+
: undefined;
|
|
71
|
+
if (error instanceof TypeError || error instanceof RangeError || error instanceof SyntaxError) {
|
|
72
|
+
return { kind: "invalid-request", ...(code ? { code } : {}) };
|
|
73
|
+
}
|
|
74
|
+
if (code === "SQLITE_BUSY") return { kind: "lock-busy", code };
|
|
75
|
+
if (code !== undefined) return { kind: "io", code };
|
|
76
|
+
return { kind: "unknown" };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Classify a failure that is NOT a filesystem problem.
|
|
81
|
+
*
|
|
82
|
+
* `disk` used to be the catch-all, so an operator saw "non-retryable disk failure" for a
|
|
83
|
+
* malformed request. Reserve `disk` for real IO and route the rest to honest reasons.
|
|
84
|
+
*/
|
|
85
|
+
function classifiedCatalogFailure(error: unknown, commitBegan: boolean): CatalogDisposition {
|
|
86
|
+
const cause = catalogFailureCause(error);
|
|
87
|
+
const reason = cause.kind === "invalid-request"
|
|
88
|
+
? "request-invalid" as const
|
|
89
|
+
: cause.kind === "lock-busy"
|
|
90
|
+
? "admission" as const
|
|
91
|
+
: cause.kind === "io"
|
|
92
|
+
? "disk" as const
|
|
93
|
+
: "internal" as const;
|
|
94
|
+
return {
|
|
95
|
+
status: "failed",
|
|
96
|
+
reason,
|
|
97
|
+
phase: commitBegan ? "commit" : "gather",
|
|
98
|
+
// Contention is the one class worth retrying unchanged.
|
|
99
|
+
retryable: reason === "admission",
|
|
100
|
+
partialWrite: commitBegan,
|
|
101
|
+
cause,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
52
105
|
function unexpectedCatalogFailure(commitBegan: boolean): CatalogDisposition {
|
|
53
106
|
return {
|
|
54
107
|
status: "failed",
|
|
@@ -64,7 +117,7 @@ function admissionFailure(error: unknown): CatalogDisposition {
|
|
|
64
117
|
if (message.includes("config generation is busy") || message.includes("config generation is database")) {
|
|
65
118
|
return { status: "skipped", reason: "busy", retryable: true };
|
|
66
119
|
}
|
|
67
|
-
return
|
|
120
|
+
return classifiedCatalogFailure(error, false);
|
|
68
121
|
}
|
|
69
122
|
|
|
70
123
|
/** Project catalog work into the shared no-change/not-evaluated outcome shape. */
|
|
@@ -107,7 +160,7 @@ export function createManagementConvergeCodex(
|
|
|
107
160
|
} catch (error) {
|
|
108
161
|
return projectCatalogOnlyOutcome({
|
|
109
162
|
changed: false,
|
|
110
|
-
catalogRefresh: commitBegan ?
|
|
163
|
+
catalogRefresh: commitBegan ? classifiedCatalogFailure(error, true) : admissionFailure(error),
|
|
111
164
|
});
|
|
112
165
|
}
|
|
113
166
|
};
|
|
@@ -23,6 +23,63 @@ export interface CodexPreStreamRejection {
|
|
|
23
23
|
alternateRetryEligible: boolean;
|
|
24
24
|
resetCreditEligible: boolean;
|
|
25
25
|
semanticCode?: CodexResetEligibleExhaustionCode;
|
|
26
|
+
/**
|
|
27
|
+
* Structured denial evidence for a 403. Present only when the upstream body names a
|
|
28
|
+
* workspace/entitlement denial, which proves the CREDENTIAL is valid and the account
|
|
29
|
+
* simply lacks access here (#1789). Status alone can never set this.
|
|
30
|
+
*/
|
|
31
|
+
denial?: "workspace" | "entitlement";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Upstream codes that identify a WORKSPACE denial rather than a bad credential.
|
|
36
|
+
*
|
|
37
|
+
* #1789: a K12 account whose credential validates and whose WHAM usage returns 200 still
|
|
38
|
+
* gets 403 `codex_workspace_access_denied` on a routed prompt. Treating that as a credential
|
|
39
|
+
* failure tells the user to re-authenticate a credential that is already valid, and the loop
|
|
40
|
+
* repeats forever.
|
|
41
|
+
*/
|
|
42
|
+
const WORKSPACE_DENIAL_CODES: ReadonlySet<string> = new Set([
|
|
43
|
+
"codex_workspace_access_denied",
|
|
44
|
+
"workspace_access_denied",
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
const ENTITLEMENT_DENIAL_CODES: ReadonlySet<string> = new Set([
|
|
48
|
+
"codex_entitlement_missing",
|
|
49
|
+
"entitlement_missing",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
/** Read a structured denial code out of a 403 body. Fails closed to undefined. */
|
|
53
|
+
async function denialFromResponse(
|
|
54
|
+
response: Response,
|
|
55
|
+
signal?: AbortSignal,
|
|
56
|
+
): Promise<"workspace" | "entitlement" | undefined> {
|
|
57
|
+
try {
|
|
58
|
+
const body = await readBoundedResponseBody(response.clone(), { signal, fatalUtf8: true });
|
|
59
|
+
if (!body.displaySafe || body.truncated || !body.text.trim()) return undefined;
|
|
60
|
+
if (isUnsafeJsonDocument(body.text)) return undefined;
|
|
61
|
+
const payload = JSON.parse(body.text) as unknown;
|
|
62
|
+
const code = structuredDenialCode(payload);
|
|
63
|
+
if (code === undefined) return undefined;
|
|
64
|
+
if (WORKSPACE_DENIAL_CODES.has(code)) return "workspace";
|
|
65
|
+
if (ENTITLEMENT_DENIAL_CODES.has(code)) return "entitlement";
|
|
66
|
+
return undefined;
|
|
67
|
+
} catch {
|
|
68
|
+
// Same fail-closed rule as the exhaustion classifier: an unreadable body must not
|
|
69
|
+
// downgrade a credential failure into a workspace one.
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Own-property `code` lookup at the top level or under `error`. No coercion, no accessors. */
|
|
75
|
+
function structuredDenialCode(payload: unknown): string | undefined {
|
|
76
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) return undefined;
|
|
77
|
+
const direct = (payload as Record<string, unknown>).code;
|
|
78
|
+
if (typeof direct === "string") return direct;
|
|
79
|
+
const error = (payload as Record<string, unknown>).error;
|
|
80
|
+
if (error === null || typeof error !== "object" || Array.isArray(error)) return undefined;
|
|
81
|
+
const nested = (error as Record<string, unknown>).code;
|
|
82
|
+
return typeof nested === "string" ? nested : undefined;
|
|
26
83
|
}
|
|
27
84
|
|
|
28
85
|
const RESET_ELIGIBLE_CODES: ReadonlySet<string> = new Set(RESET_ELIGIBLE_CODE_VALUES);
|
|
@@ -205,7 +262,10 @@ export async function classifyCodexPreStreamRejection(
|
|
|
205
262
|
): Promise<CodexPreStreamRejection> {
|
|
206
263
|
const status = response.status;
|
|
207
264
|
if (status === 401) return rejection(status, "authentication-error");
|
|
208
|
-
if (status === 403)
|
|
265
|
+
if (status === 403) {
|
|
266
|
+
const denial = await denialFromResponse(response, options.signal);
|
|
267
|
+
return { ...rejection(status, "permission-error"), ...(denial ? { denial } : {}) };
|
|
268
|
+
}
|
|
209
269
|
if (TRANSIENT_SERVER_STATUSES.has(status)) return rejection(status, "transient-server-error");
|
|
210
270
|
if (status !== 429 && status !== 402) return rejection(status, "other");
|
|
211
271
|
|
package/src/codex/quota.ts
CHANGED
|
@@ -9,6 +9,20 @@ export type StoredAccountQuota = {
|
|
|
9
9
|
monthlyPercent?: number;
|
|
10
10
|
weeklyResetAt?: number;
|
|
11
11
|
monthlyResetAt?: number;
|
|
12
|
+
/**
|
|
13
|
+
* A sub-day burst window, when upstream declares one (#1791).
|
|
14
|
+
*
|
|
15
|
+
* K12 and similar plans enforce a rolling 5-hour limit ALONGSIDE the weekly one.
|
|
16
|
+
* Not folding it into `weeklyPercent` stopped the mislabeling, but dropping it
|
|
17
|
+
* entirely hides a limit that genuinely blocks the account: a 429 at 100% here is
|
|
18
|
+
* real even while the weekly quota is untouched.
|
|
19
|
+
*
|
|
20
|
+
* `shortWindowSeconds` is retained because the duration is the only thing that makes
|
|
21
|
+
* this window self-describing; the slot it arrived in is not stable across plans.
|
|
22
|
+
*/
|
|
23
|
+
shortPercent?: number;
|
|
24
|
+
shortResetAt?: number;
|
|
25
|
+
shortWindowSeconds?: number;
|
|
12
26
|
resetCredits?: number;
|
|
13
27
|
/**
|
|
14
28
|
* True when `monthlyPercent` came from an explicitly-monthly PRIMARY window —
|
|
@@ -56,6 +70,19 @@ type WhamUsageWindow = {
|
|
|
56
70
|
};
|
|
57
71
|
|
|
58
72
|
const MONTHLY_WINDOW_MIN_SECONDS = 28 * 24 * 60 * 60;
|
|
73
|
+
/**
|
|
74
|
+
* Shortest window still plausibly the WEEKLY quota (#1791).
|
|
75
|
+
*
|
|
76
|
+
* K12 and similar plans send a 5-hour primary window plus a 7-day secondary. Folding the
|
|
77
|
+
* primary into `weeklyPercent` reported the 5-hour bar as the weekly one and discarded the
|
|
78
|
+
* real weekly reading entirely, so the dashboard showed a window that reset every few hours
|
|
79
|
+
* and routing never saw the limit that actually gates the account.
|
|
80
|
+
*
|
|
81
|
+
* 24h is the discriminator: anything shorter is a burst window, not a weekly one. A window
|
|
82
|
+
* with no declared duration is unchanged, because older payloads omit `limit_window_seconds`
|
|
83
|
+
* and guessing there would break every legacy account.
|
|
84
|
+
*/
|
|
85
|
+
const WEEKLY_WINDOW_MIN_SECONDS = 24 * 60 * 60;
|
|
59
86
|
const MONTHLY_WINDOW_MIN_MINUTES = MONTHLY_WINDOW_MIN_SECONDS / 60;
|
|
60
87
|
|
|
61
88
|
const accountQuota = new Map<string, StoredAccountQuota>();
|
|
@@ -72,13 +99,16 @@ export const CODEX_UNKNOWN_USAGE_SCORE = 101;
|
|
|
72
99
|
export const CODEX_EXHAUSTED_USAGE_PERCENT = 100;
|
|
73
100
|
|
|
74
101
|
export function isCodexQuotaExhausted(
|
|
75
|
-
quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent"> | null,
|
|
102
|
+
quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "shortPercent"> | null,
|
|
76
103
|
plan?: unknown,
|
|
77
104
|
): boolean {
|
|
78
105
|
if (!quota) return false;
|
|
106
|
+
// The burst window counts on EVERY plan. It is upstream-enforced independently, so an
|
|
107
|
+
// account at 100% there is blocked regardless of which longer window governs its plan;
|
|
108
|
+
// omitting it would route traffic straight into a 429 (#1791).
|
|
79
109
|
const values = codexQuotaWindowForPlan(plan) === "monthly"
|
|
80
|
-
? [quota.monthlyPercent]
|
|
81
|
-
: [quota.weeklyPercent, quota.monthlyPercent];
|
|
110
|
+
? [quota.monthlyPercent, quota.shortPercent]
|
|
111
|
+
: [quota.weeklyPercent, quota.monthlyPercent, quota.shortPercent];
|
|
82
112
|
return values.some(value => typeof value === "number"
|
|
83
113
|
&& Number.isFinite(value)
|
|
84
114
|
&& value >= CODEX_EXHAUSTED_USAGE_PERCENT);
|
|
@@ -104,7 +134,7 @@ export function codexQuotaWindowForPlan(plan?: unknown): "monthly" | "weekly" {
|
|
|
104
134
|
}
|
|
105
135
|
|
|
106
136
|
export function isCompleteCodexQuotaRecoverySnapshot(
|
|
107
|
-
quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "monthlyIsPrimaryWindow"> | null,
|
|
137
|
+
quota: Pick<StoredAccountQuota, "weeklyPercent" | "monthlyPercent" | "monthlyIsPrimaryWindow" | "shortPercent"> | null,
|
|
108
138
|
plan?: unknown,
|
|
109
139
|
): boolean {
|
|
110
140
|
if (!quota || isCodexQuotaExhausted(quota, plan)) return false;
|
|
@@ -160,6 +190,15 @@ function hasKnownQuotaValue(quota: Omit<StoredAccountQuota, "updatedAt">): boole
|
|
|
160
190
|
.some(value => typeof value === "number" && Number.isFinite(value));
|
|
161
191
|
}
|
|
162
192
|
|
|
193
|
+
/** True only for a window that DECLARES a duration shorter than a day. */
|
|
194
|
+
function isExplicitShortWindow(window: WhamUsageWindow | null | undefined): boolean {
|
|
195
|
+
const seconds = window?.limit_window_seconds;
|
|
196
|
+
return typeof seconds === "number"
|
|
197
|
+
&& Number.isFinite(seconds)
|
|
198
|
+
&& seconds > 0
|
|
199
|
+
&& seconds < WEEKLY_WINDOW_MIN_SECONDS;
|
|
200
|
+
}
|
|
201
|
+
|
|
163
202
|
function isExplicitMonthlyWindow(window: WhamUsageWindow | null | undefined): boolean {
|
|
164
203
|
const seconds = window?.limit_window_seconds;
|
|
165
204
|
return typeof seconds === "number"
|
|
@@ -465,10 +504,25 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
|
|
|
465
504
|
// - 선택한 방식: only an explicit primary duration of at least 28 days changes it to monthly.
|
|
466
505
|
// - 다른 대안 대신 이 방식을 선택한 이유: it accepts calendar-month variance and preserves legacy payloads.
|
|
467
506
|
// - 장점, 단점 및 영향: Team monthly quotas classify correctly; unknown durations remain weekly by design.
|
|
468
|
-
|
|
507
|
+
// #1791: a primary window that declares a sub-day duration is a burst window, not the
|
|
508
|
+
// weekly one. Skip it so the secondary (the real 7-day window) is what lands in
|
|
509
|
+
// `weeklyPercent`; without this the 5-hour bar was reported as weekly and the actual
|
|
510
|
+
// weekly reading was dropped on the floor.
|
|
511
|
+
const primaryIsShort = isExplicitShortWindow(primaryWindow);
|
|
512
|
+
const weeklyCandidatePercent = primaryIsShort ? undefined : primaryPercent;
|
|
513
|
+
const weeklyCandidateResetAt = primaryIsShort ? undefined : primaryResetAt;
|
|
514
|
+
// Keep the burst reading instead of dropping it on the floor: it is a real limit, and
|
|
515
|
+
// the account is blocked when it fills even though the weekly window is fine (#1791).
|
|
516
|
+
if (primaryIsShort && primaryPercent !== undefined) {
|
|
517
|
+
quota.shortPercent = primaryPercent;
|
|
518
|
+
if (primaryResetAt !== undefined) quota.shortResetAt = primaryResetAt;
|
|
519
|
+
const seconds = primaryWindow?.limit_window_seconds;
|
|
520
|
+
if (typeof seconds === "number" && Number.isFinite(seconds)) quota.shortWindowSeconds = seconds;
|
|
521
|
+
}
|
|
522
|
+
const weeklyPercent = primaryIsMonthly ? secondaryPercent : weeklyCandidatePercent ?? secondaryPercent;
|
|
469
523
|
const weeklyResetAt = primaryIsMonthly
|
|
470
524
|
? secondaryResetAt
|
|
471
|
-
:
|
|
525
|
+
: weeklyCandidatePercent !== undefined ? weeklyCandidateResetAt : secondaryResetAt;
|
|
472
526
|
const monthlyPercent = primaryIsMonthly ? primaryPercent ?? tertiaryPercent : tertiaryPercent;
|
|
473
527
|
const monthlyResetAt = primaryIsMonthly && primaryPercent !== undefined ? primaryResetAt : tertiaryResetAt;
|
|
474
528
|
if (thirtyDayOnly) {
|
package/src/codex/routing.ts
CHANGED
|
@@ -127,7 +127,8 @@ let lastReconciledGeneration = 0;
|
|
|
127
127
|
let liveHealthAccountIds = new Set<string>();
|
|
128
128
|
|
|
129
129
|
export type CodexUpstreamOutcome = number | "connect_error" | "timeout" | "connect_neutral";
|
|
130
|
-
export type CodexUpstreamOutcomeClass = "success" | "credential"
|
|
130
|
+
export type CodexUpstreamOutcomeClass = "success" | "credential"
|
|
131
|
+
| "workspace" | "quota" | "transient" | "caller" | "neutral" | "unknown";
|
|
131
132
|
export type CodexCooldownSource = "retry-after" | "reset-derived" | "default";
|
|
132
133
|
/**
|
|
133
134
|
* Native Codex quota groups known to be independent upstream. Keep the mapping
|
|
@@ -193,6 +194,12 @@ export type CodexUpstreamOutcomeMeta = {
|
|
|
193
194
|
now?: number;
|
|
194
195
|
/** (provider, host) ledger key for account-neutral reachability failures (#914). */
|
|
195
196
|
hostKey?: string;
|
|
197
|
+
/**
|
|
198
|
+
* Upstream denial evidence for a 403. A workspace/entitlement denial means the CREDENTIAL
|
|
199
|
+
* is fine and the account simply cannot reach this workspace, so it must not be quarantined
|
|
200
|
+
* for reauthentication (#1789). Absent evidence keeps the historical credential handling.
|
|
201
|
+
*/
|
|
202
|
+
denial?: "workspace" | "entitlement";
|
|
196
203
|
/** Stable transport code recorded alongside a neutral host failure. */
|
|
197
204
|
lastFailureCode?: string;
|
|
198
205
|
/** Native model selected for this request; used only for confirmed scoped quotas. */
|
|
@@ -327,7 +334,10 @@ export function computeCodexUsageScore(quota: {
|
|
|
327
334
|
return values.length > 0 ? Math.max(...values) : CODEX_UNKNOWN_USAGE_SCORE;
|
|
328
335
|
}
|
|
329
336
|
|
|
330
|
-
export function classifyCodexUpstreamOutcome(
|
|
337
|
+
export function classifyCodexUpstreamOutcome(
|
|
338
|
+
outcome: CodexUpstreamOutcome,
|
|
339
|
+
denial?: "workspace" | "entitlement",
|
|
340
|
+
): CodexUpstreamOutcomeClass {
|
|
331
341
|
if (outcome === "connect_neutral") return "neutral";
|
|
332
342
|
if (outcome === "connect_error" || outcome === "timeout") return "transient";
|
|
333
343
|
if (!Number.isFinite(outcome)) return "unknown";
|
|
@@ -337,6 +347,11 @@ export function classifyCodexUpstreamOutcome(outcome: CodexUpstreamOutcome): Cod
|
|
|
337
347
|
// and says nothing about the credential. Relayed as the neutral class so a
|
|
338
348
|
// stray 3xx cannot increment an account's transient streak.
|
|
339
349
|
if (outcome >= 300 && outcome < 400) return "neutral";
|
|
350
|
+
// 401 is always a credential problem. A 403 is only a credential problem when nothing
|
|
351
|
+
// tells us otherwise: a workspace/entitlement denial (#1789) means the credential is valid
|
|
352
|
+
// and the account simply lacks access here, so quarantining it for reauth is wrong advice.
|
|
353
|
+
// Absent denial evidence the historical mapping stands, so the change fails safe.
|
|
354
|
+
if (outcome === 403 && denial !== undefined) return "workspace";
|
|
340
355
|
if (outcome === 401 || outcome === 403) return "credential";
|
|
341
356
|
// 402 Payment Required is treated as quota exhaustion for pool cooldown/failover
|
|
342
357
|
// (same-request alternate retry records this outcome for the depleted account).
|
|
@@ -1596,7 +1611,7 @@ export function recordCodexUpstreamOutcome(
|
|
|
1596
1611
|
const writerGeneration = meta.writerGeneration ?? captureConfigGeneration();
|
|
1597
1612
|
if (writerGeneration < lastReconciledGeneration && !liveHealthAccountIds.has(accountId)) return;
|
|
1598
1613
|
const now = meta.now ?? Date.now();
|
|
1599
|
-
const outcomeClass = classifyCodexUpstreamOutcome(outcome);
|
|
1614
|
+
const outcomeClass = classifyCodexUpstreamOutcome(outcome, meta.denial);
|
|
1600
1615
|
const quotaScope = codexQuotaScopeForModel(meta.modelId);
|
|
1601
1616
|
if (outcomeClass === "success") {
|
|
1602
1617
|
const scopedProbe = meta.probeQuotaScope
|
|
@@ -1676,6 +1691,18 @@ export function recordCodexUpstreamOutcome(
|
|
|
1676
1691
|
}
|
|
1677
1692
|
|
|
1678
1693
|
const lastFailureStatus = typeof outcome === "number" ? outcome : 0;
|
|
1694
|
+
if (outcomeClass === "workspace") {
|
|
1695
|
+
// The credential is valid; this account just cannot reach this workspace (#1789).
|
|
1696
|
+
// Record the failure so routing stops preferring it, but do not mark it for
|
|
1697
|
+
// reauthentication and do not sweep its thread affinities: telling the user to
|
|
1698
|
+
// re-login is wrong advice that cannot fix a workspace grant.
|
|
1699
|
+
upstreamHealth.set(accountId, {
|
|
1700
|
+
consecutiveFailures: (upstreamHealth.get(accountId)?.consecutiveFailures ?? 0) + 1,
|
|
1701
|
+
lastFailureStatus,
|
|
1702
|
+
lastFailureAt: now,
|
|
1703
|
+
});
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1679
1706
|
if (outcomeClass === "credential") {
|
|
1680
1707
|
// 401/403 quarantines the account for reauth. That supersedes quota state
|
|
1681
1708
|
// entirely: a cooldown (and any probe lease) on an unusable account is moot.
|
package/src/combos/failover.ts
CHANGED
|
@@ -121,6 +121,26 @@ export function comboFailureDecision(
|
|
|
121
121
|
if (isCyberPolicyCode(options?.code)) return "stop";
|
|
122
122
|
const error = classifyError(status, "upstream_error", message);
|
|
123
123
|
if (isCyberPolicyCode(error.code)) return "stop";
|
|
124
|
+
// A local input-admission refusal (#1524) says "this candidate cannot fit the request",
|
|
125
|
+
// not "the request is impossible": the next candidate may have a larger context window.
|
|
126
|
+
//
|
|
127
|
+
// This MUST be tested before the generic stop list below. Our own refusal message says
|
|
128
|
+
// "context window" -- that is what it refuses on -- and the classifier remaps that phrase,
|
|
129
|
+
// so checking the stop list first swallowed the signal and ended the chain. An UPSTREAM
|
|
130
|
+
// `context_length_exceeded` carries no admission code and still falls through to stop.
|
|
131
|
+
//
|
|
132
|
+
// Matched on the STRUCTURED code only, which classifyError now preserves for our own
|
|
133
|
+
// refusal. A raw substring test would additionally let any upstream override a terminal
|
|
134
|
+
// verdict by echoing the token in prose we do not control.
|
|
135
|
+
//
|
|
136
|
+
// Precise about what this is NOT: an upstream can still SET this code deliberately, since
|
|
137
|
+
// both extractors read the upstream error object. That is bounded rather than dangerous --
|
|
138
|
+
// an upstream already controls other hop signals (429, 5xx), and traversal is finite: policy
|
|
139
|
+
// tries each candidate once via `tried`, and combo excludes each attempted target. So this is
|
|
140
|
+
// structured-code-only, not provably local.
|
|
141
|
+
if (options?.code === "input_admission_refused" || error.code === "input_admission_refused") {
|
|
142
|
+
return "hop";
|
|
143
|
+
}
|
|
124
144
|
if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) {
|
|
125
145
|
return "stop";
|
|
126
146
|
}
|