@bitkyc08/opencodex 2.7.4 → 2.7.7

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.
@@ -16,8 +16,8 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-66dPs6l_.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-D7o1qwy-.css">
19
+ <script type="module" crossorigin src="/assets/index-DzEDGLZh.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-C0xVu72_.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.7.4",
3
+ "version": "2.7.7",
4
4
  "description": "Universal provider proxy for OpenAI Codex — use any LLM with Codex CLI/App/SDK",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -487,9 +487,37 @@ function toolsToAnthropicFormat(parsed: OcxParsedRequest, toolNames: { toWire: (
487
487
  return converted;
488
488
  }
489
489
 
490
+ // Codex multi-agent v2 stamps a Responses-only `encrypted: true` marker on
491
+ // collaboration tool schemas (openai/codex 5f4d06ef; issue #85). It is an
492
+ // annotation for the ChatGPT backend only. Anthropic input_schema is strict
493
+ // JSON Schema; strip the marker defensively everywhere it can appear as a
494
+ // schema keyword, while preserving properties literally named "encrypted".
495
+ const ENCRYPTED_MARKER_NAME_BAG_KEYS = new Set(["properties", "patternProperties", "$defs", "definitions"]);
496
+ const ENCRYPTED_MARKER_LITERAL_VALUE_KEYS = new Set(["const", "default", "enum", "examples"]);
497
+
498
+ function stripEncryptedMarker(node: unknown, inNameBag = false): unknown {
499
+ if (Array.isArray(node)) return node.map(item => stripEncryptedMarker(item));
500
+ if (!node || typeof node !== "object") return node;
501
+
502
+ const out: Record<string, unknown> = {};
503
+
504
+ for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
505
+ if (inNameBag) {
506
+ out[key] = stripEncryptedMarker(value);
507
+ } else if (key !== "encrypted") {
508
+ out[key] = ENCRYPTED_MARKER_LITERAL_VALUE_KEYS.has(key)
509
+ ? value
510
+ : stripEncryptedMarker(value, ENCRYPTED_MARKER_NAME_BAG_KEYS.has(key));
511
+ }
512
+ }
513
+
514
+ return out;
515
+ }
516
+
490
517
  function normalizeAnthropicInputSchema(schema: unknown): Record<string, unknown> {
491
- const obj = schema && typeof schema === "object" && !Array.isArray(schema)
492
- ? schema as Record<string, unknown>
518
+ const stripped = stripEncryptedMarker(schema);
519
+ const obj = stripped && typeof stripped === "object" && !Array.isArray(stripped)
520
+ ? stripped as Record<string, unknown>
493
521
  : {};
494
522
  // Anthropic rejects root-level missing type and oneOf/anyOf/allOf in input_schema.
495
523
  // Normalize the root only: ensure type:"object" + properties, flatten root composition
@@ -9,6 +9,12 @@ export interface IncomingMeta {
9
9
  export interface ProviderAdapter {
10
10
  name: string;
11
11
 
12
+ /**
13
+ * Convert an already-read provider HTTP error into client-safe text. This hook must be pure and
14
+ * return fully redacted output: callers may pass untrusted provider headers and payload text.
15
+ */
16
+ formatErrorBody?(status: number, headers: Headers, payloadText: string): string;
17
+
12
18
  /**
13
19
  * Build the upstream request. May be async: adapters that resolve a short-lived credential
14
20
  * (e.g. Vertex AI ADC token) return a Promise. Sync adapters return the object directly; callers
@@ -39,6 +45,10 @@ export interface AdapterRequest {
39
45
  }
40
46
 
41
47
  export interface AdapterFetchContext {
48
+ /** Remains attached to the returned response body after the response headers arrive. */
42
49
  abortSignal?: AbortSignal;
50
+ /** Deadline for receiving response headers on each attempt, not for consuming the response body. */
43
51
  timeoutMs?: number;
52
+ /** Return final non-2xx responses untouched so the caller can own the error-body read. */
53
+ returnRawErrors?: boolean;
44
54
  }
@@ -1,5 +1,7 @@
1
1
  import type { AdapterFetchContext, AdapterRequest } from "./base";
2
2
  import { isQuotaExhaustedBody, retryableGoogleStatus, safeGoogleHttpErrorMessage } from "./google-errors";
3
+ import { clearableDeadline } from "../lib/abort";
4
+ import { readBoundedResponseBody } from "../lib/bounded-body";
3
5
  import { abortError, sleepWithAbort } from "../lib/upstream-retry";
4
6
 
5
7
  const GOOGLE_RETRY_ATTEMPTS = 3;
@@ -23,14 +25,28 @@ function retryDelayMs(attempt: number, headers?: Headers): number {
23
25
  return Math.floor(exp * (0.8 + Math.random() * 0.4));
24
26
  }
25
27
 
26
- function signalWithAttemptTimeout(parent: AbortSignal | undefined, timeoutMs: number): AbortSignal {
27
- const timeout = AbortSignal.timeout(timeoutMs);
28
- return parent ? AbortSignal.any([parent, timeout]) : timeout;
28
+ function cancelResponseBodyBestEffort(res: Response): void {
29
+ try {
30
+ const cancellation = res.body?.cancel();
31
+ if (cancellation) void cancellation.catch(() => {});
32
+ } catch {
33
+ // Cancellation is cleanup only; retries must not wait for or fail because of it.
34
+ }
29
35
  }
30
36
 
31
- async function normalizeFinalGoogleError(label: string, res: Response): Promise<Response> {
37
+ async function boundedBodyText(res: Response, signal?: AbortSignal): Promise<string> {
38
+ try {
39
+ const body = await readBoundedResponseBody(res, { signal });
40
+ return body.displaySafe ? body.text : "";
41
+ } catch (error) {
42
+ if (signal?.aborted) throw error;
43
+ return "";
44
+ }
45
+ }
46
+
47
+ async function normalizeFinalGoogleError(label: string, res: Response, signal?: AbortSignal): Promise<Response> {
32
48
  if (res.ok) return res;
33
- const payloadText = await res.clone().text().catch(() => "");
49
+ const payloadText = await boundedBodyText(res, signal);
34
50
  const headers = new Headers(res.headers);
35
51
  headers.delete("content-encoding");
36
52
  headers.delete("content-length");
@@ -51,17 +67,25 @@ export async function fetchGoogleWithRetry(label: string, request: AdapterReques
51
67
  for (let attempt = 0; attempt < GOOGLE_RETRY_ATTEMPTS; attempt++) {
52
68
  if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal);
53
69
  try {
54
- const res = await fetch(request.url, {
55
- method: request.method, headers: request.headers, body: request.body,
56
- signal: signalWithAttemptTimeout(ctx.abortSignal, timeoutMs),
57
- });
70
+ const attemptTimeout = clearableDeadline(timeoutMs, ctx.abortSignal);
71
+ let res: Response;
72
+ try {
73
+ res = await fetch(request.url, {
74
+ method: request.method, headers: request.headers, body: request.body,
75
+ signal: attemptTimeout.signal,
76
+ });
77
+ } finally {
78
+ // Only the header timer is cleared. The composed signal still contains the parent, so a
79
+ // caller abort after headers continue to cancel consumption of the returned response body.
80
+ attemptTimeout.clear();
81
+ }
58
82
  if (!retryableGoogleStatus(res.status) || attempt === GOOGLE_RETRY_ATTEMPTS - 1) {
59
- return normalizeFinalGoogleError(label, res);
83
+ return ctx.returnRawErrors ? res : normalizeFinalGoogleError(label, res, ctx.abortSignal);
60
84
  }
61
85
  // A 429 may be a transient rate limit (retry) or hard quota exhaustion (do NOT retry —
62
86
  // it won't recover for hours and burns retries). Peek the body to tell them apart.
63
- if (res.status === 429) {
64
- const peek = await res.clone().text().catch(() => "");
87
+ if (res.status === 429 && !ctx.returnRawErrors) {
88
+ const peek = await boundedBodyText(res, ctx.abortSignal);
65
89
  if (isQuotaExhaustedBody(peek)) {
66
90
  const headers = new Headers(res.headers);
67
91
  headers.delete("content-encoding");
@@ -71,7 +95,7 @@ export async function fetchGoogleWithRetry(label: string, request: AdapterReques
71
95
  });
72
96
  }
73
97
  }
74
- await res.body?.cancel().catch(() => {});
98
+ cancelResponseBodyBestEffort(res);
75
99
  await sleepWithAbort(retryDelayMs(attempt, res.headers), ctx.abortSignal);
76
100
  } catch (err) {
77
101
  if (ctx.abortSignal?.aborted) throw err;
@@ -4,11 +4,16 @@ type Schema = Record<string, unknown>;
4
4
  // emits full JSON-Schema (draft 2020-12) tool definitions, so passing them through verbatim makes
5
5
  // CCA reject the whole request with "Request contains an invalid argument" / "Unknown name ...".
6
6
  // Every keyword below was confirmed live against the Antigravity backend to trigger a 400.
7
+ // `encrypted` is Codex's Responses-only marker (openai/codex 5f4d06ef, PR #26210) stamped on v2
8
+ // collaboration tool schemas (spawn_agent/send_message/followup_task `message`); CCA rejects it
9
+ // with 400 "Unknown name \"encrypted\"" (issue #85). It is an annotation for the ChatGPT backend
10
+ // only, so dropping it never changes tool behavior.
7
11
  const DROPPED_SCHEMA_KEYS = new Set([
8
12
  "$schema", "$id", "$comment", "$ref", "$defs", "definitions",
9
13
  "examples", "patternProperties", "if", "then", "else",
10
14
  "uniqueItems", "additionalItems", "unevaluatedProperties", "unevaluatedItems",
11
15
  "dependentRequired", "dependentSchemas", "propertyNames", "contains",
16
+ "encrypted",
12
17
  ]);
13
18
 
14
19
  const MAX_DEREF_DEPTH = 64;
@@ -15,6 +15,7 @@ import { isAllowedToolChoice, namespacedToolName, toolAllowedByChoice } from "..
15
15
  import { contentPartsToText, parseDataUrl } from "./image";
16
16
  import { getVertexAccessToken } from "../lib/gcp-adc";
17
17
  import { fetchAntigravityWithRetry, fetchVertexWithRetry } from "./google-http";
18
+ import { safeAntigravityHttpErrorMessage, safeVertexHttpErrorMessage } from "./google-errors";
18
19
  import { isVertexTruncationReason, vertexTruncationErrorMessage } from "./google-truncation";
19
20
  import { ANTIGRAVITY_REQUEST_UA, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire";
20
21
  import { sanitizeGeminiToolParameters } from "./google-tool-schema";
@@ -206,6 +207,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
206
207
  ? {
207
208
  fetchResponse: (request: AdapterRequest, ctx?: AdapterFetchContext): Promise<Response> =>
208
209
  (provider.googleMode === "cloud-code-assist" ? fetchAntigravityWithRetry : fetchVertexWithRetry)(request, ctx),
210
+ formatErrorBody: (status: number, _headers: Headers, payloadText: string): string =>
211
+ (provider.googleMode === "cloud-code-assist" ? safeAntigravityHttpErrorMessage : safeVertexHttpErrorMessage)(status, payloadText),
209
212
  }
210
213
  : {}),
211
214
 
@@ -1,5 +1,7 @@
1
1
  import type { AdapterFetchContext, AdapterRequest } from "./base";
2
2
  import { safeKiroHttpErrorMessage } from "./kiro-errors";
3
+ import { clearableDeadline } from "../lib/abort";
4
+ import { readBoundedResponseBody } from "../lib/bounded-body";
3
5
  import { abortError, isConnectionResetError, sleepWithAbort } from "../lib/upstream-retry";
4
6
 
5
7
  const KIRO_RETRY_ATTEMPTS = 3;
@@ -27,18 +29,28 @@ function retryDelayMs(attempt: number, headers?: Headers): number {
27
29
  return Math.floor(exp * (0.8 + Math.random() * 0.4));
28
30
  }
29
31
 
30
- function signalWithAttemptTimeout(parent: AbortSignal | undefined, timeoutMs: number): AbortSignal {
31
- const timeout = AbortSignal.timeout(timeoutMs);
32
- return parent ? AbortSignal.any([parent, timeout]) : timeout;
32
+ function cancelResponseBodyBestEffort(res: Response): void {
33
+ try {
34
+ const cancellation = res.body?.cancel();
35
+ if (cancellation) void cancellation.catch(() => {});
36
+ } catch {
37
+ // Cancellation is cleanup only; retries must not wait for or fail because of it.
38
+ }
33
39
  }
34
40
 
35
41
  function retryableKiroFetchError(err: unknown): boolean {
36
42
  return isConnectionResetError(err) || (err instanceof Error && err.name === "TimeoutError");
37
43
  }
38
44
 
39
- async function normalizeFinalKiroHttpError(res: Response): Promise<Response> {
45
+ async function normalizeFinalKiroHttpError(res: Response, signal?: AbortSignal): Promise<Response> {
40
46
  if (res.ok) return res;
41
- const payloadText = await res.clone().text().catch(() => "");
47
+ let payloadText = "";
48
+ try {
49
+ const body = await readBoundedResponseBody(res, { signal });
50
+ if (body.displaySafe) payloadText = body.text;
51
+ } catch (error) {
52
+ if (signal?.aborted) throw error;
53
+ }
42
54
  const headers = new Headers(res.headers);
43
55
  headers.delete("content-encoding");
44
56
  headers.delete("content-length");
@@ -55,14 +67,22 @@ export async function fetchKiroWithRetry(request: AdapterRequest, ctx: AdapterFe
55
67
  for (let attempt = 0; attempt < KIRO_RETRY_ATTEMPTS; attempt++) {
56
68
  if (ctx.abortSignal?.aborted) throw abortError(ctx.abortSignal);
57
69
  try {
58
- const res = await fetch(request.url, {
59
- method: request.method,
60
- headers: request.headers,
61
- body: request.body,
62
- signal: signalWithAttemptTimeout(ctx.abortSignal, timeoutMs),
63
- });
64
- if (!retryableKiroStatus(res.status) || attempt === KIRO_RETRY_ATTEMPTS - 1) return normalizeFinalKiroHttpError(res);
65
- await res.body?.cancel().catch(() => {});
70
+ const attemptTimeout = clearableDeadline(timeoutMs, ctx.abortSignal);
71
+ let res: Response;
72
+ try {
73
+ res = await fetch(request.url, {
74
+ method: request.method,
75
+ headers: request.headers,
76
+ body: request.body,
77
+ signal: attemptTimeout.signal,
78
+ });
79
+ } finally {
80
+ attemptTimeout.clear();
81
+ }
82
+ if (!retryableKiroStatus(res.status) || attempt === KIRO_RETRY_ATTEMPTS - 1) {
83
+ return ctx.returnRawErrors ? res : normalizeFinalKiroHttpError(res, ctx.abortSignal);
84
+ }
85
+ cancelResponseBodyBestEffort(res);
66
86
  await sleepWithAbort(retryDelayMs(attempt, res.headers), ctx.abortSignal);
67
87
  } catch (err) {
68
88
  if (ctx.abortSignal?.aborted) throw err;
@@ -41,6 +41,10 @@ const KIRO_REJECTED_SCHEMA_KEYS = new Set([
41
41
  "contains",
42
42
  "unevaluatedProperties",
43
43
  "unevaluatedItems",
44
+ // Codex's Responses-only `encrypted: true` marker (openai/codex 5f4d06ef) stamped on v2
45
+ // collaboration tool schemas. Kiro/Bedrock validators reject a narrower, undocumented schema
46
+ // subset (issue #85 class); the marker is a ChatGPT-backend annotation with no meaning here.
47
+ "encrypted",
44
48
  ]);
45
49
 
46
50
  // Keys whose values are maps of *property/definition name -> schema* (not schema keywords). Their
@@ -5,7 +5,7 @@ import { resolveKiroApiRegion, resolveKiroProfileArn } from "../oauth/kiro";
5
5
  import { KIRO_MODEL_CONTEXT_WINDOWS, normalizeKiroModelId } from "../providers/kiro-models";
6
6
  import { modelRecordValue } from "../reasoning-effort";
7
7
  import { parseKiroEvent } from "./kiro-events";
8
- import { safeKiroErrorMessage } from "./kiro-errors";
8
+ import { safeKiroErrorMessage, safeKiroHttpErrorMessage } from "./kiro-errors";
9
9
  import { appendFallbackText, toolCallFallbackText, toolResultFallbackText } from "./kiro-tool-fallback";
10
10
  import { KiroThinkingParser } from "./kiro-thinking";
11
11
  import { isCompleteKiroToolInput, kiroTruncationErrorMessage } from "./kiro-truncation";
@@ -552,6 +552,10 @@ export function createKiroAdapter(provider: OcxProviderConfig): ProviderAdapter
552
552
  return fetchKiroWithRetry(request, ctx);
553
553
  },
554
554
 
555
+ formatErrorBody(status: number, headers: Headers, payloadText: string): string {
556
+ return safeKiroHttpErrorMessage(status, headers, payloadText);
557
+ },
558
+
555
559
  // Non-streaming path used by the web-search sidecar loop (loop.ts runs each iteration
556
560
  // non-streamed so it can inspect tool calls). CW only ever event-streams, so we drain the
557
561
  // same decoder into an array. Without this, any Codex request that includes the web_search
@@ -21,6 +21,7 @@ export const FORWARD_HEADERS = [
21
21
  "x-codex-turn-state",
22
22
  "x-codex-window-id",
23
23
  "x-oai-attestation",
24
+ "x-openai-subagent",
24
25
  "x-responsesapi-include-timing-metrics",
25
26
  ];
26
27
 
@@ -174,6 +175,46 @@ function stripPreviousResponseId(body: unknown, strip: boolean): unknown {
174
175
  return rest;
175
176
  }
176
177
 
178
+ /**
179
+ * Hosted tool types whose server-side function names collide with the client tools Codex
180
+ * declares for the matching app skill. Codex sends BOTH (e.g. hosted `image_generation` plus a
181
+ * declared `image_gen.imagegen` function/namespace tool for the imagegen skill). The ChatGPT
182
+ * backend tolerates the pair, but the platform `/v1/responses` rejects it:
183
+ * `Invalid Value: 'tools'. Function 'image_gen.imagegen' conflicts with a hosted tool in the
184
+ * same request.` Keyed hosted-type → conflicting client tool-name prefix; the hosted entry is
185
+ * dropped (the declared tool wins — Codex executes the skill client-side either way).
186
+ */
187
+ const HOSTED_TOOL_NAME_CONFLICTS: ReadonlyArray<{ hostedType: string; namePrefix: string }> = [
188
+ { hostedType: "image_generation", namePrefix: "image_gen" },
189
+ ];
190
+
191
+ /**
192
+ * Drop hosted tools whose names collide with declared function/namespace tools (see
193
+ * HOSTED_TOOL_NAME_CONFLICTS). Only applies on the API-key platform path: the ChatGPT backend
194
+ * ("forward" mode) accepts the pair, and stripping there would disable native imagegen. No-op
195
+ * (returns the original reference) when nothing matches.
196
+ */
197
+ function stripConflictingHostedTools(body: unknown): unknown {
198
+ if (!isPlainObject(body) || !Array.isArray(body.tools)) return body;
199
+ const allTools = body.tools;
200
+
201
+ const conflicting = HOSTED_TOOL_NAME_CONFLICTS.filter(c =>
202
+ allTools.some(t => {
203
+ if (!isPlainObject(t) || typeof t.name !== "string") return false;
204
+ if (t.type === "namespace") return t.name === c.namePrefix;
205
+ return t.name === c.namePrefix || t.name.startsWith(`${c.namePrefix}.`);
206
+ }),
207
+ );
208
+ if (conflicting.length === 0) return body;
209
+
210
+ const tools = allTools.filter(t => {
211
+ const type = isPlainObject(t) && typeof t.type === "string" ? t.type : undefined;
212
+ if (!type) return true;
213
+ return !conflicting.some(c => c.hostedType === type);
214
+ });
215
+ return tools.length === allTools.length ? body : { ...body, tools };
216
+ }
217
+
177
218
  /**
178
219
  * Remove hosted tool entries the target native slug rejects, so the OAuth-passthrough body never
179
220
  * carries a tool the upstream model 400s on. No-op (returns the original reference) when nothing
@@ -236,6 +277,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
236
277
  forward || parsed._previousResponseInputExpanded === true,
237
278
  );
238
279
  if (forward) outBody = repairOrphanedInputItems(outBody, unexpandedMiss);
280
+ else outBody = stripConflictingHostedTools(outBody);
239
281
  return {
240
282
  url,
241
283
  method: "POST",
@@ -303,6 +303,20 @@ type RawEntry = Record<string, unknown>;
303
303
  type RawCatalog = { models?: RawEntry[]; [k: string]: unknown };
304
304
  const JAWCODE_CATALOG_AUGMENT_PROVIDERS = new Set(["opencode-go"]);
305
305
 
306
+ /**
307
+ * Exact provider/model pairs whose discovery endpoint advertises them but whose inference backend
308
+ * rejects them. Apply this after live/static/metadata sources converge so no source can resurrect
309
+ * an uncallable picker row. Remove an entry once authenticated inference proves it usable again.
310
+ */
311
+ const ROUTED_MODEL_COMPATIBILITY_EXCLUSIONS = new Set([
312
+ // Issue #82: Zen Go /models advertises HY3, but Console Go rejects it as outside the lite list.
313
+ "opencode-go/hy3-preview",
314
+ ]);
315
+
316
+ function isRoutedModelCompatibilityExcluded(slug: string): boolean {
317
+ return ROUTED_MODEL_COMPATIBILITY_EXCLUSIONS.has(slug);
318
+ }
319
+
306
320
  /**
307
321
  * Image/video GENERATION model families. opencodex routes chat/coding models into Codex; media-
308
322
  * generation models (Grok image/video, DALL·E, Imagen, Sora, Veo, …) are useless to a coding agent
@@ -328,6 +342,7 @@ export function isMediaGenerationModelId(id: string): boolean {
328
342
  }
329
343
 
330
344
  function shouldExposeRoutedModel(model: CatalogModel): boolean {
345
+ if (isRoutedModelCompatibilityExcluded(`${model.provider}/${model.id}`)) return false;
331
346
  if (model.provider === "cursor" && model.id === "gemini-3-pro-image-preview") return true;
332
347
  return !isMediaGenerationModelId(model.id);
333
348
  }
@@ -554,6 +569,18 @@ function codexCommandCandidates(): string[] {
554
569
  return unique(candidates);
555
570
  }
556
571
 
572
+ /**
573
+ * Windows probe guard: only PE/batch launchers can be spawned as processes. Anything
574
+ * else pulled from the shim state (the extensionless Git-Bash sh backup
575
+ * `codex.opencodex-real`, `.ps1` scripts) risks falling through to the cmd/ShellExecute
576
+ * document-association path — Windows then OPENS the file in the user's editor
577
+ * (e.g. VS Code) on every `codex` launch instead of executing it.
578
+ */
579
+ export function isSpawnableCodexCandidate(path: string, platform: NodeJS.Platform = process.platform): boolean {
580
+ if (platform !== "win32") return true;
581
+ return /\.(cmd|bat|exe|com)$/i.test(path);
582
+ }
583
+
557
584
  function codexShimCommandCandidates(): string[] {
558
585
  try {
559
586
  const state = JSON.parse(readFileSync(join(getConfigDir(), "codex-shim.json"), "utf8")) as {
@@ -567,7 +594,7 @@ function codexShimCommandCandidates(): string[] {
567
594
  for (const file of files) {
568
595
  for (const value of [file.backupPath, file.originalPath, file.wrapperPath]) {
569
596
  if (typeof value !== "string" || value.length === 0) continue;
570
- if (process.platform === "win32" && value.toLowerCase().endsWith(".ps1")) continue;
597
+ if (!isSpawnableCodexCandidate(value)) continue;
571
598
  out.push(value);
572
599
  }
573
600
  }
@@ -1027,6 +1054,27 @@ function applyConfigHintsToCachedModels(name: string, prov: OcxProviderConfig, m
1027
1054
  return models.map(model => applyProviderConfigHints(name, prov, model, contextCap));
1028
1055
  }
1029
1056
 
1057
+ /**
1058
+ * TRUE when `liveId` is a dated release of the configured alias `configuredId`:
1059
+ * `<configuredId>-YYYYMMDD` (Anthropic's convention for superseded-but-callable models).
1060
+ */
1061
+ export function isDatedVariantId(liveId: string, configuredId: string): boolean {
1062
+ if (!liveId.startsWith(`${configuredId}-`)) return false;
1063
+ return /^\d{8}$/.test(liveId.slice(configuredId.length + 1));
1064
+ }
1065
+
1066
+ // Same-signature dedupe: Codex polls /v1/models frequently, and an unchanged drop list
1067
+ // repeated on every poll is pure noise. Warn once per provider until the id set changes.
1068
+ const lastDropWarnSignature = new Map<string, string>();
1069
+ function warnDroppedConfiguredIdsOnce(name: string, droppedConfiguredIds: string[]): void {
1070
+ const signature = [...droppedConfiguredIds].sort().join(",");
1071
+ if (lastDropWarnSignature.get(name) === signature) return;
1072
+ lastDropWarnSignature.set(name, signature);
1073
+ console.warn(
1074
+ `[opencodex] Provider model discovery for "${name}" omitted configured model ids; dropping them from the authoritative live catalog: ${droppedConfiguredIds.join(", ")}.`,
1075
+ );
1076
+ }
1077
+
1030
1078
  function isGlm52ModelId(id: string): boolean {
1031
1079
  const normalized = id.toLowerCase();
1032
1080
  return normalized === "glm-5.2" || normalized === "glm-5.2[1m]";
@@ -1133,15 +1181,28 @@ async function fetchProviderModels(name: string, prov: OcxProviderConfig, ttlMs:
1133
1181
  ...catalogHintsFromModelsApiItem(name, m),
1134
1182
  }, contextCap));
1135
1183
  const liveIds = new Set(live.map(m => m.id));
1136
- const droppedConfiguredIds = configured.filter(m => !liveIds.has(m.id)).map(m => m.id);
1184
+ // Dated-release aliases (Anthropic pattern): older models may appear in the live catalog
1185
+ // ONLY under their dated id (claude-haiku-4-5-20251001) while the config names the
1186
+ // API-valid alias (claude-haiku-4-5). Such aliases are real, callable models — keep them
1187
+ // in the authoritative catalog (alias id, hints from the dated live entry) instead of
1188
+ // dropping them and warning on every poll.
1189
+ const droppedConfiguredIds: string[] = [];
1190
+ for (const m of configured) {
1191
+ if (liveIds.has(m.id)) continue;
1192
+ const dated = live.find(l => isDatedVariantId(l.id, m.id));
1193
+ if (dated) {
1194
+ // Reapply config hints so alias-keyed overrides (modelContextWindows etc.) win.
1195
+ live.push(applyProviderConfigHints(name, prov, { ...dated, id: m.id }, contextCap));
1196
+ } else {
1197
+ droppedConfiguredIds.push(m.id);
1198
+ }
1199
+ }
1137
1200
  if (live.length === 0) {
1138
1201
  console.warn(
1139
1202
  `[opencodex] Provider model discovery for "${name}" returned an authoritative empty catalog; ${droppedConfiguredIds.length > 0 ? `dropping configured model ids: ${droppedConfiguredIds.join(", ")}` : "no models will be exposed"}.`,
1140
1203
  );
1141
1204
  } else if (droppedConfiguredIds.length > 0) {
1142
- console.warn(
1143
- `[opencodex] Provider model discovery for "${name}" omitted configured model ids; dropping them from the authoritative live catalog: ${droppedConfiguredIds.join(", ")}.`,
1144
- );
1205
+ warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds);
1145
1206
  }
1146
1207
  setCached(name, live);
1147
1208
  return live;
@@ -1329,9 +1390,10 @@ export function mergeCatalogEntriesForSync(
1329
1390
  }
1330
1391
 
1331
1392
  let finalRoutedEntries = routedEntries;
1332
- if (routedEntries.length === 0 && catalogModels.some(m => typeof m.slug === "string" && (m.slug as string).includes("/"))) {
1393
+ const preservingExistingRouted = routedEntries.length === 0
1394
+ && catalogModels.some(m => typeof m.slug === "string" && (m.slug as string).includes("/"));
1395
+ if (preservingExistingRouted) {
1333
1396
  finalRoutedEntries = catalogModels.filter(m => typeof m.slug === "string" && (m.slug as string).includes("/"));
1334
- console.warn(`[opencodex] catalog sync: routed model fetch returned empty; preserving ${finalRoutedEntries.length} existing routed entr${finalRoutedEntries.length === 1 ? "y" : "ies"} on disk.`);
1335
1397
  } else {
1336
1398
  const freshSlugs = new Set(routedEntries.flatMap(entry => typeof entry.slug === "string" ? [entry.slug] : []));
1337
1399
  const preservedForeignRouted = catalogModels.filter(m => {
@@ -1341,6 +1403,14 @@ export function mergeCatalogEntriesForSync(
1341
1403
  });
1342
1404
  finalRoutedEntries = [...routedEntries, ...preservedForeignRouted];
1343
1405
  }
1406
+ // Reapply final catalog policy to rows preserved from disk. Those rows bypass
1407
+ // gatherRoutedModels, so filtering only the freshly gathered list can resurrect an excluded id.
1408
+ finalRoutedEntries = finalRoutedEntries.filter(entry =>
1409
+ typeof entry.slug !== "string" || !isRoutedModelCompatibilityExcluded(entry.slug)
1410
+ );
1411
+ if (preservingExistingRouted) {
1412
+ console.warn(`[opencodex] catalog sync: routed model fetch returned empty; preserving ${finalRoutedEntries.length} existing routed entr${finalRoutedEntries.length === 1 ? "y" : "ies"} on disk.`);
1413
+ }
1344
1414
 
1345
1415
  const mergedEntries = [...native, ...finalRoutedEntries].map(m => {
1346
1416
  const normalized = normalizeServiceTiers(m);
package/src/lib/abort.ts CHANGED
@@ -3,6 +3,46 @@ export interface LinkedAbortSignal {
3
3
  cleanup: () => void;
4
4
  }
5
5
 
6
+ export interface ClearableDeadline {
7
+ /** Parent-linked signal passed to fetch; remains parent-linked after clear(). */
8
+ signal: AbortSignal;
9
+ /** Stable reason object used when this deadline wins the abort race. */
10
+ timeoutReason: DOMException;
11
+ /** True only when this deadline, rather than the parent, fired first. */
12
+ didExpire: () => boolean;
13
+ /** Clear only the timer. Never aborts the deadline controller or detaches the parent. */
14
+ clear: () => void;
15
+ }
16
+
17
+ /**
18
+ * Response-header deadline whose timer can be cleared without severing body-lifetime cancellation.
19
+ *
20
+ * `signalWithTimeout().cleanup()` intentionally removes its parent listener and is therefore suited
21
+ * to operations that are completely finished at cleanup. A fetch response body is different: once
22
+ * headers arrive the deadline ends, but the original parent/client signal must remain attached to
23
+ * the body. `AbortSignal.any()` supplies that direct lifetime link while `clear()` owns only the
24
+ * timer.
25
+ */
26
+ export function clearableDeadline(timeoutMs: number, parent?: AbortSignal): ClearableDeadline {
27
+ const deadline = new AbortController();
28
+ const timeoutReason = new DOMException("Timeout elapsed", "TimeoutError");
29
+ let timer: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {
30
+ timer = undefined;
31
+ if (!deadline.signal.aborted) deadline.abort(timeoutReason);
32
+ }, timeoutMs);
33
+ const signal = parent ? AbortSignal.any([parent, deadline.signal]) : deadline.signal;
34
+
35
+ return {
36
+ signal,
37
+ timeoutReason,
38
+ didExpire: () => signal.aborted && signal.reason === timeoutReason,
39
+ clear: () => {
40
+ if (timer !== undefined) clearTimeout(timer);
41
+ timer = undefined;
42
+ },
43
+ };
44
+ }
45
+
6
46
  export function signalWithTimeout(timeoutMs: number, parent?: AbortSignal): LinkedAbortSignal {
7
47
  const controller = new AbortController();
8
48
  const timeout = setTimeout(() => {