@bitkyc08/opencodex 2.40.0 → 2.42.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/README.md +4 -0
- package/gui/dist/assets/index-BU1tE0sr.js +112 -0
- package/gui/dist/assets/index-DL9-iS6J.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/meta.svg +1 -0
- package/package.json +4 -3
- package/src/adapters/cursor/catalog.ts +71 -29
- package/src/adapters/cursor/claude-id.ts +76 -0
- package/src/adapters/cursor/discovery.ts +16 -3
- package/src/adapters/cursor/effort-map.ts +27 -12
- package/src/adapters/cursor/protobuf-request.ts +41 -21
- package/src/adapters/google.ts +39 -2
- package/src/adapters/identity.ts +8 -2
- package/src/adapters/openai-responses.ts +57 -4
- package/src/bridge.ts +25 -3
- package/src/cli/account-auth.ts +28 -3
- package/src/cli/account-extended.ts +7 -1
- package/src/cli/capabilities.ts +2 -2
- package/src/cli/claude.ts +11 -2
- package/src/cli/connect.ts +7 -1
- package/src/cli/observe.ts +3 -1
- package/src/cli/registry.ts +1 -1
- package/src/cli/status.ts +19 -4
- package/src/client/connect.ts +5 -1
- package/src/client/hub-client.ts +29 -5
- package/src/clients/config-export.ts +12 -2
- package/src/codex/auth-api.ts +102 -9
- package/src/codex/catalog/aggregation.ts +8 -0
- package/src/codex/catalog/effort.ts +15 -2
- package/src/codex/catalog/metadata.ts +119 -9
- package/src/codex/catalog/native-models.ts +71 -0
- package/src/codex/catalog/parsing.ts +5 -3
- package/src/codex/catalog/provider-fetch.ts +166 -28
- package/src/codex/catalog.ts +1 -1
- package/src/codex/convergence-types.ts +1 -0
- package/src/codex/data/upstream-models.json +169 -0
- package/src/codex/desired-state.ts +18 -11
- package/src/codex/inject.ts +96 -6
- package/src/codex/injected-marker.ts +30 -4
- package/src/codex/journal.ts +14 -0
- package/src/combos/failover.ts +185 -6
- package/src/combos/index.ts +6 -0
- package/src/combos/resolve.ts +43 -6
- package/src/config.ts +5 -1
- package/src/generated/compatibility-version.json +115 -83
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/sync.ts +10 -2
- package/src/integrations/cursor-effort-table.ts +143 -0
- package/src/integrations/state.ts +1 -1
- package/src/integrations/writer.ts +2 -2
- package/src/lib/app-owned-memory-stores.ts +27 -8
- package/src/lib/bounded-body.ts +16 -1
- package/src/oauth/account-quota-rank.ts +40 -1
- package/src/oauth/chatgpt-device.ts +187 -0
- package/src/oauth/chatgpt.ts +31 -4
- package/src/oauth/generic-account-failover.ts +2 -2
- package/src/oauth/index.ts +24 -3
- package/src/oauth/log.ts +3 -0
- package/src/oauth/meta-muse.ts +235 -0
- package/src/providers/antigravity-models.ts +71 -13
- package/src/providers/command-code-efforts.ts +15 -0
- package/src/providers/free-directory.ts +4 -1
- package/src/providers/muse-subscription-usage.ts +95 -0
- package/src/providers/quota.ts +96 -0
- package/src/providers/registry.ts +116 -8
- package/src/responses/code-mode-helper-compat.ts +4 -1
- package/src/responses/state.ts +5 -4
- package/src/server/auth-cors.ts +241 -56
- package/src/server/chat-completions.ts +11 -2
- package/src/server/chat-native.ts +30 -4
- package/src/server/claude-messages.ts +17 -3
- package/src/server/effort-row.ts +131 -0
- package/src/server/index.ts +82 -45
- package/src/server/live.ts +18 -4
- package/src/server/management/api-key-rotation.ts +2 -1
- package/src/server/management/api-key-usage.ts +97 -43
- package/src/server/management/context.ts +3 -0
- package/src/server/management/cursor-integration-routes.ts +36 -7
- package/src/server/management/logs-usage-routes.ts +64 -87
- package/src/server/management/oauth-account-routes.ts +10 -3
- package/src/server/management/provider-routes.ts +218 -1
- package/src/server/management/route-registry.ts +1 -0
- package/src/server/management/usage-aggregate-cache.ts +464 -0
- package/src/server/management/usage-summary-cache.ts +4 -0
- package/src/server/models-capabilities.ts +60 -5
- package/src/server/responses/core.ts +95 -7
- package/src/server/responses/empty-completion-guard.ts +4 -0
- package/src/types/config.ts +10 -1
- package/src/types/request.ts +8 -0
- package/src/types/tools.ts +12 -9
- package/src/usage/expected-prices.ts +43 -7
- package/src/usage/ledger-scanner.ts +448 -0
- package/src/usage/log.ts +1 -1
- package/src/usage/summary.ts +915 -655
- package/src/web-search/index.ts +1 -1
- package/gui/dist/assets/index-BHe2rl_C.js +0 -112
- package/gui/dist/assets/index-CJSb3HPe.css +0 -1
|
@@ -15,6 +15,20 @@ export function isRootOpenaiBaseUrlLine(line: string): boolean {
|
|
|
15
15
|
return /^\s*openai_base_url\s*=/.test(line);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* codex-rs root key that redirects the realtime sideband WebSocket (WebRTC voice
|
|
20
|
+
* join + standalone realtime WS) without touching ordinary provider HTTP. Since
|
|
21
|
+
* openai/codex 438c9e98d (#35830) the sideband ignores the provider base URL and
|
|
22
|
+
* dials `https://api.openai.com/v1` unless this key is set, so a Pool-routed
|
|
23
|
+
* call-create and a directly-joined sideband end up on different accounts (404).
|
|
24
|
+
* Injected next to `openai_base_url` with the same value.
|
|
25
|
+
*/
|
|
26
|
+
export const REALTIME_WS_BASE_URL_KEY = "experimental_realtime_ws_base_url";
|
|
27
|
+
|
|
28
|
+
export function isRootRealtimeWsBaseUrlLine(line: string): boolean {
|
|
29
|
+
return /^\s*experimental_realtime_ws_base_url\s*=/.test(line);
|
|
30
|
+
}
|
|
31
|
+
|
|
18
32
|
export function tomlStringPattern(key: string): RegExp {
|
|
19
33
|
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
20
34
|
const keyToken = `(?:${escaped}|"${escaped}"|'${escaped}')`;
|
|
@@ -65,16 +79,28 @@ export function providerTableString(content: string, provider: string, key: stri
|
|
|
65
79
|
* EXACT value match against what we recorded writing: a user gateway we never wrote
|
|
66
80
|
* cannot match, so restore can never delete a URL that was not ours.
|
|
67
81
|
*/
|
|
68
|
-
export function stripJournaledOpenaiBaseUrl(
|
|
69
|
-
|
|
82
|
+
export function stripJournaledOpenaiBaseUrl(
|
|
83
|
+
content: string,
|
|
84
|
+
injectedUrl: string | null,
|
|
85
|
+
injectedRealtimeWsUrl: string | null = null,
|
|
86
|
+
): string {
|
|
87
|
+
if (!injectedUrl && !injectedRealtimeWsUrl) return content;
|
|
70
88
|
const lines = content.split(String.fromCharCode(10));
|
|
71
89
|
const firstTable = lines.findIndex(l => /^\s*\[/.test(l));
|
|
72
90
|
const rootEnd = firstTable === -1 ? lines.length : firstTable;
|
|
73
91
|
const drop = new Set<number>();
|
|
74
92
|
for (let i = 0; i < rootEnd; i++) {
|
|
75
93
|
const line = lines[i]!;
|
|
76
|
-
|
|
77
|
-
|
|
94
|
+
// Each key is matched against ITS OWN recorded value. The realtime override is
|
|
95
|
+
// journaled separately so a user-owned override that happens to equal the proxy
|
|
96
|
+
// URL is never mistaken for ours.
|
|
97
|
+
if (isRootOpenaiBaseUrlLine(line)) {
|
|
98
|
+
if (!injectedUrl || rootTomlString(line, "openai_base_url") !== injectedUrl) continue;
|
|
99
|
+
} else if (isRootRealtimeWsBaseUrlLine(line)) {
|
|
100
|
+
if (!injectedRealtimeWsUrl || rootTomlString(line, REALTIME_WS_BASE_URL_KEY) !== injectedRealtimeWsUrl) continue;
|
|
101
|
+
} else {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
78
104
|
drop.add(i);
|
|
79
105
|
// Take an ownership marker directly above it too, so repeated cycles cannot
|
|
80
106
|
// accumulate orphaned comments.
|
package/src/codex/journal.ts
CHANGED
|
@@ -35,6 +35,13 @@ interface Journal {
|
|
|
35
35
|
* and it is what lets restore tell OUR loopback URL apart from a gateway the user set.
|
|
36
36
|
*/
|
|
37
37
|
injectedOpenaiBaseUrl?: string | null;
|
|
38
|
+
/**
|
|
39
|
+
* The root `experimental_realtime_ws_base_url` this injection wrote, when it wrote one.
|
|
40
|
+
* Recorded on its own rather than inferred from `injectedOpenaiBaseUrl`: a user can own a
|
|
41
|
+
* realtime override whose value happens to equal the proxy URL, and restore must not treat
|
|
42
|
+
* that as ours. Null when the key was preserved or not injected.
|
|
43
|
+
*/
|
|
44
|
+
injectedRealtimeWsBaseUrl?: string | null;
|
|
38
45
|
/**
|
|
39
46
|
* The catalog path this injection actually wrote to.
|
|
40
47
|
*
|
|
@@ -119,6 +126,7 @@ export function writeJournal(options: WriteJournalOptions = {}): void {
|
|
|
119
126
|
|
|
120
127
|
export interface InjectedJournalOwnership {
|
|
121
128
|
injectedOpenaiBaseUrl: string | null;
|
|
129
|
+
injectedRealtimeWsBaseUrl: string | null;
|
|
122
130
|
injectedCatalogPath: string | null;
|
|
123
131
|
}
|
|
124
132
|
|
|
@@ -140,6 +148,7 @@ export function markJournalInjectedState(
|
|
|
140
148
|
// Only the caller knows which values it actually owns. Deriving these from the final TOML
|
|
141
149
|
// would mistake a preserved user override for injected routing.
|
|
142
150
|
journal.injectedOpenaiBaseUrl = ownership.injectedOpenaiBaseUrl;
|
|
151
|
+
journal.injectedRealtimeWsBaseUrl = ownership.injectedRealtimeWsBaseUrl;
|
|
143
152
|
journal.injectedCatalogPath = ownership.injectedCatalogPath;
|
|
144
153
|
atomicWriteFile(JOURNAL_PATH, JSON.stringify(journal));
|
|
145
154
|
}
|
|
@@ -156,6 +165,11 @@ export function journaledInjectedOpenaiBaseUrl(): string | null {
|
|
|
156
165
|
return readJournal()?.injectedOpenaiBaseUrl ?? null;
|
|
157
166
|
}
|
|
158
167
|
|
|
168
|
+
/** The root `experimental_realtime_ws_base_url` the last injection wrote, or null. */
|
|
169
|
+
export function journaledInjectedRealtimeWsBaseUrl(): string | null {
|
|
170
|
+
return readJournal()?.injectedRealtimeWsBaseUrl ?? null;
|
|
171
|
+
}
|
|
172
|
+
|
|
159
173
|
/** The catalog path the last injection wrote to, or null when none was recorded. */
|
|
160
174
|
export function journaledInjectedCatalogPath(): string | null {
|
|
161
175
|
return readJournal()?.injectedCatalogPath ?? null;
|
package/src/combos/failover.ts
CHANGED
|
@@ -13,6 +13,28 @@ interface TargetCooldown {
|
|
|
13
13
|
|
|
14
14
|
const DEFAULT_COOLDOWN_MS = 60_000;
|
|
15
15
|
const MAX_COOLDOWN_MS = 10 * 60_000;
|
|
16
|
+
/** Short cooldown for request-rate 429s (for example provider code 1302) that omit Retry-After. */
|
|
17
|
+
export const COMBO_REQUEST_RATE_COOLDOWN_MS = 5_000;
|
|
18
|
+
|
|
19
|
+
const QUOTA_LIMIT_CODES = new Set([
|
|
20
|
+
"1308",
|
|
21
|
+
"1310",
|
|
22
|
+
"1316",
|
|
23
|
+
"1317",
|
|
24
|
+
"1318",
|
|
25
|
+
"1319",
|
|
26
|
+
"1320",
|
|
27
|
+
"1321",
|
|
28
|
+
"insufficient_quota",
|
|
29
|
+
]);
|
|
30
|
+
const TRANSIENT_REQUEST_RATE_CODES = new Set(["1302", "1305"]);
|
|
31
|
+
const IMF_FIXDATE_RE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\d{2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{4}) (\d{2}):(\d{2}):(\d{2}) GMT$/i;
|
|
32
|
+
const RFC850_DATE_RE = /^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\d{2})-(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-(\d{2}) (\d{2}):(\d{2}):(\d{2}) GMT$/i;
|
|
33
|
+
const ASCTIME_DATE_RE = /^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) ( \d|\d{2}) (\d{2}):(\d{2}):(\d{2}) (\d{4})$/i;
|
|
34
|
+
const HTTP_MONTH_INDEX: Record<string, number> = {
|
|
35
|
+
jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5,
|
|
36
|
+
jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
|
|
37
|
+
};
|
|
16
38
|
|
|
17
39
|
/** Map<`${comboId}\0${provider/model}`, TargetCooldown> */
|
|
18
40
|
const targetCooldowns = new Map<string, TargetCooldown>();
|
|
@@ -26,22 +48,92 @@ function cooldownMapKey(
|
|
|
26
48
|
return `${comboId}\0${targetKey(target)}`;
|
|
27
49
|
}
|
|
28
50
|
|
|
51
|
+
function parseUtcDateParts(
|
|
52
|
+
year: number,
|
|
53
|
+
monthName: string,
|
|
54
|
+
day: number,
|
|
55
|
+
hour: number,
|
|
56
|
+
minute: number,
|
|
57
|
+
second: number,
|
|
58
|
+
): number | undefined {
|
|
59
|
+
const month = HTTP_MONTH_INDEX[monthName.toLowerCase()];
|
|
60
|
+
if (month === undefined) return undefined;
|
|
61
|
+
const timestamp = Date.UTC(year, month, day, hour, minute, second);
|
|
62
|
+
const parsed = new Date(timestamp);
|
|
63
|
+
return parsed.getUTCFullYear() === year
|
|
64
|
+
&& parsed.getUTCMonth() === month
|
|
65
|
+
&& parsed.getUTCDate() === day
|
|
66
|
+
&& parsed.getUTCHours() === hour
|
|
67
|
+
&& parsed.getUTCMinutes() === minute
|
|
68
|
+
&& parsed.getUTCSeconds() === second
|
|
69
|
+
? timestamp
|
|
70
|
+
: undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseHttpDate(value: string, now: number): number | undefined {
|
|
74
|
+
const imf = IMF_FIXDATE_RE.exec(value);
|
|
75
|
+
if (imf) {
|
|
76
|
+
return parseUtcDateParts(
|
|
77
|
+
Number(imf[3]), imf[2]!, Number(imf[1]),
|
|
78
|
+
Number(imf[4]), Number(imf[5]), Number(imf[6]),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
const rfc850 = RFC850_DATE_RE.exec(value);
|
|
82
|
+
if (rfc850) {
|
|
83
|
+
const current = new Date(now);
|
|
84
|
+
const currentYear = current.getUTCFullYear();
|
|
85
|
+
const month = HTTP_MONTH_INDEX[rfc850[2]!.toLowerCase()];
|
|
86
|
+
if (month === undefined) return undefined;
|
|
87
|
+
let year = Math.floor(currentYear / 100) * 100 + Number(rfc850[3]);
|
|
88
|
+
const yearDelta = year - currentYear;
|
|
89
|
+
const candidateTimeOfYear = Date.UTC(
|
|
90
|
+
2000, month, Number(rfc850[1]),
|
|
91
|
+
Number(rfc850[4]), Number(rfc850[5]), Number(rfc850[6]),
|
|
92
|
+
);
|
|
93
|
+
const currentTimeOfYear = Date.UTC(
|
|
94
|
+
2000, current.getUTCMonth(), current.getUTCDate(),
|
|
95
|
+
current.getUTCHours(), current.getUTCMinutes(), current.getUTCSeconds(),
|
|
96
|
+
current.getUTCMilliseconds(),
|
|
97
|
+
);
|
|
98
|
+
if (yearDelta < -50 || (yearDelta === -50 && candidateTimeOfYear < currentTimeOfYear)) {
|
|
99
|
+
year += 100;
|
|
100
|
+
} else if (yearDelta > 50 || (yearDelta === 50 && candidateTimeOfYear > currentTimeOfYear)) {
|
|
101
|
+
year -= 100;
|
|
102
|
+
}
|
|
103
|
+
return parseUtcDateParts(
|
|
104
|
+
year, rfc850[2]!, Number(rfc850[1]),
|
|
105
|
+
Number(rfc850[4]), Number(rfc850[5]), Number(rfc850[6]),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const asctime = ASCTIME_DATE_RE.exec(value);
|
|
109
|
+
if (!asctime) return undefined;
|
|
110
|
+
return parseUtcDateParts(
|
|
111
|
+
Number(asctime[6]), asctime[1]!, Number(asctime[2]),
|
|
112
|
+
Number(asctime[3]), Number(asctime[4]), Number(asctime[5]),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
29
116
|
export function parseRetryAfterMs(
|
|
30
117
|
value: string | null | undefined,
|
|
31
118
|
now = Date.now(),
|
|
119
|
+
options?: { preserveImmediate?: boolean },
|
|
32
120
|
): number | undefined {
|
|
33
121
|
const text = value?.trim();
|
|
34
122
|
if (!text) return undefined;
|
|
35
123
|
if (/^\d+(?:\.\d+)?$/.test(text)) {
|
|
36
124
|
const seconds = Number(text);
|
|
37
|
-
if (
|
|
125
|
+
if (
|
|
126
|
+
Number.isFinite(seconds)
|
|
127
|
+
&& (seconds > 0 || (options?.preserveImmediate && seconds === 0))
|
|
128
|
+
) {
|
|
38
129
|
return Math.min(Math.max(Math.ceil(seconds * 1000), 1), MAX_COOLDOWN_MS);
|
|
39
130
|
}
|
|
40
131
|
}
|
|
41
|
-
const timestamp =
|
|
42
|
-
if (
|
|
132
|
+
const timestamp = parseHttpDate(text, now);
|
|
133
|
+
if (timestamp === undefined) return undefined;
|
|
43
134
|
const delay = timestamp - now;
|
|
44
|
-
|
|
135
|
+
if (delay > 0) return Math.min(delay, MAX_COOLDOWN_MS);
|
|
136
|
+
return options?.preserveImmediate ? 1 : undefined;
|
|
45
137
|
}
|
|
46
138
|
|
|
47
139
|
export function isComboTargetInCooldown(
|
|
@@ -59,10 +151,59 @@ export function isComboTargetInCooldown(
|
|
|
59
151
|
return true;
|
|
60
152
|
}
|
|
61
153
|
|
|
154
|
+
export function isTransientRequestRateLimit(input: {
|
|
155
|
+
status?: number;
|
|
156
|
+
code?: string | null;
|
|
157
|
+
message?: string;
|
|
158
|
+
}): boolean {
|
|
159
|
+
if (isProviderScopedQuotaCap(input.status, input.message ?? "", input.code)) return false;
|
|
160
|
+
const code = (input.code ?? "").trim().toLowerCase().replaceAll("-", "_");
|
|
161
|
+
if (QUOTA_LIMIT_CODES.has(code)) return false;
|
|
162
|
+
if (TRANSIENT_REQUEST_RATE_CODES.has(code)) return true;
|
|
163
|
+
const text = (input.message ?? "").toLowerCase();
|
|
164
|
+
if (
|
|
165
|
+
text.includes("usage limit reached")
|
|
166
|
+
|| text.includes("insufficient_quota")
|
|
167
|
+
|| text.includes("quota exhausted")
|
|
168
|
+
) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
return text.includes("rate limit reached for requests");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function remainingComboCooldownMs(comboId: string, now = Date.now()): number | undefined {
|
|
175
|
+
const prefix = `${comboId}\0`;
|
|
176
|
+
let soonest: number | undefined;
|
|
177
|
+
for (const [key, cooldown] of targetCooldowns) {
|
|
178
|
+
if (!key.startsWith(prefix)) continue;
|
|
179
|
+
const remaining = cooldown.cooldownUntil - now;
|
|
180
|
+
if (remaining <= 0) {
|
|
181
|
+
targetCooldowns.delete(key);
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (soonest === undefined || remaining < soonest) soonest = remaining;
|
|
185
|
+
}
|
|
186
|
+
return soonest;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function comboCooldownRetryAfterSeconds(comboId: string, now = Date.now()): string | undefined {
|
|
190
|
+
const remainingMs = remainingComboCooldownMs(comboId, now);
|
|
191
|
+
if (remainingMs === undefined) return undefined;
|
|
192
|
+
return String(Math.max(1, Math.ceil(remainingMs / 1000)));
|
|
193
|
+
}
|
|
194
|
+
|
|
62
195
|
export function coolComboTarget(
|
|
63
196
|
comboId: string,
|
|
64
197
|
target: Pick<OcxComboTarget, "provider" | "model">,
|
|
65
|
-
options?: {
|
|
198
|
+
options?: {
|
|
199
|
+
retryAfter?: string | null;
|
|
200
|
+
now?: number;
|
|
201
|
+
cooldownMs?: number;
|
|
202
|
+
writerGeneration?: number;
|
|
203
|
+
status?: number;
|
|
204
|
+
code?: string | null;
|
|
205
|
+
message?: string;
|
|
206
|
+
},
|
|
66
207
|
): void {
|
|
67
208
|
const now = options?.now ?? Date.now();
|
|
68
209
|
const writerGeneration = options?.writerGeneration ?? captureConfigGeneration();
|
|
@@ -70,7 +211,11 @@ export function coolComboTarget(
|
|
|
70
211
|
if (writerGeneration < lastReconciledGeneration && !liveComboTargets.has(ownerKey)) return;
|
|
71
212
|
const cooldownMs = options?.cooldownMs
|
|
72
213
|
?? parseRetryAfterMs(options?.retryAfter, now)
|
|
73
|
-
??
|
|
214
|
+
?? (isTransientRequestRateLimit({
|
|
215
|
+
status: options?.status,
|
|
216
|
+
code: options?.code,
|
|
217
|
+
message: options?.message,
|
|
218
|
+
}) ? COMBO_REQUEST_RATE_COOLDOWN_MS : DEFAULT_COOLDOWN_MS);
|
|
74
219
|
targetCooldowns.set(cooldownMapKey(comboId, target), {
|
|
75
220
|
cooldownUntil: now + Math.min(Math.max(cooldownMs, 1), MAX_COOLDOWN_MS),
|
|
76
221
|
});
|
|
@@ -108,6 +253,37 @@ export function clearComboTargetCooldowns(comboId?: string): void {
|
|
|
108
253
|
}
|
|
109
254
|
|
|
110
255
|
export type ComboFailureDecision = "hop" | "stop";
|
|
256
|
+
export type ComboFailureCooldownScope = "target" | "provider";
|
|
257
|
+
|
|
258
|
+
function normalizedFailureCode(code?: string | null): string {
|
|
259
|
+
return code?.trim().toLowerCase().replaceAll("-", "_") ?? "";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function isProviderScopedQuotaCap(
|
|
263
|
+
status: number | undefined,
|
|
264
|
+
message: string,
|
|
265
|
+
code?: string | null,
|
|
266
|
+
): boolean {
|
|
267
|
+
const normalizedCode = normalizedFailureCode(code);
|
|
268
|
+
const text = message.toLowerCase();
|
|
269
|
+
if (
|
|
270
|
+
status === 429
|
|
271
|
+
&& (normalizedCode === "gousagelimiterror" || text.includes("monthly usage limit reached"))
|
|
272
|
+
) {
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
return normalizedCode === "free_rate_limited"
|
|
276
|
+
|| text.includes("err_free_prompt_cap")
|
|
277
|
+
|| (text.includes("free tier") && text.includes("single request"));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function comboFailureCooldownScope(
|
|
281
|
+
status: number,
|
|
282
|
+
message: string,
|
|
283
|
+
options?: { code?: string | null },
|
|
284
|
+
): ComboFailureCooldownScope {
|
|
285
|
+
return isProviderScopedQuotaCap(status, message, options?.code) ? "provider" : "target";
|
|
286
|
+
}
|
|
111
287
|
|
|
112
288
|
function isModelLifecycleGone(
|
|
113
289
|
status: number,
|
|
@@ -168,6 +344,9 @@ export function comboFailureDecision(
|
|
|
168
344
|
if (options?.code === "input_admission_refused" || error.code === "input_admission_refused") {
|
|
169
345
|
return "hop";
|
|
170
346
|
}
|
|
347
|
+
if (isProviderScopedQuotaCap(status, message, options?.code || error.code)) {
|
|
348
|
+
return "hop";
|
|
349
|
+
}
|
|
171
350
|
if (["origin_rejected", "context_length_exceeded", "invalid_request_error"].includes(error.code ?? "")) {
|
|
172
351
|
return "stop";
|
|
173
352
|
}
|
package/src/combos/index.ts
CHANGED
|
@@ -31,11 +31,17 @@ export {
|
|
|
31
31
|
} from "./resolve";
|
|
32
32
|
export {
|
|
33
33
|
clearComboTargetCooldowns,
|
|
34
|
+
comboCooldownRetryAfterSeconds,
|
|
35
|
+
COMBO_REQUEST_RATE_COOLDOWN_MS,
|
|
34
36
|
coolComboTarget,
|
|
35
37
|
isComboTargetInCooldown,
|
|
38
|
+
isTransientRequestRateLimit,
|
|
36
39
|
parseRetryAfterMs,
|
|
40
|
+
remainingComboCooldownMs,
|
|
37
41
|
comboFailureDecision,
|
|
42
|
+
comboFailureCooldownScope,
|
|
38
43
|
type ComboFailureDecision,
|
|
44
|
+
type ComboFailureCooldownScope,
|
|
39
45
|
} from "./failover";
|
|
40
46
|
export {
|
|
41
47
|
comboIdFromRawBody,
|
package/src/combos/resolve.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { OcxComboTarget, OcxConfig } from "../types";
|
|
2
2
|
import { getCachedProviderQuota } from "../providers/quota-routing-cache";
|
|
3
|
-
import {
|
|
3
|
+
import type { ProviderQuota } from "../providers/quota-types";
|
|
4
|
+
import { coolComboTarget, isComboTargetInCooldown, type ComboFailureCooldownScope } from "./failover";
|
|
4
5
|
import { quotaResetRemainingMs } from "./reset-window";
|
|
5
6
|
import { getCombo, resolveComboId, targetKey } from "./types";
|
|
6
7
|
import type { NormalizedComboConfig } from "./types";
|
|
@@ -58,6 +59,28 @@ function targetProviderIsUsable(config: OcxConfig, target: OcxComboTarget): bool
|
|
|
58
59
|
&& config.providers[target.provider]?.disabled !== true;
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
function quotaWindowExhausted(percent: number | undefined, resetAt: number | undefined, now: number): boolean {
|
|
63
|
+
if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 100) return false;
|
|
64
|
+
return typeof resetAt !== "number" || !Number.isFinite(resetAt) || resetAt > now;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function cachedProviderQuotaIsExhausted(
|
|
68
|
+
quota: ProviderQuota | null,
|
|
69
|
+
now = Date.now(),
|
|
70
|
+
): boolean {
|
|
71
|
+
if (!quota) return false;
|
|
72
|
+
if (quotaWindowExhausted(quota.fiveHourPercent, quota.fiveHourResetAt, now)) return true;
|
|
73
|
+
if (quotaWindowExhausted(quota.weeklyPercent, quota.weeklyResetAt, now)) return true;
|
|
74
|
+
if (quotaWindowExhausted(quota.monthlyPercent, quota.monthlyResetAt, now)) return true;
|
|
75
|
+
if (quota.customWindows?.some(window => quotaWindowExhausted(window.percent, window.resetAt, now))) return true;
|
|
76
|
+
if (quota.creditsUsd?.unlimited !== true
|
|
77
|
+
&& typeof quota.creditsUsd?.percent === "number"
|
|
78
|
+
&& Number.isFinite(quota.creditsUsd.percent)
|
|
79
|
+
&& quota.creditsUsd.percent >= 100
|
|
80
|
+
&& quota.creditsUsd.remaining <= 0) return true;
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
61
84
|
function smoothWeightedIndex(
|
|
62
85
|
targets: Required<OcxComboTarget>[],
|
|
63
86
|
state: SelectionState,
|
|
@@ -121,14 +144,17 @@ export function pickComboTarget(
|
|
|
121
144
|
options: {
|
|
122
145
|
exclude?: Iterable<string>;
|
|
123
146
|
eligible?: (target: Required<OcxComboTarget>) => boolean;
|
|
147
|
+
now?: number;
|
|
124
148
|
} = {},
|
|
125
149
|
): ComboPick | null {
|
|
126
150
|
const writerGeneration = captureConfigGeneration();
|
|
127
151
|
const combo = getCombo(config, comboId);
|
|
128
152
|
if (!combo) throw new UnknownComboError(comboId);
|
|
129
153
|
const excluded = new Set(options.exclude ?? []);
|
|
154
|
+
const now = options.now ?? Date.now();
|
|
130
155
|
const eligible = (target: Required<OcxComboTarget>): boolean =>
|
|
131
156
|
targetProviderIsUsable(config, target)
|
|
157
|
+
&& !cachedProviderQuotaIsExhausted(getCachedProviderQuota(target.provider, now), now)
|
|
132
158
|
&& !excluded.has(targetKey(target))
|
|
133
159
|
&& (options.eligible?.(target) ?? true);
|
|
134
160
|
|
|
@@ -187,7 +213,7 @@ export function pickComboTarget(
|
|
|
187
213
|
}
|
|
188
214
|
}
|
|
189
215
|
} else if (combo.strategy === "reset-window") {
|
|
190
|
-
targetIndex = resetWindowIndex(combo.targets, eligible);
|
|
216
|
+
targetIndex = resetWindowIndex(combo.targets, eligible, now);
|
|
191
217
|
} else {
|
|
192
218
|
targetIndex = combo.targets.findIndex(eligible);
|
|
193
219
|
}
|
|
@@ -250,15 +276,26 @@ export function advanceComboAfterFailure(
|
|
|
250
276
|
retryAfter?: string | null;
|
|
251
277
|
now?: number;
|
|
252
278
|
eligible?: (target: Required<OcxComboTarget>) => boolean;
|
|
279
|
+
cooldownScope?: ComboFailureCooldownScope;
|
|
280
|
+
status?: number;
|
|
281
|
+
code?: string | null;
|
|
282
|
+
message?: string;
|
|
253
283
|
} = {},
|
|
254
284
|
): ComboPick | null {
|
|
255
285
|
noteComboFailure(pick.comboId, pick.target, pick.writerGeneration);
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
286
|
+
const combo = getCombo(config, pick.comboId);
|
|
287
|
+
const cooldownTargets = options.cooldownScope === "provider" && combo
|
|
288
|
+
? combo.targets.filter(target => target.provider === pick.target.provider)
|
|
289
|
+
: [pick.target];
|
|
290
|
+
for (const target of cooldownTargets) {
|
|
291
|
+
coolComboTarget(pick.comboId, target, {
|
|
292
|
+
...options,
|
|
293
|
+
writerGeneration: pick.writerGeneration,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
260
296
|
return pickComboTarget(config, pick.comboId, {
|
|
261
297
|
exclude: pick.attempted,
|
|
298
|
+
now: options.now,
|
|
262
299
|
eligible: target => !isComboTargetInCooldown(pick.comboId, target, options.now)
|
|
263
300
|
&& (options.eligible?.(target) ?? true),
|
|
264
301
|
});
|
package/src/config.ts
CHANGED
|
@@ -1010,7 +1010,9 @@ const configSchema = z.object({
|
|
|
1010
1010
|
// A malformed present client block must remain diagnosable from raw config and
|
|
1011
1011
|
// fail closed through src/client/state.ts; unrelated provider state still loads.
|
|
1012
1012
|
client: clientConnectionSchema.optional().catch(undefined),
|
|
1013
|
-
managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024)
|
|
1013
|
+
managementUsageMaxReadBytes: z.number().int().positive().default(64 * 1024 * 1024).describe(
|
|
1014
|
+
"Deprecated compatibility limit for bounded legacy usage readers; GET /api/usage always aggregates the complete ledger",
|
|
1015
|
+
),
|
|
1014
1016
|
// Invalid hand edits disable only this opt-in circuit. Live writes remain strict.
|
|
1015
1017
|
upstreamHostCircuitThreshold: z.number().int()
|
|
1016
1018
|
.min(0)
|
|
@@ -1046,6 +1048,8 @@ const configSchema = z.object({
|
|
|
1046
1048
|
providers: z.record(z.string(), providerConfigSchema),
|
|
1047
1049
|
defaultProvider: z.string().min(1).default("openai"),
|
|
1048
1050
|
defaultModelAliases: z.boolean().optional(),
|
|
1051
|
+
// Malformed hand edits disable this opt-in projection without rejecting providers.
|
|
1052
|
+
cursorEffortRows: z.boolean().optional().catch(false),
|
|
1049
1053
|
// Future versions remain opaque through passthrough-compatible whole-config saves.
|
|
1050
1054
|
// Only version 1 grants deletion authority in the rebase path.
|
|
1051
1055
|
configRebaseProvenance: z.unknown().optional(),
|