@nvae/llmswitch 0.2.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.
@@ -0,0 +1,385 @@
1
+ /**
2
+ * Translate OpenAI Responses API requests → Chat Completions / Completions.
3
+ */
4
+ function asRecord(value) {
5
+ if (value && typeof value === "object" && !Array.isArray(value)) {
6
+ return value;
7
+ }
8
+ return null;
9
+ }
10
+ function extractText(content) {
11
+ if (typeof content === "string")
12
+ return content;
13
+ if (!Array.isArray(content))
14
+ return "";
15
+ const parts = [];
16
+ for (const part of content) {
17
+ const row = asRecord(part);
18
+ if (!row)
19
+ continue;
20
+ if (typeof row.text === "string")
21
+ parts.push(row.text);
22
+ else if (typeof row.input_text === "string")
23
+ parts.push(row.input_text);
24
+ else if (typeof row.output_text === "string")
25
+ parts.push(row.output_text);
26
+ else if (row.type === "input_text" && typeof row.text === "string") {
27
+ parts.push(row.text);
28
+ }
29
+ else if (row.type === "output_text" && typeof row.text === "string") {
30
+ parts.push(row.text);
31
+ }
32
+ else if (row.type === "text" && typeof row.text === "string") {
33
+ parts.push(row.text);
34
+ }
35
+ }
36
+ return parts.join("");
37
+ }
38
+ function mapRole(role) {
39
+ if (role === "developer" || role === "system")
40
+ return "system";
41
+ if (role === "assistant")
42
+ return "assistant";
43
+ if (role === "tool")
44
+ return "tool";
45
+ return "user";
46
+ }
47
+ /**
48
+ * Convert Responses `input` (+ instructions) into Chat `messages`.
49
+ */
50
+ export function responsesInputToMessages(body) {
51
+ const messages = [];
52
+ const instructions = body.instructions;
53
+ if (typeof instructions === "string" && instructions.trim()) {
54
+ messages.push({ role: "system", content: instructions });
55
+ }
56
+ const input = body.input;
57
+ if (typeof input === "string") {
58
+ messages.push({ role: "user", content: input });
59
+ return messages;
60
+ }
61
+ if (!Array.isArray(input))
62
+ return messages;
63
+ // Pending assistant tool_calls aggregation
64
+ let pendingToolCalls = [];
65
+ const flushToolCalls = () => {
66
+ if (pendingToolCalls.length === 0)
67
+ return;
68
+ messages.push({
69
+ role: "assistant",
70
+ content: null,
71
+ tool_calls: pendingToolCalls,
72
+ });
73
+ pendingToolCalls = [];
74
+ };
75
+ for (const raw of input) {
76
+ if (typeof raw === "string") {
77
+ flushToolCalls();
78
+ messages.push({ role: "user", content: raw });
79
+ continue;
80
+ }
81
+ const item = asRecord(raw);
82
+ if (!item)
83
+ continue;
84
+ const type = String(item.type || "message");
85
+ if (type === "message") {
86
+ flushToolCalls();
87
+ const role = mapRole(item.role);
88
+ const text = extractText(item.content);
89
+ messages.push({ role, content: text });
90
+ continue;
91
+ }
92
+ if (type === "function_call" || type === "custom_tool_call") {
93
+ const callId = String(item.call_id || item.id || `call_${messages.length}`);
94
+ const name = String(item.name || "tool");
95
+ let args;
96
+ if (type === "custom_tool_call") {
97
+ const input = typeof item.input === "string"
98
+ ? item.input
99
+ : typeof item.arguments === "string"
100
+ ? item.arguments
101
+ : JSON.stringify(item.input ?? item.arguments ?? {});
102
+ // Chat upstreams expect function.arguments to be a JSON object string.
103
+ args = JSON.stringify({ input });
104
+ }
105
+ else if (typeof item.arguments === "string") {
106
+ args = item.arguments;
107
+ }
108
+ else if (typeof item.input === "string") {
109
+ args = item.input;
110
+ }
111
+ else {
112
+ args = JSON.stringify(item.arguments ?? item.input ?? {});
113
+ }
114
+ pendingToolCalls.push({
115
+ id: callId,
116
+ type: "function",
117
+ function: { name, arguments: args },
118
+ });
119
+ continue;
120
+ }
121
+ if (type === "function_call_output" || type === "custom_tool_call_output") {
122
+ flushToolCalls();
123
+ const callId = String(item.call_id || "");
124
+ const output = typeof item.output === "string"
125
+ ? item.output
126
+ : JSON.stringify(item.output ?? "");
127
+ if (!callId)
128
+ continue;
129
+ messages.push({
130
+ role: "tool",
131
+ tool_call_id: callId,
132
+ content: output,
133
+ });
134
+ continue;
135
+ }
136
+ if (type === "reasoning") {
137
+ // Drop encrypted reasoning for chat upstreams
138
+ continue;
139
+ }
140
+ // Fallback: treat unknown items with text as user content
141
+ const text = extractText(item.content) || extractText(item);
142
+ if (text) {
143
+ flushToolCalls();
144
+ messages.push({ role: "user", content: text });
145
+ }
146
+ }
147
+ flushToolCalls();
148
+ return messages;
149
+ }
150
+ /**
151
+ * Names of Responses `type: "custom"` (freeform) tools.
152
+ * Chat upstreams only speak function tools; the response translator must
153
+ * convert matching tool_calls back to `custom_tool_call` for Codex.
154
+ */
155
+ export function collectCustomToolNames(tools) {
156
+ const names = new Set();
157
+ if (!Array.isArray(tools))
158
+ return names;
159
+ for (const raw of tools) {
160
+ const tool = asRecord(raw);
161
+ if (!tool)
162
+ continue;
163
+ if (String(tool.type || "") !== "custom")
164
+ continue;
165
+ const name = String(tool.name || "").trim();
166
+ if (name)
167
+ names.add(name);
168
+ }
169
+ return names;
170
+ }
171
+ export function mapResponsesTools(tools) {
172
+ if (!Array.isArray(tools) || tools.length === 0)
173
+ return undefined;
174
+ const out = [];
175
+ for (const raw of tools) {
176
+ const tool = asRecord(raw);
177
+ if (!tool)
178
+ continue;
179
+ const type = String(tool.type || "function");
180
+ if (type === "function") {
181
+ // Already flat Responses style OR nested chat style
182
+ const nested = asRecord(tool.function);
183
+ if (nested) {
184
+ out.push({
185
+ type: "function",
186
+ function: {
187
+ name: String(nested.name || "tool"),
188
+ description: typeof nested.description === "string"
189
+ ? nested.description
190
+ : undefined,
191
+ parameters: nested.parameters,
192
+ },
193
+ });
194
+ }
195
+ else {
196
+ out.push({
197
+ type: "function",
198
+ function: {
199
+ name: String(tool.name || "tool"),
200
+ description: typeof tool.description === "string" ? tool.description : undefined,
201
+ parameters: tool.parameters,
202
+ },
203
+ });
204
+ }
205
+ continue;
206
+ }
207
+ // Map Codex local_shell / custom / hosted tools to chat function tools
208
+ if (type === "local_shell") {
209
+ out.push({
210
+ type: "function",
211
+ function: {
212
+ name: "local_shell",
213
+ description: "Run a local shell command",
214
+ parameters: tool.parameters || {
215
+ type: "object",
216
+ properties: {
217
+ command: { type: "array", items: { type: "string" } },
218
+ },
219
+ },
220
+ },
221
+ });
222
+ continue;
223
+ }
224
+ if (type === "custom") {
225
+ const description = typeof tool.description === "string"
226
+ ? tool.description
227
+ : "Custom freeform tool. Put the entire freeform payload in the input field; do not wrap it in extra JSON beyond the function arguments object.";
228
+ out.push({
229
+ type: "function",
230
+ function: {
231
+ name: String(tool.name || "custom"),
232
+ description,
233
+ parameters: tool.parameters || {
234
+ type: "object",
235
+ properties: {
236
+ input: {
237
+ type: "string",
238
+ description: "Freeform tool input (raw text, not nested JSON)",
239
+ },
240
+ },
241
+ required: ["input"],
242
+ },
243
+ },
244
+ });
245
+ continue;
246
+ }
247
+ // OpenAI hosted web_search is not available on chat upstreams; expose as a
248
+ // client-executed function so Codex can still run its local handler.
249
+ if (type === "web_search" || type === "web_search_preview") {
250
+ out.push({
251
+ type: "function",
252
+ function: {
253
+ name: "web_search",
254
+ description: typeof tool.description === "string"
255
+ ? tool.description
256
+ : "Search the web for current information",
257
+ parameters: {
258
+ type: "object",
259
+ properties: {
260
+ query: { type: "string", description: "Search query" },
261
+ },
262
+ required: ["query"],
263
+ },
264
+ },
265
+ });
266
+ continue;
267
+ }
268
+ if (type === "tool_search") {
269
+ out.push({
270
+ type: "function",
271
+ function: {
272
+ name: "tool_search",
273
+ description: typeof tool.description === "string"
274
+ ? tool.description
275
+ : "Search for available tools",
276
+ parameters: tool.parameters || {
277
+ type: "object",
278
+ properties: {
279
+ query: { type: "string" },
280
+ },
281
+ },
282
+ },
283
+ });
284
+ }
285
+ }
286
+ return out.length ? out : undefined;
287
+ }
288
+ export function mapToolChoice(toolChoice) {
289
+ if (toolChoice == null || toolChoice === "auto" || toolChoice === "none" || toolChoice === "required") {
290
+ return toolChoice;
291
+ }
292
+ const obj = asRecord(toolChoice);
293
+ if (!obj)
294
+ return toolChoice;
295
+ if (obj.type === "function") {
296
+ const nested = asRecord(obj.function);
297
+ const name = String(nested?.name || obj.name || "");
298
+ if (!name)
299
+ return "auto";
300
+ return { type: "function", function: { name } };
301
+ }
302
+ return toolChoice;
303
+ }
304
+ export function responsesToChatRequest(body) {
305
+ const model = String(body.model || "");
306
+ const messages = responsesInputToMessages(body);
307
+ const stream = Boolean(body.stream);
308
+ const req = {
309
+ model,
310
+ messages,
311
+ stream,
312
+ };
313
+ if (stream) {
314
+ req.stream_options = { include_usage: true };
315
+ }
316
+ const tools = mapResponsesTools(body.tools);
317
+ if (tools)
318
+ req.tools = tools;
319
+ if (body.tool_choice !== undefined) {
320
+ req.tool_choice = mapToolChoice(body.tool_choice);
321
+ }
322
+ if (typeof body.parallel_tool_calls === "boolean") {
323
+ req.parallel_tool_calls = body.parallel_tool_calls;
324
+ }
325
+ if (typeof body.temperature === "number")
326
+ req.temperature = body.temperature;
327
+ if (typeof body.top_p === "number")
328
+ req.top_p = body.top_p;
329
+ const maxOut = body.max_output_tokens ?? body.max_tokens;
330
+ if (typeof maxOut === "number") {
331
+ req.max_tokens = maxOut;
332
+ req.max_completion_tokens = maxOut;
333
+ }
334
+ const reasoning = asRecord(body.reasoning);
335
+ if (reasoning && typeof reasoning.effort === "string") {
336
+ req.reasoning_effort = reasoning.effort;
337
+ }
338
+ const text = asRecord(body.text);
339
+ const format = text ? asRecord(text.format) : null;
340
+ if (format) {
341
+ req.response_format = format;
342
+ }
343
+ return req;
344
+ }
345
+ /**
346
+ * Degraded path: flatten chat messages into a single completions prompt.
347
+ * Tool calls are stringified; many Codex agent flows will not work well.
348
+ */
349
+ export function chatToCompletionsRequest(chat) {
350
+ const lines = [];
351
+ for (const msg of chat.messages) {
352
+ const role = msg.role.toUpperCase();
353
+ let content = "";
354
+ if (typeof msg.content === "string")
355
+ content = msg.content;
356
+ else if (msg.content == null && msg.tool_calls) {
357
+ content = JSON.stringify(msg.tool_calls);
358
+ }
359
+ else if (Array.isArray(msg.content)) {
360
+ content = extractText(msg.content);
361
+ }
362
+ if (msg.tool_call_id) {
363
+ lines.push(`[TOOL ${msg.tool_call_id}]: ${content}`);
364
+ }
365
+ else {
366
+ lines.push(`${role}: ${content}`);
367
+ }
368
+ }
369
+ lines.push("ASSISTANT:");
370
+ const req = {
371
+ model: chat.model,
372
+ prompt: lines.join("\n\n"),
373
+ stream: chat.stream,
374
+ };
375
+ if (typeof chat.temperature === "number")
376
+ req.temperature = chat.temperature;
377
+ if (typeof chat.top_p === "number")
378
+ req.top_p = chat.top_p;
379
+ if (typeof chat.max_tokens === "number")
380
+ req.max_tokens = chat.max_tokens;
381
+ return req;
382
+ }
383
+ export function responsesToCompletionsRequest(body) {
384
+ return chatToCompletionsRequest(responsesToChatRequest(body));
385
+ }