@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 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.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.0",
42
- "@oh-my-pi/pi-utils": "16.0.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's Anthropic-compatible gateway validates API-key auth through
2386
- // `X-Api-Key`; bearer-only requests reach the endpoint but return
2387
- // `Missing API key` before token validation.
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
- const params = buildParams(model, context, options, deploymentName);
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);
@@ -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: `[Tool Result]\n${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: `[Tool Result]\n${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: Record<string, unknown> = {
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
- recordCodexWebSocketRequestStats(state, body);
1026
- return { eventStream: await open(body), requestBodyForState: structuredCloneJSON(body), transport: "sse" };
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
- // Shallow-merge into the accumulated object; for shared string keys, detect
985
- // cumulative-vs-delta semantics with `startsWith` so we neither duplicate cumulative
986
- // payloads nor lose delta fragments. Degenerates to the previous "last wins"
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
- block.partialArgs &&
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 { params, trailingScaffoldingItems } = buildParams(model, context, options, providerSessionState);
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
- const chained: OpenAIResponsesChainedParams =
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
- rawRequestDump.body = params;
496
- openaiStream = await openResponsesStream(params);
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 });
@@ -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 = typeVariants.map(variantType => {
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
- return sanitizeSchemaForStrictMode(variantSchema, epoch, cache, root);
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>;
@@ -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
- for (const key in scalarVariant) {
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
 
@@ -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
  }