@bitkyc08/opencodex 2.7.39 → 2.7.40
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 -4
- package/gui/dist/assets/index-CMip1DzF.css +1 -0
- package/gui/dist/assets/index-cydcmbzC.js +52 -0
- package/gui/dist/index.html +2 -2
- package/package.json +2 -2
- package/src/adapters/cursor/arg-normalize.ts +23 -7
- package/src/adapters/cursor/live-transport.ts +26 -14
- package/src/adapters/cursor/native-exec-fs.ts +1 -1
- package/src/adapters/cursor/native-exec-network.ts +1 -1
- package/src/adapters/cursor/native-exec-shell.ts +1 -1
- package/src/adapters/cursor/protobuf-events.ts +72 -13
- package/src/adapters/cursor/protobuf-request.ts +82 -11
- package/src/adapters/cursor/request-builder.ts +35 -11
- package/src/adapters/cursor/tool-definitions.ts +175 -30
- package/src/adapters/openai-chat.ts +28 -7
- package/src/adapters/openai-responses.ts +150 -4
- package/src/bridge.ts +20 -1
- package/src/claude/outbound.ts +91 -6
- package/src/codex/auth-api.ts +12 -25
- package/src/codex/auth-context.ts +48 -3
- package/src/codex/catalog/provider-fetch.ts +56 -24
- package/src/codex/model-cache.ts +23 -0
- package/src/codex/quota.ts +120 -0
- package/src/codex/routing.ts +178 -9
- package/src/config.ts +56 -1
- package/src/providers/openai-sidecar.ts +8 -1
- package/src/providers/openai-tiers.ts +18 -0
- package/src/server/adapter-resolve.ts +24 -10
- package/src/server/auth-cors.ts +3 -0
- package/src/server/chat-completions.ts +4 -0
- package/src/server/claude-messages.ts +4 -0
- package/src/server/index.ts +3 -1
- package/src/server/live.ts +56 -0
- package/src/server/memory-watchdog.ts +1 -1
- package/src/server/responses/compact.ts +40 -10
- package/src/server/responses/core.ts +180 -26
- package/src/server/responses/terminal-guard.ts +230 -0
- package/src/service.ts +113 -30
- package/src/types.ts +52 -0
- package/src/usage/expected-prices.ts +12 -0
- package/src/web-search/anthropic-executor.ts +3 -1
- package/src/web-search/index.ts +7 -1
- package/src/web-search/loop.ts +17 -3
- package/README.ja.md +0 -445
- package/README.ko.md +0 -435
- package/README.ru.md +0 -486
- package/README.zh-CN.md +0 -411
- package/gui/dist/assets/index-B-cheu55.js +0 -52
- package/gui/dist/assets/index-oOZcqVmj.css +0 -1
package/src/claude/outbound.ts
CHANGED
|
@@ -76,6 +76,43 @@ function sseFrame(name: string, data: Rec): string {
|
|
|
76
76
|
return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Claude Code / Anthropic WebSearch domain filters are optional and mutually exclusive.
|
|
81
|
+
* Empty arrays are rejected ("ambiguous"); both fields together are rejected. Routed models
|
|
82
|
+
* often emit both shapes — sanitize before Claude Code sees the tool_use / server_tool_use
|
|
83
|
+
* input (issue #381).
|
|
84
|
+
*/
|
|
85
|
+
export function isClaudeWebSearchToolName(name: string): boolean {
|
|
86
|
+
const trimmed = name.trim();
|
|
87
|
+
return trimmed === "WebSearch" || /^web_search/i.test(trimmed);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function normalizeWebSearchDomainList(value: unknown): string[] | undefined {
|
|
91
|
+
if (!Array.isArray(value)) return undefined;
|
|
92
|
+
const domains = value
|
|
93
|
+
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
|
94
|
+
.map(entry => entry.trim());
|
|
95
|
+
return domains.length > 0 ? domains : undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Strip empty domain filters; if both remain, keep `allowed_domains` and drop `blocked_domains`. */
|
|
99
|
+
export function sanitizeWebSearchInput(input: unknown): Rec {
|
|
100
|
+
const src: Rec = isRec(input) ? { ...input } : {};
|
|
101
|
+
const allowed = normalizeWebSearchDomainList(src.allowed_domains);
|
|
102
|
+
const blocked = normalizeWebSearchDomainList(src.blocked_domains);
|
|
103
|
+
delete src.allowed_domains;
|
|
104
|
+
delete src.blocked_domains;
|
|
105
|
+
if (allowed && blocked) {
|
|
106
|
+
// Prefer allow-list (restrict-to) when a routed model sets both non-empty fields.
|
|
107
|
+
src.allowed_domains = allowed;
|
|
108
|
+
} else if (allowed) {
|
|
109
|
+
src.allowed_domains = allowed;
|
|
110
|
+
} else if (blocked) {
|
|
111
|
+
src.blocked_domains = blocked;
|
|
112
|
+
}
|
|
113
|
+
return src;
|
|
114
|
+
}
|
|
115
|
+
|
|
79
116
|
/**
|
|
80
117
|
* Map a Responses `web_search_call` item to its Anthropic pair: the server_tool_use
|
|
81
118
|
* input (query/queries) and the web_search_tool_result content (hits, or the error
|
|
@@ -87,7 +124,9 @@ function webSearchPairFromItem(item: Rec): { id: string; input: Rec; resultConte
|
|
|
87
124
|
? action.queries.filter((q): q is string => typeof q === "string" && q.length > 0)
|
|
88
125
|
: [];
|
|
89
126
|
const query = typeof action.query === "string" ? action.query : "";
|
|
90
|
-
const input
|
|
127
|
+
const input = sanitizeWebSearchInput(
|
|
128
|
+
queries.length > 1 ? { queries } : { query: queries[0] ?? query },
|
|
129
|
+
);
|
|
91
130
|
const completed = item.status !== "failed";
|
|
92
131
|
let resultContent: unknown;
|
|
93
132
|
if (completed) {
|
|
@@ -125,6 +164,10 @@ interface OpenBlock {
|
|
|
125
164
|
index: number;
|
|
126
165
|
/** Responses item_id (tool calls) so output_item.done can match. */
|
|
127
166
|
itemId?: string;
|
|
167
|
+
/** Buffer WebSearch args and emit one sanitized input_json_delta on close (#381). */
|
|
168
|
+
bufferWebSearchArgs?: boolean;
|
|
169
|
+
argsBuf?: string;
|
|
170
|
+
webSearchArgsEmitted?: boolean;
|
|
128
171
|
}
|
|
129
172
|
|
|
130
173
|
/** Streaming: Responses SSE bytes -> Anthropic Messages SSE bytes. */
|
|
@@ -170,6 +213,19 @@ export function responsesSseToAnthropicSse(
|
|
|
170
213
|
}
|
|
171
214
|
const closeOpenBlock = () => {
|
|
172
215
|
if (!open) return;
|
|
216
|
+
if (open.kind === "tool_use" && open.bufferWebSearchArgs && !open.webSearchArgsEmitted) {
|
|
217
|
+
// Emit sanitized args even if output_item.done never supplied a full arguments field
|
|
218
|
+
// (finish/fail paths, or deltas-only streams).
|
|
219
|
+
let parsed: unknown = {};
|
|
220
|
+
const rawArgs = open.argsBuf ?? "";
|
|
221
|
+
try { parsed = rawArgs.length > 0 ? JSON.parse(rawArgs) : {}; } catch { parsed = {}; }
|
|
222
|
+
emit("content_block_delta", {
|
|
223
|
+
type: "content_block_delta",
|
|
224
|
+
index: open.index,
|
|
225
|
+
delta: { type: "input_json_delta", partial_json: JSON.stringify(sanitizeWebSearchInput(parsed)) },
|
|
226
|
+
});
|
|
227
|
+
open.webSearchArgsEmitted = true;
|
|
228
|
+
}
|
|
173
229
|
if (open.kind === "thinking") {
|
|
174
230
|
// Synthetic signature: Claude Code accepts it (003 E6); inbound drops replays anyway.
|
|
175
231
|
emit("content_block_delta", {
|
|
@@ -253,21 +309,34 @@ export function responsesSseToAnthropicSse(
|
|
|
253
309
|
closeOpenBlock();
|
|
254
310
|
sawToolUse = true;
|
|
255
311
|
const index = blockIndex++;
|
|
312
|
+
const name = typeof item.name === "string" ? item.name : "";
|
|
313
|
+
const bufferWebSearchArgs = isClaudeWebSearchToolName(name);
|
|
256
314
|
emit("content_block_start", {
|
|
257
315
|
type: "content_block_start", index,
|
|
258
316
|
content_block: {
|
|
259
317
|
type: "tool_use",
|
|
260
318
|
id: typeof item.call_id === "string" ? item.call_id : `toolu_${uuid()}`,
|
|
261
|
-
name
|
|
319
|
+
name,
|
|
262
320
|
input: {},
|
|
263
321
|
},
|
|
264
322
|
});
|
|
265
|
-
open = {
|
|
323
|
+
open = {
|
|
324
|
+
kind: "tool_use",
|
|
325
|
+
index,
|
|
326
|
+
itemId: typeof item.id === "string" ? item.id : undefined,
|
|
327
|
+
bufferWebSearchArgs,
|
|
328
|
+
argsBuf: "",
|
|
329
|
+
webSearchArgsEmitted: false,
|
|
330
|
+
};
|
|
266
331
|
break;
|
|
267
332
|
}
|
|
268
333
|
case "response.function_call_arguments.delta": {
|
|
269
334
|
if (typeof data.delta !== "string" || data.delta.length === 0) break;
|
|
270
335
|
if (!open || open.kind !== "tool_use") break;
|
|
336
|
+
if (open.bufferWebSearchArgs) {
|
|
337
|
+
open.argsBuf = `${open.argsBuf ?? ""}${data.delta}`;
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
271
340
|
emit("content_block_delta", {
|
|
272
341
|
type: "content_block_delta", index: open.index,
|
|
273
342
|
delta: { type: "input_json_delta", partial_json: data.delta },
|
|
@@ -307,7 +376,22 @@ export function responsesSseToAnthropicSse(
|
|
|
307
376
|
if (!open) break;
|
|
308
377
|
// Close the matching open block (message/reasoning items close implicitly on
|
|
309
378
|
// the next block; function_call items must close here so tool input parses).
|
|
310
|
-
if (open.kind === "tool_use" && item.type === "function_call")
|
|
379
|
+
if (open.kind === "tool_use" && item.type === "function_call") {
|
|
380
|
+
if (open.bufferWebSearchArgs && !open.webSearchArgsEmitted) {
|
|
381
|
+
const rawArgs = typeof item.arguments === "string" && item.arguments.length > 0
|
|
382
|
+
? item.arguments
|
|
383
|
+
: (open.argsBuf ?? "");
|
|
384
|
+
let parsed: unknown = {};
|
|
385
|
+
try { parsed = rawArgs.length > 0 ? JSON.parse(rawArgs) : {}; } catch { parsed = {}; }
|
|
386
|
+
emit("content_block_delta", {
|
|
387
|
+
type: "content_block_delta",
|
|
388
|
+
index: open.index,
|
|
389
|
+
delta: { type: "input_json_delta", partial_json: JSON.stringify(sanitizeWebSearchInput(parsed)) },
|
|
390
|
+
});
|
|
391
|
+
open.webSearchArgsEmitted = true;
|
|
392
|
+
}
|
|
393
|
+
closeOpenBlock();
|
|
394
|
+
}
|
|
311
395
|
else if (open.kind === "text" && item.type === "message") closeOpenBlock();
|
|
312
396
|
else if (open.kind === "thinking" && item.type === "reasoning") closeOpenBlock();
|
|
313
397
|
break;
|
|
@@ -442,11 +526,12 @@ export function responsesJsonToAnthropicMessage(json: unknown, model: string): R
|
|
|
442
526
|
if (typeof raw.arguments === "string" && raw.arguments.length > 0) {
|
|
443
527
|
try { input = JSON.parse(raw.arguments); } catch { input = {}; }
|
|
444
528
|
}
|
|
529
|
+
const name = typeof raw.name === "string" ? raw.name : "";
|
|
445
530
|
content.push({
|
|
446
531
|
type: "tool_use",
|
|
447
532
|
id: typeof raw.call_id === "string" ? raw.call_id : `toolu_${uuid()}`,
|
|
448
|
-
name
|
|
449
|
-
input,
|
|
533
|
+
name,
|
|
534
|
+
input: isClaudeWebSearchToolName(name) ? sanitizeWebSearchInput(input) : input,
|
|
450
535
|
});
|
|
451
536
|
break;
|
|
452
537
|
}
|
package/src/codex/auth-api.ts
CHANGED
|
@@ -19,11 +19,19 @@ import {
|
|
|
19
19
|
getAccountQuota,
|
|
20
20
|
listAccountQuotas,
|
|
21
21
|
parseUsageQuota,
|
|
22
|
+
setAccountQuotaFromParsed,
|
|
22
23
|
updateAccountQuota,
|
|
23
24
|
type StoredAccountQuota,
|
|
24
25
|
type WhamUsageResponse,
|
|
25
26
|
} from "./quota";
|
|
26
|
-
export {
|
|
27
|
+
export {
|
|
28
|
+
applyAccountQuotaFromUpstreamHeaders,
|
|
29
|
+
clearAccountQuota,
|
|
30
|
+
getAccountQuota,
|
|
31
|
+
parseUsageQuota,
|
|
32
|
+
setAccountQuotaFromParsed,
|
|
33
|
+
updateAccountQuota,
|
|
34
|
+
} from "./quota";
|
|
27
35
|
import { extractAccountId, decodeJwtPayload } from "../oauth/chatgpt";
|
|
28
36
|
import { MAIN_CODEX_ACCOUNT_ID, setMainAccountPlan } from "./main-account";
|
|
29
37
|
import { maskEmail } from "../lib/privacy";
|
|
@@ -280,14 +288,7 @@ export async function fetchMainAccountInfo(forceRefresh = false): Promise<{ emai
|
|
|
280
288
|
// score and auto-switch the main account exactly like a pool account (Option A).
|
|
281
289
|
setMainAccountPlan(result.plan);
|
|
282
290
|
if (result.quota) {
|
|
283
|
-
|
|
284
|
-
MAIN_CODEX_ACCOUNT_ID,
|
|
285
|
-
result.quota.weeklyPercent,
|
|
286
|
-
result.quota.weeklyResetAt,
|
|
287
|
-
result.quota.monthlyPercent,
|
|
288
|
-
result.quota.monthlyResetAt,
|
|
289
|
-
result.quota.resetCredits,
|
|
290
|
-
);
|
|
291
|
+
setAccountQuotaFromParsed(MAIN_CODEX_ACCOUNT_ID, result.quota);
|
|
291
292
|
}
|
|
292
293
|
return result;
|
|
293
294
|
} catch {
|
|
@@ -327,14 +328,7 @@ async function fetchPoolAccountQuota(accountId: string, forceRefresh = false, co
|
|
|
327
328
|
const data = (await resp.json()) as WhamUsageResponse;
|
|
328
329
|
const quota = parseUsageQuota({ ...data, plan_type: data.plan_type ?? configuredPlan });
|
|
329
330
|
if (!quota) return { quota: existing ?? null, needsReauth: false };
|
|
330
|
-
|
|
331
|
-
accountId,
|
|
332
|
-
quota.weeklyPercent,
|
|
333
|
-
quota.weeklyResetAt,
|
|
334
|
-
quota.monthlyPercent,
|
|
335
|
-
quota.monthlyResetAt,
|
|
336
|
-
quota.resetCredits,
|
|
337
|
-
);
|
|
331
|
+
setAccountQuotaFromParsed(accountId, quota);
|
|
338
332
|
return { quota: getAccountQuota(accountId), needsReauth: false };
|
|
339
333
|
} catch (e) {
|
|
340
334
|
if (e instanceof CodexCredentialGenerationConflictError || e instanceof CodexCredentialRefreshLockTimeoutError) return { quota: existing ?? null, needsReauth: false };
|
|
@@ -767,14 +761,7 @@ export async function handleCodexAuthAPI(
|
|
|
767
761
|
markCodexAccountValidated(accountId, warmup.validatedAt);
|
|
768
762
|
clearAccountNeedsReauth(accountId);
|
|
769
763
|
if (quota) {
|
|
770
|
-
|
|
771
|
-
accountId,
|
|
772
|
-
quota.weeklyPercent,
|
|
773
|
-
quota.weeklyResetAt,
|
|
774
|
-
quota.monthlyPercent,
|
|
775
|
-
quota.monthlyResetAt,
|
|
776
|
-
quota.resetCredits,
|
|
777
|
-
);
|
|
764
|
+
setAccountQuotaFromParsed(accountId, quota);
|
|
778
765
|
}
|
|
779
766
|
|
|
780
767
|
const latestConfig = getRuntimeConfig(config);
|
|
@@ -9,6 +9,8 @@ import { isCodexAccountUsable } from "./account-usability";
|
|
|
9
9
|
import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken } from "./main-account";
|
|
10
10
|
import {
|
|
11
11
|
getCodexAccountCooldownUntil,
|
|
12
|
+
releaseCodexQuotaProbeLease,
|
|
13
|
+
tryAcquireCodexQuotaProbeLease,
|
|
12
14
|
pickLowestUsageCodexAccount,
|
|
13
15
|
resolveCodexAccountForThreadDetailed,
|
|
14
16
|
} from "./routing";
|
|
@@ -24,6 +26,12 @@ export type CodexAuthContext =
|
|
|
24
26
|
generation: number;
|
|
25
27
|
accessToken: string;
|
|
26
28
|
chatgptAccountId: string;
|
|
29
|
+
/**
|
|
30
|
+
* Set when this request was admitted through an active quota cooldown as
|
|
31
|
+
* the account's single probe. Must be echoed into the upstream outcome so
|
|
32
|
+
* only this request can clear the cooldown (#433).
|
|
33
|
+
*/
|
|
34
|
+
probeLeaseId?: string;
|
|
27
35
|
}
|
|
28
36
|
| {
|
|
29
37
|
// Main Codex account participating in rotation: token injected from ~/.codex/auth.json
|
|
@@ -32,8 +40,24 @@ export type CodexAuthContext =
|
|
|
32
40
|
accountId: string;
|
|
33
41
|
accessToken: string;
|
|
34
42
|
chatgptAccountId: string;
|
|
43
|
+
/** See `pool.probeLeaseId`. */
|
|
44
|
+
probeLeaseId?: string;
|
|
35
45
|
};
|
|
36
46
|
|
|
47
|
+
/** Probe lease carried by this context, when it holds one. */
|
|
48
|
+
export function codexProbeLeaseId(ctx: CodexAuthContext | undefined): string | undefined {
|
|
49
|
+
return ctx?.kind === "pool" || ctx?.kind === "main-pool" ? ctx.probeLeaseId : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Hand back a probe lease for a request that will not reach upstream. Safe to
|
|
54
|
+
* call with a context that holds no lease.
|
|
55
|
+
*/
|
|
56
|
+
export function releaseCodexAuthContextProbeLease(ctx: CodexAuthContext | undefined): void {
|
|
57
|
+
const leaseId = codexProbeLeaseId(ctx);
|
|
58
|
+
if (ctx && leaseId) releaseCodexQuotaProbeLease(ctx.accountId!, leaseId);
|
|
59
|
+
}
|
|
60
|
+
|
|
37
61
|
export type OcxRuntimeProviderConfig = OcxProviderConfig & {
|
|
38
62
|
_codexAccountOverride?: { accessToken: string; chatgptAccountId: string };
|
|
39
63
|
_codexAccountRequired?: boolean;
|
|
@@ -130,13 +154,30 @@ export async function resolveCodexAuthContext(
|
|
|
130
154
|
.catch(() => {});
|
|
131
155
|
}
|
|
132
156
|
const cooldownUntil = getCodexAccountCooldownUntil(accountId);
|
|
133
|
-
|
|
157
|
+
// A cooled-down account never sends traffic, so upstream recovery can never be
|
|
158
|
+
// observed and the cooldown outlives the real limit. Admit one probe per
|
|
159
|
+
// interval; its outcome decides whether the cooldown ends (#433).
|
|
160
|
+
let probeLeaseId: string | undefined;
|
|
161
|
+
if (cooldownUntil) {
|
|
162
|
+
probeLeaseId = tryAcquireCodexQuotaProbeLease(accountId) ?? undefined;
|
|
163
|
+
if (!probeLeaseId) throw new CodexAccountCooldownError(accountId, cooldownUntil);
|
|
164
|
+
}
|
|
134
165
|
|
|
135
166
|
if (accountId === MAIN_CODEX_ACCOUNT_ID) {
|
|
136
167
|
// Main account in rotation: inject the read-only auth.json token and fail closed if it vanished.
|
|
137
168
|
const token = getMainAccountToken();
|
|
138
|
-
if (!token)
|
|
139
|
-
|
|
169
|
+
if (!token) {
|
|
170
|
+
// Nothing will reach upstream, so give the probe back instead of burning it.
|
|
171
|
+
if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
|
|
172
|
+
throw new CodexPoolAuthenticationError();
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
kind: "main-pool",
|
|
176
|
+
accountId,
|
|
177
|
+
accessToken: token.accessToken,
|
|
178
|
+
chatgptAccountId: token.chatgptAccountId,
|
|
179
|
+
...(probeLeaseId ? { probeLeaseId } : {}),
|
|
180
|
+
};
|
|
140
181
|
}
|
|
141
182
|
|
|
142
183
|
try {
|
|
@@ -147,8 +188,10 @@ export async function resolveCodexAuthContext(
|
|
|
147
188
|
generation: token.generation,
|
|
148
189
|
accessToken: token.accessToken,
|
|
149
190
|
chatgptAccountId: token.chatgptAccountId,
|
|
191
|
+
...(probeLeaseId ? { probeLeaseId } : {}),
|
|
150
192
|
};
|
|
151
193
|
} catch (cause) {
|
|
194
|
+
if (probeLeaseId) releaseCodexQuotaProbeLease(accountId, probeLeaseId);
|
|
152
195
|
if (shouldMarkAccountNeedsReauthForCodexAuthFailure(cause)) {
|
|
153
196
|
markAccountNeedsReauth(accountId);
|
|
154
197
|
}
|
|
@@ -158,6 +201,8 @@ export async function resolveCodexAuthContext(
|
|
|
158
201
|
|
|
159
202
|
export function assertCodexAuthContextNotCooled(ctx: CodexAuthContext | undefined): void {
|
|
160
203
|
if (ctx?.kind !== "pool" && ctx?.kind !== "main-pool") return;
|
|
204
|
+
// A context holding the probe lease was deliberately admitted through the cooldown.
|
|
205
|
+
if (ctx.probeLeaseId) return;
|
|
161
206
|
const cooldownUntil = getCodexAccountCooldownUntil(ctx.accountId);
|
|
162
207
|
if (cooldownUntil) throw new CodexAccountCooldownError(ctx.accountId, cooldownUntil);
|
|
163
208
|
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
markModelsFetchFailure,
|
|
15
15
|
markProviderDiscoveryFailed,
|
|
16
16
|
markProviderDiscoveryOk,
|
|
17
|
+
shouldLogDiscoveryFailure,
|
|
17
18
|
setCached,
|
|
18
19
|
type ProviderModelDiscoveryFailure,
|
|
19
20
|
} from "../model-cache";
|
|
@@ -302,7 +303,11 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
|
|
|
302
303
|
: "provider-models";
|
|
303
304
|
const failedDiscoveryFallback = (
|
|
304
305
|
failure: ProviderModelDiscoveryFailure,
|
|
305
|
-
): { models: CatalogModel[]; fallback: "stale" | "configured" } => {
|
|
306
|
+
): { models: CatalogModel[]; fallback: "stale" | "configured"; shouldLog: boolean } => {
|
|
307
|
+
// Decide logging BEFORE recording the new status, so we can compare against the prior one and
|
|
308
|
+
// suppress an identical repeated failure (#395 log flood). The failure stays observable via the
|
|
309
|
+
// discovery-status API regardless.
|
|
310
|
+
const shouldLog = shouldLogDiscoveryFailure(name, failure);
|
|
306
311
|
markModelsFetchFailure(name);
|
|
307
312
|
markProviderDiscoveryFailed(name, failure);
|
|
308
313
|
const stale = getStaleCached(name);
|
|
@@ -311,6 +316,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
|
|
|
311
316
|
? withVertexDefaultSeed(applyConfigHintsToCachedModels(name, prov, stale, contextCap))
|
|
312
317
|
: failedDiscoveryConfigured,
|
|
313
318
|
fallback: stale ? "stale" : "configured",
|
|
319
|
+
shouldLog,
|
|
314
320
|
};
|
|
315
321
|
};
|
|
316
322
|
try {
|
|
@@ -319,19 +325,23 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
|
|
|
319
325
|
allowPrivateNetwork: prov.allowPrivateNetwork,
|
|
320
326
|
});
|
|
321
327
|
if (destinationError) {
|
|
322
|
-
const { models, fallback } = failedDiscoveryFallback({ reason: "blocked" });
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
328
|
+
const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "blocked" });
|
|
329
|
+
if (shouldLog) {
|
|
330
|
+
console.warn(
|
|
331
|
+
`[opencodex] Provider model discovery for "${name}" was blocked by destination policy: ${destinationError} [urlClass=${urlClass}, fallback=${fallback}].`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
326
334
|
return models;
|
|
327
335
|
}
|
|
328
336
|
|
|
329
337
|
const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) });
|
|
330
338
|
if (!res.ok) {
|
|
331
|
-
const { models, fallback } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status });
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
339
|
+
const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status });
|
|
340
|
+
if (shouldLog) {
|
|
341
|
+
console.warn(
|
|
342
|
+
`[opencodex] Provider model discovery for "${name}" failed with HTTP ${res.status} [urlClass=${urlClass}, fallback=${fallback}].`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
335
345
|
return models;
|
|
336
346
|
}
|
|
337
347
|
|
|
@@ -343,23 +353,27 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
|
|
|
343
353
|
try {
|
|
344
354
|
json = JSON.parse(body) as unknown;
|
|
345
355
|
} catch {
|
|
346
|
-
const { models, fallback } = failedDiscoveryFallback({ reason: "invalid_response" });
|
|
356
|
+
const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" });
|
|
347
357
|
const diagnostic = contentType === "application/json" || contentType.endsWith("+json")
|
|
348
358
|
? "returned invalid JSON in a 2xx response"
|
|
349
359
|
: "returned a non-JSON 2xx response";
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
360
|
+
if (shouldLog) {
|
|
361
|
+
console.warn(
|
|
362
|
+
`[opencodex] Provider model discovery for "${name}" ${diagnostic} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
353
365
|
return models;
|
|
354
366
|
}
|
|
355
367
|
const data = json !== null && typeof json === "object" && !Array.isArray(json)
|
|
356
368
|
? (json as { data?: unknown }).data
|
|
357
369
|
: undefined;
|
|
358
370
|
if (!isProviderModelsApiItems(data)) {
|
|
359
|
-
const { models, fallback } = failedDiscoveryFallback({ reason: "invalid_response" });
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
371
|
+
const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "invalid_response" });
|
|
372
|
+
if (shouldLog) {
|
|
373
|
+
console.warn(
|
|
374
|
+
`[opencodex] Provider model discovery for "${name}" returned malformed 2xx data [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
363
377
|
return models;
|
|
364
378
|
}
|
|
365
379
|
const items = data;
|
|
@@ -402,10 +416,12 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
|
|
|
402
416
|
setCached(name, live);
|
|
403
417
|
return live;
|
|
404
418
|
} catch (error) {
|
|
405
|
-
const { models, fallback } = failedDiscoveryFallback({ reason: "network" });
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
419
|
+
const { models, fallback, shouldLog } = failedDiscoveryFallback({ reason: "network" });
|
|
420
|
+
if (shouldLog) {
|
|
421
|
+
console.warn(
|
|
422
|
+
`[opencodex] Provider model discovery for "${name}" threw ${error instanceof Error ? error.name : "unknown"} [urlClass=${urlClass}, fallback=${fallback}].`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
409
425
|
return models;
|
|
410
426
|
}
|
|
411
427
|
}
|
|
@@ -525,10 +541,13 @@ export async function gatherRoutedModels(config: OcxConfig): Promise<CatalogMode
|
|
|
525
541
|
else warnUncataloguedComboOnce(id, combo, members);
|
|
526
542
|
}
|
|
527
543
|
all.sort((a, b) => (a.provider === b.provider ? a.id.localeCompare(b.id) : a.provider.localeCompare(b.provider)));
|
|
544
|
+
// Enriched (registry-hydrated) provider clones, keyed by name — the same view used above so
|
|
545
|
+
// custom rows get the same noVisionModels / inputModalities treatment as discovered rows.
|
|
546
|
+
const enrichedByName = new Map(activeProviders);
|
|
528
547
|
const customModels = (config.customModels ?? []).map(cm => {
|
|
529
|
-
const
|
|
530
|
-
const supportsReasoningSummaries = modelRecordValue(
|
|
531
|
-
|
|
548
|
+
const rawProvider = config.providers[cm.provider] as OcxProviderConfigWithReasoningSummaries | undefined;
|
|
549
|
+
const supportsReasoningSummaries = modelRecordValue(rawProvider?.modelSupportsReasoningSummaries, cm.modelId);
|
|
550
|
+
const base: CatalogModel = {
|
|
532
551
|
id: cm.modelId,
|
|
533
552
|
provider: cm.provider,
|
|
534
553
|
// Display-only label: never feeds routing (customModels are keyed by routedSlug below).
|
|
@@ -537,6 +556,19 @@ export async function gatherRoutedModels(config: OcxConfig): Promise<CatalogMode
|
|
|
537
556
|
...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}),
|
|
538
557
|
...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}),
|
|
539
558
|
};
|
|
559
|
+
// Vision-sidecar coverage ONLY: if the custom model is in the enriched provider's
|
|
560
|
+
// noVisionModels, advertise image input so the Codex app lets images reach the sidecar
|
|
561
|
+
// (#349/#344). Deliberately NOT the full applyProviderConfigHints pass — custom rows are a
|
|
562
|
+
// user override, so their explicit contextWindow / inputModalities / reasoning fields must be
|
|
563
|
+
// preserved verbatim (the hint pass would cap context and overwrite modalities from registry).
|
|
564
|
+
const enrichedProvider = enrichedByName.get(cm.provider) ?? rawProvider;
|
|
565
|
+
if (enrichedProvider && modelInList(enrichedProvider.noVisionModels, base.id)) {
|
|
566
|
+
const current = base.inputModalities ?? ["text"];
|
|
567
|
+
if (!current.includes("image")) {
|
|
568
|
+
return { ...base, inputModalities: [...current, "image"] };
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
return base;
|
|
540
572
|
});
|
|
541
573
|
// Custom rows override discovered rows that encode to the same Codex-facing slug.
|
|
542
574
|
const customKeys = new Set(customModels.map(c => routedSlug(c.provider, c.id)));
|
package/src/codex/model-cache.ts
CHANGED
|
@@ -63,6 +63,29 @@ export function markProviderDiscoveryFailed(
|
|
|
63
63
|
discoveryStatus.set(provider, { status: "failed", ...failure });
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Decide whether a discovery FAILURE should be logged, to avoid flooding the log with an identical
|
|
68
|
+
* warning on every poll (#395: an anthropic-adapter baseUrl without `/v1/models`, e.g. Azure AI
|
|
69
|
+
* Foundry, returns HTTP 404 forever; the 30s cooldown re-probes and previously re-logged each time).
|
|
70
|
+
*
|
|
71
|
+
* Returns true only when the failure SIGNATURE changed since the last observed status — i.e. the
|
|
72
|
+
* previous state was ok/undefined, or a different reason/httpStatus. Repeated identical failures
|
|
73
|
+
* stay observable through `getProviderDiscoveryStatus()` / the providers API without log spam.
|
|
74
|
+
* Call this BEFORE `markProviderDiscoveryFailed` so it can see the prior state.
|
|
75
|
+
*/
|
|
76
|
+
export function shouldLogDiscoveryFailure(
|
|
77
|
+
provider: string,
|
|
78
|
+
failure: ProviderModelDiscoveryFailure,
|
|
79
|
+
): boolean {
|
|
80
|
+
const prev = discoveryStatus.get(provider);
|
|
81
|
+
if (!prev || prev.status !== "failed") return true;
|
|
82
|
+
if (prev.reason !== failure.reason) return true;
|
|
83
|
+
if (prev.reason === "http" && failure.reason === "http") {
|
|
84
|
+
return prev.httpStatus !== failure.httpStatus;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
66
89
|
export function clearProviderDiscoveryStatus(provider: string): void {
|
|
67
90
|
discoveryStatus.delete(provider);
|
|
68
91
|
}
|
package/src/codex/quota.ts
CHANGED
|
@@ -28,6 +28,7 @@ type WhamUsageWindow = {
|
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
const MONTHLY_WINDOW_MIN_SECONDS = 28 * 24 * 60 * 60;
|
|
31
|
+
const MONTHLY_WINDOW_MIN_MINUTES = MONTHLY_WINDOW_MIN_SECONDS / 60;
|
|
31
32
|
|
|
32
33
|
const accountQuota = new Map<string, StoredAccountQuota>();
|
|
33
34
|
|
|
@@ -65,6 +66,125 @@ function isExplicitMonthlyWindow(window: WhamUsageWindow | null | undefined): bo
|
|
|
65
66
|
&& seconds >= MONTHLY_WINDOW_MIN_SECONDS;
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
function isExplicitMonthlyWindowMinutes(windowMinutes: unknown): boolean {
|
|
70
|
+
const minutes = typeof windowMinutes === "number"
|
|
71
|
+
? windowMinutes
|
|
72
|
+
: typeof windowMinutes === "string" && windowMinutes.trim() !== ""
|
|
73
|
+
? Number(windowMinutes)
|
|
74
|
+
: undefined;
|
|
75
|
+
return typeof minutes === "number"
|
|
76
|
+
&& Number.isFinite(minutes)
|
|
77
|
+
&& minutes >= MONTHLY_WINDOW_MIN_MINUTES;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
function snapshotHasWeekly(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
|
|
82
|
+
return quota.weeklyPercent !== undefined || quota.weeklyResetAt !== undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function snapshotHasMonthly(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
|
|
86
|
+
return quota.monthlyPercent !== undefined || quota.monthlyResetAt !== undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function snapshotHasUsage(quota: Omit<StoredAccountQuota, "updatedAt">): boolean {
|
|
90
|
+
return snapshotHasWeekly(quota) || snapshotHasMonthly(quota);
|
|
91
|
+
}
|
|
92
|
+
export function setAccountQuotaFromParsed(
|
|
93
|
+
accountId: string,
|
|
94
|
+
quota: Omit<StoredAccountQuota, "updatedAt"> | null,
|
|
95
|
+
): void {
|
|
96
|
+
if (!quota) return;
|
|
97
|
+
const existing = accountQuota.get(accountId);
|
|
98
|
+
const next: StoredAccountQuota = { updatedAt: Date.now() };
|
|
99
|
+
const creditsOnly = quota.resetCredits !== undefined && !snapshotHasUsage(quota);
|
|
100
|
+
|
|
101
|
+
if (creditsOnly) {
|
|
102
|
+
if (existing?.weeklyPercent !== undefined) next.weeklyPercent = existing.weeklyPercent;
|
|
103
|
+
if (existing?.weeklyResetAt !== undefined) next.weeklyResetAt = existing.weeklyResetAt;
|
|
104
|
+
if (existing?.monthlyPercent !== undefined) next.monthlyPercent = existing.monthlyPercent;
|
|
105
|
+
if (existing?.monthlyResetAt !== undefined) next.monthlyResetAt = existing.monthlyResetAt;
|
|
106
|
+
next.resetCredits = quota.resetCredits;
|
|
107
|
+
accountQuota.set(accountId, next);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (snapshotHasWeekly(quota)) {
|
|
112
|
+
if (quota.weeklyPercent !== undefined) next.weeklyPercent = quota.weeklyPercent;
|
|
113
|
+
if (quota.weeklyResetAt !== undefined) next.weeklyResetAt = quota.weeklyResetAt;
|
|
114
|
+
} else if (snapshotHasMonthly(quota) && !snapshotHasWeekly(quota)) {
|
|
115
|
+
// Monthly-only snapshots intentionally clear stale weekly values (issue #382).
|
|
116
|
+
} else if (existing?.weeklyPercent !== undefined) {
|
|
117
|
+
next.weeklyPercent = existing.weeklyPercent;
|
|
118
|
+
if (existing.weeklyResetAt !== undefined) next.weeklyResetAt = existing.weeklyResetAt;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (snapshotHasMonthly(quota)) {
|
|
122
|
+
if (quota.monthlyPercent !== undefined) next.monthlyPercent = quota.monthlyPercent;
|
|
123
|
+
if (quota.monthlyResetAt !== undefined) next.monthlyResetAt = quota.monthlyResetAt;
|
|
124
|
+
} else if (snapshotHasWeekly(quota) && existing?.monthlyPercent !== undefined) {
|
|
125
|
+
next.monthlyPercent = existing.monthlyPercent;
|
|
126
|
+
if (existing.monthlyResetAt !== undefined) next.monthlyResetAt = existing.monthlyResetAt;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (quota.resetCredits !== undefined) next.resetCredits = quota.resetCredits;
|
|
130
|
+
else if (existing?.resetCredits !== undefined) next.resetCredits = existing.resetCredits;
|
|
131
|
+
|
|
132
|
+
accountQuota.set(accountId, next);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function parseUpstreamQuotaHeaders(headers: Headers): Omit<StoredAccountQuota, "updatedAt"> | null {
|
|
136
|
+
const primaryRaw = headers.get("x-codex-primary-used-percent");
|
|
137
|
+
const secondaryRaw = headers.get("x-codex-secondary-used-percent");
|
|
138
|
+
const tertiaryRaw = headers.get("x-codex-tertiary-used-percent");
|
|
139
|
+
const primaryResetRaw = headers.get("x-codex-primary-reset-at");
|
|
140
|
+
const secondaryResetRaw = headers.get("x-codex-secondary-reset-at");
|
|
141
|
+
const tertiaryResetRaw = headers.get("x-codex-tertiary-reset-at");
|
|
142
|
+
const primaryWindowMinutes = headers.get("x-codex-primary-window-minutes");
|
|
143
|
+
const secondaryWindowMinutes = headers.get("x-codex-secondary-window-minutes");
|
|
144
|
+
|
|
145
|
+
const quota: Omit<StoredAccountQuota, "updatedAt"> = {};
|
|
146
|
+
const primaryPercent = normalizeUsagePercent(primaryRaw);
|
|
147
|
+
const secondaryPercent = normalizeUsagePercent(secondaryRaw);
|
|
148
|
+
const tertiaryPercent = normalizeUsagePercent(tertiaryRaw);
|
|
149
|
+
const primaryResetAt = normalizeResetAt(primaryResetRaw);
|
|
150
|
+
const secondaryResetAt = normalizeResetAt(secondaryResetRaw);
|
|
151
|
+
const tertiaryResetAt = normalizeResetAt(tertiaryResetRaw);
|
|
152
|
+
const primaryIsMonthly = primaryRaw !== null && isExplicitMonthlyWindowMinutes(primaryWindowMinutes);
|
|
153
|
+
|
|
154
|
+
if (primaryIsMonthly) {
|
|
155
|
+
if (primaryPercent !== undefined) {
|
|
156
|
+
quota.monthlyPercent = primaryPercent;
|
|
157
|
+
if (primaryResetAt !== undefined) quota.monthlyResetAt = primaryResetAt;
|
|
158
|
+
}
|
|
159
|
+
if (secondaryPercent !== undefined) {
|
|
160
|
+
quota.weeklyPercent = secondaryPercent;
|
|
161
|
+
if (secondaryResetAt !== undefined) quota.weeklyResetAt = secondaryResetAt;
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
const weeklyPercent = primaryPercent ?? secondaryPercent;
|
|
165
|
+
const weeklyResetAt = primaryPercent !== undefined
|
|
166
|
+
? primaryResetAt
|
|
167
|
+
: secondaryResetAt;
|
|
168
|
+
if (weeklyPercent !== undefined) {
|
|
169
|
+
quota.weeklyPercent = weeklyPercent;
|
|
170
|
+
if (weeklyResetAt !== undefined) quota.weeklyResetAt = weeklyResetAt;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (tertiaryPercent !== undefined && quota.monthlyPercent === undefined) {
|
|
175
|
+
quota.monthlyPercent = tertiaryPercent;
|
|
176
|
+
if (tertiaryResetAt !== undefined) quota.monthlyResetAt = tertiaryResetAt;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return hasKnownQuotaValue(quota) ? quota : null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function applyAccountQuotaFromUpstreamHeaders(accountId: string, headers: Headers): void {
|
|
183
|
+
const quota = parseUpstreamQuotaHeaders(headers);
|
|
184
|
+
if (!quota) return;
|
|
185
|
+
setAccountQuotaFromParsed(accountId, quota);
|
|
186
|
+
}
|
|
187
|
+
|
|
68
188
|
export function updateAccountQuota(
|
|
69
189
|
accountId: string,
|
|
70
190
|
weekly: unknown,
|