@jini-ai/agent-runtime 0.2.1 → 0.3.1
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/README.md +2 -2
- package/dist/agent-protocol/acp/session.d.ts +206 -1
- package/dist/agent-protocol/acp/session.d.ts.map +1 -1
- package/dist/agent-protocol/acp/session.js +505 -350
- package/dist/agent-protocol/acp/session.js.map +1 -1
- package/dist/agent-protocol/core/json-line-stream.d.ts +53 -0
- package/dist/agent-protocol/core/json-line-stream.d.ts.map +1 -1
- package/dist/agent-protocol/core/json-line-stream.js +307 -255
- package/dist/agent-protocol/core/json-line-stream.js.map +1 -1
- package/dist/agent-protocol/pi-rpc/events.d.ts +22 -1
- package/dist/agent-protocol/pi-rpc/events.d.ts.map +1 -1
- package/dist/agent-protocol/pi-rpc/events.js +189 -153
- package/dist/agent-protocol/pi-rpc/events.js.map +1 -1
- package/dist/agent-protocol/pi-rpc/session.d.ts +39 -0
- package/dist/agent-protocol/pi-rpc/session.d.ts.map +1 -1
- package/dist/agent-protocol/pi-rpc/session.js +143 -107
- package/dist/agent-protocol/pi-rpc/session.js.map +1 -1
- package/dist/auth.js +1 -1
- package/dist/capabilities.d.ts +1 -1
- package/dist/claude-stream.d.ts +65 -2
- package/dist/claude-stream.d.ts.map +1 -1
- package/dist/claude-stream.js +569 -442
- package/dist/claude-stream.js.map +1 -1
- package/dist/defs/aider.d.ts.map +1 -1
- package/dist/defs/aider.js +13 -1
- package/dist/defs/aider.js.map +1 -1
- package/dist/defs/amp.d.ts.map +1 -1
- package/dist/defs/amp.js +16 -1
- package/dist/defs/amp.js.map +1 -1
- package/dist/defs/amr.d.ts +2 -1
- package/dist/defs/amr.d.ts.map +1 -1
- package/dist/defs/amr.js +12 -1
- package/dist/defs/amr.js.map +1 -1
- package/dist/defs/antigravity.d.ts +25 -13
- package/dist/defs/antigravity.d.ts.map +1 -1
- package/dist/defs/antigravity.js +190 -163
- package/dist/defs/antigravity.js.map +1 -1
- package/dist/defs/claude.d.ts +7 -0
- package/dist/defs/claude.d.ts.map +1 -1
- package/dist/defs/claude.js +61 -3
- package/dist/defs/claude.js.map +1 -1
- package/dist/defs/codebuddy.d.ts.map +1 -1
- package/dist/defs/codebuddy.js +9 -1
- package/dist/defs/codebuddy.js.map +1 -1
- package/dist/defs/codex.d.ts +1 -0
- package/dist/defs/codex.d.ts.map +1 -1
- package/dist/defs/codex.js +34 -0
- package/dist/defs/codex.js.map +1 -1
- package/dist/defs/copilot.d.ts.map +1 -1
- package/dist/defs/copilot.js +15 -1
- package/dist/defs/copilot.js.map +1 -1
- package/dist/defs/cursor-agent.d.ts.map +1 -1
- package/dist/defs/cursor-agent.js +24 -2
- package/dist/defs/cursor-agent.js.map +1 -1
- package/dist/defs/deepseek.d.ts.map +1 -1
- package/dist/defs/deepseek.js +12 -0
- package/dist/defs/deepseek.js.map +1 -1
- package/dist/defs/devin.d.ts +2 -1
- package/dist/defs/devin.d.ts.map +1 -1
- package/dist/defs/devin.js +12 -7
- package/dist/defs/devin.js.map +1 -1
- package/dist/defs/grok-build.d.ts.map +1 -1
- package/dist/defs/grok-build.js +10 -0
- package/dist/defs/grok-build.js.map +1 -1
- package/dist/defs/hermes.d.ts +1 -0
- package/dist/defs/hermes.d.ts.map +1 -1
- package/dist/defs/hermes.js +3 -0
- package/dist/defs/hermes.js.map +1 -1
- package/dist/defs/kilo.d.ts +1 -0
- package/dist/defs/kilo.d.ts.map +1 -1
- package/dist/defs/kilo.js +3 -0
- package/dist/defs/kilo.js.map +1 -1
- package/dist/defs/kimi.d.ts +1 -0
- package/dist/defs/kimi.d.ts.map +1 -1
- package/dist/defs/kimi.js +3 -0
- package/dist/defs/kimi.js.map +1 -1
- package/dist/defs/kiro.d.ts +1 -0
- package/dist/defs/kiro.d.ts.map +1 -1
- package/dist/defs/kiro.js +3 -0
- package/dist/defs/kiro.js.map +1 -1
- package/dist/defs/opencode.d.ts +4 -0
- package/dist/defs/opencode.d.ts.map +1 -1
- package/dist/defs/opencode.js +19 -0
- package/dist/defs/opencode.js.map +1 -1
- package/dist/defs/pi.d.ts +5 -1
- package/dist/defs/pi.d.ts.map +1 -1
- package/dist/defs/pi.js +16 -4
- package/dist/defs/pi.js.map +1 -1
- package/dist/defs/qoder.d.ts +1 -0
- package/dist/defs/qoder.d.ts.map +1 -1
- package/dist/defs/qoder.js +15 -0
- package/dist/defs/qoder.js.map +1 -1
- package/dist/defs/qwen.d.ts.map +1 -1
- package/dist/defs/qwen.js +9 -0
- package/dist/defs/qwen.js.map +1 -1
- package/dist/defs/reasonix.d.ts +5 -0
- package/dist/defs/reasonix.d.ts.map +1 -1
- package/dist/defs/reasonix.js +19 -6
- package/dist/defs/reasonix.js.map +1 -1
- package/dist/defs/shared.d.ts +31 -1
- package/dist/defs/shared.d.ts.map +1 -1
- package/dist/defs/shared.js +35 -0
- package/dist/defs/shared.js.map +1 -1
- package/dist/defs/trae-cli.d.ts +1 -0
- package/dist/defs/trae-cli.d.ts.map +1 -1
- package/dist/defs/trae-cli.js +3 -0
- package/dist/defs/trae-cli.js.map +1 -1
- package/dist/defs/vibe.d.ts +1 -0
- package/dist/defs/vibe.d.ts.map +1 -1
- package/dist/defs/vibe.js +3 -0
- package/dist/defs/vibe.js.map +1 -1
- package/dist/detection.d.ts +1 -1
- package/dist/detection.d.ts.map +1 -1
- package/dist/detection.js +7 -2
- package/dist/detection.js.map +1 -1
- package/dist/env.js +1 -1
- package/dist/executables.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/invocation.js +1 -1
- package/dist/json-event-stream.d.ts.map +1 -1
- package/dist/json-event-stream.js +63 -0
- package/dist/json-event-stream.js.map +1 -1
- package/dist/launch.js +1 -1
- package/dist/log-file.d.ts +23 -0
- package/dist/log-file.d.ts.map +1 -0
- package/dist/log-file.js +56 -0
- package/dist/log-file.js.map +1 -0
- package/dist/mcp.d.ts +1 -1
- package/dist/metadata.d.ts +1 -1
- package/dist/mmd-routes.js +1 -1
- package/dist/model-registry.d.ts +7 -45
- package/dist/model-registry.d.ts.map +1 -1
- package/dist/model-registry.js.map +1 -1
- package/dist/models.d.ts +1 -1
- package/dist/opencode-log.js +1 -1
- package/dist/paths.js +1 -1
- package/dist/prompt-augmenter.d.ts +1 -1
- package/dist/prompt-augmenter.js +1 -1
- package/dist/prompt-budget.d.ts +1 -1
- package/dist/prompt-file.js +1 -1
- package/dist/providers/anthropic-messages.d.ts +198 -6
- package/dist/providers/anthropic-messages.d.ts.map +1 -1
- package/dist/providers/anthropic-messages.js +318 -141
- package/dist/providers/anthropic-messages.js.map +1 -1
- package/dist/providers/azure-chat.d.ts +113 -3
- package/dist/providers/azure-chat.d.ts.map +1 -1
- package/dist/providers/azure-chat.js +114 -21
- package/dist/providers/azure-chat.js.map +1 -1
- package/dist/providers/connection-guard.d.ts +121 -1
- package/dist/providers/connection-guard.d.ts.map +1 -1
- package/dist/providers/connection-guard.js +205 -3
- package/dist/providers/connection-guard.js.map +1 -1
- package/dist/providers/connection-test.d.ts +62 -0
- package/dist/providers/connection-test.d.ts.map +1 -0
- package/dist/providers/connection-test.js +404 -0
- package/dist/providers/connection-test.js.map +1 -0
- package/dist/providers/elevenlabs.d.ts.map +1 -1
- package/dist/providers/elevenlabs.js +3 -2
- package/dist/providers/elevenlabs.js.map +1 -1
- package/dist/providers/google-messages.d.ts +208 -7
- package/dist/providers/google-messages.d.ts.map +1 -1
- package/dist/providers/google-messages.js +331 -105
- package/dist/providers/google-messages.js.map +1 -1
- package/dist/providers/index.d.ts +1 -0
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/index.js +1 -0
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/model-catalog.d.ts.map +1 -1
- package/dist/providers/model-catalog.js +21 -0
- package/dist/providers/model-catalog.js.map +1 -1
- package/dist/providers/ollama-chat.d.ts +190 -1
- package/dist/providers/ollama-chat.d.ts.map +1 -1
- package/dist/providers/ollama-chat.js +319 -110
- package/dist/providers/ollama-chat.js.map +1 -1
- package/dist/providers/openai-chat.d.ts +222 -14
- package/dist/providers/openai-chat.d.ts.map +1 -1
- package/dist/providers/openai-chat.js +377 -128
- package/dist/providers/openai-chat.js.map +1 -1
- package/dist/providers/sse-decode.d.ts +2 -2
- package/dist/providers/sse-decode.js +2 -2
- package/dist/qoder-stream.d.ts +1 -1
- package/dist/qoder-stream.js +1 -1
- package/dist/registry.d.ts +16 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +19 -1
- package/dist/registry.js.map +1 -1
- package/dist/resolution.js +1 -1
- package/dist/terminal-launch.js +1 -1
- package/dist/types.d.ts +181 -89
- package/dist/types.d.ts.map +1 -1
- 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,
|
|
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
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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
|
|
100
|
-
const
|
|
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 =
|
|
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 {
|
|
250
|
+
return { type: 'ended' };
|
|
115
251
|
}
|
|
116
252
|
if (!response.ok) {
|
|
117
253
|
const rawText = await response.text();
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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 {
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
527
|
+
const exitReason = openAiLoopExitReason(outcome, toolTurns, maxToolTurns);
|
|
528
|
+
if (exitReason) {
|
|
529
|
+
emitEnd(exitReason);
|
|
272
530
|
break;
|
|
273
531
|
}
|
|
274
|
-
if (!
|
|
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
|
-
|
|
297
|
-
...toolResultMessages,
|
|
545
|
+
buildOpenAiAssistantToolCallMessage(outcome.text, assistantToolCalls),
|
|
546
|
+
...buildOpenAiToolExchangeMessages(toolResultMessages, followUpParts),
|
|
298
547
|
];
|
|
299
548
|
}
|
|
300
549
|
return { finishReason: lastFinishReason, toolTurns };
|