@oh-my-pi/pi-ai 16.1.2 → 16.1.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,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.3] - 2026-06-19
6
+
7
+ ### Added
8
+
9
+ - Added regression test pinning that `openai-completions` emits a `thinking` block for `reasoning_content` deltas even when `delta.content` is explicitly JSON `null` (the DeepSeek-format dual-key pattern used by custom GLM/Qwen reasoning providers). See [#2996](https://github.com/can1357/oh-my-pi/issues/2996).
10
+
11
+ ### Changed
12
+
13
+ - Improved the thinking loop guard to treat assistant text loops as retryable errors
14
+ - Refined text normalization logic to reduce false positives in the thinking loop detector
15
+
16
+ ### Fixed
17
+
18
+ - Fixed Ollama chat requests sending image payloads to text-only models. Image blocks are now omitted and replaced with the standard non-vision placeholder for models without vision support, while vision-capable Ollama models continue to receive images. ([#3009](https://github.com/can1357/oh-my-pi/pull/3009) by [@serverinspector](https://github.com/serverinspector))
19
+ - Fixed `SqliteAuthCredentialStore.close()` leaking one-off prepared statements created by inline `this.#db.prepare()` calls in `#authCredentialsTableExists`, `#readAuthSchemaVersion`, `#inferAuthSchemaVersion`, `#migrateAuthSchemaV0ToV1`, `#backfillCredentialIdentityKeys`, and `updateAuthCredential`. Each statement is now wrapped in `try/finally` with `stmt.finalize()`, and the `close()` method finalizes `#insertUsageCostStmt` and `#listUsageCostsStmt` which were previously missed. This caused EBUSY on Windows when tests tried to delete temp dirs containing open SQLite handles.
20
+
5
21
  ## [16.1.2] - 2026-06-19
6
22
 
7
23
  ### Added
@@ -3934,4 +3950,4 @@ _Dedicated to Peter's shoulder ([@steipete](https://twitter.com/steipete))_
3934
3950
 
3935
3951
  ## [0.9.4] - 2025-11-26
3936
3952
 
3937
- Initial release with multi-provider LLM support.
3953
+ Initial release with multi-provider LLM support.
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.2",
4
+ "version": "16.1.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.1.2",
42
- "@oh-my-pi/pi-utils": "16.1.2",
43
- "@oh-my-pi/pi-wire": "16.1.2",
41
+ "@oh-my-pi/pi-catalog": "16.1.3",
42
+ "@oh-my-pi/pi-utils": "16.1.3",
43
+ "@oh-my-pi/pi-wire": "16.1.3",
44
44
  "arktype": "^2.2.0",
45
45
  "partial-json": "^0.1.7",
46
46
  "zod": "^4"
@@ -4742,25 +4742,47 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4742
4742
  }
4743
4743
 
4744
4744
  #authCredentialsTableExists(): boolean {
4745
- const row = this.#db
4746
- .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'auth_credentials'")
4747
- .get() as { present?: number } | undefined;
4748
- return row?.present === 1;
4745
+ const stmt = this.#db.prepare(
4746
+ "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'auth_credentials'",
4747
+ );
4748
+ try {
4749
+ const row = stmt.get() as { present?: number } | undefined;
4750
+ return row?.present === 1;
4751
+ } finally {
4752
+ stmt.finalize();
4753
+ }
4749
4754
  }
4750
4755
 
4751
4756
  #readAuthSchemaVersion(): number | null {
4752
- const row = this.#db.prepare("SELECT version FROM auth_schema_version WHERE id = 1").get() as
4753
- | { version?: number }
4754
- | undefined;
4755
- return typeof row?.version === "number" ? row.version : null;
4757
+ const stmt = this.#db.prepare("SELECT version FROM auth_schema_version WHERE id = 1");
4758
+ try {
4759
+ const row = stmt.get() as { version?: number } | undefined;
4760
+ return typeof row?.version === "number" ? row.version : null;
4761
+ } finally {
4762
+ stmt.finalize();
4763
+ }
4756
4764
  }
4757
4765
 
4758
4766
  #writeAuthSchemaVersion(version: number): void {
4759
- this.#db.prepare("INSERT OR REPLACE INTO auth_schema_version(id, version) VALUES (1, ?)").run(version);
4767
+ const stmt = this.#db.prepare("INSERT OR REPLACE INTO auth_schema_version(id, version) VALUES (1, ?)");
4768
+ try {
4769
+ stmt.run(version);
4770
+ } finally {
4771
+ stmt.finalize();
4772
+ }
4760
4773
  }
4761
4774
 
4762
4775
  #inferAuthSchemaVersion(): number {
4763
- const cols = this.#db.prepare("PRAGMA table_info(auth_credentials)").all() as Array<{ name?: string }>;
4776
+ const stmt = this.#db.prepare("PRAGMA table_info(auth_credentials)");
4777
+ try {
4778
+ const cols = stmt.all() as Array<{ name?: string }>;
4779
+ return this.#inferAuthSchemaVersionFromColumns(cols);
4780
+ } finally {
4781
+ stmt.finalize();
4782
+ }
4783
+ }
4784
+
4785
+ #inferAuthSchemaVersionFromColumns(cols: Array<{ name?: string }>): number {
4764
4786
  const hasDisabledCause = cols.some(column => column.name === "disabled_cause");
4765
4787
  const hasIdentityKey = cols.some(column => column.name === "identity_key");
4766
4788
  const hasAccountId = cols.some(column => column.name === "account_id");
@@ -4808,8 +4830,14 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4808
4830
 
4809
4831
  #migrateAuthSchemaV0ToV1(): void {
4810
4832
  const migrate = this.#db.transaction(() => {
4811
- const v0Cols = this.#db.prepare("PRAGMA table_info(auth_credentials)").all() as Array<{ name?: string }>;
4812
- const hasDisabled = v0Cols.some(col => col.name === "disabled");
4833
+ const stmt = this.#db.prepare("PRAGMA table_info(auth_credentials)");
4834
+ let hasDisabled = false;
4835
+ try {
4836
+ const v0Cols = stmt.all() as Array<{ name?: string }>;
4837
+ hasDisabled = v0Cols.some(col => col.name === "disabled");
4838
+ } finally {
4839
+ stmt.finalize();
4840
+ }
4813
4841
 
4814
4842
  this.#db.run("ALTER TABLE auth_credentials RENAME TO auth_credentials_v0");
4815
4843
  this.#db.run(`
@@ -4885,21 +4913,29 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
4885
4913
  }
4886
4914
 
4887
4915
  #backfillCredentialIdentityKeys(): void {
4888
- const rows = this.#db
4889
- .prepare(
4890
- "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC",
4891
- )
4892
- .all() as AuthRow[];
4916
+ const selectRowsStmt = this.#db.prepare(
4917
+ "SELECT id, provider, credential_type, data, disabled_cause, identity_key FROM auth_credentials WHERE identity_key IS NULL ORDER BY id ASC",
4918
+ );
4919
+ let rows: AuthRow[];
4920
+ try {
4921
+ rows = selectRowsStmt.all() as AuthRow[];
4922
+ } finally {
4923
+ selectRowsStmt.finalize();
4924
+ }
4893
4925
  if (rows.length === 0) return;
4894
4926
 
4895
4927
  let updateIdentity: Statement | null = null;
4896
- for (const row of rows) {
4897
- const identityKey = resolveRowCredentialIdentityKey(row.provider, row);
4898
- // Rows whose identity cannot be derived stay NULL; writing NULL over
4899
- // NULL would just burn a write transaction on every boot.
4900
- if (identityKey === null) continue;
4901
- updateIdentity ??= this.#db.prepare("UPDATE auth_credentials SET identity_key = ? WHERE id = ?");
4902
- updateIdentity.run(identityKey, row.id);
4928
+ try {
4929
+ for (const row of rows) {
4930
+ const identityKey = resolveRowCredentialIdentityKey(row.provider, row);
4931
+ // Rows whose identity cannot be derived stay NULL; writing NULL over
4932
+ // NULL would just burn a write transaction on every boot.
4933
+ if (identityKey === null) continue;
4934
+ updateIdentity ??= this.#db.prepare("UPDATE auth_credentials SET identity_key = ? WHERE id = ?");
4935
+ updateIdentity.run(identityKey, row.id);
4936
+ }
4937
+ } finally {
4938
+ updateIdentity?.finalize();
4903
4939
  }
4904
4940
  }
4905
4941
 
@@ -5063,9 +5099,13 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5063
5099
 
5064
5100
  updateAuthCredential(id: number, credential: AuthCredential): void {
5065
5101
  try {
5066
- const providerRow = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?").get(id) as
5067
- | { provider?: string }
5068
- | undefined;
5102
+ const providerStmt = this.#db.prepare("SELECT provider FROM auth_credentials WHERE id = ?");
5103
+ let providerRow: { provider?: string } | undefined;
5104
+ try {
5105
+ providerRow = providerStmt.get(id) as { provider?: string } | undefined;
5106
+ } finally {
5107
+ providerStmt.finalize();
5108
+ }
5069
5109
  const provider = providerRow?.provider ?? "";
5070
5110
  const serialized = serializeCredential(provider, credential);
5071
5111
  if (!serialized) return;
@@ -5334,6 +5374,8 @@ export class SqliteAuthCredentialStore implements AuthCredentialStore {
5334
5374
  this.#lastUsageHistoryStmt.finalize();
5335
5375
  this.#listUsageHistoryStmt.finalize();
5336
5376
  this.#updateUsageHistoryStmt.finalize();
5377
+ this.#insertUsageCostStmt.finalize();
5378
+ this.#listUsageCostsStmt.finalize();
5337
5379
  this.#db.close();
5338
5380
  }
5339
5381
  }
@@ -5,15 +5,14 @@ import type {
5
5
  Api,
6
6
  AssistantMessage,
7
7
  Context,
8
- DeveloperMessage,
8
+ ImageContent,
9
9
  Message,
10
10
  Model,
11
11
  StreamFunction,
12
12
  StreamOptions,
13
+ TextContent,
13
14
  Tool,
14
15
  ToolChoice,
15
- ToolResultMessage,
16
- UserMessage,
17
16
  } from "../types";
18
17
  import { normalizeSystemPrompts } from "../utils";
19
18
  import { AssistantMessageEventStream } from "../utils/event-stream";
@@ -32,6 +31,7 @@ import {
32
31
  type StreamMarkupHealingEvent,
33
32
  } from "../utils/stream-markup-healing";
34
33
  import { transformMessages } from "./transform-messages";
34
+ import { joinTextWithImagePlaceholder, partitionVisionContent } from "./vision-guard";
35
35
 
36
36
  /** Non-2xx response from the Ollama `/api/chat` endpoint. */
37
37
  export class OllamaApiError extends ProviderHttpError {
@@ -174,40 +174,35 @@ function selectToolsForToolChoice(tools: Tool[] | undefined, toolChoice: ToolCho
174
174
  return [];
175
175
  }
176
176
 
177
- function toPlainContent(content: string | Array<{ type: "text" | "image"; text?: string; data?: string }>): {
177
+ function toPlainContent(
178
+ content: string | ReadonlyArray<TextContent | ImageContent>,
179
+ supportsImages: boolean,
180
+ ): {
178
181
  content: string;
179
182
  images?: string[];
180
183
  } {
181
184
  if (typeof content === "string") {
182
185
  return { content };
183
186
  }
184
- const textParts: string[] = [];
185
- const images: string[] = [];
186
- for (const block of content) {
187
- if (block.type === "text" && typeof block.text === "string") {
188
- textParts.push(block.text);
189
- }
190
- if (block.type === "image" && typeof block.data === "string") {
191
- images.push(block.data);
192
- }
193
- }
187
+ const { textBlocks, imageBlocks, omittedImages } = partitionVisionContent(content, supportsImages);
188
+ const text = textBlocks.map(block => block.text).join("\n");
194
189
  return {
195
- content: textParts.join("\n"),
196
- ...(images.length > 0 ? { images } : {}),
190
+ content: joinTextWithImagePlaceholder(text, omittedImages),
191
+ ...(imageBlocks.length > 0 ? { images: imageBlocks.map(block => block.data) } : {}),
197
192
  };
198
193
  }
199
194
 
200
- function convertMessage(message: Message): OllamaMessage {
195
+ function convertMessage(message: Message, supportsImages: boolean): OllamaMessage {
201
196
  if (message.role === "user") {
202
- const converted = toPlainContent(message.content as UserMessage["content"]);
197
+ const converted = toPlainContent(message.content, supportsImages);
203
198
  return { role: "user", ...converted };
204
199
  }
205
200
  if (message.role === "developer") {
206
- const converted = toPlainContent(message.content as DeveloperMessage["content"]);
201
+ const converted = toPlainContent(message.content, supportsImages);
207
202
  return { role: "system", ...converted };
208
203
  }
209
204
  if (message.role === "toolResult") {
210
- const converted = toPlainContent(message.content as ToolResultMessage["content"]);
205
+ const converted = toPlainContent(message.content, supportsImages);
211
206
  return {
212
207
  role: "tool",
213
208
  tool_name: message.toolName,
@@ -259,8 +254,9 @@ function convertMessages(model: Model<"ollama-chat">, context: Context): OllamaM
259
254
  }
260
255
  messages.push(...context.messages);
261
256
  const isCloud = model.provider === "ollama-cloud";
257
+ const supportsImages = model.input.includes("image");
262
258
  return transformMessages(messages, model).map(msg => {
263
- const converted = convertMessage(msg);
259
+ const converted = convertMessage(msg, supportsImages);
264
260
  // Ollama cloud rejects requests when assistant history messages contain the `thinking`
265
261
  // field — it's valid in model responses but not accepted as a history input. Strip it
266
262
  // to prevent HTTP 400 errors. Local Ollama instances are unaffected.
@@ -25,11 +25,11 @@
25
25
  * 13.5k non-loop thinking blocks (zero false positives; hardest negative
26
26
  * scored 3 against the trigger of 4).
27
27
  *
28
- * Scope is deliberately narrow: **thinking only**. Answer text is left
29
- * untouched so the guard can never discard already-streamed visible output. The
30
- * guard is gated to Gemini models and wraps the provider stream, so it works
31
- * across every Gemini transport (OpenRouter `openai-completions`, direct
32
- * `google-generative-ai` / `google-gemini-cli`, Vertex). Disable with
28
+ * Scope is narrow: guarded Gemini/DeepSeek streams before any tool call. Native
29
+ * thinking is checked first; assistant text can also be checked for providers
30
+ * that surface reasoning as visible prose. On a hit, the failed turn is emitted
31
+ * as an empty retryable stream-stall error so the session drops and re-samples
32
+ * it instead of committing the runaway transcript. Disable with
33
33
  * `PI_NO_THINKING_LOOP_GUARD=1`.
34
34
  */
35
35
  import { logger } from "@oh-my-pi/pi-utils";
@@ -208,7 +208,6 @@ export function guardThinkingLoopStream(
208
208
  void (async () => {
209
209
  let thinkingArmed = true;
210
210
  let textArmed = checkAssistantContent;
211
- let accumulatedText = "";
212
211
  try {
213
212
  for await (const event of inner) {
214
213
  let detail: string | null = null;
@@ -221,7 +220,6 @@ export function guardThinkingLoopStream(
221
220
  thinkingArmed = false;
222
221
  if (textArmed && event.type === "text_delta") {
223
222
  detail = textDetector.push(event.delta);
224
- accumulatedText += event.delta;
225
223
  }
226
224
  } else if (event.type === "toolcall_start" || event.type === "toolcall_delta") {
227
225
  thinkingArmed = false;
@@ -244,7 +242,7 @@ export function guardThinkingLoopStream(
244
242
  outer.push({
245
243
  type: "error",
246
244
  reason: "error",
247
- error: buildThinkingLoopError(model, detail, accumulatedText),
245
+ error: buildThinkingLoopError(model, detail),
248
246
  });
249
247
  return;
250
248
  }
@@ -289,13 +287,13 @@ export function withGeminiThinkingLoopGuard<
289
287
  return guardThinkingLoopStream(dispatch(merged), model, controller, options);
290
288
  }
291
289
 
292
- function buildThinkingLoopError(model: Model<Api>, detail: string, accumulatedText?: string): AssistantMessage {
293
- const hasText = Boolean(accumulatedText);
290
+ function buildThinkingLoopError(model: Model<Api>, detail: string): AssistantMessage {
294
291
  return {
295
292
  role: "assistant",
296
- // Empty content is load-bearing: a contentful error stop is replay-unsafe
297
- // and would NOT be auto-retried by the session.
298
- content: hasText ? [{ type: "text", text: accumulatedText! }] : [],
293
+ // Empty content is load-bearing: loop-guard output is replay garbage, even
294
+ // when it arrived as assistant text instead of native thinking. Keeping it
295
+ // would persist the failed attempt before AgentSession retries.
296
+ content: [],
299
297
  api: model.api,
300
298
  provider: model.provider,
301
299
  model: model.id,
@@ -310,7 +308,7 @@ function buildThinkingLoopError(model: Model<Api>, detail: string, accumulatedTe
310
308
  stopReason: "error",
311
309
  // "stream stall" makes the transport/session retry classifiers treat this
312
310
  // as a transient (retryable) failure with no bespoke rule.
313
- errorMessage: `${THINKING_LOOP_ERROR_MARKER}: the model repeated near-identical content (${detail}).${hasText ? " Non-retryable because output was already streamed." : " Treating as a stream stall and retrying."}`,
311
+ errorMessage: `${THINKING_LOOP_ERROR_MARKER}: the model repeated near-identical content (${detail}). Treating as a stream stall and retrying.`,
314
312
  timestamp: Date.now(),
315
313
  };
316
314
  }
@@ -345,14 +343,15 @@ function detectVerbatimRepetition(text: string): [unit: string, count: number] |
345
343
  return null;
346
344
  }
347
345
 
348
- /** Lowercase, drop code spans / paths / digits, keep only letter words. */
346
+ /** Lowercase and tokenize prose plus code/path payloads, dropping pure numbers. */
349
347
  function normalizeSegment(segment: string): string {
350
348
  return segment
351
349
  .toLowerCase()
352
- .replace(/`[^`]*`/g, " ")
353
- .replace(/\/[^\s`]+/g, " ")
354
- .replace(/\d+/g, " ")
355
- .replace(/[^a-z]+/g, " ")
350
+ .replace(/`([^`]*)`/g, " $1 ")
351
+ .replace(/[^a-z0-9]+/g, " ")
352
+ .split(/\s+/)
353
+ .filter(token => /[a-z]/.test(token))
354
+ .join(" ")
356
355
  .trim();
357
356
  }
358
357