zengate 1.0.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 (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +296 -0
  3. package/config.json.example +23 -0
  4. package/index.js +84 -0
  5. package/package.json +57 -0
  6. package/scripts/setup.mjs +24 -0
  7. package/src/bootstrap.js +66 -0
  8. package/src/cli.js +55 -0
  9. package/src/config.js +193 -0
  10. package/src/gateway.js +90 -0
  11. package/src/logger.js +35 -0
  12. package/src/openai/chat-request.js +183 -0
  13. package/src/openai/chat.js +100 -0
  14. package/src/openai/generate.js +143 -0
  15. package/src/openai/media.js +104 -0
  16. package/src/openai/models.js +17 -0
  17. package/src/openai/prompt.js +101 -0
  18. package/src/openai/response-builder.js +135 -0
  19. package/src/openai/responses-request.js +176 -0
  20. package/src/openai/responses-store.js +78 -0
  21. package/src/openai/responses.js +65 -0
  22. package/src/openai/sse-writer.js +37 -0
  23. package/src/openai/stop.js +41 -0
  24. package/src/openai/tool-calls.js +187 -0
  25. package/src/openai/url-guard.js +61 -0
  26. package/src/opencode/backend.js +201 -0
  27. package/src/opencode/binary.js +75 -0
  28. package/src/opencode/catalog.js +83 -0
  29. package/src/opencode/client.js +84 -0
  30. package/src/opencode/events.js +168 -0
  31. package/src/opencode/isolation.js +119 -0
  32. package/src/opencode/model-errors.js +66 -0
  33. package/src/opencode/runner.js +224 -0
  34. package/src/opencode/sse-reader.js +40 -0
  35. package/src/paths.js +40 -0
  36. package/src/server/app.js +96 -0
  37. package/src/server/errors.js +56 -0
  38. package/src/server/limiter.js +72 -0
  39. package/src/server/metrics.js +31 -0
  40. package/src/server/middleware.js +94 -0
  41. package/src/server/slot.js +32 -0
@@ -0,0 +1,143 @@
1
+ import { createStopFilter } from './stop.js';
2
+ import { createToolCallParser } from './tool-calls.js';
3
+
4
+ const STOP_REACHED = Symbol('stop-sequence');
5
+ const SEPARATOR = '\n\n';
6
+
7
+ const FORCED_RETRY_NOTE = 'To call a function, write a <tool_call>{"name": ..., "arguments": {...}}</tool_call> block as plain text in your reply. Do not describe the call; write the block.';
8
+
9
+ const addUsage = (a, b) => ({ input: a.input + b.input, output: a.output + b.output, reasoning: a.reasoning + b.reasoning, cacheRead: a.cacheRead + b.cacheRead });
10
+
11
+ /**
12
+ * Run one completion through OpenCode and apply the OpenAI-side semantics:
13
+ * stop sequences, emulated function calls and structured output.
14
+ *
15
+ * @param {{ runner: object, prompt: object, request: object, signal: AbortSignal,
16
+ * onText?: (text: string) => void, onReasoning?: (text: string) => void }} options
17
+ * @returns {Promise<{ content: string, reasoning: string, toolCalls: object[], finish: string, usage: object }>}
18
+ */
19
+ async function attempt({ runner, prompt, request, signal, onText, onReasoning }) {
20
+ const controller = new AbortController();
21
+ const forward = () => controller.abort(signal.reason);
22
+ if (signal.aborted) forward();
23
+ else signal.addEventListener('abort', forward, { once: true });
24
+
25
+ const stopFilter = createStopFilter(request.stop);
26
+ const parser = request.tools.length && request.toolChoice !== 'none' ? createToolCallParser(request.tools) : null;
27
+ // With native structured output, OpenCode returns the JSON separately
28
+ // (info.structured) and any streamed text may be prose, so hold it back.
29
+ const nativeFormat = Boolean(prompt.format);
30
+ let held = '';
31
+ let content = '';
32
+ let reasoning = '';
33
+
34
+ const emitText = (text) => {
35
+ if (!text) return;
36
+ content += text;
37
+ onText(text);
38
+ };
39
+ const acceptText = (text) => {
40
+ const allowed = stopFilter.push(text);
41
+ emitText(parser ? parser.push(allowed) : allowed);
42
+ if (stopFilter.stopped && !controller.signal.aborted) controller.abort(STOP_REACHED);
43
+ };
44
+
45
+ let result = null;
46
+ try {
47
+ result = await runner.run(prompt, {
48
+ signal: controller.signal,
49
+ onDelta(kind, text) {
50
+ if (kind === 'reasoning') {
51
+ reasoning += text;
52
+ onReasoning(text);
53
+ } else if (nativeFormat) {
54
+ held += text;
55
+ } else if (!stopFilter.stopped) {
56
+ acceptText(text);
57
+ }
58
+ },
59
+ });
60
+ } catch (error) {
61
+ if (controller.signal.reason !== STOP_REACHED) throw error;
62
+ } finally {
63
+ signal.removeEventListener('abort', forward);
64
+ }
65
+
66
+ if (result?.structured !== undefined) {
67
+ acceptText(typeof result.structured === 'string' ? result.structured : JSON.stringify(result.structured));
68
+ } else if (held) {
69
+ acceptText(held);
70
+ }
71
+ const tail = stopFilter.flush();
72
+ emitText(parser ? parser.push(tail) : tail);
73
+ let toolCalls = [];
74
+ if (parser) {
75
+ const finished = parser.finish();
76
+ toolCalls = finished.calls;
77
+ emitText(finished.text);
78
+ }
79
+ let finish = result?.finish || 'stop';
80
+ if (toolCalls.length) finish = 'tool_calls';
81
+ else if (stopFilter.stopped) finish = 'stop';
82
+
83
+ return {
84
+ content,
85
+ reasoning,
86
+ toolCalls,
87
+ finish,
88
+ usage: result?.usage || { input: 0, output: 0, reasoning: 0, cacheRead: 0 },
89
+ nativeToolAttempt: result?.nativeToolAttempt || null,
90
+ };
91
+ }
92
+
93
+ /**
94
+ * attempt() plus one corrective retry when the model tried to call a client
95
+ * function as a native tool, or answered in prose although tool_choice
96
+ * demands a call (weaker models occasionally do either).
97
+ * @param {Parameters<typeof attempt>[0]} options
98
+ */
99
+ export async function generate({ onText = () => {}, onReasoning = () => {}, ...options }) {
100
+ const first = await attempt({ ...options, onText, onReasoning });
101
+ const { request, prompt, signal } = options;
102
+ const forced = request.toolChoice === 'required' || (request.toolChoice && typeof request.toolChoice === 'object');
103
+ if (first.toolCalls.length || signal.aborted || !(forced || first.nativeToolAttempt)) return first;
104
+ const hasClientTools = request.tools.length > 0 && request.toolChoice !== 'none';
105
+ let note = FORCED_RETRY_NOTE;
106
+ if (first.nativeToolAttempt && hasClientTools) {
107
+ note = `Your previous attempt to call "${first.nativeToolAttempt}" as a native tool failed: client functions are not native tools. ${FORCED_RETRY_NOTE}`;
108
+ } else if (first.nativeToolAttempt) {
109
+ note = `There is no tool named "${first.nativeToolAttempt}". Answer directly without calling tools.`;
110
+ }
111
+ const retryPrompt = { ...prompt, system: [prompt.system, note].filter(Boolean).join(SEPARATOR) };
112
+ let separated = false;
113
+ const separate = (emit) => (text) => {
114
+ if (first.content && !separated && text) {
115
+ separated = true;
116
+ onText(SEPARATOR);
117
+ }
118
+ emit(text);
119
+ };
120
+ const second = await attempt({ ...options, prompt: retryPrompt, onText: separate(onText), onReasoning });
121
+ return {
122
+ ...second,
123
+ content: separated ? `${first.content}${SEPARATOR}${second.content}` : first.content + second.content,
124
+ reasoning: first.reasoning + second.reasoning,
125
+ usage: addUsage(first.usage, second.usage),
126
+ };
127
+ }
128
+
129
+ /** OpenAI Chat Completions usage object. */
130
+ export function chatUsage(usages) {
131
+ const total = usages.reduce((acc, u) => ({
132
+ input: acc.input + u.input, output: acc.output + u.output, reasoning: acc.reasoning + u.reasoning, cacheRead: acc.cacheRead + u.cacheRead,
133
+ }), { input: 0, output: 0, reasoning: 0, cacheRead: 0 });
134
+ const prompt = total.input + total.cacheRead;
135
+ const completion = total.output + total.reasoning;
136
+ return {
137
+ prompt_tokens: prompt,
138
+ completion_tokens: completion,
139
+ total_tokens: prompt + completion,
140
+ prompt_tokens_details: { cached_tokens: total.cacheRead },
141
+ completion_tokens_details: { reasoning_tokens: total.reasoning },
142
+ };
143
+ }
@@ -0,0 +1,104 @@
1
+ import { invalidRequest, unsupported } from '../server/errors.js';
2
+
3
+ /**
4
+ * Canonical media attachment:
5
+ * { kind: 'image'|'audio'|'video'|'pdf'|'text', mime, url, filename?, text? }
6
+ * `url` is a data: URI or an https URL; `text` holds decoded text files,
7
+ * which are inlined into the prompt rather than attached.
8
+ */
9
+
10
+ const AUDIO_MIME = Object.freeze({
11
+ wav: 'audio/wav', mp3: 'audio/mpeg', mpeg: 'audio/mpeg', ogg: 'audio/ogg', flac: 'audio/flac',
12
+ m4a: 'audio/mp4', aac: 'audio/aac', opus: 'audio/opus', webm: 'audio/webm', pcm16: 'audio/L16',
13
+ });
14
+ const EXTENSION_MIME = Object.freeze({
15
+ pdf: 'application/pdf', txt: 'text/plain', md: 'text/markdown', csv: 'text/csv', json: 'application/json',
16
+ xml: 'application/xml', html: 'text/html', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
17
+ gif: 'image/gif', webp: 'image/webp', mp3: 'audio/mpeg', wav: 'audio/wav', mp4: 'video/mp4', webm: 'video/webm',
18
+ });
19
+ const TEXT_MIME = /^(text\/[\w.+-]+|application\/(json|xml|x-yaml|yaml|javascript|x-sh))$/;
20
+ const DATA_URI = /^data:([\w.+-]+\/[\w.+-]+)(?:;[\w.+-]+=[\w.+-]+)*;base64,([A-Za-z0-9+/_-]*={0,2})$/;
21
+ const MAX_URL_CHARS = 8192;
22
+
23
+ export function kindForMime(mime) {
24
+ if (mime.startsWith('image/')) return 'image';
25
+ if (mime.startsWith('audio/')) return 'audio';
26
+ if (mime.startsWith('video/')) return 'video';
27
+ if (mime === 'application/pdf') return 'pdf';
28
+ if (TEXT_MIME.test(mime)) return 'text';
29
+ return null;
30
+ }
31
+
32
+ function checkSize(base64, maxBytes, param) {
33
+ const bytes = Math.floor((base64.length * 3) / 4);
34
+ if (bytes > maxBytes) {
35
+ throw invalidRequest(`Attachment is ${(bytes / 1048576).toFixed(1)} MB; the limit is ${(maxBytes / 1048576).toFixed(0)} MB per part (MAX_MEDIA_MB).`, param, 'attachment_too_large');
36
+ }
37
+ }
38
+
39
+ function fromDataUri(uri, { maxBytes, param, filename, expect }) {
40
+ const match = DATA_URI.exec(uri);
41
+ if (!match) throw invalidRequest('Attachments must be base64 data URIs (data:<mime>;base64,...) or https URLs.', param);
42
+ const mime = match[1].toLowerCase() === 'image/jpg' ? 'image/jpeg' : match[1].toLowerCase();
43
+ const kind = kindForMime(mime);
44
+ if (!kind) throw unsupported(`Unsupported attachment type '${mime}'.`, param);
45
+ if (expect && kind !== expect) throw invalidRequest(`Expected ${expect} data but received '${mime}'.`, param);
46
+ if (mime === 'image/svg+xml') throw unsupported('SVG images are not supported; send PNG, JPEG, GIF or WebP.', param);
47
+ checkSize(match[2], maxBytes, param);
48
+ if (kind === 'text') {
49
+ return { kind, mime, filename, text: Buffer.from(match[2], 'base64').toString('utf8') };
50
+ }
51
+ return { kind, mime, url: uri, filename };
52
+ }
53
+
54
+ function fromHttpsUrl(url, { param, kind, mime, filename }) {
55
+ if (url.length > MAX_URL_CHARS) throw invalidRequest(`URL is longer than ${MAX_URL_CHARS} characters.`, param);
56
+ let parsed;
57
+ try { parsed = new URL(url); } catch { throw invalidRequest('Invalid attachment URL.', param); }
58
+ if (parsed.protocol !== 'https:') throw invalidRequest('Attachment URLs must use https (or send a base64 data URI).', param);
59
+ return { kind, mime, url: parsed.href, filename };
60
+ }
61
+
62
+ function urlPath(url) {
63
+ try { return new URL(url).pathname; } catch { return ''; }
64
+ }
65
+
66
+ function guessMime(filename, fallback) {
67
+ const ext = String(filename || '').toLowerCase().split('.').pop();
68
+ return EXTENSION_MIME[ext] || fallback;
69
+ }
70
+
71
+ export function imageFromUrl(url, options) {
72
+ if (typeof url !== 'string' || !url) throw invalidRequest('image_url.url is required.', options.param);
73
+ if (url.startsWith('data:')) return fromDataUri(url, { ...options, expect: 'image' });
74
+ return fromHttpsUrl(url, { ...options, kind: 'image', mime: guessMime(urlPath(url), 'image/*') });
75
+ }
76
+
77
+ export function videoFromUrl(url, options) {
78
+ if (typeof url !== 'string' || !url) throw invalidRequest('video_url.url is required.', options.param);
79
+ if (url.startsWith('data:')) return fromDataUri(url, { ...options, expect: 'video' });
80
+ return fromHttpsUrl(url, { ...options, kind: 'video', mime: guessMime(urlPath(url), 'video/*') });
81
+ }
82
+
83
+ export function audioFromBase64(data, format, options) {
84
+ const mime = AUDIO_MIME[String(format || '').toLowerCase()];
85
+ if (!mime) throw invalidRequest(`input_audio.format must be one of: ${Object.keys(AUDIO_MIME).join(', ')}.`, options.param);
86
+ if (typeof data !== 'string' || !data) throw invalidRequest('input_audio.data (base64) is required.', options.param);
87
+ if (data.startsWith('data:')) return fromDataUri(data, { ...options, expect: 'audio' });
88
+ return fromDataUri(`data:${mime};base64,${data}`, { ...options, expect: 'audio' });
89
+ }
90
+
91
+ /** OpenAI `file` parts: file_data (data URI or bare base64), or file_url. */
92
+ export function fileAttachment({ file_data: fileData, file_id: fileId, file_url: fileUrl, filename }, options) {
93
+ const name = typeof filename === 'string' ? filename.slice(0, 255) : undefined;
94
+ if (fileId) throw unsupported('file_id requires the Files API, which this gateway does not provide; send file_data instead.', options.param);
95
+ if (typeof fileUrl === 'string' && fileUrl) {
96
+ const mime = guessMime(urlPath(fileUrl), 'application/pdf');
97
+ return fromHttpsUrl(fileUrl, { ...options, kind: kindForMime(mime) || 'pdf', mime, filename: name });
98
+ }
99
+ if (typeof fileData !== 'string' || !fileData) throw invalidRequest('file.file_data is required.', options.param);
100
+ if (fileData.startsWith('data:')) return fromDataUri(fileData, { ...options, filename: name });
101
+ const mime = guessMime(name, null);
102
+ if (!mime) throw invalidRequest('Send file_data as a data URI (data:<mime>;base64,...) or include a filename with an extension.', options.param);
103
+ return fromDataUri(`data:${mime};base64,${fileData}`, { ...options, filename: name });
104
+ }
@@ -0,0 +1,17 @@
1
+ import { toOpenAIModel } from '../opencode/catalog.js';
2
+
3
+ /** GET /v1/models and GET /v1/models/{id} (ids may contain a slash). */
4
+ export function modelsHandlers({ catalog }) {
5
+ const list = async (req, res) => {
6
+ const models = await catalog.list();
7
+ res.json({ object: 'list', data: models.map(toOpenAIModel) });
8
+ };
9
+
10
+ const retrieve = async (req, res) => {
11
+ const raw = req.params.id;
12
+ const id = Array.isArray(raw) ? raw.join('/') : String(raw);
13
+ res.json(toOpenAIModel(await catalog.resolve(id)));
14
+ };
15
+
16
+ return { list, retrieve };
17
+ }
@@ -0,0 +1,101 @@
1
+ import { invalidRequest } from '../server/errors.js';
2
+ import { renderToolCall, toolInstructions } from './tool-calls.js';
3
+
4
+ const TRANSCRIPT_INTRO = 'The conversation so far is below, oldest first. Write only the next assistant reply — no role tags, no transcript markup.';
5
+
6
+ function attachmentLabel(media, index) {
7
+ return `[attachment ${index + 1}: ${media.kind}${media.filename ? ` "${media.filename}"` : ''}]`;
8
+ }
9
+
10
+ function toolNameFor(messages, callId) {
11
+ for (const message of messages) {
12
+ const call = message.toolCalls?.find((c) => c.id === callId);
13
+ if (call) return call.name;
14
+ }
15
+ return null;
16
+ }
17
+
18
+ /** Render a multi-turn conversation as one transcript with attachment markers. */
19
+ function renderTranscript(messages, attachments) {
20
+ const blocks = [TRANSCRIPT_INTRO];
21
+ for (const message of messages) {
22
+ const labels = message.media.map((media) => {
23
+ attachments.push(media);
24
+ return attachmentLabel(media, attachments.length - 1);
25
+ });
26
+ const body = [message.content, ...labels].filter(Boolean).join('\n');
27
+ if (message.role === 'tool') {
28
+ const name = message.name || toolNameFor(messages, message.toolCallId) || 'function';
29
+ blocks.push(`<tool_result name="${name}" call_id="${message.toolCallId}">\n${body}\n</tool_result>`);
30
+ } else if (message.role === 'assistant') {
31
+ const calls = (message.toolCalls || []).map(renderToolCall);
32
+ blocks.push(`<assistant>\n${[body, ...calls].filter(Boolean).join('\n')}\n</assistant>`);
33
+ } else {
34
+ blocks.push(`<user>\n${body}\n</user>`);
35
+ }
36
+ }
37
+ return blocks.join('\n\n');
38
+ }
39
+
40
+ function assertModalities(model, media) {
41
+ for (const item of media) {
42
+ if (!model.input.includes(item.kind)) {
43
+ throw invalidRequest(`The model '${model.id}' does not accept ${item.kind} input (it accepts: ${model.input.join(', ')}). Pick a model from GET /v1/models that supports it.`, 'messages', 'unsupported_modality');
44
+ }
45
+ }
46
+ }
47
+
48
+ function pickVariant(model, effort) {
49
+ if (!effort || effort === 'none') return undefined;
50
+ return model.variants.includes(effort) ? effort : undefined;
51
+ }
52
+
53
+ /**
54
+ * Translate a canonical request into an OpenCode prompt:
55
+ * { model, system, parts, variant, format }.
56
+ * @param {ReturnType<typeof import('./chat-request.js').parseChatRequest>} request
57
+ * @param {object} model catalog entry
58
+ */
59
+ export function buildPrompt(request, model) {
60
+ const systemTexts = request.messages.filter((m) => m.role === 'system').map((m) => m.content).filter(Boolean);
61
+ const turns = request.messages.filter((m) => m.role !== 'system');
62
+ if (turns.length === 0) throw invalidRequest('messages must include at least one user message.', 'messages');
63
+
64
+ const useNativeFormat = request.format && (request.tools.length === 0 || request.toolChoice === 'none');
65
+ const tools = toolInstructions(request.tools, request.toolChoice, request.parallelToolCalls);
66
+ if (tools) systemTexts.push(tools);
67
+ if (request.format && !useNativeFormat) {
68
+ systemTexts.push(request.format.type === 'json_schema'
69
+ ? `When you answer without calling a function, reply with only JSON matching this schema: ${JSON.stringify(request.format.schema)}`
70
+ : 'When you answer without calling a function, reply with only a valid JSON object.');
71
+ }
72
+
73
+ const attachments = [];
74
+ let text;
75
+ const single = turns.length === 1 && turns[0].role === 'user';
76
+ if (single) {
77
+ text = turns[0].content;
78
+ attachments.push(...turns[0].media);
79
+ } else {
80
+ text = renderTranscript(turns, attachments);
81
+ }
82
+ assertModalities(model, attachments);
83
+
84
+ const parts = [];
85
+ if (text || attachments.length === 0) parts.push({ type: 'text', text: text || ' ' });
86
+ attachments.forEach((media, index) => {
87
+ parts.push({ type: 'file', mime: media.mime, url: media.url, filename: media.filename || `attachment-${index + 1}` });
88
+ });
89
+
90
+ return {
91
+ model: { providerID: model.providerID, modelID: model.modelID },
92
+ system: systemTexts.join('\n\n') || undefined,
93
+ parts,
94
+ variant: pickVariant(model, request.reasoningEffort),
95
+ format: useNativeFormat
96
+ ? { type: 'json_schema', schema: request.format.type === 'json_schema' ? request.format.schema : { type: 'object' } }
97
+ : undefined,
98
+ // Not sent to OpenCode: lets the runner spot native attempts at client functions.
99
+ clientTools: tools ? request.tools.map((tool) => tool.name) : [],
100
+ };
101
+ }
@@ -0,0 +1,135 @@
1
+ import crypto from 'node:crypto';
2
+ import { toApiError } from '../server/errors.js';
3
+
4
+ const rid = (prefix) => `${prefix}_${crypto.randomBytes(16).toString('hex')}`;
5
+
6
+ export function responsesUsage(usage) {
7
+ const input = usage.input + usage.cacheRead;
8
+ const output = usage.output + usage.reasoning;
9
+ return {
10
+ input_tokens: input,
11
+ input_tokens_details: { cached_tokens: usage.cacheRead },
12
+ output_tokens: output,
13
+ output_tokens_details: { reasoning_tokens: usage.reasoning },
14
+ total_tokens: input + output,
15
+ };
16
+ }
17
+
18
+ function callItem(call) {
19
+ if (call.kind === 'custom') {
20
+ let input = '';
21
+ try { input = String(JSON.parse(call.arguments).input ?? ''); } catch { input = call.arguments; }
22
+ return { id: rid('ctc'), type: 'custom_tool_call', status: 'completed', call_id: call.id, name: call.name, input };
23
+ }
24
+ return { id: rid('fc'), type: 'function_call', status: 'completed', call_id: call.id, name: call.name, arguments: call.arguments };
25
+ }
26
+
27
+ /**
28
+ * Builds a Responses API object and (optionally) its streaming events in
29
+ * the documented order: created → in_progress → output items with their
30
+ * part/delta/done events → completed | incomplete | failed.
31
+ * @param {{ model: string, echo: object, emit?: (event: object) => void }} options
32
+ */
33
+ export function createResponseBuilder({ model, echo, emit = () => {} }) {
34
+ const id = rid('resp');
35
+ const createdAt = Math.floor(Date.now() / 1000);
36
+ const output = [];
37
+ let sequence = 0;
38
+ let reasoning = null;
39
+ let message = null;
40
+
41
+ const send = (type, fields) => emit({ type, sequence_number: sequence++, ...fields });
42
+ const snapshot = (status, extra = {}) => ({
43
+ id, object: 'response', created_at: createdAt, status, background: false, error: null, incomplete_details: null,
44
+ model, output: output.map((entry) => entry.item), usage: null, ...echo, ...extra,
45
+ });
46
+ const indexOf = (entry) => output.indexOf(entry);
47
+
48
+ function openReasoning() {
49
+ reasoning = { item: { id: rid('rs'), type: 'reasoning', summary: [] }, text: '' };
50
+ output.push(reasoning);
51
+ send('response.output_item.added', { output_index: indexOf(reasoning), item: { ...reasoning.item } });
52
+ send('response.reasoning_summary_part.added', { item_id: reasoning.item.id, output_index: indexOf(reasoning), summary_index: 0, part: { type: 'summary_text', text: '' } });
53
+ }
54
+
55
+ function openMessage() {
56
+ message = { item: { id: rid('msg'), type: 'message', status: 'in_progress', role: 'assistant', content: [] }, text: '' };
57
+ output.push(message);
58
+ send('response.output_item.added', { output_index: indexOf(message), item: { ...message.item } });
59
+ send('response.content_part.added', { item_id: message.item.id, output_index: indexOf(message), content_index: 0, part: { type: 'output_text', text: '', annotations: [], logprobs: [] } });
60
+ }
61
+
62
+ function closeReasoning() {
63
+ if (!reasoning) return;
64
+ const index = indexOf(reasoning);
65
+ const part = { type: 'summary_text', text: reasoning.text };
66
+ reasoning.item = { ...reasoning.item, summary: [part] };
67
+ send('response.reasoning_summary_text.done', { item_id: reasoning.item.id, output_index: index, summary_index: 0, text: reasoning.text });
68
+ send('response.reasoning_summary_part.done', { item_id: reasoning.item.id, output_index: index, summary_index: 0, part });
69
+ send('response.output_item.done', { output_index: index, item: reasoning.item });
70
+ }
71
+
72
+ function closeMessage() {
73
+ if (!message) return;
74
+ const index = indexOf(message);
75
+ const part = { type: 'output_text', text: message.text, annotations: [], logprobs: [] };
76
+ message.item = { ...message.item, status: 'completed', content: [part] };
77
+ send('response.output_text.done', { item_id: message.item.id, output_index: index, content_index: 0, text: message.text, logprobs: [] });
78
+ send('response.content_part.done', { item_id: message.item.id, output_index: index, content_index: 0, part });
79
+ send('response.output_item.done', { output_index: index, item: message.item });
80
+ }
81
+
82
+ function addCall(call) {
83
+ const entry = { item: callItem(call) };
84
+ output.push(entry);
85
+ const index = indexOf(entry);
86
+ const { item } = entry;
87
+ if (item.type === 'custom_tool_call') {
88
+ send('response.output_item.added', { output_index: index, item: { ...item, status: 'in_progress', input: '' } });
89
+ send('response.custom_tool_call_input.delta', { item_id: item.id, output_index: index, delta: item.input });
90
+ send('response.custom_tool_call_input.done', { item_id: item.id, output_index: index, input: item.input });
91
+ } else {
92
+ send('response.output_item.added', { output_index: index, item: { ...item, status: 'in_progress', arguments: '' } });
93
+ send('response.function_call_arguments.delta', { item_id: item.id, output_index: index, delta: item.arguments });
94
+ send('response.function_call_arguments.done', { item_id: item.id, output_index: index, name: item.name, arguments: item.arguments });
95
+ }
96
+ send('response.output_item.done', { output_index: index, item });
97
+ }
98
+
99
+ return {
100
+ id,
101
+ start() {
102
+ send('response.created', { response: snapshot('in_progress') });
103
+ send('response.in_progress', { response: snapshot('in_progress') });
104
+ },
105
+ reasoningDelta(text) {
106
+ if (!reasoning) openReasoning();
107
+ reasoning.text += text;
108
+ send('response.reasoning_summary_text.delta', { item_id: reasoning.item.id, output_index: indexOf(reasoning), summary_index: 0, delta: text });
109
+ },
110
+ textDelta(text) {
111
+ if (!message) openMessage();
112
+ message.text += text;
113
+ send('response.output_text.delta', { item_id: message.item.id, output_index: indexOf(message), content_index: 0, delta: text, logprobs: [] });
114
+ },
115
+ /** Close all items and emit the terminal event; returns the final Response. */
116
+ finish(result) {
117
+ closeReasoning();
118
+ if (!message && result.toolCalls.length === 0) openMessage();
119
+ closeMessage();
120
+ result.toolCalls.forEach(addCall);
121
+ const incomplete = result.finish === 'length' || result.finish === 'content_filter';
122
+ const response = snapshot(incomplete ? 'incomplete' : 'completed', {
123
+ incomplete_details: incomplete ? { reason: result.finish === 'length' ? 'max_output_tokens' : 'content_filter' } : null,
124
+ usage: responsesUsage(result.usage),
125
+ });
126
+ send(incomplete ? 'response.incomplete' : 'response.completed', { response });
127
+ return response;
128
+ },
129
+ fail(error) {
130
+ const apiError = toApiError(error);
131
+ send('response.failed', { response: snapshot('failed', { error: { code: apiError.code || 'server_error', message: apiError.message } }) });
132
+ return apiError;
133
+ },
134
+ };
135
+ }