@oh-my-pi/pi-ai 16.1.8 → 16.1.10

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,49 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.10] - 2026-06-21
6
+
7
+ ### Changed
8
+
9
+ - Improved JSON robustness by replacing external dependency with a custom, high-performance parser
10
+ - Strengthened streaming JSON parsing to prevent non-finite numbers from surfacing as `undefined/NaN`
11
+ - Configured JSON parser to reject JS-specific `NaN` and `Infinity` values for tool arguments
12
+ - Replaced the JSON repair/parse helpers (`parseJsonWithRepair`, `parseStreamingJson`) with a single from-scratch tolerant parser (`RelaxedJson`) that accepts single-quoted strings, unquoted object keys, trailing/stray commas, `//` and `/* */` comments, Python `True`/`False`/`None`, raw control characters, invalid escapes, and unescaped apostrophes (`'it's'`). Final parsing still throws on truncated/garbage input (so a malformed tool call is skipped rather than executed with half-formed args) and rejects JS-only `NaN`/`Infinity`; streaming parsing stays non-throwing and rolls back incomplete trailing tokens instead of surfacing `undefined`/`NaN`. The Cursor provider's ad-hoc regex + JSON5 tool-argument parser now routes through the shared parser.
13
+
14
+ ### Fixed
15
+
16
+ - Fixed tool call ID normalization for Anthropic-compatible models
17
+ - Fixed Anthropic Messages replay sanitizing malformed tool-call IDs, including aborted native tool calls with empty IDs, so retries no longer send invalid `tool_use.id` / `tool_result.tool_use_id` pairs.
18
+ - Fixed the Codex Responses WebSocket transport attributing a prior turn's output to the current one on a reused connection: a trailing/duplicate frame from a cleanly-completed previous response that slipped past the queue drain could be consumed as this request's terminal (ending the turn with empty output) or as a stale tool call. Frames are now keyed by `response.id` — a frame carrying the previous response's id is dropped, and one carrying a third id (or a regressed `sequence_number`) fails closed so the turn retries instead of mixing two responses' streams. Idless frames (deltas, the rate-limit/metadata preamble, `response.created`-less streams) still pass through, matching upstream codex-rs.
19
+ - Fixed `transformMessages` pulling an earlier, orphaned tool result onto a later tool call that reused the same id (left behind when compaction folded the originating `tool_use` into a summary). The pending-call flush now pairs each call with a result positioned *after* its assistant turn, so a reused id surfaces its own output rather than a prior turn's.
20
+ - Fixed DashScope 429 rate-limit messages that mention authorization being classified as credential failures, preventing valid API keys from being invalidated after throttling. ([#3172](https://github.com/can1357/oh-my-pi/issues/3172))
21
+ - Fixed OpenCode Go `401 Insufficient balance` quota errors being treated as unknown failures instead of usage-limit errors, restoring credential rotation and fallback chains. ([#3169](https://github.com/can1357/oh-my-pi/issues/3169))
22
+
23
+ ### Removed
24
+
25
+ - Removed the `partial-json` dependency; streaming JSON parsing now uses the in-house `RelaxedJson` parser.
26
+
27
+ ## [16.1.9] - 2026-06-21
28
+
29
+ ### Added
30
+
31
+ - Added `llama.cpp` to the interactive `/login` provider list, accepting an optional API key while defaulting to local no-auth mode.
32
+
33
+ ### Changed
34
+
35
+ - Optimized generated AI tool schemas by collapsing verbose `anyOf` unions into standard `enum` types
36
+
37
+ ### Fixed
38
+
39
+ - Fixed tool-call argument validation dropping nested keys that were accidentally double-encoded
40
+ - Fixed the `moonshot` provider being locked to the international Kimi host (`api.moonshot.ai`): OpenAI-completions requests now honor a `MOONSHOT_BASE_URL` override so users can reach the Kimi China platform (`api.moonshot.cn`), which rejects keys issued for the international endpoint. ([#2883](https://github.com/can1357/oh-my-pi/issues/2883))
41
+ - Fixed tool-call argument validation dropping fields whose object keys were accidentally JSON-encoded a second time (e.g. `{ "\"op\"": "done" }`), which surfaced as spurious missing-required errors. A schema-agnostic pre-validation pass now recursively unwraps such double-encoded keys — through arrays and nested objects, and again after a JSON-string container is parsed — before the unrecognized-key repair can delete them.
42
+
43
+ ### Removed
44
+
45
+ - Removed the `setNextRequestDebugPath`, `clearNextRequestDebugPath`, and `getNextRequestDebugPath` utility functions for request debugging, as request/response recording now relies exclusively on the `PI_REQ_DEBUG` environment variable.
46
+ - Removed Wafer Pass (`wafer-pass`) login support; Wafer Serverless remains available as `wafer-serverless`.
47
+
5
48
  ## [16.1.8] - 2026-06-20
6
49
 
7
50
  ### Changed
package/README.md CHANGED
@@ -62,7 +62,6 @@ Unified LLM API with automatic model discovery, provider configuration, token an
62
62
  - **Hugging Face Inference**
63
63
  - **xAI**
64
64
  - **Venice** (requires `VENICE_API_KEY`)
65
- - **Wafer Pass** (requires `WAFER_PASS_API_KEY`; flat-rate subscription, includes GLM-5.1 and Qwen3.5-397B-A17B)
66
65
  - **Wafer Serverless** (requires `WAFER_SERVERLESS_API_KEY`; pay-as-you-go)
67
66
  - **OpenRouter**
68
67
  - **Kilo Gateway** (supports OAuth `/login kilo` or `KILO_API_KEY`)
@@ -0,0 +1,8 @@
1
+ import type { OAuthController, OAuthLoginCallbacks } from "./oauth/types";
2
+ export declare function loginLlamaCpp(options: OAuthController): Promise<string>;
3
+ export declare const llamaCppProvider: {
4
+ readonly id: "llama.cpp";
5
+ readonly name: "llama.cpp (Local OpenAI-compatible)";
6
+ readonly envKeys: "LLAMA_CPP_API_KEY";
7
+ readonly login: (cb: OAuthLoginCallbacks) => Promise<string>;
8
+ };
@@ -1,2 +1 @@
1
- export declare const loginWaferPass: (options: import("./types").OAuthController) => Promise<string>;
2
1
  export declare const loginWaferServerless: (options: import("./types").OAuthController) => Promise<string>;
@@ -112,6 +112,11 @@ declare const ALL: ({
112
112
  readonly id: "litellm";
113
113
  readonly name: "LiteLLM";
114
114
  readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
115
+ } | {
116
+ readonly id: "llama.cpp";
117
+ readonly name: "llama.cpp (Local OpenAI-compatible)";
118
+ readonly envKeys: "LLAMA_CPP_API_KEY";
119
+ readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
115
120
  } | {
116
121
  readonly id: "lm-studio";
117
122
  readonly name: "LM Studio (Local OpenAI-compatible)";
@@ -226,10 +231,6 @@ declare const ALL: ({
226
231
  readonly id: "vllm";
227
232
  readonly name: "vLLM (Local OpenAI-compatible)";
228
233
  readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
229
- } | {
230
- readonly id: "wafer-pass";
231
- readonly name: "Wafer Pass (flat-rate subscription)";
232
- readonly login: (cb: import("./oauth").OAuthLoginCallbacks) => Promise<string>;
233
234
  } | {
234
235
  readonly id: "wafer-serverless";
235
236
  readonly name: "Wafer Serverless (pay-as-you-go)";
@@ -1,11 +1,22 @@
1
+ /**
2
+ * Lightweight string-level repair of the escape/control-char hazards that make
3
+ * otherwise-valid JSON fail `JSON.parse`: raw control characters inside strings
4
+ * are escaped, and invalid `\x` escapes have their backslash escaped. Returns the
5
+ * input unchanged when no repair is needed. Pure string→string; does not parse.
6
+ */
1
7
  export declare function repairJson(json: string): string;
8
+ /**
9
+ * Final-parse a JSON value, repairing the common LLM malformations
10
+ * ({@link RelaxedJson}). Tries strict `JSON.parse` first (fast path, exact JSON
11
+ * semantics), then the relaxed parser. Throws when the input is unrepairable,
12
+ * truncated, or carries trailing garbage — so callers can skip a bad tool call
13
+ * rather than execute a half-formed one.
14
+ */
2
15
  export declare function parseJsonWithRepair<T>(json: string): T;
3
16
  /**
4
- * Attempts to parse potentially incomplete JSON during streaming.
5
- * Always returns a valid object, even if the JSON is incomplete.
6
- *
7
- * @param partialJson The partial JSON string from streaming
8
- * @returns Parsed object or empty object if parsing fails
17
+ * Parse possibly-incomplete JSON during streaming. Always returns a value, never
18
+ * throws: `{}` for empty/whitespace/unrecoverable buffers, and an auto-closed
19
+ * best-effort object for truncated ones.
9
20
  */
10
21
  export declare function parseStreamingJson<T = Record<string, unknown>>(partialJson: string | undefined): T;
11
22
  /**
@@ -22,9 +22,6 @@ export interface RequestDebugSession {
22
22
  wrapResponse(response: Response): Promise<Response>;
23
23
  }
24
24
  export declare function isRequestDebugEnabled(): boolean;
25
- export declare function setNextRequestDebugPath(requestPath: string): void;
26
- export declare function clearNextRequestDebugPath(): void;
27
- export declare function getNextRequestDebugPath(): string | undefined;
28
25
  export declare function wrapFetchForRequestDebug(fetchImpl: FetchImpl): FetchImpl;
29
26
  export declare function withRequestDebugFetch<T extends {
30
27
  fetch?: FetchImpl;
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.1.8",
4
+ "version": "16.1.10",
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,11 +38,10 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.0",
41
- "@oh-my-pi/pi-catalog": "16.1.8",
42
- "@oh-my-pi/pi-utils": "16.1.8",
43
- "@oh-my-pi/pi-wire": "16.1.8",
41
+ "@oh-my-pi/pi-catalog": "16.1.10",
42
+ "@oh-my-pi/pi-utils": "16.1.10",
43
+ "@oh-my-pi/pi-wire": "16.1.10",
44
44
  "arktype": "^2.2.0",
45
- "partial-json": "^0.1.7",
46
45
  "zod": "^4"
47
46
  },
48
47
  "devDependencies": {
@@ -229,11 +229,10 @@ export function classifyGatewayError(err: unknown): { status: number; type: stri
229
229
  if (/\baborted\b|\babort signal\b/i.test(message)) {
230
230
  return { status: 499, type: "request_aborted", message };
231
231
  }
232
- if (/\b(?:unauthorized|forbidden)\b/i.test(message)) {
233
- return { status: 401, type: "authentication_error", message };
234
- }
235
232
  if (
236
- // Match rate-limit phrasings without colliding with
233
+ // Match rate-limit phrasings before auth wording: some providers
234
+ // describe throttling as "unauthorized due to rate limit".
235
+ // Keep boundaries so this does not collide with
237
236
  // `GenerateContentRequest`, `accelerate`, `iterate`, `deprecated`, etc.
238
237
  /\brate[- _]?limit(?:s|ed|ing)?\b|\bquota(?:_exceeded| exceeded)?\b|\btoo[- _]many[- _]requests\b/i.test(
239
238
  message,
@@ -249,6 +248,9 @@ export function classifyGatewayError(err: unknown): { status: number; type: stri
249
248
  ) {
250
249
  return { status: 429, type: "rate_limit_error", message };
251
250
  }
251
+ if (/\b(?:unauthorized|forbidden)\b/i.test(message)) {
252
+ return { status: 401, type: "authentication_error", message };
253
+ }
252
254
  if (/\b(?:unsupported|invalid_request|invalid request|bad request|malformed)\b/i.test(message)) {
253
255
  return { status: 400, type: "invalid_request_error", message };
254
256
  }
@@ -125,7 +125,7 @@ import type {
125
125
  } from "../types";
126
126
  import { normalizeSystemPrompts } from "../utils";
127
127
  import { AssistantMessageEventStream } from "../utils/event-stream";
128
- import { parseStreamingJson } from "../utils/json-parse";
128
+ import { parseJsonWithRepair, parseStreamingJson } from "../utils/json-parse";
129
129
  import { createRequestDebugSession, isRequestDebugEnabled, type RequestDebugResponseLog } from "../utils/request-debug";
130
130
  import { formatErrorMessageWithRetryAfter } from "../utils/retry-after";
131
131
  import { toolWireSchema } from "../utils/schema/wire";
@@ -1781,13 +1781,10 @@ function parseToolArgsJson(text: string): unknown {
1781
1781
  return text;
1782
1782
  }
1783
1783
  try {
1784
- const normalized = trimmed
1785
- .replace(/\bNone\b/g, "null")
1786
- .replace(/\bTrue\b/g, "true")
1787
- .replace(/\bFalse\b/g, "false");
1788
- return Bun.JSON5.parse(normalized);
1789
- } catch {}
1790
- return text;
1784
+ return parseJsonWithRepair<unknown>(trimmed);
1785
+ } catch {
1786
+ return text;
1787
+ }
1791
1788
  }
1792
1789
 
1793
1790
  function decodeMcpArgValue(value: Uint8Array): unknown {
@@ -205,6 +205,17 @@ function isCodexStreamProgressEvent(event: unknown): boolean {
205
205
  return typeof type === "string" && CODEX_ADDITIONAL_PROGRESS_EVENT_TYPES.has(type);
206
206
  }
207
207
 
208
+ function extractCodexFrameResponseId(frame: Record<string, unknown>): string | undefined {
209
+ const response = (frame as { response?: { id?: unknown } }).response;
210
+ const id = response?.id;
211
+ return typeof id === "string" && id.length > 0 ? id : undefined;
212
+ }
213
+
214
+ function extractCodexFrameSequenceNumber(frame: Record<string, unknown>): number | undefined {
215
+ const raw = (frame as { sequence_number?: unknown }).sequence_number;
216
+ return typeof raw === "number" && Number.isFinite(raw) ? Math.trunc(raw) : undefined;
217
+ }
218
+
208
219
  type CodexWebSocketTimeoutDetails = {
209
220
  lastEventAt: number;
210
221
  lastEventType?: string;
@@ -2403,6 +2414,12 @@ class CodexWebSocketConnection {
2403
2414
  #lastInboundAt = 0;
2404
2415
  /** Wall-clock of the last heartbeat ping we issued; 0 if none yet. */
2405
2416
  #lastPingAt = 0;
2417
+ /**
2418
+ * Most recent `response.id` accepted on this socket, retained across
2419
+ * requests. Lets the next request drop a trailing/duplicate frame from the
2420
+ * previous (cleanly-completed) response that outlived the queue drain.
2421
+ */
2422
+ #lastSeenResponseId?: string;
2406
2423
 
2407
2424
  constructor(url: string, headers: Record<string, string>, options: CodexWebSocketConnectionOptions) {
2408
2425
  this.#url = url;
@@ -2650,6 +2667,11 @@ class CodexWebSocketConnection {
2650
2667
  let lastProgressEventType: string | undefined;
2651
2668
  let lastEventAt = lastProgressAt;
2652
2669
  let lastEventType: string | undefined;
2670
+ // Cross-request frame guard: lock onto this response's id and reject
2671
+ // frames belonging to another response interleaved on the reused socket.
2672
+ let activeResponseId: string | undefined;
2673
+ let lastSequence: number | undefined;
2674
+ const priorResponseId = this.#lastSeenResponseId;
2653
2675
  while (true) {
2654
2676
  let timeoutMs: number | undefined;
2655
2677
  let timeoutReason: string;
@@ -2691,8 +2713,51 @@ class CodexWebSocketConnection {
2691
2713
  if (next === null) {
2692
2714
  throw new CodexWebSocketTransportError(`websocket closed before response completion`);
2693
2715
  }
2694
- sawFirstEvent = true;
2695
2716
  const eventType = typeof next.type === "string" ? next.type : "";
2717
+ // Cross-request frame guard. The socket is reused across turns. Upstream
2718
+ // codex-rs leans on the protocol guarantee that nothing follows a
2719
+ // response's terminal event, but our queue can still surface a trailing
2720
+ // or duplicate frame from a cleanly-completed prior response after
2721
+ // #dropStaleFrames() drained the queue at send time. Attaching such a
2722
+ // frame to THIS turn misattributes an earlier turn's output (a stale
2723
+ // `response.completed` ends the turn early; a stale item makes the model
2724
+ // see an unrelated call). Only lifecycle events (created/completed/
2725
+ // failed/incomplete) carry a `response.id` — exactly the harmful ones —
2726
+ // so key the guard on it and let idless frames (deltas, the rate-limit/
2727
+ // metadata preamble, created-less streams) pass through, matching
2728
+ // upstream rather than gating on `response.created`.
2729
+ const frameResponseId = extractCodexFrameResponseId(next);
2730
+ const frameSequence = extractCodexFrameSequenceNumber(next);
2731
+ if (frameResponseId !== undefined) {
2732
+ if (activeResponseId === undefined) {
2733
+ if (priorResponseId !== undefined && frameResponseId === priorResponseId) {
2734
+ // Trailing/duplicate frame of the previous response that
2735
+ // outlived the drain. Drop without locking or advancing the
2736
+ // first-event clocks so our own response can still start.
2737
+ continue;
2738
+ }
2739
+ activeResponseId = frameResponseId;
2740
+ } else if (frameResponseId !== activeResponseId) {
2741
+ // A different response is interleaving on the socket; the idless
2742
+ // deltas that follow are indistinguishable, so fail closed
2743
+ // (retryable) instead of risking misattribution.
2744
+ this.close("stale-frame");
2745
+ throw new CodexWebSocketTransportError(
2746
+ `websocket frame for response ${frameResponseId} interleaved into active response ${activeResponseId}`,
2747
+ );
2748
+ }
2749
+ this.#lastSeenResponseId = frameResponseId;
2750
+ }
2751
+ if (frameSequence !== undefined) {
2752
+ if (activeResponseId !== undefined && lastSequence !== undefined && frameSequence < lastSequence) {
2753
+ this.close("stale-frame");
2754
+ throw new CodexWebSocketTransportError(
2755
+ `websocket sequence_number ${frameSequence} regressed below ${lastSequence} within response ${activeResponseId}`,
2756
+ );
2757
+ }
2758
+ lastSequence = frameSequence;
2759
+ }
2760
+ sawFirstEvent = true;
2696
2761
  lastEventAt = Date.now();
2697
2762
  lastEventType = eventType || undefined;
2698
2763
  if (isCodexStreamProgressEvent(next)) {
@@ -155,6 +155,16 @@ export function resolveOpenAIRequestSetup(
155
155
 
156
156
  let copilotPremiumRequests: number | undefined;
157
157
  let baseUrl = model.baseUrl;
158
+ if (model.provider === "moonshot") {
159
+ // Bundled `moonshot` catalog models hardcode the international endpoint
160
+ // (`api.moonshot.ai`). MOONSHOT_BASE_URL lets users redirect the provider
161
+ // at the China platform (`api.moonshot.cn`), which only accepts China keys
162
+ // and rejects the international host. (#2883)
163
+ const moonshotBaseUrl = $env.MOONSHOT_BASE_URL?.trim();
164
+ if (moonshotBaseUrl) {
165
+ baseUrl = moonshotBaseUrl;
166
+ }
167
+ }
158
168
  if (model.provider === "github-copilot") {
159
169
  apiKey = parseGitHubCopilotApiKey(rawApiKey).accessToken;
160
170
  const copilot = buildCopilotDynamicHeaders({