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 +16 -0
- package/README.md +7 -7
- package/dist/types/auth-broker/client.d.ts +2 -2
- package/dist/types/auth-broker/discover.d.ts +1 -1
- package/dist/types/auth-gateway/server.d.ts +3 -3
- package/dist/types/auth-gateway/types.d.ts +2 -2
- package/dist/types/dialect/thinking.d.ts +8 -0
- package/dist/types/providers/pi-native-client.d.ts +1 -1
- package/dist/types/stream.d.ts +1 -1
- package/package.json +4 -4
- package/src/auth-broker/client.ts +2 -2
- package/src/auth-broker/discover.ts +1 -1
- package/src/auth-broker/remote-store.ts +3 -3
- package/src/auth-gateway/server.ts +3 -3
- package/src/auth-gateway/types.ts +2 -2
- package/src/dialect/anthropic.ts +21 -3
- package/src/dialect/thinking.ts +169 -10
- package/src/providers/google-gemini-cli.ts +16 -2
- package/src/providers/pi-native-client.ts +2 -2
- package/src/stream.ts +1 -1
- package/src/utils/openrouter-headers.ts +3 -3
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
|
|
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 [`
|
|
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
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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
|
|
2
|
+
* HTTP client for the jeopi auth-broker server.
|
|
3
3
|
*
|
|
4
4
|
* Used by {@link RemoteAuthCredentialStore} (snapshot pulls) and by
|
|
5
|
-
* `
|
|
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 `
|
|
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
|
-
*
|
|
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 →
|
|
9
|
-
*
|
|
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
|
|
4
|
+
* Wire types for the jeopi auth-gateway.
|
|
5
5
|
*
|
|
6
|
-
* The gateway sits between unauthenticated clients (containerized
|
|
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
|
|
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` /
|
package/dist/types/stream.d.ts
CHANGED
|
@@ -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 `
|
|
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.
|
|
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.
|
|
42
|
-
"jeopi-utils": "16.2.
|
|
43
|
-
"jeopi-wire": "16.2.
|
|
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
|
|
2
|
+
* HTTP client for the jeopi auth-broker server.
|
|
3
3
|
*
|
|
4
4
|
* Used by {@link RemoteAuthCredentialStore} (snapshot pulls) and by
|
|
5
|
-
* `
|
|
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 `
|
|
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 `
|
|
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 `
|
|
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 `
|
|
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
|
-
*
|
|
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 →
|
|
9
|
-
*
|
|
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
|
|
12
|
+
* Wire types for the jeopi auth-gateway.
|
|
13
13
|
*
|
|
14
|
-
* The gateway sits between unauthenticated clients (containerized
|
|
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
|
package/src/dialect/anthropic.ts
CHANGED
|
@@ -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 &&
|
|
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
|
|
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(),
|
package/src/dialect/thinking.ts
CHANGED
|
@@ -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 = {
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
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,
|
|
83
|
-
this.#buffer = this.#buffer.slice(
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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 `
|
|
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": `
|
|
6
|
-
"HTTP-Referer":
|
|
7
|
-
"X-OpenRouter-Title": "
|
|
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",
|