@arcote.tech/arc-ai-openai 0.4.9 → 0.5.2

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 +200 -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.2",
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.2",
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,130 @@ 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,
34
+ strict: false,
35
35
  }));
36
36
  }
37
37
 
38
- function parseUsage(raw: any): TokenUsage {
39
- const usage = raw.usage ?? {};
38
+ function parseUsage(usage: any): TokenUsage {
40
39
  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,
40
+ inputTokens: usage?.input_tokens ?? 0,
41
+ outputTokens: usage?.output_tokens ?? 0,
42
+ totalTokens: (usage?.input_tokens ?? 0) + (usage?.output_tokens ?? 0),
43
+ cachedTokens: usage?.input_tokens_details?.cached_tokens ?? 0,
44
+ reasoningTokens: usage?.output_tokens_details?.reasoning_tokens ?? 0,
47
45
  };
48
46
  }
49
47
 
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),
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 {
71
+ type: "message",
72
+ role: m.role,
73
+ content: m.content,
74
+ };
75
+ });
76
+
77
+ return {
78
+ instructions: systemMsg?.content,
79
+ input,
80
+ previous_response_id: previousResponseId,
81
+ };
82
+ }
83
+
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,
56
89
  }));
90
+
91
+ return {
92
+ instructions: systemMsg?.content,
93
+ input,
94
+ };
57
95
  }
58
96
 
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
- }
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
+ });
70
111
  }
71
112
 
72
- async function complete(request: CompletionRequest): Promise<CompletionResult> {
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
+ );
131
+
73
132
  const body: Record<string, unknown> = {
74
133
  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,
134
+ input,
135
+ ...(instructions ? { instructions } : {}),
136
+ ...(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
+ : {}),
83
143
  };
84
144
 
85
145
  const tools = translateTools(request.tools);
86
146
  if (tools) body.tools = tools;
87
-
88
147
  if (request.webSearch) {
89
- body.tools = [
90
- ...(tools ?? []),
91
- { type: "web_search_preview" },
92
- ];
148
+ body.tools = [...(tools ?? []), { type: "web_search_preview" }];
149
+ }
150
+ if (request.toolChoice) {
151
+ body.tool_choice = request.toolChoice;
93
152
  }
94
153
 
95
- const response = await fetch(`${baseUrl}/chat/completions`, {
154
+ const response = await fetch(`${baseUrl}/responses`, {
96
155
  method: "POST",
97
156
  headers: {
98
157
  "Content-Type": "application/json",
@@ -106,14 +165,16 @@ export function openai(config: OpenAIConfig): LLMProvider {
106
165
  throw new Error(`OpenAI API error ${response.status}: ${error}`);
107
166
  }
108
167
 
109
- const data = await response.json() as any;
110
- const choice = data.choices[0];
168
+ const data = (await response.json());
169
+ const toolCalls = extractToolCallsFromOutput(data.output ?? []);
170
+ const hasToolCalls = toolCalls.length > 0;
111
171
 
112
172
  return {
113
- content: choice.message?.content ?? "",
114
- toolCalls: extractToolCalls(choice),
115
- usage: parseUsage(data),
116
- finishReason: mapFinishReason(choice.finish_reason),
173
+ content: extractContentFromOutput(data.output ?? []),
174
+ toolCalls,
175
+ usage: parseUsage(data.usage),
176
+ finishReason: hasToolCalls ? "tool_call" : "stop",
177
+ responseId: data.id,
117
178
  };
118
179
  }
119
180
 
@@ -121,31 +182,35 @@ export function openai(config: OpenAIConfig): LLMProvider {
121
182
  request: CompletionRequest,
122
183
  onChunk: (chunk: StreamChunk) => void,
123
184
  ): Promise<CompletionResult> {
185
+ const { instructions, input, previous_response_id } = buildInput(
186
+ request.messages,
187
+ request.previousResponseId,
188
+ );
189
+
124
190
  const body: Record<string, unknown> = {
125
191
  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,
192
+ input,
134
193
  stream: true,
135
- stream_options: { include_usage: 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
+ : {}),
136
202
  };
137
203
 
138
204
  const tools = translateTools(request.tools);
139
205
  if (tools) body.tools = tools;
140
-
141
206
  if (request.webSearch) {
142
- body.tools = [
143
- ...(tools ?? []),
144
- { type: "web_search_preview" },
145
- ];
207
+ body.tools = [...(tools ?? []), { type: "web_search_preview" }];
208
+ }
209
+ if (request.toolChoice) {
210
+ body.tool_choice = request.toolChoice;
146
211
  }
147
212
 
148
- const response = await fetch(`${baseUrl}/chat/completions`, {
213
+ const response = await fetch(`${baseUrl}/responses`, {
149
214
  method: "POST",
150
215
  headers: {
151
216
  "Content-Type": "application/json",
@@ -160,6 +225,7 @@ export function openai(config: OpenAIConfig): LLMProvider {
160
225
  }
161
226
 
162
227
  let content = "";
228
+ let responseId = "";
163
229
  let finishReason: FinishReason = "stop";
164
230
  let usage: TokenUsage = {
165
231
  inputTokens: 0,
@@ -168,11 +234,8 @@ export function openai(config: OpenAIConfig): LLMProvider {
168
234
  cachedTokens: 0,
169
235
  reasoningTokens: 0,
170
236
  };
171
- const toolCallBuffers = new Map<
172
- number,
173
- { id: string; name: string; arguments: string }
174
- >();
175
237
  const completedToolCalls: ToolCall[] = [];
238
+ const toolCallArgBuffers = new Map<string, string>();
176
239
 
177
240
  const reader = response.body!.getReader();
178
241
  const decoder = new TextDecoder();
@@ -189,79 +252,68 @@ export function openai(config: OpenAIConfig): LLMProvider {
189
252
  for (const line of lines) {
190
253
  if (!line.startsWith("data: ")) continue;
191
254
  const data = line.slice(6).trim();
192
- if (data === "[DONE]") continue;
255
+ if (!data) continue;
193
256
 
194
257
  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
- }
258
+ const event = JSON.parse(data);
203
259
 
204
- const delta = parsed.choices?.[0]?.delta;
205
- if (!delta) continue;
260
+ 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;
206
267
 
207
- // Content delta
208
- if (delta.content) {
209
- content += delta.content;
210
- onChunk({ type: "content_delta", content: delta.content });
211
- }
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;
212
274
 
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
- });
275
+ case "response.output_item.added":
276
+ if (event.item?.type === "function_call") {
277
+ toolCallArgBuffers.set(event.item.call_id, "");
224
278
  onChunk({
225
279
  type: "tool_call_start",
226
- toolCall: { id: tc.id, name: tc.function?.name ?? "", arguments: {} },
280
+ toolCall: {
281
+ id: event.item.call_id,
282
+ name: event.item.name,
283
+ arguments: {},
284
+ },
227
285
  });
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
286
  }
239
- }
240
- }
287
+ break;
288
+
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 ?? "{}");
295
+ let args: Record<string, unknown> = {};
296
+ try {
297
+ args = JSON.parse(argsStr);
298
+ } catch {}
299
+ completedToolCalls.push({
300
+ id: event.item.call_id,
301
+ name: event.item.name,
302
+ arguments: args,
303
+ });
304
+ }
305
+ break;
241
306
 
242
- // Finish reason
243
- const fr = parsed.choices?.[0]?.finish_reason;
244
- if (fr) {
245
- finishReason = mapFinishReason(fr);
307
+ case "response.completed":
308
+ responseId = event.response?.id ?? "";
309
+ usage = parseUsage(event.response?.usage);
310
+ onChunk({ type: "usage_update", usage });
246
311
 
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
- }
312
+ // Extract content from completed response if not streamed
313
+ if (!content && event.response?.output) {
314
+ content = extractContentFromOutput(event.response.output);
263
315
  }
264
- }
316
+ break;
265
317
  }
266
318
  } catch {
267
319
  // Skip malformed JSON
@@ -269,18 +321,33 @@ export function openai(config: OpenAIConfig): LLMProvider {
269
321
  }
270
322
  }
271
323
 
324
+ finishReason = completedToolCalls.length > 0 ? "tool_call" : "stop";
325
+
272
326
  return {
273
327
  content,
274
328
  toolCalls: completedToolCalls,
275
329
  usage,
276
330
  finishReason,
331
+ responseId,
277
332
  };
278
333
  }
279
334
 
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 },
344
+ };
345
+
280
346
  return {
281
347
  name: "openai",
282
- models: ["gpt-4o", "gpt-4o-mini", "o3", "o3-mini", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano"],
348
+ models: Object.keys(pricing),
283
349
  complete,
284
350
  streamComplete,
351
+ getPricing: (model: string) => pricing[model],
285
352
  };
286
353
  }