@arcote.tech/arc-ai-openai 0.7.12 → 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.
- package/package.json +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.
|
|
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.
|
|
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
|
-
|
|
2
|
+
ArcFileRef,
|
|
3
|
+
AssistantContentBlock,
|
|
4
|
+
BoundProviderFile,
|
|
3
5
|
CompletionRequest,
|
|
4
6
|
CompletionResult,
|
|
5
7
|
Conversation,
|
|
6
8
|
ConversationTurn,
|
|
7
|
-
|
|
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.
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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(
|
|
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:
|
|
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(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
} {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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(
|
|
123
|
-
|
|
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
|
|
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
|
|
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
|
|