@arcote.tech/arc-ai-openai 0.7.11 → 0.7.13

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 +139 -31
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.7.11",
4
+ "version": "0.7.13",
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.7.11",
13
+ "@arcote.tech/arc-ai": "^0.7.13",
14
14
  "typescript": "^5.0.0"
15
15
  },
16
16
  "devDependencies": {
package/src/index.ts CHANGED
@@ -1,14 +1,17 @@
1
1
  import type {
2
- LLMProvider,
2
+ ArcFileRef,
3
+ AssistantContentBlock,
4
+ BoundProviderFile,
3
5
  CompletionRequest,
4
6
  CompletionResult,
5
7
  Conversation,
6
8
  ConversationTurn,
7
- AssistantContentBlock,
9
+ FileDownloader,
10
+ FinishReason,
11
+ LLMProvider,
8
12
  StreamChunk,
9
13
  ToolCall,
10
14
  TokenUsage,
11
- FinishReason,
12
15
  } from "@arcote.tech/arc-ai";
13
16
 
14
17
  // ─── Config ──────────────────────────────────────────────────────
@@ -17,6 +20,13 @@ export interface OpenAIConfig {
17
20
  apiKey: string;
18
21
  baseUrl?: string;
19
22
  defaultModel?: string;
23
+ /**
24
+ * Opcjonalny downloader pobierający binary z S3 dla file attachmentów.
25
+ * Wymagany jeśli consumer wysyła `CompletionRequest.files` — adapter
26
+ * używa go w lazy upload do OpenAI Files API (`purpose=user_data`).
27
+ * Bez fileDownloadera + z `request.files` adapter rzuci jasny error.
28
+ */
29
+ fileDownloader?: FileDownloader;
20
30
  }
21
31
 
22
32
  // ─── Adapter (Responses API) ────────────────────────────────────
@@ -51,17 +61,31 @@ export function openai(config: OpenAIConfig): LLMProvider {
51
61
 
52
62
  /**
53
63
  * 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.
64
+ * input items, preserving block ordering for assistant turns.
65
+ *
66
+ * Jeśli `files` jest podany dla user turn, content staje się tablicą z
67
+ * `input_file` blockami przed `input_text`. Wywoływać tylko dla ostatniego
68
+ * user turn (pliki są attachmentem do tego konkretnego user message).
57
69
  */
58
- function turnToInputItems(turn: ConversationTurn): unknown[] {
70
+ function turnToInputItems(
71
+ turn: ConversationTurn,
72
+ files?: ArcFileRef[],
73
+ ): unknown[] {
59
74
  if (turn.role === "user") {
75
+ const fileItems = (files ?? [])
76
+ .filter((f) => f.providerFileIds?.openai)
77
+ .map((f) => ({ type: "input_file", file_id: f.providerFileIds!.openai }));
78
+ if (fileItems.length === 0) {
79
+ return [{ type: "message", role: "user", content: turn.content }];
80
+ }
60
81
  return [
61
82
  {
62
83
  type: "message",
63
84
  role: "user",
64
- content: turn.content,
85
+ content: [
86
+ ...fileItems,
87
+ { type: "input_text", text: turn.content },
88
+ ],
65
89
  },
66
90
  ];
67
91
  }
@@ -104,29 +128,48 @@ export function openai(config: OpenAIConfig): LLMProvider {
104
128
  return items;
105
129
  }
106
130
 
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
- };
131
+ function buildInput(
132
+ conversation: Conversation,
133
+ files?: ArcFileRef[],
134
+ ): { input: unknown[]; previous_response_id?: string } {
135
+ const turns =
136
+ conversation.mode === "full"
137
+ ? conversation.turns
138
+ : conversation.newTurns;
139
+
140
+ // Pliki idą do ostatniego user turn (najnowsza wiadomość usera).
141
+ // findLastIndex wymaga ES2023 — fallback przez reverse iteration.
142
+ let lastUserIdx = -1;
143
+ for (let i = turns.length - 1; i >= 0; i--) {
144
+ if (turns[i].role === "user") {
145
+ lastUserIdx = i;
146
+ break;
147
+ }
115
148
  }
116
- return {
117
- input: conversation.newTurns.flatMap(turnToInputItems),
118
- previous_response_id: conversation.previousResponseId,
119
- };
149
+
150
+ const input = turns.flatMap((turn, i) =>
151
+ turnToInputItems(turn, i === lastUserIdx ? files : undefined),
152
+ );
153
+
154
+ if (conversation.mode === "continuation") {
155
+ return { input, previous_response_id: conversation.previousResponseId };
156
+ }
157
+ return { input };
120
158
  }
121
159
 
122
- function buildBody(request: CompletionRequest, stream: boolean): Record<string, unknown> {
123
- const { input, previous_response_id } = buildInput(request.conversation);
160
+ function buildBody(
161
+ request: CompletionRequest,
162
+ stream: boolean,
163
+ enrichedFiles: ArcFileRef[],
164
+ ): Record<string, unknown> {
165
+ const { input, previous_response_id } = buildInput(
166
+ request.conversation,
167
+ enrichedFiles,
168
+ );
124
169
 
125
170
  const body: Record<string, unknown> = {
126
171
  model: request.model,
127
172
  input,
128
- // `instructions` is sent on every call. With previous_response_id it
129
- // replaces the prior server-side instructions for this turn.
130
173
  instructions: request.instructions,
131
174
  ...(stream ? { stream: true } : {}),
132
175
  ...(previous_response_id ? { previous_response_id } : {}),
@@ -176,12 +219,79 @@ export function openai(config: OpenAIConfig): LLMProvider {
176
219
  return blocks;
177
220
  }
178
221
 
222
+ // ─── File uploads (OpenAI Files API) ─────────────────────────
223
+
224
+ async function uploadFileToOpenAI(file: ArcFileRef): Promise<string> {
225
+ if (!config.fileDownloader) {
226
+ throw new Error(
227
+ `openai adapter received request.files but no fileDownloader was configured — wstrzyknij \`fileDownloader\` w openai({...})`,
228
+ );
229
+ }
230
+ const buffer = await config.fileDownloader.download(file.s3Key);
231
+ const form = new FormData();
232
+ form.append("purpose", "user_data");
233
+ // OpenAI Files API odrzuca `purpose=user_data` z generic
234
+ // `application/octet-stream` ("badly formatted or corrupted"). Jeśli
235
+ // upstream pickier nie wykrył mime z extension, pomijamy `type:` na
236
+ // Blob — OpenAI sam infer'uje typ z `filename` field formData.
237
+ const effectiveMime =
238
+ file.mime && file.mime !== "application/octet-stream" ? file.mime : "";
239
+ form.append(
240
+ "file",
241
+ new Blob([new Uint8Array(buffer)], effectiveMime ? { type: effectiveMime } : {}),
242
+ file.name,
243
+ );
244
+ const resp = await fetch(`${baseUrl}/files`, {
245
+ method: "POST",
246
+ headers: { Authorization: `Bearer ${config.apiKey}` },
247
+ body: form,
248
+ });
249
+ if (!resp.ok) {
250
+ const text = await resp.text();
251
+ throw new Error(`OpenAI Files API error ${resp.status}: ${text}`);
252
+ }
253
+ const data = await resp.json();
254
+ if (!data.id) throw new Error(`OpenAI Files API returned no id: ${JSON.stringify(data)}`);
255
+ return data.id as string;
256
+ }
257
+
258
+ /**
259
+ * Dla każdego pliku w request bez `providerFileIds.openai` robi lazy
260
+ * upload do OpenAI Files API. Zwraca:
261
+ * - `enriched`: pliki z uzupełnionym `providerFileIds.openai` (do
262
+ * użycia w `turnToInputItems`)
263
+ * - `bound`: nowe binding'i (consumer zapisuje w aggregate'cie żeby
264
+ * następne request'y nie powtarzały uploadu)
265
+ */
266
+ async function ensureFileIds(
267
+ files: ArcFileRef[] | undefined,
268
+ ): Promise<{ enriched: ArcFileRef[]; bound: BoundProviderFile[] }> {
269
+ if (!files || files.length === 0) return { enriched: [], bound: [] };
270
+ const enriched: ArcFileRef[] = [];
271
+ const bound: BoundProviderFile[] = [];
272
+ for (const f of files) {
273
+ const existing = f.providerFileIds?.openai;
274
+ if (existing) {
275
+ enriched.push(f);
276
+ continue;
277
+ }
278
+ const providerFileId = await uploadFileToOpenAI(f);
279
+ bound.push({ fileId: f.fileId, provider: "openai", providerFileId });
280
+ enriched.push({
281
+ ...f,
282
+ providerFileIds: { ...(f.providerFileIds ?? {}), openai: providerFileId },
283
+ });
284
+ }
285
+ return { enriched, bound };
286
+ }
287
+
179
288
  // ─── complete (non-streaming) ─────────────────────────────────
180
289
 
181
290
  async function complete(
182
291
  request: CompletionRequest,
183
292
  ): Promise<CompletionResult> {
184
- const body = buildBody(request, false);
293
+ const { enriched, bound } = await ensureFileIds(request.files);
294
+ const body = buildBody(request, false, enriched);
185
295
 
186
296
  const response = await fetch(`${baseUrl}/responses`, {
187
297
  method: "POST",
@@ -206,6 +316,7 @@ export function openai(config: OpenAIConfig): LLMProvider {
206
316
  usage: parseUsage(data.usage),
207
317
  finishReason: hasToolCalls ? "tool_call" : "stop",
208
318
  responseId: data.id,
319
+ ...(bound.length > 0 ? { boundProviderFiles: bound } : {}),
209
320
  };
210
321
  }
211
322
 
@@ -215,7 +326,8 @@ export function openai(config: OpenAIConfig): LLMProvider {
215
326
  request: CompletionRequest,
216
327
  onChunk: (chunk: StreamChunk) => void,
217
328
  ): Promise<CompletionResult> {
218
- const body = buildBody(request, true);
329
+ const { enriched, bound } = await ensureFileIds(request.files);
330
+ const body = buildBody(request, true, enriched);
219
331
 
220
332
  const response = await fetch(`${baseUrl}/responses`, {
221
333
  method: "POST",
@@ -287,7 +399,6 @@ export function openai(config: OpenAIConfig): LLMProvider {
287
399
  orderedBlocks[idx] = block;
288
400
  toolCallArgBuffers.set(item.call_id, "");
289
401
  toolCallIndex.set(item.call_id, idx);
290
- // Phase 1: tool call ujawniony — klient pokazuje "Przygotowuje: {name}…"
291
402
  onChunk({
292
403
  type: "tool_call_started",
293
404
  toolCallId: item.call_id,
@@ -312,7 +423,6 @@ export function openai(config: OpenAIConfig): LLMProvider {
312
423
  if (event.call_id && event.delta) {
313
424
  const existing = toolCallArgBuffers.get(event.call_id) ?? "";
314
425
  toolCallArgBuffers.set(event.call_id, existing + event.delta);
315
- // Phase 2: streaming JSON args — opcjonalne dla UI (loader).
316
426
  onChunk({
317
427
  type: "tool_call_arguments_delta",
318
428
  toolCallId: event.call_id,
@@ -341,7 +451,6 @@ export function openai(config: OpenAIConfig): LLMProvider {
341
451
  block.arguments = args;
342
452
  }
343
453
  }
344
- // Phase 3: args complete — klient promotuje status do "executing".
345
454
  onChunk({
346
455
  type: "tool_call_arguments_complete",
347
456
  toolCallId: item.call_id,
@@ -355,8 +464,6 @@ export function openai(config: OpenAIConfig): LLMProvider {
355
464
  responseId = event.response?.id ?? "";
356
465
  usage = parseUsage(event.response?.usage);
357
466
  onChunk({ type: "usage_update", usage });
358
- // Final reconciliation: if our streaming reconstruction missed
359
- // anything, fall back to the completed output.
360
467
  if (orderedBlocks.length === 0 && event.response?.output) {
361
468
  const fallback = blocksFromOutput(event.response.output);
362
469
  orderedBlocks.push(...fallback);
@@ -381,6 +488,7 @@ export function openai(config: OpenAIConfig): LLMProvider {
381
488
  usage,
382
489
  finishReason: hasToolCalls ? "tool_call" : "stop",
383
490
  responseId,
491
+ ...(bound.length > 0 ? { boundProviderFiles: bound } : {}),
384
492
  };
385
493
  }
386
494