@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.
- package/README.ko.md +6 -3
- package/README.md +6 -4
- package/README.zh-CN.md +4 -3
- package/gui/dist/assets/index-C0xVu72_.css +1 -0
- package/gui/dist/assets/index-DzEDGLZh.js +40 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +30 -2
- package/src/adapters/base.ts +10 -0
- package/src/adapters/google-http.ts +37 -13
- package/src/adapters/google-tool-schema.ts +5 -0
- package/src/adapters/google.ts +3 -0
- package/src/adapters/kiro-retry.ts +33 -13
- package/src/adapters/kiro-tools.ts +4 -0
- package/src/adapters/kiro.ts +5 -1
- package/src/adapters/openai-responses.ts +42 -0
- package/src/codex/catalog.ts +77 -7
- package/src/lib/abort.ts +40 -0
- package/src/lib/bounded-body.ts +202 -0
- package/src/providers/registry.ts +8 -1
- package/src/reasoning-effort.ts +5 -0
- package/src/server/auth-cors.ts +16 -13
- package/src/server/effort-policy.ts +172 -0
- package/src/server/images.ts +218 -0
- package/src/server/index.ts +57 -2
- package/src/server/management-api.ts +28 -0
- package/src/server/responses.ts +29 -1
- package/src/server/search.ts +150 -0
- package/src/types.ts +37 -0
- package/src/web-search/index.ts +44 -10
- package/src/web-search/loop.ts +165 -112
- package/src/web-search/progress-stream.ts +329 -0
- package/gui/dist/assets/index-66dPs6l_.js +0 -40
- package/gui/dist/assets/index-D7o1qwy-.css +0 -1
package/gui/dist/index.html
CHANGED
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
20
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
@@ -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
|
|
492
|
-
|
|
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
|
package/src/adapters/base.ts
CHANGED
|
@@ -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
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
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
|
|
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
|
|
55
|
-
|
|
56
|
-
|
|
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.
|
|
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
|
-
|
|
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;
|
package/src/adapters/google.ts
CHANGED
|
@@ -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
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
package/src/adapters/kiro.ts
CHANGED
|
@@ -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",
|
package/src/codex/catalog.ts
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(() => {
|