@bitkyc08/opencodex 2.7.9-preview.20260712.2 → 2.7.9
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/gui/dist/assets/index-BAAFKwsh.js +40 -0
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/bridge.ts +6 -2
- package/src/claude/outbound.ts +77 -5
- package/src/oauth/index.ts +9 -7
- package/src/providers/registry.ts +5 -0
- package/src/providers/xai-transport.ts +89 -0
- package/src/server/claude-messages.ts +25 -1
- package/src/server/management-api.ts +99 -10
- package/src/server/responses.ts +16 -8
- package/src/types.ts +17 -2
- package/src/vision/anthropic-describe.ts +185 -0
- package/src/vision/index.ts +219 -10
- package/src/web-search/anthropic-executor.ts +187 -0
- package/src/web-search/executor.ts +4 -2
- package/src/web-search/index.ts +80 -18
- package/src/web-search/loop.ts +14 -2
- package/gui/dist/assets/index-SnN_1Qr9.js +0 -40
package/gui/dist/index.html
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
} catch (e) {}
|
|
17
17
|
})();
|
|
18
18
|
</script>
|
|
19
|
-
<script type="module" crossorigin src="/assets/index-
|
|
19
|
+
<script type="module" crossorigin src="/assets/index-BAAFKwsh.js"></script>
|
|
20
20
|
<link rel="stylesheet" crossorigin href="/assets/index-Cq8maiJf.css">
|
|
21
21
|
</head>
|
|
22
22
|
<body>
|
package/package.json
CHANGED
package/src/bridge.ts
CHANGED
|
@@ -379,11 +379,14 @@ export function bridgeToResponsesSSE(
|
|
|
379
379
|
// Finalize an open web-search cell. `status` is "completed" on a normal end, or "failed" when
|
|
380
380
|
// the stream terminates (error/incomplete) while a search was still in flight, so Codex never
|
|
381
381
|
// leaves a "Searching the web" spinner spinning forever.
|
|
382
|
-
|
|
382
|
+
// `sources` rides on the done item (additive field; codex-rs serde ignores unknown fields) so
|
|
383
|
+
// downstream translators (claude outbound) can fill web_search_tool_result content.
|
|
384
|
+
const closeCurrentWebSearch = (status: "completed" | "failed", queries: string[], sources?: { url: string; title?: string }[]) => {
|
|
383
385
|
if (!currentWebSearch) return;
|
|
384
386
|
const item = {
|
|
385
387
|
type: "web_search_call", id: currentWebSearch.itemId, status,
|
|
386
388
|
action: webSearchAction(queries),
|
|
389
|
+
...(sources && sources.length > 0 ? { sources } : {}),
|
|
387
390
|
};
|
|
388
391
|
emit("response.output_item.done", { output_index: currentWebSearch.outputIndex, item });
|
|
389
392
|
finishedItems.push(item as OutputItem);
|
|
@@ -569,7 +572,7 @@ export function bridgeToResponsesSSE(
|
|
|
569
572
|
});
|
|
570
573
|
currentWebSearch = { itemId: event.id, outputIndex };
|
|
571
574
|
}
|
|
572
|
-
closeCurrentWebSearch(event.status ?? "completed", event.queries);
|
|
575
|
+
closeCurrentWebSearch(event.status ?? "completed", event.queries, event.sources);
|
|
573
576
|
// Queue this search's sources for the next assistant message (dedup by URL).
|
|
574
577
|
if (event.sources) {
|
|
575
578
|
const seen = new Set(pendingWebSources.map(s => s.url));
|
|
@@ -861,6 +864,7 @@ export function buildResponseJSON(
|
|
|
861
864
|
output.push({
|
|
862
865
|
type: "web_search_call", id: e.id, status: e.status ?? "completed",
|
|
863
866
|
action: webSearchAction(e.queries),
|
|
867
|
+
...(e.sources && e.sources.length > 0 ? { sources: e.sources } : {}),
|
|
864
868
|
});
|
|
865
869
|
if (e.sources) {
|
|
866
870
|
const seen = new Set(pendingWebSources.map(s => s.url));
|
package/src/claude/outbound.ts
CHANGED
|
@@ -54,7 +54,7 @@ export function anthropicErrorResponse(status: number, message: string, type?: s
|
|
|
54
54
|
* subtract the full cache detail (devlog 070 — subtracting reads only inflated the
|
|
55
55
|
* non-cached input Claude Code displays by the write share).
|
|
56
56
|
*/
|
|
57
|
-
export function anthropicUsage(usage: unknown): Rec {
|
|
57
|
+
export function anthropicUsage(usage: unknown, webSearchRequests = 0): Rec {
|
|
58
58
|
const u = isRec(usage) ? usage : {};
|
|
59
59
|
const details = isRec(u.input_tokens_details) ? u.input_tokens_details : {};
|
|
60
60
|
const cached = typeof details.cached_tokens === "number" ? details.cached_tokens : 0;
|
|
@@ -66,6 +66,8 @@ export function anthropicUsage(usage: unknown): Rec {
|
|
|
66
66
|
output_tokens: output,
|
|
67
67
|
cache_read_input_tokens: cached,
|
|
68
68
|
cache_creation_input_tokens: cacheWrite,
|
|
69
|
+
// Only successful searches are billed/counted (Anthropic contract; Claude Code cost accounting).
|
|
70
|
+
...(webSearchRequests > 0 ? { server_tool_use: { web_search_requests: webSearchRequests } } : {}),
|
|
69
71
|
};
|
|
70
72
|
}
|
|
71
73
|
|
|
@@ -73,6 +75,37 @@ function sseFrame(name: string, data: Rec): string {
|
|
|
73
75
|
return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
74
76
|
}
|
|
75
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Map a Responses `web_search_call` item to its Anthropic pair: the server_tool_use
|
|
80
|
+
* input (query/queries) and the web_search_tool_result content (hits, or the error
|
|
81
|
+
* object when the search failed). Shared by the SSE and JSON translation paths.
|
|
82
|
+
*/
|
|
83
|
+
function webSearchPairFromItem(item: Rec): { id: string; input: Rec; resultContent: unknown; completed: boolean } {
|
|
84
|
+
const action = isRec(item.action) ? item.action : {};
|
|
85
|
+
const queries = Array.isArray(action.queries)
|
|
86
|
+
? action.queries.filter((q): q is string => typeof q === "string" && q.length > 0)
|
|
87
|
+
: [];
|
|
88
|
+
const query = typeof action.query === "string" ? action.query : "";
|
|
89
|
+
const input: Rec = queries.length > 1 ? { queries } : { query: queries[0] ?? query };
|
|
90
|
+
const completed = item.status !== "failed";
|
|
91
|
+
let resultContent: unknown;
|
|
92
|
+
if (completed) {
|
|
93
|
+
const hits: Rec[] = [];
|
|
94
|
+
if (Array.isArray(item.sources)) {
|
|
95
|
+
for (const s of item.sources) {
|
|
96
|
+
if (isRec(s) && typeof s.url === "string" && s.url.length > 0) {
|
|
97
|
+
hits.push({ type: "web_search_result", title: typeof s.title === "string" ? s.title : "", url: s.url });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
resultContent = hits;
|
|
102
|
+
} else {
|
|
103
|
+
resultContent = { type: "web_search_tool_result_error", error_code: "unavailable" };
|
|
104
|
+
}
|
|
105
|
+
const id = typeof item.id === "string" && item.id.length > 0 ? item.id : `srvtoolu_${uuid()}`;
|
|
106
|
+
return { id, input, resultContent, completed };
|
|
107
|
+
}
|
|
108
|
+
|
|
76
109
|
function messageSnapshot(model: string): Rec {
|
|
77
110
|
return {
|
|
78
111
|
id: `msg_${uuid()}`,
|
|
@@ -109,6 +142,7 @@ export function responsesSseToAnthropicSse(
|
|
|
109
142
|
let blockIndex = 0;
|
|
110
143
|
let open: OpenBlock | null = null;
|
|
111
144
|
let sawToolUse = false;
|
|
145
|
+
let webSearchRequests = 0;
|
|
112
146
|
let pingTimer: ReturnType<typeof setInterval> | undefined;
|
|
113
147
|
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
114
148
|
|
|
@@ -164,7 +198,7 @@ export function responsesSseToAnthropicSse(
|
|
|
164
198
|
emit("message_delta", {
|
|
165
199
|
type: "message_delta",
|
|
166
200
|
delta: { stop_reason: stopReason, stop_sequence: null },
|
|
167
|
-
usage: anthropicUsage(usage),
|
|
201
|
+
usage: anthropicUsage(usage, webSearchRequests),
|
|
168
202
|
});
|
|
169
203
|
emit("message_stop", { type: "message_stop" });
|
|
170
204
|
};
|
|
@@ -234,7 +268,35 @@ export function responsesSseToAnthropicSse(
|
|
|
234
268
|
}
|
|
235
269
|
case "response.output_item.done": {
|
|
236
270
|
const item = isRec(data.item) ? data.item : null;
|
|
237
|
-
if (!
|
|
271
|
+
if (!item) break;
|
|
272
|
+
// Server-side web search (native passthrough or sidecar bridge): translate the
|
|
273
|
+
// finished call into the Anthropic pair Claude Code natively parses —
|
|
274
|
+
// server_tool_use (query via input_json_delta) + web_search_tool_result.
|
|
275
|
+
// Never marks sawToolUse (stop_reason stays end_turn unless a real tool ran).
|
|
276
|
+
if (item.type === "web_search_call") {
|
|
277
|
+
ensureStarted();
|
|
278
|
+
closeOpenBlock();
|
|
279
|
+
const pair = webSearchPairFromItem(item);
|
|
280
|
+
const toolIndex = blockIndex++;
|
|
281
|
+
emit("content_block_start", {
|
|
282
|
+
type: "content_block_start", index: toolIndex,
|
|
283
|
+
content_block: { type: "server_tool_use", id: pair.id, name: "web_search" },
|
|
284
|
+
});
|
|
285
|
+
emit("content_block_delta", {
|
|
286
|
+
type: "content_block_delta", index: toolIndex,
|
|
287
|
+
delta: { type: "input_json_delta", partial_json: JSON.stringify(pair.input) },
|
|
288
|
+
});
|
|
289
|
+
emit("content_block_stop", { type: "content_block_stop", index: toolIndex });
|
|
290
|
+
const resultIndex = blockIndex++;
|
|
291
|
+
emit("content_block_start", {
|
|
292
|
+
type: "content_block_start", index: resultIndex,
|
|
293
|
+
content_block: { type: "web_search_tool_result", tool_use_id: pair.id, content: pair.resultContent },
|
|
294
|
+
});
|
|
295
|
+
emit("content_block_stop", { type: "content_block_stop", index: resultIndex });
|
|
296
|
+
if (pair.completed) webSearchRequests++;
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
if (!open) break;
|
|
238
300
|
// Close the matching open block (message/reasoning items close implicitly on
|
|
239
301
|
// the next block; function_call items must close here so tool input parses).
|
|
240
302
|
if (open.kind === "tool_use" && item.type === "function_call") closeOpenBlock();
|
|
@@ -322,6 +384,7 @@ export function responsesJsonToAnthropicMessage(json: unknown, model: string): R
|
|
|
322
384
|
const output = Array.isArray(body.output) ? body.output : [];
|
|
323
385
|
const content: Rec[] = [];
|
|
324
386
|
let sawToolUse = false;
|
|
387
|
+
let webSearchRequests = 0;
|
|
325
388
|
|
|
326
389
|
for (const raw of output) {
|
|
327
390
|
if (!isRec(raw)) continue;
|
|
@@ -366,6 +429,14 @@ export function responsesJsonToAnthropicMessage(json: unknown, model: string): R
|
|
|
366
429
|
});
|
|
367
430
|
break;
|
|
368
431
|
}
|
|
432
|
+
case "web_search_call": {
|
|
433
|
+
// Server-side search: emit the Anthropic pair. Does NOT set sawToolUse.
|
|
434
|
+
const pair = webSearchPairFromItem(raw);
|
|
435
|
+
content.push({ type: "server_tool_use", id: pair.id, name: "web_search", input: pair.input });
|
|
436
|
+
content.push({ type: "web_search_tool_result", tool_use_id: pair.id, content: pair.resultContent });
|
|
437
|
+
if (pair.completed) webSearchRequests++;
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
369
440
|
default:
|
|
370
441
|
break;
|
|
371
442
|
}
|
|
@@ -384,7 +455,7 @@ export function responsesJsonToAnthropicMessage(json: unknown, model: string): R
|
|
|
384
455
|
model,
|
|
385
456
|
stop_reason: stopReason,
|
|
386
457
|
stop_sequence: null,
|
|
387
|
-
usage: anthropicUsage(body.usage),
|
|
458
|
+
usage: anthropicUsage(body.usage, webSearchRequests),
|
|
388
459
|
};
|
|
389
460
|
}
|
|
390
461
|
|
|
@@ -407,7 +478,8 @@ export async function collectAnthropicMessage(stream: ReadableStream<Uint8Array>
|
|
|
407
478
|
|
|
408
479
|
const closeBlock = () => {
|
|
409
480
|
if (!openBlock) return;
|
|
410
|
-
|
|
481
|
+
// server_tool_use streams its query via input_json_delta exactly like tool_use (audit F3).
|
|
482
|
+
if (openBlock.type === "tool_use" || openBlock.type === "server_tool_use") {
|
|
411
483
|
try { openBlock.input = toolJson.length > 0 ? JSON.parse(toolJson) : {}; } catch { openBlock.input = {}; }
|
|
412
484
|
}
|
|
413
485
|
content.push(openBlock);
|
package/src/oauth/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity"
|
|
|
12
12
|
import { loginCursor, refreshCursorToken } from "./cursor";
|
|
13
13
|
import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
|
|
14
14
|
import { effectiveGoogleMode } from "../providers/registry";
|
|
15
|
+
import { resolveProviderTransport } from "../providers/xai-transport";
|
|
15
16
|
|
|
16
17
|
const REFRESH_SKEW_MS = 60_000;
|
|
17
18
|
const tokenRefreshes = new Map<string, Promise<string>>();
|
|
@@ -254,27 +255,28 @@ export async function resolveModelsAuthToken(name: string, prov: OcxProviderConf
|
|
|
254
255
|
* response.
|
|
255
256
|
*/
|
|
256
257
|
export function buildModelsRequest(prov: OcxProviderConfig, apiKey: string | undefined, providerName = ""): { url: string; headers: Record<string, string> } {
|
|
257
|
-
const
|
|
258
|
-
|
|
258
|
+
const effectiveProvider = resolveProviderTransport(providerName, prov);
|
|
259
|
+
const headers: Record<string, string> = { ...(effectiveProvider.headers ?? {}) };
|
|
260
|
+
if (effectiveGoogleMode(providerName, effectiveProvider) === "ai-studio") {
|
|
259
261
|
// Generative Language API: API key goes in x-goog-api-key (never Authorization: Bearer),
|
|
260
262
|
// models live under /v1beta (v1 misses preview models), and pageSize maxes at 1000 —
|
|
261
263
|
// enough to list everything without a pageToken loop. Vertex/antigravity keep the
|
|
262
264
|
// generic branch (they fall back to their static model lists).
|
|
263
265
|
if (apiKey) headers["x-goog-api-key"] = apiKey;
|
|
264
|
-
return { url: `${
|
|
266
|
+
return { url: `${effectiveProvider.baseUrl}/v1beta/models?pageSize=1000`, headers };
|
|
265
267
|
}
|
|
266
|
-
if (
|
|
268
|
+
if (effectiveProvider.adapter === "anthropic") {
|
|
267
269
|
headers["anthropic-version"] = "2023-06-01";
|
|
268
|
-
if (
|
|
270
|
+
if (effectiveProvider.authMode === "oauth") {
|
|
269
271
|
headers["anthropic-beta"] = ANTHROPIC_OAUTH_BETA;
|
|
270
272
|
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
|
271
273
|
} else if (apiKey) {
|
|
272
274
|
headers["x-api-key"] = apiKey;
|
|
273
275
|
}
|
|
274
|
-
return { url: `${
|
|
276
|
+
return { url: `${effectiveProvider.baseUrl}/v1/models?limit=1000`, headers };
|
|
275
277
|
}
|
|
276
278
|
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
|
277
|
-
return { url: `${
|
|
279
|
+
return { url: `${effectiveProvider.baseUrl}/models`, headers };
|
|
278
280
|
}
|
|
279
281
|
|
|
280
282
|
/**
|
|
@@ -236,6 +236,11 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
|
|
|
236
236
|
models: ["grok-4.5", "grok-4.3", "grok-4.20-multi-agent-0309", "grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning", "grok-build-0.1", "grok-composer-2.5-fast"],
|
|
237
237
|
defaultModel: "grok-4.5",
|
|
238
238
|
noReasoningModels: ["grok-4.20-0309-non-reasoning", "grok-build-0.1", "grok-composer-2.5-fast"],
|
|
239
|
+
// Replay assistant reasoning_content for grok reasoning models: xAI documents dropped
|
|
240
|
+
// reasoning_content as the top cause of prompt-cache misses on multi-turn conversations
|
|
241
|
+
// (docs.x.ai prompt-caching/multi-turn, verified 2026-07-13 — devlog/_plan/260713_grok_caching).
|
|
242
|
+
// Models that never emit reasoning simply have no thinking parts to replay (no-op).
|
|
243
|
+
preserveReasoningContentModels: ["grok-4.5", "grok-4.3", "grok-4.20-multi-agent-0309", "grok-4.20-0309-reasoning"],
|
|
239
244
|
// grok-4.5 reasoning is always-on with low/medium/high control (no off tier upstream).
|
|
240
245
|
modelReasoningEfforts: { "grok-4.5": ["low", "medium", "high"] },
|
|
241
246
|
modelContextWindows: {
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type { OcxProviderConfig } from "../types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* xAI account OAuth and xAI API keys share a bearer shape but not a billing
|
|
6
|
+
* transport. OAuth represents the Grok CLI subscription entitlement, while a
|
|
7
|
+
* key represents the API team. Keep the saved provider preset compatible with
|
|
8
|
+
* the dashboard's "Use an API key instead" switch and resolve the transport at
|
|
9
|
+
* request time.
|
|
10
|
+
*/
|
|
11
|
+
export const XAI_GROK_CLI_BASE_URL = "https://cli-chat-proxy.grok.com/v1";
|
|
12
|
+
|
|
13
|
+
/** Minimum-compatible official Grok CLI wire version verified with the proxy. */
|
|
14
|
+
export const XAI_GROK_CLIENT_VERSION = "0.2.93";
|
|
15
|
+
|
|
16
|
+
const XAI_GROK_CLI_HEADERS: Readonly<Record<string, string>> = {
|
|
17
|
+
"x-grok-client-identifier": "opencodex",
|
|
18
|
+
"x-grok-client-version": XAI_GROK_CLIENT_VERSION,
|
|
19
|
+
"x-xai-token-auth": "xai-grok-cli",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Sticky-routing hint for xAI's automatic prefix cache. xAI routes requests
|
|
24
|
+
* carrying the same `x-grok-conv-id` to the same server, which is where the
|
|
25
|
+
* prompt cache lives (docs.x.ai prompt-caching best-practices; verified
|
|
26
|
+
* 2026-07-13, devlog/_plan/260713_grok_caching). Codex clients send a stable
|
|
27
|
+
* per-conversation `prompt_cache_key`; hash it so the raw session id never
|
|
28
|
+
* leaves the proxy.
|
|
29
|
+
*/
|
|
30
|
+
export const XAI_CONV_ID_HEADER = "x-grok-conv-id";
|
|
31
|
+
|
|
32
|
+
function hasHeaderCaseInsensitive(headers: Record<string, string> | undefined, name: string): boolean {
|
|
33
|
+
if (!headers) return false;
|
|
34
|
+
const target = name.toLowerCase();
|
|
35
|
+
return Object.keys(headers).some(key => key.toLowerCase() === target);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Drop default entries the user already overrides under any header-name casing. */
|
|
39
|
+
function withoutUserOverridden(defaults: Readonly<Record<string, string>>, userHeaders: Record<string, string> | undefined): Record<string, string> {
|
|
40
|
+
if (!userHeaders) return { ...defaults };
|
|
41
|
+
const out: Record<string, string> = {};
|
|
42
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
43
|
+
if (!hasHeaderCaseInsensitive(userHeaders, key)) out[key] = value;
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function deriveXaiConvId(promptCacheKey: string): string {
|
|
49
|
+
return createHash("sha256").update(promptCacheKey).digest("hex").slice(0, 32);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve the effective xAI transport without mutating persisted config.
|
|
54
|
+
* User-provided headers are preserved and may advance the compatibility
|
|
55
|
+
* version without waiting for an opencodex release.
|
|
56
|
+
*
|
|
57
|
+
* `promptCacheKey` (the client's stable conversation key) additionally pins
|
|
58
|
+
* cache-affinity routing via `x-grok-conv-id` in BOTH auth modes. Blank or
|
|
59
|
+
* whitespace-only keys are ignored so unrelated requests can never collapse
|
|
60
|
+
* onto one shared conv id, and any user-configured header (any case) wins.
|
|
61
|
+
*/
|
|
62
|
+
export function resolveProviderTransport(
|
|
63
|
+
providerName: string,
|
|
64
|
+
provider: OcxProviderConfig,
|
|
65
|
+
promptCacheKey?: string,
|
|
66
|
+
): OcxProviderConfig {
|
|
67
|
+
if (providerName !== "xai") return provider;
|
|
68
|
+
const cacheKey = promptCacheKey?.trim();
|
|
69
|
+
const convIdHeaders: Record<string, string> =
|
|
70
|
+
cacheKey && !hasHeaderCaseInsensitive(provider.headers, XAI_CONV_ID_HEADER)
|
|
71
|
+
? { [XAI_CONV_ID_HEADER]: deriveXaiConvId(cacheKey) }
|
|
72
|
+
: {};
|
|
73
|
+
if (provider.authMode !== "oauth") {
|
|
74
|
+
if (Object.keys(convIdHeaders).length === 0) return provider;
|
|
75
|
+
return {
|
|
76
|
+
...provider,
|
|
77
|
+
headers: { ...convIdHeaders, ...(provider.headers ?? {}) },
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
...provider,
|
|
82
|
+
baseUrl: XAI_GROK_CLI_BASE_URL,
|
|
83
|
+
headers: {
|
|
84
|
+
...withoutUserOverridden(XAI_GROK_CLI_HEADERS, provider.headers),
|
|
85
|
+
...convIdHeaders,
|
|
86
|
+
...(provider.headers ?? {}),
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -31,6 +31,21 @@ function isRec(v: unknown): v is Rec {
|
|
|
31
31
|
return !!v && typeof v === "object" && !Array.isArray(v);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** Resolve Claude-only sidecar overrides without mutating the shared server config. */
|
|
35
|
+
export function buildClaudeReplayConfig(config: OcxConfig): OcxConfig {
|
|
36
|
+
return {
|
|
37
|
+
...config,
|
|
38
|
+
webSearchSidecar: {
|
|
39
|
+
...config.webSearchSidecar,
|
|
40
|
+
...config.claudeCode?.webSearchSidecar,
|
|
41
|
+
},
|
|
42
|
+
visionSidecar: {
|
|
43
|
+
...config.visionSidecar,
|
|
44
|
+
...config.claudeCode?.visionSidecar,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
34
49
|
function claudeInboundDisabled(config: OcxConfig): Response | null {
|
|
35
50
|
if (config.claudeCode?.enabled === false) {
|
|
36
51
|
return anthropicErrorResponse(403, "Claude inbound is disabled (GUI: Claude ON toggle / config.claudeCode.enabled)", "permission_error");
|
|
@@ -332,6 +347,15 @@ export async function handleClaudeMessages(
|
|
|
332
347
|
const value = req.headers.get(name);
|
|
333
348
|
if (value) headers.set(name, value);
|
|
334
349
|
}
|
|
350
|
+
if (!nativeRoute) {
|
|
351
|
+
// Routed replays need main ChatGPT auth so OpenAI-backed sidecars remain reachable.
|
|
352
|
+
const { getMainAccountToken } = await import("../codex/main-account");
|
|
353
|
+
const token = getMainAccountToken();
|
|
354
|
+
if (token) {
|
|
355
|
+
headers.set("authorization", `Bearer ${token.accessToken}`);
|
|
356
|
+
headers.set("chatgpt-account-id", token.chatgptAccountId);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
335
359
|
if (nativeRoute) {
|
|
336
360
|
// No forwarded ChatGPT auth exists on this surface. Attach the main codex login
|
|
337
361
|
// (read-only auth.json token); account-pool rotation still overrides downstream.
|
|
@@ -368,7 +392,7 @@ export async function handleClaudeMessages(
|
|
|
368
392
|
nativeLogged = true;
|
|
369
393
|
addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, meta);
|
|
370
394
|
};
|
|
371
|
-
const upstream = await handleResponses(internalReq, config, logCtx, {
|
|
395
|
+
const upstream = await handleResponses(internalReq, buildClaudeReplayConfig(config), logCtx, {
|
|
372
396
|
abortSignal: req.signal,
|
|
373
397
|
onNativePassthroughTerminal: status => finalizeNativeLog(httpStatusForTerminalStatus(status), { terminalStatus: status, closeReason: "terminal" }),
|
|
374
398
|
onNativePassthroughCancel: () => finalizeNativeLog(499, { closeReason: "client_cancel" }),
|
|
@@ -37,7 +37,7 @@ import {
|
|
|
37
37
|
setDebugSettings,
|
|
38
38
|
type DebugFlag,
|
|
39
39
|
} from "../lib/debug-settings";
|
|
40
|
-
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
40
|
+
import type { OcxClaudeCodeConfig, OcxConfig, OcxProviderConfig } from "../types";
|
|
41
41
|
import { drainAndShutdown } from "./lifecycle";
|
|
42
42
|
import { filterRequestLogs, getRequestLogEntries } from "./request-log";
|
|
43
43
|
import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "./auth-cors";
|
|
@@ -58,6 +58,11 @@ export interface ManagementApiDeps {
|
|
|
58
58
|
refreshCodexCatalog?: () => Promise<void>;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/** Narrow an unknown JSON value to a plain (non-array) object for strict request-body validation. */
|
|
62
|
+
function isPlainRecord(v: unknown): v is Record<string, unknown> {
|
|
63
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
64
|
+
}
|
|
65
|
+
|
|
61
66
|
function parseDebugLogQuery(url: URL): { after: number; limit: number } {
|
|
62
67
|
const after = Number(url.searchParams.get("after") ?? url.searchParams.get("since") ?? "0");
|
|
63
68
|
const limit = Number(url.searchParams.get("limit") ?? "500");
|
|
@@ -193,30 +198,78 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
193
198
|
const ws = config.webSearchSidecar ?? {};
|
|
194
199
|
const vs = config.visionSidecar ?? {};
|
|
195
200
|
return jsonResponse({
|
|
196
|
-
webSearch: { model: ws.model ?? "gpt-5.6-luna",
|
|
197
|
-
vision: {
|
|
201
|
+
webSearch: { model: ws.model ?? "gpt-5.6-luna", backend: ws.backend },
|
|
202
|
+
vision: {
|
|
203
|
+
model: vs.model ?? "gpt-5.6-luna",
|
|
204
|
+
backend: vs.backend,
|
|
205
|
+
maxDescriptionsPerTurn: vs.maxDescriptionsPerTurn,
|
|
206
|
+
},
|
|
198
207
|
});
|
|
199
208
|
}
|
|
200
209
|
|
|
201
210
|
if (url.pathname === "/api/sidecar-settings" && req.method === "PUT") {
|
|
202
|
-
let
|
|
203
|
-
try {
|
|
211
|
+
let raw: unknown;
|
|
212
|
+
try { raw = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
213
|
+
// Strict shape (review F2): reject non-object bodies and non-object sections instead of throwing
|
|
214
|
+
// on `null` or silently accepting arrays/strings as no-op updates.
|
|
215
|
+
if (!isPlainRecord(raw)) return jsonResponse({ error: "body must be a JSON object" }, 400);
|
|
216
|
+
if (raw.webSearch !== undefined && !isPlainRecord(raw.webSearch)) return jsonResponse({ error: "webSearch must be an object" }, 400);
|
|
217
|
+
if (raw.vision !== undefined && !isPlainRecord(raw.vision)) return jsonResponse({ error: "vision must be an object" }, 400);
|
|
218
|
+
const body = raw as {
|
|
219
|
+
webSearch?: { model?: unknown; backend?: unknown; reasoning?: unknown };
|
|
220
|
+
vision?: { model?: unknown; backend?: unknown; maxDescriptionsPerTurn?: unknown };
|
|
221
|
+
};
|
|
222
|
+
if (body.webSearch && body.webSearch.backend !== undefined && body.webSearch.backend !== null
|
|
223
|
+
&& body.webSearch.backend !== "openai" && body.webSearch.backend !== "anthropic") {
|
|
224
|
+
return jsonResponse({ error: "webSearch.backend must be openai, anthropic, or null" }, 400);
|
|
225
|
+
}
|
|
226
|
+
if (body.vision && body.vision.backend !== undefined
|
|
227
|
+
&& body.vision.backend !== null && body.vision.backend !== "openai" && body.vision.backend !== "anthropic") {
|
|
228
|
+
return jsonResponse({ error: "vision.backend must be openai, anthropic, or null" }, 400);
|
|
229
|
+
}
|
|
230
|
+
if (body.vision && body.vision.maxDescriptionsPerTurn !== undefined
|
|
231
|
+
&& (typeof body.vision.maxDescriptionsPerTurn !== "number"
|
|
232
|
+
|| !Number.isInteger(body.vision.maxDescriptionsPerTurn)
|
|
233
|
+
|| body.vision.maxDescriptionsPerTurn <= 0)) {
|
|
234
|
+
return jsonResponse({ error: "vision.maxDescriptionsPerTurn must be a positive integer" }, 400);
|
|
235
|
+
}
|
|
204
236
|
if (body.webSearch) {
|
|
205
237
|
config.webSearchSidecar = { ...config.webSearchSidecar };
|
|
206
|
-
if (typeof body.webSearch.model === "string")
|
|
238
|
+
if (typeof body.webSearch.model === "string") {
|
|
239
|
+
if (body.webSearch.model === "") delete config.webSearchSidecar.model;
|
|
240
|
+
else config.webSearchSidecar.model = body.webSearch.model;
|
|
241
|
+
}
|
|
242
|
+
if (body.webSearch.backend === null) delete config.webSearchSidecar.backend;
|
|
243
|
+
else if (body.webSearch.backend === "openai" || body.webSearch.backend === "anthropic") {
|
|
244
|
+
config.webSearchSidecar.backend = body.webSearch.backend;
|
|
245
|
+
}
|
|
207
246
|
if (typeof body.webSearch.reasoning === "string") config.webSearchSidecar.reasoning = body.webSearch.reasoning;
|
|
208
247
|
}
|
|
209
248
|
if (body.vision) {
|
|
210
249
|
config.visionSidecar = { ...config.visionSidecar };
|
|
211
|
-
if (typeof body.vision.model === "string")
|
|
250
|
+
if (typeof body.vision.model === "string") {
|
|
251
|
+
if (body.vision.model === "") delete config.visionSidecar.model;
|
|
252
|
+
else config.visionSidecar.model = body.vision.model;
|
|
253
|
+
}
|
|
254
|
+
if (body.vision.backend === null) delete config.visionSidecar.backend;
|
|
255
|
+
else if (body.vision.backend === "openai" || body.vision.backend === "anthropic") {
|
|
256
|
+
config.visionSidecar.backend = body.vision.backend;
|
|
257
|
+
}
|
|
258
|
+
if (typeof body.vision.maxDescriptionsPerTurn === "number") {
|
|
259
|
+
config.visionSidecar.maxDescriptionsPerTurn = body.vision.maxDescriptionsPerTurn;
|
|
260
|
+
}
|
|
212
261
|
}
|
|
213
262
|
saveConfig(config);
|
|
214
263
|
const ws = config.webSearchSidecar ?? {};
|
|
215
264
|
const vs = config.visionSidecar ?? {};
|
|
216
265
|
return jsonResponse({
|
|
217
266
|
ok: true,
|
|
218
|
-
webSearch: { model: ws.model ?? "gpt-5.6-luna",
|
|
219
|
-
vision: {
|
|
267
|
+
webSearch: { model: ws.model ?? "gpt-5.6-luna", backend: ws.backend },
|
|
268
|
+
vision: {
|
|
269
|
+
model: vs.model ?? "gpt-5.6-luna",
|
|
270
|
+
backend: vs.backend,
|
|
271
|
+
maxDescriptionsPerTurn: vs.maxDescriptionsPerTurn,
|
|
272
|
+
},
|
|
220
273
|
});
|
|
221
274
|
}
|
|
222
275
|
|
|
@@ -707,6 +760,8 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
707
760
|
aliases.push({ id: claudeCodeAlias(m.provider, m.id), display_name: `${m.id} (${m.provider})` });
|
|
708
761
|
}
|
|
709
762
|
const contextWindows = buildClaudeContextWindows([...visibleNativeSlugs(config)], models);
|
|
763
|
+
const webSearchOverride = config.claudeCode?.webSearchSidecar;
|
|
764
|
+
const visionOverride = config.claudeCode?.visionSidecar;
|
|
710
765
|
return jsonResponse({
|
|
711
766
|
enabled: config.claudeCode?.enabled !== false,
|
|
712
767
|
model: config.claudeCode?.model ?? "",
|
|
@@ -720,6 +775,12 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
720
775
|
autoCompactWindow: config.claudeCode?.autoCompactWindow ?? null,
|
|
721
776
|
blockedSkills: config.claudeCode?.blockedSkills ?? null,
|
|
722
777
|
injectAgents: config.claudeCode?.injectAgents !== false,
|
|
778
|
+
...(webSearchOverride && Object.keys(webSearchOverride).length > 0
|
|
779
|
+
? { webSearchSidecar: { backend: webSearchOverride.backend, model: webSearchOverride.model } }
|
|
780
|
+
: {}),
|
|
781
|
+
...(visionOverride && Object.keys(visionOverride).length > 0
|
|
782
|
+
? { visionSidecar: { backend: visionOverride.backend, model: visionOverride.model } }
|
|
783
|
+
: {}),
|
|
723
784
|
fastMode: config.fastMode,
|
|
724
785
|
contextWindows,
|
|
725
786
|
effectiveModelEnv: effectiveModelEnv(config.claudeCode, contextWindows),
|
|
@@ -743,8 +804,36 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
743
804
|
return prototype === Object.prototype || prototype === null;
|
|
744
805
|
};
|
|
745
806
|
if (!isPlainObject(parsedBody)) return jsonResponse({ error: "body must be an object" }, 400);
|
|
746
|
-
const body = parsedBody as { enabled?: unknown; model?: unknown; smallFastModel?: unknown; modelMap?: unknown; systemEnv?: unknown; fastMode?: unknown; maxContextTokens?: unknown; alwaysEnableEffort?: unknown; tierModels?: unknown; autoContext?: unknown; autoCompactWindow?: unknown; blockedSkills?: unknown; injectAgents?: unknown };
|
|
807
|
+
const body = parsedBody as { enabled?: unknown; model?: unknown; smallFastModel?: unknown; modelMap?: unknown; systemEnv?: unknown; fastMode?: unknown; maxContextTokens?: unknown; alwaysEnableEffort?: unknown; tierModels?: unknown; autoContext?: unknown; autoCompactWindow?: unknown; blockedSkills?: unknown; injectAgents?: unknown; webSearchSidecar?: unknown; visionSidecar?: unknown };
|
|
808
|
+
for (const field of ["webSearchSidecar", "visionSidecar"] as const) {
|
|
809
|
+
const section = body[field];
|
|
810
|
+
if (section === undefined || section === null) continue;
|
|
811
|
+
if (!isPlainObject(section)) return jsonResponse({ error: `${field} must be an object or null` }, 400);
|
|
812
|
+
if (section.backend !== undefined && section.backend !== null
|
|
813
|
+
&& section.backend !== "openai" && section.backend !== "anthropic") {
|
|
814
|
+
return jsonResponse({ error: `${field}.backend must be openai, anthropic, or null` }, 400);
|
|
815
|
+
}
|
|
816
|
+
if (section.model !== undefined && typeof section.model !== "string") {
|
|
817
|
+
return jsonResponse({ error: `${field}.model must be a string` }, 400);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
747
820
|
const next = { ...(config.claudeCode ?? {}) };
|
|
821
|
+
for (const field of ["webSearchSidecar", "visionSidecar"] as const) {
|
|
822
|
+
const section = body[field];
|
|
823
|
+
if (section === undefined) continue;
|
|
824
|
+
if (section === null || Object.keys(section as Record<string, unknown>).length === 0) {
|
|
825
|
+
delete next[field];
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
const requested = section as { backend?: "openai" | "anthropic" | null; model?: string };
|
|
829
|
+
const override: NonNullable<OcxClaudeCodeConfig[typeof field]> = { ...next[field] };
|
|
830
|
+
if (requested.backend === null) delete override.backend;
|
|
831
|
+
else if (requested.backend !== undefined) override.backend = requested.backend;
|
|
832
|
+
if (requested.model === "") delete override.model;
|
|
833
|
+
else if (requested.model !== undefined) override.model = requested.model;
|
|
834
|
+
if (Object.keys(override).length > 0) next[field] = override;
|
|
835
|
+
else delete next[field];
|
|
836
|
+
}
|
|
748
837
|
if (body.enabled !== undefined) {
|
|
749
838
|
if (typeof body.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400);
|
|
750
839
|
next.enabled = body.enabled;
|
package/src/server/responses.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { isUsageDebugEnabled } from "../usage/debug";
|
|
|
41
41
|
import { readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "./request-decompress";
|
|
42
42
|
import { resolveAdapter, resolveWireProtocolOverride } from "./adapter-resolve";
|
|
43
43
|
import { hasKeyPoolFailover, rotateKeyOn429 } from "../providers/key-failover";
|
|
44
|
+
import { resolveProviderTransport } from "../providers/xai-transport";
|
|
44
45
|
import type { WsData } from "./ws-bridge";
|
|
45
46
|
import { registerTurn, trackStreamLifetime, unregisterTurn } from "./lifecycle";
|
|
46
47
|
import { redactSecretString } from "../lib/redact";
|
|
@@ -612,14 +613,15 @@ export async function handleResponses(
|
|
|
612
613
|
return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err));
|
|
613
614
|
}
|
|
614
615
|
}
|
|
616
|
+
route.provider = resolveProviderTransport(route.providerName, route.provider, parsed.options.promptCacheKey);
|
|
615
617
|
|
|
616
|
-
// Vision sidecar: the routed model can't see images (provider.noVisionModels).
|
|
617
|
-
//
|
|
618
|
-
//
|
|
618
|
+
// Vision sidecar: the routed model can't see images (provider.noVisionModels). Describe each
|
|
619
|
+
// attached image through the selected sidecar backend and replace it with text BEFORE the main
|
|
620
|
+
// call, so the text-only model can reason about it.
|
|
619
621
|
const visionPlan = planVisionSidecar(config, route.provider, route.modelId, parsed, selectedForwardHeaders, authCtx);
|
|
620
622
|
const recordSidecarOutcome = sidecarOutcomeRecorder(config, authCtx);
|
|
621
623
|
if (visionPlan) {
|
|
622
|
-
await describeImagesInPlace(parsed, visionPlan
|
|
624
|
+
await describeImagesInPlace(parsed, visionPlan, selectedForwardHeaders, options.abortSignal, recordSidecarOutcome);
|
|
623
625
|
} else if (modelInList(route.provider.noVisionModels, route.modelId)) {
|
|
624
626
|
// Sidecar-covered model but NO plan (no forward provider / missing forwarded auth / sidecar
|
|
625
627
|
// disabled): fail closed — never forward raw images to a text-only upstream.
|
|
@@ -870,7 +872,9 @@ export async function handleResponses(
|
|
|
870
872
|
parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()];
|
|
871
873
|
const wsResponse = await runWithWebSearch({
|
|
872
874
|
parsed, adapter,
|
|
875
|
+
backend: wsPlan.backend,
|
|
873
876
|
forwardProvider: wsPlan.forwardProvider,
|
|
877
|
+
anthropicSidecar: wsPlan.anthropicSidecar,
|
|
874
878
|
hostedTool: wsPlan.hostedTool,
|
|
875
879
|
selectedForwardHeaders,
|
|
876
880
|
settings: wsPlan.settings,
|
|
@@ -884,9 +888,11 @@ export async function handleResponses(
|
|
|
884
888
|
on429: retryAfter => {
|
|
885
889
|
const rotated = rotateKeyOn429(config, route.providerName, retryAfter, Date.now(), route.provider.apiKey);
|
|
886
890
|
if (!rotated) return null;
|
|
887
|
-
|
|
891
|
+
// Re-resolve the auth-mode transport so the conv-id / subscription headers derived at
|
|
892
|
+
// line ~616 survive the key rotation (rotated providers come from raw config).
|
|
893
|
+
route.provider = resolveProviderTransport(route.providerName, rotated, parsed.options.promptCacheKey);
|
|
888
894
|
return resolveAdapter(
|
|
889
|
-
resolveWireProtocolOverride(route.providerName, route.modelId,
|
|
895
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
|
|
890
896
|
config.cacheRetention,
|
|
891
897
|
);
|
|
892
898
|
},
|
|
@@ -940,9 +946,11 @@ export async function handleResponses(
|
|
|
940
946
|
// Release the failed response's socket before retrying; unread bodies otherwise linger
|
|
941
947
|
// until runtime cleanup (one per rotated key under a rate-limit storm).
|
|
942
948
|
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
943
|
-
|
|
949
|
+
// Same transport re-resolution as the streaming on429 path: keep conv-id + subscription
|
|
950
|
+
// headers on the retried request instead of silently reverting to the raw config provider.
|
|
951
|
+
route.provider = resolveProviderTransport(route.providerName, rotated, parsed.options.promptCacheKey);
|
|
944
952
|
const retryAdapter = resolveAdapter(
|
|
945
|
-
resolveWireProtocolOverride(route.providerName, route.modelId,
|
|
953
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
|
|
946
954
|
config.cacheRetention,
|
|
947
955
|
);
|
|
948
956
|
const retryRequest = await retryAdapter.buildRequest(parsed, { headers: selectedForwardHeaders });
|