@bitkyc08/opencodex 2.39.0 → 2.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -21
- package/gui/dist/assets/index-BHe2rl_C.js +112 -0
- package/gui/dist/assets/index-CJSb3HPe.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/cursor/catalog.ts +140 -7
- package/src/adapters/cursor/discovery.ts +79 -87
- package/src/adapters/cursor/effort-map.ts +11 -0
- package/src/adapters/cursor/request-builder.ts +31 -4
- package/src/adapters/cursor.ts +17 -0
- package/src/adapters/openai-chat.ts +95 -9
- package/src/adapters/openai-responses.ts +73 -9
- package/src/bridge.ts +58 -13
- package/src/claude/auth-mode.ts +6 -9
- package/src/claude/gateway-cache.ts +23 -3
- package/src/claude/model-info.ts +13 -2
- package/src/cli/access.ts +30 -0
- package/src/cli/account-extended.ts +19 -10
- package/src/cli/capabilities.ts +40 -1
- package/src/cli/claude-agent-startup-sync.ts +3 -0
- package/src/cli/claude.ts +134 -36
- package/src/cli/connect.ts +226 -0
- package/src/cli/dispatch.ts +126 -21
- package/src/cli/gui-pair-client.ts +170 -0
- package/src/cli/gui.ts +87 -0
- package/src/cli/help.ts +4 -1
- package/src/cli/index.ts +76 -22
- package/src/cli/models-runtime-subcommands.ts +34 -0
- package/src/cli/models-runtime.ts +4 -0
- package/src/cli/models.ts +2 -1
- package/src/cli/opencode.ts +2 -1
- package/src/cli/provider-runtime.ts +31 -1
- package/src/cli/registry.ts +32 -2
- package/src/cli/runtime-api.ts +10 -1
- package/src/cli/status.ts +26 -0
- package/src/cli/system-command.ts +9 -3
- package/src/client/connect.ts +650 -0
- package/src/client/hub-client.ts +457 -0
- package/src/client/hub-relay.ts +288 -0
- package/src/client/machine-api.ts +139 -0
- package/src/client/machine-auth.ts +54 -0
- package/src/client/machine-listener.ts +143 -0
- package/src/client/runtime.ts +93 -0
- package/src/client/state.ts +175 -0
- package/src/clients/config-export.ts +16 -5
- package/src/codex/auth-api.ts +169 -15
- package/src/codex/auth-context.ts +61 -6
- package/src/codex/catalog/aggregation.ts +17 -13
- package/src/codex/catalog/metadata.ts +28 -0
- package/src/codex/catalog/provider-fetch.ts +38 -5
- package/src/codex/catalog.ts +1 -1
- package/src/codex/desired-state.ts +13 -3
- package/src/codex/inject.ts +212 -31
- package/src/codex/journal.ts +54 -6
- package/src/codex/main-account.ts +105 -33
- package/src/codex/native-main-claim.ts +23 -1
- package/src/codex/native-residue.ts +7 -0
- package/src/codex/reset-credit-auto-redeem.ts +237 -0
- package/src/combos/request.ts +15 -3
- package/src/combos/types.ts +12 -0
- package/src/config/paths.ts +15 -1
- package/src/config/provider-validation.ts +40 -0
- package/src/config.ts +447 -3
- package/src/generated/compatibility-version.json +224 -108
- package/src/images/artifacts.ts +73 -37
- package/src/images/fulfill.ts +6 -1
- package/src/images/index.ts +1 -1
- package/src/images/plan.ts +16 -2
- package/src/images/synthetic-tool.ts +5 -0
- package/src/images/xai-client.ts +34 -1
- package/src/integrations/cursor-detect.ts +133 -0
- package/src/integrations/cursor-seen.ts +31 -0
- package/src/lib/errors.ts +18 -0
- package/src/lib/gui-pair-capability.ts +104 -0
- package/src/lib/lab-live-route-production.ts +2 -1
- package/src/lib/process-control.ts +4 -3
- package/src/lib/service-secrets.ts +161 -1
- package/src/lib/windows-system-proxy.ts +115 -0
- package/src/oauth/index.ts +3 -2
- package/src/oauth/pool-settings-capability.ts +55 -0
- package/src/providers/api-keys.ts +3 -1
- package/src/providers/codex-capacity.ts +34 -4
- package/src/providers/derive.ts +10 -0
- package/src/providers/fastwire.ts +10 -2
- package/src/providers/key-store.ts +197 -0
- package/src/providers/model-rename-migration.ts +3 -0
- package/src/providers/openai-sidecar.ts +3 -3
- package/src/providers/quota.ts +91 -42
- package/src/providers/registry.ts +33 -3
- package/src/reasoning-effort.ts +32 -0
- package/src/remote/protocol.ts +109 -0
- package/src/responses/citation-markers.ts +101 -0
- package/src/responses/parser.ts +43 -3
- package/src/responses/spill-store.ts +9 -4
- package/src/router.ts +72 -8
- package/src/server/auth-cors.ts +37 -1
- package/src/server/gui-session.ts +449 -0
- package/src/server/gui-static.ts +24 -7
- package/src/server/images.ts +237 -10
- package/src/server/index.ts +356 -30
- package/src/server/management/agent-settings-routes.ts +7 -1
- package/src/server/management/api-key-rotation.ts +74 -0
- package/src/server/management/combo-routes.ts +18 -6
- package/src/server/management/config-routes.ts +22 -3
- package/src/server/management/context.ts +5 -1
- package/src/server/management/cursor-integration-routes.ts +98 -0
- package/src/server/management/logs-usage-routes.ts +2 -1
- package/src/server/management/model-routes.ts +79 -1
- package/src/server/management/model-rows.ts +23 -1
- package/src/server/management/oauth-account-routes.ts +135 -2
- package/src/server/management/provider-routes.ts +143 -9
- package/src/server/management/route-registry.ts +10 -0
- package/src/server/management/session-routes.ts +13 -0
- package/src/server/management/system-routes.ts +13 -1
- package/src/server/management-api.ts +8 -3
- package/src/server/management-auth.ts +124 -82
- package/src/server/models-capabilities.ts +124 -0
- package/src/server/ports.ts +7 -0
- package/src/server/proxy-liveness.ts +7 -0
- package/src/server/request-log.ts +11 -5
- package/src/server/responses/agent-task-recovery.ts +1 -0
- package/src/server/responses/codex-auth-error.ts +3 -1
- package/src/server/responses/collaboration.ts +26 -1
- package/src/server/responses/combo-stream-preflight.ts +25 -6
- package/src/server/responses/compact.ts +32 -12
- package/src/server/responses/context-overflow.ts +49 -0
- package/src/server/responses/core.ts +196 -12
- package/src/server/responses/fetch-helpers.ts +2 -1
- package/src/server/responses/outbound-body-guard.ts +110 -0
- package/src/server/responses/ws-upstream.ts +87 -6
- package/src/server/responses-self-named-namespace-scrub.ts +181 -0
- package/src/server/system-env.ts +78 -25
- package/src/service.ts +218 -42
- package/src/types/config.ts +134 -0
- package/src/types/provider.ts +54 -1
- package/src/types/request.ts +2 -0
- package/src/types.ts +5 -0
- package/src/update/job.ts +33 -5
- package/src/usage/cost.ts +32 -1
- package/src/usage/expected-prices.ts +16 -0
- package/src/usage/log.ts +6 -2
- package/src/usage/summary.ts +48 -6
- package/gui/dist/assets/index-D-lchsPw.js +0 -112
- package/gui/dist/assets/index-uvENYLin.css +0 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { OcxConfig } from "../types/config";
|
|
2
|
+
|
|
3
|
+
export const REMOTE_HUB_PROTOCOL = 1;
|
|
4
|
+
export const MINIMUM_REMOTE_CLIENT_PROTOCOL = 1;
|
|
5
|
+
|
|
6
|
+
export interface RemoteReadyMetadata {
|
|
7
|
+
protocol: number;
|
|
8
|
+
minimumClientProtocol: number;
|
|
9
|
+
managementUrl: string;
|
|
10
|
+
features?: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type RemoteProtocolCompatibility =
|
|
14
|
+
| { ok: true; metadata: RemoteReadyMetadata; features: Set<string> }
|
|
15
|
+
| { ok: false; reason: "invalid" | "hub-too-new" | "hub-too-old"; message: string };
|
|
16
|
+
|
|
17
|
+
const INVALID_REMOTE_PROTOCOL_MESSAGE =
|
|
18
|
+
"OpenCodex hub returned invalid remote protocol metadata; upgrade or repair ocx on the hub.";
|
|
19
|
+
|
|
20
|
+
function positiveSafeInteger(value: unknown): value is number {
|
|
21
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function managementOrigin(value: unknown): string | null {
|
|
25
|
+
if (typeof value !== "string") return null;
|
|
26
|
+
try {
|
|
27
|
+
const parsed = new URL(value);
|
|
28
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
29
|
+
if (parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) return null;
|
|
30
|
+
return parsed.origin;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function observedManagementOrigin(req: Request): string | null {
|
|
37
|
+
try {
|
|
38
|
+
const requestUrl = new URL(req.url);
|
|
39
|
+
const host = req.headers.get("Host") ?? requestUrl.host;
|
|
40
|
+
return managementOrigin(`${requestUrl.protocol}//${host}`);
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function readyProtocolMetadata(config: OcxConfig, req: Request): RemoteReadyMetadata {
|
|
47
|
+
const configured = config.runtimeRole === "hub"
|
|
48
|
+
? managementOrigin(config.hub?.managementPublicOrigin)
|
|
49
|
+
: null;
|
|
50
|
+
const managementUrl = configured ?? observedManagementOrigin(req);
|
|
51
|
+
if (!managementUrl) throw new Error("Readiness request does not have an HTTP(S) management origin");
|
|
52
|
+
return {
|
|
53
|
+
protocol: REMOTE_HUB_PROTOCOL,
|
|
54
|
+
minimumClientProtocol: MINIMUM_REMOTE_CLIENT_PROTOCOL,
|
|
55
|
+
managementUrl,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parseRemoteReadyMetadata(value: unknown): RemoteReadyMetadata | null {
|
|
60
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
61
|
+
const raw = value as Record<string, unknown>;
|
|
62
|
+
if (!positiveSafeInteger(raw.protocol) || !positiveSafeInteger(raw.minimumClientProtocol)) return null;
|
|
63
|
+
if (raw.minimumClientProtocol > raw.protocol) return null;
|
|
64
|
+
const parsedManagementOrigin = managementOrigin(raw.managementUrl);
|
|
65
|
+
if (!parsedManagementOrigin) return null;
|
|
66
|
+
const features = raw.features;
|
|
67
|
+
if (features !== undefined && (
|
|
68
|
+
!Array.isArray(features)
|
|
69
|
+
|| features.length > 64
|
|
70
|
+
|| features.some(feature => typeof feature !== "string" || !feature || feature.length > 80 || /[\x00-\x1f\x7f]/.test(feature))
|
|
71
|
+
|| new Set(features).size !== features.length
|
|
72
|
+
)) return null;
|
|
73
|
+
return {
|
|
74
|
+
protocol: raw.protocol,
|
|
75
|
+
minimumClientProtocol: raw.minimumClientProtocol,
|
|
76
|
+
managementUrl: parsedManagementOrigin,
|
|
77
|
+
...(Array.isArray(features) ? { features: [...features] as string[] } : {}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function checkRemoteProtocolCompatibility(
|
|
82
|
+
value: unknown,
|
|
83
|
+
client: { protocol: number; minimumHubProtocol: number; features?: readonly string[] } = {
|
|
84
|
+
protocol: REMOTE_HUB_PROTOCOL,
|
|
85
|
+
minimumHubProtocol: MINIMUM_REMOTE_CLIENT_PROTOCOL,
|
|
86
|
+
},
|
|
87
|
+
): RemoteProtocolCompatibility {
|
|
88
|
+
const metadata = parseRemoteReadyMetadata(value);
|
|
89
|
+
if (!metadata || !positiveSafeInteger(client.protocol) || !positiveSafeInteger(client.minimumHubProtocol)) {
|
|
90
|
+
return { ok: false, reason: "invalid", message: INVALID_REMOTE_PROTOCOL_MESSAGE };
|
|
91
|
+
}
|
|
92
|
+
if (client.protocol < metadata.minimumClientProtocol) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
reason: "hub-too-new",
|
|
96
|
+
message: `OpenCodex hub requires remote protocol ${metadata.minimumClientProtocol}; this client supports protocol ${client.protocol}. Upgrade ocx on this client.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
if (metadata.protocol < client.minimumHubProtocol) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
reason: "hub-too-old",
|
|
103
|
+
message: `OpenCodex hub provides remote protocol ${metadata.protocol}; this client requires at least ${client.minimumHubProtocol}. Upgrade ocx on the hub.`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const supported = new Set(client.features ?? []);
|
|
107
|
+
const features = new Set((metadata.features ?? []).filter(feature => supported.has(feature)));
|
|
108
|
+
return { ok: true, metadata, features };
|
|
109
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatGPT-backend citation markers.
|
|
3
|
+
*
|
|
4
|
+
* The ChatGPT backend delimits inline citations with Unicode private-use characters:
|
|
5
|
+
*
|
|
6
|
+
* \uE200 cite \uE202 turn1view0 \uE202 turn1view1 \uE201
|
|
7
|
+
*
|
|
8
|
+
* The desktop client renders that as source chips. The Codex TUI does not: it prints the
|
|
9
|
+
* codepoints literally, so the user sees "citeturn1view0turn1view1" in the answer and in
|
|
10
|
+
* the saved transcript (#3150).
|
|
11
|
+
*
|
|
12
|
+
* OpenCodex neither produces nor understands this grammar — it arrives as ordinary
|
|
13
|
+
* assistant text from a ChatGPT-derived backend (GitHub Copilot in the report). The proxy
|
|
14
|
+
* is the last place that can remove it before a client that cannot render it.
|
|
15
|
+
*
|
|
16
|
+
* Strip, do not translate. The `turnNviewN` ids are turn-scoped and opaque, and the
|
|
17
|
+
* response carries no mapping from them to a URL, so there is nothing to convert them
|
|
18
|
+
* into. Structured `url_citation` annotations are a separate path and are untouched.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Opens a citation span. */
|
|
22
|
+
export const CITATION_MARKER_START = "\uE200";
|
|
23
|
+
/** Separates the `cite` keyword and each source reference inside a span. */
|
|
24
|
+
export const CITATION_MARKER_SEPARATOR = "\uE202";
|
|
25
|
+
/** Closes a citation span. */
|
|
26
|
+
export const CITATION_MARKER_END = "\uE201";
|
|
27
|
+
|
|
28
|
+
/** True when the text contains any of the three delimiters. Cheap pre-check. */
|
|
29
|
+
export function hasCitationMarker(text: string): boolean {
|
|
30
|
+
return text.includes(CITATION_MARKER_START)
|
|
31
|
+
|| text.includes(CITATION_MARKER_SEPARATOR)
|
|
32
|
+
|| text.includes(CITATION_MARKER_END);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Remove every complete `START … END` span from a whole string.
|
|
37
|
+
*
|
|
38
|
+
* A START with no END is left alone rather than truncating the remainder: an unterminated
|
|
39
|
+
* marker is malformed input, and dropping everything after it would delete real answer
|
|
40
|
+
* text. A stray SEPARATOR or END outside a span is also left alone for the same reason —
|
|
41
|
+
* this function only removes what it can prove is a citation span.
|
|
42
|
+
*/
|
|
43
|
+
export function stripCitationMarkers(text: string): string {
|
|
44
|
+
if (!text.includes(CITATION_MARKER_START)) return text;
|
|
45
|
+
let out = "";
|
|
46
|
+
let index = 0;
|
|
47
|
+
for (;;) {
|
|
48
|
+
const start = text.indexOf(CITATION_MARKER_START, index);
|
|
49
|
+
if (start === -1) {
|
|
50
|
+
out += text.slice(index);
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
const end = text.indexOf(CITATION_MARKER_END, start + 1);
|
|
54
|
+
if (end === -1) {
|
|
55
|
+
// Unterminated: keep the rest verbatim.
|
|
56
|
+
out += text.slice(index);
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
out += text.slice(index, start);
|
|
60
|
+
index = end + 1;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CitationMarkerFilter {
|
|
65
|
+
/** Feed one streaming delta; returns the portion safe to emit now. */
|
|
66
|
+
push(delta: string): string;
|
|
67
|
+
/** Release anything still held when the message closes. */
|
|
68
|
+
flush(): string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Streaming filter.
|
|
73
|
+
*
|
|
74
|
+
* A marker can straddle a delta boundary — `\uE200cite` in one chunk and the rest in the
|
|
75
|
+
* next — so a stateless per-delta strip would emit the tail of a span it never recognized.
|
|
76
|
+
* This holds back the text from an unterminated START and releases it once the END arrives
|
|
77
|
+
* (removed) or the stream ends (verbatim, so nothing the model actually said is lost).
|
|
78
|
+
*/
|
|
79
|
+
export function createCitationMarkerFilter(): CitationMarkerFilter {
|
|
80
|
+
// Text from an open START that has not been terminated yet.
|
|
81
|
+
let held = "";
|
|
82
|
+
return {
|
|
83
|
+
push(delta: string): string {
|
|
84
|
+
const combined = held + delta;
|
|
85
|
+
held = "";
|
|
86
|
+
const start = combined.lastIndexOf(CITATION_MARKER_START);
|
|
87
|
+
if (start === -1) return stripCitationMarkers(combined);
|
|
88
|
+
const endAfterStart = combined.indexOf(CITATION_MARKER_END, start + 1);
|
|
89
|
+
if (endAfterStart !== -1) return stripCitationMarkers(combined);
|
|
90
|
+
// The trailing span is still open: emit everything before it, hold the rest.
|
|
91
|
+
held = combined.slice(start);
|
|
92
|
+
return stripCitationMarkers(combined.slice(0, start));
|
|
93
|
+
},
|
|
94
|
+
flush(): string {
|
|
95
|
+
const rest = held;
|
|
96
|
+
held = "";
|
|
97
|
+
return rest;
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
package/src/responses/parser.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { compactionItemToText, isCompactionItemType } from "./compaction";
|
|
|
19
19
|
import { previousResponseReplayPrefixLength } from "./state";
|
|
20
20
|
import { decodeReasoningEnvelope } from "./reasoning-envelope";
|
|
21
21
|
import { extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "../web-search/synthetic-tool";
|
|
22
|
-
import { extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME } from "../images/synthetic-tool";
|
|
22
|
+
import { buildImageTool, extractHostedImageGeneration, IMAGE_GEN_TOOL_NAME } from "../images/synthetic-tool";
|
|
23
23
|
import { toolSearchDescription, toolSearchParameters } from "./tool-search-compat";
|
|
24
24
|
|
|
25
25
|
function isObj(v: unknown): v is Record<string, unknown> {
|
|
@@ -165,6 +165,15 @@ function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined {
|
|
|
165
165
|
return { ...(isObj(raw) ? raw : {}), type: "object" };
|
|
166
166
|
};
|
|
167
167
|
const pushFn = (t: Record<string, unknown>, namespace?: string) => {
|
|
168
|
+
// Hosted image_generation already installed the synthetic root tool. A later
|
|
169
|
+
// ordinary root `image_gen` must not create a second un-namespaced identity.
|
|
170
|
+
if (
|
|
171
|
+
!namespace
|
|
172
|
+
&& t.name === IMAGE_GEN_TOOL_NAME
|
|
173
|
+
&& out.some(tool => tool.name === IMAGE_GEN_TOOL_NAME && !tool.namespace && tool.imageGeneration)
|
|
174
|
+
) {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
168
177
|
const tool: OcxTool = {
|
|
169
178
|
name: t.name as string,
|
|
170
179
|
description: (t.description as string) ?? "",
|
|
@@ -175,6 +184,16 @@ function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined {
|
|
|
175
184
|
out.push(tool);
|
|
176
185
|
};
|
|
177
186
|
const pushCustom = (t: Record<string, unknown>, namespace?: string) => {
|
|
187
|
+
// Hosted image_generation already installed the synthetic root tool. A later
|
|
188
|
+
// root custom `image_gen` would collide on the same wire name with a different
|
|
189
|
+
// `freeform` flag and throw `ambiguous tool catalog`.
|
|
190
|
+
if (
|
|
191
|
+
!namespace
|
|
192
|
+
&& t.name === IMAGE_GEN_TOOL_NAME
|
|
193
|
+
&& out.some(tool => tool.name === IMAGE_GEN_TOOL_NAME && !tool.namespace && tool.imageGeneration)
|
|
194
|
+
) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
178
197
|
// Freeform custom tools are lowered to a single string `input` because chat models cannot
|
|
179
198
|
// emit Responses grammar payloads directly. Keep tool-specific input guidance scoped to the
|
|
180
199
|
// tool that owns it: leaking apply_patch syntax into `exec` or another freeform tool teaches
|
|
@@ -224,6 +243,28 @@ function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined {
|
|
|
224
243
|
toolSearch: true,
|
|
225
244
|
});
|
|
226
245
|
}
|
|
246
|
+
else if (t.type === "image_generation" || t.type === "image_gen") {
|
|
247
|
+
// Keep Codex's image_gen visible to routed chat models. The hosted OpenAI tool
|
|
248
|
+
// cannot execute on Grok; the model still has to see a callable image_gen so
|
|
249
|
+
// Codex's client-side /v1/images request can fire and be relayed to xAI.
|
|
250
|
+
// Identity is the un-namespaced synthetic root (`imageGeneration: true`), not
|
|
251
|
+
// the bare name: a namespaced ordinary `image_gen` must not suppress it.
|
|
252
|
+
const synthetic = buildImageTool();
|
|
253
|
+
// Every un-namespaced `image_gen` collides on one wire name, so removing only
|
|
254
|
+
// the first leaves a second root behind and the catalog stays ambiguous.
|
|
255
|
+
// Drop all root collisions, keep namespaced entries, then insert exactly one
|
|
256
|
+
// synthetic root — at the earliest colliding position so declaration order is
|
|
257
|
+
// preserved for models that read the catalog positionally.
|
|
258
|
+
let insertAt = -1;
|
|
259
|
+
for (let i = out.length - 1; i >= 0; i -= 1) {
|
|
260
|
+
const tool = out[i]!;
|
|
261
|
+
if (tool.name !== IMAGE_GEN_TOOL_NAME || tool.namespace) continue;
|
|
262
|
+
out.splice(i, 1);
|
|
263
|
+
insertAt = i;
|
|
264
|
+
}
|
|
265
|
+
if (insertAt >= 0) out.splice(insertAt, 0, synthetic);
|
|
266
|
+
else out.push(synthetic);
|
|
267
|
+
}
|
|
227
268
|
else if (typeof t.name === "string" && t.type !== "web_search" && t.type !== "image_generation") {
|
|
228
269
|
// Any OTHER named tool (e.g. a native/computer-use tool type opencodex doesn't explicitly
|
|
229
270
|
// model) is client-executed — pass it through as a function so the routed model can read and
|
|
@@ -231,8 +272,7 @@ function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined {
|
|
|
231
272
|
// silently dropped, so the model never saw them.
|
|
232
273
|
pushFn(t);
|
|
233
274
|
}
|
|
234
|
-
//
|
|
235
|
-
// dropped — they're executed by OpenAI and can't be relayed to a routed chat model.
|
|
275
|
+
// Hosted web_search is still dropped here — the web-search sidecar re-injects it.
|
|
236
276
|
}
|
|
237
277
|
return out.length > 0 ? out : undefined;
|
|
238
278
|
}
|
|
@@ -215,7 +215,9 @@ function isErrno(error: unknown, code: string): boolean {
|
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
function canUseExclusiveCopyFallback(error: unknown): boolean {
|
|
218
|
-
|
|
218
|
+
// Same platform seam as harden(): a fixture pinned to the POSIX lane on a Windows host must
|
|
219
|
+
// see a link failure as a failure, not as a cue to copy.
|
|
220
|
+
return windowsSecretAclApplies() || ["EPERM", "EACCES", "ENOSYS", "ENOTSUP", "EOPNOTSUPP", "EXDEV"]
|
|
219
221
|
.some(code => isErrno(error, code));
|
|
220
222
|
}
|
|
221
223
|
|
|
@@ -235,7 +237,9 @@ function nextSpillHardenDeadlineMs(budget: SpillAclBudget | undefined): number |
|
|
|
235
237
|
}
|
|
236
238
|
|
|
237
239
|
function harden(path: string, mode: number, budget?: SpillAclBudget): void {
|
|
238
|
-
|
|
240
|
+
// One predicate for both lanes: the test seam that forces a platform must reach the
|
|
241
|
+
// sync harden too, or a fixture pinned to "linux" on a Windows host still spawns icacls.
|
|
242
|
+
const aclApplies = windowsSecretAclApplies();
|
|
239
243
|
try {
|
|
240
244
|
chmodSync(path, mode);
|
|
241
245
|
} catch {
|
|
@@ -384,7 +388,8 @@ function publishNoReplace(
|
|
|
384
388
|
else copyFileSync(tempPath, destinationPath, constants.COPYFILE_EXCL);
|
|
385
389
|
copied = true;
|
|
386
390
|
harden(destinationPath, 0o600, budget);
|
|
387
|
-
|
|
391
|
+
// "r+": a read-only handle cannot be fsynced on Windows (EPERM).
|
|
392
|
+
const copyFd = openSync(destinationPath, "r+");
|
|
388
393
|
try {
|
|
389
394
|
if (spillIoForTest?.fsync) spillIoForTest.fsync(copyFd);
|
|
390
395
|
else fsyncSync(copyFd);
|
|
@@ -422,7 +427,7 @@ async function publishNoReplaceAsync(
|
|
|
422
427
|
copied = true;
|
|
423
428
|
await hardenAsync(destinationPath, 0o600, budget, retryTimedOutOnce);
|
|
424
429
|
throwIfPublicationSuperseded(publicationControl);
|
|
425
|
-
const copyFd = openSync(destinationPath, "r");
|
|
430
|
+
const copyFd = openSync(destinationPath, "r+");
|
|
426
431
|
try {
|
|
427
432
|
if (spillIoForTest?.fsync) spillIoForTest.fsync(copyFd);
|
|
428
433
|
else fsyncSync(copyFd);
|
package/src/router.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from "./combos";
|
|
10
10
|
import type { NormalizedComboConfig } from "./combos/types";
|
|
11
11
|
import { hasOwnProvider } from "./config/provider-name";
|
|
12
|
-
import {
|
|
12
|
+
import { resolveProviderApiKey } from "./providers/key-store";
|
|
13
13
|
import { assertProviderDestinationAllowed } from "./lib/destination-policy";
|
|
14
14
|
import { redactSecretString, redactUrlForLog } from "./lib/redact";
|
|
15
15
|
import {
|
|
@@ -265,8 +265,27 @@ function warnIfBaseUrlDiscarded(providerName: string, userBaseUrl: string, effec
|
|
|
265
265
|
);
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
/**
|
|
269
|
+
* One notice per provider id: the destination is an operator-configured key
|
|
270
|
+
* (never a caller-supplied string), so the log carries no request content.
|
|
271
|
+
*/
|
|
272
|
+
const compactionFallbackWarnings = new Set<string>();
|
|
273
|
+
function warnCompactionDefaultProviderFallbackOnce(providerName: string): void {
|
|
274
|
+
if (compactionFallbackWarnings.has(providerName)) return;
|
|
275
|
+
compactionFallbackWarnings.add(providerName);
|
|
276
|
+
console.warn(
|
|
277
|
+
`compaction: no enabled canonical "openai" provider for the native compaction model;`
|
|
278
|
+
+ ` summarizing through default provider "${providerName}" instead (#2901).`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Test seam: forget which compaction fallbacks have been announced. */
|
|
283
|
+
export function resetCompactionFallbackWarningsForTests(): void {
|
|
284
|
+
compactionFallbackWarnings.clear();
|
|
285
|
+
}
|
|
286
|
+
|
|
268
287
|
function usableResolvedApiKey(apiKey: string | undefined): string | undefined {
|
|
269
|
-
const resolved =
|
|
288
|
+
const resolved = resolveProviderApiKey(apiKey);
|
|
270
289
|
return typeof resolved === "string" && resolved.trim().length > 0 ? resolved : undefined;
|
|
271
290
|
}
|
|
272
291
|
|
|
@@ -328,6 +347,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
|
|
|
328
347
|
const preserveReasoningContentModels = mergeStringArray(registryEntry.preserveReasoningContentModels, provider.preserveReasoningContentModels);
|
|
329
348
|
const requiresReasoningPlaceholderModels = mergeStringArray(registryEntry.requiresReasoningPlaceholderModels, provider.requiresReasoningPlaceholderModels);
|
|
330
349
|
const reasoningSplitModels = mergeStringArray(registryEntry.reasoningSplitModels, provider.reasoningSplitModels);
|
|
350
|
+
const reasoningDetailsModels = mergeStringArray(registryEntry.reasoningDetailsModels, provider.reasoningDetailsModels);
|
|
331
351
|
const thinkingToggleModels = mergeStringArray(registryEntry.thinkingToggleModels, provider.thinkingToggleModels);
|
|
332
352
|
const thinkingBudgetModels = mergeStringArray(registryEntry.thinkingBudgetModels, provider.thinkingBudgetModels);
|
|
333
353
|
const registryBaseUrlIsTemplate = /\{[^}]*\}/.test(registryEntry.baseUrl);
|
|
@@ -451,6 +471,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
|
|
|
451
471
|
...(preserveReasoningContentModels ? { preserveReasoningContentModels } : {}),
|
|
452
472
|
...(requiresReasoningPlaceholderModels ? { requiresReasoningPlaceholderModels } : {}),
|
|
453
473
|
...(reasoningSplitModels ? { reasoningSplitModels } : {}),
|
|
474
|
+
...(reasoningDetailsModels ? { reasoningDetailsModels } : {}),
|
|
454
475
|
...(thinkingToggleModels ? { thinkingToggleModels } : {}),
|
|
455
476
|
...(thinkingBudgetModels ? { thinkingBudgetModels } : {}),
|
|
456
477
|
};
|
|
@@ -576,6 +597,7 @@ function routeModelInternal(
|
|
|
576
597
|
modelId: string,
|
|
577
598
|
bypassCombos: boolean,
|
|
578
599
|
policyEvidence?: PolicyRequestEvidence,
|
|
600
|
+
allowCompactionNativeFallback = false,
|
|
579
601
|
): RouteResult {
|
|
580
602
|
const slash = modelId.indexOf("/");
|
|
581
603
|
// Policy namespace is system-reserved: an explicit `policy/<id>` or a
|
|
@@ -701,6 +723,28 @@ function routeModelInternal(
|
|
|
701
723
|
if (provider && provider.disabled !== true) {
|
|
702
724
|
return routeResult(config, OPENAI_CODEX_PROVIDER_ID, provider, modelId, "native", "native-family");
|
|
703
725
|
}
|
|
726
|
+
// Codex chooses a bare native model for compaction even when the operator's
|
|
727
|
+
// ordinary route is a third-party provider. Keep the native reservation
|
|
728
|
+
// unchanged for ordinary turns; only the explicit compaction surface may
|
|
729
|
+
// use the configured default as its summarizer destination.
|
|
730
|
+
if (allowCompactionNativeFallback
|
|
731
|
+
&& config.defaultProvider !== OPENAI_CODEX_PROVIDER_ID
|
|
732
|
+
&& config.defaultProvider !== LEGACY_CHATGPT_PROVIDER_ID
|
|
733
|
+
&& config.defaultProvider !== LEGACY_OPENAI_MULTI_PROVIDER_ID
|
|
734
|
+
&& hasOwnProvider(config.providers, config.defaultProvider)) {
|
|
735
|
+
const defaultProvider = config.providers[config.defaultProvider];
|
|
736
|
+
if (defaultProvider.disabled !== true) {
|
|
737
|
+
warnCompactionDefaultProviderFallbackOnce(config.defaultProvider);
|
|
738
|
+
return routeResult(
|
|
739
|
+
config,
|
|
740
|
+
config.defaultProvider,
|
|
741
|
+
defaultProvider,
|
|
742
|
+
modelId,
|
|
743
|
+
"default-provider",
|
|
744
|
+
"compaction-default-provider",
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
704
748
|
throw new NoEnabledOpenAiProviderError(modelId);
|
|
705
749
|
}
|
|
706
750
|
|
|
@@ -753,12 +797,7 @@ function routeModelInternal(
|
|
|
753
797
|
throw new Error(`No provider configured for model: ${modelId}`);
|
|
754
798
|
}
|
|
755
799
|
|
|
756
|
-
|
|
757
|
-
config: OcxConfig,
|
|
758
|
-
modelId: string,
|
|
759
|
-
policyEvidence?: PolicyRequestEvidence,
|
|
760
|
-
): RouteResult {
|
|
761
|
-
const route = routeModelInternal(config, modelId, false, policyEvidence);
|
|
800
|
+
function routeWithDecisionTrace(config: OcxConfig, modelId: string, route: RouteResult): RouteResult {
|
|
762
801
|
// Policy routes carry a full evaluation trace already; never rebuild it.
|
|
763
802
|
if (route.routeDecision) return route;
|
|
764
803
|
const accountRef = route.codexAccountNamespace;
|
|
@@ -783,6 +822,31 @@ export function routeModel(
|
|
|
783
822
|
return route;
|
|
784
823
|
}
|
|
785
824
|
|
|
825
|
+
export function routeModel(
|
|
826
|
+
config: OcxConfig,
|
|
827
|
+
modelId: string,
|
|
828
|
+
policyEvidence?: PolicyRequestEvidence,
|
|
829
|
+
): RouteResult {
|
|
830
|
+
const route = routeModelInternal(config, modelId, false, policyEvidence);
|
|
831
|
+
return routeWithDecisionTrace(config, modelId, route);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Route a client-selected compaction model. Codex may send a bare native model
|
|
836
|
+
* even when its ordinary turns are configured for another provider; in that
|
|
837
|
+
* one case the configured default provider is a safe summarizer destination.
|
|
838
|
+
* This helper is intentionally separate so ordinary requests retain the
|
|
839
|
+
* canonical OpenAI reservation and exact account selectors remain fail-closed.
|
|
840
|
+
*/
|
|
841
|
+
export function routeCompactionModel(
|
|
842
|
+
config: OcxConfig,
|
|
843
|
+
modelId: string,
|
|
844
|
+
policyEvidence?: PolicyRequestEvidence,
|
|
845
|
+
): RouteResult {
|
|
846
|
+
const route = routeModelInternal(config, modelId, false, policyEvidence, true);
|
|
847
|
+
return routeWithDecisionTrace(config, modelId, route);
|
|
848
|
+
}
|
|
849
|
+
|
|
786
850
|
/** Resolve a combo-selected provider/model target without consulting public combo aliases again. */
|
|
787
851
|
export function routeConcreteModel(config: OcxConfig, modelId: string): RouteResult {
|
|
788
852
|
return routeModelInternal(config, modelId, true, undefined);
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -121,7 +121,29 @@ export function managementRequestOrigin(req: Request, config: OcxConfig): string
|
|
|
121
121
|
const host = req.headers.get("Host");
|
|
122
122
|
const parsedHost = parseHttpHost(host);
|
|
123
123
|
if (!host || !parsedHost) return null;
|
|
124
|
-
if (
|
|
124
|
+
if (isLoopbackHostname(parsedHost.hostname)) {
|
|
125
|
+
try {
|
|
126
|
+
const protocol = new URL(req.url).protocol;
|
|
127
|
+
if (protocol !== "http:" && protocol !== "https:") return null;
|
|
128
|
+
return new URL(`${protocol}//${host}`).origin;
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (!isApiAuthRequired(config)) return null;
|
|
134
|
+
if (config.runtimeRole === "hub" && config.hub?.managementPublicOrigin) {
|
|
135
|
+
try {
|
|
136
|
+
const configured = new URL(config.hub.managementPublicOrigin);
|
|
137
|
+
if (
|
|
138
|
+
(configured.protocol === "http:" || configured.protocol === "https:")
|
|
139
|
+
&& !configured.username
|
|
140
|
+
&& !configured.password
|
|
141
|
+
&& configured.pathname === "/"
|
|
142
|
+
&& !configured.search
|
|
143
|
+
&& !configured.hash
|
|
144
|
+
) return configured.origin;
|
|
145
|
+
} catch { /* malformed direct fixture: fall through to observed origin */ }
|
|
146
|
+
}
|
|
125
147
|
try {
|
|
126
148
|
const protocol = new URL(req.url).protocol;
|
|
127
149
|
if (protocol !== "http:" && protocol !== "https:") return null;
|
|
@@ -200,6 +222,7 @@ export function corsHeaders(req?: Request, config?: RequestPolicyView): Record<s
|
|
|
200
222
|
|
|
201
223
|
export function managementCorsHeaders(req?: Request, config?: OcxConfig): Record<string, string> {
|
|
202
224
|
const headers = corsHeaders();
|
|
225
|
+
headers["Access-Control-Allow-Headers"] = `${STATIC_ALLOWED_REQUEST_HEADERS}, X-OpenCodex-GUI-Origin, X-OpenCodex-CSRF-Token`;
|
|
203
226
|
const origin = req?.headers.get("Origin");
|
|
204
227
|
if (origin && req && config && isAllowedManagementOrigin(req, config)) {
|
|
205
228
|
headers["Access-Control-Allow-Origin"] = origin;
|
|
@@ -351,6 +374,10 @@ export function resolveDataPlaneAdmissionSecret(
|
|
|
351
374
|
if (secretEquals(actual, configuredApiAuthToken(config))) return { kind: "environment", source };
|
|
352
375
|
for (const k of config.apiKeys ?? []) {
|
|
353
376
|
if (secretEquals(actual, k.key)) return { kind: "configured", keyId: k.id, source };
|
|
377
|
+
const pending = k.pendingRotation;
|
|
378
|
+
if (pending && Date.parse(pending.expiresAt) > Date.now() && secretEquals(actual, pending.key)) {
|
|
379
|
+
return { kind: "configured", keyId: k.id, source };
|
|
380
|
+
}
|
|
354
381
|
}
|
|
355
382
|
return null;
|
|
356
383
|
}
|
|
@@ -667,6 +694,13 @@ export function providerManagementConfigError(name: unknown, provider: unknown):
|
|
|
667
694
|
"noStructuredOutputModels",
|
|
668
695
|
);
|
|
669
696
|
if (structuredOutputOptOutError) return `provider ${name} ${structuredOutputOptOutError}`;
|
|
697
|
+
const retainModelsError = nonBlankStringArrayConfigError(raw.retainModels, "retainModels");
|
|
698
|
+
if (retainModelsError) return `provider ${name} ${retainModelsError}`;
|
|
699
|
+
const toolReasoningOptOutError = nonBlankStringArrayConfigError(
|
|
700
|
+
raw.omitReasoningEffortWithToolsModels,
|
|
701
|
+
"omitReasoningEffortWithToolsModels",
|
|
702
|
+
);
|
|
703
|
+
if (toolReasoningOptOutError) return `provider ${name} ${toolReasoningOptOutError}`;
|
|
670
704
|
const openRouterError = openRouterRoutingConfigError(typed);
|
|
671
705
|
if (openRouterError) return `provider ${name} ${openRouterError}`;
|
|
672
706
|
const vercelError = vercelGatewayRoutingConfigError(typed);
|
|
@@ -760,6 +794,8 @@ export function safeConfigDTO(config: OcxConfig): unknown {
|
|
|
760
794
|
"noTopPModels",
|
|
761
795
|
"noPenaltyModels",
|
|
762
796
|
"noStructuredOutputModels",
|
|
797
|
+
"retainModels",
|
|
798
|
+
"omitReasoningEffortWithToolsModels",
|
|
763
799
|
"upstreamHttpVersion",
|
|
764
800
|
"autoToolChoiceOnlyModels",
|
|
765
801
|
"preserveReasoningContentModels",
|