@oh-my-pi/pi-ai 17.0.1 → 17.0.2

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,9 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.0.2] - 2026-07-17
6
+
5
7
  ### Fixed
6
8
 
7
- - Automatically invalidate and rotate OAuth credentials when an "invalidated oauth token" error occurs
9
+ - Automatically invalidate and rotate OAuth credentials when an "invalidated oauth token" error occurs.
10
+ - Fixed auth-broker snapshot validation rejecting API keys stored via the `/login` flow, restoring support for gateway/broker setups serving login-sourced keys on custom hosts.
11
+ - Fixed an issue where literal reasoning tags (e.g., `<think>`) inside Markdown code blocks or inline code were incorrectly treated as reasoning boundaries, which corrupted the rendered Markdown.
12
+ - Classified HTTP 402 and "balance exhausted" quota responses as persistent usage limits, enabling automatic rotation of multi-account requests to a sibling credential.
13
+ - Fixed `kimi-code` Anthropic-format requests ignoring custom provider base URLs.
14
+ - Fixed an issue where GPT-5.6 Codex Responses-Lite requests failed with an HTTP 400 error due to invalid `tool_choice` parameters after tools were rewritten, by automatically downgrading forced hosted choices to `tool_choice: "auto"` while preserving explicit tool-use constraints.
15
+ - Fixed Cursor streams prematurely reporting success before late CONNECT or gRPC terminal failures were observed, and resolved issues rejecting transport ends without a `turnEnded` signal.
8
16
 
9
17
  ## [17.0.1] - 2026-07-16
10
18
 
@@ -29,6 +29,7 @@ export declare const remoteOauthCredentialSchema: import("arktype/internal/varia
29
29
  export declare const apiKeyCredentialSchema: import("arktype/internal/variants/object.ts").ObjectType<{
30
30
  type: "api_key";
31
31
  key: string;
32
+ source?: "login" | undefined;
32
33
  }, {}>;
33
34
  /** Discriminated union accepted on POST /v1/credential (writes). */
34
35
  export declare const writableAuthCredentialSchema: import("arktype/internal/variants/object.ts").ObjectType<{
@@ -46,6 +47,7 @@ export declare const writableAuthCredentialSchema: import("arktype/internal/vari
46
47
  } | {
47
48
  type: "api_key";
48
49
  key: string;
50
+ source?: "login" | undefined;
49
51
  }, {}>;
50
52
  /** Discriminated union returned in snapshots (refresh is sentinel for OAuth). */
51
53
  export declare const snapshotCredentialSchema: import("arktype/internal/variants/object.ts").ObjectType<{
@@ -63,6 +65,7 @@ export declare const snapshotCredentialSchema: import("arktype/internal/variants
63
65
  } | {
64
66
  type: "api_key";
65
67
  key: string;
68
+ source?: "login" | undefined;
66
69
  }, {}>;
67
70
  export declare const credentialSnapshotEntrySchema: import("arktype/internal/variants/object.ts").ObjectType<{
68
71
  id: number;
@@ -82,6 +85,7 @@ export declare const credentialSnapshotEntrySchema: import("arktype/internal/var
82
85
  } | {
83
86
  type: "api_key";
84
87
  key: string;
88
+ source?: "login" | undefined;
85
89
  };
86
90
  identityKey: string | null;
87
91
  }, {}>;
@@ -109,6 +113,7 @@ export declare const snapshotEntrySchema: import("arktype/internal/variants/obje
109
113
  } | {
110
114
  type: "api_key";
111
115
  key: string;
116
+ source?: "login" | undefined;
112
117
  };
113
118
  identityKey: string | null;
114
119
  rotatesInMs: number | null;
@@ -153,6 +158,7 @@ export declare const snapshotResponseSchema: import("arktype/internal/variants/o
153
158
  } | {
154
159
  type: "api_key";
155
160
  key: string;
161
+ source?: "login" | undefined;
156
162
  };
157
163
  identityKey: string | null;
158
164
  rotatesInMs: number | null;
@@ -193,6 +199,7 @@ export declare const snapshotStreamSnapshotEventSchema: import("arktype/internal
193
199
  } | {
194
200
  type: "api_key";
195
201
  key: string;
202
+ source?: "login" | undefined;
196
203
  };
197
204
  identityKey: string | null;
198
205
  rotatesInMs: number | null;
@@ -234,6 +241,7 @@ export declare const snapshotStreamEntryEventSchema: import("arktype/internal/va
234
241
  } | {
235
242
  type: "api_key";
236
243
  key: string;
244
+ source?: "login" | undefined;
237
245
  };
238
246
  identityKey: string | null;
239
247
  rotatesInMs: number | null;
@@ -287,6 +295,7 @@ export declare const snapshotStreamEventSchema: import("arktype/internal/variant
287
295
  } | {
288
296
  type: "api_key";
289
297
  key: string;
298
+ source?: "login" | undefined;
290
299
  };
291
300
  identityKey: string | null;
292
301
  rotatesInMs: number | null;
@@ -326,6 +335,7 @@ export declare const snapshotStreamEventSchema: import("arktype/internal/variant
326
335
  } | {
327
336
  type: "api_key";
328
337
  key: string;
338
+ source?: "login" | undefined;
329
339
  };
330
340
  identityKey: string | null;
331
341
  rotatesInMs: number | null;
@@ -427,6 +437,7 @@ export declare const credentialRefreshResponseSchema: import("arktype/internal/v
427
437
  } | {
428
438
  type: "api_key";
429
439
  key: string;
440
+ source?: "login" | undefined;
430
441
  };
431
442
  identityKey: string | null;
432
443
  };
@@ -469,6 +480,7 @@ export declare const credentialUploadRequestSchema: import("arktype/internal/var
469
480
  } | {
470
481
  type: "api_key";
471
482
  key: string;
483
+ source?: "login" | undefined;
472
484
  };
473
485
  }, {}>;
474
486
  export declare const credentialUploadResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
@@ -490,6 +502,7 @@ export declare const credentialUploadResponseSchema: import("arktype/internal/va
490
502
  } | {
491
503
  type: "api_key";
492
504
  key: string;
505
+ source?: "login" | undefined;
493
506
  };
494
507
  identityKey: string | null;
495
508
  }[];
@@ -20,6 +20,9 @@ export declare function calculateRateLimitBackoffMs(reason: RateLimitReason): nu
20
20
  /**
21
21
  * HTTP status codes that, absent richer body classification, represent an
22
22
  * account-local usage cap rather than a bad credential or a transient blip.
23
+ * HTTP 402 Payment Required is categorically an account-billing cap (xAI
24
+ * Grok Build "usage balance exhausted", DeepSeek "Insufficient Balance",
25
+ * OpenRouter credit exhaustion) — never a transient blip or bad credential.
23
26
  * Always combine with {@link isUsageLimitOutcome} when a message is available
24
27
  * — a 429 carrying transient rate-limit wording is NOT a usage cap.
25
28
  */
@@ -31,7 +34,7 @@ export declare function isUsageLimitStatus(status: number | undefined): boolean;
31
34
  * 1. Body matches {@link isUsageLimitError} (Codex `usage_limit_reached`,
32
35
  * Anthropic account rate-limit, Google `resource_exhausted`, OpenAI
33
36
  * `insufficient_quota`, …) → rotate.
34
- * 2. Status is not 429 → backoff (caller's domain).
37
+ * 2. Status is not a usage-limit status (429/402) → backoff (caller's domain).
35
38
  * 3. Body is absent or {@link isOpaqueStatusBody opaque} (just the status,
36
39
  * empty JSON, HTTP framing only) → rotate conservatively: the server
37
40
  * gave us nothing else to go on.
@@ -44,10 +47,11 @@ export declare function isUsageLimitStatus(status: number | undefined): boolean;
44
47
  */
45
48
  export declare function isUsageLimitOutcome(status: number | undefined, message: string | undefined): boolean;
46
49
  /**
47
- * A 429 body is opaque when it carries no signal beyond the status itself —
48
- * empty, whitespace-only, the status digits with HTTP/JSON framing, or
49
- * generic punctuation. Anything else (retry hints, capacity wording, error
50
- * descriptions) is informative enough to defer to the classifier.
50
+ * A usage-limit status body is opaque when it carries no signal beyond the
51
+ * status itself — empty, whitespace-only, the status digits with HTTP/JSON
52
+ * framing, or generic punctuation. Anything else (retry hints, capacity
53
+ * wording, error descriptions) is informative enough to defer to the
54
+ * classifier.
51
55
  */
52
56
  export declare function isOpaqueStatusBody(message: string): boolean;
53
57
  /**
@@ -29,6 +29,8 @@ export interface BlockState {
29
29
  [kStreamingBlockIndex]: number;
30
30
  }) | null;
31
31
  currentToolCall: ToolCallState | null;
32
+ /** MCP call IDs executed through Cursor's exec channel before their stream block arrives. */
33
+ resolvedMcpToolCallIds: Set<string>;
32
34
  firstTokenTime: number | undefined;
33
35
  setTextBlock: (b: (TextContent & {
34
36
  [kStreamingBlockIndex]: number;
@@ -77,6 +77,7 @@ export declare function resolveCodexResponsesLite(model: Model<"openai-codex-res
77
77
  export interface CodexLiteShapedBody {
78
78
  instructions?: unknown;
79
79
  tools?: unknown;
80
+ tool_choice?: unknown;
80
81
  input?: unknown;
81
82
  parallel_tool_calls?: unknown;
82
83
  }
@@ -85,9 +86,14 @@ export interface CodexLiteShapedBody {
85
86
  * `build_responses_request` with `use_responses_lite`): strips pinned image
86
87
  * detail, forces parallel tool calling off, moves tools into a leading
87
88
  * `additional_tools` developer item and the base instructions into a
88
- * developer message, then omits top-level `instructions`/`tools`. Shared by
89
- * normal turns and both remote-compaction paths — codex-rs routes
90
- * `/responses/compact` through the same builder.
89
+ * developer message, then omits top-level `instructions`/`tools`. Because the
90
+ * rewrite removes top-level `tools`, a forced hosted-tool choice (e.g.
91
+ * `{ type: "web_search" }`) would leave the backend unable to validate the
92
+ * choice against a tools collection and it rejects the request with HTTP 400
93
+ * (#5771). Such choices must fall back to `"auto"`; explicit string constraints
94
+ * such as `"none"` and `"required"` remain valid. Shared by normal turns and
95
+ * both remote-compaction paths — codex-rs routes `/responses/compact` through
96
+ * the same builder.
91
97
  */
92
98
  export declare function applyCodexResponsesLiteShape(body: CodexLiteShapedBody): void;
93
99
  export declare function transformRequestBody(body: RequestBody, model: Model<"openai-codex-responses">, options?: CodexRequestOptions, prompt?: {
@@ -297,12 +297,14 @@ export declare function applyChatCompletionsReasoningParams(params: OpenAIComple
297
297
  }) | undefined): void;
298
298
  export declare function disableChatCompletionsReasoningForDialect(params: OpenAICompletionsParams, compat: ResolvedOpenAICompat): void;
299
299
  /**
300
- * Output-token clamp for the Z.AI/GLM-5.2 reasoning dialect: these hosts accept
301
- * the full model window on reasoning turns, so clamp to the model cap. Returns
302
- * `undefined` for every other model, leaving {@link resolveOpenAIOutputTokenParam}
303
- * on its default `OPENAI_MAX_OUTPUT_TOKENS` clamp.
300
+ * Provider-specific Chat Completions output clamp.
301
+ *
302
+ * Most OpenAI-compatible endpoints retain the conservative 64k ceiling from
303
+ * {@link resolveOpenAIOutputTokenParam}. Z.AI/GLM-5.2 reasoning and native
304
+ * Moonshot K3 explicitly accept their full advertised model caps, so those
305
+ * routes clamp to `model.maxTokens` instead.
304
306
  */
305
- export declare function resolveZaiReasoningOutputClamp(model: Model<"openai-completions">, compat: ResolvedOpenAICompat): number | undefined;
307
+ export declare function resolveOpenAICompletionsOutputClamp(model: Model<"openai-completions">, compat: ResolvedOpenAICompat): number | undefined;
306
308
  /**
307
309
  * Enable `tool_stream` for Z.AI/GLM-5.2 reasoning models when tools are present
308
310
  * (GLM-5.2 streams tool-call arguments incrementally and needs the flag to do so).
@@ -1,11 +1,7 @@
1
1
  import type { Api, AssistantMessage, Message, Model } from "../types.js";
2
- /**
3
- * Normalize tool call ID for cross-provider compatibility.
4
- * OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`.
5
- * Anthropic APIs require IDs matching ^[a-zA-Z0-9_-]+$ (max 64 chars).
6
- *
7
- * For aborted/errored turns, this function:
8
- * - Preserves tool call structure (unlike converting to text summaries)
9
- * - Injects synthetic "aborted" tool results
10
- */
2
+ export declare function redactSensitiveCredentials(text: string): string;
3
+ export declare function redactSensitiveInObject(val: unknown): {
4
+ result: unknown;
5
+ changed: boolean;
6
+ };
11
7
  export declare function transformMessages<TApi extends Api>(messages: Message[], model: Model<TApi>, normalizeToolCallId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string, maxNormalizedToolCallIdLength?: number, duplicateToolCallIdSuffixPrefix?: string, targetCompat?: Model<TApi>["compat"]): Message[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "17.0.1",
4
+ "version": "17.0.2",
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,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.1",
41
- "@oh-my-pi/pi-catalog": "17.0.1",
42
- "@oh-my-pi/pi-utils": "17.0.1",
43
- "@oh-my-pi/pi-wire": "17.0.1",
41
+ "@oh-my-pi/pi-catalog": "17.0.2",
42
+ "@oh-my-pi/pi-utils": "17.0.2",
43
+ "@oh-my-pi/pi-wire": "17.0.2",
44
44
  "arktype": "2.2.3",
45
45
  "zod": "^4"
46
46
  },
@@ -55,6 +55,7 @@ export const apiKeyCredentialSchema = type({
55
55
  "+": "reject",
56
56
  type: "'api_key'",
57
57
  key: type("string").atLeastLength(1),
58
+ "source?": "'login'",
58
59
  });
59
60
 
60
61
  /** Discriminated union accepted on POST /v1/credential (writes). */
@@ -32,6 +32,16 @@ export class ThinkingInbandScanner implements InbandScanner {
32
32
  #thinking = "";
33
33
  /** Fence-aware close-matcher while inside a ` ```thinking ` block; undefined otherwise. */
34
34
  #fenced: FencedThinkingScanner | undefined;
35
+ /** Backtick count that opened the Markdown code span/fence we are inside; 0 when not in code. */
36
+ #codeTicks = 0;
37
+ /** True when {@link #codeTicks} opened a fenced block (closes on a fence line), not an inline span. */
38
+ #codeFenced = false;
39
+ /**
40
+ * Leading-space count on the current output line, or -1 once a non-space
41
+ * character has appeared. Starts at 0 (line start) so a fence opening the
42
+ * stream — or one indented ≤3 spaces, as CommonMark allows — is recognized.
43
+ */
44
+ #lineIndent = 0;
35
45
 
36
46
  feed(text: string): InbandScanEvent[] {
37
47
  if (text.length === 0) return [];
@@ -86,25 +96,93 @@ export class ThinkingInbandScanner implements InbandScanner {
86
96
  this.#closeTag = "";
87
97
  continue;
88
98
  }
99
+ if (this.#codeTicks > 0) {
100
+ if (this.#emitCode(final, events)) continue;
101
+ break;
102
+ }
89
103
 
90
- const tag = findEarliestOpen(this.#buffer);
91
- if (!tag) {
92
- const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, OPENS);
93
- const emit = this.#buffer.slice(0, this.#buffer.length - hold);
94
- if (emit.length > 0) events.push({ type: "text", text: emit });
95
- this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
104
+ const hit = scanVisible(this.#buffer, final);
105
+ if (hit.kind === "none") {
106
+ this.#emitText(this.#buffer, events);
107
+ this.#buffer = "";
108
+ break;
109
+ }
110
+ if (hit.index > 0) this.#emitText(this.#buffer.slice(0, hit.index), events);
111
+ if (hit.kind === "hold") {
112
+ this.#buffer = this.#buffer.slice(hit.index);
96
113
  break;
97
114
  }
98
- if (tag.index > 0) events.push({ type: "text", text: this.#buffer.slice(0, tag.index) });
99
- this.#buffer = this.#buffer.slice(tag.index + tag.open.length);
100
- this.#closeTag = tag.close;
115
+ if (hit.kind === "code") {
116
+ const fenced = hit.ticks >= 3 && this.#lineIndent >= 0 && this.#lineIndent <= 3;
117
+ this.#emitText(this.#buffer.slice(hit.index, hit.index + hit.ticks), events);
118
+ this.#buffer = this.#buffer.slice(hit.index + hit.ticks);
119
+ this.#codeTicks = hit.ticks;
120
+ this.#codeFenced = fenced;
121
+ continue;
122
+ }
123
+ this.#buffer = this.#buffer.slice(hit.index + hit.tag.open.length);
124
+ this.#closeTag = hit.tag.close;
101
125
  this.#thinking = "";
102
- if (tag.fenced) this.#fenced = new FencedThinkingScanner();
126
+ if (hit.tag.fenced) this.#fenced = new FencedThinkingScanner();
103
127
  events.push({ type: "thinkingStart" });
104
128
  }
105
129
  return events;
106
130
  }
107
131
 
132
+ /**
133
+ * Emit buffered content while inside a Markdown code region, suppressing
134
+ * reasoning-tag detection. A fenced block closes only on a fence line (a line
135
+ * of backticks ≥ the opener); an inline span closes on the first backtick run
136
+ * of exactly the opener length. Returns true when the region closed and the
137
+ * loop should continue, false when it held back and should break.
138
+ */
139
+ #emitCode(final: boolean, events: InbandScanEvent[]): boolean {
140
+ if (this.#codeFenced) {
141
+ const end = findFenceCloseEnd(this.#buffer, this.#codeTicks, final);
142
+ if (end !== -1) {
143
+ this.#emitText(this.#buffer.slice(0, end), events);
144
+ this.#buffer = this.#buffer.slice(end);
145
+ this.#codeTicks = 0;
146
+ this.#codeFenced = false;
147
+ return true;
148
+ }
149
+ if (final) {
150
+ this.#emitText(this.#buffer, events);
151
+ this.#buffer = "";
152
+ this.#codeTicks = 0;
153
+ this.#codeFenced = false;
154
+ return false;
155
+ }
156
+ // Stream committed lines; hold only the last (possibly partial) fence line.
157
+ const lastNl = this.#buffer.lastIndexOf("\n");
158
+ if (lastNl !== -1) {
159
+ this.#emitText(this.#buffer.slice(0, lastNl + 1), events);
160
+ this.#buffer = this.#buffer.slice(lastNl + 1);
161
+ }
162
+ return false;
163
+ }
164
+ const close = findBacktickRun(this.#buffer, 0, this.#codeTicks);
165
+ if (close !== -1 && (final || close + this.#codeTicks < this.#buffer.length)) {
166
+ this.#emitText(this.#buffer.slice(0, close + this.#codeTicks), events);
167
+ this.#buffer = this.#buffer.slice(close + this.#codeTicks);
168
+ this.#codeTicks = 0;
169
+ return true;
170
+ }
171
+ // No committed close yet: emit text, holding a trailing backtick run that
172
+ // may still grow into — or past — the closing delimiter.
173
+ const hold = final ? 0 : trailingBacktickRun(this.#buffer);
174
+ this.#emitText(this.#buffer.slice(0, this.#buffer.length - hold), events);
175
+ this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
176
+ if (final) this.#codeTicks = 0;
177
+ return false;
178
+ }
179
+
180
+ #emitText(text: string, events: InbandScanEvent[]): void {
181
+ if (text.length === 0) return;
182
+ events.push({ type: "text", text });
183
+ this.#lineIndent = trailingLineIndent(text, this.#lineIndent);
184
+ }
185
+
108
186
  #emitThinking(delta: string, events: InbandScanEvent[]): void {
109
187
  if (delta.length === 0) return;
110
188
  this.#thinking += delta;
@@ -112,11 +190,103 @@ export class ThinkingInbandScanner implements InbandScanner {
112
190
  }
113
191
  }
114
192
 
115
- function findEarliestOpen(buffer: string): (Tag & { index: number }) | undefined {
116
- let best: (Tag & { index: number }) | undefined;
117
- for (const tag of TAGS) {
118
- const index = buffer.indexOf(tag.open);
119
- if (index !== -1 && (!best || index < best.index)) best = { ...tag, index };
193
+ /** Outcome of scanning idle visible text for the next reasoning-tag or code-span boundary. */
194
+ type VisibleHit =
195
+ | { readonly kind: "tag"; readonly index: number; readonly tag: Tag }
196
+ | { readonly kind: "code"; readonly index: number; readonly ticks: number }
197
+ | { readonly kind: "hold"; readonly index: number }
198
+ | { readonly kind: "none" };
199
+
200
+ /**
201
+ * Walk idle visible text for the earliest boundary: a leaked reasoning-tag open,
202
+ * a Markdown code-span/fence opener (a backtick run), or — when more chunks may
203
+ * follow — a held partial delimiter at the buffer tail.
204
+ *
205
+ * Reasoning tags win at any position so the gemini ` ```thinking ` fence is
206
+ * healed instead of being read as a code fence. Backtick runs enter code mode so
207
+ * a literal `<think>` inside inline code or a fenced block stays visible text.
208
+ */
209
+ function scanVisible(buffer: string, final: boolean): VisibleHit {
210
+ for (let i = 0; i < buffer.length; i++) {
211
+ const tag = TAGS.find(candidate => buffer.startsWith(candidate.open, i));
212
+ if (tag) return { kind: "tag", index: i, tag };
213
+ if (!final) {
214
+ const rest = buffer.slice(i);
215
+ if (OPENS.some(open => open.length > rest.length && open.startsWith(rest))) {
216
+ return { kind: "hold", index: i };
217
+ }
218
+ }
219
+ if (buffer[i] === "`") {
220
+ const ticks = backtickRun(buffer, i);
221
+ if (!final && i + ticks === buffer.length) return { kind: "hold", index: i };
222
+ return { kind: "code", index: i, ticks };
223
+ }
224
+ }
225
+ return { kind: "none" };
226
+ }
227
+
228
+ /** Length of the maximal backtick run beginning at `from`. */
229
+ function backtickRun(buffer: string, from: number): number {
230
+ let end = from;
231
+ while (end < buffer.length && buffer[end] === "`") end++;
232
+ return end - from;
233
+ }
234
+
235
+ /** Index of the first maximal backtick run of exactly `ticks` at/after `from`, else -1. */
236
+ function findBacktickRun(buffer: string, from: number, ticks: number): number {
237
+ for (let i = buffer.indexOf("`", from); i !== -1; i = buffer.indexOf("`", i)) {
238
+ const run = backtickRun(buffer, i);
239
+ if (run === ticks) return i;
240
+ i += run;
120
241
  }
121
- return best;
242
+ return -1;
243
+ }
244
+
245
+ /** Length of a backtick run that ends at the buffer tail; 0 when the tail is not a backtick. */
246
+ function trailingBacktickRun(buffer: string): number {
247
+ let start = buffer.length;
248
+ while (start > 0 && buffer[start - 1] === "`") start--;
249
+ return buffer.length - start;
250
+ }
251
+
252
+ /**
253
+ * Leading-space count of the line at the tail of `text`, continuing from the
254
+ * prior line's `indent` state (see {@link ThinkingInbandScanner.#lineIndent}).
255
+ * Returns -1 once any non-space character has appeared on the current line.
256
+ */
257
+ function trailingLineIndent(text: string, prior: number): number {
258
+ const lastNl = text.lastIndexOf("\n");
259
+ let indent = lastNl === -1 ? prior : 0;
260
+ for (let i = lastNl + 1; i < text.length; i++) {
261
+ if (indent === -1) break;
262
+ indent = text[i] === " " ? indent + 1 : -1;
263
+ }
264
+ return indent;
265
+ }
266
+
267
+ /**
268
+ * Index just past the first closing fence line for a fenced block opened with
269
+ * `ticks` backticks, or -1 when none is committed yet. A closing fence is a whole
270
+ * line whose trimmed content is only backticks, at least `ticks` of them. A line
271
+ * without a terminating newline is committed only when `final` (no more input can
272
+ * extend it into a non-fence line).
273
+ */
274
+ function findFenceCloseEnd(buffer: string, ticks: number, final: boolean): number {
275
+ for (let start = 0; start <= buffer.length; ) {
276
+ const nl = buffer.indexOf("\n", start);
277
+ const terminated = nl !== -1;
278
+ const line = buffer.slice(start, terminated ? nl : buffer.length).trim();
279
+ if (line.length >= ticks && isAllBackticks(line) && (terminated || final)) {
280
+ return terminated ? nl + 1 : buffer.length;
281
+ }
282
+ if (!terminated) break;
283
+ start = nl + 1;
284
+ }
285
+ return -1;
286
+ }
287
+
288
+ /** True when `text` is non-empty and every character is a backtick. */
289
+ function isAllBackticks(text: string): boolean {
290
+ for (let i = 0; i < text.length; i++) if (text[i] !== "`") return false;
291
+ return text.length > 0;
122
292
  }
@@ -7,7 +7,7 @@ import {
7
7
  ProviderHttpError,
8
8
  STREAM_ENVELOPE_ERROR_PREFIX,
9
9
  } from "./classes";
10
- import { isOpaqueStatusBody, matchesUsageLimitText, parseRateLimitReason } from "./rate-limit";
10
+ import { isOpaqueStatusBody, isUsageLimitStatus, matchesUsageLimitText, parseRateLimitReason } from "./rate-limit";
11
11
 
12
12
  export const Flag = {
13
13
  Class: 0x1000,
@@ -318,7 +318,7 @@ function classifyText(errorMessage: string | undefined, errorStatus: number | un
318
318
  const cleanMessage = errorMessage;
319
319
  const isOpaque = isOpaqueStatusBody(cleanMessage);
320
320
 
321
- const isLimitStatus = statusClean === 429;
321
+ const isLimitStatus = isUsageLimitStatus(statusClean);
322
322
  if (
323
323
  matchesUsageLimitText(cleanMessage) ||
324
324
  (isLimitStatus && (isOpaque || parseRateLimitReason(cleanMessage) === "QUOTA_EXHAUSTED"))
@@ -116,16 +116,19 @@ export function calculateRateLimitBackoffMs(reason: RateLimitReason): number {
116
116
 
117
117
  /** Detect usage/quota limit errors in error messages (persistent, requires credential switch). */
118
118
  const USAGE_LIMIT_PATTERN =
119
- /usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?(?:exceeded|reached|insufficient)|额度不足|额度耗尽|resource.?exhausted|exhausted your capacity|quota will reset|insufficient.?(?:balance|quota)|run out of credits|out of credits|spending[- _]?limit|personal-team-blocked/i;
119
+ /usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?(?:exceeded|reached|insufficient)|额度不足|额度耗尽|resource.?exhausted|exhausted your capacity|quota will reset|insufficient.?(?:balance|quota)|balance.?exhausted|run out of credits|out of credits|spending[- _]?limit|personal-team-blocked/i;
120
120
 
121
121
  /**
122
122
  * HTTP status codes that, absent richer body classification, represent an
123
123
  * account-local usage cap rather than a bad credential or a transient blip.
124
+ * HTTP 402 Payment Required is categorically an account-billing cap (xAI
125
+ * Grok Build "usage balance exhausted", DeepSeek "Insufficient Balance",
126
+ * OpenRouter credit exhaustion) — never a transient blip or bad credential.
124
127
  * Always combine with {@link isUsageLimitOutcome} when a message is available
125
128
  * — a 429 carrying transient rate-limit wording is NOT a usage cap.
126
129
  */
127
130
  export function isUsageLimitStatus(status: number | undefined): boolean {
128
- return status === 429;
131
+ return status === 429 || status === 402;
129
132
  }
130
133
 
131
134
  /**
@@ -135,7 +138,7 @@ export function isUsageLimitStatus(status: number | undefined): boolean {
135
138
  * 1. Body matches {@link isUsageLimitError} (Codex `usage_limit_reached`,
136
139
  * Anthropic account rate-limit, Google `resource_exhausted`, OpenAI
137
140
  * `insufficient_quota`, …) → rotate.
138
- * 2. Status is not 429 → backoff (caller's domain).
141
+ * 2. Status is not a usage-limit status (429/402) → backoff (caller's domain).
139
142
  * 3. Body is absent or {@link isOpaqueStatusBody opaque} (just the status,
140
143
  * empty JSON, HTTP framing only) → rotate conservatively: the server
141
144
  * gave us nothing else to go on.
@@ -154,14 +157,15 @@ export function isUsageLimitOutcome(status: number | undefined, message: string
154
157
  }
155
158
 
156
159
  /**
157
- * A 429 body is opaque when it carries no signal beyond the status itself —
158
- * empty, whitespace-only, the status digits with HTTP/JSON framing, or
159
- * generic punctuation. Anything else (retry hints, capacity wording, error
160
- * descriptions) is informative enough to defer to the classifier.
160
+ * A usage-limit status body is opaque when it carries no signal beyond the
161
+ * status itself — empty, whitespace-only, the status digits with HTTP/JSON
162
+ * framing, or generic punctuation. Anything else (retry hints, capacity
163
+ * wording, error descriptions) is informative enough to defer to the
164
+ * classifier.
161
165
  */
162
166
  export function isOpaqueStatusBody(message: string): boolean {
163
167
  const cleaned = message
164
- .replace(/\b429\b/g, "")
168
+ .replace(/\b(?:429|402)\b/g, "")
165
169
  .replace(/\b(?:http|https|status|error|code|response|message)\b/gi, "");
166
170
  return !/[a-z\d]{3,}/i.test(cleaned);
167
171
  }
@@ -1,7 +1,9 @@
1
- import { describe, expect, it } from "bun:test";
1
+ import { afterEach, describe, expect, it, vi } from "bun:test";
2
2
  import { getBundledModel } from "@oh-my-pi/pi-catalog";
3
+ import * as kimiOauth from "../../registry/oauth/kimi";
3
4
  import type { Context } from "../../types";
4
5
  import type { MessageCreateParamsStreaming } from "../anthropic-wire";
6
+ import { streamKimi } from "../kimi";
5
7
  import { streamOpenAIAnthropicShim } from "../openai-anthropic-shim";
6
8
  import {
7
9
  applyChatCompletionsCompatPolicy,
@@ -10,6 +12,15 @@ import {
10
12
  } from "../openai-shared";
11
13
 
12
14
  const BASE_CHAT_COMPLETIONS_PARAMS: OpenAICompletionsParams = { messages: [], model: "unused", stream: true };
15
+ const KIMI_HEADERS = Object.freeze({
16
+ "User-Agent": "KimiCLI/test",
17
+ "X-Msh-Platform": "kimi_cli",
18
+ "X-Msh-Version": "test",
19
+ "X-Msh-Device-Name": "test",
20
+ "X-Msh-Device-Model": "test",
21
+ "X-Msh-Os-Version": "test",
22
+ "X-Msh-Device-Id": "test",
23
+ });
13
24
  const TITLE_CONTEXT: Context = {
14
25
  systemPrompt: ["Generate a title."],
15
26
  messages: [{ role: "user", content: "Explain the login failure", timestamp: 0 }],
@@ -27,8 +38,12 @@ const TITLE_CONTEXT: Context = {
27
38
  ],
28
39
  };
29
40
 
41
+ afterEach(() => {
42
+ vi.restoreAllMocks();
43
+ });
44
+
30
45
  describe("Kimi K2.7 Code thinking policy", () => {
31
- it("omits disabled thinking for title-generator-style Kimi Code requests", () => {
46
+ it("expresses disabled thinking explicitly for title-generator-style Kimi Code requests", () => {
32
47
  const model = getBundledModel<"openai-completions">("kimi-code", "kimi-for-coding");
33
48
  const policy = resolveOpenAICompatPolicy(model, {
34
49
  endpoint: "chat-completions",
@@ -39,11 +54,16 @@ describe("Kimi K2.7 Code thinking policy", () => {
39
54
 
40
55
  applyChatCompletionsCompatPolicy(params, policy);
41
56
 
42
- expect("thinking" in params).toBe(false);
43
- expect(model.compat.supportsForcedToolChoice).toBe(false);
57
+ // Kimi's native hosts speak the z.ai binary thinking field: a disabled
58
+ // request carries `{ type: "disabled" }` rather than omitting the block.
59
+ expect((params as Record<string, unknown>).thinking).toEqual({ type: "disabled" });
60
+ // Thinking yields to a forced tool choice (#5758 review): the choice is
61
+ // honored and reasoning is turned off, instead of downgrading the choice.
62
+ expect(model.compat.supportsForcedToolChoice).toBe(true);
63
+ expect(model.compat.disableReasoningOnForcedToolChoice).toBe(true);
44
64
  });
45
65
 
46
- it("enables thinking and downgrades forced tool choice on Kimi Code's Anthropic endpoint", async () => {
66
+ it("keeps the forced tool choice and omits thinking on Kimi Code's Anthropic endpoint", async () => {
47
67
  const model = getBundledModel<"openai-completions">("kimi-code", "kimi-for-coding");
48
68
  let payload: MessageCreateParamsStreaming | undefined;
49
69
  const stream = streamOpenAIAnthropicShim(
@@ -67,8 +87,43 @@ describe("Kimi K2.7 Code thinking policy", () => {
67
87
 
68
88
  await stream.result();
69
89
 
70
- expect(payload?.thinking?.type).toBe("enabled");
71
- expect(payload?.tool_choice).toEqual({ type: "auto" });
90
+ // With reasoning disabled the Anthropic wire carries no thinking block,
91
+ // and the forced tool choice survives (thinking yields to the choice).
92
+ expect(payload?.thinking).toBeUndefined();
93
+ expect(payload?.tool_choice).toEqual({ type: "tool", name: "set_title" });
94
+ });
95
+
96
+ it("uses the configured Kimi base URL for Anthropic requests", async () => {
97
+ vi.spyOn(kimiOauth, "getKimiCommonHeaders").mockReturnValue(KIMI_HEADERS);
98
+ const bundledModel = getBundledModel<"openai-completions">("kimi-code", "kimi-for-coding");
99
+ const model = { ...bundledModel, baseUrl: "https://gateway.example.com/v1" };
100
+ let requestedUrl: string | undefined;
101
+ const stream = streamKimi(
102
+ model,
103
+ {
104
+ systemPrompt: [],
105
+ messages: [{ role: "user", content: "Reply OK", timestamp: 0 }],
106
+ tools: [],
107
+ },
108
+ {
109
+ format: "anthropic",
110
+ apiKey: "gateway-key",
111
+ fetch: async input => {
112
+ requestedUrl = String(input);
113
+ return new Response(
114
+ JSON.stringify({
115
+ type: "error",
116
+ error: { type: "authentication_error", message: "stop after URL capture" },
117
+ }),
118
+ { status: 401, headers: { "content-type": "application/json" } },
119
+ );
120
+ },
121
+ },
122
+ );
123
+
124
+ await stream.result();
125
+
126
+ expect(requestedUrl).toBe("https://gateway.example.com/v1/messages");
72
127
  });
73
128
 
74
129
  it("omits disabled thinking for native Moonshot Kimi K2.7 Code variants", () => {
@@ -352,7 +352,30 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
352
352
  let heartbeatTimer: NodeJS.Timeout | null = null;
353
353
  let debugResponseLogPromise: Promise<RequestDebugResponseLog | undefined> | undefined;
354
354
  const h2Completion = Promise.withResolvers<void>();
355
- let resolveH2: (() => void) | undefined = h2Completion.resolve;
355
+ let h2Settled = false;
356
+ let sawTurnEnded = false;
357
+ let endStreamError: Error | null = null;
358
+ const settleH2 = (error?: unknown): void => {
359
+ if (h2Settled) return;
360
+ h2Settled = true;
361
+ if (error !== undefined) {
362
+ h2Completion.reject(error);
363
+ return;
364
+ }
365
+ if (endStreamError) {
366
+ h2Completion.reject(endStreamError);
367
+ return;
368
+ }
369
+ if (!sawTurnEnded) {
370
+ h2Completion.reject(
371
+ new AIError.ProviderResponseError("Cursor stream ended before turnEnded", {
372
+ kind: "incomplete-stream",
373
+ }),
374
+ );
375
+ return;
376
+ }
377
+ h2Completion.resolve();
378
+ };
356
379
 
357
380
  try {
358
381
  const apiKey = options?.apiKey;
@@ -408,17 +431,17 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
408
431
  } else {
409
432
  h2Client = http2.connect(baseUrl);
410
433
  }
411
- h2Client.on("error", h2Completion.reject);
434
+ h2Client.on("error", error => settleH2(error));
412
435
 
413
436
  h2Request = h2Client.request(requestHeaders);
414
437
 
415
438
  stream.push({ type: "start", partial: output });
416
439
 
417
440
  let pendingBuffer = Buffer.alloc(0);
418
- let endStreamError: Error | null = null;
419
441
  let currentTextBlock: (TextContent & { [kStreamingBlockIndex]: number }) | null = null;
420
442
  let currentThinkingBlock: (ThinkingContent & { [kStreamingBlockIndex]: number }) | null = null;
421
443
  let currentToolCall: ToolCallState | null = null;
444
+ const resolvedMcpToolCallIds = new Set<string>();
422
445
  const usageState: UsageState = { sawTokenDelta: false };
423
446
 
424
447
  const state: BlockState = {
@@ -431,6 +454,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
431
454
  get currentToolCall() {
432
455
  return currentToolCall;
433
456
  },
457
+ resolvedMcpToolCallIds,
434
458
  get firstTokenTime() {
435
459
  return firstTokenTime;
436
460
  },
@@ -505,12 +529,9 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
505
529
  log("error", "handleServerMessage", { error: String(error) });
506
530
  });
507
531
 
508
- // Resolve only on explicit turnEnded. stopReason defaults to "stop"
509
- // and is not a reliable signal for stream completion.
510
- if (isTurnEnded && resolveH2) {
511
- const r = resolveH2;
512
- resolveH2 = undefined;
513
- r();
532
+ // Application completion is not protocol success; wait for a clean HTTP/2 end.
533
+ if (isTurnEnded) {
534
+ sawTurnEnded = true;
514
535
  }
515
536
  } catch (e) {
516
537
  log("error", "parseServerMessage", { error: String(e) });
@@ -537,40 +558,29 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
537
558
  h2Request.on("trailers", trailers => {
538
559
  const status = trailers["grpc-status"];
539
560
  const msg = trailers["grpc-message"];
540
- if (status && status !== "0") {
541
- void closeDebugLog().finally(() => {
542
- h2Completion.reject(
543
- new AIError.ProviderResponseError(
544
- `gRPC error ${status}: ${decodeURIComponent(String(msg || ""))}`,
545
- { kind: "envelope" },
546
- ),
547
- );
548
- });
561
+ if (status && status !== "0" && !endStreamError) {
562
+ endStreamError = new AIError.ProviderResponseError(
563
+ `gRPC error ${status}: ${decodeURIComponent(String(msg || ""))}`,
564
+ { kind: "envelope" },
565
+ );
549
566
  }
550
567
  });
551
568
 
552
569
  h2Request.on("end", () => {
553
- resolveH2 = undefined;
554
570
  void closeDebugLog()
555
- .then(() => {
556
- if (endStreamError) {
557
- h2Completion.reject(endStreamError);
558
- return;
559
- }
560
- h2Completion.resolve();
561
- })
562
- .catch(h2Completion.reject);
571
+ .then(() => settleH2())
572
+ .catch(error => settleH2(error));
563
573
  });
564
574
 
565
575
  h2Request.on("error", error => {
566
- void closeDebugLog().finally(() => h2Completion.reject(error));
576
+ void closeDebugLog().finally(() => settleH2(error));
567
577
  });
568
578
 
569
579
  if (options?.signal) {
570
580
  options.signal.addEventListener("abort", () => {
571
581
  h2Request?.close();
572
582
  void closeDebugLog().finally(() => {
573
- h2Completion.reject(new AIError.AbortError());
583
+ settleH2(new AIError.AbortError());
574
584
  });
575
585
  });
576
586
  }
@@ -640,6 +650,8 @@ export interface BlockState {
640
650
  currentTextBlock: (TextContent & { [kStreamingBlockIndex]: number }) | null;
641
651
  currentThinkingBlock: (ThinkingContent & { [kStreamingBlockIndex]: number }) | null;
642
652
  currentToolCall: ToolCallState | null;
653
+ /** MCP call IDs executed through Cursor's exec channel before their stream block arrives. */
654
+ resolvedMcpToolCallIds: Set<string>;
643
655
  firstTokenTime: number | undefined;
644
656
  setTextBlock: (b: (TextContent & { [kStreamingBlockIndex]: number }) | null) => void;
645
657
  setThinkingBlock: (b: (ThinkingContent & { [kStreamingBlockIndex]: number }) | null) => void;
@@ -1292,6 +1304,13 @@ async function handleExecServerMessage(
1292
1304
  case "mcpArgs": {
1293
1305
  const args = execMsg.message.value;
1294
1306
  const mcpCall = decodeMcpCall(args);
1307
+ if (execHandlers?.mcp) {
1308
+ if (state.currentToolCall?.id === mcpCall.toolCallId) {
1309
+ state.currentToolCall[kCursorExecResolved] = true;
1310
+ } else {
1311
+ state.resolvedMcpToolCallIds.add(mcpCall.toolCallId);
1312
+ }
1313
+ }
1295
1314
  const { execResult } = await resolveExecHandler(
1296
1315
  mcpCall,
1297
1316
  execHandlers?.mcp?.bind(execHandlers),
@@ -2217,15 +2236,19 @@ export function processInteractionUpdate(
2217
2236
  const mcpCall = toolCall.mcpToolCall;
2218
2237
  if (mcpCall) {
2219
2238
  const args = mcpCall.args || {};
2239
+ const id = args.toolCallId || crypto.randomUUID();
2220
2240
  const block: ToolCallState = {
2221
2241
  type: "toolCall",
2222
- id: args.toolCallId || crypto.randomUUID(),
2242
+ id,
2223
2243
  name: args.name || args.toolName || "",
2224
2244
  arguments: {},
2225
2245
  [kStreamingBlockIndex]: output.content.length,
2226
2246
  [kStreamingPartialJson]: "",
2227
2247
  [kStreamingBlockKind]: "mcp",
2228
2248
  };
2249
+ if (state.resolvedMcpToolCallIds.delete(id)) {
2250
+ block[kCursorExecResolved] = true;
2251
+ }
2229
2252
  output.content.push(block);
2230
2253
  state.setToolCall(block);
2231
2254
  stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
@@ -24,6 +24,7 @@ import { normalizeSystemPrompts } from "../utils";
24
24
  import { AssistantMessageEventStream } from "../utils/event-stream";
25
25
  import { toolWireSchema } from "../utils/schema/wire";
26
26
  import chatmlHistoryNote from "./gitlab-duo-workflow-chatml-note.md" with { type: "text" };
27
+ import { redactSensitiveCredentials } from "./transform-messages";
27
28
 
28
29
  export const GITLAB_DUO_WORKFLOW_PROVIDER_ID = "gitlab-duo-agent";
29
30
  export const GITLAB_DUO_WORKFLOW_API = "gitlab-duo-agent";
@@ -2581,10 +2582,13 @@ function isGitLabDuoWorkflowChatMlGoal(context: Context): boolean {
2581
2582
  // conversation sequences the way `Human:`/`Assistant:` are.
2582
2583
  function buildGitLabDuoWorkflowGoal(context: Context): string {
2583
2584
  const conversation = buildGitLabDuoWorkflowConversationHistory(context.messages);
2585
+ // The goal transcript bypasses transformMessages, so apply the outbound
2586
+ // credential redaction here — the same scrub the flow-config system slot
2587
+ // already receives — before the payload leaves the process.
2584
2588
  if (conversation.length <= 1) {
2585
- return extractLatestUserPrompt(context.messages);
2589
+ return redactSensitiveCredentials(extractLatestUserPrompt(context.messages));
2586
2590
  }
2587
- return renderGitLabDuoWorkflowChatMl(conversation);
2591
+ return redactSensitiveCredentials(renderGitLabDuoWorkflowChatMl(conversation));
2588
2592
  }
2589
2593
 
2590
2594
  const GITLAB_DUO_WORKFLOW_CHATML_START = "<|im_start|>";
@@ -20,9 +20,6 @@ import {
20
20
 
21
21
  export type KimiApiFormat = OpenAIAnthropicApiFormat;
22
22
 
23
- // Note: Anthropic SDK appends /v1/messages, so base URL should not include /v1
24
- const KIMI_ANTHROPIC_BASE_URL = "https://api.kimi.com/coding";
25
-
26
23
  export interface KimiOptions extends OpenAIAnthropicShimOptions {
27
24
  /** API format: "openai" or "anthropic". Default: "anthropic" */
28
25
  format?: KimiApiFormat;
@@ -38,7 +35,7 @@ export function streamKimi(
38
35
  options?: KimiOptions,
39
36
  ): AssistantMessageEventStream {
40
37
  return streamOpenAIAnthropicShim(model, context, options, {
41
- anthropicBaseUrl: KIMI_ANTHROPIC_BASE_URL,
38
+ anthropicBaseUrl: model.baseUrl.replace(/\/v1\/?$/, ""),
42
39
  defaultFormat: "anthropic",
43
40
  extraHeaders: getKimiCommonHeaders,
44
41
  });
@@ -269,6 +269,7 @@ function stripImageDetails(input: unknown[]): void {
269
269
  export interface CodexLiteShapedBody {
270
270
  instructions?: unknown;
271
271
  tools?: unknown;
272
+ tool_choice?: unknown;
272
273
  input?: unknown;
273
274
  parallel_tool_calls?: unknown;
274
275
  }
@@ -278,9 +279,14 @@ export interface CodexLiteShapedBody {
278
279
  * `build_responses_request` with `use_responses_lite`): strips pinned image
279
280
  * detail, forces parallel tool calling off, moves tools into a leading
280
281
  * `additional_tools` developer item and the base instructions into a
281
- * developer message, then omits top-level `instructions`/`tools`. Shared by
282
- * normal turns and both remote-compaction paths — codex-rs routes
283
- * `/responses/compact` through the same builder.
282
+ * developer message, then omits top-level `instructions`/`tools`. Because the
283
+ * rewrite removes top-level `tools`, a forced hosted-tool choice (e.g.
284
+ * `{ type: "web_search" }`) would leave the backend unable to validate the
285
+ * choice against a tools collection and it rejects the request with HTTP 400
286
+ * (#5771). Such choices must fall back to `"auto"`; explicit string constraints
287
+ * such as `"none"` and `"required"` remain valid. Shared by normal turns and
288
+ * both remote-compaction paths — codex-rs routes `/responses/compact` through
289
+ * the same builder.
284
290
  */
285
291
  export function applyCodexResponsesLiteShape(body: CodexLiteShapedBody): void {
286
292
  const input = Array.isArray(body.input) ? body.input : [];
@@ -297,6 +303,9 @@ export function applyCodexResponsesLiteShape(body: CodexLiteShapedBody): void {
297
303
  });
298
304
  }
299
305
  body.input = [...prefix, ...input];
306
+ if (body.tool_choice !== "none" && body.tool_choice !== "required") {
307
+ body.tool_choice = "auto";
308
+ }
300
309
  delete body.instructions;
301
310
  delete body.tools;
302
311
  }
@@ -112,7 +112,7 @@ import {
112
112
  promoteResponsesToolUseStopReason,
113
113
  type SequentialCutoffSummaryState,
114
114
  } from "./openai-shared";
115
- import { transformMessages } from "./transform-messages";
115
+ import { redactSensitiveInObject, transformMessages } from "./transform-messages";
116
116
 
117
117
  export interface OpenAICodexResponsesOptions extends StreamOptions {
118
118
  reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
@@ -3954,13 +3954,14 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
3954
3954
  | Array<ResponseInput[number]>
3955
3955
  | undefined;
3956
3956
  if (historyItems) {
3957
- for (const item of historyItems) {
3957
+ const redactedHistoryItems = redactSensitiveInObject(historyItems).result as Array<ResponseInput[number]>;
3958
+ for (const item of redactedHistoryItems) {
3958
3959
  const maybe = item as { type?: string; call_id?: string };
3959
3960
  if (maybe.type === "custom_tool_call" && typeof maybe.call_id === "string") {
3960
3961
  customCallIds.add(maybe.call_id);
3961
3962
  }
3962
3963
  }
3963
- messages.push(...historyItems);
3964
+ messages.push(...redactedHistoryItems);
3964
3965
  msgIndex += 1;
3965
3966
  continue;
3966
3967
  }
@@ -94,9 +94,9 @@ import {
94
94
  type OpenAIStrictToolsState,
95
95
  parseAzureDeploymentNameMap,
96
96
  resolveOpenAICompatPolicy,
97
+ resolveOpenAICompletionsOutputClamp,
97
98
  resolveOpenAIOutputTokenParam,
98
99
  resolveOpenAIRequestSetup,
99
- resolveZaiReasoningOutputClamp,
100
100
  shouldRetryWithoutStrictTools,
101
101
  } from "./openai-shared";
102
102
  import { transformMessages } from "./transform-messages";
@@ -1571,7 +1571,7 @@ function buildParams(
1571
1571
  omitMaxOutputTokens: model.omitMaxOutputTokens ?? false,
1572
1572
  isOpenRouterHost: compat.isOpenRouterHost,
1573
1573
  alwaysSendMaxTokens: compat.alwaysSendMaxTokens,
1574
- providerOutputClamp: resolveZaiReasoningOutputClamp(model, compat),
1574
+ providerOutputClamp: resolveOpenAICompletionsOutputClamp(model, compat),
1575
1575
  });
1576
1576
  if (outputToken) {
1577
1577
  if (outputToken.field === "max_tokens") {
@@ -594,7 +594,11 @@ const streamOpenAIResponsesOnce = (
594
594
  error instanceof Error &&
595
595
  /previous[ _]?response/i.test(error.message) &&
596
596
  /zero[ _-]?data[ _-]?retention/i.test(error.message);
597
- if (!zdrRejection && !isOpenAIResponsesStalePreviousResponseError(error)) {
597
+ const isPromptBlocked =
598
+ error instanceof Error &&
599
+ ((error as { code?: string }).code === "invalid_prompt" ||
600
+ /invalid_prompt|Request blocked/i.test(error.message));
601
+ if (!zdrRejection && !isPromptBlocked && !isOpenAIResponsesStalePreviousResponseError(error)) {
598
602
  throw error;
599
603
  }
600
604
  // Server rejected the chain baseline: reset, count the failure (or
@@ -1,6 +1,6 @@
1
1
  import type { Effort } from "@oh-my-pi/pi-catalog/effort";
2
2
  import { toFirepassWireModelId, toFireworksWireModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id";
3
- import { isGlm52ReasoningEffortModelId } from "@oh-my-pi/pi-catalog/identity";
3
+ import { isGlm52ReasoningEffortModelId, isKimiK3ModelId } from "@oh-my-pi/pi-catalog/identity";
4
4
  import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
5
5
  import { calculateCost } from "@oh-my-pi/pi-catalog/models";
6
6
  import type {
@@ -1025,16 +1025,24 @@ function isZaiReasoningEffortDialect(model: Model<"openai-completions">, compat:
1025
1025
  }
1026
1026
 
1027
1027
  /**
1028
- * Output-token clamp for the Z.AI/GLM-5.2 reasoning dialect: these hosts accept
1029
- * the full model window on reasoning turns, so clamp to the model cap. Returns
1030
- * `undefined` for every other model, leaving {@link resolveOpenAIOutputTokenParam}
1031
- * on its default `OPENAI_MAX_OUTPUT_TOKENS` clamp.
1028
+ * Provider-specific Chat Completions output clamp.
1029
+ *
1030
+ * Most OpenAI-compatible endpoints retain the conservative 64k ceiling from
1031
+ * {@link resolveOpenAIOutputTokenParam}. Z.AI/GLM-5.2 reasoning and native
1032
+ * Moonshot K3 explicitly accept their full advertised model caps, so those
1033
+ * routes clamp to `model.maxTokens` instead.
1032
1034
  */
1033
- export function resolveZaiReasoningOutputClamp(
1035
+ export function resolveOpenAICompletionsOutputClamp(
1034
1036
  model: Model<"openai-completions">,
1035
1037
  compat: ResolvedOpenAICompat,
1036
1038
  ): number | undefined {
1037
- return isZaiReasoningEffortDialect(model, compat) ? (model.maxTokens ?? OPENAI_MAX_OUTPUT_TOKENS) : undefined;
1039
+ if (isZaiReasoningEffortDialect(model, compat)) {
1040
+ return model.maxTokens ?? OPENAI_MAX_OUTPUT_TOKENS;
1041
+ }
1042
+ if (model.provider === "moonshot" && isKimiK3ModelId(model.id)) {
1043
+ return model.maxTokens ?? OPENAI_MAX_OUTPUT_TOKENS;
1044
+ }
1045
+ return undefined;
1038
1046
  }
1039
1047
 
1040
1048
  /**
@@ -1,5 +1,14 @@
1
1
  import { renderDemotedThinking } from "../dialect/demotion";
2
- import type { Api, AssistantMessage, Message, Model, ToolCall, ToolResultMessage, UserMessage } from "../types";
2
+ import type {
3
+ Api,
4
+ AssistantMessage,
5
+ DeveloperMessage,
6
+ Message,
7
+ Model,
8
+ ToolCall,
9
+ ToolResultMessage,
10
+ UserMessage,
11
+ } from "../types";
3
12
  import { isDemotedThinking, kDemotedThinking } from "../utils/block-symbols";
4
13
 
5
14
  const enum ToolCallStatus {
@@ -286,6 +295,156 @@ function normalizeAnthropicTargetToolCallId<TApi extends Api>(
286
295
  * - Preserves tool call structure (unlike converting to text summaries)
287
296
  * - Injects synthetic "aborted" tool results
288
297
  */
298
+ const SENSITIVE_TOKEN_RE =
299
+ /(?<![a-zA-Z0-9_*-])(gh[opusr]_[a-zA-Z0-9_*]{36,}|github_pat_[a-zA-Z0-9_*]{36,}|glpat-[a-zA-Z0-9_*-]{20,}|sk-proj-[a-zA-Z0-9_*-]{36,}|sk-ant-[a-zA-Z0-9_*-]{36,}|sk-[a-zA-Z0-9_*-]{48,})(?![a-zA-Z0-9_*-])/gi;
300
+
301
+ function hasPlausibleCredentialEntropy(token: string): boolean {
302
+ const lower = token.toLowerCase();
303
+ const prefixLength = lower.startsWith("github_pat_")
304
+ ? "github_pat_".length
305
+ : lower.startsWith("glpat-")
306
+ ? "glpat-".length
307
+ : lower.startsWith("sk-proj-")
308
+ ? "sk-proj-".length
309
+ : lower.startsWith("sk-ant-")
310
+ ? "sk-ant-".length
311
+ : lower.startsWith("gh")
312
+ ? 4
313
+ : 3;
314
+ const secret = token.slice(prefixLength);
315
+ if (/^\*+$/.test(secret)) return true;
316
+ return [/[a-z]/, /[A-Z]/, /\d/, /[_-]/].filter(pattern => pattern.test(secret)).length >= 2;
317
+ }
318
+
319
+ export function redactSensitiveCredentials(text: string): string {
320
+ return text.replace(SENSITIVE_TOKEN_RE, match => {
321
+ if (!hasPlausibleCredentialEntropy(match)) return match;
322
+ const lower = match.toLowerCase();
323
+ if (lower.startsWith("gh")) {
324
+ return "[github_token_redacted]";
325
+ }
326
+ if (lower.startsWith("gl")) {
327
+ return "[gitlab_token_redacted]";
328
+ }
329
+ if (lower.startsWith("sk-ant-")) {
330
+ return "[anthropic_token_redacted]";
331
+ }
332
+ if (lower.startsWith("sk")) {
333
+ return "[openai_token_redacted]";
334
+ }
335
+ return "[token_redacted]";
336
+ });
337
+ }
338
+
339
+ export function redactSensitiveInObject(val: unknown): { result: unknown; changed: boolean } {
340
+ if (typeof val === "string") {
341
+ const redacted = redactSensitiveCredentials(val);
342
+ return { result: redacted, changed: redacted !== val };
343
+ }
344
+ if (Array.isArray(val)) {
345
+ let changed = false;
346
+ const result = val.map(item => {
347
+ const res = redactSensitiveInObject(item);
348
+ if (res.changed) changed = true;
349
+ return res.result;
350
+ });
351
+ return { result, changed };
352
+ }
353
+ if (val !== null && typeof val === "object") {
354
+ let changed = false;
355
+ const res: Record<string, unknown> = {};
356
+ for (const [k, v] of Object.entries(val)) {
357
+ const sub = redactSensitiveInObject(v);
358
+ if (sub.changed) changed = true;
359
+ res[k] = sub.result;
360
+ }
361
+ return { result: res, changed };
362
+ }
363
+ return { result: val, changed: false };
364
+ }
365
+
366
+ function redactSensitiveCredentialsInMessages(messages: Message[]): Message[] {
367
+ return messages.map((msg): Message => {
368
+ if (msg.role === "user" || msg.role === "developer") {
369
+ const userMsg = msg as UserMessage | DeveloperMessage;
370
+ if (typeof userMsg.content === "string") {
371
+ const redacted = redactSensitiveCredentials(userMsg.content);
372
+ if (redacted === userMsg.content) return msg;
373
+ return { ...userMsg, content: redacted } as Message;
374
+ }
375
+ const contentArray = userMsg.content;
376
+ let changed = false;
377
+ const content = contentArray.map((block): UserMessage["content"][number] => {
378
+ if (block.type === "text") {
379
+ const redacted = redactSensitiveCredentials(block.text);
380
+ if (redacted !== block.text) {
381
+ changed = true;
382
+ return { ...block, text: redacted };
383
+ }
384
+ }
385
+ return block;
386
+ });
387
+ return (changed ? { ...userMsg, content } : userMsg) as Message;
388
+ }
389
+
390
+ if (msg.role === "toolResult") {
391
+ const toolResultMsg = msg as ToolResultMessage;
392
+ let changed = false;
393
+ const content = toolResultMsg.content.map((block): ToolResultMessage["content"][number] => {
394
+ if (block.type === "text") {
395
+ const redacted = redactSensitiveCredentials(block.text);
396
+ if (redacted !== block.text) {
397
+ changed = true;
398
+ return { ...block, text: redacted };
399
+ }
400
+ }
401
+ return block;
402
+ });
403
+ return (changed ? { ...toolResultMsg, content } : toolResultMsg) as Message;
404
+ }
405
+
406
+ if (msg.role === "assistant") {
407
+ const assistantMsg = msg as AssistantMessage;
408
+ let changed = false;
409
+ const content = assistantMsg.content.map((block): AssistantMessage["content"][number] => {
410
+ if (block.type === "text") {
411
+ const redacted = redactSensitiveCredentials(block.text);
412
+ if (redacted !== block.text) {
413
+ changed = true;
414
+ return { ...block, text: redacted };
415
+ }
416
+ } else if (block.type === "thinking") {
417
+ const redacted = redactSensitiveCredentials(block.thinking);
418
+ if (redacted !== block.thinking) {
419
+ changed = true;
420
+ return { ...block, thinking: redacted, thinkingSignature: undefined };
421
+ }
422
+ } else if (block.type === "toolCall") {
423
+ if (block.arguments) {
424
+ const { result: redactedArgs, changed: argsChanged } = redactSensitiveInObject(block.arguments);
425
+ if (argsChanged) {
426
+ changed = true;
427
+ const castArgs =
428
+ redactedArgs && typeof redactedArgs === "object" && !Array.isArray(redactedArgs)
429
+ ? (redactedArgs as Record<string, unknown>)
430
+ : undefined;
431
+ return {
432
+ ...block,
433
+ arguments: castArgs,
434
+ thoughtSignature: undefined,
435
+ } as AssistantMessage["content"][number];
436
+ }
437
+ }
438
+ }
439
+ return block;
440
+ });
441
+ return (changed ? { ...assistantMsg, content } : assistantMsg) as Message;
442
+ }
443
+
444
+ return msg;
445
+ });
446
+ }
447
+
289
448
  export function transformMessages<TApi extends Api>(
290
449
  messages: Message[],
291
450
  model: Model<TApi>,
@@ -294,6 +453,10 @@ export function transformMessages<TApi extends Api>(
294
453
  duplicateToolCallIdSuffixPrefix = "_dup",
295
454
  targetCompat: Model<TApi>["compat"] = model.compat,
296
455
  ): Message[] {
456
+ // Redact sensitive credential-like patterns from all outbound messages
457
+ // to prevent security block errors from LLM providers (e.g. invalid_prompt).
458
+ messages = redactSensitiveCredentialsInMessages(messages);
459
+
297
460
  // Drop assistant `toolCall` blocks with empty/whitespace `id` or `name`
298
461
  // (and their matched `toolResult` messages) before anything else looks at
299
462
  // the history. Replays of these would 400 every provider — see
package/src/utils.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { $env } from "@oh-my-pi/pi-utils";
2
2
  import type { ResponseInput, ResponseInputItem } from "./providers/openai-responses-wire";
3
+ import { redactSensitiveCredentials } from "./providers/transform-messages";
3
4
  import type { CacheRetention, OpenAIResponsesHistoryPayload, ProviderPayload } from "./types";
4
5
 
5
6
  type OpenAIResponsesReplayItem = ResponseInput[number];
@@ -9,7 +10,9 @@ export { isRecord } from "@oh-my-pi/pi-utils";
9
10
  export function normalizeSystemPrompts(systemPrompt: readonly string[] | string | undefined | null): string[] {
10
11
  if (systemPrompt === undefined || systemPrompt === null) return [];
11
12
  const prompts = Array.isArray(systemPrompt) ? systemPrompt : typeof systemPrompt === "string" ? [systemPrompt] : [];
12
- return prompts.map(prompt => prompt.toWellFormed()).filter(prompt => prompt.trim().length > 0);
13
+ return prompts
14
+ .map(prompt => redactSensitiveCredentials(prompt.toWellFormed()))
15
+ .filter(prompt => prompt.trim().length > 0);
13
16
  }
14
17
 
15
18
  export function normalizeToolCallId(id: string): string {