jeopi-ai 16.2.26 → 16.2.28

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.2.28] - 2026-07-08
6
+
7
+ ### Fixed
8
+
9
+ - `ThinkingInbandScanner` (leaked-reasoning healer for OpenAI-compatible/Ollama providers) only recognized a bare thinking tag once its closing `>` arrived — a model that drops the `>` entirely (`<thinke\n<reasoning text>`, going straight into its reasoning instead of finishing the tag) never resolved as an open at all: `bareTagPartialHold` holds the unterminated run only while the buffer stays within `MAX_BARE_TAG_NAME_LENGTH`, then gives up and flushes it as visible text once the reasoning that follows pushes the buffer past that bound. A well-formed close tag emitted later (`</thinking>`) then had no open to pair with either, so it also leaked verbatim — both the malformed open and the clean close ended up in the visible channel, sandwiching the reasoning they were meant to hide. Added `findUnterminatedBareThinkingOpen`: once a `<name` run is proven complete (immediately followed by anything other than `>`) and the name passes the existing one-typo thinking-name check, it's treated as an open with a `</thinking>` fallback close accepted alongside its own derived close — covering opens and closes that diverge independently within the same turn, matching the pattern actually observed in practice.
10
+ - `AnthropicInbandScanner`'s (native Claude/`xml` dialect) tag tokenizer (`parseTag`) parsed a `<...>` span by taking everything between `<` and the *first* `>` anywhere ahead in the buffer as that one tag's attributes, with no check that the span was a coherent single-line attribute list. An unterminated/malformed open (the same dropped-`>` shape above) therefore did not just leak as text: it silently swallowed every character up to and including the *next* legitimate `>` in the stream — including a real `</thinking>` close — as bogus "attributes", discarding the actual reasoning between them and misclassifying whatever text followed the close as hidden thinking instead of a visible reply. `parseTag` now rejects a match whose captured attrs span contains a newline or an embedded `<` (real single-line attribute lists here, e.g. `signature="…"`, never contain either), falling through to the existing malformed-tag handling that leaks the lone `<` as one visible character and rescans — trading a cosmetic leak for what was previously silent data loss and mislabeling.
11
+
12
+ ## [16.2.27] - 2026-07-07
13
+
14
+ ### Fixed
15
+
16
+ - Leaked reasoning healing (`ThinkingInbandScanner`, and the owned `AnthropicInbandScanner`/`xml` dialect scanner) required an exact `<think>`/`<thinking>`/`<scratchpad>` tag name, so a model that consistently hallucinates a one-character-off spelling for both its open and close tag (e.g. `<thinke>...</thinke>`) leaked the entire block — tag and body — into the visible reply verbatim instead of collapsing into a thinking section. Both scanners now recognize any bare `<name>` whose name is within one character edit of a canonical thinking tag name and shares its prefix (`thinke`, `thinkin`, `scratchpaid`, …), while unrelated short words (`thing`, `thin`, `<div>`, …) are unaffected.
17
+ - Cloud Code Assist (`google-gemini-cli`/`google-antigravity`) buffers a leaked raw tool-planning JSON object (`{"thought": "…", "call": "read", "paths": […]}`, mirroring jeopi's own tool-argument shapes) out of the visible text stream instead of a structured `functionCall` — but the buffer was gated to `model.id.includes("flash")`, so every non-flash id was unprotected. That includes the `google-antigravity` default model (`gemini-3.1-pro`), other Gemini-3 pro/agent tiers, and Claude-on-antigravity ids: the identical leak rendered straight into the TUI as raw JSON on every occurrence instead of being intercepted. The buffer now applies unconditionally across the whole Cloud Code Assist surface; the underlying `isPlanningLeakPrefix`/`isPlanningLeakObject` heuristics only fire on a leading `{"thought"…}`-shaped object, so this carries no additional false-positive risk for other model families.
18
+ - `getOpenRouterHeaders()` identified every OpenRouter-routed request (chat completions and, via a duplicated inline copy in the coding-agent `image-gen` tool, image generation) as `User-Agent: Oh-My-Pi/…`, `HTTP-Referer: https://omp.sh/`, `X-OpenRouter-Title: Oh-My-Pi` — a leftover from before the fork went npm-independent, misattributing every jeopi user's OpenRouter traffic, analytics, and app rankings to the upstream project. Now sends `jeopi/<version>`, the fork's GitHub homepage, and `X-OpenRouter-Title: jeopi`; `image-gen.ts`'s duplicated header block was replaced with a call to the shared helper.
19
+ - `RemoteAuthCredentialStore`'s three mutation-refusal error messages (`replaceAuthCredentialsForProvider`, `upsertAuthCredentialForProvider`, `deleteAuthCredentialsForProvider`) told users to run `omp auth-broker login`/`omp auth-broker logout` — a command that doesn't exist; the real CLI is `jeopi auth-broker login <provider>`/`jeopi auth-broker logout <provider>`. Doc comments across `stream.ts`, `auth-broker/client.ts`, `auth-broker/discover.ts`, `auth-gateway/server.ts`, `auth-gateway/types.ts`, and `providers/pi-native-client.ts` carried the same stale `omp auth-broker`/`omp auth-gateway` command examples and product references. Fixed all to `jeopi`.
20
+
5
21
  ## [16.2.26] - 2026-07-05
6
22
 
7
23
  ### Fixed
package/README.md CHANGED
@@ -970,7 +970,7 @@ and optional mTLS material (`CLAUDE_CODE_CLIENT_CERT`, `CLAUDE_CODE_CLIENT_KEY`)
970
970
  `NODE_EXTRA_CA_CERTS` (PEM file path or inline PEM, mirroring Node's contract)
971
971
  is honoured on every provider fetch — OpenAI-compatible, Codex, Ollama, Azure
972
972
  Responses, Google, and Anthropic alike — for corporate relays or private CA
973
- bundles. Bun's `fetch` does not consume the env var natively, so omp injects
973
+ bundles. Bun's `fetch` does not consume the env var natively, so jeopi injects
974
974
  the bundle into `RequestInit.tls.ca` and seeds the system root store
975
975
  alongside it.
976
976
 
@@ -1068,14 +1068,14 @@ Official docs: [Application Default Credentials](https://cloud.google.com/docs/a
1068
1068
 
1069
1069
  ### CLI Login
1070
1070
 
1071
- Authenticate via the [`omp`](https://omp.sh) coding-agent CLI, which drives this library's OAuth/API-key flows in-process and persists into `agent.db`:
1071
+ Authenticate via the [`jeopi`](https://github.com/akillness/jeopi) coding-agent CLI, which drives this library's OAuth/API-key flows in-process and persists into `agent.db`:
1072
1072
 
1073
1073
  ```bash
1074
- omp auth-broker login # interactive provider selection
1075
- omp auth-broker login anthropic # login to a specific provider
1076
- omp auth-broker login vllm # store vLLM API key (or placeholder for local no-auth)
1077
- omp auth-broker list # list supported providers
1078
- omp auth-broker logout # interactive — pick a stored credential to remove
1074
+ jeopi auth-broker login # interactive provider selection
1075
+ jeopi auth-broker login anthropic # login to a specific provider
1076
+ jeopi auth-broker login vllm # store vLLM API key (or placeholder for local no-auth)
1077
+ jeopi auth-broker list # list supported providers
1078
+ jeopi auth-broker logout # interactive — pick a stored credential to remove
1079
1079
  ```
1080
1080
 
1081
1081
  Credentials are saved to `agent.db` in the agent directory. `/login qianfan` opens the Qianfan console and stores the pasted API key.
@@ -1,8 +1,8 @@
1
1
  /**
2
- * HTTP client for the omp auth-broker server.
2
+ * HTTP client for the jeopi auth-broker server.
3
3
  *
4
4
  * Used by {@link RemoteAuthCredentialStore} (snapshot pulls) and by
5
- * `omp auth-broker status` (liveness checks). All endpoints except
5
+ * `jeopi auth-broker status` (liveness checks). All endpoints except
6
6
  * `/v1/healthz` require a bearer token.
7
7
  */
8
8
  import type { AuthCredential } from "../auth-storage";
@@ -13,7 +13,7 @@ export interface DiscoverAuthStorageOptions {
13
13
  cachePath?: string;
14
14
  sourceLabel?: string;
15
15
  }
16
- /** Path to the local bearer token file. Created by `omp auth-broker token`. */
16
+ /** Path to the local bearer token file. Created by `jeopi auth-broker token`. */
17
17
  export declare function getAuthBrokerTokenFilePath(): string;
18
18
  /**
19
19
  * Resolve broker connection configuration using the same precedence as the TUI:
@@ -1,12 +1,12 @@
1
1
  /**
2
- * omp auth-gateway HTTP server.
2
+ * jeopi auth-gateway HTTP server.
3
3
  *
4
4
  * Accepts any provider-format request (OpenAI chat-completions, Anthropic
5
5
  * messages, OpenAI Responses) and dispatches through pi-ai's `streamSimple()`
6
6
  * — which handles credential injection, anthropic-beta headers, codex
7
7
  * websocket transport, and all the per-provider intricacies. The gateway is
8
- * pure protocol translation: foreign wire → omp Context → pi-ai stream() →
9
- * omp events → foreign wire.
8
+ * pure protocol translation: foreign wire → jeopi Context → pi-ai stream() →
9
+ * jeopi events → foreign wire.
10
10
  *
11
11
  * Endpoints:
12
12
  * GET /healthz → unauth; ok + version
@@ -1,9 +1,9 @@
1
1
  import type { Effort } from "jeopi-catalog/effort";
2
2
  import type { AssistantMessage, AssistantMessageEventStream, CacheRetention, Context, ServiceTier, TokenTaskBudget } from "../types";
3
3
  /**
4
- * Wire types for the omp auth-gateway.
4
+ * Wire types for the jeopi auth-gateway.
5
5
  *
6
- * The gateway sits between unauthenticated clients (containerized omp,
6
+ * The gateway sits between unauthenticated clients (containerized jeopi,
7
7
  * llm-git, …) and the broker. It accepts provider-format HTTP requests
8
8
  * (OpenAI chat-completions / Anthropic messages / OpenAI Responses),
9
9
  * dispatches them through pi-ai's `streamSimple()`, and translates the
@@ -1,4 +1,12 @@
1
1
  import type { InbandScanEvent, InbandScanner } from "./types";
2
+ /**
3
+ * True for the canonical thinking tag names, plus names within one character
4
+ * edit of `think`/`thinking`/`scratchpad` that also share that name's prefix —
5
+ * the shape of a real hallucinated typo (`thinke`, `thinkin`, `scratchpaid`),
6
+ * not an unrelated short word (`thing`, `thin`, `chink` all fail the prefix
7
+ * guard despite being edit-distance 1 from `think`).
8
+ */
9
+ export declare function isThinkingTagName(name: string): boolean;
2
10
  export declare class ThinkingInbandScanner implements InbandScanner {
3
11
  #private;
4
12
  feed(text: string): InbandScanEvent[];
@@ -1,6 +1,6 @@
1
1
  import type { Api, AssistantMessageEventStream as AssistantMessageEventStreamType, Context, Model, SimpleStreamOptions } from "../types";
2
2
  /**
3
- * Stream a turn through an `omp auth-gateway` over the pi-native protocol.
3
+ * Stream a turn through a `jeopi auth-gateway` over the pi-native protocol.
4
4
  *
5
5
  * The returned {@link AssistantMessageEventStream} receives each parsed
6
6
  * `AssistantMessageEvent` verbatim from the gateway; the terminal `done` /
@@ -30,7 +30,7 @@ export declare function getEnvApiKey(provider: string): string | undefined;
30
30
  export declare function getEnvApiKeyName(provider: string): string | undefined;
31
31
  /**
32
32
  * Enumerate every provider that has an env-var fallback for `getEnvApiKey`.
33
- * Used by `omp auth-broker migrate --include-env` to discover env-sourced keys
33
+ * Used by `jeopi auth-broker migrate --include-env` to discover env-sourced keys
34
34
  * that should be uploaded to the broker.
35
35
  */
36
36
  export declare function listProvidersWithEnvKey(): string[];
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "jeopi-ai",
4
- "version": "16.2.26",
4
+ "version": "16.2.28",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://github.com/akillness/jeopi",
7
7
  "author": "Can Boluk",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.0",
41
- "jeopi-catalog": "16.2.26",
42
- "jeopi-utils": "16.2.26",
43
- "jeopi-wire": "16.2.26",
41
+ "jeopi-catalog": "16.2.28",
42
+ "jeopi-utils": "16.2.28",
43
+ "jeopi-wire": "16.2.28",
44
44
  "arktype": "^2.2.0",
45
45
  "zod": "^4"
46
46
  },
@@ -1,8 +1,8 @@
1
1
  /**
2
- * HTTP client for the omp auth-broker server.
2
+ * HTTP client for the jeopi auth-broker server.
3
3
  *
4
4
  * Used by {@link RemoteAuthCredentialStore} (snapshot pulls) and by
5
- * `omp auth-broker status` (liveness checks). All endpoints except
5
+ * `jeopi auth-broker status` (liveness checks). All endpoints except
6
6
  * `/v1/healthz` require a bearer token.
7
7
  */
8
8
 
@@ -38,7 +38,7 @@ export interface DiscoverAuthStorageOptions {
38
38
  sourceLabel?: string;
39
39
  }
40
40
 
41
- /** Path to the local bearer token file. Created by `omp auth-broker token`. */
41
+ /** Path to the local bearer token file. Created by `jeopi auth-broker token`. */
42
42
  export function getAuthBrokerTokenFilePath(): string {
43
43
  return path.join(getConfigRootDir(), "auth-broker.token");
44
44
  }
@@ -328,19 +328,19 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
328
328
 
329
329
  replaceAuthCredentialsForProvider(_provider: string, _credentials: AuthCredential[]): StoredAuthCredential[] {
330
330
  throw new AIError.AuthBrokerError(
331
- "RemoteAuthCredentialStore is read-only on the client. Use `omp auth-broker login <provider>` to mutate credentials.",
331
+ "RemoteAuthCredentialStore is read-only on the client. Use `jeopi auth-broker login <provider>` to mutate credentials.",
332
332
  );
333
333
  }
334
334
 
335
335
  upsertAuthCredentialForProvider(_provider: string, _credential: AuthCredential): StoredAuthCredential[] {
336
336
  throw new AIError.AuthBrokerError(
337
- "RemoteAuthCredentialStore is read-only on the client. Use `omp auth-broker login <provider>` to mutate credentials.",
337
+ "RemoteAuthCredentialStore is read-only on the client. Use `jeopi auth-broker login <provider>` to mutate credentials.",
338
338
  );
339
339
  }
340
340
 
341
341
  deleteAuthCredentialsForProvider(_provider: string, _disabledCause: string): void {
342
342
  throw new AIError.AuthBrokerError(
343
- "RemoteAuthCredentialStore is read-only on the client. Use `omp auth-broker logout <provider>` to mutate credentials.",
343
+ "RemoteAuthCredentialStore is read-only on the client. Use `jeopi auth-broker logout <provider>` to mutate credentials.",
344
344
  );
345
345
  }
346
346
 
@@ -1,12 +1,12 @@
1
1
  /**
2
- * omp auth-gateway HTTP server.
2
+ * jeopi auth-gateway HTTP server.
3
3
  *
4
4
  * Accepts any provider-format request (OpenAI chat-completions, Anthropic
5
5
  * messages, OpenAI Responses) and dispatches through pi-ai's `streamSimple()`
6
6
  * — which handles credential injection, anthropic-beta headers, codex
7
7
  * websocket transport, and all the per-provider intricacies. The gateway is
8
- * pure protocol translation: foreign wire → omp Context → pi-ai stream() →
9
- * omp events → foreign wire.
8
+ * pure protocol translation: foreign wire → jeopi Context → pi-ai stream() →
9
+ * jeopi events → foreign wire.
10
10
  *
11
11
  * Endpoints:
12
12
  * GET /healthz → unauth; ok + version
@@ -9,9 +9,9 @@ import type {
9
9
  } from "../types";
10
10
 
11
11
  /**
12
- * Wire types for the omp auth-gateway.
12
+ * Wire types for the jeopi auth-gateway.
13
13
  *
14
- * The gateway sits between unauthenticated clients (containerized omp,
14
+ * The gateway sits between unauthenticated clients (containerized jeopi,
15
15
  * llm-git, …) and the broker. It accepts provider-format HTTP requests
16
16
  * (OpenAI chat-completions / Anthropic messages / OpenAI Responses),
17
17
  * dispatches them through pi-ai's `streamSimple()`, and translates the
@@ -9,6 +9,7 @@ import {
9
9
  renderLegacyTextTranscript,
10
10
  stringifyJson,
11
11
  } from "./rendering";
12
+ import { isThinkingTagName } from "./thinking";
12
13
  import type {
13
14
  DialectDefinition,
14
15
  DialectRenderOptions,
@@ -22,7 +23,6 @@ const MAX_PARTIAL_TAG_LENGTH = 256;
22
23
  const MAX_PARAMETER_VALUE_LENGTH = 1_000_000;
23
24
 
24
25
  const WRAPPER_TAGS: Readonly<Record<string, true>> = { function_calls: true, tool_calls: true };
25
- const THINKING_TAGS: Record<string, true> = { thinking: true, think: true, scratchpad: true };
26
26
  const BASE_TAG_PREFIXES = [
27
27
  "<function_calls",
28
28
  "</function_calls",
@@ -217,7 +217,7 @@ export class AnthropicInbandScanner implements InbandScanner {
217
217
  this.#startInvoke(tag, "section", events);
218
218
  return true;
219
219
  }
220
- if (this.#parseThinking && !tag.closing && THINKING_TAGS[tag.localName] === true) {
220
+ if (this.#parseThinking && !tag.closing && isThinkingTagName(tag.localName)) {
221
221
  this.#startThinking(tag, "section", events);
222
222
  }
223
223
  return true;
@@ -480,7 +480,7 @@ export class AnthropicInbandScanner implements InbandScanner {
480
480
 
481
481
  #isThinkingOpen(tag: ParsedTag): boolean {
482
482
  if (!this.#parseThinking || tag.closing) return false;
483
- return THINKING_TAGS[tag.localName] === true;
483
+ return isThinkingTagName(tag.localName);
484
484
  }
485
485
 
486
486
  #relevantPrefixes(): readonly string[] {
@@ -494,6 +494,23 @@ export class AnthropicInbandScanner implements InbandScanner {
494
494
 
495
495
  const ALL_TAG_PREFIXES = [...BASE_TAG_PREFIXES, ...ANTHROPIC_THINKING_TAG_PREFIXES] as const;
496
496
 
497
+ /**
498
+ * Parse one `<...>` span already isolated by `#peekTag` (buffer up to and
499
+ * including its first `>`).
500
+ *
501
+ * The `attrs` capture (`[^>]*` with `/s`, so it also matches newlines) has no
502
+ * bound on its own past "not another `>`" — an unterminated open tag whose
503
+ * name/spelling never resolves before the *next* legitimate `>` elsewhere in
504
+ * the buffer (e.g. a later `</thinking>`) would otherwise parse as one tag
505
+ * whose bogus "attributes" span silently swallows everything in between,
506
+ * including real content and that legitimate close tag. Reject the match
507
+ * when the attrs span contains a newline or an embedded `<`: real attribute
508
+ * lists here (e.g. `signature="…"`) are always single-line with no nested
509
+ * tags, so either is conclusive proof `raw` isn't one coherent tag. The
510
+ * caller (`#peekTag` returning `undefined`) then falls through to emitting
511
+ * the leading `<` as one visible character and rescanning — malformed input
512
+ * leaks as text instead of corrupting parser state.
513
+ */
497
514
  function parseTag(raw: string): ParsedTag | undefined {
498
515
  const match = /^<\s*(\/?)\s*(?:(?<prefix>[A-Za-z_][\w.-]*):)?(?<localName>[A-Za-z_][\w.-]*)(?<attrs>[^>]*)>$/s.exec(
499
516
  raw,
@@ -501,6 +518,7 @@ function parseTag(raw: string): ParsedTag | undefined {
501
518
  const localName = match?.groups?.localName;
502
519
  if (!match || !localName) return undefined;
503
520
  const attrsText = match.groups?.attrs ?? "";
521
+ if (attrsText.includes("\n") || attrsText.includes("<")) return undefined;
504
522
  return {
505
523
  raw,
506
524
  localName: localName.toLowerCase(),
@@ -2,7 +2,12 @@ import { partialSuffixOverlapAny } from "./coercion";
2
2
  import { FencedThinkingScanner } from "./fenced-thinking";
3
3
  import type { InbandScanEvent, InbandScanner } from "./types";
4
4
 
5
- type Tag = { readonly open: string; readonly close: string; readonly fenced?: boolean };
5
+ type Tag = {
6
+ readonly open: string;
7
+ readonly close: string;
8
+ readonly fenced?: boolean;
9
+ readonly closeFallback?: string;
10
+ };
6
11
 
7
12
  /**
8
13
  * Every dialect's in-band thinking section in its canonical `renderThinking`
@@ -14,21 +19,154 @@ type Tag = { readonly open: string; readonly close: string; readonly fenced?: bo
14
19
  * emits and what models leak in practice. Attributed or namespaced XML thinking
15
20
  * tags (`<thinking signature="…">`, `antml:thinking`) are recovered by the owned
16
21
  * anthropic-dialect parser, not this text-channel healing fallback.
22
+ *
23
+ * `<think>`/`<thinking>`/`<scratchpad>` are additionally matched with one-typo
24
+ * tolerance ({@link isThinkingTagName}) — weaker models occasionally hallucinate
25
+ * a near-miss spelling (`<thinke>`, `<thinkin>`) consistently for both the open
26
+ * and close tag on long turns; without this the whole malformed pair leaks
27
+ * verbatim into the visible channel instead of collapsing into a thinking block.
17
28
  */
18
29
  const TAGS: readonly Tag[] = [
19
- { open: "<think>", close: "</think>" }, // deepseek, glm, hermes, kimi, qwen3 (and anthropic/minimax/xml)
20
- { open: "<thinking>", close: "</thinking>" }, // anthropic, minimax, xml
21
- { open: "<scratchpad>", close: "</scratchpad>" }, // anthropic
22
30
  { open: "```thinking\n", close: "```", fenced: true }, // gemini fenced thinking
23
31
  { open: "<|channel>thought\n", close: "<channel|>" }, // gemma reasoning channel
24
32
  { open: "<|start|>assistant<|channel|>analysis<|message|>", close: "<|end|>" }, // harmony analysis (rendered)
25
33
  { open: "<|channel|>analysis<|message|>", close: "<|end|>" }, // harmony analysis (bare leak)
26
34
  ];
27
35
  const OPENS = TAGS.map(tag => tag.open);
36
+ /** Longest bare `<name>` this scanner ever recognizes; bounds partial-tag holding. */
37
+ const MAX_BARE_TAG_NAME_LENGTH = 20;
38
+ const BARE_TAG_OPEN_PATTERN = /<([A-Za-z][A-Za-z0-9]{0,19})>/g;
39
+ const BARE_TAG_PARTIAL_PATTERN = /<(?:[A-Za-z][A-Za-z0-9]{0,19})?$/;
40
+
41
+ /**
42
+ * True for the canonical thinking tag names, plus names within one character
43
+ * edit of `think`/`thinking`/`scratchpad` that also share that name's prefix —
44
+ * the shape of a real hallucinated typo (`thinke`, `thinkin`, `scratchpaid`),
45
+ * not an unrelated short word (`thing`, `thin`, `chink` all fail the prefix
46
+ * guard despite being edit-distance 1 from `think`).
47
+ */
48
+ export function isThinkingTagName(name: string): boolean {
49
+ const lower = name.toLowerCase();
50
+ if (lower === "thinking" || lower === "think" || lower === "scratchpad") return true;
51
+ if (lower.startsWith("think") && levenshteinAtMost(lower, "think", 1)) return true;
52
+ if (lower.startsWith("thinking") && levenshteinAtMost(lower, "thinking", 1)) return true;
53
+ if (lower.startsWith("scratchpad") && levenshteinAtMost(lower, "scratchpad", 1)) return true;
54
+ return false;
55
+ }
56
+
57
+ /** Bounded Levenshtein distance check; avoids the full DP once `max` is exceeded. */
58
+ function levenshteinAtMost(a: string, b: string, max: number): boolean {
59
+ if (Math.abs(a.length - b.length) > max) return false;
60
+ const prev = new Array<number>(b.length + 1);
61
+ const curr = new Array<number>(b.length + 1);
62
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
63
+ for (let i = 1; i <= a.length; i++) {
64
+ curr[0] = i;
65
+ let rowMin = curr[0]!;
66
+ for (let j = 1; j <= b.length; j++) {
67
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
68
+ curr[j] = Math.min(prev[j]! + 1, curr[j - 1]! + 1, prev[j - 1]! + cost);
69
+ rowMin = Math.min(rowMin, curr[j]!);
70
+ }
71
+ if (rowMin > max) return false;
72
+ for (let j = 0; j <= b.length; j++) prev[j] = curr[j]!;
73
+ }
74
+ return prev[b.length]! <= max;
75
+ }
76
+
77
+ /**
78
+ * Earliest bare `<name>` tag (no attributes) whose name passes
79
+ * {@link isThinkingTagName}, paired with its matching `</name>` close. Runs
80
+ * alongside the literal {@link TAGS} idioms so `<think>`/`<thinking>`/
81
+ * `<scratchpad>` and their one-typo variants share a single fuzzy path.
82
+ * `closeFallback` additionally accepts a clean `</thinking>` close even when
83
+ * the open itself was a typo'd variant — evidence shows a model's open and
84
+ * close spellings can diverge independently within the same turn.
85
+ */
86
+ function findBareThinkingOpen(buffer: string): (Tag & { index: number }) | undefined {
87
+ for (const match of buffer.matchAll(BARE_TAG_OPEN_PATTERN)) {
88
+ const name = match[1]!;
89
+ if (!isThinkingTagName(name)) continue;
90
+ const close = `</${name}>`;
91
+ return {
92
+ open: match[0],
93
+ close,
94
+ closeFallback: close === "</thinking>" ? undefined : "</thinking>",
95
+ index: match.index,
96
+ };
97
+ }
98
+ return undefined;
99
+ }
100
+
101
+ /**
102
+ * Detect a bare thinking-tag open that can never close validly: `<` followed
103
+ * by a name-shaped run (passing {@link isThinkingTagName}) that is itself
104
+ * immediately followed by something other than `>` — whitespace, a newline,
105
+ * or any other character. A model that drops the closing `>` (or garbles
106
+ * `<thinking>` into `<thinke` before moving straight into its actual
107
+ * reasoning) leaves exactly this shape: `<thinke\n<reasoning text>`. Unlike
108
+ * {@link bareTagPartialHold}, which holds a run that could *still* grow into
109
+ * a valid `<name>`, this fires only once the buffer proves the run is over
110
+ * and no `>` arrived — so it never races with, or preempts, the
111
+ * well-formed/one-typo match above (that one requires `>` immediately after
112
+ * the name; this one requires the opposite).
113
+ *
114
+ * `closeFallback` is always `</thinking>`: the malformed open carries no
115
+ * reliable spelling to expect back, so any well-formed thinking close ends
116
+ * the block. This is the exact failure mode reported in practice: a broken
117
+ * open (`<thinke`, no `>`) paired with a clean `</thinking>` close — without
118
+ * this, neither tag is ever recognized and both leak verbatim into the
119
+ * visible channel around the reasoning text they were meant to wrap.
120
+ */
121
+ function findUnterminatedBareThinkingOpen(buffer: string): (Tag & { index: number }) | undefined {
122
+ for (let i = 0; i < buffer.length; i++) {
123
+ if (buffer[i] !== "<") continue;
124
+ const nameStart = i + 1;
125
+ if (!/[A-Za-z]/.test(buffer[nameStart] ?? "")) continue;
126
+ let end = nameStart + 1;
127
+ while (end < buffer.length && end - nameStart < MAX_BARE_TAG_NAME_LENGTH && /[A-Za-z0-9]/.test(buffer[end]!)) {
128
+ end++;
129
+ }
130
+ // Still growable (name run hit the end of the buffer with no terminator
131
+ // yet), or a valid `>`-closed tag — not this function's job either way.
132
+ if (end >= buffer.length || buffer[end] === ">") continue;
133
+ const name = buffer.slice(nameStart, end);
134
+ if (!isThinkingTagName(name)) continue;
135
+ return { open: buffer.slice(i, end), close: `</${name}>`, closeFallback: "</thinking>", index: i };
136
+ }
137
+ return undefined;
138
+ }
139
+
140
+ /**
141
+ * Holds back a buffer tail shaped like an unterminated bare tag (`<`,
142
+ * optionally followed by letters/digits, no `>` yet) so a diverging spelling
143
+ * isn't flushed as visible text one character before its typo would have
144
+ * matched {@link isThinkingTagName} — e.g. `<thinke` must survive until the
145
+ * closing `>` arrives and resolves it. Bounded by
146
+ * {@link MAX_BARE_TAG_NAME_LENGTH} so an angle bracket in ordinary prose
147
+ * can't stall the stream indefinitely; once that bound is exceeded without a
148
+ * `>`, {@link findUnterminatedBareThinkingOpen} has already had — and taken —
149
+ * its chance to recognize a conclusively-broken thinking-tag attempt on the
150
+ * same buffer, so anything still unresolved here is either not
151
+ * thinking-tag-shaped or a `<` in ordinary prose, and is safe to release as
152
+ * visible text.
153
+ */
154
+ function bareTagPartialHold(buffer: string): number {
155
+ const openIndex = buffer.lastIndexOf("<");
156
+ if (openIndex === -1) return 0;
157
+ const tail = buffer.slice(openIndex);
158
+ if (tail.length > MAX_BARE_TAG_NAME_LENGTH + 1) return 0;
159
+ return BARE_TAG_PARTIAL_PATTERN.test(tail) ? tail.length : 0;
160
+ }
28
161
 
29
162
  export class ThinkingInbandScanner implements InbandScanner {
30
163
  #buffer = "";
31
164
  #closeTag = "";
165
+ /** Alternate close accepted alongside {@link #closeTag} — set when the open
166
+ * tag's own spelling can't be trusted as a predictor of the close spelling
167
+ * (see {@link findUnterminatedBareThinkingOpen}). Empty when there is no
168
+ * fallback, in which case only `#closeTag` is checked. */
169
+ #closeFallback = "";
32
170
  #thinking = "";
33
171
  /** Fence-aware close-matcher while inside a ` ```thinking ` block; undefined otherwise. */
34
172
  #fenced: FencedThinkingScanner | undefined;
@@ -50,6 +188,7 @@ export class ThinkingInbandScanner implements InbandScanner {
50
188
  }
51
189
  this.#buffer = "";
52
190
  this.#closeTag = "";
191
+ this.#closeFallback = "";
53
192
  return events;
54
193
  }
55
194
 
@@ -65,6 +204,7 @@ export class ThinkingInbandScanner implements InbandScanner {
65
204
  events.push({ type: "thinkingEnd", thinking: this.#thinking });
66
205
  this.#thinking = "";
67
206
  this.#closeTag = "";
207
+ this.#closeFallback = "";
68
208
  this.#fenced = undefined;
69
209
  }
70
210
  if (this.#fenced) break;
@@ -72,24 +212,28 @@ export class ThinkingInbandScanner implements InbandScanner {
72
212
  }
73
213
  if (this.#buffer.length === 0) break;
74
214
  if (this.#closeTag) {
75
- const close = this.#buffer.indexOf(this.#closeTag);
76
- if (close === -1) {
77
- const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, [this.#closeTag]);
215
+ const candidates = this.#closeFallback ? [this.#closeTag, this.#closeFallback] : [this.#closeTag];
216
+ const found = earliestIndexOf(this.#buffer, candidates);
217
+ if (!found) {
218
+ const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, candidates);
78
219
  this.#emitThinking(this.#buffer.slice(0, this.#buffer.length - hold), events);
79
220
  this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
80
221
  break;
81
222
  }
82
- this.#emitThinking(this.#buffer.slice(0, close), events);
83
- this.#buffer = this.#buffer.slice(close + this.#closeTag.length);
223
+ this.#emitThinking(this.#buffer.slice(0, found.index), events);
224
+ this.#buffer = this.#buffer.slice(found.index + found.tag.length);
84
225
  events.push({ type: "thinkingEnd", thinking: this.#thinking });
85
226
  this.#thinking = "";
86
227
  this.#closeTag = "";
228
+ this.#closeFallback = "";
87
229
  continue;
88
230
  }
89
231
 
90
232
  const tag = findEarliestOpen(this.#buffer);
91
233
  if (!tag) {
92
- const hold = final ? 0 : partialSuffixOverlapAny(this.#buffer, OPENS);
234
+ const hold = final
235
+ ? 0
236
+ : Math.max(partialSuffixOverlapAny(this.#buffer, OPENS), bareTagPartialHold(this.#buffer));
93
237
  const emit = this.#buffer.slice(0, this.#buffer.length - hold);
94
238
  if (emit.length > 0) events.push({ type: "text", text: emit });
95
239
  this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
@@ -98,6 +242,7 @@ export class ThinkingInbandScanner implements InbandScanner {
98
242
  if (tag.index > 0) events.push({ type: "text", text: this.#buffer.slice(0, tag.index) });
99
243
  this.#buffer = this.#buffer.slice(tag.index + tag.open.length);
100
244
  this.#closeTag = tag.close;
245
+ this.#closeFallback = tag.closeFallback ?? "";
101
246
  this.#thinking = "";
102
247
  if (tag.fenced) this.#fenced = new FencedThinkingScanner();
103
248
  events.push({ type: "thinkingStart" });
@@ -112,11 +257,25 @@ export class ThinkingInbandScanner implements InbandScanner {
112
257
  }
113
258
  }
114
259
 
260
+ /** Earliest match among several candidate substrings, or `undefined` if none occur. */
261
+ function earliestIndexOf(buffer: string, candidates: readonly string[]): { index: number; tag: string } | undefined {
262
+ let best: { index: number; tag: string } | undefined;
263
+ for (const tag of candidates) {
264
+ const index = buffer.indexOf(tag);
265
+ if (index !== -1 && (!best || index < best.index)) best = { index, tag };
266
+ }
267
+ return best;
268
+ }
269
+
115
270
  function findEarliestOpen(buffer: string): (Tag & { index: number }) | undefined {
116
271
  let best: (Tag & { index: number }) | undefined;
117
272
  for (const tag of TAGS) {
118
273
  const index = buffer.indexOf(tag.open);
119
274
  if (index !== -1 && (!best || index < best.index)) best = { ...tag, index };
120
275
  }
276
+ const bare = findBareThinkingOpen(buffer);
277
+ if (bare && (!best || bare.index < best.index)) best = bare;
278
+ const unterminated = findUnterminatedBareThinkingOpen(buffer);
279
+ if (unterminated && (!best || unterminated.index < best.index)) best = unterminated;
121
280
  return best;
122
281
  }
@@ -715,7 +715,21 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
715
715
  options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(undefined, 300_000);
716
716
  const callerSignal = options?.signal;
717
717
  const toolNames = new Set(context.tools?.map(t => t.name) ?? []);
718
- const isFlashLeakModel = model.id.includes("flash");
718
+ // Cloud Code Assist (both `google-gemini-cli` and `google-antigravity`,
719
+ // covering Gemini and Claude-on-antigravity ids alike) can leak its raw
720
+ // tool-planning JSON (`{"thought": "…", "call": "read", "paths": […]}`,
721
+ // mirroring jeopi's own tool-argument shapes) into the visible text
722
+ // channel instead of emitting a structured `functionCall`. This was
723
+ // first captured and fixed for `gemini-3.5-flash`, but the gate on
724
+ // `model.id.includes("flash")` left every non-flash id (including the
725
+ // `google-antigravity` default model `gemini-3.1-pro`, other Gemini-3
726
+ // pro/flash-agent tiers, and Claude-on-antigravity ids) unprotected —
727
+ // so the same leak still renders straight into the TUI as raw JSON
728
+ // text on every occurrence for those models. `isPlanningLeakPrefix`/
729
+ // `isPlanningLeakObject` are narrow, shape-specific heuristics (they
730
+ // only fire on a leading `{"thought"…}`-style object), so buffering is
731
+ // safe to apply unconditionally across this whole provider surface
732
+ // rather than gating it to one model family.
719
733
 
720
734
  let started = false;
721
735
  let sawFinishReason = false;
@@ -899,7 +913,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
899
913
  bufferedTextSignature,
900
914
  part.thoughtSignature,
901
915
  );
902
- } else if (isFlashLeakModel && part.text.trimStart().startsWith("{")) {
916
+ } else if (part.text.trimStart().startsWith("{")) {
903
917
  isBuffering = true;
904
918
  textBuffer = part.text;
905
919
  bufferedTextSignature = part.thoughtSignature;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Client half of the pi-native auth-gateway protocol.
3
3
  *
4
- * Dispatches a {@link streamSimple}-shaped request to an `omp auth-gateway`
4
+ * Dispatches a {@link streamSimple}-shaped request to a `jeopi auth-gateway`
5
5
  * via `POST /v1/pi/stream`, reads the SSE event stream back, and pushes the
6
6
  * parsed events into a local {@link AssistantMessageEventStream} — the same
7
7
  * stream type every other provider client produces. Callers downstream of
@@ -125,7 +125,7 @@ function buildHeaders(model: Model<Api>, apiKey: string | undefined): Record<str
125
125
  }
126
126
 
127
127
  /**
128
- * Stream a turn through an `omp auth-gateway` over the pi-native protocol.
128
+ * Stream a turn through a `jeopi auth-gateway` over the pi-native protocol.
129
129
  *
130
130
  * The returned {@link AssistantMessageEventStream} receives each parsed
131
131
  * `AssistantMessageEvent` verbatim from the gateway; the terminal `done` /
package/src/stream.ts CHANGED
@@ -686,7 +686,7 @@ export function getEnvApiKeyName(provider: string): string | undefined {
686
686
 
687
687
  /**
688
688
  * Enumerate every provider that has an env-var fallback for `getEnvApiKey`.
689
- * Used by `omp auth-broker migrate --include-env` to discover env-sourced keys
689
+ * Used by `jeopi auth-broker migrate --include-env` to discover env-sourced keys
690
690
  * that should be uploaded to the broker.
691
691
  */
692
692
  export function listProvidersWithEnvKey(): string[] {
@@ -2,9 +2,9 @@ import packageJson from "../../package.json" with { type: "json" };
2
2
 
3
3
  export function getOpenRouterHeaders(): Record<string, string> {
4
4
  return {
5
- "User-Agent": `Oh-My-Pi/${packageJson.version}`,
6
- "HTTP-Referer": "https://omp.sh/",
7
- "X-OpenRouter-Title": "Oh-My-Pi",
5
+ "User-Agent": `jeopi/${packageJson.version}`,
6
+ "HTTP-Referer": packageJson.homepage,
7
+ "X-OpenRouter-Title": "jeopi",
8
8
  "X-OpenRouter-Categories": "cli-agent",
9
9
  "X-OpenRouter-Cache": "true",
10
10
  "X-OpenRouter-Cache-TTL": "3600",