@arcote.tech/arc-ai-openai 0.4.9 → 0.5.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.
Files changed (2) hide show
  1. package/package.json +2 -2
  2. package/src/index.ts +190 -133
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.4.9",
4
+ "version": "0.5.0",
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.4.9",
13
+ "@arcote.tech/arc-ai": "^0.5.0",
14
14
  "typescript": "^5.0.0"
15
15
  },
16
16
  "devDependencies": {
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  ToolCall,
7
7
  TokenUsage,
8
8
  FinishReason,
9
+ Message,
9
10
  } from "@arcote.tech/arc-ai";
10
11
 
11
12
  // ─── Config ──────────────────────────────────────────────────────
@@ -16,7 +17,7 @@ export interface OpenAIConfig {
16
17
  defaultModel?: string;
17
18
  }
18
19
 
19
- // ─── Adapter ─────────────────────────────────────────────────────
20
+ // ─── Adapter (Responses API) ────────────────────────────────────
20
21
 
21
22
  export function openai(config: OpenAIConfig): LLMProvider {
22
23
  const baseUrl = config.baseUrl ?? "https://api.openai.com/v1";
@@ -27,72 +28,126 @@ export function openai(config: OpenAIConfig): LLMProvider {
27
28
  if (!tools || tools.length === 0) return undefined;
28
29
  return tools.map((t) => ({
29
30
  type: "function",
30
- function: {
31
- name: t.name,
32
- description: t.description,
33
- parameters: t.parameters,
34
- },
31
+ name: t.name,
32
+ description: t.description,
33
+ parameters: t.parameters,
35
34
  }));
36
35
  }
37
36
 
38
- function parseUsage(raw: any): TokenUsage {
39
- const usage = raw.usage ?? {};
37
+ function parseUsage(usage: any): TokenUsage {
40
38
  return {
41
- inputTokens: usage.prompt_tokens ?? 0,
42
- outputTokens: usage.completion_tokens ?? 0,
43
- totalTokens: usage.total_tokens ?? 0,
44
- cachedTokens: usage.prompt_tokens_details?.cached_tokens ?? 0,
45
- reasoningTokens:
46
- usage.completion_tokens_details?.reasoning_tokens ?? 0,
39
+ inputTokens: usage?.input_tokens ?? 0,
40
+ outputTokens: usage?.output_tokens ?? 0,
41
+ totalTokens: (usage?.input_tokens ?? 0) + (usage?.output_tokens ?? 0),
42
+ cachedTokens: usage?.input_tokens_details?.cached_tokens ?? 0,
43
+ reasoningTokens: usage?.output_tokens_details?.reasoning_tokens ?? 0,
47
44
  };
48
45
  }
49
46
 
50
- function extractToolCalls(choice: any): ToolCall[] {
51
- const toolCalls = choice.message?.tool_calls ?? [];
52
- return toolCalls.map((tc: any) => ({
53
- id: tc.id,
54
- name: tc.function.name,
55
- arguments: JSON.parse(tc.function.arguments),
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 {
70
+ type: "message",
71
+ role: m.role,
72
+ content: m.content,
73
+ };
74
+ });
75
+
76
+ return {
77
+ instructions: systemMsg?.content,
78
+ input,
79
+ previous_response_id: previousResponseId,
80
+ };
81
+ }
82
+
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,
56
88
  }));
89
+
90
+ return {
91
+ instructions: systemMsg?.content,
92
+ input,
93
+ };
57
94
  }
58
95
 
59
- function mapFinishReason(reason: string): FinishReason {
60
- switch (reason) {
61
- case "stop":
62
- return "stop";
63
- case "tool_calls":
64
- return "tool_call";
65
- case "length":
66
- return "max_tokens";
67
- default:
68
- return "stop";
69
- }
96
+ function extractToolCallsFromOutput(output: any[]): ToolCall[] {
97
+ return output
98
+ .filter((item: any) => item.type === "function_call")
99
+ .map((item: any) => {
100
+ let args: Record<string, unknown> = {};
101
+ try {
102
+ args = JSON.parse(item.arguments);
103
+ } catch {}
104
+ return {
105
+ id: item.call_id,
106
+ name: item.name,
107
+ arguments: args,
108
+ };
109
+ });
110
+ }
111
+
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("");
70
121
  }
71
122
 
72
- async function complete(request: CompletionRequest): Promise<CompletionResult> {
123
+ async function complete(
124
+ request: CompletionRequest,
125
+ ): Promise<CompletionResult> {
126
+ const { instructions, input, previous_response_id } = buildInput(
127
+ request.messages,
128
+ request.previousResponseId,
129
+ );
130
+
73
131
  const body: Record<string, unknown> = {
74
132
  model: request.model,
75
- messages: request.messages.map((m) => ({
76
- role: m.role,
77
- content: m.content,
78
- ...(m.name ? { name: m.name } : {}),
79
- ...(m.toolCallId ? { tool_call_id: m.toolCallId } : {}),
80
- })),
81
- temperature: request.temperature,
82
- max_tokens: request.maxTokens,
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
+ : {}),
83
142
  };
84
143
 
85
144
  const tools = translateTools(request.tools);
86
145
  if (tools) body.tools = tools;
87
-
88
146
  if (request.webSearch) {
89
- body.tools = [
90
- ...(tools ?? []),
91
- { type: "web_search_preview" },
92
- ];
147
+ body.tools = [...(tools ?? []), { type: "web_search_preview" }];
93
148
  }
94
149
 
95
- const response = await fetch(`${baseUrl}/chat/completions`, {
150
+ const response = await fetch(`${baseUrl}/responses`, {
96
151
  method: "POST",
97
152
  headers: {
98
153
  "Content-Type": "application/json",
@@ -106,14 +161,16 @@ export function openai(config: OpenAIConfig): LLMProvider {
106
161
  throw new Error(`OpenAI API error ${response.status}: ${error}`);
107
162
  }
108
163
 
109
- const data = await response.json() as any;
110
- const choice = data.choices[0];
164
+ const data = (await response.json());
165
+ const toolCalls = extractToolCallsFromOutput(data.output ?? []);
166
+ const hasToolCalls = toolCalls.length > 0;
111
167
 
112
168
  return {
113
- content: choice.message?.content ?? "",
114
- toolCalls: extractToolCalls(choice),
115
- usage: parseUsage(data),
116
- finishReason: mapFinishReason(choice.finish_reason),
169
+ content: extractContentFromOutput(data.output ?? []),
170
+ toolCalls,
171
+ usage: parseUsage(data.usage),
172
+ finishReason: hasToolCalls ? "tool_call" : "stop",
173
+ responseId: data.id,
117
174
  };
118
175
  }
119
176
 
@@ -121,31 +178,32 @@ export function openai(config: OpenAIConfig): LLMProvider {
121
178
  request: CompletionRequest,
122
179
  onChunk: (chunk: StreamChunk) => void,
123
180
  ): Promise<CompletionResult> {
181
+ const { instructions, input, previous_response_id } = buildInput(
182
+ request.messages,
183
+ request.previousResponseId,
184
+ );
185
+
124
186
  const body: Record<string, unknown> = {
125
187
  model: request.model,
126
- messages: request.messages.map((m) => ({
127
- role: m.role,
128
- content: m.content,
129
- ...(m.name ? { name: m.name } : {}),
130
- ...(m.toolCallId ? { tool_call_id: m.toolCallId } : {}),
131
- })),
132
- temperature: request.temperature,
133
- max_tokens: request.maxTokens,
188
+ input,
134
189
  stream: true,
135
- stream_options: { include_usage: 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
+ : {}),
136
198
  };
137
199
 
138
200
  const tools = translateTools(request.tools);
139
201
  if (tools) body.tools = tools;
140
-
141
202
  if (request.webSearch) {
142
- body.tools = [
143
- ...(tools ?? []),
144
- { type: "web_search_preview" },
145
- ];
203
+ body.tools = [...(tools ?? []), { type: "web_search_preview" }];
146
204
  }
147
205
 
148
- const response = await fetch(`${baseUrl}/chat/completions`, {
206
+ const response = await fetch(`${baseUrl}/responses`, {
149
207
  method: "POST",
150
208
  headers: {
151
209
  "Content-Type": "application/json",
@@ -160,6 +218,7 @@ export function openai(config: OpenAIConfig): LLMProvider {
160
218
  }
161
219
 
162
220
  let content = "";
221
+ let responseId = "";
163
222
  let finishReason: FinishReason = "stop";
164
223
  let usage: TokenUsage = {
165
224
  inputTokens: 0,
@@ -168,11 +227,8 @@ export function openai(config: OpenAIConfig): LLMProvider {
168
227
  cachedTokens: 0,
169
228
  reasoningTokens: 0,
170
229
  };
171
- const toolCallBuffers = new Map<
172
- number,
173
- { id: string; name: string; arguments: string }
174
- >();
175
230
  const completedToolCalls: ToolCall[] = [];
231
+ const toolCallArgBuffers = new Map<string, string>();
176
232
 
177
233
  const reader = response.body!.getReader();
178
234
  const decoder = new TextDecoder();
@@ -189,79 +245,68 @@ export function openai(config: OpenAIConfig): LLMProvider {
189
245
  for (const line of lines) {
190
246
  if (!line.startsWith("data: ")) continue;
191
247
  const data = line.slice(6).trim();
192
- if (data === "[DONE]") continue;
248
+ if (!data) continue;
193
249
 
194
250
  try {
195
- const parsed = JSON.parse(data);
196
-
197
- // Usage-only chunk (last chunk)
198
- if (parsed.usage && !parsed.choices?.length) {
199
- usage = parseUsage(parsed);
200
- onChunk({ type: "usage_update", usage });
201
- continue;
202
- }
251
+ const event = JSON.parse(data);
203
252
 
204
- const delta = parsed.choices?.[0]?.delta;
205
- if (!delta) continue;
253
+ 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;
206
260
 
207
- // Content delta
208
- if (delta.content) {
209
- content += delta.content;
210
- onChunk({ type: "content_delta", content: delta.content });
211
- }
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;
212
267
 
213
- // Tool call chunks (arguments arrive fragmented)
214
- if (delta.tool_calls) {
215
- for (const tc of delta.tool_calls) {
216
- const idx = tc.index;
217
- if (tc.id) {
218
- // First chunk for this tool call
219
- toolCallBuffers.set(idx, {
220
- id: tc.id,
221
- name: tc.function?.name ?? "",
222
- arguments: tc.function?.arguments ?? "",
223
- });
268
+ case "response.output_item.added":
269
+ if (event.item?.type === "function_call") {
270
+ toolCallArgBuffers.set(event.item.call_id, "");
224
271
  onChunk({
225
272
  type: "tool_call_start",
226
- toolCall: { id: tc.id, name: tc.function?.name ?? "", arguments: {} },
273
+ toolCall: {
274
+ id: event.item.call_id,
275
+ name: event.item.name,
276
+ arguments: {},
277
+ },
227
278
  });
228
- } else {
229
- // Continuation chunk
230
- const buf = toolCallBuffers.get(idx);
231
- if (buf && tc.function?.arguments) {
232
- buf.arguments += tc.function.arguments;
233
- onChunk({
234
- type: "tool_call_delta",
235
- content: tc.function.arguments,
236
- });
237
- }
238
279
  }
239
- }
240
- }
280
+ break;
281
+
282
+ case "response.output_item.done":
283
+ if (event.item?.type === "function_call") {
284
+ const argsStr =
285
+ toolCallArgBuffers.get(event.item.call_id) ??
286
+ event.item.arguments ??
287
+ "{}";
288
+ let args: Record<string, unknown> = {};
289
+ try {
290
+ args = JSON.parse(argsStr);
291
+ } catch {}
292
+ completedToolCalls.push({
293
+ id: event.item.call_id,
294
+ name: event.item.name,
295
+ arguments: args,
296
+ });
297
+ }
298
+ break;
241
299
 
242
- // Finish reason
243
- const fr = parsed.choices?.[0]?.finish_reason;
244
- if (fr) {
245
- finishReason = mapFinishReason(fr);
300
+ case "response.completed":
301
+ responseId = event.response?.id ?? "";
302
+ usage = parseUsage(event.response?.usage);
303
+ onChunk({ type: "usage_update", usage });
246
304
 
247
- // Finalize tool calls
248
- if (fr === "tool_calls") {
249
- for (const buf of toolCallBuffers.values()) {
250
- try {
251
- completedToolCalls.push({
252
- id: buf.id,
253
- name: buf.name,
254
- arguments: JSON.parse(buf.arguments),
255
- });
256
- } catch {
257
- completedToolCalls.push({
258
- id: buf.id,
259
- name: buf.name,
260
- arguments: {},
261
- });
262
- }
305
+ // Extract content from completed response if not streamed
306
+ if (!content && event.response?.output) {
307
+ content = extractContentFromOutput(event.response.output);
263
308
  }
264
- }
309
+ break;
265
310
  }
266
311
  } catch {
267
312
  // Skip malformed JSON
@@ -269,17 +314,29 @@ export function openai(config: OpenAIConfig): LLMProvider {
269
314
  }
270
315
  }
271
316
 
317
+ finishReason = completedToolCalls.length > 0 ? "tool_call" : "stop";
318
+
272
319
  return {
273
320
  content,
274
321
  toolCalls: completedToolCalls,
275
322
  usage,
276
323
  finishReason,
324
+ responseId,
277
325
  };
278
326
  }
279
327
 
280
328
  return {
281
329
  name: "openai",
282
- models: ["gpt-4o", "gpt-4o-mini", "o3", "o3-mini", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano"],
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
+ ],
283
340
  complete,
284
341
  streamComplete,
285
342
  };