@arcote.tech/arc-ai-openai 0.5.0 → 0.5.5

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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/index.ts +219 -160
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-ai-openai",
3
3
  "type": "module",
4
- "version": "0.5.0",
4
+ "version": "0.5.5",
5
5
  "private": false,
6
6
  "description": "OpenAI adapter for Arc AI framework",
7
7
  "main": "./src/index.ts",
@@ -10,7 +10,7 @@
10
10
  "type-check": "tsc --noEmit"
11
11
  },
12
12
  "peerDependencies": {
13
- "@arcote.tech/arc-ai": "^0.5.0",
13
+ "@arcote.tech/arc-ai": "^0.5.5",
14
14
  "typescript": "^5.0.0"
15
15
  },
16
16
  "devDependencies": {
package/src/index.ts CHANGED
@@ -2,11 +2,13 @@ import type {
2
2
  LLMProvider,
3
3
  CompletionRequest,
4
4
  CompletionResult,
5
+ Conversation,
6
+ ConversationTurn,
7
+ AssistantContentBlock,
5
8
  StreamChunk,
6
9
  ToolCall,
7
10
  TokenUsage,
8
11
  FinishReason,
9
- Message,
10
12
  } from "@arcote.tech/arc-ai";
11
13
 
12
14
  // ─── Config ──────────────────────────────────────────────────────
@@ -22,6 +24,8 @@ export interface OpenAIConfig {
22
24
  export function openai(config: OpenAIConfig): LLMProvider {
23
25
  const baseUrl = config.baseUrl ?? "https://api.openai.com/v1";
24
26
 
27
+ // ─── Helpers ──────────────────────────────────────────────────
28
+
25
29
  function translateTools(
26
30
  tools: CompletionRequest["tools"],
27
31
  ): unknown[] | undefined {
@@ -31,6 +35,7 @@ export function openai(config: OpenAIConfig): LLMProvider {
31
35
  name: t.name,
32
36
  description: t.description,
33
37
  parameters: t.parameters,
38
+ strict: false,
34
39
  }));
35
40
  }
36
41
 
@@ -44,108 +49,136 @@ export function openai(config: OpenAIConfig): LLMProvider {
44
49
  };
45
50
  }
46
51
 
47
- function buildInput(
48
- messages: Message[],
49
- previousResponseId?: string,
50
- ): { instructions?: string; input: unknown; previous_response_id?: string } {
51
- // Extract system message as instructions
52
- const systemMsg = messages.find((m) => m.role === "system");
53
- const nonSystemMessages = messages.filter((m) => m.role !== "system");
54
-
55
- if (previousResponseId) {
56
- // Continuation — send only new messages (tool results or new user message)
57
- const newMessages = nonSystemMessages.filter(
58
- (m) => m.role === "tool" || m === nonSystemMessages[nonSystemMessages.length - 1],
59
- );
60
-
61
- const input = newMessages.map((m) => {
62
- if (m.role === "tool" && m.toolCallId) {
63
- return {
64
- type: "function_call_output",
65
- call_id: m.toolCallId,
66
- output: m.content,
67
- };
68
- }
69
- return {
52
+ /**
53
+ * Translate a single ConversationTurn into one or more OpenAI Responses API
54
+ * input items, preserving block ordering for assistant turns. Adapter is a
55
+ * pure translator caller already decided what to send via the
56
+ * Conversation discriminated union.
57
+ */
58
+ function turnToInputItems(turn: ConversationTurn): unknown[] {
59
+ if (turn.role === "user") {
60
+ return [
61
+ {
70
62
  type: "message",
71
- role: m.role,
72
- content: m.content,
73
- };
74
- });
63
+ role: "user",
64
+ content: turn.content,
65
+ },
66
+ ];
67
+ }
75
68
 
69
+ if (turn.role === "tool_result") {
70
+ // OpenAI Responses API requires `output` to be a string. If upstream
71
+ // deserialized our JSON-shaped content into an object, re-stringify.
72
+ const output =
73
+ typeof turn.content === "string"
74
+ ? turn.content
75
+ : JSON.stringify(turn.content);
76
+ return [
77
+ {
78
+ type: "function_call_output",
79
+ call_id: turn.toolCallId,
80
+ output,
81
+ },
82
+ ];
83
+ }
84
+
85
+ // assistant — emit blocks in order, mapping each block to its OpenAI shape
86
+ const items: unknown[] = [];
87
+ for (const block of turn.blocks) {
88
+ if (block.type === "text") {
89
+ if (!block.text) continue;
90
+ items.push({
91
+ type: "message",
92
+ role: "assistant",
93
+ content: block.text,
94
+ });
95
+ } else {
96
+ items.push({
97
+ type: "function_call",
98
+ call_id: block.id,
99
+ name: block.name,
100
+ arguments: JSON.stringify(block.arguments),
101
+ });
102
+ }
103
+ }
104
+ return items;
105
+ }
106
+
107
+ function buildInput(conversation: Conversation): {
108
+ input: unknown[];
109
+ previous_response_id?: string;
110
+ } {
111
+ if (conversation.mode === "full") {
76
112
  return {
77
- instructions: systemMsg?.content,
78
- input,
79
- previous_response_id: previousResponseId,
113
+ input: conversation.turns.flatMap(turnToInputItems),
80
114
  };
81
115
  }
116
+ return {
117
+ input: conversation.newTurns.flatMap(turnToInputItems),
118
+ previous_response_id: conversation.previousResponseId,
119
+ };
120
+ }
82
121
 
83
- // First call send all messages
84
- const input = nonSystemMessages.map((m) => ({
85
- type: "message",
86
- role: m.role === "tool" ? "user" : m.role,
87
- content: m.content,
88
- }));
122
+ function buildBody(request: CompletionRequest, stream: boolean): Record<string, unknown> {
123
+ const { input, previous_response_id } = buildInput(request.conversation);
89
124
 
90
- return {
91
- instructions: systemMsg?.content,
125
+ const body: Record<string, unknown> = {
126
+ model: request.model,
92
127
  input,
128
+ // `instructions` is sent on every call. With previous_response_id it
129
+ // replaces the prior server-side instructions for this turn.
130
+ instructions: request.instructions,
131
+ ...(stream ? { stream: true } : {}),
132
+ ...(previous_response_id ? { previous_response_id } : {}),
133
+ ...(request.temperature != null ? { temperature: request.temperature } : {}),
134
+ ...(request.maxTokens != null ? { max_output_tokens: request.maxTokens } : {}),
93
135
  };
136
+
137
+ const tools = translateTools(request.tools);
138
+ if (tools) body.tools = tools;
139
+ if (request.webSearch) {
140
+ body.tools = [...(tools ?? []), { type: "web_search_preview" }];
141
+ }
142
+ if (request.toolChoice) {
143
+ body.tool_choice = request.toolChoice;
144
+ }
145
+
146
+ return body;
94
147
  }
95
148
 
96
- function extractToolCallsFromOutput(output: any[]): ToolCall[] {
97
- return output
98
- .filter((item: any) => item.type === "function_call")
99
- .map((item: any) => {
149
+ function blocksFromOutput(output: any[]): AssistantContentBlock[] {
150
+ const blocks: AssistantContentBlock[] = [];
151
+ for (const item of output ?? []) {
152
+ if (item.type === "message") {
153
+ const text = (item.content ?? [])
154
+ .filter((c: any) => c.type === "output_text")
155
+ .map((c: any) => c.text)
156
+ .join("");
157
+ if (text) blocks.push({ type: "text", text });
158
+ continue;
159
+ }
160
+ if (item.type === "function_call") {
100
161
  let args: Record<string, unknown> = {};
101
162
  try {
102
163
  args = JSON.parse(item.arguments);
103
164
  } catch {}
104
- return {
165
+ blocks.push({
166
+ type: "tool_call",
105
167
  id: item.call_id,
106
168
  name: item.name,
107
169
  arguments: args,
108
- };
109
- });
170
+ });
171
+ }
172
+ }
173
+ return blocks;
110
174
  }
111
175
 
112
- function extractContentFromOutput(output: any[]): string {
113
- return output
114
- .filter((item: any) => item.type === "message")
115
- .flatMap((item: any) =>
116
- (item.content ?? [])
117
- .filter((c: any) => c.type === "output_text")
118
- .map((c: any) => c.text),
119
- )
120
- .join("");
121
- }
176
+ // ─── complete (non-streaming) ─────────────────────────────────
122
177
 
123
178
  async function complete(
124
179
  request: CompletionRequest,
125
180
  ): Promise<CompletionResult> {
126
- const { instructions, input, previous_response_id } = buildInput(
127
- request.messages,
128
- request.previousResponseId,
129
- );
130
-
131
- const body: Record<string, unknown> = {
132
- model: request.model,
133
- input,
134
- ...(instructions ? { instructions } : {}),
135
- ...(previous_response_id ? { previous_response_id } : {}),
136
- ...(request.temperature != null
137
- ? { temperature: request.temperature }
138
- : {}),
139
- ...(request.maxTokens != null
140
- ? { max_output_tokens: request.maxTokens }
141
- : {}),
142
- };
143
-
144
- const tools = translateTools(request.tools);
145
- if (tools) body.tools = tools;
146
- if (request.webSearch) {
147
- body.tools = [...(tools ?? []), { type: "web_search_preview" }];
148
- }
181
+ const body = buildBody(request, false);
149
182
 
150
183
  const response = await fetch(`${baseUrl}/responses`, {
151
184
  method: "POST",
@@ -161,47 +194,25 @@ export function openai(config: OpenAIConfig): LLMProvider {
161
194
  throw new Error(`OpenAI API error ${response.status}: ${error}`);
162
195
  }
163
196
 
164
- const data = (await response.json());
165
- const toolCalls = extractToolCallsFromOutput(data.output ?? []);
166
- const hasToolCalls = toolCalls.length > 0;
197
+ const data = await response.json();
198
+ const blocks = blocksFromOutput(data.output ?? []);
199
+ const hasToolCalls = blocks.some((b) => b.type === "tool_call");
167
200
 
168
201
  return {
169
- content: extractContentFromOutput(data.output ?? []),
170
- toolCalls,
202
+ blocks,
171
203
  usage: parseUsage(data.usage),
172
204
  finishReason: hasToolCalls ? "tool_call" : "stop",
173
205
  responseId: data.id,
174
206
  };
175
207
  }
176
208
 
209
+ // ─── streamComplete ───────────────────────────────────────────
210
+
177
211
  async function streamComplete(
178
212
  request: CompletionRequest,
179
213
  onChunk: (chunk: StreamChunk) => void,
180
214
  ): Promise<CompletionResult> {
181
- const { instructions, input, previous_response_id } = buildInput(
182
- request.messages,
183
- request.previousResponseId,
184
- );
185
-
186
- const body: Record<string, unknown> = {
187
- model: request.model,
188
- input,
189
- stream: true,
190
- ...(instructions ? { instructions } : {}),
191
- ...(previous_response_id ? { previous_response_id } : {}),
192
- ...(request.temperature != null
193
- ? { temperature: request.temperature }
194
- : {}),
195
- ...(request.maxTokens != null
196
- ? { max_output_tokens: request.maxTokens }
197
- : {}),
198
- };
199
-
200
- const tools = translateTools(request.tools);
201
- if (tools) body.tools = tools;
202
- if (request.webSearch) {
203
- body.tools = [...(tools ?? []), { type: "web_search_preview" }];
204
- }
215
+ const body = buildBody(request, true);
205
216
 
206
217
  const response = await fetch(`${baseUrl}/responses`, {
207
218
  method: "POST",
@@ -217,9 +228,15 @@ export function openai(config: OpenAIConfig): LLMProvider {
217
228
  throw new Error(`OpenAI API error ${response.status}: ${error}`);
218
229
  }
219
230
 
220
- let content = "";
231
+ // Reconstruct ordered blocks from streamed events. The Responses API
232
+ // emits output_item.added/done events in order, so we keep an array
233
+ // anchored by output_index for deterministic placement.
234
+ const orderedBlocks: AssistantContentBlock[] = [];
235
+ const indexToBlock = new Map<number, AssistantContentBlock>();
236
+ const toolCallArgBuffers = new Map<string, string>();
237
+ const toolCallIndex = new Map<string, number>();
238
+
221
239
  let responseId = "";
222
- let finishReason: FinishReason = "stop";
223
240
  let usage: TokenUsage = {
224
241
  inputTokens: 0,
225
242
  outputTokens: 0,
@@ -227,8 +244,6 @@ export function openai(config: OpenAIConfig): LLMProvider {
227
244
  cachedTokens: 0,
228
245
  reasoningTokens: 0,
229
246
  };
230
- const completedToolCalls: ToolCall[] = [];
231
- const toolCallArgBuffers = new Map<string, string>();
232
247
 
233
248
  const reader = response.body!.getReader();
234
249
  const decoder = new TextDecoder();
@@ -251,62 +266,90 @@ export function openai(config: OpenAIConfig): LLMProvider {
251
266
  const event = JSON.parse(data);
252
267
 
253
268
  switch (event.type) {
254
- case "response.output_text.delta":
255
- if (event.delta) {
256
- content += event.delta;
257
- onChunk({ type: "content_delta", content: event.delta });
258
- }
259
- break;
260
-
261
- case "response.function_call_arguments.delta":
262
- if (event.call_id && event.delta) {
263
- const existing = toolCallArgBuffers.get(event.call_id) ?? "";
264
- toolCallArgBuffers.set(event.call_id, existing + event.delta);
265
- }
266
- break;
267
-
268
- case "response.output_item.added":
269
- if (event.item?.type === "function_call") {
270
- toolCallArgBuffers.set(event.item.call_id, "");
269
+ case "response.output_item.added": {
270
+ const item = event.item;
271
+ const idx = event.output_index ?? orderedBlocks.length;
272
+ if (item?.type === "message") {
273
+ const block: AssistantContentBlock = { type: "text", text: "" };
274
+ indexToBlock.set(idx, block);
275
+ orderedBlocks[idx] = block;
276
+ } else if (item?.type === "function_call") {
277
+ const block: AssistantContentBlock = {
278
+ type: "tool_call",
279
+ id: item.call_id,
280
+ name: item.name,
281
+ arguments: {},
282
+ };
283
+ indexToBlock.set(idx, block);
284
+ orderedBlocks[idx] = block;
285
+ toolCallArgBuffers.set(item.call_id, "");
286
+ toolCallIndex.set(item.call_id, idx);
271
287
  onChunk({
272
288
  type: "tool_call_start",
273
289
  toolCall: {
274
- id: event.item.call_id,
275
- name: event.item.name,
290
+ id: item.call_id,
291
+ name: item.name,
276
292
  arguments: {},
277
293
  },
278
294
  });
279
295
  }
280
296
  break;
297
+ }
298
+
299
+ case "response.output_text.delta": {
300
+ if (!event.delta) break;
301
+ const idx = event.output_index;
302
+ const block = indexToBlock.get(idx);
303
+ if (block?.type === "text") {
304
+ block.text += event.delta;
305
+ }
306
+ onChunk({ type: "content_delta", content: event.delta });
307
+ break;
308
+ }
281
309
 
282
- case "response.output_item.done":
283
- if (event.item?.type === "function_call") {
310
+ case "response.function_call_arguments.delta": {
311
+ if (event.call_id && event.delta) {
312
+ const existing = toolCallArgBuffers.get(event.call_id) ?? "";
313
+ toolCallArgBuffers.set(event.call_id, existing + event.delta);
314
+ }
315
+ break;
316
+ }
317
+
318
+ case "response.output_item.done": {
319
+ const item = event.item;
320
+ if (item?.type === "function_call") {
321
+ const buffered = toolCallArgBuffers.get(item.call_id);
284
322
  const argsStr =
285
- toolCallArgBuffers.get(event.item.call_id) ??
286
- event.item.arguments ??
287
- "{}";
323
+ buffered && buffered.length > 0
324
+ ? buffered
325
+ : item.arguments ?? "{}";
288
326
  let args: Record<string, unknown> = {};
289
327
  try {
290
328
  args = JSON.parse(argsStr);
291
329
  } catch {}
292
- completedToolCalls.push({
293
- id: event.item.call_id,
294
- name: event.item.name,
295
- arguments: args,
296
- });
330
+ const idx = toolCallIndex.get(item.call_id);
331
+ if (idx != null) {
332
+ const block = indexToBlock.get(idx);
333
+ if (block?.type === "tool_call") {
334
+ block.arguments = args;
335
+ }
336
+ }
297
337
  }
298
338
  break;
339
+ }
299
340
 
300
- case "response.completed":
341
+ case "response.completed": {
301
342
  responseId = event.response?.id ?? "";
302
343
  usage = parseUsage(event.response?.usage);
303
344
  onChunk({ type: "usage_update", usage });
304
-
305
- // Extract content from completed response if not streamed
306
- if (!content && event.response?.output) {
307
- content = extractContentFromOutput(event.response.output);
345
+ // Final reconciliation: if our streaming reconstruction missed
346
+ // anything, fall back to the completed output.
347
+ if (orderedBlocks.length === 0 && event.response?.output) {
348
+ const fallback = blocksFromOutput(event.response.output);
349
+ orderedBlocks.push(...fallback);
308
350
  }
309
351
  break;
352
+ }
310
353
  }
311
354
  } catch {
312
355
  // Skip malformed JSON
@@ -314,30 +357,46 @@ export function openai(config: OpenAIConfig): LLMProvider {
314
357
  }
315
358
  }
316
359
 
317
- finishReason = completedToolCalls.length > 0 ? "tool_call" : "stop";
360
+ // Compact: drop any holes in orderedBlocks (defensive)
361
+ const blocks = orderedBlocks.filter(
362
+ (b): b is AssistantContentBlock => b != null,
363
+ );
364
+ const hasToolCalls = blocks.some((b) => b.type === "tool_call");
318
365
 
319
366
  return {
320
- content,
321
- toolCalls: completedToolCalls,
367
+ blocks,
322
368
  usage,
323
- finishReason,
369
+ finishReason: hasToolCalls ? "tool_call" : "stop",
324
370
  responseId,
325
371
  };
326
372
  }
327
373
 
374
+ const pricing: Record<
375
+ string,
376
+ {
377
+ inputPer1M: number;
378
+ outputPer1M: number;
379
+ cachedInputPer1M?: number;
380
+ reasoningPer1M?: number;
381
+ }
382
+ > = {
383
+ "gpt-4o": { inputPer1M: 2.5, outputPer1M: 10.0, cachedInputPer1M: 1.25 },
384
+ "gpt-4o-mini": { inputPer1M: 0.15, outputPer1M: 0.6, cachedInputPer1M: 0.075 },
385
+ o3: { inputPer1M: 10.0, outputPer1M: 40.0, reasoningPer1M: 40.0 },
386
+ "o3-mini": { inputPer1M: 1.1, outputPer1M: 4.4, reasoningPer1M: 4.4 },
387
+ "gpt-4.1": { inputPer1M: 2.0, outputPer1M: 8.0, cachedInputPer1M: 0.5 },
388
+ "gpt-4.1-mini": { inputPer1M: 0.4, outputPer1M: 1.6, cachedInputPer1M: 0.1 },
389
+ "gpt-4.1-nano": { inputPer1M: 0.1, outputPer1M: 0.4, cachedInputPer1M: 0.025 },
390
+ "gpt-5.4-nano": { inputPer1M: 0.1, outputPer1M: 0.4, cachedInputPer1M: 0.025 },
391
+ "gpt-5.4-mini": { inputPer1M: 0.4, outputPer1M: 1.6, cachedInputPer1M: 0.1 },
392
+ };
393
+
328
394
  return {
329
395
  name: "openai",
330
- models: [
331
- "gpt-4o",
332
- "gpt-4o-mini",
333
- "o3",
334
- "o3-mini",
335
- "gpt-4.1",
336
- "gpt-4.1-mini",
337
- "gpt-4.1-nano",
338
- "gpt-5.4-nano",
339
- ],
396
+ models: Object.keys(pricing),
397
+ supportsContinuation: true,
340
398
  complete,
341
399
  streamComplete,
400
+ getPricing: (model: string) => pricing[model],
342
401
  };
343
402
  }