@oh-my-pi/pi-ai 15.10.12 → 15.11.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 +13 -0
- package/dist/types/types.d.ts +6 -0
- package/package.json +3 -3
- package/src/providers/openai-completions.ts +36 -9
- package/src/providers/openai-responses-shared.ts +2 -2
- package/src/providers/transform-messages.ts +80 -7
- package/src/types.ts +6 -0
- package/src/utils/schema/CONSTRAINTS.md +4 -2
- package/src/utils/schema/normalize.ts +39 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.11.0] - 2026-06-10
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added optional `ImageContent.detail` (`"auto" | "low" | "high" | "original"`): an OpenAI resolution hint forwarded by the `openai-responses` serializers (default stays `auto`) and by `openai-completions` for the values Chat Completions supports. `"original"` preserves native resolution — required for snapcompact frames, whose pixel-font glyphs do not survive the default downscale. Providers without a detail knob ignore the field.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed OpenRouter DeepSeek V4 strict tool schemas nesting `anyOf` inside the nullable wrapper for optional unions, which produced a branch without `type` and triggered OpenRouter's `Invalid tool parameters schema : field anyOf: missing field type` 400. ([#2270](https://github.com/can1357/oh-my-pi/issues/2270))
|
|
14
|
+
- Hardened strict tool-schema handling beyond the optional-union case: `enforceStrictSchema` now splices natively nested pure unions into the parent `anyOf` (only when the inner node carries no constraining siblings, since sibling keywords are conjunctive with `anyOf`), so source schemas with nested unions no longer produce type-less `anyOf` branches that strict upstream validators reject. ([#2270](https://github.com/can1357/oh-my-pi/issues/2270))
|
|
15
|
+
- Made the openai-completions non-strict retry reachable for `"mixed"` strict mode (previously gated to `all_strict`, i.e. Cerebras only) and taught it to recognize upstream tool-schema validation 400s (`Invalid tool parameters schema …`, `Invalid schema for function …`). A matching rejection now retries the request with base (non-strict) schemas and persists `strictToolsDisabled` on the provider session, so later requests skip the doomed strict attempt instead of paying a 400 + retry round-trip each turn. ([#2270](https://github.com/can1357/oh-my-pi/issues/2270))
|
|
16
|
+
- Cross-model `anthropic-messages → anthropic-messages` continuations now preserve prior assistant turns' reasoning chains end-to-end: every prior `thinking`/`redactedThinking` block survives (not just the latest surviving assistant), and third-party ↔ third-party replays keep their signatures intact so the reasoning chain stays signed for the next turn. Signatures are stripped (and any `redacted_thinking` sibling without a native landing spot is dropped) only when an official Anthropic endpoint is on either end of the replay — official Anthropic cryptographically binds reasoning signatures to its key+session+model, while compatible reasoning endpoints (Z.AI, DeepSeek, custom anthropic-messages providers configured via `models.yaml`) treat them as opaque continuation hints. Source-side official detection uses the canonical catalog provider id `"anthropic"` (assistant messages carry no `baseUrl`); target-side detection reuses the baked `compat.officialEndpoint` flag. Latest-turn byte-for-byte behavior (Anthropic's "thinking blocks in the latest assistant message cannot be modified" rule) and existing aborted/errored last-block sanitization are unchanged. ([#2257](https://github.com/can1357/oh-my-pi/issues/2257), [#2265](https://github.com/can1357/oh-my-pi/issues/2265))
|
|
17
|
+
|
|
5
18
|
## [15.10.12] - 2026-06-10
|
|
6
19
|
|
|
7
20
|
### Added
|
package/dist/types/types.d.ts
CHANGED
|
@@ -328,6 +328,12 @@ export interface ImageContent {
|
|
|
328
328
|
type: "image";
|
|
329
329
|
data: string;
|
|
330
330
|
mimeType: string;
|
|
331
|
+
/**
|
|
332
|
+
* OpenAI-only resolution hint. `"original"` preserves native resolution
|
|
333
|
+
* (required for snapcompact frames, whose glyphs do not survive the
|
|
334
|
+
* default `auto` downscale). Providers without a detail knob ignore it.
|
|
335
|
+
*/
|
|
336
|
+
detail?: "auto" | "low" | "high" | "original";
|
|
331
337
|
}
|
|
332
338
|
export interface ToolCall {
|
|
333
339
|
type: "toolCall";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "15.
|
|
4
|
+
"version": "15.11.0",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.0",
|
|
41
|
-
"@oh-my-pi/pi-catalog": "15.
|
|
42
|
-
"@oh-my-pi/pi-utils": "15.
|
|
41
|
+
"@oh-my-pi/pi-catalog": "15.11.0",
|
|
42
|
+
"@oh-my-pi/pi-utils": "15.11.0",
|
|
43
43
|
"openai": "^6.39.0",
|
|
44
44
|
"partial-json": "^0.1.7",
|
|
45
45
|
"zod": "4.4.3"
|
|
@@ -278,6 +278,8 @@ type ToolStrictModeOverride = Exclude<ResolvedOpenAICompat["toolStrictMode"], "m
|
|
|
278
278
|
type BuiltOpenAICompletionTools = {
|
|
279
279
|
tools: OpenAI.Chat.Completions.ChatCompletionTool[];
|
|
280
280
|
toolStrictMode: AppliedToolStrictMode;
|
|
281
|
+
/** True when at least one wire tool was sent with `strict: true`. */
|
|
282
|
+
strictToolsApplied: boolean;
|
|
281
283
|
};
|
|
282
284
|
|
|
283
285
|
const OPENAI_COMPLETIONS_PROVIDER_SESSION_STATE_PREFIX = "openai-completions:";
|
|
@@ -447,7 +449,7 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
447
449
|
} = await createClient(model, context, apiKey, options?.headers, options?.initiatorOverride, options?.fetch);
|
|
448
450
|
const premiumRequestsTotal = copilotPremiumRequests;
|
|
449
451
|
getCapturedErrorResponse = captureErrorResponse;
|
|
450
|
-
let
|
|
452
|
+
let appliedStrictTools = false;
|
|
451
453
|
const providerSessionState = getOpenAICompletionsProviderSessionState(
|
|
452
454
|
model,
|
|
453
455
|
baseUrl,
|
|
@@ -458,8 +460,13 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
458
460
|
const createCompletionsStream = async (toolStrictModeOverride?: ToolStrictModeOverride) => {
|
|
459
461
|
clearCapturedErrorResponse();
|
|
460
462
|
const effectiveToolStrictModeOverride = disableStrictTools ? "none" : toolStrictModeOverride;
|
|
461
|
-
const { params,
|
|
462
|
-
|
|
463
|
+
const { params, strictToolsApplied } = buildParams(
|
|
464
|
+
model,
|
|
465
|
+
context,
|
|
466
|
+
options,
|
|
467
|
+
effectiveToolStrictModeOverride,
|
|
468
|
+
);
|
|
469
|
+
appliedStrictTools = strictToolsApplied;
|
|
463
470
|
options?.onPayload?.(params);
|
|
464
471
|
rawRequestDump = {
|
|
465
472
|
provider: model.provider,
|
|
@@ -517,9 +524,15 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
517
524
|
disableStrictTools = true;
|
|
518
525
|
openaiStream = await createCompletionsStream("none");
|
|
519
526
|
} else {
|
|
520
|
-
if (!shouldRetryWithoutStrictTools(error, capturedErrorResponse,
|
|
527
|
+
if (!shouldRetryWithoutStrictTools(error, capturedErrorResponse, appliedStrictTools, context.tools)) {
|
|
521
528
|
throw error;
|
|
522
529
|
}
|
|
530
|
+
// Remember the rejection for the rest of the session so every
|
|
531
|
+
// subsequent request doesn't pay a strict-400 + retry round-trip.
|
|
532
|
+
if (providerSessionState) {
|
|
533
|
+
providerSessionState.strictToolsDisabled = true;
|
|
534
|
+
}
|
|
535
|
+
disableStrictTools = true;
|
|
523
536
|
openaiStream = await createCompletionsStream("none");
|
|
524
537
|
}
|
|
525
538
|
}
|
|
@@ -1174,7 +1187,7 @@ function buildParams(
|
|
|
1174
1187
|
context: Context,
|
|
1175
1188
|
options: OpenAICompletionsOptions | undefined,
|
|
1176
1189
|
toolStrictModeOverride?: ToolStrictModeOverride,
|
|
1177
|
-
): { params: OpenAICompletionsParams; toolStrictMode: AppliedToolStrictMode } {
|
|
1190
|
+
): { params: OpenAICompletionsParams; toolStrictMode: AppliedToolStrictMode; strictToolsApplied: boolean } {
|
|
1178
1191
|
let compat = model.compat;
|
|
1179
1192
|
const thinkingEnabledForRequest =
|
|
1180
1193
|
Boolean(options?.reasoning) && !options?.disableReasoning && Boolean(model.reasoning);
|
|
@@ -1211,6 +1224,7 @@ function buildParams(
|
|
|
1211
1224
|
stream: true,
|
|
1212
1225
|
};
|
|
1213
1226
|
let toolStrictMode: AppliedToolStrictMode = "none";
|
|
1227
|
+
let strictToolsApplied = false;
|
|
1214
1228
|
|
|
1215
1229
|
if (compat.supportsUsageInStreaming !== false) {
|
|
1216
1230
|
params.stream_options = { include_usage: true };
|
|
@@ -1264,6 +1278,7 @@ function buildParams(
|
|
|
1264
1278
|
const builtTools = convertTools(context.tools, compat, toolStrictModeOverride);
|
|
1265
1279
|
params.tools = builtTools.tools;
|
|
1266
1280
|
toolStrictMode = builtTools.toolStrictMode;
|
|
1281
|
+
strictToolsApplied = builtTools.strictToolsApplied;
|
|
1267
1282
|
} else if (context.tools === undefined && hasToolHistory(context.messages)) {
|
|
1268
1283
|
// Anthropic (via LiteLLM/proxy) requires the `tools` param when the conversation
|
|
1269
1284
|
// contains tool_calls/tool_results, even when no tools are offered this turn.
|
|
@@ -1391,7 +1406,7 @@ function buildParams(
|
|
|
1391
1406
|
}
|
|
1392
1407
|
}
|
|
1393
1408
|
|
|
1394
|
-
return { params, toolStrictMode };
|
|
1409
|
+
return { params, toolStrictMode, strictToolsApplied };
|
|
1395
1410
|
}
|
|
1396
1411
|
|
|
1397
1412
|
function getOptionalNumberProperty(value: object, key: string): number | undefined {
|
|
@@ -1651,6 +1666,8 @@ export function convertMessages(
|
|
|
1651
1666
|
type: "image_url",
|
|
1652
1667
|
image_url: {
|
|
1653
1668
|
url: `data:${item.mimeType};base64,${item.data}`,
|
|
1669
|
+
// Chat Completions has no "original"; omit it (provider default).
|
|
1670
|
+
...(item.detail && item.detail !== "original" ? { detail: item.detail } : {}),
|
|
1654
1671
|
},
|
|
1655
1672
|
} satisfies ChatCompletionContentPartImage);
|
|
1656
1673
|
} else {
|
|
@@ -1999,16 +2016,19 @@ function convertTools(
|
|
|
1999
2016
|
};
|
|
2000
2017
|
}),
|
|
2001
2018
|
toolStrictMode,
|
|
2019
|
+
strictToolsApplied:
|
|
2020
|
+
tools.length > 0 &&
|
|
2021
|
+
(toolStrictMode === "all_strict" || (toolStrictMode === "mixed" && adaptedTools.some(tool => tool.strict))),
|
|
2002
2022
|
};
|
|
2003
2023
|
}
|
|
2004
2024
|
|
|
2005
2025
|
function shouldRetryWithoutStrictTools(
|
|
2006
2026
|
error: unknown,
|
|
2007
2027
|
capturedErrorResponse: CapturedHttpErrorResponse | undefined,
|
|
2008
|
-
|
|
2028
|
+
strictToolsApplied: boolean,
|
|
2009
2029
|
tools: Tool[] | undefined,
|
|
2010
2030
|
): boolean {
|
|
2011
|
-
if (!tools || tools.length === 0 ||
|
|
2031
|
+
if (!tools || tools.length === 0 || !strictToolsApplied) {
|
|
2012
2032
|
return false;
|
|
2013
2033
|
}
|
|
2014
2034
|
const status = extractHttpStatusFromError(error) ?? capturedErrorResponse?.status;
|
|
@@ -2018,7 +2038,14 @@ function shouldRetryWithoutStrictTools(
|
|
|
2018
2038
|
const messageParts = [error instanceof Error ? error.message : undefined, capturedErrorResponse?.bodyText]
|
|
2019
2039
|
.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
|
2020
2040
|
.join("\n");
|
|
2021
|
-
|
|
2041
|
+
// Last two alternatives catch upstream tool-schema validators rejecting our
|
|
2042
|
+
// strictified schemas outright (e.g. OpenRouter DeepSeek's "Invalid tool
|
|
2043
|
+
// parameters schema : field `anyOf`: missing field `type`", #2270, and
|
|
2044
|
+
// OpenAI's own "Invalid schema for function 'x'"). Retrying non-strict sends
|
|
2045
|
+
// the unmodified base schemas, which those validators accept.
|
|
2046
|
+
return /wrong_api_format|mixed values for 'strict'|tool[s]?\b.*strict|\bstrict\b.*tool|tool parameters? schema|invalid schema for function/i.test(
|
|
2047
|
+
messageParts,
|
|
2048
|
+
);
|
|
2022
2049
|
}
|
|
2023
2050
|
|
|
2024
2051
|
function mapStopReason(reason: ChatCompletionChunk.Choice["finish_reason"] | string): {
|
|
@@ -289,7 +289,7 @@ export function convertResponsesInputContent(
|
|
|
289
289
|
for (const item of imageBlocks) {
|
|
290
290
|
normalizedContent.push({
|
|
291
291
|
type: "input_image",
|
|
292
|
-
detail: "auto",
|
|
292
|
+
detail: item.detail ?? "auto",
|
|
293
293
|
image_url: `data:${item.mimeType};base64,${item.data}`,
|
|
294
294
|
} satisfies ResponseInputImage);
|
|
295
295
|
}
|
|
@@ -448,7 +448,7 @@ export function appendResponsesToolResultMessages<TApi extends Api>(
|
|
|
448
448
|
if (block.type === "image") {
|
|
449
449
|
contentParts.push({
|
|
450
450
|
type: "input_image",
|
|
451
|
-
detail: "auto",
|
|
451
|
+
detail: block.detail ?? "auto",
|
|
452
452
|
image_url: `data:${block.mimeType};base64,${block.data}`,
|
|
453
453
|
} satisfies ResponseInputImage);
|
|
454
454
|
}
|
|
@@ -139,6 +139,10 @@ function getLatestSurvivingAssistantIndex(messages: readonly Message[]): number
|
|
|
139
139
|
return -1;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
function isAnthropicMessagesModel(model: Model): model is Model<"anthropic-messages"> {
|
|
143
|
+
return model.api === "anthropic-messages";
|
|
144
|
+
}
|
|
145
|
+
|
|
142
146
|
/**
|
|
143
147
|
* Normalize tool call ID for cross-provider compatibility.
|
|
144
148
|
* OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`.
|
|
@@ -184,10 +188,45 @@ export function transformMessages<TApi extends Api>(
|
|
|
184
188
|
assistantMsg.api === model.api &&
|
|
185
189
|
assistantMsg.model === model.id;
|
|
186
190
|
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
+
const isAnthropicTarget = isAnthropicMessagesModel(model);
|
|
192
|
+
// Anthropic's all-or-none contract on prior-turn thinking blocks
|
|
193
|
+
// applies to every `anthropic-messages → anthropic-messages` replay,
|
|
194
|
+
// not just the latest assistant turn. The legacy
|
|
195
|
+
// `mustPreserveLatestAnthropicThinking` flag only honored it for the
|
|
196
|
+
// latest turn; every prior turn fell through to the cross-API
|
|
197
|
+
// text-demotion path whenever the conversation crossed a model id,
|
|
198
|
+
// silently dropping the reasoning chain on continuation for custom
|
|
199
|
+
// anthropic-messages providers configured via `models.yaml` and
|
|
200
|
+
// session-level model swaps (#2257).
|
|
201
|
+
const isAnthropicReplay = isAnthropicTarget && assistantMsg.api === "anthropic-messages";
|
|
202
|
+
const isLatestSurvivingAssistant = index === latestSurvivingAssistantIndex;
|
|
203
|
+
// Signature policy is a second axis. Anthropic cryptographically
|
|
204
|
+
// binds reasoning signatures to its key+session+model, so cross-model
|
|
205
|
+
// signatures must be stripped whenever official Anthropic is on
|
|
206
|
+
// either end of the replay:
|
|
207
|
+
// * official → 3p: the 3p target can't reverify the signature;
|
|
208
|
+
// keeping it leaks private continuation metadata for no benefit.
|
|
209
|
+
// * 3p → official: official rejects a foreign signature outright.
|
|
210
|
+
// * official → official cross-model: the new model rejects the
|
|
211
|
+
// previous model's signature.
|
|
212
|
+
// 3p ↔ 3p replays preserve signatures because compatible providers
|
|
213
|
+
// (Z.AI, DeepSeek, custom `models.yaml` providers) treat them as
|
|
214
|
+
// opaque continuation hints rather than verified material; stripping
|
|
215
|
+
// degrades the reasoning chain into unsigned/text on the next turn
|
|
216
|
+
// (#2265). Source-side official detection uses the canonical catalog
|
|
217
|
+
// provider id `"anthropic"` because assistant messages carry no
|
|
218
|
+
// `baseUrl` — a user who manually points `provider: "anthropic"` at
|
|
219
|
+
// a custom proxy via `models.yaml` will see signatures stripped, the
|
|
220
|
+
// conservative direction (degraded reasoning, not broken requests).
|
|
221
|
+
const isOfficialAnthropicSource = isAnthropicReplay && assistantMsg.provider === "anthropic";
|
|
222
|
+
const isOfficialAnthropicTarget = isAnthropicTarget && model.compat.officialEndpoint;
|
|
223
|
+
const officialAnthropicInvolved = isOfficialAnthropicSource || isOfficialAnthropicTarget;
|
|
224
|
+
// Compatible Anthropic-messages reasoning targets that accept
|
|
225
|
+
// unsigned thinking natively (Z.AI, DeepSeek, the generic
|
|
226
|
+
// `reasoning && !official` case in the compat builder). Used to keep
|
|
227
|
+
// `redacted_thinking` siblings beside unsigned visible thinking on
|
|
228
|
+
// targets that won't text-demote it.
|
|
229
|
+
const replaysUnsignedAnthropicThinking = isAnthropicTarget && model.compat.replayUnsignedThinking;
|
|
191
230
|
// Thinking signatures can be untrustworthy for two distinct reasons with very
|
|
192
231
|
// different blast radii:
|
|
193
232
|
//
|
|
@@ -226,11 +265,37 @@ export function transformMessages<TApi extends Api>(
|
|
|
226
265
|
// untrustworthy signature so the encoder can downgrade the block to text.
|
|
227
266
|
const signatureUntrustworthy =
|
|
228
267
|
abandonedToolUse || (invalidStopReason && blockIndex === lastBlockIndex);
|
|
229
|
-
|
|
268
|
+
let sanitized: typeof block =
|
|
230
269
|
signatureUntrustworthy && block.thinkingSignature
|
|
231
270
|
? { ...block, thinkingSignature: undefined }
|
|
232
271
|
: block;
|
|
233
|
-
if (
|
|
272
|
+
if (isAnthropicReplay) {
|
|
273
|
+
// Latest abandoned turn: Anthropic's byte-for-byte rule forbids
|
|
274
|
+
// even stripping a signature on the latest message.
|
|
275
|
+
if (isLatestSurvivingAssistant && abandonedToolUse) return block;
|
|
276
|
+
// Cross-model prior turns crossing an official Anthropic endpoint
|
|
277
|
+
// must strip the source signature so the downstream encoder
|
|
278
|
+
// applies its `replayUnsignedThinking` policy (unsigned thinking
|
|
279
|
+
// is emitted natively on Anthropic-compatible reasoning endpoints
|
|
280
|
+
// and demoted to text on official Anthropic). 3p ↔ 3p replays
|
|
281
|
+
// keep the signature so the reasoning chain stays signed on
|
|
282
|
+
// continuation (#2265).
|
|
283
|
+
if (
|
|
284
|
+
!isLatestSurvivingAssistant &&
|
|
285
|
+
!isSameModel &&
|
|
286
|
+
officialAnthropicInvolved &&
|
|
287
|
+
sanitized.thinkingSignature
|
|
288
|
+
) {
|
|
289
|
+
sanitized = { ...sanitized, thinkingSignature: undefined };
|
|
290
|
+
}
|
|
291
|
+
// Drop blocks with neither a signature anchor nor any text —
|
|
292
|
+
// nothing for the next turn to replay.
|
|
293
|
+
if (!sanitized.thinkingSignature && (!sanitized.thinking || sanitized.thinking.trim() === "")) {
|
|
294
|
+
return [];
|
|
295
|
+
}
|
|
296
|
+
return sanitized;
|
|
297
|
+
}
|
|
298
|
+
// Cross-API target: keep the existing text-demotion fallback.
|
|
234
299
|
// For same model: keep thinking blocks with signatures (needed for replay)
|
|
235
300
|
// even if the thinking text is empty (OpenAI encrypted reasoning)
|
|
236
301
|
if (isSameModel && sanitized.thinkingSignature) return sanitized;
|
|
@@ -244,7 +309,15 @@ export function transformMessages<TApi extends Api>(
|
|
|
244
309
|
}
|
|
245
310
|
|
|
246
311
|
if (block.type === "redactedThinking") {
|
|
247
|
-
|
|
312
|
+
// Redacted thinking is native-only. Keep it for same-model
|
|
313
|
+
// signed replay, the latest byte-for-byte Anthropic turn, or
|
|
314
|
+
// compatible targets that will also emit sibling unsigned
|
|
315
|
+
// thinking natively. Drop it when the visible thinking was
|
|
316
|
+
// cross-model stripped and will be demoted to text.
|
|
317
|
+
if (isAnthropicReplay) {
|
|
318
|
+
if (isSameModel || isLatestSurvivingAssistant || replaysUnsignedAnthropicThinking) return block;
|
|
319
|
+
return [];
|
|
320
|
+
}
|
|
248
321
|
if (isSameModel) return block;
|
|
249
322
|
return [];
|
|
250
323
|
}
|
package/src/types.ts
CHANGED
|
@@ -409,6 +409,12 @@ export interface ImageContent {
|
|
|
409
409
|
type: "image";
|
|
410
410
|
data: string; // base64 encoded image data
|
|
411
411
|
mimeType: string; // e.g., "image/jpeg", "image/png"
|
|
412
|
+
/**
|
|
413
|
+
* OpenAI-only resolution hint. `"original"` preserves native resolution
|
|
414
|
+
* (required for snapcompact frames, whose glyphs do not survive the
|
|
415
|
+
* default `auto` downscale). Providers without a detail knob ignore it.
|
|
416
|
+
*/
|
|
417
|
+
detail?: "auto" | "low" | "high" | "original";
|
|
412
418
|
}
|
|
413
419
|
|
|
414
420
|
export interface ToolCall {
|
|
@@ -37,8 +37,10 @@ When strict mode is requested (`strict=true` at call site), the schema MUST sati
|
|
|
37
37
|
3. **Object and tuple strictness is enforced recursively**
|
|
38
38
|
- Every object node gets `additionalProperties: false`.
|
|
39
39
|
- Every property key is included in `required`.
|
|
40
|
-
- Optional properties are
|
|
41
|
-
- `anyOf
|
|
40
|
+
- Optional properties are made nullable:
|
|
41
|
+
- Pure union nodes (only `anyOf` plus optional `description`) get a `{ "type": "null" }` branch appended in place — never a nested wrapper.
|
|
42
|
+
- All other nodes are wrapped as `anyOf: [<original schema>, { "type": "null" }]`. Nodes with constraining siblings next to `anyOf` MUST keep the wrapper: sibling keywords are conjunctive with `anyOf`, so appending a null branch would not make the node nullable.
|
|
43
|
+
- Nested pure unions are spliced into the parent `anyOf` (`(A ∨ B) ∨ C` → `A ∨ B ∨ C`); an inner `description` is hoisted to the parent when the parent has none. Strict output MUST NOT contain an `anyOf` branch that is itself a pure union — some upstream validators (OpenRouter DeepSeek) reject branches without `type`.
|
|
42
44
|
- Tuple entries in `prefixItems` are strictified recursively.
|
|
43
45
|
|
|
44
46
|
4. **Schema nodes must be representable in strict mode**
|
|
@@ -1437,6 +1437,21 @@ export function sanitizeSchemaForStrictMode(
|
|
|
1437
1437
|
return sanitized;
|
|
1438
1438
|
}
|
|
1439
1439
|
|
|
1440
|
+
/**
|
|
1441
|
+
* A node whose only constraining keyword is `anyOf` (annotations like
|
|
1442
|
+
* `description` aside). Only such nodes can be merged into an enclosing
|
|
1443
|
+
* union without changing semantics: sibling keywords (`type`, `enum`,
|
|
1444
|
+
* `properties`, …) apply conjunctively with `anyOf`, so spreading the
|
|
1445
|
+
* branches of a non-pure node would drop those constraints.
|
|
1446
|
+
*/
|
|
1447
|
+
function isPureAnyOfNode(value: unknown): value is Record<string, unknown> & { anyOf: unknown[] } {
|
|
1448
|
+
if (!isJsonObject(value) || !Array.isArray(value.anyOf)) return false;
|
|
1449
|
+
for (const key in value) {
|
|
1450
|
+
if (key !== "anyOf" && key !== "description") return false;
|
|
1451
|
+
}
|
|
1452
|
+
return true;
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1440
1455
|
/**
|
|
1441
1456
|
* Recursively enforces JSON Schema constraints required by OpenAI/Codex strict mode:
|
|
1442
1457
|
* - `additionalProperties: false` on every object node
|
|
@@ -1505,6 +1520,10 @@ function enforceStrictSchemaBody(
|
|
|
1505
1520
|
strictProperties[key] = processed;
|
|
1506
1521
|
continue;
|
|
1507
1522
|
}
|
|
1523
|
+
if (isPureAnyOfNode(processed)) {
|
|
1524
|
+
strictProperties[key] = { ...processed, anyOf: [...processed.anyOf, { type: "null" }] };
|
|
1525
|
+
continue;
|
|
1526
|
+
}
|
|
1508
1527
|
if (isJsonObject(processed) && typeof processed.description === "string") {
|
|
1509
1528
|
const { description, ...withoutDescription } = processed;
|
|
1510
1529
|
strictProperties[key] = { anyOf: [withoutDescription, { type: "null" }], description };
|
|
@@ -1545,6 +1564,26 @@ function enforceStrictSchemaBody(
|
|
|
1545
1564
|
);
|
|
1546
1565
|
}
|
|
1547
1566
|
}
|
|
1567
|
+
// Splice nested pure unions into the parent `anyOf`: `(A ∨ B) ∨ C` ≡ `A ∨ B ∨ C`.
|
|
1568
|
+
// Some strict-mode validators (e.g. DeepSeek behind OpenRouter) reject anyOf
|
|
1569
|
+
// branches that carry no `type`, which is exactly what a nested combinator
|
|
1570
|
+
// node looks like (#2270). Branch recursion above already flattened deeper
|
|
1571
|
+
// levels bottom-up, so a single pass suffices.
|
|
1572
|
+
if (Array.isArray(result.anyOf) && result.anyOf.some(isPureAnyOfNode)) {
|
|
1573
|
+
const flattened: unknown[] = [];
|
|
1574
|
+
for (const branch of result.anyOf) {
|
|
1575
|
+
if (!isPureAnyOfNode(branch)) {
|
|
1576
|
+
flattened.push(branch);
|
|
1577
|
+
continue;
|
|
1578
|
+
}
|
|
1579
|
+
flattened.push(...branch.anyOf);
|
|
1580
|
+
// Keep the inner annotation when the parent has none.
|
|
1581
|
+
if (typeof branch.description === "string" && result.description === undefined) {
|
|
1582
|
+
result.description = branch.description;
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
result.anyOf = flattened;
|
|
1586
|
+
}
|
|
1548
1587
|
for (const defsKey of ["$defs", "definitions"] as const) {
|
|
1549
1588
|
if (result[defsKey] != null && typeof result[defsKey] === "object" && !Array.isArray(result[defsKey])) {
|
|
1550
1589
|
const defs = result[defsKey] as Record<string, unknown>;
|