@oh-my-pi/pi-ai 16.0.0 → 16.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +3 -0
- package/dist/types/registry/registry.d.ts +4 -0
- package/dist/types/registry/umans.d.ts +7 -0
- package/package.json +3 -3
- package/src/providers/anthropic.ts +19 -4
- package/src/providers/azure-openai-responses.ts +5 -2
- package/src/providers/cursor.ts +4 -2
- package/src/providers/openai-codex-responses.ts +20 -5
- package/src/providers/openai-completions.ts +104 -19
- package/src/providers/openai-responses.ts +14 -5
- package/src/registry/registry.ts +2 -0
- package/src/registry/umans.ts +23 -0
- package/src/utils/schema/normalize.ts +40 -3
- package/src/utils/schema/wire.ts +18 -3
- package/src/utils/validation.ts +159 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.0.1] - 2026-06-15
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added Umans AI Coding Plan API-key login support and `UMANS_AI_CODING_PLAN_API_KEY` environment fallback ([#2636](https://github.com/can1357/oh-my-pi/pull/2636) by [@oldschoola](https://github.com/oldschoola)).
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed OpenAI Responses, Azure OpenAI Responses, and Codex Responses providers ignoring async `onPayload` replacement bodies. Provider payload hooks can now transform the actual request body sent upstream, matching the Anthropic/Gemini replacement contract.
|
|
14
|
+
- Fixed OpenAI-compatible chat-completions streams that send object-shaped tool arguments in fragments by deep-merging nested objects and task arrays instead of replacing earlier chunks. ([#2617](https://github.com/can1357/oh-my-pi/issues/2617))
|
|
15
|
+
- Fixed OpenAI Responses strict-mode tool schema normalization for nullable enum MCP parameters so enum constraints are distributed to matching `anyOf` branches instead of being copied onto the `null` branch. ([#1835](https://github.com/can1357/oh-my-pi/issues/1835))
|
|
16
|
+
- Fixed Cursor provider formatting tool errors with the same `[Tool Result]` prefix as successful results, causing Composer models to misinterpret error messages (e.g. "Pattern must not be empty") as directives over long conversations. Errors now use a `[Tool Error]` prefix so the model can distinguish failures from successes in the prompt history. ([#1853](https://github.com/can1357/oh-my-pi/pull/1853))
|
|
17
|
+
- Fixed `validateToolArguments` silently accepting JSON-encoded array strings (e.g. `'["a","b"]'`) against `union(string, array<string>)` schemas — providers that double-serialize tool-call arguments (Z.AI / GLM) caused tools like `search` to receive the literal `["a","b"]` as a single path, producing zero matches (single element) or glob parse errors (multi-element). A new pre-validation pass parses JSON-array-shaped strings when the schema explicitly accepts both shapes. ([#1788](https://github.com/can1357/oh-my-pi/issues/1788))
|
|
18
|
+
- Fixed Anthropic thinking summaries that arrive wrapped in literal `<thinking>` tags so advisor/raw transcript dumps do not render nested thinking tags ([#2695](https://github.com/can1357/oh-my-pi/issues/2695)).
|
|
19
|
+
|
|
5
20
|
## [16.0.0] - 2026-06-15
|
|
6
21
|
|
|
7
22
|
### Breaking Changes
|
package/README.md
CHANGED
|
@@ -68,6 +68,7 @@ Unified LLM API with automatic model discovery, provider configuration, token an
|
|
|
68
68
|
- **Kilo Gateway** (supports OAuth `/login kilo` or `KILO_API_KEY`)
|
|
69
69
|
- **LiteLLM** (requires `LITELLM_API_KEY`)
|
|
70
70
|
- **zAI** (requires `ZAI_API_KEY`)
|
|
71
|
+
- **Umans AI Coding Plan** (supports `/login umans` or `UMANS_AI_CODING_PLAN_API_KEY`)
|
|
71
72
|
- **MiniMax Token Plan** (requires `MINIMAX_CODE_API_KEY` or `MINIMAX_CODE_CN_API_KEY`)
|
|
72
73
|
- **Xiaomi MiMo** (requires `XIAOMI_API_KEY`)
|
|
73
74
|
- **ZenMux** (requires `ZENMUX_API_KEY`)
|
|
@@ -952,6 +953,7 @@ In Node.js environments, you can set environment variables to avoid passing API
|
|
|
952
953
|
| Ollama Cloud | `OLLAMA_CLOUD_API_KEY` |
|
|
953
954
|
| Qwen Portal | `QWEN_OAUTH_TOKEN` or `QWEN_PORTAL_API_KEY` |
|
|
954
955
|
| zAI | `ZAI_API_KEY` |
|
|
956
|
+
| Umans AI Coding Plan | `UMANS_AI_CODING_PLAN_API_KEY` |
|
|
955
957
|
| MiniMax Code | `MINIMAX_CODE_API_KEY` (international) or `MINIMAX_CODE_CN_API_KEY` (China) |
|
|
956
958
|
| Xiaomi MiMo | `XIAOMI_API_KEY` |
|
|
957
959
|
| ZenMux | `ZENMUX_API_KEY` |
|
|
@@ -978,6 +980,7 @@ Provider endpoint defaults for the current OpenAI-compatible integrations:
|
|
|
978
980
|
- Xiaomi MiMo: `https://api.xiaomimimo.com/anthropic`
|
|
979
981
|
- ZenMux (OpenAI): `https://zenmux.ai/api/v1`
|
|
980
982
|
- ZenMux (Anthropic models): `https://zenmux.ai/api/anthropic`
|
|
983
|
+
- Umans AI Coding Plan: `https://api.code.umans.ai`
|
|
981
984
|
- vLLM: `http://127.0.0.1:8000/v1`
|
|
982
985
|
- Ollama: local OpenAI-compatible runtime (`http://127.0.0.1:11434/v1`)
|
|
983
986
|
- Ollama Cloud: native Ollama API host (`https://ollama.com/api`, configured here as base URL `https://ollama.com`)
|
|
@@ -208,6 +208,10 @@ declare const ALL: ({
|
|
|
208
208
|
readonly id: "together";
|
|
209
209
|
readonly name: "Together";
|
|
210
210
|
readonly login: (cb: Parameters<typeof import("./together").loginTogether>[0]) => Promise<string>;
|
|
211
|
+
} | {
|
|
212
|
+
readonly id: "umans";
|
|
213
|
+
readonly name: "Umans AI Coding Plan";
|
|
214
|
+
readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
|
|
211
215
|
} | {
|
|
212
216
|
readonly id: "venice";
|
|
213
217
|
readonly name: "Venice";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { OAuthLoginCallbacks } from "./oauth/types";
|
|
2
|
+
export declare const loginUmans: (options: import("./oauth").OAuthController) => Promise<string>;
|
|
3
|
+
export declare const umansProvider: {
|
|
4
|
+
readonly id: "umans";
|
|
5
|
+
readonly name: "Umans AI Coding Plan";
|
|
6
|
+
readonly login: (cb: OAuthLoginCallbacks) => Promise<string>;
|
|
7
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "16.0.
|
|
4
|
+
"version": "16.0.1",
|
|
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": "16.0.
|
|
42
|
-
"@oh-my-pi/pi-utils": "16.0.
|
|
41
|
+
"@oh-my-pi/pi-catalog": "16.0.1",
|
|
42
|
+
"@oh-my-pi/pi-utils": "16.0.1",
|
|
43
43
|
"partial-json": "^0.1.7",
|
|
44
44
|
"zod": "^4"
|
|
45
45
|
},
|
|
@@ -1462,6 +1462,17 @@ export function isProviderRetryableError(error: unknown, provider?: string): boo
|
|
|
1462
1462
|
return isRetryableError(error);
|
|
1463
1463
|
}
|
|
1464
1464
|
|
|
1465
|
+
const THINKING_ENVELOPE_OPEN = "<thinking>";
|
|
1466
|
+
const THINKING_ENVELOPE_CLOSE = "</thinking>";
|
|
1467
|
+
|
|
1468
|
+
function unwrapAnthropicThinkingEnvelope(text: string): string | undefined {
|
|
1469
|
+
let current = text.trim();
|
|
1470
|
+
while (current.startsWith(THINKING_ENVELOPE_OPEN) && current.endsWith(THINKING_ENVELOPE_CLOSE)) {
|
|
1471
|
+
current = current.slice(THINKING_ENVELOPE_OPEN.length, current.length - THINKING_ENVELOPE_CLOSE.length).trim();
|
|
1472
|
+
}
|
|
1473
|
+
return current === text ? undefined : current;
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1465
1476
|
function createEmptyUsage(premiumRequests?: number): Usage {
|
|
1466
1477
|
return {
|
|
1467
1478
|
input: 0,
|
|
@@ -1668,6 +1679,11 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
|
|
|
1668
1679
|
if (block.type === "text") {
|
|
1669
1680
|
stream.push({ type: "text_end", contentIndex, content: block.text, partial: output });
|
|
1670
1681
|
} else if (block.type === "thinking") {
|
|
1682
|
+
const unwrappedThinking = unwrapAnthropicThinkingEnvelope(block.thinking);
|
|
1683
|
+
if (unwrappedThinking !== undefined) {
|
|
1684
|
+
block.thinking = unwrappedThinking;
|
|
1685
|
+
block.thinkingSignature = undefined;
|
|
1686
|
+
}
|
|
1671
1687
|
stream.push({ type: "thinking_end", contentIndex, content: block.thinking, partial: output });
|
|
1672
1688
|
} else if (block.type === "toolCall") {
|
|
1673
1689
|
const finalJson =
|
|
@@ -2382,10 +2398,9 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
|
|
2382
2398
|
};
|
|
2383
2399
|
}
|
|
2384
2400
|
|
|
2385
|
-
// OpenCode Go
|
|
2386
|
-
// `X-Api-Key`; bearer-only requests reach the endpoint but
|
|
2387
|
-
|
|
2388
|
-
if (model.provider === "opencode-go") {
|
|
2401
|
+
// OpenCode Go and Umans validate Anthropic-compatible API-key auth through
|
|
2402
|
+
// `X-Api-Key`; bearer-only requests reach the endpoint but fail auth.
|
|
2403
|
+
if (model.provider === "opencode-go" || model.provider === "umans") {
|
|
2389
2404
|
delete defaultHeaders.Authorization;
|
|
2390
2405
|
return {
|
|
2391
2406
|
isOAuthToken: false,
|
|
@@ -139,8 +139,11 @@ export const streamAzureOpenAIResponses: StreamFunction<"azure-openai-responses"
|
|
|
139
139
|
try {
|
|
140
140
|
const apiKey = options?.apiKey || getEnvApiKey(model.provider) || "";
|
|
141
141
|
const { url, headers } = buildAzureResponsesRequest(model, apiKey, options);
|
|
142
|
-
|
|
143
|
-
options?.onPayload?.(params);
|
|
142
|
+
let params = buildParams(model, context, options, deploymentName);
|
|
143
|
+
const replacementPayload = await options?.onPayload?.(params, model);
|
|
144
|
+
if (replacementPayload !== undefined) {
|
|
145
|
+
params = replacementPayload as typeof params;
|
|
146
|
+
}
|
|
144
147
|
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getOpenAIStreamIdleTimeoutMs();
|
|
145
148
|
const firstEventTimeoutMs =
|
|
146
149
|
options?.streamFirstEventTimeoutMs ?? getOpenAIStreamFirstEventTimeoutMs(idleTimeoutMs);
|
package/src/providers/cursor.ts
CHANGED
|
@@ -2335,9 +2335,10 @@ function buildRootPromptMessagesJson(
|
|
|
2335
2335
|
} else if (msg.role === "toolResult") {
|
|
2336
2336
|
const text = toolResultToText(msg);
|
|
2337
2337
|
if (!text) continue;
|
|
2338
|
+
const prefix = msg.isError ? "[Tool Error]" : "[Tool Result]";
|
|
2338
2339
|
pushJson({
|
|
2339
2340
|
role: "user",
|
|
2340
|
-
content: [{ type: "text", text:
|
|
2341
|
+
content: [{ type: "text", text: `${prefix}\n${text}` }],
|
|
2341
2342
|
});
|
|
2342
2343
|
}
|
|
2343
2344
|
}
|
|
@@ -2415,10 +2416,11 @@ function buildConversationTurns(
|
|
|
2415
2416
|
// Include tool results as assistant text for context
|
|
2416
2417
|
const text = toolResultToText(stepMsg);
|
|
2417
2418
|
if (text) {
|
|
2419
|
+
const prefix = stepMsg.isError ? "[Tool Error]" : "[Tool Result]";
|
|
2418
2420
|
const step = create(ConversationStepSchema, {
|
|
2419
2421
|
message: {
|
|
2420
2422
|
case: "assistantMessage",
|
|
2421
|
-
value: create(AssistantMessageSchema, { text:
|
|
2423
|
+
value: create(AssistantMessageSchema, { text: `${prefix}\n${text}` }),
|
|
2422
2424
|
},
|
|
2423
2425
|
});
|
|
2424
2426
|
stepBlobIds.push(storeCursorBlob(blobStore, toBinary(ConversationStepSchema, step)));
|
|
@@ -686,6 +686,14 @@ function resetOutputState(output: AssistantMessage): void {
|
|
|
686
686
|
output.stopReason = "stop";
|
|
687
687
|
output.stopDetails = undefined;
|
|
688
688
|
}
|
|
689
|
+
async function applyCodexPayloadReplacement<T extends Record<string, unknown>>(
|
|
690
|
+
model: Model<"openai-codex-responses">,
|
|
691
|
+
options: OpenAICodexResponsesOptions | undefined,
|
|
692
|
+
payload: T,
|
|
693
|
+
): Promise<T> {
|
|
694
|
+
const replacementPayload = await options?.onPayload?.(payload, model);
|
|
695
|
+
return replacementPayload !== undefined ? (replacementPayload as T) : payload;
|
|
696
|
+
}
|
|
689
697
|
|
|
690
698
|
function removeTransientBlockIndices(output: AssistantMessage): void {
|
|
691
699
|
for (const block of output.content) {
|
|
@@ -742,7 +750,6 @@ async function buildCodexRequestContext(
|
|
|
742
750
|
const promptCacheKey = resolveCodexPromptCacheKey(options);
|
|
743
751
|
const transportSessionId = resolveCodexTransportSessionId(options);
|
|
744
752
|
const transformedBody = await buildTransformedCodexRequestBody(model, context, options, promptCacheKey);
|
|
745
|
-
options?.onPayload?.(transformedBody);
|
|
746
753
|
|
|
747
754
|
const requestHeaders = { ...(model.headers ?? {}), ...(options?.headers ?? {}) };
|
|
748
755
|
const rawRequestDump: RawHttpRequestDump = {
|
|
@@ -878,6 +885,8 @@ async function openInitialCodexEventStream(
|
|
|
878
885
|
while (true) {
|
|
879
886
|
try {
|
|
880
887
|
return await openCodexWebSocketTransport(
|
|
888
|
+
model,
|
|
889
|
+
options,
|
|
881
890
|
requestContext,
|
|
882
891
|
requestSetup,
|
|
883
892
|
websocketState,
|
|
@@ -910,6 +919,8 @@ async function openInitialCodexEventStream(
|
|
|
910
919
|
return openCodexSseTransport(model, requestContext, requestSetup, options, websocketState, transformedBody);
|
|
911
920
|
}
|
|
912
921
|
async function openCodexWebSocketTransport(
|
|
922
|
+
model: Model<"openai-codex-responses">,
|
|
923
|
+
options: OpenAICodexResponsesOptions | undefined,
|
|
913
924
|
requestContext: CodexRequestContext,
|
|
914
925
|
requestSetup: CodexRequestSetup,
|
|
915
926
|
websocketState: CodexWebSocketSessionState,
|
|
@@ -923,7 +934,7 @@ async function openCodexWebSocketTransport(
|
|
|
923
934
|
const chainedBody = buildCodexChainedRequestBody(requestContext.transformedBody, websocketState);
|
|
924
935
|
// WebSocket frames cannot carry per-request HTTP headers, so the Responses
|
|
925
936
|
// Lite marker rides in `client_metadata` on every `response.create`.
|
|
926
|
-
const websocketRequest
|
|
937
|
+
const websocketRequest = await applyCodexPayloadReplacement(model, options, {
|
|
927
938
|
type: "response.create",
|
|
928
939
|
...chainedBody,
|
|
929
940
|
...(requestContext.responsesLite
|
|
@@ -934,7 +945,7 @@ async function openCodexWebSocketTransport(
|
|
|
934
945
|
},
|
|
935
946
|
}
|
|
936
947
|
: {}),
|
|
937
|
-
};
|
|
948
|
+
});
|
|
938
949
|
const websocketHeaders = createCodexHeaders(
|
|
939
950
|
requestContext.requestHeaders,
|
|
940
951
|
requestContext.accountId,
|
|
@@ -945,6 +956,7 @@ async function openCodexWebSocketTransport(
|
|
|
945
956
|
requestContext.responsesLite,
|
|
946
957
|
);
|
|
947
958
|
const requestBodyForState = structuredCloneJSON(requestContext.transformedBody);
|
|
959
|
+
requestContext.rawRequestDump.body = websocketRequest;
|
|
948
960
|
logCodexDebug("codex websocket request", {
|
|
949
961
|
url: toWebSocketUrl(requestContext.url),
|
|
950
962
|
model: requestContext.transformedBody.model,
|
|
@@ -1022,8 +1034,9 @@ async function openCodexSseTransport(
|
|
|
1022
1034
|
),
|
|
1023
1035
|
);
|
|
1024
1036
|
};
|
|
1025
|
-
|
|
1026
|
-
|
|
1037
|
+
const wireBody = await applyCodexPayloadReplacement(model, options, body);
|
|
1038
|
+
recordCodexWebSocketRequestStats(state, wireBody);
|
|
1039
|
+
return { eventStream: await open(wireBody), requestBodyForState: structuredCloneJSON(wireBody), transport: "sse" };
|
|
1027
1040
|
}
|
|
1028
1041
|
|
|
1029
1042
|
async function reopenCodexWebSocketRuntimeStream(
|
|
@@ -1033,6 +1046,8 @@ async function reopenCodexWebSocketRuntimeStream(
|
|
|
1033
1046
|
): Promise<void> {
|
|
1034
1047
|
try {
|
|
1035
1048
|
const next = await openCodexWebSocketTransport(
|
|
1049
|
+
context.model,
|
|
1050
|
+
context.options,
|
|
1036
1051
|
context.requestContext,
|
|
1037
1052
|
context.requestSetup,
|
|
1038
1053
|
state,
|
|
@@ -186,6 +186,105 @@ function serializeToolArguments(value: unknown): string {
|
|
|
186
186
|
return "{}";
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
function isUnsafeToolArgumentKey(key: string): boolean {
|
|
190
|
+
return key === "__proto__" || key === "constructor" || key === "prototype";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function isStreamingArgumentObject(value: unknown): value is Record<string, unknown> {
|
|
194
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function cloneStreamingArgumentValue(value: unknown): unknown {
|
|
198
|
+
if (Array.isArray(value)) {
|
|
199
|
+
return value.map(cloneStreamingArgumentValue);
|
|
200
|
+
}
|
|
201
|
+
if (isStreamingArgumentObject(value)) {
|
|
202
|
+
return mergeStreamingArgumentObjects(undefined, value);
|
|
203
|
+
}
|
|
204
|
+
return value;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function streamingArgumentValuesEqual(left: unknown, right: unknown): boolean {
|
|
208
|
+
if (left === right) return true;
|
|
209
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
210
|
+
if (left.length !== right.length) return false;
|
|
211
|
+
for (let i = 0; i < left.length; i++) {
|
|
212
|
+
if (!streamingArgumentValuesEqual(left[i], right[i])) return false;
|
|
213
|
+
}
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
if (isStreamingArgumentObject(left) && isStreamingArgumentObject(right)) {
|
|
217
|
+
let leftKeys = 0;
|
|
218
|
+
for (const key in left) {
|
|
219
|
+
if (!Object.hasOwn(left, key) || isUnsafeToolArgumentKey(key)) continue;
|
|
220
|
+
leftKeys++;
|
|
221
|
+
if (!Object.hasOwn(right, key) || !streamingArgumentValuesEqual(left[key], right[key])) return false;
|
|
222
|
+
}
|
|
223
|
+
let rightKeys = 0;
|
|
224
|
+
for (const key in right) {
|
|
225
|
+
if (!Object.hasOwn(right, key) || isUnsafeToolArgumentKey(key)) continue;
|
|
226
|
+
rightKeys++;
|
|
227
|
+
}
|
|
228
|
+
return leftKeys === rightKeys;
|
|
229
|
+
}
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function streamingArgumentArrayStartsWith(value: unknown[], prefix: unknown[]): boolean {
|
|
234
|
+
if (prefix.length > value.length) return false;
|
|
235
|
+
for (let i = 0; i < prefix.length; i++) {
|
|
236
|
+
if (!streamingArgumentValuesEqual(value[i], prefix[i])) return false;
|
|
237
|
+
}
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function mergeStreamingArgumentArrays(prev: unknown[], fragment: unknown[]): unknown[] {
|
|
242
|
+
if (streamingArgumentArrayStartsWith(fragment, prev)) {
|
|
243
|
+
return fragment.map(cloneStreamingArgumentValue);
|
|
244
|
+
}
|
|
245
|
+
if (streamingArgumentArrayStartsWith(prev, fragment)) {
|
|
246
|
+
return prev.map(cloneStreamingArgumentValue);
|
|
247
|
+
}
|
|
248
|
+
const merged = prev.map(cloneStreamingArgumentValue);
|
|
249
|
+
for (const value of fragment) {
|
|
250
|
+
merged.push(cloneStreamingArgumentValue(value));
|
|
251
|
+
}
|
|
252
|
+
return merged;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function mergeStreamingArgumentValues(prev: unknown, fragment: unknown): unknown {
|
|
256
|
+
if (typeof prev === "string" && typeof fragment === "string") {
|
|
257
|
+
return fragment.startsWith(prev) ? fragment : prev + fragment;
|
|
258
|
+
}
|
|
259
|
+
if (Array.isArray(prev) && Array.isArray(fragment)) {
|
|
260
|
+
return mergeStreamingArgumentArrays(prev, fragment);
|
|
261
|
+
}
|
|
262
|
+
if (isStreamingArgumentObject(prev) && isStreamingArgumentObject(fragment)) {
|
|
263
|
+
return mergeStreamingArgumentObjects(prev, fragment);
|
|
264
|
+
}
|
|
265
|
+
return cloneStreamingArgumentValue(fragment);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function mergeStreamingArgumentObjects(
|
|
269
|
+
prev: Record<string, unknown> | undefined,
|
|
270
|
+
fragment: Record<string, unknown>,
|
|
271
|
+
): Record<string, unknown> {
|
|
272
|
+
const merged: Record<string, unknown> = {};
|
|
273
|
+
if (prev) {
|
|
274
|
+
for (const key in prev) {
|
|
275
|
+
if (!Object.hasOwn(prev, key) || isUnsafeToolArgumentKey(key)) continue;
|
|
276
|
+
merged[key] = cloneStreamingArgumentValue(prev[key]);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
for (const key in fragment) {
|
|
280
|
+
if (!Object.hasOwn(fragment, key) || isUnsafeToolArgumentKey(key)) continue;
|
|
281
|
+
merged[key] = Object.hasOwn(merged, key)
|
|
282
|
+
? mergeStreamingArgumentValues(merged[key], fragment[key])
|
|
283
|
+
: cloneStreamingArgumentValue(fragment[key]);
|
|
284
|
+
}
|
|
285
|
+
return merged;
|
|
286
|
+
}
|
|
287
|
+
|
|
189
288
|
/**
|
|
190
289
|
* Check if conversation messages contain tool calls or tool results.
|
|
191
290
|
* This is needed because Anthropic (via proxy) requires the tools param
|
|
@@ -981,31 +1080,17 @@ export const streamOpenAICompletions: StreamFunction<"openai-completions"> = (
|
|
|
981
1080
|
// OpenAI JSON-string contract. Most chunks carry the complete object in one delta,
|
|
982
1081
|
// but cannot rely on that: replacing per-chunk drops earlier keys (and earlier
|
|
983
1082
|
// string content for the same key) when the host fragments the args across deltas.
|
|
984
|
-
//
|
|
985
|
-
// cumulative-vs-delta semantics
|
|
986
|
-
//
|
|
987
|
-
// behaviour for the common single-chunk shape (no prior value to merge with).
|
|
1083
|
+
// Deep-merge into the accumulated object. Strings and arrays detect
|
|
1084
|
+
// cumulative-vs-delta semantics by prefix, nested objects merge by key, and
|
|
1085
|
+
// prototype-polluting keys are ignored before storing or comparing values.
|
|
988
1086
|
//
|
|
989
1087
|
// `delta` stays empty here: emitting `JSON.stringify(rawArgs)` per chunk feeds
|
|
990
1088
|
// downstream concat-based accumulators (proxy.ts, openai-chat-server,
|
|
991
1089
|
// openai-responses-server, anthropic-messages-server) an invalid sequence like
|
|
992
1090
|
// `{"input":"a"}{"input":"b"}`. The merged object is flushed as a single
|
|
993
1091
|
// concat-safe delta in `finishToolCallBlock` before `toolcall_end` instead.
|
|
994
|
-
const prev =
|
|
995
|
-
|
|
996
|
-
typeof block.partialArgs === "object" &&
|
|
997
|
-
!Array.isArray(block.partialArgs)
|
|
998
|
-
? (block.partialArgs as Record<string, unknown>)
|
|
999
|
-
: undefined;
|
|
1000
|
-
const merged: Record<string, unknown> = prev ? { ...prev } : {};
|
|
1001
|
-
for (const [key, value] of Object.entries(rawArgs)) {
|
|
1002
|
-
const prevValue = merged[key];
|
|
1003
|
-
if (typeof prevValue === "string" && typeof value === "string") {
|
|
1004
|
-
merged[key] = value.startsWith(prevValue) ? value : prevValue + value;
|
|
1005
|
-
} else {
|
|
1006
|
-
merged[key] = value;
|
|
1007
|
-
}
|
|
1008
|
-
}
|
|
1092
|
+
const prev = isStreamingArgumentObject(block.partialArgs) ? block.partialArgs : undefined;
|
|
1093
|
+
const merged = mergeStreamingArgumentObjects(prev, rawArgs);
|
|
1009
1094
|
block.partialArgs = merged;
|
|
1010
1095
|
block.arguments = merged;
|
|
1011
1096
|
}
|
|
@@ -398,7 +398,9 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
|
|
398
398
|
} = createRequestSetup(model, context, apiKey, options?.headers, options?.initiatorOverride, routingSessionId);
|
|
399
399
|
const premiumRequestsTotal = copilotPremiumRequests;
|
|
400
400
|
const providerSessionState = getOpenAIResponsesProviderSessionState(model, options?.providerSessionState);
|
|
401
|
-
const
|
|
401
|
+
const builtParams = buildParams(model, context, options, providerSessionState);
|
|
402
|
+
const params = builtParams.params;
|
|
403
|
+
const { trailingScaffoldingItems } = builtParams;
|
|
402
404
|
if (isOpenAIResponsesStatefulEnabled(options, baseUrl) && routingSessionId && providerSessionState) {
|
|
403
405
|
chainState = getOpenAIResponsesChainState(providerSessionState, model, routingSessionId);
|
|
404
406
|
if (!chainState.disabled) {
|
|
@@ -406,7 +408,7 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
|
|
406
408
|
params.store = true;
|
|
407
409
|
}
|
|
408
410
|
}
|
|
409
|
-
|
|
411
|
+
let chained: OpenAIResponsesChainedParams =
|
|
410
412
|
chainState && !chainState.disabled
|
|
411
413
|
? buildOpenAIResponsesChainedParams(params, trailingScaffoldingItems, chainState)
|
|
412
414
|
: { params };
|
|
@@ -416,8 +418,14 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
|
|
416
418
|
options?.streamFirstEventTimeoutMs ?? getOpenAIStreamFirstEventTimeoutMs(idleTimeoutMs);
|
|
417
419
|
const requestTimeoutMs =
|
|
418
420
|
firstEventTimeoutMs !== undefined && firstEventTimeoutMs > 0 ? firstEventTimeoutMs : undefined;
|
|
419
|
-
options?.onPayload?.(params);
|
|
420
421
|
const requestUrl = `${(baseUrl ?? "https://api.openai.com/v1").replace(/\/+$/, "")}/responses`;
|
|
422
|
+
const applyPayloadReplacement = async (requestParams: OpenAIResponsesSamplingParams) => {
|
|
423
|
+
const replacementPayload = await options?.onPayload?.(requestParams, model);
|
|
424
|
+
return replacementPayload !== undefined
|
|
425
|
+
? (replacementPayload as OpenAIResponsesSamplingParams)
|
|
426
|
+
: requestParams;
|
|
427
|
+
};
|
|
428
|
+
chained = { ...chained, params: await applyPayloadReplacement(chained.params) };
|
|
421
429
|
rawRequestDump = {
|
|
422
430
|
provider: model.provider,
|
|
423
431
|
api: output.api,
|
|
@@ -492,8 +500,9 @@ export const streamOpenAIResponses: StreamFunction<"openai-responses"> = (
|
|
|
492
500
|
registerOpenAIResponsesChainStaleFailure(chainState, error);
|
|
493
501
|
}
|
|
494
502
|
sentPreviousResponseId = undefined;
|
|
495
|
-
|
|
496
|
-
|
|
503
|
+
const retryParams = await applyPayloadReplacement(params);
|
|
504
|
+
rawRequestDump.body = retryParams;
|
|
505
|
+
openaiStream = await openResponsesStream(retryParams);
|
|
497
506
|
}
|
|
498
507
|
if (premiumRequestsTotal !== undefined) output.usage.premiumRequests = premiumRequestsTotal;
|
|
499
508
|
stream.push({ type: "start", partial: output });
|
package/src/registry/registry.ts
CHANGED
|
@@ -46,6 +46,7 @@ import { syntheticProvider } from "./synthetic";
|
|
|
46
46
|
import { tavilyProvider } from "./tavily";
|
|
47
47
|
import { togetherProvider } from "./together";
|
|
48
48
|
import type { ProviderDefinition } from "./types";
|
|
49
|
+
import { umansProvider } from "./umans";
|
|
49
50
|
import { veniceProvider } from "./venice";
|
|
50
51
|
import { vercelAiGatewayProvider } from "./vercel-ai-gateway";
|
|
51
52
|
import { vllmProvider } from "./vllm";
|
|
@@ -85,6 +86,7 @@ const ALL = [
|
|
|
85
86
|
alibabaCodingPlanProvider,
|
|
86
87
|
aimlApiProvider,
|
|
87
88
|
zhipuCodingPlanProvider,
|
|
89
|
+
umansProvider,
|
|
88
90
|
qwenPortalProvider,
|
|
89
91
|
minimaxCodeProvider,
|
|
90
92
|
minimaxCodeCnProvider,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createApiKeyLogin } from "./api-key-login";
|
|
2
|
+
import type { OAuthLoginCallbacks } from "./oauth/types";
|
|
3
|
+
import type { ProviderDefinition } from "./types";
|
|
4
|
+
|
|
5
|
+
export const loginUmans = createApiKeyLogin({
|
|
6
|
+
providerLabel: "Umans AI Coding Plan",
|
|
7
|
+
authUrl: "https://app.umans.ai/billing",
|
|
8
|
+
instructions: "Create or copy your Umans API key from Dashboard → API Keys.",
|
|
9
|
+
promptMessage: "Paste your Umans API key",
|
|
10
|
+
placeholder: "sk-...",
|
|
11
|
+
validation: {
|
|
12
|
+
kind: "anthropic-messages",
|
|
13
|
+
provider: "Umans AI Coding Plan",
|
|
14
|
+
baseUrl: "https://api.code.umans.ai",
|
|
15
|
+
model: "umans-coder",
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const umansProvider = {
|
|
20
|
+
id: "umans",
|
|
21
|
+
name: "Umans AI Coding Plan",
|
|
22
|
+
login: (cb: OAuthLoginCallbacks) => loginUmans(cb),
|
|
23
|
+
} as const satisfies ProviderDefinition;
|
|
@@ -1069,6 +1069,36 @@ function primitiveJsonTypeOf(value: unknown): StrictPrimitiveType | undefined {
|
|
|
1069
1069
|
return undefined;
|
|
1070
1070
|
}
|
|
1071
1071
|
}
|
|
1072
|
+
function jsonSchemaTypeAcceptsValue(type: string, value: unknown): boolean {
|
|
1073
|
+
switch (type) {
|
|
1074
|
+
case "null":
|
|
1075
|
+
return value === null;
|
|
1076
|
+
case "string":
|
|
1077
|
+
return typeof value === "string";
|
|
1078
|
+
case "number":
|
|
1079
|
+
return typeof value === "number";
|
|
1080
|
+
case "integer":
|
|
1081
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
1082
|
+
case "boolean":
|
|
1083
|
+
return typeof value === "boolean";
|
|
1084
|
+
case "array":
|
|
1085
|
+
return Array.isArray(value);
|
|
1086
|
+
case "object":
|
|
1087
|
+
return isJsonObject(value);
|
|
1088
|
+
default:
|
|
1089
|
+
return true;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function narrowEnumToType(schema: Record<string, unknown>, type: string): boolean {
|
|
1094
|
+
const enumValues = schema.enum;
|
|
1095
|
+
if (!Array.isArray(enumValues)) return true;
|
|
1096
|
+
|
|
1097
|
+
const narrowed = enumValues.filter(value => jsonSchemaTypeAcceptsValue(type, value));
|
|
1098
|
+
if (narrowed.length === 0) return false;
|
|
1099
|
+
if (narrowed.length !== enumValues.length) schema.enum = narrowed;
|
|
1100
|
+
return true;
|
|
1101
|
+
}
|
|
1072
1102
|
|
|
1073
1103
|
/**
|
|
1074
1104
|
* Returns the primitive `type` keyword that fully describes the constraint
|
|
@@ -1283,7 +1313,8 @@ export function sanitizeSchemaForStrictMode(
|
|
|
1283
1313
|
// `enforceStrictSchema` and the typical OpenAI strict-mode "description
|
|
1284
1314
|
// on the union" shape.
|
|
1285
1315
|
const { description, ...variantBase } = sanitizedWithoutType;
|
|
1286
|
-
const variants
|
|
1316
|
+
const variants: Record<string, unknown>[] = [];
|
|
1317
|
+
for (const variantType of typeVariants) {
|
|
1287
1318
|
const variantSchema: Record<string, unknown> = { ...variantBase, type: variantType };
|
|
1288
1319
|
if (variantType !== "object") {
|
|
1289
1320
|
delete variantSchema.properties;
|
|
@@ -1293,8 +1324,14 @@ export function sanitizeSchemaForStrictMode(
|
|
|
1293
1324
|
if (variantType !== "array") {
|
|
1294
1325
|
delete variantSchema.items;
|
|
1295
1326
|
}
|
|
1296
|
-
|
|
1297
|
-
|
|
1327
|
+
if (!narrowEnumToType(variantSchema, variantType)) continue;
|
|
1328
|
+
variants.push(sanitizeSchemaForStrictMode(variantSchema, epoch, cache, root));
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
if (variants.length === 0) {
|
|
1332
|
+
cache.set(schema, sanitizedWithoutType);
|
|
1333
|
+
return sanitizedWithoutType;
|
|
1334
|
+
}
|
|
1298
1335
|
|
|
1299
1336
|
if (variants.length === 1) {
|
|
1300
1337
|
const sole = variants[0] as Record<string, unknown>;
|
package/src/utils/schema/wire.ts
CHANGED
|
@@ -128,6 +128,23 @@ function hasIntegerType(type: unknown): boolean {
|
|
|
128
128
|
return type === "integer" || (Array.isArray(type) && type.includes("integer"));
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
function copyNullableScalarConstraints(schema: Record<string, unknown>, scalarVariant: Record<string, unknown>): void {
|
|
132
|
+
for (const key in scalarVariant) {
|
|
133
|
+
if (key === "type" || key === "enum" || key === "const" || Object.hasOwn(schema, key)) continue;
|
|
134
|
+
schema[key] = scalarVariant[key];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (Object.hasOwn(scalarVariant, "const")) {
|
|
138
|
+
schema.enum = [scalarVariant.const, null];
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const enumValues = scalarVariant.enum;
|
|
143
|
+
if (Array.isArray(enumValues)) {
|
|
144
|
+
schema.enum = enumValues.includes(null) ? enumValues : [...enumValues, null];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
131
148
|
function rewriteNullableScalarAnyOf(schema: Record<string, unknown>): void {
|
|
132
149
|
if (hasSchemaDefiningSibling(schema)) return;
|
|
133
150
|
const variants = schema.anyOf;
|
|
@@ -150,9 +167,7 @@ function rewriteNullableScalarAnyOf(schema: Record<string, unknown>): void {
|
|
|
150
167
|
if (!sawNull || !scalarVariant || !scalarType) return;
|
|
151
168
|
|
|
152
169
|
delete schema.anyOf;
|
|
153
|
-
|
|
154
|
-
if (key !== "type" && !Object.hasOwn(schema, key)) schema[key] = scalarVariant[key];
|
|
155
|
-
}
|
|
170
|
+
copyNullableScalarConstraints(schema, scalarVariant);
|
|
156
171
|
schema.type = [scalarType, "null"];
|
|
157
172
|
}
|
|
158
173
|
|
package/src/utils/validation.ts
CHANGED
|
@@ -795,6 +795,146 @@ function normalizeOptionalNullsForSchema(
|
|
|
795
795
|
return { value: changed ? nextValue : value, changed };
|
|
796
796
|
}
|
|
797
797
|
|
|
798
|
+
// ============================================================================
|
|
799
|
+
// String-encoded array coercion for union(string, array) schemas.
|
|
800
|
+
// ============================================================================
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Detects whether a schema node accepts BOTH the `string` and `array` JSON
|
|
804
|
+
* Schema types. Recognizes:
|
|
805
|
+
* - `{ "type": ["string", "array"] }` (multi-type),
|
|
806
|
+
* - `{ "anyOf": [...] }` / `{ "oneOf": [...] }` with at least one string
|
|
807
|
+
* branch and one array branch.
|
|
808
|
+
*/
|
|
809
|
+
function schemaAcceptsStringAndArray(schema: Record<string, unknown>): boolean {
|
|
810
|
+
if (Array.isArray(schema.type) && schema.type.includes("string") && schema.type.includes("array")) {
|
|
811
|
+
return true;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
for (const key of ["anyOf", "oneOf"] as const) {
|
|
815
|
+
const branches = schema[key];
|
|
816
|
+
if (!Array.isArray(branches)) continue;
|
|
817
|
+
let hasString = false;
|
|
818
|
+
let hasArray = false;
|
|
819
|
+
for (const branch of branches) {
|
|
820
|
+
if (!branch || typeof branch !== "object") continue;
|
|
821
|
+
const branchType = (branch as Record<string, unknown>).type;
|
|
822
|
+
if (branchType === "string" || (Array.isArray(branchType) && branchType.includes("string"))) {
|
|
823
|
+
hasString = true;
|
|
824
|
+
}
|
|
825
|
+
if (branchType === "array" || (Array.isArray(branchType) && branchType.includes("array"))) {
|
|
826
|
+
hasArray = true;
|
|
827
|
+
}
|
|
828
|
+
if (hasString && hasArray) return true;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return false;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function schemaNodeAcceptsArray(schema: unknown): schema is Record<string, unknown> {
|
|
835
|
+
if (!schema || typeof schema !== "object") return false;
|
|
836
|
+
const schemaObject = schema as Record<string, unknown>;
|
|
837
|
+
const schemaType = schemaObject.type;
|
|
838
|
+
return schemaType === "array" || (Array.isArray(schemaType) && schemaType.includes("array"));
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function parsedArrayMatchesArrayBranch(schema: Record<string, unknown>, value: unknown[]): boolean {
|
|
842
|
+
if (schemaNodeAcceptsArray(schema)) {
|
|
843
|
+
return isJsonSchemaValueValid(schema, value);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
for (const key of ["anyOf", "oneOf"] as const) {
|
|
847
|
+
const branches = schema[key];
|
|
848
|
+
if (!Array.isArray(branches)) continue;
|
|
849
|
+
const branchList: unknown[] = branches;
|
|
850
|
+
for (const branch of branchList) {
|
|
851
|
+
if (!schemaNodeAcceptsArray(branch)) continue;
|
|
852
|
+
if (isJsonSchemaValueValid(branch, value)) return true;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
return false;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Pre-validation normalization: when a schema field accepts BOTH `string` and
|
|
860
|
+
* `array`, providers that double-serialize tool arguments (e.g. Z.AI / GLM)
|
|
861
|
+
* deliver array values as JSON-encoded strings like `'["a","b"]'`. Zod's
|
|
862
|
+
* `union([string, array])` happily accepts that string against the string
|
|
863
|
+
* branch, so the type-error driven coercion in {@link coerceArgsFromIssues}
|
|
864
|
+
* never fires, and downstream tools treat the literal `["a","b"]` as a path
|
|
865
|
+
* (silently producing zero matches or glob parse errors).
|
|
866
|
+
*
|
|
867
|
+
* Walk the schema; when both shapes are accepted AND the incoming value is a
|
|
868
|
+
* JSON-array-shaped string, substitute the parsed array only if it validates
|
|
869
|
+
* against the schema's array branch. Conservative: array-shaped strings like
|
|
870
|
+
* `"[1]"` stay on the string branch when the array branch is `string[]`.
|
|
871
|
+
*
|
|
872
|
+
* See https://github.com/can1357/oh-my-pi/issues/1788.
|
|
873
|
+
*/
|
|
874
|
+
function normalizeStringEncodedArrayUnions(schema: unknown, value: unknown): { value: unknown; changed: boolean } {
|
|
875
|
+
if (value === null || value === undefined) return { value, changed: false };
|
|
876
|
+
if (schema === null || typeof schema !== "object") return { value, changed: false };
|
|
877
|
+
|
|
878
|
+
const schemaObject = schema as Record<string, unknown>;
|
|
879
|
+
|
|
880
|
+
// Leaf case: this schema node accepts both string and array.
|
|
881
|
+
if (typeof value === "string" && schemaAcceptsStringAndArray(schemaObject)) {
|
|
882
|
+
const trimmed = value.trim();
|
|
883
|
+
if (!trimmed.startsWith("[")) return { value, changed: false };
|
|
884
|
+
try {
|
|
885
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
886
|
+
if (Array.isArray(parsed) && parsedArrayMatchesArrayBranch(schemaObject, parsed)) {
|
|
887
|
+
return { value: parsed, changed: true };
|
|
888
|
+
}
|
|
889
|
+
} catch {
|
|
890
|
+
// Not valid JSON — leave the string alone for the validator to handle.
|
|
891
|
+
}
|
|
892
|
+
return { value, changed: false };
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// Recurse into array items.
|
|
896
|
+
if (Array.isArray(value)) {
|
|
897
|
+
const itemSchema = schemaObject.items;
|
|
898
|
+
if (!itemSchema || typeof itemSchema !== "object" || Array.isArray(itemSchema)) {
|
|
899
|
+
return { value, changed: false };
|
|
900
|
+
}
|
|
901
|
+
let changed = false;
|
|
902
|
+
let nextValue = value;
|
|
903
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
904
|
+
const normalized = normalizeStringEncodedArrayUnions(itemSchema, value[i]);
|
|
905
|
+
if (!normalized.changed) continue;
|
|
906
|
+
if (!changed) {
|
|
907
|
+
nextValue = [...value];
|
|
908
|
+
changed = true;
|
|
909
|
+
}
|
|
910
|
+
nextValue[i] = normalized.value;
|
|
911
|
+
}
|
|
912
|
+
return { value: changed ? nextValue : value, changed };
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Recurse into object properties.
|
|
916
|
+
if (schemaObject.type !== "object") return { value, changed: false };
|
|
917
|
+
if (typeof value !== "object" || value === null) return { value, changed: false };
|
|
918
|
+
const properties = schemaObject.properties;
|
|
919
|
+
if (!properties || typeof properties !== "object") return { value, changed: false };
|
|
920
|
+
|
|
921
|
+
const propsObject = properties as Record<string, unknown>;
|
|
922
|
+
const valueObject = value as Record<string, unknown>;
|
|
923
|
+
let changed = false;
|
|
924
|
+
let nextValue = valueObject;
|
|
925
|
+
for (const [key, propertySchema] of Object.entries(propsObject)) {
|
|
926
|
+
if (!(key in nextValue)) continue;
|
|
927
|
+
const normalized = normalizeStringEncodedArrayUnions(propertySchema, nextValue[key]);
|
|
928
|
+
if (!normalized.changed) continue;
|
|
929
|
+
if (!changed) {
|
|
930
|
+
nextValue = { ...nextValue };
|
|
931
|
+
changed = true;
|
|
932
|
+
}
|
|
933
|
+
nextValue[key] = normalized.value;
|
|
934
|
+
}
|
|
935
|
+
return { value: changed ? nextValue : valueObject, changed };
|
|
936
|
+
}
|
|
937
|
+
|
|
798
938
|
// ============================================================================
|
|
799
939
|
// Zod issue → coercion bridge
|
|
800
940
|
// ============================================================================
|
|
@@ -1095,6 +1235,16 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
|
|
|
1095
1235
|
changed = true;
|
|
1096
1236
|
}
|
|
1097
1237
|
|
|
1238
|
+
// Then re-shape JSON-stringified arrays whose schema accepts both string
|
|
1239
|
+
// and array (e.g. `paths: string | string[]`). Without this, zod accepts
|
|
1240
|
+
// the literal `'["a","b"]'` as a string and downstream tools treat it as
|
|
1241
|
+
// a single path with embedded glob brackets — silent zero results.
|
|
1242
|
+
const stringEncodedArrayNorm = normalizeStringEncodedArrayUnions(json, normalizedArgs);
|
|
1243
|
+
if (stringEncodedArrayNorm.changed) {
|
|
1244
|
+
normalizedArgs = stringEncodedArrayNorm.value;
|
|
1245
|
+
changed = true;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1098
1248
|
let result = validateContext(ctx, normalizedArgs);
|
|
1099
1249
|
if (result.success) return result.value as ToolCall["arguments"];
|
|
1100
1250
|
|
|
@@ -1110,6 +1260,15 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
|
|
|
1110
1260
|
normalizedArgs = nullNormalization.value;
|
|
1111
1261
|
}
|
|
1112
1262
|
|
|
1263
|
+
// Re-run the union-string coercion because `coerceArgsFromIssues` may
|
|
1264
|
+
// have just unwrapped a JSON-stringified object at the root or inside a
|
|
1265
|
+
// nested field — exposing `string | string[]` descendants the initial
|
|
1266
|
+
// pre-validation pass could not reach.
|
|
1267
|
+
const stringEncodedArrayNormPass = normalizeStringEncodedArrayUnions(json, normalizedArgs);
|
|
1268
|
+
if (stringEncodedArrayNormPass.changed) {
|
|
1269
|
+
normalizedArgs = stringEncodedArrayNormPass.value;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1113
1272
|
result = validateContext(ctx, normalizedArgs);
|
|
1114
1273
|
if (result.success) return result.value as ToolCall["arguments"];
|
|
1115
1274
|
}
|