@jini-ai/agent-runtime 0.2.1 → 0.3.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 (163) hide show
  1. package/README.md +2 -2
  2. package/dist/agent-protocol/acp/session.d.ts +206 -1
  3. package/dist/agent-protocol/acp/session.d.ts.map +1 -1
  4. package/dist/agent-protocol/acp/session.js +505 -350
  5. package/dist/agent-protocol/acp/session.js.map +1 -1
  6. package/dist/agent-protocol/core/json-line-stream.d.ts +53 -0
  7. package/dist/agent-protocol/core/json-line-stream.d.ts.map +1 -1
  8. package/dist/agent-protocol/core/json-line-stream.js +307 -255
  9. package/dist/agent-protocol/core/json-line-stream.js.map +1 -1
  10. package/dist/agent-protocol/pi-rpc/events.d.ts +22 -1
  11. package/dist/agent-protocol/pi-rpc/events.d.ts.map +1 -1
  12. package/dist/agent-protocol/pi-rpc/events.js +189 -153
  13. package/dist/agent-protocol/pi-rpc/events.js.map +1 -1
  14. package/dist/agent-protocol/pi-rpc/session.d.ts +39 -0
  15. package/dist/agent-protocol/pi-rpc/session.d.ts.map +1 -1
  16. package/dist/agent-protocol/pi-rpc/session.js +143 -107
  17. package/dist/agent-protocol/pi-rpc/session.js.map +1 -1
  18. package/dist/claude-stream.d.ts +64 -1
  19. package/dist/claude-stream.d.ts.map +1 -1
  20. package/dist/claude-stream.js +568 -441
  21. package/dist/claude-stream.js.map +1 -1
  22. package/dist/defs/aider.d.ts.map +1 -1
  23. package/dist/defs/aider.js +13 -1
  24. package/dist/defs/aider.js.map +1 -1
  25. package/dist/defs/amp.d.ts.map +1 -1
  26. package/dist/defs/amp.js +16 -1
  27. package/dist/defs/amp.js.map +1 -1
  28. package/dist/defs/amr.d.ts +2 -1
  29. package/dist/defs/amr.d.ts.map +1 -1
  30. package/dist/defs/amr.js +12 -1
  31. package/dist/defs/amr.js.map +1 -1
  32. package/dist/defs/antigravity.d.ts +41 -2
  33. package/dist/defs/antigravity.d.ts.map +1 -1
  34. package/dist/defs/antigravity.js +201 -22
  35. package/dist/defs/antigravity.js.map +1 -1
  36. package/dist/defs/claude.d.ts +2 -0
  37. package/dist/defs/claude.d.ts.map +1 -1
  38. package/dist/defs/claude.js +58 -3
  39. package/dist/defs/claude.js.map +1 -1
  40. package/dist/defs/codebuddy.d.ts.map +1 -1
  41. package/dist/defs/codebuddy.js +9 -1
  42. package/dist/defs/codebuddy.js.map +1 -1
  43. package/dist/defs/codex.d.ts +1 -0
  44. package/dist/defs/codex.d.ts.map +1 -1
  45. package/dist/defs/codex.js +34 -0
  46. package/dist/defs/codex.js.map +1 -1
  47. package/dist/defs/copilot.d.ts.map +1 -1
  48. package/dist/defs/copilot.js +15 -1
  49. package/dist/defs/copilot.js.map +1 -1
  50. package/dist/defs/cursor-agent.d.ts.map +1 -1
  51. package/dist/defs/cursor-agent.js +24 -2
  52. package/dist/defs/cursor-agent.js.map +1 -1
  53. package/dist/defs/deepseek.d.ts.map +1 -1
  54. package/dist/defs/deepseek.js +12 -0
  55. package/dist/defs/deepseek.js.map +1 -1
  56. package/dist/defs/devin.d.ts +2 -1
  57. package/dist/defs/devin.d.ts.map +1 -1
  58. package/dist/defs/devin.js +12 -7
  59. package/dist/defs/devin.js.map +1 -1
  60. package/dist/defs/grok-build.d.ts.map +1 -1
  61. package/dist/defs/grok-build.js +10 -0
  62. package/dist/defs/grok-build.js.map +1 -1
  63. package/dist/defs/hermes.d.ts +1 -0
  64. package/dist/defs/hermes.d.ts.map +1 -1
  65. package/dist/defs/hermes.js +3 -0
  66. package/dist/defs/hermes.js.map +1 -1
  67. package/dist/defs/kilo.d.ts +1 -0
  68. package/dist/defs/kilo.d.ts.map +1 -1
  69. package/dist/defs/kilo.js +3 -0
  70. package/dist/defs/kilo.js.map +1 -1
  71. package/dist/defs/kimi.d.ts +1 -0
  72. package/dist/defs/kimi.d.ts.map +1 -1
  73. package/dist/defs/kimi.js +3 -0
  74. package/dist/defs/kimi.js.map +1 -1
  75. package/dist/defs/kiro.d.ts +1 -0
  76. package/dist/defs/kiro.d.ts.map +1 -1
  77. package/dist/defs/kiro.js +3 -0
  78. package/dist/defs/kiro.js.map +1 -1
  79. package/dist/defs/pi.d.ts +1 -1
  80. package/dist/defs/pi.d.ts.map +1 -1
  81. package/dist/defs/pi.js +6 -1
  82. package/dist/defs/pi.js.map +1 -1
  83. package/dist/defs/qoder.d.ts +1 -0
  84. package/dist/defs/qoder.d.ts.map +1 -1
  85. package/dist/defs/qoder.js +15 -0
  86. package/dist/defs/qoder.js.map +1 -1
  87. package/dist/defs/qwen.d.ts.map +1 -1
  88. package/dist/defs/qwen.js +9 -0
  89. package/dist/defs/qwen.js.map +1 -1
  90. package/dist/defs/reasonix.d.ts +1 -0
  91. package/dist/defs/reasonix.d.ts.map +1 -1
  92. package/dist/defs/reasonix.js +3 -0
  93. package/dist/defs/reasonix.js.map +1 -1
  94. package/dist/defs/shared.d.ts +31 -1
  95. package/dist/defs/shared.d.ts.map +1 -1
  96. package/dist/defs/shared.js +35 -0
  97. package/dist/defs/shared.js.map +1 -1
  98. package/dist/defs/trae-cli.d.ts +1 -0
  99. package/dist/defs/trae-cli.d.ts.map +1 -1
  100. package/dist/defs/trae-cli.js +3 -0
  101. package/dist/defs/trae-cli.js.map +1 -1
  102. package/dist/defs/vibe.d.ts +1 -0
  103. package/dist/defs/vibe.d.ts.map +1 -1
  104. package/dist/defs/vibe.js +3 -0
  105. package/dist/defs/vibe.js.map +1 -1
  106. package/dist/detection.d.ts +1 -1
  107. package/dist/detection.d.ts.map +1 -1
  108. package/dist/detection.js +7 -2
  109. package/dist/detection.js.map +1 -1
  110. package/dist/index.d.ts +2 -1
  111. package/dist/index.d.ts.map +1 -1
  112. package/dist/index.js +2 -1
  113. package/dist/index.js.map +1 -1
  114. package/dist/log-file.d.ts +23 -0
  115. package/dist/log-file.d.ts.map +1 -0
  116. package/dist/log-file.js +56 -0
  117. package/dist/log-file.js.map +1 -0
  118. package/dist/model-registry.d.ts +7 -45
  119. package/dist/model-registry.d.ts.map +1 -1
  120. package/dist/model-registry.js.map +1 -1
  121. package/dist/providers/anthropic-messages.d.ts +198 -6
  122. package/dist/providers/anthropic-messages.d.ts.map +1 -1
  123. package/dist/providers/anthropic-messages.js +317 -140
  124. package/dist/providers/anthropic-messages.js.map +1 -1
  125. package/dist/providers/azure-chat.d.ts +113 -3
  126. package/dist/providers/azure-chat.d.ts.map +1 -1
  127. package/dist/providers/azure-chat.js +114 -21
  128. package/dist/providers/azure-chat.js.map +1 -1
  129. package/dist/providers/connection-guard.d.ts +121 -1
  130. package/dist/providers/connection-guard.d.ts.map +1 -1
  131. package/dist/providers/connection-guard.js +205 -3
  132. package/dist/providers/connection-guard.js.map +1 -1
  133. package/dist/providers/connection-test.d.ts +62 -0
  134. package/dist/providers/connection-test.d.ts.map +1 -0
  135. package/dist/providers/connection-test.js +404 -0
  136. package/dist/providers/connection-test.js.map +1 -0
  137. package/dist/providers/elevenlabs.d.ts.map +1 -1
  138. package/dist/providers/elevenlabs.js +3 -2
  139. package/dist/providers/elevenlabs.js.map +1 -1
  140. package/dist/providers/google-messages.d.ts +208 -7
  141. package/dist/providers/google-messages.d.ts.map +1 -1
  142. package/dist/providers/google-messages.js +331 -105
  143. package/dist/providers/google-messages.js.map +1 -1
  144. package/dist/providers/index.d.ts +1 -0
  145. package/dist/providers/index.d.ts.map +1 -1
  146. package/dist/providers/index.js +1 -0
  147. package/dist/providers/index.js.map +1 -1
  148. package/dist/providers/model-catalog.d.ts.map +1 -1
  149. package/dist/providers/model-catalog.js +21 -0
  150. package/dist/providers/model-catalog.js.map +1 -1
  151. package/dist/providers/ollama-chat.d.ts +190 -1
  152. package/dist/providers/ollama-chat.d.ts.map +1 -1
  153. package/dist/providers/ollama-chat.js +319 -110
  154. package/dist/providers/ollama-chat.js.map +1 -1
  155. package/dist/providers/openai-chat.d.ts +222 -14
  156. package/dist/providers/openai-chat.d.ts.map +1 -1
  157. package/dist/providers/openai-chat.js +377 -128
  158. package/dist/providers/openai-chat.js.map +1 -1
  159. package/dist/providers/sse-decode.d.ts +2 -2
  160. package/dist/providers/sse-decode.js +2 -2
  161. package/dist/types.d.ts +169 -87
  162. package/dist/types.d.ts.map +1 -1
  163. package/package.json +6 -4
@@ -32,9 +32,42 @@
32
32
  * field, not model-aware) — matching OD's own azure handler, which never
33
33
  * uses the model-aware picker (Azure deployment names are caller-defined
34
34
  * strings, not necessarily matching OpenAI's own model-naming scheme).
35
+ *
36
+ * **Image support**: verified against `developers.openai.com/api/docs/api-reference/chat/create`
37
+ * (the Chat Completions endpoint specifically — a redirect from the older
38
+ * `platform.openai.com/docs/guides/vision` URL now leads to a *different*, newer Responses API
39
+ * guide that uses `input_text`/`input_image` part names; those do not apply here and are not
40
+ * modeled). A user/system/assistant message's `content` may be an array mixing `{type:'text'}` and
41
+ * `{type:'image_url', image_url:{url, detail?}}` parts; `url` is either an `https://` URL (OpenAI's
42
+ * servers fetch it — see `invalidOpenAiContentPartReason`'s doc for why this module does not size-
43
+ * check that case) or a `data:<mime>;base64,<data>` URI. Supported formats per the same reference:
44
+ * PNG/JPEG/WEBP/non-animated GIF; documented limits are a 512 MB total request payload and 1500
45
+ * images per request (this module also applies its own conservative single-image guard — see
46
+ * `MAX_IMAGE_DATA_URI_BASE64_CHARS`'s doc).
47
+ *
48
+ * **Tool messages cannot carry an image** — confirmed against the same API reference: a `role:
49
+ * 'tool'` message's `content` is documented as `string | ChatCompletionContentPartText[]` only
50
+ * ("For tool messages, only type text is supported"). So a vision self-check whose tool result
51
+ * includes a screenshot cannot put that image on the `tool` message itself. `runOpenAiToolTurn`
52
+ * instead keeps the `tool` message text-only (see `splitOpenAiToolResultContent`) and appends one
53
+ * synthetic `role: 'user'` message carrying every image from the batch, built up while iterating
54
+ * every `tool_call` in the batch but appended exactly once, after every one of that batch's `tool`
55
+ * messages — never interleaved between them or emitted per-call, since OpenAI requires every `tool`
56
+ * message answering a batch of parallel `tool_calls` to directly follow the assistant message with
57
+ * nothing else in between. This is a real 400 if violated, not a style concern — see
58
+ * `runOpenAiToolTurn`'s tool-loop body and its test file's multi-tool-call image test.
59
+ *
60
+ * **This synthetic follow-up message is a workaround for the OpenAI wire protocol's own text-only
61
+ * `tool` message, not structure this module invented.** Anthropic's `tool_result` content block can
62
+ * carry an image directly (see `anthropic-messages.ts`) and needs no such split. Each image (or run
63
+ * of images) in the follow-up is preceded by a plain-text label naming the tool call it answers
64
+ * (name + `tool_call_id`) — an unlabeled image sitting alone in a `user` message is indistinguishable
65
+ * from a human having just pasted a screenshot, which in a vision self-check loop risks the model
66
+ * treating its own tool's output as a brand-new user request instead of a continuation of its own
67
+ * reasoning.
35
68
  */
36
69
  import { createRoleMarkerGuard } from '../role-marker-guard.js';
37
- import { redactSecrets, validateBaseUrl } from './connection-guard.js';
70
+ import { defaultDnsLookup, pinnedFetch, redactSecrets, validateBaseUrlResolved } from './connection-guard.js';
38
71
  import { decodeSseStream } from './sse-decode.js';
39
72
  import { buildOpenAIChatTokenParam } from './token-params.js';
40
73
  import { createTurnEndGuard } from './turn-end-guard.js';
@@ -46,18 +79,18 @@ export const DEFAULT_OPENAI_MAX_TOKENS = 8192;
46
79
  function isRecord(value) {
47
80
  return typeof value === 'object' && value !== null && !Array.isArray(value);
48
81
  }
49
- function openAiRequestUrl(baseUrl) {
82
+ export function openAiRequestUrl(baseUrl) {
50
83
  const base = (baseUrl ?? DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, '');
51
84
  return /\/v\d+(\/|$)/.test(base) ? `${base}/chat/completions` : `${base}/v1/chat/completions`;
52
85
  }
53
- function openAiHeaders(options) {
86
+ export function openAiHeaders(options) {
54
87
  return {
55
88
  'content-type': 'application/json',
56
89
  authorization: `Bearer ${options.apiKey}`,
57
90
  ...(options.extraHeaders ?? {}),
58
91
  };
59
92
  }
60
- function openAiRequestBody(options, messages) {
93
+ export function openAiRequestBody(options, messages) {
61
94
  const effectiveMaxTokens = typeof options.maxTokens === 'number' && options.maxTokens > 0 ? options.maxTokens : DEFAULT_OPENAI_MAX_TOKENS;
62
95
  return {
63
96
  model: options.model,
@@ -69,7 +102,7 @@ function openAiRequestBody(options, messages) {
69
102
  ...(options.tools && options.tools.length > 0 ? { tools: options.tools } : {}),
70
103
  };
71
104
  }
72
- function extractOpenAiErrorDetail(rawText) {
105
+ export function extractOpenAiErrorDetail(rawText) {
73
106
  try {
74
107
  const parsed = JSON.parse(rawText);
75
108
  if (isRecord(parsed) && isRecord(parsed.error) && typeof parsed.error.message === 'string') {
@@ -81,46 +114,147 @@ function extractOpenAiErrorDetail(rawText) {
81
114
  }
82
115
  return rawText.trim().slice(0, 500);
83
116
  }
117
+ /** Parses one SSE frame's data as a JSON object, or `null` for a malformed/empty keep-alive frame or a non-object payload — both are tolerated by the caller as "nothing to do this frame". */
118
+ export function parseOpenAiSseData(raw) {
119
+ let data;
120
+ try {
121
+ data = JSON.parse(raw);
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ return isRecord(data) ? data : null;
127
+ }
128
+ export function applyOpenAiStreamUsage(state, data, onEvent) {
129
+ if (!isRecord(data.usage))
130
+ return;
131
+ state.usage = data.usage;
132
+ onEvent({ type: 'usage', usage: data.usage });
133
+ }
134
+ /** The chunk's first (and, for Chat Completions, only) choice — `null` when the chunk carries no choice at all (e.g. a usage-only trailer chunk). */
135
+ export function firstOpenAiChoice(data) {
136
+ const choices = Array.isArray(data.choices) ? data.choices : [];
137
+ const choice = choices[0];
138
+ return isRecord(choice) ? choice : null;
139
+ }
84
140
  /**
85
- * Runs exactly one OpenAI-compatible (Chat Completions JSON wire format)
86
- * streaming HTTP request and reduces its SSE events into a single outcome.
87
- * Extracted so this ~150-line SSE-reduction loop has exactly one
88
- * implementation shared by every OpenAI-compatible provider turn-runner in
89
- * this package: `runOpenAiToolTurn` itself (below), plus
90
- * `azure-chat.ts#runAzureToolTurn` and `ollama-chat.ts#runOllamaToolTurn` —
91
- * both target byte-identical chat-completions JSON, differing only in URL
92
- * and auth. Callers own their own base-URL SSRF validation
93
- * (`validateBaseUrl`) and URL/header/body construction *before* calling
94
- * this function — it only knows how to run *a* request against whatever
95
- * URL/headers/body it is handed, and has no opinion on any provider's
96
- * defaults. Mirrors `anthropic-messages.ts#runSingleAnthropicRequest`'s
97
- * `emitEnd` contract — see that function's doc.
141
+ * Feeds one text delta through the role-marker guard and emits the safe portion. Returns
142
+ * `'break'` once the guard flags contamination (the caller ends the turn immediately), otherwise
143
+ * `'continue'` same contract as `anthropic-messages.ts#handleAnthropicTextDelta`.
98
144
  */
99
- export async function runOpenAiCompatibleRequest(init) {
100
- const { onEvent, emitEnd, hasEnded } = init;
145
+ export function handleOpenAiTextContentDelta(state, content, onEvent) {
146
+ const safe = state.guard.feedText(content);
147
+ if (safe.length > 0) {
148
+ state.fullText += safe;
149
+ onEvent({ type: 'text_delta', delta: safe });
150
+ }
151
+ if (!state.guard.contaminated)
152
+ return 'continue';
153
+ const warn = state.guard.warningEvent();
154
+ if (warn)
155
+ onEvent(warn);
156
+ return 'break';
157
+ }
158
+ /** Builds a fresh `PendingToolCall` from the streaming chunk that first mentions a given tool-call index — OpenAI sends `id`/`function.name` once, on that first chunk, then dribbles `function.arguments` in across subsequent chunks (accumulated separately by the caller). */
159
+ export function newPendingOpenAiToolCall(rawCall, index) {
160
+ const id = typeof rawCall.id === 'string' ? rawCall.id : `call_${index}`;
161
+ const fn = isRecord(rawCall.function) ? rawCall.function : {};
162
+ const name = typeof fn.name === 'string' ? fn.name : '';
163
+ return { id, name, argsJson: '' };
164
+ }
165
+ /** Accumulates one `delta.tool_calls[]` entry from a streaming chunk into its running `PendingToolCall`. */
166
+ export function accumulateOpenAiToolCallDelta(state, rawCall) {
167
+ if (!isRecord(rawCall) || typeof rawCall.index !== 'number')
168
+ return;
169
+ let pending = state.toolCalls.get(rawCall.index);
170
+ if (!pending) {
171
+ pending = newPendingOpenAiToolCall(rawCall, rawCall.index);
172
+ state.toolCalls.set(rawCall.index, pending);
173
+ }
174
+ const fn = isRecord(rawCall.function) ? rawCall.function : null;
175
+ if (fn && typeof fn.arguments === 'string') {
176
+ pending.argsJson += fn.arguments;
177
+ }
178
+ }
179
+ /** Reduces one chunk's `choices[0]` — `finish_reason`, text content, and tool-call deltas — into `state`. Returns `'break'` when the text-delta guard detects contamination. */
180
+ export function handleOpenAiChoiceDelta(state, choice, onEvent) {
181
+ if (typeof choice.finish_reason === 'string') {
182
+ state.finishReason = choice.finish_reason;
183
+ }
184
+ const delta = isRecord(choice.delta) ? choice.delta : null;
185
+ if (!delta)
186
+ return 'continue';
187
+ if (typeof delta.content === 'string' && delta.content.length > 0) {
188
+ if (handleOpenAiTextContentDelta(state, delta.content, onEvent) === 'break')
189
+ return 'break';
190
+ }
191
+ if (Array.isArray(delta.tool_calls)) {
192
+ for (const rawCall of delta.tool_calls) {
193
+ accumulateOpenAiToolCallDelta(state, rawCall);
194
+ }
195
+ }
196
+ return 'continue';
197
+ }
198
+ /** Parses one accumulated tool call's `argsJson`, falling back to `{}` for empty or malformed JSON — mirrors Anthropic's identical fallback for `input_json_delta` accumulation in `anthropic-messages.ts`. */
199
+ export function resolveOpenAiToolCalls(pending) {
200
+ return Array.from(pending.values()).map((call) => {
201
+ let input = {};
202
+ if (call.argsJson.trim()) {
203
+ try {
204
+ input = JSON.parse(call.argsJson);
205
+ }
206
+ catch {
207
+ input = {};
208
+ }
209
+ }
210
+ return { id: call.id, name: call.name, input };
211
+ });
212
+ }
213
+ function emitPendingOpenAiToolUseEvents(toolCalls, onEvent) {
214
+ for (const call of toolCalls) {
215
+ onEvent({ type: 'tool_use', id: call.id, name: call.name, input: call.input });
216
+ }
217
+ }
218
+ /**
219
+ * Opens the streaming POST and validates the response, returning its readable body once every
220
+ * pre-stream failure mode (network error, non-2xx status with no retry available, missing body)
221
+ * has been ruled out. Calls `emitEnd('error')` and returns `{type: 'ended'}` itself on any of
222
+ * those — the caller only has to branch on the outcome's `type`, keeping `hasEnded()` as the sole
223
+ * gate for every exit path (see `runOpenAiCompatibleRequest`'s doc).
224
+ */
225
+ async function requestOpenAiCompatibleStream(init) {
226
+ const { onEvent, emitEnd } = init;
101
227
  let response;
102
228
  try {
103
- response = (await fetch(init.url, {
229
+ response = await (init.fetchImpl ?? pinnedFetch)(init.url, {
104
230
  method: 'POST',
105
231
  headers: init.headers,
106
232
  body: JSON.stringify(init.body),
233
+ // The caller's SSRF check (`validateBaseUrlResolved`) only ever sees the
234
+ // ORIGINAL url. Following a redirect would let a public, guard-passing
235
+ // endpoint hand back a `302 -> http://169.254.169.254/...` and reach the
236
+ // address the guard exists to refuse — with the provider auth headers
237
+ // still attached. `model-catalog.ts`'s own fetch already refuses
238
+ // redirects for the same reason; a redirecting chat-completions endpoint
239
+ // is not a thing any supported provider does. `pinnedFetch` never
240
+ // follows one regardless (see its doc); `redirect: 'error'` here is
241
+ // self-documentation, not the mechanism.
242
+ redirect: 'error',
107
243
  ...(init.signal ? { signal: init.signal } : {}),
108
- }));
244
+ }, init.pinnedAddress);
109
245
  }
110
246
  catch (error) {
111
247
  const message = error instanceof Error ? error.message : String(error);
112
248
  onEvent({ type: 'error', message: redactSecrets(message, init.redactSecretsList) });
113
249
  emitEnd('error');
114
- return { finishReason: null, toolCalls: [], text: '' };
250
+ return { type: 'ended' };
115
251
  }
116
252
  if (!response.ok) {
117
253
  const rawText = await response.text();
118
- if (init.retryableBody) {
119
- const retryBody = init.retryableBody(response.status, rawText);
120
- if (retryBody) {
121
- const { retryableBody: _retryableBody, ...retryInit } = init;
122
- return runOpenAiCompatibleRequest({ ...retryInit, body: retryBody });
123
- }
254
+ const retryBody = init.retryableBody?.(response.status, rawText);
255
+ if (retryBody) {
256
+ const { retryableBody: _retryableBody, ...retryInit } = init;
257
+ return { type: 'retry', retryInit: { ...retryInit, body: retryBody } };
124
258
  }
125
259
  onEvent({
126
260
  type: 'error',
@@ -128,109 +262,95 @@ export async function runOpenAiCompatibleRequest(init) {
128
262
  code: String(response.status),
129
263
  });
130
264
  emitEnd('error');
131
- return { finishReason: null, toolCalls: [], text: '' };
265
+ return { type: 'ended' };
132
266
  }
133
267
  if (!response.body) {
134
268
  onEvent({ type: 'error', message: `${init.providerLabel} response had no body` });
135
269
  emitEnd('error');
270
+ return { type: 'ended' };
271
+ }
272
+ return { type: 'body', body: response.body };
273
+ }
274
+ /**
275
+ * Reduces one decoded SSE frame into `state`, reporting what the caller's loop should do next:
276
+ * `'done'` on the `[DONE]` sentinel, `'contaminated'` once the role-marker guard trips on this
277
+ * frame's text delta, otherwise `'continue'`. Pulling per-frame dispatch out of the loop body
278
+ * turns what would be four sequential, nesting-penalized `if`s into a single function call plus
279
+ * one outcome check — see `runOpenAiCompatibleRequest`'s loop below.
280
+ */
281
+ export function processOpenAiStreamFrame(state, frame, onEvent) {
282
+ if (frame.data === DONE_SENTINEL)
283
+ return 'done';
284
+ const data = parseOpenAiSseData(frame.data);
285
+ if (!data)
286
+ return 'continue'; // malformed/empty keep-alive frame, or a non-object payload
287
+ applyOpenAiStreamUsage(state, data, onEvent);
288
+ const choice = firstOpenAiChoice(data);
289
+ if (!choice)
290
+ return 'continue';
291
+ return handleOpenAiChoiceDelta(state, choice, onEvent) === 'break' ? 'contaminated' : 'continue';
292
+ }
293
+ /**
294
+ * Runs exactly one OpenAI-compatible (Chat Completions JSON wire format)
295
+ * streaming HTTP request and reduces its SSE events into a single outcome.
296
+ * Extracted so this SSE-reduction loop has exactly one implementation
297
+ * shared by every OpenAI-compatible provider turn-runner in this package:
298
+ * `runOpenAiToolTurn` itself (below), plus `azure-chat.ts#runAzureToolTurn`
299
+ * and `ollama-chat.ts#runOllamaToolTurn` — both target byte-identical
300
+ * chat-completions JSON, differing only in URL and auth. Callers own their
301
+ * own base-URL SSRF validation (`validateBaseUrl`) and URL/header/body
302
+ * construction *before* calling this function — it only knows how to run
303
+ * *a* request against whatever URL/headers/body it is handed, and has no
304
+ * opinion on any provider's defaults. Mirrors
305
+ * `anthropic-messages.ts#runSingleAnthropicRequest`'s `emitEnd` contract —
306
+ * see that function's doc.
307
+ */
308
+ export async function runOpenAiCompatibleRequest(init) {
309
+ const { onEvent, hasEnded } = init;
310
+ const fetchOutcome = await requestOpenAiCompatibleStream(init);
311
+ if (fetchOutcome.type === 'ended') {
136
312
  return { finishReason: null, toolCalls: [], text: '' };
137
313
  }
314
+ if (fetchOutcome.type === 'retry') {
315
+ return runOpenAiCompatibleRequest(fetchOutcome.retryInit);
316
+ }
138
317
  onEvent({ type: 'status', label: 'requesting' });
139
- const guard = createRoleMarkerGuard(init.guardMessageId);
140
- const toolCalls = new Map();
141
- let fullText = '';
142
- let finishReason = null;
143
- let usage = null;
144
- for await (const frame of decodeSseStream(response.body)) {
318
+ const state = {
319
+ guard: createRoleMarkerGuard(init.guardMessageId),
320
+ toolCalls: new Map(),
321
+ fullText: '',
322
+ finishReason: null,
323
+ usage: null,
324
+ };
325
+ for await (const frame of decodeSseStream(fetchOutcome.body)) {
145
326
  // No `hasEnded()` re-check at the top of this loop: the only in-loop call to `emitEnd`
146
327
  // (contamination, below) is immediately followed by `break`, and every other call site is a
147
- // pre-loop early `return` — traced across all five call sites in this function, same proof as
328
+ // pre-loop early `return` — traced across all five call sites in this function and
329
+ // `requestOpenAiCompatibleStream`, same proof as
148
330
  // `anthropic-messages.ts#runSingleAnthropicRequest`. `hasEnded()` is still consulted once,
149
331
  // after this loop, to decide whether pending tool_use events should still be emitted (a
150
332
  // contaminating delta can arrive on a *later* chunk than the one that set `finish_reason`).
151
- if (frame.data === DONE_SENTINEL)
333
+ const result = processOpenAiStreamFrame(state, frame, onEvent);
334
+ if (result === 'done')
335
+ break;
336
+ if (result === 'contaminated') {
337
+ init.emitEnd('contaminated');
152
338
  break;
153
- let data;
154
- try {
155
- data = JSON.parse(frame.data);
156
- }
157
- catch {
158
- continue; // tolerate a malformed/empty keep-alive frame
159
- }
160
- if (!isRecord(data))
161
- continue;
162
- if (isRecord(data.usage)) {
163
- usage = data.usage;
164
- onEvent({ type: 'usage', usage });
165
- }
166
- const choices = Array.isArray(data.choices) ? data.choices : [];
167
- const choice = choices[0];
168
- if (!isRecord(choice))
169
- continue;
170
- if (typeof choice.finish_reason === 'string') {
171
- finishReason = choice.finish_reason;
172
- }
173
- const delta = isRecord(choice.delta) ? choice.delta : null;
174
- if (!delta)
175
- continue;
176
- if (typeof delta.content === 'string' && delta.content.length > 0) {
177
- // No `guard.contaminated` pre-check here: the only way it becomes true is the
178
- // `emitEnd('contaminated'); break;` a few lines below, which exits this loop immediately —
179
- // see `anthropic-messages.ts#runSingleAnthropicRequest`'s identical reachability proof.
180
- const safe = guard.feedText(delta.content);
181
- if (safe.length > 0) {
182
- fullText += safe;
183
- onEvent({ type: 'text_delta', delta: safe });
184
- }
185
- if (guard.contaminated) {
186
- const warn = guard.warningEvent();
187
- if (warn)
188
- onEvent(warn);
189
- emitEnd('contaminated');
190
- break;
191
- }
192
- }
193
- if (Array.isArray(delta.tool_calls)) {
194
- for (const rawCall of delta.tool_calls) {
195
- if (!isRecord(rawCall) || typeof rawCall.index !== 'number')
196
- continue;
197
- let pending = toolCalls.get(rawCall.index);
198
- if (!pending) {
199
- const id = typeof rawCall.id === 'string' ? rawCall.id : `call_${rawCall.index}`;
200
- const fn = isRecord(rawCall.function) ? rawCall.function : {};
201
- const name = typeof fn.name === 'string' ? fn.name : '';
202
- pending = { id, name, argsJson: '' };
203
- toolCalls.set(rawCall.index, pending);
204
- }
205
- const fn = isRecord(rawCall.function) ? rawCall.function : null;
206
- if (fn && typeof fn.arguments === 'string') {
207
- pending.argsJson += fn.arguments;
208
- }
209
- }
210
339
  }
211
340
  }
212
- const resolvedToolCalls = Array.from(toolCalls.values()).map((call) => {
213
- let input = {};
214
- if (call.argsJson.trim()) {
215
- try {
216
- input = JSON.parse(call.argsJson);
217
- }
218
- catch {
219
- input = {};
220
- }
221
- }
222
- return { id: call.id, name: call.name, input };
223
- });
224
- if (finishReason === 'tool_calls' && !hasEnded()) {
225
- for (const call of resolvedToolCalls) {
226
- onEvent({ type: 'tool_use', id: call.id, name: call.name, input: call.input });
227
- }
341
+ const resolvedToolCalls = resolveOpenAiToolCalls(state.toolCalls);
342
+ if (state.finishReason === 'tool_calls' && !hasEnded()) {
343
+ emitPendingOpenAiToolUseEvents(resolvedToolCalls, onEvent);
228
344
  }
229
- return { finishReason, toolCalls: resolvedToolCalls, text: fullText };
345
+ return { finishReason: state.finishReason, toolCalls: resolvedToolCalls, text: state.fullText };
230
346
  }
231
347
  /** Validates `options.baseUrl`, then delegates to {@link runOpenAiCompatibleRequest} with OpenAI's own URL/header/body builders. Thin wrapper kept so `runOpenAiToolTurn`'s per-iteration call site stays unchanged by the extraction. */
232
348
  async function runSingleOpenAiRequest(options, messages, emitEnd, hasEnded) {
233
- const baseUrlCheck = validateBaseUrl(options.baseUrl ?? DEFAULT_OPENAI_BASE_URL);
349
+ // DNS-resolving, not merely textual: the synchronous check only inspects the literal hostname,
350
+ // so `https://internal.example.com -> 10.0.0.5` passed it and this runner connected to private
351
+ // infrastructure on behalf of whoever supplied `baseUrl`. Matches what the Azure, Google and
352
+ // Ollama runners in this directory already did.
353
+ const baseUrlCheck = await validateBaseUrlResolved(options.baseUrl ?? DEFAULT_OPENAI_BASE_URL, options.dnsLookup ?? defaultDnsLookup);
234
354
  if (baseUrlCheck.error) {
235
355
  options.onEvent({ type: 'error', message: baseUrlCheck.error });
236
356
  emitEnd('error');
@@ -240,7 +360,9 @@ async function runSingleOpenAiRequest(options, messages, emitEnd, hasEnded) {
240
360
  url: openAiRequestUrl(options.baseUrl),
241
361
  headers: openAiHeaders(options),
242
362
  body: openAiRequestBody(options, messages),
363
+ ...(baseUrlCheck.pinnedAddress ? { pinnedAddress: baseUrlCheck.pinnedAddress } : {}),
243
364
  ...(options.signal ? { signal: options.signal } : {}),
365
+ ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
244
366
  redactSecretsList: [options.apiKey],
245
367
  guardMessageId: 'openai-turn',
246
368
  providerLabel: 'OpenAI',
@@ -249,6 +371,140 @@ async function runSingleOpenAiRequest(options, messages, emitEnd, hasEnded) {
249
371
  hasEnded,
250
372
  });
251
373
  }
374
+ const OPENAI_ALLOWED_IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
375
+ /**
376
+ * OpenAI documents a 512 MB total-payload cap and a 1500-image-per-request cap for the Chat
377
+ * Completions API (see module doc's "Image support" section) but no single-image byte limit. This
378
+ * module still bounds one image's base64 length defensively, matching
379
+ * `anthropic-messages.ts`'s posture — 20 MB is this module's own conservative choice (not an
380
+ * OpenAI-documented per-image number), anchored to Gemini's documented 20 MB total-inline-request
381
+ * budget (`google-messages.ts`) as the closest real, vendor-documented single-request inline-image
382
+ * bound available across this package's providers. Approximated from the base64 *string* length,
383
+ * not decoded byte count — see `anthropic-messages.ts#MAX_IMAGE_BASE64_CHARS`'s doc for why that's
384
+ * the right thing to measure (O(1), no decode step, negligible rounding slack at this size).
385
+ */
386
+ const MAX_IMAGE_DATA_URI_BASE64_CHARS = Math.ceil((20 * 1024 * 1024 * 4) / 3);
387
+ /** OpenAI's documented per-request image count cap (see module doc). Applied per tool-result content array as a conservative simplification — a real request can also carry images from earlier turns this module doesn't track. */
388
+ const MAX_IMAGES_PER_OPENAI_TOOL_RESULT = 1500;
389
+ const OPENAI_DATA_URI_PATTERN = /^data:([^;,]+)(?:;charset=[^;,]+)?;base64,(.+)$/su;
390
+ /**
391
+ * Validates one content part against OpenAI's real format/size constraints. Returns the violation
392
+ * reason, or `null` when the part is legal to send.
393
+ *
394
+ * A plain `https://` `image_url` (not a `data:` URI) is intentionally not size-checked: OpenAI's
395
+ * servers fetch that URL, not this adapter, so there is no local payload to bound — same threat-
396
+ * model split as `anthropic-messages.ts`'s URL-sourced skip (`connection-guard.ts`'s SSRF guard
397
+ * protects this adapter's own outbound `baseUrl` request, a different thing; an `image_url` that
398
+ * points at attacker-chosen infrastructure is a request OpenAI's own servers make, not this one).
399
+ *
400
+ * @complexity O(1) — reads `data.length`, never decodes or parses the base64 payload.
401
+ */
402
+ export function invalidOpenAiContentPartReason(part) {
403
+ if (part.type === 'text')
404
+ return null;
405
+ const dataUriMatch = OPENAI_DATA_URI_PATTERN.exec(part.image_url.url);
406
+ if (!dataUriMatch)
407
+ return null;
408
+ const [, mediaType, base64Data] = dataUriMatch;
409
+ if (!mediaType || !OPENAI_ALLOWED_IMAGE_MEDIA_TYPES.has(mediaType.toLowerCase())) {
410
+ return `unsupported image media type ${JSON.stringify(mediaType)} (OpenAI's Chat Completions API supports image/jpeg, image/png, image/webp, and non-animated image/gif)`;
411
+ }
412
+ if (base64Data && base64Data.length > MAX_IMAGE_DATA_URI_BASE64_CHARS) {
413
+ return `image exceeds this adapter's 20 MB base64 size guard (${base64Data.length} base64 chars)`;
414
+ }
415
+ return null;
416
+ }
417
+ /**
418
+ * Runtime guard applied to every `OpenAiToolExecutor` result before it is wired onto the outbound
419
+ * request or reported via `onEvent`. `OpenAiToolResult` is host-owned (see `OpenAiToolExecutor`'s
420
+ * "Host-owned tool execution" doc comment) — the TS content-part union only constrains a
421
+ * well-behaved host at compile time, not a buggy one at runtime, e.g. a screenshot helper that
422
+ * hands back an oversized PNG or a HEIC file mislabeled as `image/jpeg`. Rather than forwarding an
423
+ * invalid part and letting OpenAI reject the *entire* turn with an opaque upstream 400, this
424
+ * substitutes a plain-text `isError: true` result the model can see and react to — matching this
425
+ * package's existing security-conscious posture (`connection-guard.ts`'s SSRF guard,
426
+ * `role-marker-guard.ts`'s contamination guard, and `anthropic-messages.ts`'s identical guard).
427
+ *
428
+ * @complexity O(n) in the number of content parts; O(1) per part (see `invalidOpenAiContentPartReason`).
429
+ */
430
+ export function sanitizeOpenAiToolResult(result) {
431
+ if (typeof result.content === 'string')
432
+ return { content: result.content, isError: false };
433
+ if (result.content.length > MAX_IMAGES_PER_OPENAI_TOOL_RESULT) {
434
+ return { content: `tool result rejected: exceeds the ${MAX_IMAGES_PER_OPENAI_TOOL_RESULT}-image-per-request guard (${result.content.length} parts)`, isError: true };
435
+ }
436
+ for (const part of result.content) {
437
+ const reason = invalidOpenAiContentPartReason(part);
438
+ if (reason)
439
+ return { content: `tool result rejected: ${reason}`, isError: true };
440
+ }
441
+ return { content: result.content, isError: false };
442
+ }
443
+ /**
444
+ * Splits one (already-sanitized) tool result's content into what can legally sit on the wire
445
+ * `role: 'tool'` message (text only — see module doc's "Tool messages cannot carry an image"
446
+ * section) and the `image_url` parts that must instead travel on a follow-up `user` message.
447
+ *
448
+ * A string `content` is left untouched (`imageParts: []`) — this is the pre-existing, unchanged
449
+ * path every current caller already exercises. For an array, text parts are kept in order for the
450
+ * `tool` message; if there is no text at all (an image-only result), a single placeholder text part
451
+ * is substituted so the `tool` message's content is never empty (OpenAI rejects empty content).
452
+ *
453
+ * @complexity O(n) in the number of content parts.
454
+ */
455
+ export function splitOpenAiToolResultContent(content) {
456
+ if (typeof content === 'string')
457
+ return { toolMessageContent: content, imageParts: [] };
458
+ const textParts = content.filter((part) => part.type === 'text');
459
+ const imageParts = content.filter((part) => part.type === 'image_url');
460
+ const toolMessageContent = textParts.length > 0 ? textParts : [{ type: 'text', text: '(tool result included only non-text content; see the following message)' }];
461
+ return { toolMessageContent, imageParts };
462
+ }
463
+ /**
464
+ * Decides why the tool loop should stop after one request, or returns `null` when it should
465
+ * instead proceed to execute the pending tool calls — mirrors
466
+ * `anthropic-messages.ts#anthropicLoopExitReason`'s pure decision/effect split.
467
+ */
468
+ export function openAiLoopExitReason(outcome, toolTurns, maxToolTurns) {
469
+ if (outcome.finishReason !== 'tool_calls' || outcome.toolCalls.length === 0)
470
+ return 'stop';
471
+ if (toolTurns >= maxToolTurns)
472
+ return 'max_tool_turns';
473
+ return null;
474
+ }
475
+ /**
476
+ * Runs every pending tool call in order, sanitizing and splitting each result into its `tool`
477
+ * message plus any labeled image parts for the batch's single follow-up `user` message — see
478
+ * module doc's "Tool messages cannot carry an image" section for why the split exists and why the
479
+ * follow-up is assembled once per batch rather than per call.
480
+ */
481
+ export async function executeOpenAiToolCalls(executeTool, calls, onEvent) {
482
+ const toolResultMessages = [];
483
+ const followUpParts = [];
484
+ for (const call of calls) {
485
+ const result = await executeTool(call);
486
+ const sanitized = sanitizeOpenAiToolResult(result);
487
+ onEvent({ type: 'tool_result', toolUseId: call.id, content: sanitized.content, isError: sanitized.isError });
488
+ const split = splitOpenAiToolResultContent(sanitized.content);
489
+ toolResultMessages.push({ role: 'tool', content: split.toolMessageContent, tool_call_id: call.id });
490
+ if (split.imageParts.length > 0) {
491
+ // Attribution label — without it, the model cannot tell this image apart from a human
492
+ // having just pasted one into the conversation (see module doc). Named per-call so a batch
493
+ // with multiple image-bearing tool calls stays disambiguated in one follow-up message.
494
+ followUpParts.push({ type: 'text', text: `Image output from tool \`${call.name}\` (tool_call_id: ${call.id}):` });
495
+ followUpParts.push(...split.imageParts);
496
+ }
497
+ }
498
+ return { toolResultMessages, followUpParts };
499
+ }
500
+ /** Builds the assistant turn that records the model's pending tool calls in `messages` history — `content` falls back to `null` (never `''`) per the wire schema. */
501
+ export function buildOpenAiAssistantToolCallMessage(text, toolCalls) {
502
+ return { role: 'assistant', content: text || null, tool_calls: toolCalls };
503
+ }
504
+ /** Appends the batch's `tool` messages plus, when present, the single labeled-image follow-up message — see module doc's "Tool messages cannot carry an image" section for why the follow-up is at most one message per batch. */
505
+ export function buildOpenAiToolExchangeMessages(toolResultMessages, followUpParts) {
506
+ return [...toolResultMessages, ...(followUpParts.length > 0 ? [{ role: 'user', content: followUpParts }] : [])];
507
+ }
252
508
  /**
253
509
  * Runs a full OpenAI Chat Completions turn, including the tool-execution
254
510
  * loop when `options.executeTool` is supplied and the model requests a
@@ -257,6 +513,7 @@ async function runSingleOpenAiRequest(options, messages, emitEnd, hasEnded) {
257
513
  */
258
514
  export async function runOpenAiToolTurn(options) {
259
515
  const maxToolTurns = options.maxToolTurns ?? DEFAULT_MAX_TOOL_TURNS;
516
+ const executeTool = options.executeTool;
260
517
  const endGuard = createTurnEndGuard(options.onEvent, (reason) => ({ type: 'end', reason }));
261
518
  const emitEnd = endGuard.emitEnd;
262
519
  let messages = options.messages.slice();
@@ -267,34 +524,26 @@ export async function runOpenAiToolTurn(options) {
267
524
  lastFinishReason = outcome.finishReason;
268
525
  if (endGuard.hasEnded())
269
526
  break;
270
- if (outcome.finishReason !== 'tool_calls' || outcome.toolCalls.length === 0) {
271
- emitEnd('stop');
527
+ const exitReason = openAiLoopExitReason(outcome, toolTurns, maxToolTurns);
528
+ if (exitReason) {
529
+ emitEnd(exitReason);
272
530
  break;
273
531
  }
274
- if (!options.executeTool) {
532
+ if (!executeTool) {
275
533
  emitEnd('stop');
276
534
  break;
277
535
  }
278
- if (toolTurns >= maxToolTurns) {
279
- emitEnd('max_tool_turns');
280
- break;
281
- }
282
536
  toolTurns += 1;
283
537
  const assistantToolCalls = outcome.toolCalls.map((call) => ({
284
538
  id: call.id,
285
539
  type: 'function',
286
540
  function: { name: call.name, arguments: JSON.stringify(call.input) },
287
541
  }));
288
- const toolResultMessages = [];
289
- for (const call of outcome.toolCalls) {
290
- const result = await options.executeTool(call);
291
- options.onEvent({ type: 'tool_result', toolUseId: call.id, content: result.content, isError: false });
292
- toolResultMessages.push({ role: 'tool', content: result.content, tool_call_id: call.id });
293
- }
542
+ const { toolResultMessages, followUpParts } = await executeOpenAiToolCalls(executeTool, outcome.toolCalls, options.onEvent);
294
543
  messages = [
295
544
  ...messages,
296
- { role: 'assistant', content: outcome.text || null, tool_calls: assistantToolCalls },
297
- ...toolResultMessages,
545
+ buildOpenAiAssistantToolCallMessage(outcome.text, assistantToolCalls),
546
+ ...buildOpenAiToolExchangeMessages(toolResultMessages, followUpParts),
298
547
  ];
299
548
  }
300
549
  return { finishReason: lastFinishReason, toolTurns };