@juspay/neurolink 12.35.5 → 12.36.1
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/CHANGELOG.md +2 -2
- package/dist/agent/directTools.js +56 -22
- package/dist/browser/neurolink.min.js +374 -374
- package/dist/proxy/codexOutboundFallback.d.ts +35 -0
- package/dist/proxy/codexOutboundFallback.js +515 -0
- package/dist/types/codex.d.ts +137 -1
- package/dist/types/file.d.ts +12 -0
- package/dist/utils/csvProcessor.d.ts +3 -3
- package/dist/utils/csvProcessor.js +53 -5
- package/docs-site/static/search-index.json +6 -6
- package/package.json +1 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex-outbound fallback: request translation, Codex Responses -> ClaudeRequest.
|
|
3
|
+
*
|
|
4
|
+
* Named to mirror the existing asymmetry: `codexFallback.ts` is the Claude
|
|
5
|
+
* engine's fallback _into_ Codex; this file is the Codex engine's fallback
|
|
6
|
+
* _out_ to the Anthropic OAuth pool or Vertex-Claude. Owns one thing:
|
|
7
|
+
* untrusted inbound Codex Responses JSON -> a typed `ClaudeRequest`. Does not
|
|
8
|
+
* own target/model selection, response/stream translation back to Codex wire
|
|
9
|
+
* format, tool execution, or dispatch/HTTP.
|
|
10
|
+
*
|
|
11
|
+
* `parallel_tool_calls` is parsed and validated but not yet consumed here —
|
|
12
|
+
* `mapCodexToolChoiceToClaude` stays the base bijection this PR ships.
|
|
13
|
+
* Threading it into `tool_choice.disable_parallel_tool_use` (and the matching
|
|
14
|
+
* `ClaudeRequest.tool_choice` type change) is the tool-call-fidelity build-order
|
|
15
|
+
* PR's explicit modification to this same function, not a gap in this one.
|
|
16
|
+
*/
|
|
17
|
+
import type { ClaudeRequest } from "../types/index.js";
|
|
18
|
+
import type { CodexNativeRequest, CodexTranslationError } from "../types/index.js";
|
|
19
|
+
export declare function parseCodexNativeRequest(raw: unknown): {
|
|
20
|
+
ok: true;
|
|
21
|
+
value: CodexNativeRequest;
|
|
22
|
+
} | {
|
|
23
|
+
ok: false;
|
|
24
|
+
error: CodexTranslationError;
|
|
25
|
+
};
|
|
26
|
+
export declare function translateCodexRequestToClaude(request: CodexNativeRequest, target: {
|
|
27
|
+
provider: "anthropic" | "vertex";
|
|
28
|
+
model: string;
|
|
29
|
+
}): {
|
|
30
|
+
ok: true;
|
|
31
|
+
value: ClaudeRequest;
|
|
32
|
+
} | {
|
|
33
|
+
ok: false;
|
|
34
|
+
error: CodexTranslationError;
|
|
35
|
+
};
|
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import { resolveClaudeMaxTokens } from "../utils/tokenLimits.js";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// §3.1 parseCodexNativeRequest — the untrusted-JSON -> typed boundary.
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
function isRecord(value) {
|
|
6
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
function isCodexContentPart(value) {
|
|
9
|
+
if (!isRecord(value)) {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
if (value.type === "input_text" || value.type === "output_text") {
|
|
13
|
+
return typeof value.text === "string";
|
|
14
|
+
}
|
|
15
|
+
if (value.type === "input_image") {
|
|
16
|
+
return typeof value.image_url === "string";
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
function isCodexNativeToolDeclaration(value) {
|
|
21
|
+
if (!isRecord(value) || typeof value.name !== "string") {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
if (value.type === "function") {
|
|
25
|
+
return (typeof value.strict === "boolean" &&
|
|
26
|
+
isRecord(value.parameters) &&
|
|
27
|
+
(value.description === undefined || typeof value.description === "string"));
|
|
28
|
+
}
|
|
29
|
+
if (value.type === "custom") {
|
|
30
|
+
return (isRecord(value.format) &&
|
|
31
|
+
value.format.type === "grammar" &&
|
|
32
|
+
(value.format.syntax === "lark" || value.format.syntax === "regex") &&
|
|
33
|
+
typeof value.format.definition === "string" &&
|
|
34
|
+
(value.description === undefined || typeof value.description === "string"));
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
function isCodexNativeToolNamespace(value) {
|
|
39
|
+
return (isRecord(value) &&
|
|
40
|
+
value.type === "namespace" &&
|
|
41
|
+
typeof value.name === "string" &&
|
|
42
|
+
typeof value.description === "string" &&
|
|
43
|
+
Array.isArray(value.tools) &&
|
|
44
|
+
value.tools.every(isCodexNativeToolDeclaration));
|
|
45
|
+
}
|
|
46
|
+
function isCodexNativeInputItem(value) {
|
|
47
|
+
if (!isRecord(value)) {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
switch (value.type) {
|
|
51
|
+
case "message":
|
|
52
|
+
return ((value.role === "user" ||
|
|
53
|
+
value.role === "assistant" ||
|
|
54
|
+
value.role === "developer") &&
|
|
55
|
+
Array.isArray(value.content) &&
|
|
56
|
+
value.content.every(isCodexContentPart));
|
|
57
|
+
case "function_call":
|
|
58
|
+
return (typeof value.call_id === "string" &&
|
|
59
|
+
typeof value.name === "string" &&
|
|
60
|
+
typeof value.arguments === "string");
|
|
61
|
+
case "function_call_output":
|
|
62
|
+
case "custom_tool_call_output":
|
|
63
|
+
return (typeof value.call_id === "string" && typeof value.output === "string");
|
|
64
|
+
case "custom_tool_call":
|
|
65
|
+
return (typeof value.call_id === "string" &&
|
|
66
|
+
typeof value.name === "string" &&
|
|
67
|
+
typeof value.input === "string");
|
|
68
|
+
case "reasoning":
|
|
69
|
+
return true;
|
|
70
|
+
case "additional_tools":
|
|
71
|
+
return (value.role === "developer" &&
|
|
72
|
+
Array.isArray(value.tools) &&
|
|
73
|
+
value.tools.every(isCodexNativeToolNamespace));
|
|
74
|
+
default:
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function isCodexNativeToolChoice(value) {
|
|
79
|
+
if (value === "auto" || value === "required" || value === "none") {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
return (isRecord(value) &&
|
|
83
|
+
value.type === "function" &&
|
|
84
|
+
typeof value.name === "string");
|
|
85
|
+
}
|
|
86
|
+
const CODEX_REASONING_EFFORTS = [
|
|
87
|
+
"none",
|
|
88
|
+
"minimal",
|
|
89
|
+
"low",
|
|
90
|
+
"medium",
|
|
91
|
+
"high",
|
|
92
|
+
"xhigh",
|
|
93
|
+
"max",
|
|
94
|
+
];
|
|
95
|
+
function isCodexNativeReasoning(value) {
|
|
96
|
+
return (isRecord(value) &&
|
|
97
|
+
CODEX_REASONING_EFFORTS.includes(value.effort) &&
|
|
98
|
+
(value.context === undefined || typeof value.context === "string"));
|
|
99
|
+
}
|
|
100
|
+
export function parseCodexNativeRequest(raw) {
|
|
101
|
+
if (!isRecord(raw) ||
|
|
102
|
+
typeof raw.model !== "string" ||
|
|
103
|
+
typeof raw.stream !== "boolean" ||
|
|
104
|
+
typeof raw.store !== "boolean" ||
|
|
105
|
+
!Array.isArray(raw.input) ||
|
|
106
|
+
!raw.input.every(isCodexNativeInputItem) ||
|
|
107
|
+
(raw.tool_choice !== undefined &&
|
|
108
|
+
!isCodexNativeToolChoice(raw.tool_choice)) ||
|
|
109
|
+
(raw.reasoning !== undefined && !isCodexNativeReasoning(raw.reasoning)) ||
|
|
110
|
+
(raw.parallel_tool_calls !== undefined &&
|
|
111
|
+
typeof raw.parallel_tool_calls !== "boolean")) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
error: {
|
|
115
|
+
code: "MALFORMED_REQUEST",
|
|
116
|
+
message: "inbound body does not match CodexNativeRequest",
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return { ok: true, value: raw };
|
|
121
|
+
}
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// §3.3 tool_choice — exact inversion of codexFallback.ts's convertClaudeRequestToCodex.
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
function mapCodexToolChoiceToClaude(choice) {
|
|
126
|
+
if (choice === undefined) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
if (choice === "required") {
|
|
130
|
+
return { type: "any" };
|
|
131
|
+
}
|
|
132
|
+
if (choice === "auto") {
|
|
133
|
+
return { type: "auto" };
|
|
134
|
+
}
|
|
135
|
+
if (choice === "none") {
|
|
136
|
+
return { type: "none" };
|
|
137
|
+
}
|
|
138
|
+
return { type: "tool", name: choice.name };
|
|
139
|
+
}
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// §3.4 Tools: flattened across namespaces, split by each declaration's own type.
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
function mapCodexFunctionToolToClaude(tool) {
|
|
144
|
+
return {
|
|
145
|
+
name: tool.name,
|
|
146
|
+
...(tool.description ? { description: tool.description } : {}),
|
|
147
|
+
input_schema: tool.parameters,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function mapCodexCustomToolToClaude(tool) {
|
|
151
|
+
// Lossy, explicitly flagged: no JSON Schema exists for a Lark/regex grammar.
|
|
152
|
+
// The model is no longer grammar-constrained after this mapping — needs
|
|
153
|
+
// product sign-off before shipping (§10 of the design).
|
|
154
|
+
return {
|
|
155
|
+
name: tool.name,
|
|
156
|
+
description: [
|
|
157
|
+
tool.description,
|
|
158
|
+
`Grammar (${tool.format.syntax}):\n${tool.format.definition}`,
|
|
159
|
+
]
|
|
160
|
+
.filter((s) => Boolean(s))
|
|
161
|
+
.join("\n\n"),
|
|
162
|
+
input_schema: {
|
|
163
|
+
type: "object",
|
|
164
|
+
properties: {
|
|
165
|
+
input: {
|
|
166
|
+
type: "string",
|
|
167
|
+
description: "Raw command text, constrained by the grammar in this tool's description.",
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
required: ["input"],
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function mapCodexToolDeclarationToClaude(tool) {
|
|
175
|
+
// Discriminate on the declaration's own `type`, never on which namespace it
|
|
176
|
+
// came from — the real capture disproves the namespace-implies-kind
|
|
177
|
+
// assumption (`exec` is `type:"custom"` inside a `type:"function"`-heavy
|
|
178
|
+
// namespace).
|
|
179
|
+
return tool.type === "custom"
|
|
180
|
+
? mapCodexCustomToolToClaude(tool)
|
|
181
|
+
: mapCodexFunctionToolToClaude(tool);
|
|
182
|
+
}
|
|
183
|
+
function buildClaudeTools(additionalTools) {
|
|
184
|
+
if (!additionalTools) {
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
const tools = additionalTools.tools.flatMap((namespace) => namespace.tools.map(mapCodexToolDeclarationToClaude));
|
|
188
|
+
return tools.length > 0 ? tools : undefined;
|
|
189
|
+
}
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// §3.5 Reasoning: reasoning.effort -> thinking.
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
const REASONING_BUDGET = {
|
|
194
|
+
low: 4096,
|
|
195
|
+
medium: 8192,
|
|
196
|
+
high: 16384,
|
|
197
|
+
xhigh: 24576,
|
|
198
|
+
max: 32768,
|
|
199
|
+
};
|
|
200
|
+
function mapCodexReasoningToThinking(reasoning, resolvedMaxTokens) {
|
|
201
|
+
if (!reasoning ||
|
|
202
|
+
reasoning.effort === "none" ||
|
|
203
|
+
reasoning.effort === "minimal") {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
const table = REASONING_BUDGET[reasoning.effort];
|
|
207
|
+
// Explicit floor: makes the ">= 1024" invariant correct by construction
|
|
208
|
+
// rather than an accident of today's model-table minimums.
|
|
209
|
+
const budget_tokens = Math.max(1024, Math.min(table, resolvedMaxTokens - 1024));
|
|
210
|
+
return { type: "enabled", budget_tokens };
|
|
211
|
+
}
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// §3.6 Non-JSON function_call.arguments — never throws.
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
function parseInboundToolArguments(raw) {
|
|
216
|
+
try {
|
|
217
|
+
const parsed = JSON.parse(raw);
|
|
218
|
+
if (isRecord(parsed)) {
|
|
219
|
+
return parsed;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// fall through — not JSON at all (e.g. exec's raw shell command)
|
|
224
|
+
}
|
|
225
|
+
return { input: raw };
|
|
226
|
+
}
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
// §3.7 Content parts, with the image data-URI fix.
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
const DATA_URI_RE = /^data:([^;,]+);base64,(.+)$/s;
|
|
231
|
+
// The four image types the Messages API accepts (mirrors
|
|
232
|
+
// SUPPORTED_IMAGE_MEDIA_TYPES in providers/anthropicImageBlocks.ts).
|
|
233
|
+
const ANTHROPIC_IMAGE_MEDIA_TYPES = new Set([
|
|
234
|
+
"image/jpeg",
|
|
235
|
+
"image/png",
|
|
236
|
+
"image/gif",
|
|
237
|
+
"image/webp",
|
|
238
|
+
]);
|
|
239
|
+
const ANTHROPIC_TOOL_NAME_RE = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
240
|
+
function normalizeImageMediaType(mediaType) {
|
|
241
|
+
const lower = mediaType.toLowerCase();
|
|
242
|
+
return lower === "image/jpg" ? "image/jpeg" : lower;
|
|
243
|
+
}
|
|
244
|
+
function toClaudeContentPart(part) {
|
|
245
|
+
if (part.type === "input_image") {
|
|
246
|
+
const match = DATA_URI_RE.exec(part.image_url);
|
|
247
|
+
if (match) {
|
|
248
|
+
return {
|
|
249
|
+
type: "image",
|
|
250
|
+
source: {
|
|
251
|
+
type: "base64",
|
|
252
|
+
media_type: normalizeImageMediaType(match[1]),
|
|
253
|
+
data: match[2],
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
return { type: "image", source: { type: "url", url: part.image_url } };
|
|
258
|
+
}
|
|
259
|
+
return { type: "text", text: part.text };
|
|
260
|
+
}
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// §4 Role-alternation coalescing.
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
function coalesceCodexInputToClaudeMessages(items) {
|
|
265
|
+
const groups = [];
|
|
266
|
+
let current;
|
|
267
|
+
const open = (role) => {
|
|
268
|
+
if (current?.role === role) {
|
|
269
|
+
return current;
|
|
270
|
+
}
|
|
271
|
+
if (current) {
|
|
272
|
+
groups.push(current);
|
|
273
|
+
}
|
|
274
|
+
current = { role, blocks: [] };
|
|
275
|
+
return current;
|
|
276
|
+
};
|
|
277
|
+
for (const item of items) {
|
|
278
|
+
// additional_tools is handled out-of-band (§3.4). A reasoning item carries
|
|
279
|
+
// OpenAI-encrypted chain of thought that only the OpenAI backend can read;
|
|
280
|
+
// Anthropic has no way to consume it, so it is dropped by rule, not by
|
|
281
|
+
// accident (unknown item types still fail parsing, per A.1).
|
|
282
|
+
if (item.type === "additional_tools" || item.type === "reasoning") {
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (item.type === "message") {
|
|
286
|
+
// Developer messages become system blocks (below). An empty message
|
|
287
|
+
// adds nothing, and on its own would become a content-less turn.
|
|
288
|
+
if (item.role === "developer" || item.content.length === 0) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
open(item.role).blocks.push(...item.content.map(toClaudeContentPart));
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (item.type === "function_call" || item.type === "custom_tool_call") {
|
|
295
|
+
open("assistant").blocks.push({
|
|
296
|
+
type: "tool_use",
|
|
297
|
+
id: item.call_id,
|
|
298
|
+
name: item.name,
|
|
299
|
+
// A custom tool is declared with a single `input` string (§3.4), so
|
|
300
|
+
// its raw call text maps onto that field unchanged.
|
|
301
|
+
input: item.type === "function_call"
|
|
302
|
+
? parseInboundToolArguments(item.arguments)
|
|
303
|
+
: { input: item.input },
|
|
304
|
+
});
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
open("user").blocks.push({
|
|
308
|
+
type: "tool_result",
|
|
309
|
+
tool_use_id: item.call_id,
|
|
310
|
+
content: item.output,
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
if (current) {
|
|
314
|
+
groups.push(current);
|
|
315
|
+
}
|
|
316
|
+
// In a user turn the Messages API wants tool_result blocks before any other
|
|
317
|
+
// content, so a user message that arrives between a call and its output is
|
|
318
|
+
// moved after the result rather than sent in an order the API rejects.
|
|
319
|
+
return groups.map((g) => ({
|
|
320
|
+
role: g.role,
|
|
321
|
+
content: g.role === "user"
|
|
322
|
+
? [
|
|
323
|
+
...g.blocks.filter((block) => block.type === "tool_result"),
|
|
324
|
+
...g.blocks.filter((block) => block.type !== "tool_result"),
|
|
325
|
+
]
|
|
326
|
+
: g.blocks,
|
|
327
|
+
}));
|
|
328
|
+
}
|
|
329
|
+
function buildSystemBlocksFromDeveloperMessages(items) {
|
|
330
|
+
return items
|
|
331
|
+
.filter((i) => i.type === "message" && i.role === "developer")
|
|
332
|
+
.flatMap((i) => i.content.map(toClaudeContentPart))
|
|
333
|
+
.filter((b) => b.type === "text");
|
|
334
|
+
}
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
// §5 Resumed-session history contract — fail loud, never guess.
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// Detects only a missing tools preamble. A client-side prune that drops turns
|
|
339
|
+
// from the middle while resending the preamble passes this check; nothing in
|
|
340
|
+
// the wire format marks such a gap (spec Q6, still open).
|
|
341
|
+
function hasFreshSessionPreamble(items) {
|
|
342
|
+
return items[0]?.type === "additional_tools";
|
|
343
|
+
}
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
// Inputs Codex accepts but the Messages API rejects — fail loud, never guess.
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
function describeUntranslatableItem(item, provider) {
|
|
348
|
+
if (item.type === "additional_tools") {
|
|
349
|
+
const badTool = item.tools
|
|
350
|
+
.flatMap((namespace) => namespace.tools)
|
|
351
|
+
.find((tool) => !ANTHROPIC_TOOL_NAME_RE.test(tool.name));
|
|
352
|
+
return badTool
|
|
353
|
+
? `tool name ${JSON.stringify(badTool.name)} does not match the Messages API's ${String(ANTHROPIC_TOOL_NAME_RE)}`
|
|
354
|
+
: undefined;
|
|
355
|
+
}
|
|
356
|
+
if (item.type !== "message") {
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
return item.content
|
|
360
|
+
.map((part) => {
|
|
361
|
+
if (part.type !== "input_image") {
|
|
362
|
+
return undefined;
|
|
363
|
+
}
|
|
364
|
+
if (item.role === "developer") {
|
|
365
|
+
return "a developer message carries an image, which a text-only system prompt cannot hold";
|
|
366
|
+
}
|
|
367
|
+
if (!part.image_url.startsWith("data:")) {
|
|
368
|
+
// Claude on Vertex takes only inline (base64) image sources; the
|
|
369
|
+
// Vertex client in providers/googleVertex fetches URLs for the same reason.
|
|
370
|
+
return provider === "vertex"
|
|
371
|
+
? "Claude on Vertex accepts only base64 image sources, not an image URL"
|
|
372
|
+
: undefined;
|
|
373
|
+
}
|
|
374
|
+
const match = DATA_URI_RE.exec(part.image_url);
|
|
375
|
+
if (!match) {
|
|
376
|
+
return "an image data URI is not base64-encoded";
|
|
377
|
+
}
|
|
378
|
+
const mediaType = normalizeImageMediaType(match[1]);
|
|
379
|
+
return ANTHROPIC_IMAGE_MEDIA_TYPES.has(mediaType)
|
|
380
|
+
? undefined
|
|
381
|
+
: `image media type ${mediaType} is not one the Messages API accepts (jpeg, png, gif, webp)`;
|
|
382
|
+
})
|
|
383
|
+
.find((reason) => reason !== undefined);
|
|
384
|
+
}
|
|
385
|
+
function toolUseIds(message) {
|
|
386
|
+
return message?.role === "assistant" && Array.isArray(message.content)
|
|
387
|
+
? message.content.flatMap((block) => block.type === "tool_use" ? [block.id] : [])
|
|
388
|
+
: [];
|
|
389
|
+
}
|
|
390
|
+
function toolResultIds(message) {
|
|
391
|
+
return message?.role === "user" && Array.isArray(message.content)
|
|
392
|
+
? message.content.flatMap((block) => block.type === "tool_result" ? [block.tool_use_id] : [])
|
|
393
|
+
: [];
|
|
394
|
+
}
|
|
395
|
+
// The Messages API requires every tool call to be answered in the very next
|
|
396
|
+
// user turn, and every tool result to answer a call in the turn before it.
|
|
397
|
+
// History pruned on the client side can break either; this catches it here
|
|
398
|
+
// instead of as an upstream 400.
|
|
399
|
+
function describeToolPairingGap(messages) {
|
|
400
|
+
return messages
|
|
401
|
+
.map((message, index) => {
|
|
402
|
+
const answered = toolResultIds(messages[index + 1]);
|
|
403
|
+
const unanswered = toolUseIds(message).find((id) => !answered.includes(id));
|
|
404
|
+
if (unanswered) {
|
|
405
|
+
return `tool call ${unanswered} has no result in the next user turn`;
|
|
406
|
+
}
|
|
407
|
+
const called = toolUseIds(messages[index - 1]);
|
|
408
|
+
const orphaned = toolResultIds(message).find((id) => !called.includes(id));
|
|
409
|
+
return orphaned
|
|
410
|
+
? `tool result for ${orphaned} answers no call in the preceding assistant turn`
|
|
411
|
+
: undefined;
|
|
412
|
+
})
|
|
413
|
+
.find((reason) => reason !== undefined);
|
|
414
|
+
}
|
|
415
|
+
function lastAssistantMessageUsesTool(messages) {
|
|
416
|
+
const lastAssistant = [...messages]
|
|
417
|
+
.reverse()
|
|
418
|
+
.find((message) => message.role === "assistant");
|
|
419
|
+
return (Array.isArray(lastAssistant?.content) &&
|
|
420
|
+
lastAssistant.content.some((block) => block.type === "tool_use"));
|
|
421
|
+
}
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
// §3.2 translateCodexRequestToClaude — the full field-by-field mapping.
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
export function translateCodexRequestToClaude(request, target) {
|
|
426
|
+
// Read from client_metadata, not top-level session_id/thread_id: the real
|
|
427
|
+
// capture carries neither at top level (only nested in client_metadata),
|
|
428
|
+
// which is why CodexNativeRequest has no top-level fields for them.
|
|
429
|
+
if ((request.client_metadata?.session_id !== undefined ||
|
|
430
|
+
request.client_metadata?.thread_id !== undefined) &&
|
|
431
|
+
!hasFreshSessionPreamble(request.input)) {
|
|
432
|
+
return {
|
|
433
|
+
ok: false,
|
|
434
|
+
error: {
|
|
435
|
+
code: "SUSPECTED_PARTIAL_HISTORY",
|
|
436
|
+
message: "a request with session metadata does not open with the additional_tools preamble; refusing to translate what may be an incremental history as if it were complete",
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
const untranslatable = request.input
|
|
441
|
+
.map((item) => describeUntranslatableItem(item, target.provider))
|
|
442
|
+
.find((reason) => reason !== undefined);
|
|
443
|
+
if (untranslatable) {
|
|
444
|
+
return {
|
|
445
|
+
ok: false,
|
|
446
|
+
error: { code: "UNTRANSLATABLE_REQUEST", message: untranslatable },
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
// The spec's cardinality rule: when more than one additional_tools item
|
|
450
|
+
// appears, the last one wins.
|
|
451
|
+
const additionalTools = request.input
|
|
452
|
+
.filter((item) => item.type === "additional_tools")
|
|
453
|
+
.at(-1);
|
|
454
|
+
const tools = buildClaudeTools(additionalTools);
|
|
455
|
+
const toolChoice = mapCodexToolChoiceToClaude(request.tool_choice);
|
|
456
|
+
const forcesTool = toolChoice?.type === "any" || toolChoice?.type === "tool";
|
|
457
|
+
if (forcesTool && !tools) {
|
|
458
|
+
return {
|
|
459
|
+
ok: false,
|
|
460
|
+
error: {
|
|
461
|
+
code: "MALFORMED_REQUEST",
|
|
462
|
+
message: "tool_choice forces a tool call but the request declares no tools",
|
|
463
|
+
},
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
if (toolChoice?.type === "tool" &&
|
|
467
|
+
!tools?.some((tool) => tool.name === toolChoice.name)) {
|
|
468
|
+
return {
|
|
469
|
+
ok: false,
|
|
470
|
+
error: {
|
|
471
|
+
code: "MALFORMED_REQUEST",
|
|
472
|
+
message: `tool_choice names ${JSON.stringify(toolChoice.name)}, which the request does not declare`,
|
|
473
|
+
},
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
const messages = coalesceCodexInputToClaudeMessages(request.input);
|
|
477
|
+
if (messages[0]?.role !== "user") {
|
|
478
|
+
return {
|
|
479
|
+
ok: false,
|
|
480
|
+
error: {
|
|
481
|
+
code: "UNTRANSLATABLE_REQUEST",
|
|
482
|
+
message: "the translated history does not open with a user turn, which the Messages API requires",
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
const pairingGap = describeToolPairingGap(messages);
|
|
487
|
+
if (pairingGap) {
|
|
488
|
+
return {
|
|
489
|
+
ok: false,
|
|
490
|
+
error: { code: "UNTRANSLATABLE_REQUEST", message: pairingGap },
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
const maxTokens = resolveClaudeMaxTokens(target.model, undefined);
|
|
494
|
+
const systemBlocks = buildSystemBlocksFromDeveloperMessages(request.input);
|
|
495
|
+
// Thinking is left off in two cases the Messages API would reject: a forced
|
|
496
|
+
// tool_choice (the same rule oauthFetch.ts applies), and a history whose
|
|
497
|
+
// final assistant turn calls a tool, which with thinking on must open with a
|
|
498
|
+
// thinking block. Codex history carries none that can be translated, since
|
|
499
|
+
// its reasoning is OpenAI ciphertext.
|
|
500
|
+
const thinking = forcesTool || lastAssistantMessageUsesTool(messages)
|
|
501
|
+
? undefined
|
|
502
|
+
: mapCodexReasoningToThinking(request.reasoning, maxTokens);
|
|
503
|
+
const claudeRequest = {
|
|
504
|
+
model: target.model,
|
|
505
|
+
messages,
|
|
506
|
+
max_tokens: maxTokens,
|
|
507
|
+
...(systemBlocks.length > 0 ? { system: systemBlocks } : {}),
|
|
508
|
+
...(tools ? { tools } : {}),
|
|
509
|
+
// The Messages API accepts tool_choice only alongside tools.
|
|
510
|
+
...(toolChoice && tools ? { tool_choice: toolChoice } : {}),
|
|
511
|
+
...(thinking ? { thinking } : {}),
|
|
512
|
+
stream: request.stream ?? true,
|
|
513
|
+
};
|
|
514
|
+
return { ok: true, value: claudeRequest };
|
|
515
|
+
}
|