@oh-my-pi/pi-ai 18.4.9 → 18.4.11
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 +18 -0
- package/dist/types/providers/transform-messages.d.ts +7 -0
- package/dist/types/utils/event-stream.d.ts +8 -0
- package/dist/types/utils/idle-iterator.d.ts +11 -8
- package/dist/types/utils/tool-call-arguments.d.ts +9 -0
- package/dist/types/utils.d.ts +16 -0
- package/package.json +6 -6
- package/src/auth-broker/remote-store.ts +35 -19
- package/src/dialect/deepseek.ts +2 -5
- package/src/dialect/harmony.ts +2 -6
- package/src/dialect/hermes.ts +2 -5
- package/src/dialect/kimi.ts +2 -6
- package/src/dialect/owned-stream.ts +28 -3
- package/src/dialect/qwen3.ts +2 -5
- package/src/error/rate-limit.ts +11 -0
- package/src/providers/amazon-bedrock.ts +101 -70
- package/src/providers/anthropic.ts +5 -18
- package/src/providers/apple-foundation-models.ts +2 -1
- package/src/providers/cursor.ts +21 -9
- package/src/providers/devin.ts +3 -2
- package/src/providers/gitlab-duo-workflow.ts +3 -8
- package/src/providers/ollama.ts +3 -1
- package/src/providers/openai-codex-responses.ts +6 -4
- package/src/providers/openai-completions.ts +2 -1
- package/src/providers/openai-shared.ts +5 -7
- package/src/providers/register-builtins.ts +1 -1
- package/src/providers/transform-messages.ts +32 -14
- package/src/registry/oauth/factory-droid.ts +21 -1
- package/src/utils/event-stream.ts +13 -0
- package/src/utils/idle-iterator.ts +29 -24
- package/src/utils/tool-call-arguments.ts +32 -0
- package/src/utils/validation.ts +2 -6
- package/src/utils.ts +113 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.4.11] - 2026-10-02
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed Cursor cached prompt token accounting to prevent duplicate input-token and cost reporting on cached turns.
|
|
10
|
+
- Fixed auth-broker credential handling so late token-refresh responses cannot restore logged-out credentials or overwrite a newer login.
|
|
11
|
+
|
|
12
|
+
## [18.4.10] - 2026-10-02
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Factory Droid login now reports the account's organization error instead of accepting a credential that every request rejects ([#14032](https://github.com/can1357/oh-my-pi/issues/14032)).
|
|
17
|
+
- Fixed Cursor turns being aborted with "Provider stream stalled while waiting for the next event" right after a long local tool finished; the provider now gets a full idle window once local tool work completes ([#13682](https://github.com/can1357/oh-my-pi/pull/13682) by [@eggpeat](https://github.com/eggpeat))
|
|
18
|
+
- Tool calls whose final argument JSON is cut off or followed by trailing text are no longer executed from an auto-closed preview. OpenAI Completions, Anthropic, Bedrock, Responses, Codex, Devin, Ollama, Apple, Cursor, GitLab Duo, and the in-band JSON dialects now give such a call the existing parse-error arguments, so the tool is not run and the model receives the parse error and can resend the call ([#13868](https://github.com/can1357/oh-my-pi/pull/13868) by [@alphastorm](https://github.com/alphastorm))
|
|
19
|
+
- Fixed Cursor "prepaid balance is used up" (`USAGE_PRICING_REQUIRED`) failures repeating on the same account instead of rotating to a sibling Cursor credential ([#14053](https://github.com/can1357/oh-my-pi/issues/14053))
|
|
20
|
+
- Sessions no longer get stuck on `400 string_above_max_length` after a model writes its whole tool invocation into the tool name. Tool calls with blank names, names longer than 128 characters, or names containing whitespace or control characters are dropped from replayed history, together with their tool results. This also applies when OpenAI Responses replays its stored native history ([#13985](https://github.com/can1357/oh-my-pi/pull/13985) by [@Xytronix](https://github.com/Xytronix)).
|
|
21
|
+
- Fixed Bedrock Converse requests failing with a "bound to a different conversation" 400 after the system prompt changed under signed thinking: the request is retried once without replayed reasoning ([#14019](https://github.com/can1357/oh-my-pi/pull/14019) by [@nick-maderight](https://github.com/nick-maderight))
|
|
22
|
+
|
|
5
23
|
## [18.4.9] - 2026-10-01
|
|
6
24
|
|
|
7
25
|
### Fixed
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { Api, AssistantMessage, Message, Model } from "../types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Whether a tool-call name cannot belong to any declared tool: missing, empty,
|
|
4
|
+
* longer than OpenAI's 128-character replay limit, or containing whitespace or
|
|
5
|
+
* control characters (tool schemas never allow them; their presence means the
|
|
6
|
+
* name slot carries invocation text).
|
|
7
|
+
*/
|
|
8
|
+
export declare function isMalformedToolCallName(name: unknown): boolean;
|
|
2
9
|
/**
|
|
3
10
|
* Normalize tool call ID for cross-provider compatibility.
|
|
4
11
|
* OpenAI Responses API generates IDs that are 450+ chars with special characters like `|`.
|
|
@@ -2,6 +2,12 @@ import type { AssistantMessage, AssistantMessageEvent } from "../types.js";
|
|
|
2
2
|
/** Anything a stream watchdog can consult for in-flight consumer-side local work. */
|
|
3
3
|
export interface LocalWorkSource {
|
|
4
4
|
readonly hasPendingLocalWork: boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Epoch ms at which tracked local work last drained to zero, or 0 if none
|
|
7
|
+
* has completed. The provider cannot answer until it receives the local
|
|
8
|
+
* result, so idle watchdogs measure provider silence from this instant.
|
|
9
|
+
*/
|
|
10
|
+
readonly localWorkSettledAt: number;
|
|
5
11
|
}
|
|
6
12
|
export declare class EventStream<T, R = T> implements AsyncIterable<T> {
|
|
7
13
|
#private;
|
|
@@ -35,6 +41,8 @@ export declare class EventStream<T, R = T> implements AsyncIterable<T> {
|
|
|
35
41
|
result(): Promise<R>;
|
|
36
42
|
/** True while local work tracked via {@link trackLocalWork} — on this stream or a forwarded delegate — is pending. */
|
|
37
43
|
get hasPendingLocalWork(): boolean;
|
|
44
|
+
/** Latest {@link LocalWorkSource.localWorkSettledAt} across this stream and a forwarded delegate. */
|
|
45
|
+
get localWorkSettledAt(): number;
|
|
38
46
|
/**
|
|
39
47
|
* Count `source`'s pending local work as this stream's own. Used when this
|
|
40
48
|
* stream forwards another's events (Cursor discovered-id retry) so the
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { LocalWorkSource } from "./event-stream.js";
|
|
1
2
|
/**
|
|
2
3
|
* Returns the idle timeout used for provider streaming transports.
|
|
3
4
|
*
|
|
@@ -86,15 +87,17 @@ export interface IdleTimeoutIteratorOptions {
|
|
|
86
87
|
*/
|
|
87
88
|
isProgressItem?: (item: unknown) => boolean;
|
|
88
89
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
90
|
+
* Consumer-side local work in flight for the stream: the provider transport
|
|
91
|
+
* is waiting on a server-requested local tool bridge (e.g. the Cursor exec
|
|
92
|
+
* channel) before anything can flow upstream again. While work is pending,
|
|
93
|
+
* an expired idle / first-item deadline slides forward instead of aborting —
|
|
94
|
+
* the silence is ours, not a provider stall. The provider cannot respond
|
|
95
|
+
* before it receives the local result, so both deadlines are measured from
|
|
96
|
+
* no earlier than `localWorkSettledAt`: a tool that finishes just before the
|
|
97
|
+
* deadline gets a full budget, and a provider that stalls afterwards is
|
|
98
|
+
* still caught.
|
|
96
99
|
*/
|
|
97
|
-
|
|
100
|
+
localWork?: LocalWorkSource;
|
|
98
101
|
/**
|
|
99
102
|
* Cancel iteration as soon as this signal aborts. Required for caller-driven
|
|
100
103
|
* cancellation (ESC) when the underlying transport does not surface signal
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ToolCall } from "../types.js";
|
|
2
|
+
/** Final arguments must not reuse the streaming parser's auto-closed preview. */
|
|
3
|
+
export declare function parseToolCallArguments(json: string | undefined): ToolCall["arguments"];
|
|
4
|
+
/** Longest raw argument text kept in the parse-error diagnostic. */
|
|
5
|
+
export declare const INVALID_ARGUMENTS_RAW_LIMIT = 512;
|
|
6
|
+
/** Bound raw argument text for a diagnostic. Text this function already bounded is returned unchanged. */
|
|
7
|
+
export declare function boundRawToolArguments(raw: string): string;
|
|
8
|
+
/** Preserve a bounded diagnostic, never any partially recovered executable keys. */
|
|
9
|
+
export declare function invalidToolCallArguments(raw: string, error: unknown): ToolCall["arguments"];
|
package/dist/types/utils.d.ts
CHANGED
|
@@ -63,6 +63,22 @@ export declare function sanitizeOpenAIResponsesAssistantHistoryItemsForReplay(it
|
|
|
63
63
|
* Drop hidden-only fallback assistant replay after a native Responses snapshot is rejected.
|
|
64
64
|
*/
|
|
65
65
|
export declare function sanitizeOpenAIResponsesAssistantFallbackItemsForReplay(items: ResponseInput): ResponseInput;
|
|
66
|
+
/**
|
|
67
|
+
* Drop each native tool call with a malformed name and only the matching
|
|
68
|
+
* `function_call_output` / `custom_tool_call_output` in the same window.
|
|
69
|
+
*
|
|
70
|
+
* `call_id` is not unique across a snapshot. Pair the same way
|
|
71
|
+
* `sanitizeMalformedToolCalls` does for transcript messages: a per-id FIFO of
|
|
72
|
+
* malformed-ness, keyed by function vs custom, cleared at user/developer and
|
|
73
|
+
* other non-result client boundaries so a missing result cannot eat the next
|
|
74
|
+
* window's output. Returns `items` itself when nothing is malformed.
|
|
75
|
+
*/
|
|
76
|
+
export declare function dropMalformedOpenAIResponsesToolCalls<T extends {
|
|
77
|
+
type?: unknown;
|
|
78
|
+
call_id?: unknown;
|
|
79
|
+
name?: unknown;
|
|
80
|
+
role?: unknown;
|
|
81
|
+
}>(items: readonly T[]): T[];
|
|
66
82
|
export declare function createOpenAIResponsesHistoryPayload(provider: string, items: Array<Record<string, unknown>>, incremental?: boolean): OpenAIResponsesHistoryPayload;
|
|
67
83
|
export declare function getOpenAIResponsesHistoryPayload(providerPayload: ProviderPayload | undefined, currentProvider: string, fallbackProvider?: string): OpenAIResponsesHistoryPayload | undefined;
|
|
68
84
|
export declare function getOpenAIResponsesHistoryItems(providerPayload: ProviderPayload | undefined, currentProvider: string, fallbackProvider?: string): Array<Record<string, unknown>> | undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-ai",
|
|
3
|
-
"version": "18.4.
|
|
3
|
+
"version": "18.4.11",
|
|
4
4
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -155,11 +155,11 @@
|
|
|
155
155
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
156
156
|
},
|
|
157
157
|
"dependencies": {
|
|
158
|
-
"@oh-my-pi/omptype": "18.4.
|
|
159
|
-
"@oh-my-pi/pi-catalog": "18.4.
|
|
160
|
-
"@oh-my-pi/pi-natives": "18.4.
|
|
161
|
-
"@oh-my-pi/pi-utils": "18.4.
|
|
162
|
-
"@oh-my-pi/pi-wire": "18.4.
|
|
158
|
+
"@oh-my-pi/omptype": "18.4.11",
|
|
159
|
+
"@oh-my-pi/pi-catalog": "18.4.11",
|
|
160
|
+
"@oh-my-pi/pi-natives": "18.4.11",
|
|
161
|
+
"@oh-my-pi/pi-utils": "18.4.11",
|
|
162
|
+
"@oh-my-pi/pi-wire": "18.4.11"
|
|
163
163
|
},
|
|
164
164
|
"devDependencies": {
|
|
165
165
|
"@types/bun": "^1.3.14"
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
type OAuthCredential,
|
|
18
18
|
type OAuthRefreshReason,
|
|
19
19
|
REMOTE_REFRESH_SENTINEL,
|
|
20
|
+
type RemoteOAuthCredential,
|
|
20
21
|
type StoredAuthCredential,
|
|
21
22
|
type StoredCredentialBlock,
|
|
22
23
|
} from "../auth/types";
|
|
@@ -849,15 +850,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
849
850
|
|
|
850
851
|
async markCredentialSuspect(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<void> {
|
|
851
852
|
this.#noteActivity();
|
|
852
|
-
|
|
853
|
-
if (entry.credential.type !== "oauth") {
|
|
854
|
-
throw new AIError.AuthBrokerError(`Broker returned non-OAuth credential for id=${credentialId}`);
|
|
855
|
-
}
|
|
856
|
-
if (!this.#applyCredentialEntry(entry)) {
|
|
857
|
-
throw new AIError.AuthBrokerError(
|
|
858
|
-
`Broker refreshed credential id=${credentialId} outside the configured account pool`,
|
|
859
|
-
);
|
|
860
|
-
}
|
|
853
|
+
await this.#refreshThroughBroker(credentialId, opts.signal, "auth-recovery");
|
|
861
854
|
this.#maybeRefreshSnapshot("suspect credential refresh");
|
|
862
855
|
}
|
|
863
856
|
|
|
@@ -1168,21 +1161,12 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
1168
1161
|
reason?: OAuthRefreshReason,
|
|
1169
1162
|
): Promise<OAuthCredentials> {
|
|
1170
1163
|
this.#noteActivity();
|
|
1171
|
-
const
|
|
1172
|
-
if (entry.credential.type !== "oauth") {
|
|
1173
|
-
throw new AIError.AuthBrokerError(`Broker returned non-OAuth credential for id=${credentialId}`);
|
|
1174
|
-
}
|
|
1175
|
-
if (!this.#applyCredentialEntry(entry)) {
|
|
1176
|
-
throw new AIError.AuthBrokerError(
|
|
1177
|
-
`Broker refreshed credential id=${credentialId} outside the configured account pool`,
|
|
1178
|
-
);
|
|
1179
|
-
}
|
|
1164
|
+
const refreshed = await this.#refreshThroughBroker(credentialId, signal, reason);
|
|
1180
1165
|
if (!this.#streamingActive) {
|
|
1181
1166
|
await this.refreshSnapshot().catch(error => {
|
|
1182
1167
|
logger.debug("auth-broker snapshot refresh after credential refresh failed", { error: String(error) });
|
|
1183
1168
|
});
|
|
1184
1169
|
}
|
|
1185
|
-
const refreshed = entry.credential;
|
|
1186
1170
|
return {
|
|
1187
1171
|
access: refreshed.access,
|
|
1188
1172
|
refresh: REMOTE_REFRESH_SENTINEL,
|
|
@@ -1194,6 +1178,38 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
|
|
1194
1178
|
};
|
|
1195
1179
|
}
|
|
1196
1180
|
|
|
1181
|
+
/**
|
|
1182
|
+
* Refresh one credential through the broker and apply the reply. If this
|
|
1183
|
+
* client's copy changed to something else while the request was in flight,
|
|
1184
|
+
* the reply may be stale, so the broker's current row wins: a logout stays
|
|
1185
|
+
* logged out and a newer login is kept.
|
|
1186
|
+
*/
|
|
1187
|
+
async #refreshThroughBroker(
|
|
1188
|
+
credentialId: number,
|
|
1189
|
+
signal?: AbortSignal,
|
|
1190
|
+
reason?: OAuthRefreshReason,
|
|
1191
|
+
): Promise<RemoteOAuthCredential> {
|
|
1192
|
+
const local = () => this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
|
|
1193
|
+
const before = JSON.stringify(local()?.credential);
|
|
1194
|
+
let { entry } = await this.#client.refreshCredential(credentialId, signal, reason);
|
|
1195
|
+
const current = JSON.stringify(local()?.credential);
|
|
1196
|
+
if (current !== before && current !== JSON.stringify(entry.credential)) {
|
|
1197
|
+
await this.refreshSnapshot();
|
|
1198
|
+
const latest = local();
|
|
1199
|
+
if (!latest) throw new AIError.AuthBrokerError(`Credential id=${credentialId} was removed during refresh`);
|
|
1200
|
+
entry = latest;
|
|
1201
|
+
}
|
|
1202
|
+
if (entry.credential.type !== "oauth") {
|
|
1203
|
+
throw new AIError.AuthBrokerError(`Broker returned non-OAuth credential for id=${credentialId}`);
|
|
1204
|
+
}
|
|
1205
|
+
if (!this.#applyCredentialEntry(entry)) {
|
|
1206
|
+
throw new AIError.AuthBrokerError(
|
|
1207
|
+
`Broker refreshed credential id=${credentialId} outside the configured account pool`,
|
|
1208
|
+
);
|
|
1209
|
+
}
|
|
1210
|
+
return entry.credential;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1197
1213
|
/**
|
|
1198
1214
|
* Store-level hook consumed by `AuthStorage.usage.reports()` — proxies
|
|
1199
1215
|
* to the broker's `/v1/usage` endpoint. Shared per-credential caches and
|
package/src/dialect/deepseek.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseJsonWithRepair } from "@oh-my-pi/pi-utils";
|
|
2
2
|
import type { Message, ToolCall } from "../types";
|
|
3
|
+
import { parseToolCallArguments } from "../utils/tool-call-arguments";
|
|
3
4
|
import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
|
|
4
5
|
import dialectPrompt from "./deepseek.md" with { type: "text" };
|
|
5
6
|
import { assistantTranscriptParts, collectToolResultRun, messageContentText, stringifyJson } from "./rendering";
|
|
@@ -477,11 +478,7 @@ export class DeepSeekInbandScanner implements InbandScanner {
|
|
|
477
478
|
#parseArgs(rawArgs: string): Record<string, unknown> {
|
|
478
479
|
const trimmed = rawArgs.trim();
|
|
479
480
|
if (trimmed.length === 0) return {};
|
|
480
|
-
|
|
481
|
-
return asRecord(parseJsonWithRepair<unknown>(trimmed));
|
|
482
|
-
} catch {
|
|
483
|
-
return {};
|
|
484
|
-
}
|
|
481
|
+
return asRecord(parseToolCallArguments(trimmed));
|
|
485
482
|
}
|
|
486
483
|
|
|
487
484
|
#skipWhitespace(): string {
|
package/src/dialect/harmony.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { parseJsonWithRepair } from "@oh-my-pi/pi-utils";
|
|
2
1
|
import type { Message, ToolCall } from "../types";
|
|
2
|
+
import { parseToolCallArguments } from "../utils/tool-call-arguments";
|
|
3
3
|
import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
|
|
4
4
|
import dialectPrompt from "./harmony.md" with { type: "text" };
|
|
5
5
|
import {
|
|
@@ -225,11 +225,7 @@ export class HarmonyInbandScanner implements InbandScanner {
|
|
|
225
225
|
#parseArgs(): Record<string, unknown> {
|
|
226
226
|
const raw = this.#toolArgs.trim();
|
|
227
227
|
if (raw.length === 0) return {};
|
|
228
|
-
|
|
229
|
-
return asRecord(parseJsonWithRepair<unknown>(raw));
|
|
230
|
-
} catch {
|
|
231
|
-
return {};
|
|
232
|
-
}
|
|
228
|
+
return asRecord(parseToolCallArguments(raw));
|
|
233
229
|
}
|
|
234
230
|
|
|
235
231
|
#clearBody(resetRawBlock = true): void {
|
package/src/dialect/hermes.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseJsonWithRepair, parseStreamingJson } from "@oh-my-pi/pi-utils";
|
|
2
2
|
import type { Message, ToolCall } from "../types";
|
|
3
|
+
import { parseToolCallArguments } from "../utils/tool-call-arguments";
|
|
3
4
|
import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
|
|
4
5
|
import dialectPrompt from "./hermes.md" with { type: "text" };
|
|
5
6
|
import { renderChatMlTranscript, renderDelimitedThinking, renderToolResponseResults, stringifyJson } from "./rendering";
|
|
@@ -147,11 +148,7 @@ export class HermesInbandScanner implements InbandScanner {
|
|
|
147
148
|
if (typeof parsed.name !== "string" || parsed.name.length === 0) return undefined;
|
|
148
149
|
let args = parsed.arguments;
|
|
149
150
|
if (typeof args === "string") {
|
|
150
|
-
|
|
151
|
-
args = parseJsonWithRepair<unknown>(args);
|
|
152
|
-
} catch {
|
|
153
|
-
args = {};
|
|
154
|
-
}
|
|
151
|
+
args = parseToolCallArguments(args);
|
|
155
152
|
}
|
|
156
153
|
return { name: parsed.name, arguments: asRecord(args) };
|
|
157
154
|
} catch {
|
package/src/dialect/kimi.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { parseJsonWithRepair } from "@oh-my-pi/pi-utils";
|
|
2
1
|
import type { Message, ToolCall } from "../types";
|
|
2
|
+
import { parseToolCallArguments } from "../utils/tool-call-arguments";
|
|
3
3
|
import { asRecord, normalizeKimiFunctionName, partialSuffixOverlapAny } from "./coercion";
|
|
4
4
|
import dialectPrompt from "./kimi.md" with { type: "text" };
|
|
5
5
|
import { assistantTranscriptParts, collectToolResultRun, messageContentText, stringifyJson } from "./rendering";
|
|
@@ -212,11 +212,7 @@ export class KimiInbandScanner implements InbandScanner {
|
|
|
212
212
|
|
|
213
213
|
#parseArgs(rawArgs: string): Record<string, unknown> {
|
|
214
214
|
if (rawArgs.length === 0) return {};
|
|
215
|
-
|
|
216
|
-
return asRecord(parseJsonWithRepair<unknown>(rawArgs));
|
|
217
|
-
} catch {
|
|
218
|
-
return {};
|
|
219
|
-
}
|
|
215
|
+
return asRecord(parseToolCallArguments(rawArgs));
|
|
220
216
|
}
|
|
221
217
|
|
|
222
218
|
#nextTokenIndex(): number {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
setStreamingPartialJson,
|
|
14
14
|
} from "../utils/block-symbols";
|
|
15
15
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
16
|
+
import { INVALID_ARGUMENTS_RAW_LIMIT, invalidToolCallArguments } from "../utils/tool-call-arguments";
|
|
16
17
|
import { buildStringArgsResolver } from "./coercion";
|
|
17
18
|
import { createInbandScanner } from "./factory";
|
|
18
19
|
import type { Dialect, InbandScanEvent, InbandScanner, InbandTool } from "./types";
|
|
@@ -180,6 +181,9 @@ class InbandStreamProjector {
|
|
|
180
181
|
#fedLen = 0;
|
|
181
182
|
#stopped = false;
|
|
182
183
|
#responsePending = "";
|
|
184
|
+
// Bounded tail of the text actually fed to the scanner: the diagnostic for an
|
|
185
|
+
// unfinished envelope, which excludes anything cut off after a response token.
|
|
186
|
+
#scannerTail = "";
|
|
183
187
|
// Provider-native tool calls forwarded live (e.g. Gemini still returns
|
|
184
188
|
// `functionCall` parts under owned mode), keyed by the inner stream's
|
|
185
189
|
// `contentIndex`. `#toolChannel` records which channel produced the turn's
|
|
@@ -291,7 +295,7 @@ class InbandStreamProjector {
|
|
|
291
295
|
const responseIndex = firstTokenIndex(combined, this.#responseOpenTokens);
|
|
292
296
|
if (responseIndex !== -1) {
|
|
293
297
|
this.#responsePending = "";
|
|
294
|
-
this.#apply(this.#
|
|
298
|
+
this.#apply(this.#feedScanner(combined.slice(0, responseIndex)));
|
|
295
299
|
this.#stopped = true;
|
|
296
300
|
return true;
|
|
297
301
|
}
|
|
@@ -303,7 +307,7 @@ class InbandStreamProjector {
|
|
|
303
307
|
|
|
304
308
|
const emitLength = combined.length - this.#responseOverlapLength;
|
|
305
309
|
this.#responsePending = combined.slice(emitLength);
|
|
306
|
-
this.#apply(this.#
|
|
310
|
+
this.#apply(this.#feedScanner(combined.slice(0, emitLength)));
|
|
307
311
|
return false;
|
|
308
312
|
}
|
|
309
313
|
|
|
@@ -339,10 +343,26 @@ class InbandStreamProjector {
|
|
|
339
343
|
for (const block of message.content) if (block.type === "text") fullText += block.text;
|
|
340
344
|
if (!this.#stopped && fullText.length > this.#fedLen) this.text(fullText.slice(this.#fedLen));
|
|
341
345
|
if (!this.#stopped && this.#responsePending.length > 0) {
|
|
342
|
-
this.#apply(this.#
|
|
346
|
+
this.#apply(this.#feedScanner(this.#responsePending));
|
|
343
347
|
this.#responsePending = "";
|
|
344
348
|
}
|
|
345
349
|
this.#apply(this.#scanner.flush());
|
|
350
|
+
// Scanners discard incomplete envelopes, but their toolStart previews already
|
|
351
|
+
// live in the message. Refuse those calls instead of executing partial fields.
|
|
352
|
+
// The unfinished envelope ends the scanner-fed text, so the diagnostic keeps
|
|
353
|
+
// that tail rather than any prose before the call.
|
|
354
|
+
for (const entry of this.#toolBlocks.values()) {
|
|
355
|
+
entry.block.arguments = invalidToolCallArguments(this.#scannerTail, "Incomplete in-band tool call");
|
|
356
|
+
if (this.#emitEvents) {
|
|
357
|
+
this.#out.push({
|
|
358
|
+
type: "toolcall_end",
|
|
359
|
+
contentIndex: entry.index,
|
|
360
|
+
toolCall: entry.block,
|
|
361
|
+
partial: this.#partial,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
this.#toolBlocks.clear();
|
|
346
366
|
this.#closeText();
|
|
347
367
|
this.#closeThinking();
|
|
348
368
|
const hasTools = this.#partial.content.some(block => block.type === "toolCall");
|
|
@@ -353,6 +373,11 @@ class InbandStreamProjector {
|
|
|
353
373
|
return finalMessage;
|
|
354
374
|
}
|
|
355
375
|
|
|
376
|
+
#feedScanner(text: string): InbandScanEvent[] {
|
|
377
|
+
this.#scannerTail = (this.#scannerTail + text).slice(-INVALID_ARGUMENTS_RAW_LIMIT);
|
|
378
|
+
return this.#scanner.feed(text);
|
|
379
|
+
}
|
|
380
|
+
|
|
356
381
|
#apply(events: InbandScanEvent[]): void {
|
|
357
382
|
for (const event of events) {
|
|
358
383
|
switch (event.type) {
|
package/src/dialect/qwen3.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseJsonWithRepair } from "@oh-my-pi/pi-utils";
|
|
2
2
|
import type { Message, ToolCall } from "../types";
|
|
3
|
+
import { parseToolCallArguments } from "../utils/tool-call-arguments";
|
|
3
4
|
import { asRecord, mintToolCallId, partialSuffixOverlapAny } from "./coercion";
|
|
4
5
|
import dialectPrompt from "./qwen3.md" with { type: "text" };
|
|
5
6
|
import { renderChatMlTranscript, renderToolResponseResults, stringifyJson } from "./rendering";
|
|
@@ -180,11 +181,7 @@ export class Qwen3InbandScanner implements InbandScanner {
|
|
|
180
181
|
if (typeof parsed.name !== "string" || parsed.name.length === 0) return undefined;
|
|
181
182
|
let args = parsed.arguments;
|
|
182
183
|
if (typeof args === "string") {
|
|
183
|
-
|
|
184
|
-
args = parseJsonWithRepair<unknown>(args);
|
|
185
|
-
} catch {
|
|
186
|
-
args = {};
|
|
187
|
-
}
|
|
184
|
+
args = parseToolCallArguments(args);
|
|
188
185
|
}
|
|
189
186
|
return { name: parsed.name, arguments: asRecord(args) };
|
|
190
187
|
} catch {
|
package/src/error/rate-limit.ts
CHANGED
|
@@ -37,6 +37,12 @@ const CREDITS_EXHAUSTED_PATTERN =
|
|
|
37
37
|
// in unrelated diagnostics ("Failed to fetch usage credits from billing
|
|
38
38
|
// service"), which must not rotate a healthy credential.
|
|
39
39
|
const ANTHROPIC_CREDITS_REQUIRED_PATTERN = /\busage credits are required\b|\bcredits_required\b/i;
|
|
40
|
+
// Prepaid-balance exhaustion: Cursor ERROR_USAGE_PRICING_REQUIRED (code 44,
|
|
41
|
+
// surfaced as 429) "Your prepaid balance is used up: Add funds or enable auto
|
|
42
|
+
// top-up …". Account-local until topped up, so rotate to a sibling. The `\b`
|
|
43
|
+
// after the code keeps USAGE_PRICING_REQUIRED_CHANGEABLE out.
|
|
44
|
+
const PREPAID_BALANCE_EXHAUSTED_PATTERN =
|
|
45
|
+
/\busage_pricing_required\b|\bprepaid balance\b[^\n]{0,40}\b(?:used up|exhausted|depleted)\b/i;
|
|
40
46
|
// Account billing ceilings: Anthropic "monthly spend limit" (#4787) and Google
|
|
41
47
|
// "Your project has exceeded its monthly spending cap" (#13090). The `\b` after
|
|
42
48
|
// `cap` keeps "spending capacity" — a throttle, not a billing ceiling — out.
|
|
@@ -260,6 +266,10 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason {
|
|
|
260
266
|
return "QUOTA_EXHAUSTED";
|
|
261
267
|
}
|
|
262
268
|
|
|
269
|
+
if (PREPAID_BALANCE_EXHAUSTED_PATTERN.test(errorMessage)) {
|
|
270
|
+
return "QUOTA_EXHAUSTED";
|
|
271
|
+
}
|
|
272
|
+
|
|
263
273
|
if (
|
|
264
274
|
lower.includes("per minute") ||
|
|
265
275
|
lower.includes("rate limit") ||
|
|
@@ -434,6 +444,7 @@ export function matchesUsageLimitText(errorMessage: string): boolean {
|
|
|
434
444
|
return (
|
|
435
445
|
USAGE_LIMIT_PATTERN.test(errorMessage) ||
|
|
436
446
|
ANTHROPIC_CREDITS_REQUIRED_PATTERN.test(errorMessage) ||
|
|
447
|
+
PREPAID_BALANCE_EXHAUSTED_PATTERN.test(errorMessage) ||
|
|
437
448
|
CREDITS_EXHAUSTED_PATTERN.test(errorMessage) ||
|
|
438
449
|
(CN_QUOTA_EXHAUSTED_PATTERN.test(errorMessage) && !CN_TRANSIENT_CAP_PATTERN.test(errorMessage)) ||
|
|
439
450
|
SPEND_LIMIT_PATTERN.test(errorMessage) ||
|