@warlock.js/ai-openai 5.25.0 → 5.27.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.
- package/CHANGELOG.md +22 -0
- package/cjs/index.cjs +1043 -272
- package/cjs/index.cjs.map +1 -1
- package/esm/config.type.d.mts +36 -2
- package/esm/config.type.d.mts.map +1 -1
- package/esm/known-reasoning-models.mjs +4 -2
- package/esm/known-reasoning-models.mjs.map +1 -1
- package/esm/known-vision-models.mjs +3 -0
- package/esm/known-vision-models.mjs.map +1 -1
- package/esm/model.mjs +52 -99
- package/esm/model.mjs.map +1 -1
- package/esm/responses-model.mjs +329 -0
- package/esm/responses-model.mjs.map +1 -0
- package/esm/sdk.d.mts +7 -0
- package/esm/sdk.d.mts.map +1 -1
- package/esm/sdk.mjs +21 -1
- package/esm/sdk.mjs.map +1 -1
- package/esm/utils/build-usage.mjs +23 -0
- package/esm/utils/build-usage.mjs.map +1 -0
- package/esm/utils/index.mjs +9 -1
- package/esm/utils/map-responses-status.mjs +54 -0
- package/esm/utils/map-responses-status.mjs.map +1 -0
- package/esm/utils/prompt-cache-params.mjs +17 -0
- package/esm/utils/prompt-cache-params.mjs.map +1 -0
- package/esm/utils/reasoning-effort.mjs +35 -0
- package/esm/utils/reasoning-effort.mjs.map +1 -0
- package/esm/utils/reasoning-replay.mjs +85 -0
- package/esm/utils/reasoning-replay.mjs.map +1 -0
- package/esm/utils/structured-output.mjs +84 -0
- package/esm/utils/structured-output.mjs.map +1 -0
- package/esm/utils/to-openai-messages.mjs +49 -5
- package/esm/utils/to-openai-messages.mjs.map +1 -1
- package/esm/utils/to-openai-tools.mjs +3 -3
- package/esm/utils/to-openai-tools.mjs.map +1 -1
- package/esm/utils/to-responses-input.mjs +112 -0
- package/esm/utils/to-responses-input.mjs.map +1 -0
- package/esm/utils/to-responses-tools.mjs +51 -0
- package/esm/utils/to-responses-tools.mjs.map +1 -0
- package/esm/utils/wrap-openai-error.mjs +7 -2
- package/esm/utils/wrap-openai-error.mjs.map +1 -1
- package/llms-full.txt +43 -4
- package/llms.txt +1 -1
- package/package.json +4 -4
- package/skills/setup-openai/SKILL.md +43 -4
package/cjs/index.cjs
CHANGED
|
@@ -31,6 +31,224 @@ openai = __toESM(openai, 1);
|
|
|
31
31
|
let _warlock_js_ai = require("@warlock.js/ai");
|
|
32
32
|
let _warlock_js_logger = require("@warlock.js/logger");
|
|
33
33
|
|
|
34
|
+
//#region ../ai-openai/src/utils/build-usage.ts
|
|
35
|
+
/**
|
|
36
|
+
* Build the neutral `Usage` from extracted counts. The optional detail
|
|
37
|
+
* counters are emitted only when the provider reports a positive value, so
|
|
38
|
+
* non-reasoning / uncached calls keep the lean `{ input, output, total }`
|
|
39
|
+
* shape. Shared by the Chat Completions and Responses adapters so both
|
|
40
|
+
* apply the same rule.
|
|
41
|
+
*/
|
|
42
|
+
function buildUsage(counts) {
|
|
43
|
+
const { input, output, total, cachedTokens, reasoningTokens, cacheWriteTokens } = counts;
|
|
44
|
+
return {
|
|
45
|
+
input,
|
|
46
|
+
output,
|
|
47
|
+
total,
|
|
48
|
+
...cachedTokens !== void 0 && cachedTokens > 0 ? { cachedTokens } : {},
|
|
49
|
+
...reasoningTokens !== void 0 && reasoningTokens > 0 ? { reasoningTokens } : {},
|
|
50
|
+
...cacheWriteTokens !== void 0 && cacheWriteTokens > 0 ? { cacheWriteTokens } : {}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region ../ai-openai/src/utils/wrap-openai-error.ts
|
|
56
|
+
/**
|
|
57
|
+
* Wrap any thrown value caught inside the OpenAI adapter into the
|
|
58
|
+
* appropriate `@warlock.js/ai` `AIError` subclass.
|
|
59
|
+
*
|
|
60
|
+
* **Dispatch strategy.** Prefers `APIError.code` when present (stable
|
|
61
|
+
* machine identifier across SDK versions), falls back to `status` when
|
|
62
|
+
* `code` is missing (common with proxied deployments that strip the
|
|
63
|
+
* field). Name-based detection (`APIConnectionTimeoutError`) catches
|
|
64
|
+
* transport-layer errors that never produced an HTTP response.
|
|
65
|
+
*
|
|
66
|
+
* `AIError` instances are returned unchanged — callers can pass the
|
|
67
|
+
* error through `try/catch/throw wrap(e)` pipelines without accidental
|
|
68
|
+
* double-wrapping.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* try {
|
|
72
|
+
* return await this.client.chat.completions.create(...);
|
|
73
|
+
* } catch (thrown) {
|
|
74
|
+
* throw wrapOpenAIError(thrown);
|
|
75
|
+
* }
|
|
76
|
+
*/
|
|
77
|
+
function wrapOpenAIError(thrown) {
|
|
78
|
+
if (thrown instanceof _warlock_js_ai.AIError) return thrown;
|
|
79
|
+
const shape = toShape(thrown);
|
|
80
|
+
const context = buildContext(thrown, shape);
|
|
81
|
+
const message = shape.message ?? (thrown instanceof Error ? thrown.message : String(thrown));
|
|
82
|
+
if (isTimeout(thrown, shape)) return new _warlock_js_ai.ProviderTimeoutError(message, {
|
|
83
|
+
cause: thrown,
|
|
84
|
+
context
|
|
85
|
+
});
|
|
86
|
+
if (shape.status === 401 || shape.code === "invalid_api_key") return new _warlock_js_ai.ProviderAuthError(message, {
|
|
87
|
+
cause: thrown,
|
|
88
|
+
context
|
|
89
|
+
});
|
|
90
|
+
if (shape.code === "insufficient_quota") return new _warlock_js_ai.QuotaExceededError(message, {
|
|
91
|
+
cause: thrown,
|
|
92
|
+
context
|
|
93
|
+
});
|
|
94
|
+
if (shape.status === 429 || shape.code === "rate_limit_exceeded") return new _warlock_js_ai.ProviderRateLimitError(message, {
|
|
95
|
+
cause: thrown,
|
|
96
|
+
context,
|
|
97
|
+
retryAfter: parseRetryAfter(shape.headers)
|
|
98
|
+
});
|
|
99
|
+
if (shape.code === "context_length_exceeded") return new _warlock_js_ai.ContextLengthExceededError(message, {
|
|
100
|
+
cause: thrown,
|
|
101
|
+
context
|
|
102
|
+
});
|
|
103
|
+
if (shape.code === "content_filter") return new _warlock_js_ai.ContentFilterError(message, {
|
|
104
|
+
cause: thrown,
|
|
105
|
+
context,
|
|
106
|
+
reason: message
|
|
107
|
+
});
|
|
108
|
+
if (typeof shape.status === "number" && shape.status >= 400 && shape.status < 500) return new _warlock_js_ai.InvalidRequestError(message, {
|
|
109
|
+
cause: thrown,
|
|
110
|
+
context
|
|
111
|
+
});
|
|
112
|
+
return new _warlock_js_ai.ProviderError(message, {
|
|
113
|
+
cause: thrown,
|
|
114
|
+
context
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Read the raw error shape without depending on `instanceof APIError`
|
|
119
|
+
* — some consumers wrap the SDK, and proxies sometimes strip the
|
|
120
|
+
* prototype chain. Duck-typing on the visible fields is resilient to
|
|
121
|
+
* both.
|
|
122
|
+
*/
|
|
123
|
+
function toShape(thrown) {
|
|
124
|
+
if (thrown instanceof openai.default.APIError) return {
|
|
125
|
+
status: thrown.status,
|
|
126
|
+
code: thrown.code,
|
|
127
|
+
message: thrown.message,
|
|
128
|
+
type: thrown.type,
|
|
129
|
+
param: thrown.param,
|
|
130
|
+
headers: thrown.headers,
|
|
131
|
+
name: thrown.name
|
|
132
|
+
};
|
|
133
|
+
if (typeof thrown === "object" && thrown !== null) {
|
|
134
|
+
const raw = thrown;
|
|
135
|
+
return {
|
|
136
|
+
status: typeof raw.status === "number" ? raw.status : void 0,
|
|
137
|
+
code: typeof raw.code === "string" ? raw.code : void 0,
|
|
138
|
+
message: typeof raw.message === "string" ? raw.message : void 0,
|
|
139
|
+
type: typeof raw.type === "string" ? raw.type : void 0,
|
|
140
|
+
param: typeof raw.param === "string" ? raw.param : void 0,
|
|
141
|
+
headers: typeof raw.headers === "object" && raw.headers !== null ? raw.headers : void 0,
|
|
142
|
+
name: typeof raw.name === "string" ? raw.name : void 0
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
return {};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Decide whether the thrown value represents a timeout. OpenAI's SDK
|
|
149
|
+
* throws `APIConnectionTimeoutError` for transport-level timeouts, and
|
|
150
|
+
* Node surfaces `ETIMEDOUT` / `ECONNABORTED` on the lower socket
|
|
151
|
+
* layer. Either signal counts.
|
|
152
|
+
*/
|
|
153
|
+
function isTimeout(thrown, shape) {
|
|
154
|
+
if (thrown instanceof openai.default.APIConnectionTimeoutError) return true;
|
|
155
|
+
if (shape.name === "APIConnectionTimeoutError") return true;
|
|
156
|
+
if (shape.code === "ETIMEDOUT" || shape.code === "ECONNABORTED") return true;
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Attach the raw diagnostic fields to `error.context` so consumers
|
|
161
|
+
* have everything the provider surfaced without each subclass having
|
|
162
|
+
* to redeclare them. Never includes `cause` — that lives on
|
|
163
|
+
* `error.cause`.
|
|
164
|
+
*/
|
|
165
|
+
function buildContext(thrown, shape) {
|
|
166
|
+
const context = {};
|
|
167
|
+
if (shape.status !== void 0) context.status = shape.status;
|
|
168
|
+
if (shape.code) context.code = shape.code;
|
|
169
|
+
if (shape.type) context.type = shape.type;
|
|
170
|
+
if (shape.param) context.param = shape.param;
|
|
171
|
+
const requestId = readRequestId(thrown);
|
|
172
|
+
if (requestId) context.requestId = requestId;
|
|
173
|
+
return context;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* The request id OpenAI returned. Current SDKs expose it as
|
|
177
|
+
* `APIError.requestID`; older ones and hand-rolled errors use
|
|
178
|
+
* `request_id` / `requestId`. Extract defensively.
|
|
179
|
+
*/
|
|
180
|
+
function readRequestId(thrown) {
|
|
181
|
+
if (typeof thrown !== "object" || thrown === null) return;
|
|
182
|
+
const raw = thrown;
|
|
183
|
+
if (typeof raw.request_id === "string") return raw.request_id;
|
|
184
|
+
if (typeof raw.requestId === "string") return raw.requestId;
|
|
185
|
+
if (typeof raw.requestID === "string") return raw.requestID;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Parse the `Retry-After` response header (seconds per HTTP spec)
|
|
189
|
+
* into milliseconds so consumers can feed it straight to `setTimeout`.
|
|
190
|
+
* Returns `undefined` when missing or unparseable.
|
|
191
|
+
*/
|
|
192
|
+
function parseRetryAfter(headers) {
|
|
193
|
+
if (!headers) return;
|
|
194
|
+
const raw = headers["retry-after"] ?? headers["Retry-After"];
|
|
195
|
+
if (!raw) return;
|
|
196
|
+
const seconds = Number(raw);
|
|
197
|
+
if (!Number.isFinite(seconds) || seconds < 0) return;
|
|
198
|
+
return Math.round(seconds * 1e3);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
//#endregion
|
|
202
|
+
//#region ../ai-openai/src/utils/map-responses-status.ts
|
|
203
|
+
/**
|
|
204
|
+
* Derive our `FinishReason` from a Responses terminal state. Responses has
|
|
205
|
+
* no `finish_reason`, so it comes from `status`, `incomplete_details` and the
|
|
206
|
+
* output items (design section 2, decision 5):
|
|
207
|
+
*
|
|
208
|
+
* - `completed` with a function call -> `tool_calls`; without -> `stop`
|
|
209
|
+
* (a refusal is ordinary text and also `stop`).
|
|
210
|
+
* - `incomplete` / `max_output_tokens` and `max_messages` -> `length`.
|
|
211
|
+
* - `incomplete` / `content_filter` and `steered` (or an unknown / missing
|
|
212
|
+
* reason) -> `error`. There is no neutral `content_filter` value.
|
|
213
|
+
* - `cancelled`, `queued`, `in_progress` at a terminal read -> `error`.
|
|
214
|
+
* - `failed` is not mapped here: callers throw it via {@link toResponseFailure}.
|
|
215
|
+
*
|
|
216
|
+
* A missing `status` is treated as `completed` (the SDK types it optional).
|
|
217
|
+
*/
|
|
218
|
+
function mapResponsesFinishReason(state) {
|
|
219
|
+
const status = state.status ?? "completed";
|
|
220
|
+
if (status === "completed") return state.hasToolCalls ? "tool_calls" : "stop";
|
|
221
|
+
if (status === "incomplete") {
|
|
222
|
+
const reason = state.incomplete_details?.reason;
|
|
223
|
+
return reason === "max_output_tokens" || reason === "max_messages" ? "length" : "error";
|
|
224
|
+
}
|
|
225
|
+
return "error";
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Turn a `failed` Responses payload into the same typed `AIError` the Chat
|
|
229
|
+
* path throws for a provider failure, by running it through
|
|
230
|
+
* `wrapOpenAIError` (so `rate_limit_exceeded` becomes a
|
|
231
|
+
* `ProviderRateLimitError`, everything else a `ProviderError`, with `code`
|
|
232
|
+
* and request id kept on `context`). The response id and model are added so
|
|
233
|
+
* the failure is traceable.
|
|
234
|
+
*/
|
|
235
|
+
function toResponseFailure(response, model) {
|
|
236
|
+
const error = response.error;
|
|
237
|
+
const requestId = response._request_id;
|
|
238
|
+
const wrapped = wrapOpenAIError({
|
|
239
|
+
code: error?.code,
|
|
240
|
+
message: error?.message ?? "OpenAI response failed without an error object.",
|
|
241
|
+
...requestId ? { request_id: requestId } : {}
|
|
242
|
+
});
|
|
243
|
+
if (wrapped.context) Object.assign(wrapped.context, {
|
|
244
|
+
provider: "openai",
|
|
245
|
+
model,
|
|
246
|
+
responseId: response.id
|
|
247
|
+
});
|
|
248
|
+
return wrapped;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
//#endregion
|
|
34
252
|
//#region ../ai-openai/src/utils/map-finish-reason.ts
|
|
35
253
|
const finishReasonMap = {
|
|
36
254
|
stop: "stop",
|
|
@@ -50,6 +268,219 @@ function mapFinishReason(raw) {
|
|
|
50
268
|
return finishReasonMap[raw ?? ""] ?? "error";
|
|
51
269
|
}
|
|
52
270
|
|
|
271
|
+
//#endregion
|
|
272
|
+
//#region ../ai-openai/src/utils/prompt-cache-params.ts
|
|
273
|
+
/**
|
|
274
|
+
* Build the OpenAI-only prompt-cache request fields from the model config.
|
|
275
|
+
* Returns nothing for any provider label other than `"openai"`: wrapper
|
|
276
|
+
* endpoints use the same transport but may reject these OpenAI-only fields.
|
|
277
|
+
*/
|
|
278
|
+
function buildPromptCacheParams(provider, config) {
|
|
279
|
+
if (provider !== "openai") return {};
|
|
280
|
+
return {
|
|
281
|
+
...config.promptCacheKey !== void 0 ? { prompt_cache_key: config.promptCacheKey } : {},
|
|
282
|
+
...config.promptCacheRetention !== void 0 ? { prompt_cache_retention: config.promptCacheRetention } : {}
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
//#endregion
|
|
287
|
+
//#region ../ai-openai/src/utils/reasoning-effort.ts
|
|
288
|
+
/**
|
|
289
|
+
* The installed OpenAI SDK 7.23.0 declares `reasoning_effort` as
|
|
290
|
+
* `none | minimal | low | medium | high | xhigh | max` in
|
|
291
|
+
* `openai/src/resources/shared.ts:367`. Its type notes that model support
|
|
292
|
+
* varies but does not provide a model-family-specific accepted set, so map by
|
|
293
|
+
* that union alone. This exhaustive record makes a future core level a
|
|
294
|
+
* typecheck failure until its OpenAI clamp is consciously selected.
|
|
295
|
+
*
|
|
296
|
+
* Both the Chat Completions adapter (`reasoning_effort`) and the Responses
|
|
297
|
+
* adapter (`reasoning.effort`) read this one table, so the two can never
|
|
298
|
+
* disagree on how a neutral level reaches the wire.
|
|
299
|
+
*/
|
|
300
|
+
const EFFORT_TO_OPENAI_EFFORT = {
|
|
301
|
+
none: "none",
|
|
302
|
+
minimal: "minimal",
|
|
303
|
+
low: "low",
|
|
304
|
+
medium: "medium",
|
|
305
|
+
high: "high",
|
|
306
|
+
xhigh: "xhigh",
|
|
307
|
+
max: "max"
|
|
308
|
+
};
|
|
309
|
+
/**
|
|
310
|
+
* Map a neutral `ReasoningEffort` to the value OpenAI accepts.
|
|
311
|
+
*
|
|
312
|
+
* @example
|
|
313
|
+
* toOpenAIReasoningEffort("high"); // "high"
|
|
314
|
+
*/
|
|
315
|
+
function toOpenAIReasoningEffort(effort) {
|
|
316
|
+
return EFFORT_TO_OPENAI_EFFORT[effort];
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
//#endregion
|
|
320
|
+
//#region ../ai-openai/src/utils/structured-output.ts
|
|
321
|
+
/**
|
|
322
|
+
* Map an explicit `responseFormat` override to the default
|
|
323
|
+
* `structuredOutput` capability. Loose wire modes (`"json_object"`,
|
|
324
|
+
* `"text"`) don't enforce shape, so the agent needs to see the soft
|
|
325
|
+
* schema hint in the system prompt — that only happens when the
|
|
326
|
+
* capability is `false`. Default (no override) stays `true` to
|
|
327
|
+
* preserve the prior assumption that OpenAI models support strict
|
|
328
|
+
* structured output.
|
|
329
|
+
*/
|
|
330
|
+
function inferStructuredOutput(responseFormat) {
|
|
331
|
+
if (responseFormat === "json_object" || responseFormat === "text") return false;
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Decide the structured-output mode for a call.
|
|
336
|
+
*
|
|
337
|
+
* When `override` (the `responseFormat` config) is set, it wins: `"text"`
|
|
338
|
+
* emits nothing, `"json_object"` always picks the loose mode, and
|
|
339
|
+
* `"json_schema"` picks strict mode (with the same strict-compatibility
|
|
340
|
+
* safety check - a malformed schema still degrades to `json_object` rather
|
|
341
|
+
* than 400). The override exists because some targets (older OpenAI models,
|
|
342
|
+
* OpenRouter routes, Ollama OpenAI-compat) reject strict `json_schema`
|
|
343
|
+
* outright.
|
|
344
|
+
*
|
|
345
|
+
* When the override is omitted, strict `json_schema` mode (token-level
|
|
346
|
+
* enforcement) is used only when the schema is a proper root-object JSON
|
|
347
|
+
* Schema whose every object lists all of its properties in `required`. For
|
|
348
|
+
* anything else it falls back to loose `json_object` mode. Client-side
|
|
349
|
+
* Standard Schema `validate()` still enforces the full shape.
|
|
350
|
+
*/
|
|
351
|
+
function planStructuredOutput(responseSchema, override) {
|
|
352
|
+
if (!responseSchema || override === "text") return { mode: "none" };
|
|
353
|
+
if (override === "json_object") return { mode: "json_object" };
|
|
354
|
+
if (isStrictCompatible(responseSchema)) return {
|
|
355
|
+
mode: "json_schema",
|
|
356
|
+
schema: responseSchema
|
|
357
|
+
};
|
|
358
|
+
return { mode: "json_object" };
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* OpenAI strict `json_schema` mode requires the root to be a JSON Schema
|
|
362
|
+
* object type (`{ type: "object", properties: ... }`). Anything else
|
|
363
|
+
* (top-level arrays, primitives, unknown shapes) is rejected with a 400
|
|
364
|
+
* before a token is sampled. Checked structurally here so the first call
|
|
365
|
+
* doesn't crash on a malformed extraction.
|
|
366
|
+
*/
|
|
367
|
+
function isStrictCompatible(schema) {
|
|
368
|
+
return schema.type === "object" && typeof schema.properties === "object" && schema.properties !== null && isStrictSafeNode(schema);
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Recursively check the one strict-mode rule schemas most often trip on:
|
|
372
|
+
* every object must list ALL of its `properties` in `required` (OpenAI
|
|
373
|
+
* strict has no notion of optional - optional fields must be expressed as
|
|
374
|
+
* nullable, e.g. `type: ["string", "null"]`, and still appear in
|
|
375
|
+
* `required`). A schema that violates this anywhere in the tree is NOT sent
|
|
376
|
+
* in strict mode, so a hand-built or optional-bearing schema can't 400 the
|
|
377
|
+
* call ("'required' ... must include every key in properties").
|
|
378
|
+
*/
|
|
379
|
+
function isStrictSafeNode(node) {
|
|
380
|
+
if (!node || typeof node !== "object") return true;
|
|
381
|
+
const record = node;
|
|
382
|
+
if (record.type === "object" && record.properties && typeof record.properties === "object") {
|
|
383
|
+
const properties = record.properties;
|
|
384
|
+
const keys = Object.keys(properties);
|
|
385
|
+
const required = Array.isArray(record.required) ? record.required : [];
|
|
386
|
+
if (keys.some((key) => !required.includes(key))) return false;
|
|
387
|
+
for (const key of keys) if (!isStrictSafeNode(properties[key])) return false;
|
|
388
|
+
}
|
|
389
|
+
if (record.items !== void 0 && !isStrictSafeNode(record.items)) return false;
|
|
390
|
+
for (const branch of [
|
|
391
|
+
"anyOf",
|
|
392
|
+
"allOf",
|
|
393
|
+
"oneOf"
|
|
394
|
+
]) {
|
|
395
|
+
const value = record[branch];
|
|
396
|
+
if (Array.isArray(value) && value.some((sub) => !isStrictSafeNode(sub))) return false;
|
|
397
|
+
}
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
//#endregion
|
|
402
|
+
//#region ../ai-openai/src/utils/reasoning-replay.ts
|
|
403
|
+
/**
|
|
404
|
+
* Key under `ModelToolCallRequest.providerMetadata` owned by the Responses
|
|
405
|
+
* adapter. This name and the shape below are PERSISTED in users' conversation
|
|
406
|
+
* memory, so they are part of the public contract: do not rename.
|
|
407
|
+
*/
|
|
408
|
+
const RESPONSES_METADATA_KEY = "openaiResponses";
|
|
409
|
+
/**
|
|
410
|
+
* Keep only the reasoning items that can actually be replayed. With
|
|
411
|
+
* `store: false` the server keeps nothing, so an item without
|
|
412
|
+
* `encrypted_content` has nothing to restore and replaying its bare `id`
|
|
413
|
+
* would be rejected as an unknown item.
|
|
414
|
+
*/
|
|
415
|
+
function pickReplayableReasoning(items) {
|
|
416
|
+
return items.filter((item) => typeof item.encrypted_content === "string" && item.encrypted_content !== "");
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Attach the turn's reasoning items to the FIRST tool call only (parallel
|
|
420
|
+
* calls do not each repeat the blobs). Returns the calls unchanged when
|
|
421
|
+
* there is nothing to replay or no tool call to ride on (a text-only turn is
|
|
422
|
+
* deliberately not replayed, decision 2). Does not mutate its input.
|
|
423
|
+
*/
|
|
424
|
+
function attachReasoningReplay(toolCalls, model, reasoningItems) {
|
|
425
|
+
const replayable = pickReplayableReasoning(reasoningItems);
|
|
426
|
+
const [first, ...rest] = toolCalls;
|
|
427
|
+
if (first === void 0 || replayable.length === 0) return [...toolCalls];
|
|
428
|
+
const metadata = {
|
|
429
|
+
model,
|
|
430
|
+
reasoningItems: replayable
|
|
431
|
+
};
|
|
432
|
+
return [{
|
|
433
|
+
...first,
|
|
434
|
+
providerMetadata: {
|
|
435
|
+
...first.providerMetadata,
|
|
436
|
+
[RESPONSES_METADATA_KEY]: metadata
|
|
437
|
+
}
|
|
438
|
+
}, ...rest];
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Read the stored reasoning items off an assistant turn's tool calls, for
|
|
442
|
+
* replay on `model`. Returns `[]` when the key is absent or malformed, or
|
|
443
|
+
* when the stored `model` differs from `model` (encrypted blobs are not
|
|
444
|
+
* portable across models, design section 4). De-duplicates by item `id`.
|
|
445
|
+
*/
|
|
446
|
+
function readReasoningReplay(toolCalls, model) {
|
|
447
|
+
const seen = /* @__PURE__ */ new Set();
|
|
448
|
+
const items = [];
|
|
449
|
+
for (const call of toolCalls) {
|
|
450
|
+
const stored = call.providerMetadata?.[RESPONSES_METADATA_KEY];
|
|
451
|
+
if (!isReplayMetadata(stored) || stored.model !== model) continue;
|
|
452
|
+
for (const item of stored.reasoningItems) {
|
|
453
|
+
if (item?.type !== "reasoning" || seen.has(item.id)) continue;
|
|
454
|
+
seen.add(item.id);
|
|
455
|
+
items.push(item);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return items;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Shape a stored reasoning item for the next request. Sends the four fields
|
|
462
|
+
* a stateless replay needs (`type`, `id`, `summary`, `encrypted_content`) and
|
|
463
|
+
* drops the response-only `status` and the optional raw `content`.
|
|
464
|
+
*
|
|
465
|
+
* UNVERIFIED: the SDK types accept the full item as input (`ResponseInputItem`
|
|
466
|
+
* includes `ResponseReasoningItem`) and do not say which fields the API
|
|
467
|
+
* refuses on replay with `store: false`. This is the conservative guess; a
|
|
468
|
+
* live probe settles it.
|
|
469
|
+
*/
|
|
470
|
+
function toReplayReasoningItem(item) {
|
|
471
|
+
return {
|
|
472
|
+
type: "reasoning",
|
|
473
|
+
id: item.id,
|
|
474
|
+
summary: Array.isArray(item.summary) ? item.summary : [],
|
|
475
|
+
encrypted_content: item.encrypted_content
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
function isReplayMetadata(value) {
|
|
479
|
+
if (typeof value !== "object" || value === null) return false;
|
|
480
|
+
const candidate = value;
|
|
481
|
+
return typeof candidate.model === "string" && Array.isArray(candidate.reasoningItems);
|
|
482
|
+
}
|
|
483
|
+
|
|
53
484
|
//#endregion
|
|
54
485
|
//#region ../ai-openai/src/utils/to-openai-messages.ts
|
|
55
486
|
/**
|
|
@@ -76,8 +507,8 @@ function mapFinishReason(raw) {
|
|
|
76
507
|
* ]},
|
|
77
508
|
* ]);
|
|
78
509
|
*/
|
|
79
|
-
function toOpenAIMessages(messages) {
|
|
80
|
-
return messages.map((m) => {
|
|
510
|
+
function toOpenAIMessages(messages, cacheBreakpoints = 0) {
|
|
511
|
+
return addPromptCacheBreakpoints(messages.map((m) => {
|
|
81
512
|
if (m.role === "tool") return {
|
|
82
513
|
role: "tool",
|
|
83
514
|
content: stringifyContent(m.content),
|
|
@@ -103,7 +534,44 @@ function toOpenAIMessages(messages) {
|
|
|
103
534
|
role: m.role,
|
|
104
535
|
content: stringifyContent(m.content)
|
|
105
536
|
};
|
|
106
|
-
});
|
|
537
|
+
}), cacheBreakpoints);
|
|
538
|
+
}
|
|
539
|
+
/** Place explicit markers on the last requested user-content parts (maximum four). */
|
|
540
|
+
function addPromptCacheBreakpoints(messages, requested) {
|
|
541
|
+
let remaining = Math.min(4, Math.max(0, Math.floor(requested)));
|
|
542
|
+
if (remaining === 0) return messages;
|
|
543
|
+
const marked = [...messages];
|
|
544
|
+
for (let index = marked.length - 1; index >= 0 && remaining > 0; index--) {
|
|
545
|
+
const message = marked[index];
|
|
546
|
+
if (message === void 0 || message.role !== "user") continue;
|
|
547
|
+
if (typeof message.content === "string") {
|
|
548
|
+
marked[index] = {
|
|
549
|
+
...message,
|
|
550
|
+
content: [{
|
|
551
|
+
type: "text",
|
|
552
|
+
text: message.content,
|
|
553
|
+
prompt_cache_breakpoint: { mode: "explicit" }
|
|
554
|
+
}]
|
|
555
|
+
};
|
|
556
|
+
remaining--;
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
const content = [...message.content];
|
|
560
|
+
for (let partIndex = content.length - 1; partIndex >= 0 && remaining > 0; partIndex--) {
|
|
561
|
+
const part = content[partIndex];
|
|
562
|
+
if (part === void 0) continue;
|
|
563
|
+
content[partIndex] = {
|
|
564
|
+
...part,
|
|
565
|
+
prompt_cache_breakpoint: { mode: "explicit" }
|
|
566
|
+
};
|
|
567
|
+
remaining--;
|
|
568
|
+
}
|
|
569
|
+
marked[index] = {
|
|
570
|
+
...message,
|
|
571
|
+
content
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
return marked;
|
|
107
575
|
}
|
|
108
576
|
/**
|
|
109
577
|
* Multipart content is only meaningful on user messages — for any other
|
|
@@ -141,7 +609,7 @@ function toOpenAIContentPart(part) {
|
|
|
141
609
|
};
|
|
142
610
|
if (part.type === "image") return {
|
|
143
611
|
type: "image_url",
|
|
144
|
-
image_url: { url:
|
|
612
|
+
image_url: { url: toImageUrl(part.source) }
|
|
145
613
|
};
|
|
146
614
|
if (part.type === "pdf") {
|
|
147
615
|
if ("url" in part.source) throw new _warlock_js_ai.InvalidRequestError("OpenAI chat completions cannot fetch a remote-URL PDF; supply base64 document bytes instead.");
|
|
@@ -163,6 +631,13 @@ function toOpenAIContentPart(part) {
|
|
|
163
631
|
};
|
|
164
632
|
}
|
|
165
633
|
/**
|
|
634
|
+
* Render an image source as a URL: a remote URL passes through, inlined
|
|
635
|
+
* base64 bytes become a `data:` URL. Shared with the Responses adapter.
|
|
636
|
+
*/
|
|
637
|
+
function toImageUrl(source) {
|
|
638
|
+
return "url" in source ? source.url : `data:${source.mediaType};base64,${source.base64}`;
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
166
641
|
* Narrow a neutral audio media type to the two formats OpenAI's
|
|
167
642
|
* `input_audio` accepts (`wav` / `mp3`). An unsupported type raises a
|
|
168
643
|
* typed `InvalidRequestError` up front rather than a provider 400.
|
|
@@ -185,176 +660,187 @@ function toOpenAIAudioFormat(mediaType) {
|
|
|
185
660
|
* await client.chat.completions.create({ model, messages, tools });
|
|
186
661
|
*/
|
|
187
662
|
function toOpenAITools(tools) {
|
|
188
|
-
if (!tools || tools.length === 0) return;
|
|
189
|
-
return tools.map((tool) => ({
|
|
190
|
-
type: "function",
|
|
191
|
-
function: {
|
|
192
|
-
name: tool.name,
|
|
193
|
-
description: tool.description,
|
|
194
|
-
parameters:
|
|
195
|
-
}
|
|
196
|
-
}));
|
|
197
|
-
}
|
|
198
|
-
/**
|
|
199
|
-
* Resolve a tool's input schema to a JSON-Schema object. OpenAI's
|
|
200
|
-
* function `parameters` expects an object root; anything else (or a
|
|
201
|
-
* failed extraction) degrades to an empty-object schema so the tool
|
|
202
|
-
* still registers and the model simply sees no parameters.
|
|
203
|
-
*/
|
|
204
|
-
function toParameters(input) {
|
|
205
|
-
const schema = (0, _warlock_js_ai.extractJsonSchema)(input);
|
|
206
|
-
if (schema && schema.type === "object") return schema;
|
|
207
|
-
return {
|
|
208
|
-
type: "object",
|
|
209
|
-
properties: {}
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
//#endregion
|
|
214
|
-
//#region ../ai-openai/src/utils/wrap-openai-error.ts
|
|
215
|
-
/**
|
|
216
|
-
* Wrap any thrown value caught inside the OpenAI adapter into the
|
|
217
|
-
* appropriate `@warlock.js/ai` `AIError` subclass.
|
|
218
|
-
*
|
|
219
|
-
* **Dispatch strategy.** Prefers `APIError.code` when present (stable
|
|
220
|
-
* machine identifier across SDK versions), falls back to `status` when
|
|
221
|
-
* `code` is missing (common with proxied deployments that strip the
|
|
222
|
-
* field). Name-based detection (`APIConnectionTimeoutError`) catches
|
|
223
|
-
* transport-layer errors that never produced an HTTP response.
|
|
224
|
-
*
|
|
225
|
-
* `AIError` instances are returned unchanged — callers can pass the
|
|
226
|
-
* error through `try/catch/throw wrap(e)` pipelines without accidental
|
|
227
|
-
* double-wrapping.
|
|
228
|
-
*
|
|
229
|
-
* @example
|
|
230
|
-
* try {
|
|
231
|
-
* return await this.client.chat.completions.create(...);
|
|
232
|
-
* } catch (thrown) {
|
|
233
|
-
* throw wrapOpenAIError(thrown);
|
|
234
|
-
* }
|
|
235
|
-
*/
|
|
236
|
-
function wrapOpenAIError(thrown) {
|
|
237
|
-
if (thrown instanceof _warlock_js_ai.AIError) return thrown;
|
|
238
|
-
const shape = toShape(thrown);
|
|
239
|
-
const context = buildContext(thrown, shape);
|
|
240
|
-
const message = shape.message ?? (thrown instanceof Error ? thrown.message : String(thrown));
|
|
241
|
-
if (isTimeout(thrown, shape)) return new _warlock_js_ai.ProviderTimeoutError(message, {
|
|
242
|
-
cause: thrown,
|
|
243
|
-
context
|
|
244
|
-
});
|
|
245
|
-
if (shape.status === 401 || shape.code === "invalid_api_key") return new _warlock_js_ai.ProviderAuthError(message, {
|
|
246
|
-
cause: thrown,
|
|
247
|
-
context
|
|
248
|
-
});
|
|
249
|
-
if (shape.code === "insufficient_quota") return new _warlock_js_ai.QuotaExceededError(message, {
|
|
250
|
-
cause: thrown,
|
|
251
|
-
context
|
|
252
|
-
});
|
|
253
|
-
if (shape.status === 429 || shape.code === "rate_limit_exceeded") return new _warlock_js_ai.ProviderRateLimitError(message, {
|
|
254
|
-
cause: thrown,
|
|
255
|
-
context,
|
|
256
|
-
retryAfter: parseRetryAfter(shape.headers)
|
|
257
|
-
});
|
|
258
|
-
if (shape.code === "context_length_exceeded") return new _warlock_js_ai.ContextLengthExceededError(message, {
|
|
259
|
-
cause: thrown,
|
|
260
|
-
context
|
|
261
|
-
});
|
|
262
|
-
if (shape.code === "content_filter") return new _warlock_js_ai.ContentFilterError(message, {
|
|
263
|
-
cause: thrown,
|
|
264
|
-
context,
|
|
265
|
-
reason: message
|
|
266
|
-
});
|
|
267
|
-
if (typeof shape.status === "number" && shape.status >= 400 && shape.status < 500) return new _warlock_js_ai.InvalidRequestError(message, {
|
|
268
|
-
cause: thrown,
|
|
269
|
-
context
|
|
270
|
-
});
|
|
271
|
-
return new _warlock_js_ai.ProviderError(message, {
|
|
272
|
-
cause: thrown,
|
|
273
|
-
context
|
|
274
|
-
});
|
|
275
|
-
}
|
|
276
|
-
/**
|
|
277
|
-
* Read the raw error shape without depending on `instanceof APIError`
|
|
278
|
-
* — some consumers wrap the SDK, and proxies sometimes strip the
|
|
279
|
-
* prototype chain. Duck-typing on the visible fields is resilient to
|
|
280
|
-
* both.
|
|
281
|
-
*/
|
|
282
|
-
function toShape(thrown) {
|
|
283
|
-
if (thrown instanceof openai.default.APIError) return {
|
|
284
|
-
status: thrown.status,
|
|
285
|
-
code: thrown.code,
|
|
286
|
-
message: thrown.message,
|
|
287
|
-
type: thrown.type,
|
|
288
|
-
headers: thrown.headers,
|
|
289
|
-
name: thrown.name
|
|
290
|
-
};
|
|
291
|
-
if (typeof thrown === "object" && thrown !== null) {
|
|
292
|
-
const raw = thrown;
|
|
293
|
-
return {
|
|
294
|
-
status: typeof raw.status === "number" ? raw.status : void 0,
|
|
295
|
-
code: typeof raw.code === "string" ? raw.code : void 0,
|
|
296
|
-
message: typeof raw.message === "string" ? raw.message : void 0,
|
|
297
|
-
type: typeof raw.type === "string" ? raw.type : void 0,
|
|
298
|
-
headers: typeof raw.headers === "object" && raw.headers !== null ? raw.headers : void 0,
|
|
299
|
-
name: typeof raw.name === "string" ? raw.name : void 0
|
|
300
|
-
};
|
|
301
|
-
}
|
|
302
|
-
return {};
|
|
663
|
+
if (!tools || tools.length === 0) return;
|
|
664
|
+
return tools.map((tool) => ({
|
|
665
|
+
type: "function",
|
|
666
|
+
function: {
|
|
667
|
+
name: tool.name,
|
|
668
|
+
description: tool.description,
|
|
669
|
+
parameters: toToolParameters(tool.input)
|
|
670
|
+
}
|
|
671
|
+
}));
|
|
303
672
|
}
|
|
304
673
|
/**
|
|
305
|
-
*
|
|
306
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
674
|
+
* Resolve a tool's input schema to a JSON-Schema object. OpenAI's
|
|
675
|
+
* function `parameters` expects an object root; anything else (or a
|
|
676
|
+
* failed extraction) degrades to an empty-object schema so the tool
|
|
677
|
+
* still registers and the model simply sees no parameters.
|
|
309
678
|
*/
|
|
310
|
-
function
|
|
311
|
-
|
|
312
|
-
if (
|
|
313
|
-
|
|
314
|
-
|
|
679
|
+
function toToolParameters(input) {
|
|
680
|
+
const schema = (0, _warlock_js_ai.extractJsonSchema)(input);
|
|
681
|
+
if (schema && schema.type === "object") return schema;
|
|
682
|
+
return {
|
|
683
|
+
type: "object",
|
|
684
|
+
properties: {}
|
|
685
|
+
};
|
|
315
686
|
}
|
|
687
|
+
|
|
688
|
+
//#endregion
|
|
689
|
+
//#region ../ai-openai/src/utils/to-responses-input.ts
|
|
316
690
|
/**
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
* `
|
|
691
|
+
* Convert vendor-neutral `Message[]` into the Responses request pieces.
|
|
692
|
+
*
|
|
693
|
+
* - The FIRST system message becomes `instructions` (decision 9: it avoids
|
|
694
|
+
* the `system` vs `developer` role question on reasoning models). Any
|
|
695
|
+
* later system message stays inline as a `system` role item.
|
|
696
|
+
* - `user` / `assistant` text becomes an `EasyInputMessage` (`{ role,
|
|
697
|
+
* content }`). Multipart user content becomes `input_text` / `input_image`
|
|
698
|
+
* parts; images always carry `detail: "auto"` because `ResponseInputImage.detail`
|
|
699
|
+
* is required by the SDK types.
|
|
700
|
+
* - An assistant message that requested tools becomes its text (when
|
|
701
|
+
* non-empty) followed by one top-level `function_call` item per call; our
|
|
702
|
+
* `ModelToolCallRequest.id` is the wire `call_id`. No item `id` is sent:
|
|
703
|
+
* with `store: false` nothing was persisted for it to refer to.
|
|
704
|
+
* - When `model` is given, reasoning items stored on the turn's tool calls
|
|
705
|
+
* (`providerMetadata.openaiResponses`, see `reasoning-replay.ts`) are
|
|
706
|
+
* emitted BEFORE the turn's text and `function_call` items (the order the
|
|
707
|
+
* API produced them), only if they were produced by that same model.
|
|
708
|
+
* Without `model` nothing is replayed.
|
|
709
|
+
* - A `tool` message becomes a top-level `function_call_output` item.
|
|
710
|
+
*
|
|
711
|
+
* PDF and audio parts are rejected with a typed `InvalidRequestError`: the
|
|
712
|
+
* Responses adapter declares those capabilities off, so they only arrive
|
|
713
|
+
* here if a caller bypasses the agent's capability gate.
|
|
714
|
+
*
|
|
715
|
+
* @example
|
|
716
|
+
* toResponsesInput([
|
|
717
|
+
* { role: "system", content: "Be brief." },
|
|
718
|
+
* { role: "user", content: "Hi" },
|
|
719
|
+
* ]);
|
|
720
|
+
* // { instructions: "Be brief.", input: [{ role: "user", content: "Hi" }] }
|
|
321
721
|
*/
|
|
322
|
-
function
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
722
|
+
function toResponsesInput(messages, model) {
|
|
723
|
+
const input = [];
|
|
724
|
+
let instructions;
|
|
725
|
+
let instructionsTaken = false;
|
|
726
|
+
for (const message of messages) {
|
|
727
|
+
if (message.role === "system") {
|
|
728
|
+
const text = stringifyContent(message.content);
|
|
729
|
+
if (!instructionsTaken) {
|
|
730
|
+
instructionsTaken = true;
|
|
731
|
+
instructions = text === "" ? void 0 : text;
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
input.push({
|
|
735
|
+
role: "system",
|
|
736
|
+
content: text
|
|
737
|
+
});
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
if (message.role === "tool") {
|
|
741
|
+
input.push({
|
|
742
|
+
type: "function_call_output",
|
|
743
|
+
call_id: message.toolCallId ?? "",
|
|
744
|
+
output: stringifyContent(message.content)
|
|
745
|
+
});
|
|
746
|
+
continue;
|
|
747
|
+
}
|
|
748
|
+
if (message.role === "assistant" && message.toolCalls && message.toolCalls.length > 0) {
|
|
749
|
+
const text = stringifyContent(message.content);
|
|
750
|
+
if (model !== void 0) for (const item of readReasoningReplay(message.toolCalls, model)) input.push(toReplayReasoningItem(item));
|
|
751
|
+
if (text !== "") input.push({
|
|
752
|
+
role: "assistant",
|
|
753
|
+
content: text
|
|
754
|
+
});
|
|
755
|
+
for (const call of message.toolCalls) input.push({
|
|
756
|
+
type: "function_call",
|
|
757
|
+
call_id: call.id,
|
|
758
|
+
name: call.name,
|
|
759
|
+
arguments: JSON.stringify(call.input ?? {})
|
|
760
|
+
});
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (message.role === "user" && Array.isArray(message.content)) {
|
|
764
|
+
input.push({
|
|
765
|
+
role: "user",
|
|
766
|
+
content: message.content.map(toResponsesContentPart)
|
|
767
|
+
});
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
input.push({
|
|
771
|
+
role: message.role,
|
|
772
|
+
content: stringifyContent(message.content)
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
return {
|
|
776
|
+
...instructions !== void 0 ? { instructions } : {},
|
|
777
|
+
input
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
/** Map a resolved `ContentPart` to a Responses input content part (text and images only). */
|
|
781
|
+
function toResponsesContentPart(part) {
|
|
782
|
+
if (part.type === "text") return {
|
|
783
|
+
type: "input_text",
|
|
784
|
+
text: part.text
|
|
785
|
+
};
|
|
786
|
+
if (part.type === "image") return {
|
|
787
|
+
type: "input_image",
|
|
788
|
+
image_url: toImageUrl(part.source),
|
|
789
|
+
detail: "auto"
|
|
790
|
+
};
|
|
791
|
+
throw new _warlock_js_ai.InvalidRequestError(`OpenAI api: "responses" does not accept ${part.type} input in this adapter; use api: "chat" for ${part.type} attachments.`);
|
|
330
792
|
}
|
|
793
|
+
|
|
794
|
+
//#endregion
|
|
795
|
+
//#region ../ai-openai/src/utils/to-responses-tools.ts
|
|
331
796
|
/**
|
|
332
|
-
*
|
|
333
|
-
*
|
|
797
|
+
* Convert vendor-neutral `ToolConfig[]` to Responses function tools.
|
|
798
|
+
*
|
|
799
|
+
* Responses tools are FLAT (`name` / `description` / `parameters` sit beside
|
|
800
|
+
* `type`, not under a nested `function` object) and `strict` is required by
|
|
801
|
+
* the SDK type (`FunctionTool.strict: boolean | null`). The Chat path never
|
|
802
|
+
* sets `strict` (non-strict is the OpenAI default there), so every tool is
|
|
803
|
+
* sent with `strict: false` to keep the same behavior.
|
|
804
|
+
*
|
|
805
|
+
* @example
|
|
806
|
+
* toResponsesTools([weatherTool]);
|
|
807
|
+
* // [{ type: "function", name: "getWeather", description: "...", parameters: {...}, strict: false }]
|
|
334
808
|
*/
|
|
335
|
-
function
|
|
336
|
-
if (
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
809
|
+
function toResponsesTools(tools) {
|
|
810
|
+
if (!tools || tools.length === 0) return;
|
|
811
|
+
return tools.map((tool) => ({
|
|
812
|
+
type: "function",
|
|
813
|
+
name: tool.name,
|
|
814
|
+
description: tool.description,
|
|
815
|
+
parameters: toToolParameters(tool.input),
|
|
816
|
+
strict: false
|
|
817
|
+
}));
|
|
340
818
|
}
|
|
341
819
|
/**
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
820
|
+
* Read an optional `toolChoice` call option into the Responses `tool_choice`.
|
|
821
|
+
*
|
|
822
|
+
* `ModelCallOptions` does not declare `toolChoice` today (it only arrives
|
|
823
|
+
* through the open index signature), so the value is validated here rather
|
|
824
|
+
* than trusted. Accepted: `"none" | "auto" | "required"`, or a forced
|
|
825
|
+
* function as `{ name }` / `{ type: "function", name }` (the Chat-style
|
|
826
|
+
* nested `{ type: "function", function: { name } }` is also accepted). The
|
|
827
|
+
* Responses form is flat: `{ type: "function", name }`. Anything else yields
|
|
828
|
+
* `undefined`, leaving the provider default.
|
|
345
829
|
*/
|
|
346
|
-
function
|
|
347
|
-
if (
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
const
|
|
351
|
-
if (
|
|
352
|
-
|
|
830
|
+
function toResponsesToolChoice(toolChoice) {
|
|
831
|
+
if (toolChoice === "none" || toolChoice === "auto" || toolChoice === "required") return toolChoice;
|
|
832
|
+
if (typeof toolChoice !== "object" || toolChoice === null) return;
|
|
833
|
+
const record = toolChoice;
|
|
834
|
+
const name = typeof record.name === "string" ? record.name : record.function?.name;
|
|
835
|
+
if (typeof name === "string" && name !== "") return {
|
|
836
|
+
type: "function",
|
|
837
|
+
name
|
|
838
|
+
};
|
|
353
839
|
}
|
|
354
840
|
|
|
355
841
|
//#endregion
|
|
356
842
|
//#region ../ai-openai/src/embedder.ts
|
|
357
|
-
const LOG_MODULE$
|
|
843
|
+
const LOG_MODULE$5 = "ai.openai";
|
|
358
844
|
/**
|
|
359
845
|
* OpenAI-backed implementation of `EmbedderContract`.
|
|
360
846
|
*
|
|
@@ -419,7 +905,7 @@ var OpenAIEmbedder = class {
|
|
|
419
905
|
* plus a camelCase usage object for the caller to shape.
|
|
420
906
|
*/
|
|
421
907
|
async request(input) {
|
|
422
|
-
this.logger.debug(LOG_MODULE$
|
|
908
|
+
this.logger.debug(LOG_MODULE$5, "embedder.request", "embeddings.create", {
|
|
423
909
|
model: this.name,
|
|
424
910
|
batch: Array.isArray(input),
|
|
425
911
|
count: Array.isArray(input) ? input.length : 1
|
|
@@ -433,13 +919,13 @@ var OpenAIEmbedder = class {
|
|
|
433
919
|
});
|
|
434
920
|
} catch (thrown) {
|
|
435
921
|
const wrapped = wrapOpenAIError(thrown);
|
|
436
|
-
this.logger.error(LOG_MODULE$
|
|
922
|
+
this.logger.error(LOG_MODULE$5, "embedder.error", wrapped.message, {
|
|
437
923
|
code: wrapped.code,
|
|
438
924
|
context: wrapped.context
|
|
439
925
|
});
|
|
440
926
|
throw wrapped;
|
|
441
927
|
}
|
|
442
|
-
this.logger.debug(LOG_MODULE$
|
|
928
|
+
this.logger.debug(LOG_MODULE$5, "embedder.response", "embeddings.create returned", {
|
|
443
929
|
dimensions: response.data[0]?.embedding.length,
|
|
444
930
|
usage: {
|
|
445
931
|
promptTokens: response.usage.prompt_tokens,
|
|
@@ -463,7 +949,7 @@ var OpenAIEmbedder = class {
|
|
|
463
949
|
|
|
464
950
|
//#endregion
|
|
465
951
|
//#region ../ai-openai/src/image.ts
|
|
466
|
-
const LOG_MODULE$
|
|
952
|
+
const LOG_MODULE$4 = "ai.openai";
|
|
467
953
|
/** Map a neutral output container to its IANA media type. */
|
|
468
954
|
function mediaTypeFor(format) {
|
|
469
955
|
switch (format) {
|
|
@@ -522,7 +1008,7 @@ var OpenAIImageModel = class {
|
|
|
522
1008
|
if (!isGptImage && responseFormat) body.response_format = responseFormat;
|
|
523
1009
|
if (isGptImage && options?.format !== void 0) body.output_format = options.format;
|
|
524
1010
|
if (options?.background !== void 0) body.background = options.background;
|
|
525
|
-
this.logger.debug(LOG_MODULE$
|
|
1011
|
+
this.logger.debug(LOG_MODULE$4, "image.request", "images.generate", {
|
|
526
1012
|
model: this.name,
|
|
527
1013
|
count: options?.count ?? 1
|
|
528
1014
|
});
|
|
@@ -531,7 +1017,7 @@ var OpenAIImageModel = class {
|
|
|
531
1017
|
response = await this.client.images.generate(body, options?.signal ? { signal: options.signal } : void 0);
|
|
532
1018
|
} catch (thrown) {
|
|
533
1019
|
const wrapped = wrapOpenAIError(thrown);
|
|
534
|
-
this.logger.error(LOG_MODULE$
|
|
1020
|
+
this.logger.error(LOG_MODULE$4, "image.error", wrapped.message, {
|
|
535
1021
|
code: wrapped.code,
|
|
536
1022
|
context: wrapped.context
|
|
537
1023
|
});
|
|
@@ -547,7 +1033,7 @@ var OpenAIImageModel = class {
|
|
|
547
1033
|
output: 0,
|
|
548
1034
|
total: 0
|
|
549
1035
|
};
|
|
550
|
-
this.logger.debug(LOG_MODULE$
|
|
1036
|
+
this.logger.debug(LOG_MODULE$4, "image.response", "images.generate succeeded", {
|
|
551
1037
|
images: images.length,
|
|
552
1038
|
usage
|
|
553
1039
|
});
|
|
@@ -599,11 +1085,12 @@ const REASONING_CAPABLE_PREFIXES = [
|
|
|
599
1085
|
"o1",
|
|
600
1086
|
"o3",
|
|
601
1087
|
"o4",
|
|
602
|
-
"gpt-5"
|
|
1088
|
+
"gpt-5",
|
|
1089
|
+
"gpt-6"
|
|
603
1090
|
];
|
|
604
1091
|
/**
|
|
605
1092
|
* Infer whether a given OpenAI model name is a reasoning model (o-series
|
|
606
|
-
* and the gpt-5
|
|
1093
|
+
* and the gpt-5 / gpt-6 families) based on the known-prefix list. Unknown models
|
|
607
1094
|
* default to `false` so the adapter never forwards an unsupported
|
|
608
1095
|
* `reasoning_effort` param to a non-reasoning model (which would 400).
|
|
609
1096
|
*
|
|
@@ -611,6 +1098,7 @@ const REASONING_CAPABLE_PREFIXES = [
|
|
|
611
1098
|
* inferReasoningCapability("o3-mini"); // → true
|
|
612
1099
|
* inferReasoningCapability("o4-mini"); // → true
|
|
613
1100
|
* inferReasoningCapability("gpt-5-pro"); // → true
|
|
1101
|
+
* inferReasoningCapability("gpt-6-astra"); // → true
|
|
614
1102
|
* inferReasoningCapability("gpt-4o"); // → false
|
|
615
1103
|
* inferReasoningCapability("custom-llm"); // → false
|
|
616
1104
|
*/
|
|
@@ -638,8 +1126,11 @@ const VISION_CAPABLE_PREFIXES = [
|
|
|
638
1126
|
"gpt-4o",
|
|
639
1127
|
"gpt-4-turbo",
|
|
640
1128
|
"gpt-4.1",
|
|
1129
|
+
"gpt-5",
|
|
1130
|
+
"gpt-6",
|
|
641
1131
|
"o1",
|
|
642
1132
|
"o3",
|
|
1133
|
+
"o4",
|
|
643
1134
|
"chatgpt-4o"
|
|
644
1135
|
];
|
|
645
1136
|
/**
|
|
@@ -661,20 +1152,7 @@ function inferVisionCapability(modelName) {
|
|
|
661
1152
|
|
|
662
1153
|
//#endregion
|
|
663
1154
|
//#region ../ai-openai/src/model.ts
|
|
664
|
-
const LOG_MODULE$
|
|
665
|
-
/**
|
|
666
|
-
* Map an explicit `responseFormat` override to the default
|
|
667
|
-
* `structuredOutput` capability. Loose wire modes (`"json_object"`,
|
|
668
|
-
* `"text"`) don't enforce shape, so the agent needs to see the soft
|
|
669
|
-
* schema hint in the system prompt — that only happens when the
|
|
670
|
-
* capability is `false`. Default (no override) stays `true` to
|
|
671
|
-
* preserve the prior assumption that OpenAI models support strict
|
|
672
|
-
* structured output.
|
|
673
|
-
*/
|
|
674
|
-
function inferStructuredOutput(responseFormat) {
|
|
675
|
-
if (responseFormat === "json_object" || responseFormat === "text") return false;
|
|
676
|
-
return true;
|
|
677
|
-
}
|
|
1155
|
+
const LOG_MODULE$3 = "ai.openai";
|
|
678
1156
|
/**
|
|
679
1157
|
* OpenAI-backed implementation of `ModelContract`.
|
|
680
1158
|
*
|
|
@@ -715,6 +1193,11 @@ function inferStructuredOutput(responseFormat) {
|
|
|
715
1193
|
var OpenAIModel = class {
|
|
716
1194
|
constructor(client, config, provider = "openai") {
|
|
717
1195
|
this.logger = _warlock_js_logger.log;
|
|
1196
|
+
if (config.api === "responses") throw new _warlock_js_ai.InvalidRequestError("OpenAIModel speaks Chat Completions only and cannot serve api: \"responses\". Use OpenAISDK.model({ api: \"responses\" }) on the direct OpenAI provider.", { context: {
|
|
1197
|
+
provider,
|
|
1198
|
+
model: config.name,
|
|
1199
|
+
api: config.api
|
|
1200
|
+
} });
|
|
718
1201
|
this.client = client;
|
|
719
1202
|
this.config = config;
|
|
720
1203
|
this.name = config.name;
|
|
@@ -724,7 +1207,7 @@ var OpenAIModel = class {
|
|
|
724
1207
|
structuredOutput: config.structuredOutput ?? inferStructuredOutput(config.responseFormat),
|
|
725
1208
|
vision: config.vision ?? inferVisionCapability(config.name),
|
|
726
1209
|
reasoning: config.reasoning ?? inferReasoningCapability(config.name),
|
|
727
|
-
promptCaching:
|
|
1210
|
+
promptCaching: this.provider === "openai",
|
|
728
1211
|
pdf: config.pdf ?? false,
|
|
729
1212
|
audio: config.audio ?? false
|
|
730
1213
|
};
|
|
@@ -736,7 +1219,7 @@ var OpenAIModel = class {
|
|
|
736
1219
|
* instance's `ModelConfig` defaults for this call only.
|
|
737
1220
|
*/
|
|
738
1221
|
async complete(messages, options) {
|
|
739
|
-
this.logger.debug(LOG_MODULE$
|
|
1222
|
+
this.logger.debug(LOG_MODULE$3, "request", "Starting call to chat.completions", {
|
|
740
1223
|
model: this.name,
|
|
741
1224
|
messageCount: messages.length,
|
|
742
1225
|
streaming: false,
|
|
@@ -746,16 +1229,16 @@ var OpenAIModel = class {
|
|
|
746
1229
|
try {
|
|
747
1230
|
response = await this.client.chat.completions.create({
|
|
748
1231
|
model: this.name,
|
|
749
|
-
messages: toOpenAIMessages(messages),
|
|
750
|
-
|
|
751
|
-
|
|
1232
|
+
messages: toOpenAIMessages(messages, this.promptCacheBreakpoints(options)),
|
|
1233
|
+
...this.buildSamplingParams(options),
|
|
1234
|
+
...buildPromptCacheParams(this.provider, this.config),
|
|
752
1235
|
tools: toOpenAITools(options?.tools),
|
|
753
1236
|
...this.buildResponseFormat(options?.responseSchema),
|
|
754
1237
|
...this.buildReasoningParams(options?.reasoning, Boolean(options?.tools?.length))
|
|
755
1238
|
}, options?.signal ? { signal: options.signal } : void 0);
|
|
756
1239
|
} catch (thrown) {
|
|
757
1240
|
const wrapped = wrapOpenAIError(thrown);
|
|
758
|
-
this.logger.error(LOG_MODULE$
|
|
1241
|
+
this.logger.error(LOG_MODULE$3, "error", wrapped.message, {
|
|
759
1242
|
code: wrapped.code,
|
|
760
1243
|
context: wrapped.context
|
|
761
1244
|
});
|
|
@@ -769,7 +1252,7 @@ var OpenAIModel = class {
|
|
|
769
1252
|
} });
|
|
770
1253
|
const finishReason = mapFinishReason(choice.finish_reason);
|
|
771
1254
|
const usage = this.extractUsage(response.usage);
|
|
772
|
-
this.logger.debug(LOG_MODULE$
|
|
1255
|
+
this.logger.debug(LOG_MODULE$3, "response", "call to chat.completions succeeded", {
|
|
773
1256
|
finishReason,
|
|
774
1257
|
usage
|
|
775
1258
|
});
|
|
@@ -787,7 +1270,7 @@ var OpenAIModel = class {
|
|
|
787
1270
|
* Callers consume it with `for await`.
|
|
788
1271
|
*/
|
|
789
1272
|
async *stream(messages, options) {
|
|
790
|
-
this.logger.debug(LOG_MODULE$
|
|
1273
|
+
this.logger.debug(LOG_MODULE$3, "request", "Starting streaming call to chat.completions", {
|
|
791
1274
|
model: this.name,
|
|
792
1275
|
messageCount: messages.length,
|
|
793
1276
|
streaming: true,
|
|
@@ -797,9 +1280,9 @@ var OpenAIModel = class {
|
|
|
797
1280
|
try {
|
|
798
1281
|
stream = await this.client.chat.completions.create({
|
|
799
1282
|
model: this.name,
|
|
800
|
-
messages: toOpenAIMessages(messages),
|
|
801
|
-
|
|
802
|
-
|
|
1283
|
+
messages: toOpenAIMessages(messages, this.promptCacheBreakpoints(options)),
|
|
1284
|
+
...this.buildSamplingParams(options),
|
|
1285
|
+
...buildPromptCacheParams(this.provider, this.config),
|
|
803
1286
|
tools: toOpenAITools(options?.tools),
|
|
804
1287
|
stream: true,
|
|
805
1288
|
stream_options: { include_usage: true },
|
|
@@ -808,7 +1291,7 @@ var OpenAIModel = class {
|
|
|
808
1291
|
}, options?.signal ? { signal: options.signal } : void 0);
|
|
809
1292
|
} catch (thrown) {
|
|
810
1293
|
const wrapped = wrapOpenAIError(thrown);
|
|
811
|
-
this.logger.error(LOG_MODULE$
|
|
1294
|
+
this.logger.error(LOG_MODULE$3, "error", wrapped.message, {
|
|
812
1295
|
code: wrapped.code,
|
|
813
1296
|
context: wrapped.context
|
|
814
1297
|
});
|
|
@@ -863,14 +1346,14 @@ var OpenAIModel = class {
|
|
|
863
1346
|
}
|
|
864
1347
|
} catch (thrown) {
|
|
865
1348
|
const wrapped = wrapOpenAIError(thrown);
|
|
866
|
-
this.logger.error(LOG_MODULE$
|
|
1349
|
+
this.logger.error(LOG_MODULE$3, "error", wrapped.message, {
|
|
867
1350
|
code: wrapped.code,
|
|
868
1351
|
context: wrapped.context
|
|
869
1352
|
});
|
|
870
1353
|
throw wrapped;
|
|
871
1354
|
}
|
|
872
1355
|
const finishReason = mapFinishReason(rawFinishReason);
|
|
873
|
-
this.logger.debug(LOG_MODULE$
|
|
1356
|
+
this.logger.debug(LOG_MODULE$3, "response", "Streaming call to chat.completions succeeded", {
|
|
874
1357
|
finishReason,
|
|
875
1358
|
usage
|
|
876
1359
|
});
|
|
@@ -880,89 +1363,31 @@ var OpenAIModel = class {
|
|
|
880
1363
|
usage
|
|
881
1364
|
};
|
|
882
1365
|
}
|
|
1366
|
+
/** Map the neutral cache hint only for the direct OpenAI provider. */
|
|
1367
|
+
promptCacheBreakpoints(options) {
|
|
1368
|
+
return this.provider === "openai" ? options?.cacheControl?.breakpoints ?? 0 : 0;
|
|
1369
|
+
}
|
|
883
1370
|
/**
|
|
884
1371
|
* Translate the neutral `responseSchema` option into OpenAI's
|
|
885
|
-
* `response_format` parameter.
|
|
886
|
-
*
|
|
887
|
-
*
|
|
888
|
-
* `response_format` at all, `"json_object"` always picks the loose
|
|
889
|
-
* mode, and `"json_schema"` picks strict mode (with the same
|
|
890
|
-
* `isStrictCompatible` safety check — a malformed schema still
|
|
891
|
-
* degrades to `json_object` rather than 400). The override exists
|
|
892
|
-
* because some targets (older OpenAI models, OpenRouter routes,
|
|
893
|
-
* Ollama OpenAI-compat) reject strict `json_schema` outright.
|
|
894
|
-
*
|
|
895
|
-
* When the override is omitted, uses strict `json_schema` mode
|
|
896
|
-
* (token-level enforcement) only when the schema is a proper
|
|
897
|
-
* root-object JSON Schema (`{ type: "object", properties: ... }`).
|
|
898
|
-
* For anything else — malformed extractor output, non-object
|
|
899
|
-
* schemas, or future shapes we haven't tested — falls back to loose
|
|
900
|
-
* `json_object` mode, which guarantees *some* valid JSON without
|
|
901
|
-
* enforcing shape. The agent's soft instruction already embeds the
|
|
902
|
-
* schema text in the system prompt when the model declares no
|
|
903
|
-
* native structured-output capability, so shape validation still
|
|
904
|
-
* runs client-side via the Standard Schema `validate()` call.
|
|
1372
|
+
* `response_format` parameter. The mode decision (override handling,
|
|
1373
|
+
* strict-compatibility downgrade to `json_object`) lives in
|
|
1374
|
+
* `planStructuredOutput`, shared with the Responses adapter.
|
|
905
1375
|
*
|
|
906
1376
|
* Returns an empty spread when no schema was supplied, so the caller
|
|
907
1377
|
* can unconditionally `...buildResponseFormat(...)` into the request.
|
|
908
1378
|
*/
|
|
909
1379
|
buildResponseFormat(responseSchema) {
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
if (override === "text") return {};
|
|
913
|
-
if (override === "json_object") return { response_format: { type: "json_object" } };
|
|
914
|
-
if (this.isStrictCompatible(responseSchema)) return { response_format: {
|
|
1380
|
+
const plan = planStructuredOutput(responseSchema, this.config.responseFormat);
|
|
1381
|
+
if (plan.mode === "json_schema") return { response_format: {
|
|
915
1382
|
type: "json_schema",
|
|
916
1383
|
json_schema: {
|
|
917
1384
|
name: "response",
|
|
918
|
-
schema:
|
|
1385
|
+
schema: plan.schema,
|
|
919
1386
|
strict: true
|
|
920
1387
|
}
|
|
921
1388
|
} };
|
|
922
|
-
return { response_format: { type: "json_object" } };
|
|
923
|
-
|
|
924
|
-
/**
|
|
925
|
-
* OpenAI strict `json_schema` mode requires the root to be a JSON
|
|
926
|
-
* Schema object type (`{ type: "object", properties: ... }`). Anything
|
|
927
|
-
* else (top-level arrays, primitives, unknown shapes) is rejected with
|
|
928
|
-
* a 400 before a token is sampled. We check structurally here so the
|
|
929
|
-
* first call doesn't crash on a malformed extraction — loose
|
|
930
|
-
* `json_object` mode is a safe degradation.
|
|
931
|
-
*/
|
|
932
|
-
isStrictCompatible(schema) {
|
|
933
|
-
return schema.type === "object" && typeof schema.properties === "object" && schema.properties !== null && this.isStrictSafeNode(schema);
|
|
934
|
-
}
|
|
935
|
-
/**
|
|
936
|
-
* Recursively check the one strict-mode rule schemas most often trip on:
|
|
937
|
-
* every object must list ALL of its `properties` in `required` (OpenAI
|
|
938
|
-
* strict has no notion of optional — optional fields must be expressed
|
|
939
|
-
* as nullable, e.g. `type: ["string", "null"]`, and still appear in
|
|
940
|
-
* `required`). A schema that violates this anywhere in the tree is NOT
|
|
941
|
-
* sent in strict `json_schema` mode — it degrades to loose
|
|
942
|
-
* `json_object` so a hand-built or optional-bearing schema can't 400
|
|
943
|
-
* the call ("'required' ... must include every key in properties").
|
|
944
|
-
* Client-side `validate()` still enforces the full shape.
|
|
945
|
-
*/
|
|
946
|
-
isStrictSafeNode(node) {
|
|
947
|
-
if (!node || typeof node !== "object") return true;
|
|
948
|
-
const record = node;
|
|
949
|
-
if (record.type === "object" && record.properties && typeof record.properties === "object") {
|
|
950
|
-
const properties = record.properties;
|
|
951
|
-
const keys = Object.keys(properties);
|
|
952
|
-
const required = Array.isArray(record.required) ? record.required : [];
|
|
953
|
-
if (keys.some((key) => !required.includes(key))) return false;
|
|
954
|
-
for (const key of keys) if (!this.isStrictSafeNode(properties[key])) return false;
|
|
955
|
-
}
|
|
956
|
-
if (record.items !== void 0 && !this.isStrictSafeNode(record.items)) return false;
|
|
957
|
-
for (const branch of [
|
|
958
|
-
"anyOf",
|
|
959
|
-
"allOf",
|
|
960
|
-
"oneOf"
|
|
961
|
-
]) {
|
|
962
|
-
const value = record[branch];
|
|
963
|
-
if (Array.isArray(value) && value.some((sub) => !this.isStrictSafeNode(sub))) return false;
|
|
964
|
-
}
|
|
965
|
-
return true;
|
|
1389
|
+
if (plan.mode === "json_object") return { response_format: { type: "json_object" } };
|
|
1390
|
+
return {};
|
|
966
1391
|
}
|
|
967
1392
|
/**
|
|
968
1393
|
* Normalize OpenAI's `usage` block (which may be absent on some responses
|
|
@@ -984,15 +1409,13 @@ var OpenAIModel = class {
|
|
|
984
1409
|
output: 0,
|
|
985
1410
|
total: 0
|
|
986
1411
|
};
|
|
987
|
-
|
|
988
|
-
const reasoningTokens = raw.completion_tokens_details?.reasoning_tokens;
|
|
989
|
-
return {
|
|
1412
|
+
return buildUsage({
|
|
990
1413
|
input: raw.prompt_tokens,
|
|
991
1414
|
output: raw.completion_tokens,
|
|
992
1415
|
total: raw.total_tokens,
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
};
|
|
1416
|
+
cachedTokens: raw.prompt_tokens_details?.cached_tokens,
|
|
1417
|
+
reasoningTokens: raw.completion_tokens_details?.reasoning_tokens
|
|
1418
|
+
});
|
|
996
1419
|
}
|
|
997
1420
|
/**
|
|
998
1421
|
* Translate the neutral `ModelCallOptions.reasoning` hint into OpenAI's
|
|
@@ -1029,10 +1452,27 @@ var OpenAIModel = class {
|
|
|
1029
1452
|
* Returns an empty spread when nothing applies, so the caller can
|
|
1030
1453
|
* unconditionally `...buildReasoningParams(...)` into the request.
|
|
1031
1454
|
*/
|
|
1455
|
+
/**
|
|
1456
|
+
* Output-length and sampling params, in current Chat Completions shapes only.
|
|
1457
|
+
*
|
|
1458
|
+
* - `max_completion_tokens`, never the legacy `max_tokens`: current
|
|
1459
|
+
* models (the gpt-5 family and later) answer `400 unsupported_parameter`
|
|
1460
|
+
* to `max_tokens`, and `max_completion_tokens` is accepted everywhere.
|
|
1461
|
+
* - No `temperature` for a reasoning-capable model: those accept only the
|
|
1462
|
+
* default value and reject any other with a 400.
|
|
1463
|
+
*/
|
|
1464
|
+
buildSamplingParams(options) {
|
|
1465
|
+
const maxTokens = options?.maxTokens ?? this.config.maxTokens;
|
|
1466
|
+
const temperature = options?.temperature ?? this.config.temperature;
|
|
1467
|
+
return {
|
|
1468
|
+
max_completion_tokens: maxTokens,
|
|
1469
|
+
...!this.capabilities.reasoning && { temperature }
|
|
1470
|
+
};
|
|
1471
|
+
}
|
|
1032
1472
|
buildReasoningParams(reasoning, hasTools) {
|
|
1033
1473
|
if (!this.capabilities.reasoning) return {};
|
|
1034
1474
|
if (!reasoning?.effort) return hasTools ? { reasoning_effort: "none" } : {};
|
|
1035
|
-
return { reasoning_effort: reasoning.effort };
|
|
1475
|
+
return { reasoning_effort: toOpenAIReasoningEffort(reasoning.effort) };
|
|
1036
1476
|
}
|
|
1037
1477
|
/**
|
|
1038
1478
|
* Reshape OpenAI's `tool_calls` array into the neutral
|
|
@@ -1052,6 +1492,318 @@ var OpenAIModel = class {
|
|
|
1052
1492
|
}
|
|
1053
1493
|
};
|
|
1054
1494
|
|
|
1495
|
+
//#endregion
|
|
1496
|
+
//#region ../ai-openai/src/responses-model.ts
|
|
1497
|
+
const LOG_MODULE$2 = "ai.openai";
|
|
1498
|
+
/**
|
|
1499
|
+
* OpenAI Responses API implementation of `ModelContract` (`api: "responses"`).
|
|
1500
|
+
*
|
|
1501
|
+
* **Role.** A sibling of `OpenAIModel` that speaks `POST /v1/responses`
|
|
1502
|
+
* instead of Chat Completions. Its point is letting a tool-using agent run a
|
|
1503
|
+
* reasoning model with reasoning ON: the Chat path has to force
|
|
1504
|
+
* `reasoning_effort: "none"` whenever tools are attached, Responses does not.
|
|
1505
|
+
*
|
|
1506
|
+
* **Why a separate class.** Only `OpenAISDK.model()` constructs it, and only
|
|
1507
|
+
* for the direct `"openai"` provider. The wrapper packages (deepseek, groq,
|
|
1508
|
+
* xai, mistral) build `OpenAIModel` and cannot reach this class, so an
|
|
1509
|
+
* endpoint that does not implement `/v1/responses` is never sent there. The
|
|
1510
|
+
* constructor re-checks the provider label as a second lock.
|
|
1511
|
+
*
|
|
1512
|
+
* **Stateless.** Every request sends `store: false`; nothing is kept on the
|
|
1513
|
+
* server and no `previous_response_id` is used. Full history is re-sent each
|
|
1514
|
+
* call, exactly like the Chat path.
|
|
1515
|
+
*
|
|
1516
|
+
* **Reasoning replay.** Every request asks for
|
|
1517
|
+
* `include: ["reasoning.encrypted_content"]`. The `reasoning` items of a
|
|
1518
|
+
* tool-calling turn are stored on the FIRST tool call as
|
|
1519
|
+
* `providerMetadata.openaiResponses = { model, reasoningItems }` and sent back
|
|
1520
|
+
* before that turn's `function_call` items on the next request (only when the
|
|
1521
|
+
* stored model equals the current one). Text-only turns are not replayed.
|
|
1522
|
+
*
|
|
1523
|
+
* **Streaming.** `stream()` uses `responses.create({ stream: true })` and yields
|
|
1524
|
+
* the same chunk sequence as the Chat path: text `delta`s, then the
|
|
1525
|
+
* `tool-call`s, then one `done`.
|
|
1526
|
+
*/
|
|
1527
|
+
var OpenAIResponsesModel = class {
|
|
1528
|
+
constructor(client, config, provider = "openai") {
|
|
1529
|
+
this.logger = _warlock_js_logger.log;
|
|
1530
|
+
if (provider !== "openai") throw new _warlock_js_ai.InvalidRequestError(`api: "responses" is supported only on the direct "openai" provider; got provider "${provider}".`, { context: {
|
|
1531
|
+
provider,
|
|
1532
|
+
model: config.name,
|
|
1533
|
+
api: "responses"
|
|
1534
|
+
} });
|
|
1535
|
+
this.client = client;
|
|
1536
|
+
this.config = config;
|
|
1537
|
+
this.name = config.name;
|
|
1538
|
+
this.provider = provider;
|
|
1539
|
+
this.pricing = config.pricing;
|
|
1540
|
+
this.capabilities = {
|
|
1541
|
+
structuredOutput: config.structuredOutput ?? inferStructuredOutput(config.responseFormat),
|
|
1542
|
+
vision: config.vision ?? inferVisionCapability(config.name),
|
|
1543
|
+
reasoning: config.reasoning ?? inferReasoningCapability(config.name),
|
|
1544
|
+
promptCaching: true,
|
|
1545
|
+
pdf: false,
|
|
1546
|
+
audio: false
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
/**
|
|
1550
|
+
* Single-shot completion against `client.responses.create`. Sends the full
|
|
1551
|
+
* history as Responses `input` items, walks `response.output`, and reshapes
|
|
1552
|
+
* it into a vendor-neutral `ModelResponse`. Per-call `options` override the
|
|
1553
|
+
* instance defaults for this call only.
|
|
1554
|
+
*/
|
|
1555
|
+
async complete(messages, options) {
|
|
1556
|
+
this.logger.debug(LOG_MODULE$2, "request", "Starting call to responses.create", {
|
|
1557
|
+
model: this.name,
|
|
1558
|
+
messageCount: messages.length,
|
|
1559
|
+
streaming: false,
|
|
1560
|
+
toolCount: options?.tools?.length ?? 0
|
|
1561
|
+
});
|
|
1562
|
+
let response;
|
|
1563
|
+
try {
|
|
1564
|
+
response = await this.client.responses.create(this.buildRequest(messages, options), options?.signal ? { signal: options.signal } : void 0);
|
|
1565
|
+
} catch (thrown) {
|
|
1566
|
+
throw this.logAndWrap(thrown);
|
|
1567
|
+
}
|
|
1568
|
+
if (response.status === "failed") throw this.logAndWrap(toResponseFailure(response, this.name));
|
|
1569
|
+
const output = Array.isArray(response.output) ? response.output : [];
|
|
1570
|
+
const { content, toolCalls } = this.readOutput(output);
|
|
1571
|
+
const finishReason = mapResponsesFinishReason({
|
|
1572
|
+
status: response.status,
|
|
1573
|
+
incomplete_details: response.incomplete_details,
|
|
1574
|
+
hasToolCalls: toolCalls !== void 0
|
|
1575
|
+
});
|
|
1576
|
+
const usage = this.extractUsage(response.usage);
|
|
1577
|
+
this.logger.debug(LOG_MODULE$2, "response", "call to responses.create succeeded", {
|
|
1578
|
+
finishReason,
|
|
1579
|
+
usage
|
|
1580
|
+
});
|
|
1581
|
+
return {
|
|
1582
|
+
content,
|
|
1583
|
+
finishReason,
|
|
1584
|
+
usage,
|
|
1585
|
+
...toolCalls ? { toolCalls } : {}
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
/**
|
|
1589
|
+
* Incremental streaming completion via `responses.create({ stream: true })`
|
|
1590
|
+
* (the SDK's `Stream<ResponseStreamEvent>`, `responses.d.ts:55`). Yields the
|
|
1591
|
+
* same neutral `ModelStreamChunk` sequence as the Chat path:
|
|
1592
|
+
*
|
|
1593
|
+
* - `response.output_text.delta` / `response.refusal.delta` -> `delta`.
|
|
1594
|
+
* - `response.output_item.done` with a completed `function_call` item ->
|
|
1595
|
+
* buffered; `reasoning` items are captured for replay. The `done` copy is
|
|
1596
|
+
* used because the `added` copy may be incomplete (`responses.d.ts:5711`).
|
|
1597
|
+
* - After the loop: the buffered `tool-call`s (the first carries the
|
|
1598
|
+
* replay metadata), then a single `done` with the finish reason and usage
|
|
1599
|
+
* from `response.completed` / `response.incomplete`.
|
|
1600
|
+
* - `response.failed` and `error` events throw a typed `AIError`; a stream
|
|
1601
|
+
* that ends with no terminal event throws `ProviderError`.
|
|
1602
|
+
* - Abort goes through the request `signal`; the SDK rejects the read and
|
|
1603
|
+
* the error is wrapped like any other.
|
|
1604
|
+
*/
|
|
1605
|
+
async *stream(messages, options) {
|
|
1606
|
+
this.logger.debug(LOG_MODULE$2, "request", "Starting streaming call to responses.create", {
|
|
1607
|
+
model: this.name,
|
|
1608
|
+
messageCount: messages.length,
|
|
1609
|
+
streaming: true,
|
|
1610
|
+
toolCount: options?.tools?.length ?? 0
|
|
1611
|
+
});
|
|
1612
|
+
let events;
|
|
1613
|
+
try {
|
|
1614
|
+
events = await this.client.responses.create({
|
|
1615
|
+
...this.buildRequest(messages, options),
|
|
1616
|
+
stream: true
|
|
1617
|
+
}, options?.signal ? { signal: options.signal } : void 0);
|
|
1618
|
+
} catch (thrown) {
|
|
1619
|
+
throw this.logAndWrap(thrown);
|
|
1620
|
+
}
|
|
1621
|
+
const toolCalls = [];
|
|
1622
|
+
const reasoningItems = [];
|
|
1623
|
+
let terminal;
|
|
1624
|
+
try {
|
|
1625
|
+
for await (const event of events) {
|
|
1626
|
+
if (event.type === "response.output_text.delta" || event.type === "response.refusal.delta") {
|
|
1627
|
+
if (event.delta) yield {
|
|
1628
|
+
type: "delta",
|
|
1629
|
+
content: event.delta
|
|
1630
|
+
};
|
|
1631
|
+
continue;
|
|
1632
|
+
}
|
|
1633
|
+
if (event.type === "response.output_item.done") {
|
|
1634
|
+
const { item } = event;
|
|
1635
|
+
if (item.type === "reasoning") reasoningItems.push(item);
|
|
1636
|
+
else if (item.type === "function_call" && item.status !== "incomplete") toolCalls.push(this.toToolCall(item));
|
|
1637
|
+
continue;
|
|
1638
|
+
}
|
|
1639
|
+
if (event.type === "response.completed" || event.type === "response.incomplete") {
|
|
1640
|
+
terminal = event.response;
|
|
1641
|
+
continue;
|
|
1642
|
+
}
|
|
1643
|
+
if (event.type === "response.failed") throw toResponseFailure(event.response, this.name);
|
|
1644
|
+
if (event.type === "error") throw wrapOpenAIError({
|
|
1645
|
+
code: event.code,
|
|
1646
|
+
message: event.message,
|
|
1647
|
+
param: event.param
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
if (terminal === void 0) throw new _warlock_js_ai.ProviderError("OpenAI Responses stream ended before response.completed.", { context: {
|
|
1651
|
+
provider: this.provider,
|
|
1652
|
+
model: this.name
|
|
1653
|
+
} });
|
|
1654
|
+
} catch (thrown) {
|
|
1655
|
+
throw this.logAndWrap(thrown);
|
|
1656
|
+
}
|
|
1657
|
+
for (const call of attachReasoningReplay(toolCalls, this.name, reasoningItems)) yield {
|
|
1658
|
+
type: "tool-call",
|
|
1659
|
+
id: call.id,
|
|
1660
|
+
name: call.name,
|
|
1661
|
+
input: call.input,
|
|
1662
|
+
...call.providerMetadata ? { providerMetadata: call.providerMetadata } : {}
|
|
1663
|
+
};
|
|
1664
|
+
const finishReason = mapResponsesFinishReason({
|
|
1665
|
+
status: terminal.status,
|
|
1666
|
+
incomplete_details: terminal.incomplete_details,
|
|
1667
|
+
hasToolCalls: toolCalls.length > 0
|
|
1668
|
+
});
|
|
1669
|
+
const usage = this.extractUsage(terminal.usage);
|
|
1670
|
+
this.logger.debug(LOG_MODULE$2, "response", "Streaming call to responses.create succeeded", {
|
|
1671
|
+
finishReason,
|
|
1672
|
+
usage
|
|
1673
|
+
});
|
|
1674
|
+
yield {
|
|
1675
|
+
type: "done",
|
|
1676
|
+
finishReason,
|
|
1677
|
+
usage
|
|
1678
|
+
};
|
|
1679
|
+
}
|
|
1680
|
+
/**
|
|
1681
|
+
* Assemble the `responses.create` body.
|
|
1682
|
+
*
|
|
1683
|
+
* - System prompt -> `instructions`; history -> `input` items.
|
|
1684
|
+
* - `store: false` always (the API default is `true` when omitted), plus
|
|
1685
|
+
* `include: ["reasoning.encrypted_content"]` for stateless reasoning replay.
|
|
1686
|
+
* - `max_output_tokens` caps reasoning and visible output together.
|
|
1687
|
+
* - `temperature` only for non-reasoning models (same rule as Chat).
|
|
1688
|
+
* - `tools` flat with `strict: false`; `tool_choice` only with tools.
|
|
1689
|
+
* - `reasoning.effort` only when the caller gave one, through the same
|
|
1690
|
+
* clamp table as Chat. With no explicit effort the provider default
|
|
1691
|
+
* applies EVEN with tools attached - that is the point of this API.
|
|
1692
|
+
*/
|
|
1693
|
+
buildRequest(messages, options) {
|
|
1694
|
+
const { instructions, input } = toResponsesInput(messages, this.name);
|
|
1695
|
+
const tools = toResponsesTools(options?.tools);
|
|
1696
|
+
const toolChoice = tools ? toResponsesToolChoice(options?.toolChoice) : void 0;
|
|
1697
|
+
const maxTokens = options?.maxTokens ?? this.config.maxTokens;
|
|
1698
|
+
const temperature = options?.temperature ?? this.config.temperature;
|
|
1699
|
+
return {
|
|
1700
|
+
model: this.name,
|
|
1701
|
+
...instructions !== void 0 ? { instructions } : {},
|
|
1702
|
+
input,
|
|
1703
|
+
store: false,
|
|
1704
|
+
include: ["reasoning.encrypted_content"],
|
|
1705
|
+
...maxTokens !== void 0 ? { max_output_tokens: maxTokens } : {},
|
|
1706
|
+
...!this.capabilities.reasoning && temperature !== void 0 ? { temperature } : {},
|
|
1707
|
+
...tools ? { tools } : {},
|
|
1708
|
+
...toolChoice !== void 0 ? { tool_choice: toolChoice } : {},
|
|
1709
|
+
...this.buildTextFormat(options?.responseSchema),
|
|
1710
|
+
...this.buildReasoningParams(options?.reasoning),
|
|
1711
|
+
...buildPromptCacheParams(this.provider, this.config)
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
/**
|
|
1715
|
+
* Structured output on Responses lives in `text.format`, and `name` /
|
|
1716
|
+
* `schema` / `strict` sit directly on the format object (Chat nests them
|
|
1717
|
+
* under `json_schema`). The strict-or-downgrade decision is the shared
|
|
1718
|
+
* `planStructuredOutput`.
|
|
1719
|
+
*/
|
|
1720
|
+
buildTextFormat(responseSchema) {
|
|
1721
|
+
const plan = planStructuredOutput(responseSchema, this.config.responseFormat);
|
|
1722
|
+
if (plan.mode === "json_schema") return { text: { format: {
|
|
1723
|
+
type: "json_schema",
|
|
1724
|
+
name: "response",
|
|
1725
|
+
schema: plan.schema,
|
|
1726
|
+
strict: true
|
|
1727
|
+
} } };
|
|
1728
|
+
if (plan.mode === "json_object") return { text: { format: { type: "json_object" } } };
|
|
1729
|
+
return {};
|
|
1730
|
+
}
|
|
1731
|
+
/** `reasoning.effort` for reasoning-capable models, only when the caller set one. */
|
|
1732
|
+
buildReasoningParams(reasoning) {
|
|
1733
|
+
if (!this.capabilities.reasoning || !reasoning?.effort) return {};
|
|
1734
|
+
return { reasoning: { effort: toOpenAIReasoningEffort(reasoning.effort) } };
|
|
1735
|
+
}
|
|
1736
|
+
/**
|
|
1737
|
+
* Walk `response.output`: `message` items contribute their `output_text`
|
|
1738
|
+
* text and any `refusal` text (surfaced as ordinary content, decision 5);
|
|
1739
|
+
* each completed `function_call` item becomes one tool call with
|
|
1740
|
+
* `id = call_id`. `reasoning` items carry no visible content and are
|
|
1741
|
+
* not content. A `function_call` the API marked `incomplete` (truncated
|
|
1742
|
+
* arguments) is skipped rather than dispatched with empty input.
|
|
1743
|
+
* `reasoning` items are captured and attached to the first tool call for
|
|
1744
|
+
* replay (see `attachReasoningReplay`).
|
|
1745
|
+
*/
|
|
1746
|
+
readOutput(output) {
|
|
1747
|
+
let content = "";
|
|
1748
|
+
const toolCalls = [];
|
|
1749
|
+
const reasoningItems = [];
|
|
1750
|
+
for (const item of output) {
|
|
1751
|
+
if (item.type === "reasoning") {
|
|
1752
|
+
reasoningItems.push(item);
|
|
1753
|
+
continue;
|
|
1754
|
+
}
|
|
1755
|
+
if (item.type === "message") {
|
|
1756
|
+
for (const part of item.content) content += part.type === "refusal" ? part.refusal : part.text;
|
|
1757
|
+
continue;
|
|
1758
|
+
}
|
|
1759
|
+
if (item.type === "function_call" && item.status !== "incomplete") toolCalls.push(this.toToolCall(item));
|
|
1760
|
+
}
|
|
1761
|
+
return {
|
|
1762
|
+
content,
|
|
1763
|
+
toolCalls: toolCalls.length > 0 ? attachReasoningReplay(toolCalls, this.name, reasoningItems) : void 0
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
/** One completed `function_call` item as a neutral tool call (`call_id` -> `id`). */
|
|
1767
|
+
toToolCall(item) {
|
|
1768
|
+
return {
|
|
1769
|
+
id: item.call_id,
|
|
1770
|
+
name: item.name,
|
|
1771
|
+
input: (0, _warlock_js_ai.safeJsonParse)(item.arguments, {})
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* Normalize `usage` (absent on some partials) into the neutral `Usage`.
|
|
1776
|
+
* `input_tokens_details.cached_tokens` -> `cachedTokens`,
|
|
1777
|
+
* `cache_write_tokens` -> `cacheWriteTokens`,
|
|
1778
|
+
* `output_tokens_details.reasoning_tokens` -> `reasoningTokens`. The
|
|
1779
|
+
* detail blocks are typed non-optional but read defensively.
|
|
1780
|
+
*/
|
|
1781
|
+
extractUsage(raw) {
|
|
1782
|
+
if (!raw) return {
|
|
1783
|
+
input: 0,
|
|
1784
|
+
output: 0,
|
|
1785
|
+
total: 0
|
|
1786
|
+
};
|
|
1787
|
+
return buildUsage({
|
|
1788
|
+
input: raw.input_tokens,
|
|
1789
|
+
output: raw.output_tokens,
|
|
1790
|
+
total: raw.total_tokens,
|
|
1791
|
+
cachedTokens: raw.input_tokens_details?.cached_tokens,
|
|
1792
|
+
cacheWriteTokens: raw.input_tokens_details?.cache_write_tokens,
|
|
1793
|
+
reasoningTokens: raw.output_tokens_details?.reasoning_tokens
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
/** Wrap (idempotently), log and return a failure for the caller to throw. */
|
|
1797
|
+
logAndWrap(thrown) {
|
|
1798
|
+
const wrapped = wrapOpenAIError(thrown);
|
|
1799
|
+
this.logger.error(LOG_MODULE$2, "error", wrapped.message, {
|
|
1800
|
+
code: wrapped.code,
|
|
1801
|
+
context: wrapped.context
|
|
1802
|
+
});
|
|
1803
|
+
return wrapped;
|
|
1804
|
+
}
|
|
1805
|
+
};
|
|
1806
|
+
|
|
1055
1807
|
//#endregion
|
|
1056
1808
|
//#region ../ai-openai/src/speech.ts
|
|
1057
1809
|
const LOG_MODULE$1 = "ai.openai";
|
|
@@ -1264,13 +2016,32 @@ var OpenAISDK = class {
|
|
|
1264
2016
|
* Pricing resolution: per-model `config.pricing` wins; otherwise the
|
|
1265
2017
|
* SDK-level registry entry keyed by `config.name`; otherwise
|
|
1266
2018
|
* `undefined` (no cost computed).
|
|
2019
|
+
*
|
|
2020
|
+
* `config.api` selects the wire API. Omitted or `"chat"` builds the Chat
|
|
2021
|
+
* Completions `OpenAIModel`. `"responses"` builds `OpenAIResponsesModel`
|
|
2022
|
+
* and is accepted ONLY when this SDK's provider label is `"openai"`: any
|
|
2023
|
+
* other label (the wrapper packages deepseek / groq / xai / mistral, or a
|
|
2024
|
+
* custom gateway label) throws, so an endpoint not known to implement
|
|
2025
|
+
* `/v1/responses` is never sent there.
|
|
1267
2026
|
*/
|
|
1268
2027
|
model(config) {
|
|
2028
|
+
const api = config.api;
|
|
2029
|
+
if (api !== void 0 && api !== "chat" && api !== "responses") throw new _warlock_js_ai.InvalidRequestError(`Unknown OpenAI api "${String(api)}"; expected "chat" or "responses".`, { context: {
|
|
2030
|
+
provider: this.provider,
|
|
2031
|
+
model: config.name,
|
|
2032
|
+
api
|
|
2033
|
+
} });
|
|
2034
|
+
if (api === "responses" && this.provider !== "openai") throw new _warlock_js_ai.InvalidRequestError(`api: "responses" is supported only on the direct "openai" provider; this SDK is labeled "${this.provider}". Use the default api: "chat".`, { context: {
|
|
2035
|
+
provider: this.provider,
|
|
2036
|
+
model: config.name,
|
|
2037
|
+
api
|
|
2038
|
+
} });
|
|
1269
2039
|
const resolvedPricing = config.pricing ?? this.pricing?.[config.name];
|
|
1270
2040
|
const resolvedConfig = resolvedPricing === config.pricing ? config : {
|
|
1271
2041
|
...config,
|
|
1272
2042
|
pricing: resolvedPricing
|
|
1273
2043
|
};
|
|
2044
|
+
if (api === "responses") return new OpenAIResponsesModel(this.client, resolvedConfig, this.provider);
|
|
1274
2045
|
return new OpenAIModel(this.client, resolvedConfig, this.provider);
|
|
1275
2046
|
}
|
|
1276
2047
|
/**
|