@oh-my-pi/pi-ai 16.2.2 → 16.2.3

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,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.2.3] - 2026-06-28
6
+
5
7
  ### Changed
6
8
 
7
- - Default reasoning context to `all_turns` for all Codex requests
9
+ - Enabled automatic removal of leaked reasoning tags for all models
10
+ - Prevented reasoning text duplication when models emit both structured and inline thinking
11
+ - Defaulted reasoning context to all turns for all Codex requests.
12
+
13
+ ### Fixed
14
+
15
+ - Enabled freeform tool patch support for Azure OpenAI and Codex models.
16
+ - Fixed an issue where the `/usage show` command returned "No usage data available" when using a custom proxy base URL for Codex.
8
17
 
9
18
  ## [16.2.2] - 2026-06-27
10
19
 
@@ -118,9 +118,9 @@ export declare function buildParams(model: Model<"openai-responses">, context: C
118
118
  * runtime path only consumes that metadata.
119
119
  * @internal Exported for tests.
120
120
  */
121
- export declare function supportsFreeformApplyPatch(model: Model<"openai-responses">): boolean;
121
+ export declare function supportsFreeformApplyPatch(model: Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">): boolean;
122
122
  /** @internal Exported for tests. */
123
123
  export declare function mapOpenAIResponsesToolChoiceForTools(choice: ToolChoice | undefined, tools: Tool[], model: Model<"openai-responses">): OpenAIResponsesToolChoice;
124
124
  /** @internal Exported for tests. */
125
- export declare function convertTools(tools: Tool[], strictMode: boolean, model: Model<"openai-responses">, onQuarantine?: (toolName: string, schemaPath: string) => void): OpenAITool[];
125
+ export declare function convertTools(tools: Tool[], strictMode: boolean, model: Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">, onQuarantine?: (toolName: string, schemaPath: string) => void): OpenAITool[];
126
126
  export {};
@@ -1 +1,18 @@
1
+ /**
2
+ * Resolve the base URL for ChatGPT account-API requests (`wham/usage`,
3
+ * `wham/rate-limit-reset-credits`).
4
+ *
5
+ * These endpoints live on the canonical ChatGPT origin and authenticate with
6
+ * the Codex OAuth bearer minted for that origin. They are NOT part of the
7
+ * `/responses` API surface that streaming proxies (Headroom, 9router, etc.)
8
+ * forward, so a `providers.openai-codex.baseUrl` override pointed at such a
9
+ * proxy MUST NOT be used here — doing so 404s and silently breaks
10
+ * `/usage show` (issue #3679).
11
+ *
12
+ * Accepted overrides are the canonical `chatgpt.com` / `chat.openai.com`
13
+ * origins. Any extra path (e.g. a streaming override like
14
+ * `/backend-api/codex/responses`) is normalized to `${origin}/backend-api`,
15
+ * since the account endpoints always live directly under `/backend-api`. Any
16
+ * other host falls back to {@link CODEX_BASE_URL}.
17
+ */
1
18
  export declare function normalizeCodexBaseUrl(baseUrl?: string): string;
@@ -29,6 +29,12 @@ export type StreamMarkupHealingEvent = {
29
29
  * State machine that consumes streamed visible text and emits cleaned text,
30
30
  * thinking deltas, and reconstructed tool calls.
31
31
  *
32
+ * A {@link ThinkingInbandScanner} always heals leaked reasoning idioms
33
+ * (`<think>`, `<thinking>`, ` ```thinking `, Gemma/Harmony channels, …) out of
34
+ * the visible channel. For Kimi / DeepSeek-DSML the provider tool-call grammar
35
+ * runs first and its cleaned text is piped through that thinking healer, so a
36
+ * model can leak tool-call markup and reasoning in the same stream.
37
+ *
32
38
  * Feed only one stream channel (usually `delta.content` / `message.content`).
33
39
  * Mixing reasoning and visible text into the same instance can corrupt held-back
34
40
  * partial tag buffers.
@@ -71,8 +77,11 @@ export declare class StreamMarkupHealing {
71
77
  export declare function modelMayLeakKimiToolCalls(provider: string, modelId: string): boolean;
72
78
  /** Cheap model/provider gate for DeepSeek DSML envelope leaks. */
73
79
  export declare function modelMayLeakDsmlToolCalls(provider: string, modelId: string): boolean;
74
- /** Cheap model/provider gate for MiniMax plain thinking tag leaks. */
75
- export declare function modelMayLeakThinkingTags(provider: string, modelId: string): boolean;
76
- export declare function getStreamMarkupHealingPattern(provider: string, modelId: string, options?: {
77
- readonly parseThinkingTags?: boolean;
78
- }): StreamMarkupHealingPattern | undefined;
80
+ /**
81
+ * Pick the leaked-markup healer for an OpenAI-compatible / Ollama visible-text
82
+ * stream. Kimi chat-template tokens and DeepSeek DSML envelopes need their
83
+ * dedicated tool-call grammars; every other model uses `"thinking"`. All three
84
+ * patterns run the generic {@link ThinkingInbandScanner}, so leaked reasoning
85
+ * idioms (e.g. a Gemini ` ```thinking ` fence on OpenRouter) are always healed.
86
+ */
87
+ export declare function getStreamMarkupHealingPattern(provider: string, modelId: string): StreamMarkupHealingPattern;
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.2.2",
4
+ "version": "16.2.3",
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.0",
41
- "@oh-my-pi/pi-catalog": "16.2.2",
42
- "@oh-my-pi/pi-utils": "16.2.2",
43
- "@oh-my-pi/pi-wire": "16.2.2",
41
+ "@oh-my-pi/pi-catalog": "16.2.3",
42
+ "@oh-my-pi/pi-utils": "16.2.3",
43
+ "@oh-my-pi/pi-wire": "16.2.3",
44
44
  "arktype": "^2.2.0",
45
45
  "zod": "^4"
46
46
  },
@@ -465,6 +465,10 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
465
465
  ? new StreamMarkupHealing({ pattern: streamMarkupHealingPattern })
466
466
  : undefined;
467
467
  let healedToolCallEmitted = false;
468
+ // Once the provider streams native reasoning (`message.thinking`), drop any
469
+ // thinking the text-channel healer also recovers so a model that emits both
470
+ // does not double-count its reasoning.
471
+ let suppressHealedThinking = false;
468
472
  const endActiveTextBlock = (): void => {
469
473
  if (activeTextIndex === undefined) return;
470
474
  endTextBlock(stream, output, activeTextIndex);
@@ -542,7 +546,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
542
546
  if (event.type === "text") {
543
547
  appendVisibleText(event.text);
544
548
  } else if (event.type === "thinking") {
545
- appendVisibleThinking(event.thinking);
549
+ if (!suppressHealedThinking) appendVisibleThinking(event.thinking);
546
550
  } else {
547
551
  emitHealedToolCall(event.call);
548
552
  }
@@ -614,6 +618,7 @@ export const streamOllama: StreamFunction<"ollama-chat"> = (
614
618
  stream.push({ type: "start", partial: output });
615
619
  for await (const chunk of iterateNdjson(response.body)) {
616
620
  if (chunk.message?.thinking) {
621
+ suppressHealedThinking = true;
617
622
  endActiveTextBlock();
618
623
  if (activeThinkingIndex === undefined) {
619
624
  output.content.push({ type: "thinking", thinking: "" });
@@ -936,7 +936,9 @@ export function buildParams(
936
936
  * runtime path only consumes that metadata.
937
937
  * @internal Exported for tests.
938
938
  */
939
- export function supportsFreeformApplyPatch(model: Model<"openai-responses">): boolean {
939
+ export function supportsFreeformApplyPatch(
940
+ model: Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">,
941
+ ): boolean {
940
942
  return model.applyPatchToolType === "freeform";
941
943
  }
942
944
 
@@ -970,7 +972,7 @@ export function mapOpenAIResponsesToolChoiceForTools(
970
972
  export function convertTools(
971
973
  tools: Tool[],
972
974
  strictMode: boolean,
973
- model: Model<"openai-responses">,
975
+ model: Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">,
974
976
  onQuarantine: (toolName: string, schemaPath: string) => void = (toolName, schemaPath) =>
975
977
  logger.warn(
976
978
  `Tool "${toolName}" omitted from the openai-responses request: its parameter schema is invalid for this provider at ${schemaPath} (an enum/const value cannot match its declared type). Other tools are unaffected.`,
@@ -1,15 +1,35 @@
1
1
  import { CODEX_BASE_URL } from "@oh-my-pi/pi-catalog/wire/codex";
2
2
 
3
+ /**
4
+ * Resolve the base URL for ChatGPT account-API requests (`wham/usage`,
5
+ * `wham/rate-limit-reset-credits`).
6
+ *
7
+ * These endpoints live on the canonical ChatGPT origin and authenticate with
8
+ * the Codex OAuth bearer minted for that origin. They are NOT part of the
9
+ * `/responses` API surface that streaming proxies (Headroom, 9router, etc.)
10
+ * forward, so a `providers.openai-codex.baseUrl` override pointed at such a
11
+ * proxy MUST NOT be used here — doing so 404s and silently breaks
12
+ * `/usage show` (issue #3679).
13
+ *
14
+ * Accepted overrides are the canonical `chatgpt.com` / `chat.openai.com`
15
+ * origins. Any extra path (e.g. a streaming override like
16
+ * `/backend-api/codex/responses`) is normalized to `${origin}/backend-api`,
17
+ * since the account endpoints always live directly under `/backend-api`. Any
18
+ * other host falls back to {@link CODEX_BASE_URL}.
19
+ */
3
20
  export function normalizeCodexBaseUrl(baseUrl?: string): string {
4
- const fallback = CODEX_BASE_URL;
5
- const trimmed = baseUrl?.trim() ? baseUrl.trim() : fallback;
6
- const base = trimmed.replace(/\/+$/, "");
7
- const lower = base.toLowerCase();
8
- if (
9
- (lower.startsWith("https://chatgpt.com") || lower.startsWith("https://chat.openai.com")) &&
10
- !lower.includes("/backend-api")
11
- ) {
12
- return `${base}/backend-api`;
21
+ const trimmed = baseUrl?.trim().replace(/\/+$/, "");
22
+ if (!trimmed) return CODEX_BASE_URL;
23
+ let parsed: URL;
24
+ try {
25
+ parsed = new URL(trimmed);
26
+ } catch {
27
+ return CODEX_BASE_URL;
13
28
  }
14
- return base;
29
+ const host = parsed.host.toLowerCase();
30
+ if (host !== "chatgpt.com" && host !== "chat.openai.com") return CODEX_BASE_URL;
31
+ // wham/usage always lives at `${origin}/backend-api/...` on the canonical
32
+ // ChatGPT origin, so ignore any extra path (e.g. `/backend-api/codex/responses`
33
+ // from a streaming baseUrl) — otherwise we'd build `.../codex/responses/wham/usage`.
34
+ return `${parsed.origin}/backend-api`;
15
35
  }
@@ -38,24 +38,33 @@ export type StreamMarkupHealingEvent =
38
38
  * State machine that consumes streamed visible text and emits cleaned text,
39
39
  * thinking deltas, and reconstructed tool calls.
40
40
  *
41
+ * A {@link ThinkingInbandScanner} always heals leaked reasoning idioms
42
+ * (`<think>`, `<thinking>`, ` ```thinking `, Gemma/Harmony channels, …) out of
43
+ * the visible channel. For Kimi / DeepSeek-DSML the provider tool-call grammar
44
+ * runs first and its cleaned text is piped through that thinking healer, so a
45
+ * model can leak tool-call markup and reasoning in the same stream.
46
+ *
41
47
  * Feed only one stream channel (usually `delta.content` / `message.content`).
42
48
  * Mixing reasoning and visible text into the same instance can corrupt held-back
43
49
  * partial tag buffers.
44
50
  */
45
51
  export class StreamMarkupHealing {
46
52
  readonly #pattern: StreamMarkupHealingPattern;
47
- readonly #scanner: InbandScanner;
53
+ /** Provider tool-call grammar (Kimi tokens / DSML envelope); absent for plain text streams. */
54
+ readonly #toolScanner: InbandScanner | undefined;
55
+ /** Always-on healer for leaked reasoning idioms in the visible text channel. */
56
+ readonly #thinkingScanner = new ThinkingInbandScanner();
48
57
  #sectionTerminated = false;
49
58
  readonly #completed: HealedToolCall[] = [];
50
59
 
51
60
  constructor(options: StreamMarkupHealingOptions) {
52
61
  this.#pattern = options.pattern;
53
- this.#scanner =
62
+ this.#toolScanner =
54
63
  options.pattern === "kimi"
55
64
  ? createInbandScanner("kimi")
56
65
  : options.pattern === "dsml"
57
66
  ? createInbandScanner("xml", { xmlTagset: "dsml" })
58
- : new ThinkingInbandScanner();
67
+ : undefined;
59
68
  }
60
69
 
61
70
  get pattern(): StreamMarkupHealingPattern {
@@ -84,7 +93,8 @@ export class StreamMarkupHealing {
84
93
  feedEvents(text: string): StreamMarkupHealingEvent[] {
85
94
  if (text.length === 0) return [];
86
95
  this.#markSectionClosed(text);
87
- return this.#convertScannerEvents(this.#scanner.feed(text));
96
+ if (!this.#toolScanner) return this.#convertScannerEvents(this.#thinkingScanner.feed(text));
97
+ return this.#convertScannerEvents(this.#healThinking(this.#toolScanner.feed(text)));
88
98
  }
89
99
 
90
100
  /**
@@ -120,7 +130,9 @@ export class StreamMarkupHealing {
120
130
  * behavior.
121
131
  */
122
132
  flushEvents(): StreamMarkupHealingEvent[] {
123
- return this.#convertScannerEvents(this.#scanner.flush());
133
+ const tail = this.#toolScanner ? this.#healThinking(this.#toolScanner.flush()) : [];
134
+ tail.push(...this.#thinkingScanner.flush());
135
+ return this.#convertScannerEvents(tail);
124
136
  }
125
137
 
126
138
  /** Flush held-back text only. Reconstructed calls are retained for {@link drainCompleted}. */
@@ -142,7 +154,7 @@ export class StreamMarkupHealing {
142
154
  }
143
155
 
144
156
  #markSectionClosed(text: string): void {
145
- if (this.#sectionTerminated) return;
157
+ if (this.#sectionTerminated || !this.#toolScanner) return;
146
158
  if (this.#pattern === "kimi") {
147
159
  this.#sectionTerminated = text.includes(KIMI_SECTION_END);
148
160
  return;
@@ -151,6 +163,20 @@ export class StreamMarkupHealing {
151
163
  text.includes(DSML_TOOL_CALLS_CLOSE_FULLWIDTH) || text.includes(DSML_TOOL_CALLS_CLOSE_ASCII);
152
164
  }
153
165
 
166
+ /**
167
+ * Re-scan the tool scanner's visible text through the always-on thinking
168
+ * healer: `text` events are healed for leaked reasoning idioms, while the tool
169
+ * scanner's own thinking / tool-call events pass through in stream order.
170
+ */
171
+ #healThinking(toolEvents: readonly InbandScanEvent[]): InbandScanEvent[] {
172
+ const out: InbandScanEvent[] = [];
173
+ for (const event of toolEvents) {
174
+ if (event.type === "text") out.push(...this.#thinkingScanner.feed(event.text));
175
+ else out.push(event);
176
+ }
177
+ return out;
178
+ }
179
+
154
180
  #convertScannerEvents(events: readonly InbandScanEvent[]): StreamMarkupHealingEvent[] {
155
181
  const out: StreamMarkupHealingEvent[] = [];
156
182
  for (const event of events) {
@@ -207,18 +233,15 @@ export function modelMayLeakDsmlToolCalls(provider: string, modelId: string): bo
207
233
  );
208
234
  }
209
235
 
210
- /** Cheap model/provider gate for MiniMax plain thinking tag leaks. */
211
- export function modelMayLeakThinkingTags(provider: string, modelId: string): boolean {
212
- return /minimax/i.test(provider) || /minimax/i.test(modelId);
213
- }
214
-
215
- export function getStreamMarkupHealingPattern(
216
- provider: string,
217
- modelId: string,
218
- options?: { readonly parseThinkingTags?: boolean },
219
- ): StreamMarkupHealingPattern | undefined {
220
- if (options?.parseThinkingTags || modelMayLeakThinkingTags(provider, modelId)) return "thinking";
236
+ /**
237
+ * Pick the leaked-markup healer for an OpenAI-compatible / Ollama visible-text
238
+ * stream. Kimi chat-template tokens and DeepSeek DSML envelopes need their
239
+ * dedicated tool-call grammars; every other model uses `"thinking"`. All three
240
+ * patterns run the generic {@link ThinkingInbandScanner}, so leaked reasoning
241
+ * idioms (e.g. a Gemini ` ```thinking ` fence on OpenRouter) are always healed.
242
+ */
243
+ export function getStreamMarkupHealingPattern(provider: string, modelId: string): StreamMarkupHealingPattern {
221
244
  if (modelMayLeakKimiToolCalls(provider, modelId)) return "kimi";
222
245
  if (modelMayLeakDsmlToolCalls(provider, modelId)) return "dsml";
223
- return undefined;
246
+ return "thinking";
224
247
  }