@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.
Files changed (49) hide show
  1. package/README.md +4 -4
  2. package/gui/dist/assets/index-CMip1DzF.css +1 -0
  3. package/gui/dist/assets/index-cydcmbzC.js +52 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +2 -2
  6. package/src/adapters/cursor/arg-normalize.ts +23 -7
  7. package/src/adapters/cursor/live-transport.ts +26 -14
  8. package/src/adapters/cursor/native-exec-fs.ts +1 -1
  9. package/src/adapters/cursor/native-exec-network.ts +1 -1
  10. package/src/adapters/cursor/native-exec-shell.ts +1 -1
  11. package/src/adapters/cursor/protobuf-events.ts +72 -13
  12. package/src/adapters/cursor/protobuf-request.ts +82 -11
  13. package/src/adapters/cursor/request-builder.ts +35 -11
  14. package/src/adapters/cursor/tool-definitions.ts +175 -30
  15. package/src/adapters/openai-chat.ts +28 -7
  16. package/src/adapters/openai-responses.ts +150 -4
  17. package/src/bridge.ts +20 -1
  18. package/src/claude/outbound.ts +91 -6
  19. package/src/codex/auth-api.ts +12 -25
  20. package/src/codex/auth-context.ts +48 -3
  21. package/src/codex/catalog/provider-fetch.ts +56 -24
  22. package/src/codex/model-cache.ts +23 -0
  23. package/src/codex/quota.ts +120 -0
  24. package/src/codex/routing.ts +178 -9
  25. package/src/config.ts +56 -1
  26. package/src/providers/openai-sidecar.ts +8 -1
  27. package/src/providers/openai-tiers.ts +18 -0
  28. package/src/server/adapter-resolve.ts +24 -10
  29. package/src/server/auth-cors.ts +3 -0
  30. package/src/server/chat-completions.ts +4 -0
  31. package/src/server/claude-messages.ts +4 -0
  32. package/src/server/index.ts +3 -1
  33. package/src/server/live.ts +56 -0
  34. package/src/server/memory-watchdog.ts +1 -1
  35. package/src/server/responses/compact.ts +40 -10
  36. package/src/server/responses/core.ts +180 -26
  37. package/src/server/responses/terminal-guard.ts +230 -0
  38. package/src/service.ts +113 -30
  39. package/src/types.ts +52 -0
  40. package/src/usage/expected-prices.ts +12 -0
  41. package/src/web-search/anthropic-executor.ts +3 -1
  42. package/src/web-search/index.ts +7 -1
  43. package/src/web-search/loop.ts +17 -3
  44. package/README.ja.md +0 -445
  45. package/README.ko.md +0 -435
  46. package/README.ru.md +0 -486
  47. package/README.zh-CN.md +0 -411
  48. package/gui/dist/assets/index-B-cheu55.js +0 -52
  49. package/gui/dist/assets/index-oOZcqVmj.css +0 -1
@@ -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: Rec = queries.length > 1 ? { queries } : { query: queries[0] ?? query };
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: typeof item.name === "string" ? item.name : "",
319
+ name,
262
320
  input: {},
263
321
  },
264
322
  });
265
- open = { kind: "tool_use", index, itemId: typeof item.id === "string" ? item.id : undefined };
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") closeOpenBlock();
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: typeof raw.name === "string" ? raw.name : "",
449
- input,
533
+ name,
534
+ input: isClaudeWebSearchToolName(name) ? sanitizeWebSearchInput(input) : input,
450
535
  });
451
536
  break;
452
537
  }
@@ -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 { clearAccountQuota, getAccountQuota, parseUsageQuota, updateAccountQuota } from "./quota";
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
- updateAccountQuota(
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
- updateAccountQuota(
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
- updateAccountQuota(
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
- if (cooldownUntil) throw new CodexAccountCooldownError(accountId, cooldownUntil);
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) throw new CodexPoolAuthenticationError();
139
- return { kind: "main-pool", accountId, accessToken: token.accessToken, chatgptAccountId: token.chatgptAccountId };
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
- console.warn(
324
- `[opencodex] Provider model discovery for "${name}" was blocked by destination policy: ${destinationError} [urlClass=${urlClass}, fallback=${fallback}].`,
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
- console.warn(
333
- `[opencodex] Provider model discovery for "${name}" failed with HTTP ${res.status} [urlClass=${urlClass}, fallback=${fallback}].`,
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
- console.warn(
351
- `[opencodex] Provider model discovery for "${name}" ${diagnostic} [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
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
- console.warn(
361
- `[opencodex] Provider model discovery for "${name}" returned malformed 2xx data [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
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
- console.warn(
407
- `[opencodex] Provider model discovery for "${name}" threw ${error instanceof Error ? error.name : "unknown"} [urlClass=${urlClass}, fallback=${fallback}].`,
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 provider = config.providers[cm.provider] as OcxProviderConfigWithReasoningSummaries | undefined;
530
- const supportsReasoningSummaries = modelRecordValue(provider?.modelSupportsReasoningSummaries, cm.modelId);
531
- return {
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)));
@@ -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
  }
@@ -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,