@arcote.tech/arc-ai-openai 0.5.2 → 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 +215 -166
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.2",
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.2",
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 {
@@ -45,101 +49,89 @@ export function openai(config: OpenAIConfig): LLMProvider {
45
49
  };
46
50
  }
47
51
 
48
- function buildInput(
49
- messages: Message[],
50
- previousResponseId?: string,
51
- ): { instructions?: string; input: unknown; previous_response_id?: string } {
52
- // Extract system message as instructions
53
- const systemMsg = messages.find((m) => m.role === "system");
54
- const nonSystemMessages = messages.filter((m) => m.role !== "system");
55
-
56
- if (previousResponseId) {
57
- // Continuation — send only new messages (tool results or new user message)
58
- const newMessages = nonSystemMessages.filter(
59
- (m) => m.role === "tool" || m === nonSystemMessages[nonSystemMessages.length - 1],
60
- );
61
-
62
- const input = newMessages.map((m) => {
63
- if (m.role === "tool" && m.toolCallId) {
64
- return {
65
- type: "function_call_output",
66
- call_id: m.toolCallId,
67
- output: m.content,
68
- };
69
- }
70
- 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
+ {
71
62
  type: "message",
72
- role: m.role,
73
- content: m.content,
74
- };
75
- });
63
+ role: "user",
64
+ content: turn.content,
65
+ },
66
+ ];
67
+ }
76
68
 
77
- return {
78
- instructions: systemMsg?.content,
79
- input,
80
- previous_response_id: previousResponseId,
81
- };
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
+ ];
82
83
  }
83
84
 
84
- // First call send all messages
85
- const input = nonSystemMessages.map((m) => ({
86
- type: "message",
87
- role: m.role === "tool" ? "user" : m.role,
88
- content: m.content,
89
- }));
85
+ // assistantemit 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
+ }
90
106
 
107
+ function buildInput(conversation: Conversation): {
108
+ input: unknown[];
109
+ previous_response_id?: string;
110
+ } {
111
+ if (conversation.mode === "full") {
112
+ return {
113
+ input: conversation.turns.flatMap(turnToInputItems),
114
+ };
115
+ }
91
116
  return {
92
- instructions: systemMsg?.content,
93
- input,
117
+ input: conversation.newTurns.flatMap(turnToInputItems),
118
+ previous_response_id: conversation.previousResponseId,
94
119
  };
95
120
  }
96
121
 
97
- function extractToolCallsFromOutput(output: any[]): ToolCall[] {
98
- return output
99
- .filter((item: any) => item.type === "function_call")
100
- .map((item: any) => {
101
- let args: Record<string, unknown> = {};
102
- try {
103
- args = JSON.parse(item.arguments);
104
- } catch {}
105
- return {
106
- id: item.call_id,
107
- name: item.name,
108
- arguments: args,
109
- };
110
- });
111
- }
112
-
113
- function extractContentFromOutput(output: any[]): string {
114
- return output
115
- .filter((item: any) => item.type === "message")
116
- .flatMap((item: any) =>
117
- (item.content ?? [])
118
- .filter((c: any) => c.type === "output_text")
119
- .map((c: any) => c.text),
120
- )
121
- .join("");
122
- }
123
-
124
- async function complete(
125
- request: CompletionRequest,
126
- ): Promise<CompletionResult> {
127
- const { instructions, input, previous_response_id } = buildInput(
128
- request.messages,
129
- request.previousResponseId,
130
- );
122
+ function buildBody(request: CompletionRequest, stream: boolean): Record<string, unknown> {
123
+ const { input, previous_response_id } = buildInput(request.conversation);
131
124
 
132
125
  const body: Record<string, unknown> = {
133
126
  model: request.model,
134
127
  input,
135
- ...(instructions ? { instructions } : {}),
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 } : {}),
136
132
  ...(previous_response_id ? { previous_response_id } : {}),
137
- ...(request.temperature != null
138
- ? { temperature: request.temperature }
139
- : {}),
140
- ...(request.maxTokens != null
141
- ? { max_output_tokens: request.maxTokens }
142
- : {}),
133
+ ...(request.temperature != null ? { temperature: request.temperature } : {}),
134
+ ...(request.maxTokens != null ? { max_output_tokens: request.maxTokens } : {}),
143
135
  };
144
136
 
145
137
  const tools = translateTools(request.tools);
@@ -151,6 +143,43 @@ export function openai(config: OpenAIConfig): LLMProvider {
151
143
  body.tool_choice = request.toolChoice;
152
144
  }
153
145
 
146
+ return body;
147
+ }
148
+
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") {
161
+ let args: Record<string, unknown> = {};
162
+ try {
163
+ args = JSON.parse(item.arguments);
164
+ } catch {}
165
+ blocks.push({
166
+ type: "tool_call",
167
+ id: item.call_id,
168
+ name: item.name,
169
+ arguments: args,
170
+ });
171
+ }
172
+ }
173
+ return blocks;
174
+ }
175
+
176
+ // ─── complete (non-streaming) ─────────────────────────────────
177
+
178
+ async function complete(
179
+ request: CompletionRequest,
180
+ ): Promise<CompletionResult> {
181
+ const body = buildBody(request, false);
182
+
154
183
  const response = await fetch(`${baseUrl}/responses`, {
155
184
  method: "POST",
156
185
  headers: {
@@ -165,50 +194,25 @@ export function openai(config: OpenAIConfig): LLMProvider {
165
194
  throw new Error(`OpenAI API error ${response.status}: ${error}`);
166
195
  }
167
196
 
168
- const data = (await response.json());
169
- const toolCalls = extractToolCallsFromOutput(data.output ?? []);
170
- 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");
171
200
 
172
201
  return {
173
- content: extractContentFromOutput(data.output ?? []),
174
- toolCalls,
202
+ blocks,
175
203
  usage: parseUsage(data.usage),
176
204
  finishReason: hasToolCalls ? "tool_call" : "stop",
177
205
  responseId: data.id,
178
206
  };
179
207
  }
180
208
 
209
+ // ─── streamComplete ───────────────────────────────────────────
210
+
181
211
  async function streamComplete(
182
212
  request: CompletionRequest,
183
213
  onChunk: (chunk: StreamChunk) => void,
184
214
  ): Promise<CompletionResult> {
185
- const { instructions, input, previous_response_id } = buildInput(
186
- request.messages,
187
- request.previousResponseId,
188
- );
189
-
190
- const body: Record<string, unknown> = {
191
- model: request.model,
192
- input,
193
- stream: true,
194
- ...(instructions ? { instructions } : {}),
195
- ...(previous_response_id ? { previous_response_id } : {}),
196
- ...(request.temperature != null
197
- ? { temperature: request.temperature }
198
- : {}),
199
- ...(request.maxTokens != null
200
- ? { max_output_tokens: request.maxTokens }
201
- : {}),
202
- };
203
-
204
- const tools = translateTools(request.tools);
205
- if (tools) body.tools = tools;
206
- if (request.webSearch) {
207
- body.tools = [...(tools ?? []), { type: "web_search_preview" }];
208
- }
209
- if (request.toolChoice) {
210
- body.tool_choice = request.toolChoice;
211
- }
215
+ const body = buildBody(request, true);
212
216
 
213
217
  const response = await fetch(`${baseUrl}/responses`, {
214
218
  method: "POST",
@@ -224,9 +228,15 @@ export function openai(config: OpenAIConfig): LLMProvider {
224
228
  throw new Error(`OpenAI API error ${response.status}: ${error}`);
225
229
  }
226
230
 
227
- 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
+
228
239
  let responseId = "";
229
- let finishReason: FinishReason = "stop";
230
240
  let usage: TokenUsage = {
231
241
  inputTokens: 0,
232
242
  outputTokens: 0,
@@ -234,8 +244,6 @@ export function openai(config: OpenAIConfig): LLMProvider {
234
244
  cachedTokens: 0,
235
245
  reasoningTokens: 0,
236
246
  };
237
- const completedToolCalls: ToolCall[] = [];
238
- const toolCallArgBuffers = new Map<string, string>();
239
247
 
240
248
  const reader = response.body!.getReader();
241
249
  const decoder = new TextDecoder();
@@ -258,62 +266,90 @@ export function openai(config: OpenAIConfig): LLMProvider {
258
266
  const event = JSON.parse(data);
259
267
 
260
268
  switch (event.type) {
261
- case "response.output_text.delta":
262
- if (event.delta) {
263
- content += event.delta;
264
- onChunk({ type: "content_delta", content: event.delta });
265
- }
266
- break;
267
-
268
- case "response.function_call_arguments.delta":
269
- if (event.call_id && event.delta) {
270
- const existing = toolCallArgBuffers.get(event.call_id) ?? "";
271
- toolCallArgBuffers.set(event.call_id, existing + event.delta);
272
- }
273
- break;
274
-
275
- case "response.output_item.added":
276
- if (event.item?.type === "function_call") {
277
- 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);
278
287
  onChunk({
279
288
  type: "tool_call_start",
280
289
  toolCall: {
281
- id: event.item.call_id,
282
- name: event.item.name,
290
+ id: item.call_id,
291
+ name: item.name,
283
292
  arguments: {},
284
293
  },
285
294
  });
286
295
  }
287
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
+ }
288
309
 
289
- case "response.output_item.done":
290
- if (event.item?.type === "function_call") {
291
- const buffered = toolCallArgBuffers.get(event.item.call_id);
292
- const argsStr = (buffered && buffered.length > 0)
293
- ? buffered
294
- : (event.item.arguments ?? "{}");
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);
322
+ const argsStr =
323
+ buffered && buffered.length > 0
324
+ ? buffered
325
+ : item.arguments ?? "{}";
295
326
  let args: Record<string, unknown> = {};
296
327
  try {
297
328
  args = JSON.parse(argsStr);
298
329
  } catch {}
299
- completedToolCalls.push({
300
- id: event.item.call_id,
301
- name: event.item.name,
302
- arguments: args,
303
- });
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
+ }
304
337
  }
305
338
  break;
339
+ }
306
340
 
307
- case "response.completed":
341
+ case "response.completed": {
308
342
  responseId = event.response?.id ?? "";
309
343
  usage = parseUsage(event.response?.usage);
310
344
  onChunk({ type: "usage_update", usage });
311
-
312
- // Extract content from completed response if not streamed
313
- if (!content && event.response?.output) {
314
- 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);
315
350
  }
316
351
  break;
352
+ }
317
353
  }
318
354
  } catch {
319
355
  // Skip malformed JSON
@@ -321,31 +357,44 @@ export function openai(config: OpenAIConfig): LLMProvider {
321
357
  }
322
358
  }
323
359
 
324
- 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");
325
365
 
326
366
  return {
327
- content,
328
- toolCalls: completedToolCalls,
367
+ blocks,
329
368
  usage,
330
- finishReason,
369
+ finishReason: hasToolCalls ? "tool_call" : "stop",
331
370
  responseId,
332
371
  };
333
372
  }
334
373
 
335
- const pricing: Record<string, { inputPer1M: number; outputPer1M: number; cachedInputPer1M?: number; reasoningPer1M?: number }> = {
336
- "gpt-4o": { inputPer1M: 2.50, outputPer1M: 10.00, cachedInputPer1M: 1.25 },
337
- "gpt-4o-mini": { inputPer1M: 0.15, outputPer1M: 0.60, cachedInputPer1M: 0.075 },
338
- "o3": { inputPer1M: 10.00, outputPer1M: 40.00, reasoningPer1M: 40.00 },
339
- "o3-mini": { inputPer1M: 1.10, outputPer1M: 4.40, reasoningPer1M: 4.40 },
340
- "gpt-4.1": { inputPer1M: 2.00, outputPer1M: 8.00, cachedInputPer1M: 0.50 },
341
- "gpt-4.1-mini": { inputPer1M: 0.40, outputPer1M: 1.60, cachedInputPer1M: 0.10 },
342
- "gpt-4.1-nano": { inputPer1M: 0.10, outputPer1M: 0.40, cachedInputPer1M: 0.025 },
343
- "gpt-5.4-nano": { inputPer1M: 0.10, outputPer1M: 0.40, cachedInputPer1M: 0.025 },
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 },
344
392
  };
345
393
 
346
394
  return {
347
395
  name: "openai",
348
396
  models: Object.keys(pricing),
397
+ supportsContinuation: true,
349
398
  complete,
350
399
  streamComplete,
351
400
  getPricing: (model: string) => pricing[model],