@oh-my-pi/pi-ai 17.0.1 → 17.0.2
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 +9 -1
- package/dist/types/auth-broker/wire-schemas.d.ts +13 -0
- package/dist/types/error/rate-limit.d.ts +9 -5
- package/dist/types/providers/cursor.d.ts +2 -0
- package/dist/types/providers/openai-codex/request-transformer.d.ts +9 -3
- package/dist/types/providers/openai-shared.d.ts +7 -5
- package/dist/types/providers/transform-messages.d.ts +5 -9
- package/package.json +4 -4
- package/src/auth-broker/wire-schemas.ts +1 -0
- package/src/dialect/thinking.ts +186 -16
- package/src/error/flags.ts +2 -2
- package/src/error/rate-limit.ts +12 -8
- package/src/providers/__tests__/kimi-code-thinking.test.ts +62 -7
- package/src/providers/cursor.ts +53 -30
- package/src/providers/gitlab-duo-workflow.ts +6 -2
- package/src/providers/kimi.ts +1 -4
- package/src/providers/openai-codex/request-transformer.ts +12 -3
- package/src/providers/openai-codex-responses.ts +4 -3
- package/src/providers/openai-completions.ts +2 -2
- package/src/providers/openai-responses.ts +5 -1
- package/src/providers/openai-shared.ts +15 -7
- package/src/providers/transform-messages.ts +164 -1
- package/src/utils.ts +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,9 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.0.2] - 2026-07-17
|
|
6
|
+
|
|
5
7
|
### Fixed
|
|
6
8
|
|
|
7
|
-
- Automatically invalidate and rotate OAuth credentials when an "invalidated oauth token" error occurs
|
|
9
|
+
- Automatically invalidate and rotate OAuth credentials when an "invalidated oauth token" error occurs.
|
|
10
|
+
- Fixed auth-broker snapshot validation rejecting API keys stored via the `/login` flow, restoring support for gateway/broker setups serving login-sourced keys on custom hosts.
|
|
11
|
+
- Fixed an issue where literal reasoning tags (e.g., `<think>`) inside Markdown code blocks or inline code were incorrectly treated as reasoning boundaries, which corrupted the rendered Markdown.
|
|
12
|
+
- Classified HTTP 402 and "balance exhausted" quota responses as persistent usage limits, enabling automatic rotation of multi-account requests to a sibling credential.
|
|
13
|
+
- Fixed `kimi-code` Anthropic-format requests ignoring custom provider base URLs.
|
|
14
|
+
- Fixed an issue where GPT-5.6 Codex Responses-Lite requests failed with an HTTP 400 error due to invalid `tool_choice` parameters after tools were rewritten, by automatically downgrading forced hosted choices to `tool_choice: "auto"` while preserving explicit tool-use constraints.
|
|
15
|
+
- Fixed Cursor streams prematurely reporting success before late CONNECT or gRPC terminal failures were observed, and resolved issues rejecting transport ends without a `turnEnded` signal.
|
|
8
16
|
|
|
9
17
|
## [17.0.1] - 2026-07-16
|
|
10
18
|
|
|
@@ -29,6 +29,7 @@ export declare const remoteOauthCredentialSchema: import("arktype/internal/varia
|
|
|
29
29
|
export declare const apiKeyCredentialSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
|
30
30
|
type: "api_key";
|
|
31
31
|
key: string;
|
|
32
|
+
source?: "login" | undefined;
|
|
32
33
|
}, {}>;
|
|
33
34
|
/** Discriminated union accepted on POST /v1/credential (writes). */
|
|
34
35
|
export declare const writableAuthCredentialSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
|
@@ -46,6 +47,7 @@ export declare const writableAuthCredentialSchema: import("arktype/internal/vari
|
|
|
46
47
|
} | {
|
|
47
48
|
type: "api_key";
|
|
48
49
|
key: string;
|
|
50
|
+
source?: "login" | undefined;
|
|
49
51
|
}, {}>;
|
|
50
52
|
/** Discriminated union returned in snapshots (refresh is sentinel for OAuth). */
|
|
51
53
|
export declare const snapshotCredentialSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
|
@@ -63,6 +65,7 @@ export declare const snapshotCredentialSchema: import("arktype/internal/variants
|
|
|
63
65
|
} | {
|
|
64
66
|
type: "api_key";
|
|
65
67
|
key: string;
|
|
68
|
+
source?: "login" | undefined;
|
|
66
69
|
}, {}>;
|
|
67
70
|
export declare const credentialSnapshotEntrySchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
|
68
71
|
id: number;
|
|
@@ -82,6 +85,7 @@ export declare const credentialSnapshotEntrySchema: import("arktype/internal/var
|
|
|
82
85
|
} | {
|
|
83
86
|
type: "api_key";
|
|
84
87
|
key: string;
|
|
88
|
+
source?: "login" | undefined;
|
|
85
89
|
};
|
|
86
90
|
identityKey: string | null;
|
|
87
91
|
}, {}>;
|
|
@@ -109,6 +113,7 @@ export declare const snapshotEntrySchema: import("arktype/internal/variants/obje
|
|
|
109
113
|
} | {
|
|
110
114
|
type: "api_key";
|
|
111
115
|
key: string;
|
|
116
|
+
source?: "login" | undefined;
|
|
112
117
|
};
|
|
113
118
|
identityKey: string | null;
|
|
114
119
|
rotatesInMs: number | null;
|
|
@@ -153,6 +158,7 @@ export declare const snapshotResponseSchema: import("arktype/internal/variants/o
|
|
|
153
158
|
} | {
|
|
154
159
|
type: "api_key";
|
|
155
160
|
key: string;
|
|
161
|
+
source?: "login" | undefined;
|
|
156
162
|
};
|
|
157
163
|
identityKey: string | null;
|
|
158
164
|
rotatesInMs: number | null;
|
|
@@ -193,6 +199,7 @@ export declare const snapshotStreamSnapshotEventSchema: import("arktype/internal
|
|
|
193
199
|
} | {
|
|
194
200
|
type: "api_key";
|
|
195
201
|
key: string;
|
|
202
|
+
source?: "login" | undefined;
|
|
196
203
|
};
|
|
197
204
|
identityKey: string | null;
|
|
198
205
|
rotatesInMs: number | null;
|
|
@@ -234,6 +241,7 @@ export declare const snapshotStreamEntryEventSchema: import("arktype/internal/va
|
|
|
234
241
|
} | {
|
|
235
242
|
type: "api_key";
|
|
236
243
|
key: string;
|
|
244
|
+
source?: "login" | undefined;
|
|
237
245
|
};
|
|
238
246
|
identityKey: string | null;
|
|
239
247
|
rotatesInMs: number | null;
|
|
@@ -287,6 +295,7 @@ export declare const snapshotStreamEventSchema: import("arktype/internal/variant
|
|
|
287
295
|
} | {
|
|
288
296
|
type: "api_key";
|
|
289
297
|
key: string;
|
|
298
|
+
source?: "login" | undefined;
|
|
290
299
|
};
|
|
291
300
|
identityKey: string | null;
|
|
292
301
|
rotatesInMs: number | null;
|
|
@@ -326,6 +335,7 @@ export declare const snapshotStreamEventSchema: import("arktype/internal/variant
|
|
|
326
335
|
} | {
|
|
327
336
|
type: "api_key";
|
|
328
337
|
key: string;
|
|
338
|
+
source?: "login" | undefined;
|
|
329
339
|
};
|
|
330
340
|
identityKey: string | null;
|
|
331
341
|
rotatesInMs: number | null;
|
|
@@ -427,6 +437,7 @@ export declare const credentialRefreshResponseSchema: import("arktype/internal/v
|
|
|
427
437
|
} | {
|
|
428
438
|
type: "api_key";
|
|
429
439
|
key: string;
|
|
440
|
+
source?: "login" | undefined;
|
|
430
441
|
};
|
|
431
442
|
identityKey: string | null;
|
|
432
443
|
};
|
|
@@ -469,6 +480,7 @@ export declare const credentialUploadRequestSchema: import("arktype/internal/var
|
|
|
469
480
|
} | {
|
|
470
481
|
type: "api_key";
|
|
471
482
|
key: string;
|
|
483
|
+
source?: "login" | undefined;
|
|
472
484
|
};
|
|
473
485
|
}, {}>;
|
|
474
486
|
export declare const credentialUploadResponseSchema: import("arktype/internal/variants/object.ts").ObjectType<{
|
|
@@ -490,6 +502,7 @@ export declare const credentialUploadResponseSchema: import("arktype/internal/va
|
|
|
490
502
|
} | {
|
|
491
503
|
type: "api_key";
|
|
492
504
|
key: string;
|
|
505
|
+
source?: "login" | undefined;
|
|
493
506
|
};
|
|
494
507
|
identityKey: string | null;
|
|
495
508
|
}[];
|
|
@@ -20,6 +20,9 @@ export declare function calculateRateLimitBackoffMs(reason: RateLimitReason): nu
|
|
|
20
20
|
/**
|
|
21
21
|
* HTTP status codes that, absent richer body classification, represent an
|
|
22
22
|
* account-local usage cap rather than a bad credential or a transient blip.
|
|
23
|
+
* HTTP 402 Payment Required is categorically an account-billing cap (xAI
|
|
24
|
+
* Grok Build "usage balance exhausted", DeepSeek "Insufficient Balance",
|
|
25
|
+
* OpenRouter credit exhaustion) — never a transient blip or bad credential.
|
|
23
26
|
* Always combine with {@link isUsageLimitOutcome} when a message is available
|
|
24
27
|
* — a 429 carrying transient rate-limit wording is NOT a usage cap.
|
|
25
28
|
*/
|
|
@@ -31,7 +34,7 @@ export declare function isUsageLimitStatus(status: number | undefined): boolean;
|
|
|
31
34
|
* 1. Body matches {@link isUsageLimitError} (Codex `usage_limit_reached`,
|
|
32
35
|
* Anthropic account rate-limit, Google `resource_exhausted`, OpenAI
|
|
33
36
|
* `insufficient_quota`, …) → rotate.
|
|
34
|
-
* 2. Status is not 429 → backoff (caller's domain).
|
|
37
|
+
* 2. Status is not a usage-limit status (429/402) → backoff (caller's domain).
|
|
35
38
|
* 3. Body is absent or {@link isOpaqueStatusBody opaque} (just the status,
|
|
36
39
|
* empty JSON, HTTP framing only) → rotate conservatively: the server
|
|
37
40
|
* gave us nothing else to go on.
|
|
@@ -44,10 +47,11 @@ export declare function isUsageLimitStatus(status: number | undefined): boolean;
|
|
|
44
47
|
*/
|
|
45
48
|
export declare function isUsageLimitOutcome(status: number | undefined, message: string | undefined): boolean;
|
|
46
49
|
/**
|
|
47
|
-
* A
|
|
48
|
-
* empty, whitespace-only, the status digits with HTTP/JSON
|
|
49
|
-
* generic punctuation. Anything else (retry hints, capacity
|
|
50
|
-
* descriptions) is informative enough to defer to the
|
|
50
|
+
* A usage-limit status body is opaque when it carries no signal beyond the
|
|
51
|
+
* status itself — empty, whitespace-only, the status digits with HTTP/JSON
|
|
52
|
+
* framing, or generic punctuation. Anything else (retry hints, capacity
|
|
53
|
+
* wording, error descriptions) is informative enough to defer to the
|
|
54
|
+
* classifier.
|
|
51
55
|
*/
|
|
52
56
|
export declare function isOpaqueStatusBody(message: string): boolean;
|
|
53
57
|
/**
|
|
@@ -29,6 +29,8 @@ export interface BlockState {
|
|
|
29
29
|
[kStreamingBlockIndex]: number;
|
|
30
30
|
}) | null;
|
|
31
31
|
currentToolCall: ToolCallState | null;
|
|
32
|
+
/** MCP call IDs executed through Cursor's exec channel before their stream block arrives. */
|
|
33
|
+
resolvedMcpToolCallIds: Set<string>;
|
|
32
34
|
firstTokenTime: number | undefined;
|
|
33
35
|
setTextBlock: (b: (TextContent & {
|
|
34
36
|
[kStreamingBlockIndex]: number;
|
|
@@ -77,6 +77,7 @@ export declare function resolveCodexResponsesLite(model: Model<"openai-codex-res
|
|
|
77
77
|
export interface CodexLiteShapedBody {
|
|
78
78
|
instructions?: unknown;
|
|
79
79
|
tools?: unknown;
|
|
80
|
+
tool_choice?: unknown;
|
|
80
81
|
input?: unknown;
|
|
81
82
|
parallel_tool_calls?: unknown;
|
|
82
83
|
}
|
|
@@ -85,9 +86,14 @@ export interface CodexLiteShapedBody {
|
|
|
85
86
|
* `build_responses_request` with `use_responses_lite`): strips pinned image
|
|
86
87
|
* detail, forces parallel tool calling off, moves tools into a leading
|
|
87
88
|
* `additional_tools` developer item and the base instructions into a
|
|
88
|
-
* developer message, then omits top-level `instructions`/`tools`.
|
|
89
|
-
*
|
|
90
|
-
*
|
|
89
|
+
* developer message, then omits top-level `instructions`/`tools`. Because the
|
|
90
|
+
* rewrite removes top-level `tools`, a forced hosted-tool choice (e.g.
|
|
91
|
+
* `{ type: "web_search" }`) would leave the backend unable to validate the
|
|
92
|
+
* choice against a tools collection and it rejects the request with HTTP 400
|
|
93
|
+
* (#5771). Such choices must fall back to `"auto"`; explicit string constraints
|
|
94
|
+
* such as `"none"` and `"required"` remain valid. Shared by normal turns and
|
|
95
|
+
* both remote-compaction paths — codex-rs routes `/responses/compact` through
|
|
96
|
+
* the same builder.
|
|
91
97
|
*/
|
|
92
98
|
export declare function applyCodexResponsesLiteShape(body: CodexLiteShapedBody): void;
|
|
93
99
|
export declare function transformRequestBody(body: RequestBody, model: Model<"openai-codex-responses">, options?: CodexRequestOptions, prompt?: {
|
|
@@ -297,12 +297,14 @@ export declare function applyChatCompletionsReasoningParams(params: OpenAIComple
|
|
|
297
297
|
}) | undefined): void;
|
|
298
298
|
export declare function disableChatCompletionsReasoningForDialect(params: OpenAICompletionsParams, compat: ResolvedOpenAICompat): void;
|
|
299
299
|
/**
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
300
|
+
* Provider-specific Chat Completions output clamp.
|
|
301
|
+
*
|
|
302
|
+
* Most OpenAI-compatible endpoints retain the conservative 64k ceiling from
|
|
303
|
+
* {@link resolveOpenAIOutputTokenParam}. Z.AI/GLM-5.2 reasoning and native
|
|
304
|
+
* Moonshot K3 explicitly accept their full advertised model caps, so those
|
|
305
|
+
* routes clamp to `model.maxTokens` instead.
|
|
304
306
|
*/
|
|
305
|
-
export declare function
|
|
307
|
+
export declare function resolveOpenAICompletionsOutputClamp(model: Model<"openai-completions">, compat: ResolvedOpenAICompat): number | undefined;
|
|
306
308
|
/**
|
|
307
309
|
* Enable `tool_stream` for Z.AI/GLM-5.2 reasoning models when tools are present
|
|
308
310
|
* (GLM-5.2 streams tool-call arguments incrementally and needs the flag to do so).
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import type { Api, AssistantMessage, Message, Model } from "../types.js";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
* For aborted/errored turns, this function:
|
|
8
|
-
* - Preserves tool call structure (unlike converting to text summaries)
|
|
9
|
-
* - Injects synthetic "aborted" tool results
|
|
10
|
-
*/
|
|
2
|
+
export declare function redactSensitiveCredentials(text: string): string;
|
|
3
|
+
export declare function redactSensitiveInObject(val: unknown): {
|
|
4
|
+
result: unknown;
|
|
5
|
+
changed: boolean;
|
|
6
|
+
};
|
|
11
7
|
export declare function transformMessages<TApi extends Api>(messages: Message[], model: Model<TApi>, normalizeToolCallId?: (id: string, model: Model<TApi>, source: AssistantMessage) => string, maxNormalizedToolCallIdLength?: number, duplicateToolCallIdSuffixPrefix?: string, targetCompat?: Model<TApi>["compat"]): Message[];
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-ai",
|
|
4
|
-
"version": "17.0.
|
|
4
|
+
"version": "17.0.2",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -38,9 +38,9 @@
|
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
40
|
"@bufbuild/protobuf": "^2.12.1",
|
|
41
|
-
"@oh-my-pi/pi-catalog": "17.0.
|
|
42
|
-
"@oh-my-pi/pi-utils": "17.0.
|
|
43
|
-
"@oh-my-pi/pi-wire": "17.0.
|
|
41
|
+
"@oh-my-pi/pi-catalog": "17.0.2",
|
|
42
|
+
"@oh-my-pi/pi-utils": "17.0.2",
|
|
43
|
+
"@oh-my-pi/pi-wire": "17.0.2",
|
|
44
44
|
"arktype": "2.2.3",
|
|
45
45
|
"zod": "^4"
|
|
46
46
|
},
|
package/src/dialect/thinking.ts
CHANGED
|
@@ -32,6 +32,16 @@ export class ThinkingInbandScanner implements InbandScanner {
|
|
|
32
32
|
#thinking = "";
|
|
33
33
|
/** Fence-aware close-matcher while inside a ` ```thinking ` block; undefined otherwise. */
|
|
34
34
|
#fenced: FencedThinkingScanner | undefined;
|
|
35
|
+
/** Backtick count that opened the Markdown code span/fence we are inside; 0 when not in code. */
|
|
36
|
+
#codeTicks = 0;
|
|
37
|
+
/** True when {@link #codeTicks} opened a fenced block (closes on a fence line), not an inline span. */
|
|
38
|
+
#codeFenced = false;
|
|
39
|
+
/**
|
|
40
|
+
* Leading-space count on the current output line, or -1 once a non-space
|
|
41
|
+
* character has appeared. Starts at 0 (line start) so a fence opening the
|
|
42
|
+
* stream — or one indented ≤3 spaces, as CommonMark allows — is recognized.
|
|
43
|
+
*/
|
|
44
|
+
#lineIndent = 0;
|
|
35
45
|
|
|
36
46
|
feed(text: string): InbandScanEvent[] {
|
|
37
47
|
if (text.length === 0) return [];
|
|
@@ -86,25 +96,93 @@ export class ThinkingInbandScanner implements InbandScanner {
|
|
|
86
96
|
this.#closeTag = "";
|
|
87
97
|
continue;
|
|
88
98
|
}
|
|
99
|
+
if (this.#codeTicks > 0) {
|
|
100
|
+
if (this.#emitCode(final, events)) continue;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
89
103
|
|
|
90
|
-
const
|
|
91
|
-
if (
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
104
|
+
const hit = scanVisible(this.#buffer, final);
|
|
105
|
+
if (hit.kind === "none") {
|
|
106
|
+
this.#emitText(this.#buffer, events);
|
|
107
|
+
this.#buffer = "";
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
if (hit.index > 0) this.#emitText(this.#buffer.slice(0, hit.index), events);
|
|
111
|
+
if (hit.kind === "hold") {
|
|
112
|
+
this.#buffer = this.#buffer.slice(hit.index);
|
|
96
113
|
break;
|
|
97
114
|
}
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
|
|
115
|
+
if (hit.kind === "code") {
|
|
116
|
+
const fenced = hit.ticks >= 3 && this.#lineIndent >= 0 && this.#lineIndent <= 3;
|
|
117
|
+
this.#emitText(this.#buffer.slice(hit.index, hit.index + hit.ticks), events);
|
|
118
|
+
this.#buffer = this.#buffer.slice(hit.index + hit.ticks);
|
|
119
|
+
this.#codeTicks = hit.ticks;
|
|
120
|
+
this.#codeFenced = fenced;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
this.#buffer = this.#buffer.slice(hit.index + hit.tag.open.length);
|
|
124
|
+
this.#closeTag = hit.tag.close;
|
|
101
125
|
this.#thinking = "";
|
|
102
|
-
if (tag.fenced) this.#fenced = new FencedThinkingScanner();
|
|
126
|
+
if (hit.tag.fenced) this.#fenced = new FencedThinkingScanner();
|
|
103
127
|
events.push({ type: "thinkingStart" });
|
|
104
128
|
}
|
|
105
129
|
return events;
|
|
106
130
|
}
|
|
107
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Emit buffered content while inside a Markdown code region, suppressing
|
|
134
|
+
* reasoning-tag detection. A fenced block closes only on a fence line (a line
|
|
135
|
+
* of backticks ≥ the opener); an inline span closes on the first backtick run
|
|
136
|
+
* of exactly the opener length. Returns true when the region closed and the
|
|
137
|
+
* loop should continue, false when it held back and should break.
|
|
138
|
+
*/
|
|
139
|
+
#emitCode(final: boolean, events: InbandScanEvent[]): boolean {
|
|
140
|
+
if (this.#codeFenced) {
|
|
141
|
+
const end = findFenceCloseEnd(this.#buffer, this.#codeTicks, final);
|
|
142
|
+
if (end !== -1) {
|
|
143
|
+
this.#emitText(this.#buffer.slice(0, end), events);
|
|
144
|
+
this.#buffer = this.#buffer.slice(end);
|
|
145
|
+
this.#codeTicks = 0;
|
|
146
|
+
this.#codeFenced = false;
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
if (final) {
|
|
150
|
+
this.#emitText(this.#buffer, events);
|
|
151
|
+
this.#buffer = "";
|
|
152
|
+
this.#codeTicks = 0;
|
|
153
|
+
this.#codeFenced = false;
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
// Stream committed lines; hold only the last (possibly partial) fence line.
|
|
157
|
+
const lastNl = this.#buffer.lastIndexOf("\n");
|
|
158
|
+
if (lastNl !== -1) {
|
|
159
|
+
this.#emitText(this.#buffer.slice(0, lastNl + 1), events);
|
|
160
|
+
this.#buffer = this.#buffer.slice(lastNl + 1);
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
const close = findBacktickRun(this.#buffer, 0, this.#codeTicks);
|
|
165
|
+
if (close !== -1 && (final || close + this.#codeTicks < this.#buffer.length)) {
|
|
166
|
+
this.#emitText(this.#buffer.slice(0, close + this.#codeTicks), events);
|
|
167
|
+
this.#buffer = this.#buffer.slice(close + this.#codeTicks);
|
|
168
|
+
this.#codeTicks = 0;
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
// No committed close yet: emit text, holding a trailing backtick run that
|
|
172
|
+
// may still grow into — or past — the closing delimiter.
|
|
173
|
+
const hold = final ? 0 : trailingBacktickRun(this.#buffer);
|
|
174
|
+
this.#emitText(this.#buffer.slice(0, this.#buffer.length - hold), events);
|
|
175
|
+
this.#buffer = this.#buffer.slice(this.#buffer.length - hold);
|
|
176
|
+
if (final) this.#codeTicks = 0;
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#emitText(text: string, events: InbandScanEvent[]): void {
|
|
181
|
+
if (text.length === 0) return;
|
|
182
|
+
events.push({ type: "text", text });
|
|
183
|
+
this.#lineIndent = trailingLineIndent(text, this.#lineIndent);
|
|
184
|
+
}
|
|
185
|
+
|
|
108
186
|
#emitThinking(delta: string, events: InbandScanEvent[]): void {
|
|
109
187
|
if (delta.length === 0) return;
|
|
110
188
|
this.#thinking += delta;
|
|
@@ -112,11 +190,103 @@ export class ThinkingInbandScanner implements InbandScanner {
|
|
|
112
190
|
}
|
|
113
191
|
}
|
|
114
192
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
193
|
+
/** Outcome of scanning idle visible text for the next reasoning-tag or code-span boundary. */
|
|
194
|
+
type VisibleHit =
|
|
195
|
+
| { readonly kind: "tag"; readonly index: number; readonly tag: Tag }
|
|
196
|
+
| { readonly kind: "code"; readonly index: number; readonly ticks: number }
|
|
197
|
+
| { readonly kind: "hold"; readonly index: number }
|
|
198
|
+
| { readonly kind: "none" };
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Walk idle visible text for the earliest boundary: a leaked reasoning-tag open,
|
|
202
|
+
* a Markdown code-span/fence opener (a backtick run), or — when more chunks may
|
|
203
|
+
* follow — a held partial delimiter at the buffer tail.
|
|
204
|
+
*
|
|
205
|
+
* Reasoning tags win at any position so the gemini ` ```thinking ` fence is
|
|
206
|
+
* healed instead of being read as a code fence. Backtick runs enter code mode so
|
|
207
|
+
* a literal `<think>` inside inline code or a fenced block stays visible text.
|
|
208
|
+
*/
|
|
209
|
+
function scanVisible(buffer: string, final: boolean): VisibleHit {
|
|
210
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
211
|
+
const tag = TAGS.find(candidate => buffer.startsWith(candidate.open, i));
|
|
212
|
+
if (tag) return { kind: "tag", index: i, tag };
|
|
213
|
+
if (!final) {
|
|
214
|
+
const rest = buffer.slice(i);
|
|
215
|
+
if (OPENS.some(open => open.length > rest.length && open.startsWith(rest))) {
|
|
216
|
+
return { kind: "hold", index: i };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (buffer[i] === "`") {
|
|
220
|
+
const ticks = backtickRun(buffer, i);
|
|
221
|
+
if (!final && i + ticks === buffer.length) return { kind: "hold", index: i };
|
|
222
|
+
return { kind: "code", index: i, ticks };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { kind: "none" };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Length of the maximal backtick run beginning at `from`. */
|
|
229
|
+
function backtickRun(buffer: string, from: number): number {
|
|
230
|
+
let end = from;
|
|
231
|
+
while (end < buffer.length && buffer[end] === "`") end++;
|
|
232
|
+
return end - from;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Index of the first maximal backtick run of exactly `ticks` at/after `from`, else -1. */
|
|
236
|
+
function findBacktickRun(buffer: string, from: number, ticks: number): number {
|
|
237
|
+
for (let i = buffer.indexOf("`", from); i !== -1; i = buffer.indexOf("`", i)) {
|
|
238
|
+
const run = backtickRun(buffer, i);
|
|
239
|
+
if (run === ticks) return i;
|
|
240
|
+
i += run;
|
|
120
241
|
}
|
|
121
|
-
return
|
|
242
|
+
return -1;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Length of a backtick run that ends at the buffer tail; 0 when the tail is not a backtick. */
|
|
246
|
+
function trailingBacktickRun(buffer: string): number {
|
|
247
|
+
let start = buffer.length;
|
|
248
|
+
while (start > 0 && buffer[start - 1] === "`") start--;
|
|
249
|
+
return buffer.length - start;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Leading-space count of the line at the tail of `text`, continuing from the
|
|
254
|
+
* prior line's `indent` state (see {@link ThinkingInbandScanner.#lineIndent}).
|
|
255
|
+
* Returns -1 once any non-space character has appeared on the current line.
|
|
256
|
+
*/
|
|
257
|
+
function trailingLineIndent(text: string, prior: number): number {
|
|
258
|
+
const lastNl = text.lastIndexOf("\n");
|
|
259
|
+
let indent = lastNl === -1 ? prior : 0;
|
|
260
|
+
for (let i = lastNl + 1; i < text.length; i++) {
|
|
261
|
+
if (indent === -1) break;
|
|
262
|
+
indent = text[i] === " " ? indent + 1 : -1;
|
|
263
|
+
}
|
|
264
|
+
return indent;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Index just past the first closing fence line for a fenced block opened with
|
|
269
|
+
* `ticks` backticks, or -1 when none is committed yet. A closing fence is a whole
|
|
270
|
+
* line whose trimmed content is only backticks, at least `ticks` of them. A line
|
|
271
|
+
* without a terminating newline is committed only when `final` (no more input can
|
|
272
|
+
* extend it into a non-fence line).
|
|
273
|
+
*/
|
|
274
|
+
function findFenceCloseEnd(buffer: string, ticks: number, final: boolean): number {
|
|
275
|
+
for (let start = 0; start <= buffer.length; ) {
|
|
276
|
+
const nl = buffer.indexOf("\n", start);
|
|
277
|
+
const terminated = nl !== -1;
|
|
278
|
+
const line = buffer.slice(start, terminated ? nl : buffer.length).trim();
|
|
279
|
+
if (line.length >= ticks && isAllBackticks(line) && (terminated || final)) {
|
|
280
|
+
return terminated ? nl + 1 : buffer.length;
|
|
281
|
+
}
|
|
282
|
+
if (!terminated) break;
|
|
283
|
+
start = nl + 1;
|
|
284
|
+
}
|
|
285
|
+
return -1;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** True when `text` is non-empty and every character is a backtick. */
|
|
289
|
+
function isAllBackticks(text: string): boolean {
|
|
290
|
+
for (let i = 0; i < text.length; i++) if (text[i] !== "`") return false;
|
|
291
|
+
return text.length > 0;
|
|
122
292
|
}
|
package/src/error/flags.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
ProviderHttpError,
|
|
8
8
|
STREAM_ENVELOPE_ERROR_PREFIX,
|
|
9
9
|
} from "./classes";
|
|
10
|
-
import { isOpaqueStatusBody, matchesUsageLimitText, parseRateLimitReason } from "./rate-limit";
|
|
10
|
+
import { isOpaqueStatusBody, isUsageLimitStatus, matchesUsageLimitText, parseRateLimitReason } from "./rate-limit";
|
|
11
11
|
|
|
12
12
|
export const Flag = {
|
|
13
13
|
Class: 0x1000,
|
|
@@ -318,7 +318,7 @@ function classifyText(errorMessage: string | undefined, errorStatus: number | un
|
|
|
318
318
|
const cleanMessage = errorMessage;
|
|
319
319
|
const isOpaque = isOpaqueStatusBody(cleanMessage);
|
|
320
320
|
|
|
321
|
-
const isLimitStatus = statusClean
|
|
321
|
+
const isLimitStatus = isUsageLimitStatus(statusClean);
|
|
322
322
|
if (
|
|
323
323
|
matchesUsageLimitText(cleanMessage) ||
|
|
324
324
|
(isLimitStatus && (isOpaque || parseRateLimitReason(cleanMessage) === "QUOTA_EXHAUSTED"))
|
package/src/error/rate-limit.ts
CHANGED
|
@@ -116,16 +116,19 @@ export function calculateRateLimitBackoffMs(reason: RateLimitReason): number {
|
|
|
116
116
|
|
|
117
117
|
/** Detect usage/quota limit errors in error messages (persistent, requires credential switch). */
|
|
118
118
|
const USAGE_LIMIT_PATTERN =
|
|
119
|
-
/usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?(?:exceeded|reached|insufficient)|额度不足|额度耗尽|resource.?exhausted|exhausted your capacity|quota will reset|insufficient.?(?:balance|quota)|run out of credits|out of credits|spending[- _]?limit|personal-team-blocked/i;
|
|
119
|
+
/usage.?limit|usage_limit_reached|usage_not_included|limit_reached|quota.?(?:exceeded|reached|insufficient)|额度不足|额度耗尽|resource.?exhausted|exhausted your capacity|quota will reset|insufficient.?(?:balance|quota)|balance.?exhausted|run out of credits|out of credits|spending[- _]?limit|personal-team-blocked/i;
|
|
120
120
|
|
|
121
121
|
/**
|
|
122
122
|
* HTTP status codes that, absent richer body classification, represent an
|
|
123
123
|
* account-local usage cap rather than a bad credential or a transient blip.
|
|
124
|
+
* HTTP 402 Payment Required is categorically an account-billing cap (xAI
|
|
125
|
+
* Grok Build "usage balance exhausted", DeepSeek "Insufficient Balance",
|
|
126
|
+
* OpenRouter credit exhaustion) — never a transient blip or bad credential.
|
|
124
127
|
* Always combine with {@link isUsageLimitOutcome} when a message is available
|
|
125
128
|
* — a 429 carrying transient rate-limit wording is NOT a usage cap.
|
|
126
129
|
*/
|
|
127
130
|
export function isUsageLimitStatus(status: number | undefined): boolean {
|
|
128
|
-
return status === 429;
|
|
131
|
+
return status === 429 || status === 402;
|
|
129
132
|
}
|
|
130
133
|
|
|
131
134
|
/**
|
|
@@ -135,7 +138,7 @@ export function isUsageLimitStatus(status: number | undefined): boolean {
|
|
|
135
138
|
* 1. Body matches {@link isUsageLimitError} (Codex `usage_limit_reached`,
|
|
136
139
|
* Anthropic account rate-limit, Google `resource_exhausted`, OpenAI
|
|
137
140
|
* `insufficient_quota`, …) → rotate.
|
|
138
|
-
* 2. Status is not 429 → backoff (caller's domain).
|
|
141
|
+
* 2. Status is not a usage-limit status (429/402) → backoff (caller's domain).
|
|
139
142
|
* 3. Body is absent or {@link isOpaqueStatusBody opaque} (just the status,
|
|
140
143
|
* empty JSON, HTTP framing only) → rotate conservatively: the server
|
|
141
144
|
* gave us nothing else to go on.
|
|
@@ -154,14 +157,15 @@ export function isUsageLimitOutcome(status: number | undefined, message: string
|
|
|
154
157
|
}
|
|
155
158
|
|
|
156
159
|
/**
|
|
157
|
-
* A
|
|
158
|
-
* empty, whitespace-only, the status digits with HTTP/JSON
|
|
159
|
-
* generic punctuation. Anything else (retry hints, capacity
|
|
160
|
-
* descriptions) is informative enough to defer to the
|
|
160
|
+
* A usage-limit status body is opaque when it carries no signal beyond the
|
|
161
|
+
* status itself — empty, whitespace-only, the status digits with HTTP/JSON
|
|
162
|
+
* framing, or generic punctuation. Anything else (retry hints, capacity
|
|
163
|
+
* wording, error descriptions) is informative enough to defer to the
|
|
164
|
+
* classifier.
|
|
161
165
|
*/
|
|
162
166
|
export function isOpaqueStatusBody(message: string): boolean {
|
|
163
167
|
const cleaned = message
|
|
164
|
-
.replace(/\
|
|
168
|
+
.replace(/\b(?:429|402)\b/g, "")
|
|
165
169
|
.replace(/\b(?:http|https|status|error|code|response|message)\b/gi, "");
|
|
166
170
|
return !/[a-z\d]{3,}/i.test(cleaned);
|
|
167
171
|
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test";
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "bun:test";
|
|
2
2
|
import { getBundledModel } from "@oh-my-pi/pi-catalog";
|
|
3
|
+
import * as kimiOauth from "../../registry/oauth/kimi";
|
|
3
4
|
import type { Context } from "../../types";
|
|
4
5
|
import type { MessageCreateParamsStreaming } from "../anthropic-wire";
|
|
6
|
+
import { streamKimi } from "../kimi";
|
|
5
7
|
import { streamOpenAIAnthropicShim } from "../openai-anthropic-shim";
|
|
6
8
|
import {
|
|
7
9
|
applyChatCompletionsCompatPolicy,
|
|
@@ -10,6 +12,15 @@ import {
|
|
|
10
12
|
} from "../openai-shared";
|
|
11
13
|
|
|
12
14
|
const BASE_CHAT_COMPLETIONS_PARAMS: OpenAICompletionsParams = { messages: [], model: "unused", stream: true };
|
|
15
|
+
const KIMI_HEADERS = Object.freeze({
|
|
16
|
+
"User-Agent": "KimiCLI/test",
|
|
17
|
+
"X-Msh-Platform": "kimi_cli",
|
|
18
|
+
"X-Msh-Version": "test",
|
|
19
|
+
"X-Msh-Device-Name": "test",
|
|
20
|
+
"X-Msh-Device-Model": "test",
|
|
21
|
+
"X-Msh-Os-Version": "test",
|
|
22
|
+
"X-Msh-Device-Id": "test",
|
|
23
|
+
});
|
|
13
24
|
const TITLE_CONTEXT: Context = {
|
|
14
25
|
systemPrompt: ["Generate a title."],
|
|
15
26
|
messages: [{ role: "user", content: "Explain the login failure", timestamp: 0 }],
|
|
@@ -27,8 +38,12 @@ const TITLE_CONTEXT: Context = {
|
|
|
27
38
|
],
|
|
28
39
|
};
|
|
29
40
|
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
vi.restoreAllMocks();
|
|
43
|
+
});
|
|
44
|
+
|
|
30
45
|
describe("Kimi K2.7 Code thinking policy", () => {
|
|
31
|
-
it("
|
|
46
|
+
it("expresses disabled thinking explicitly for title-generator-style Kimi Code requests", () => {
|
|
32
47
|
const model = getBundledModel<"openai-completions">("kimi-code", "kimi-for-coding");
|
|
33
48
|
const policy = resolveOpenAICompatPolicy(model, {
|
|
34
49
|
endpoint: "chat-completions",
|
|
@@ -39,11 +54,16 @@ describe("Kimi K2.7 Code thinking policy", () => {
|
|
|
39
54
|
|
|
40
55
|
applyChatCompletionsCompatPolicy(params, policy);
|
|
41
56
|
|
|
42
|
-
|
|
43
|
-
|
|
57
|
+
// Kimi's native hosts speak the z.ai binary thinking field: a disabled
|
|
58
|
+
// request carries `{ type: "disabled" }` rather than omitting the block.
|
|
59
|
+
expect((params as Record<string, unknown>).thinking).toEqual({ type: "disabled" });
|
|
60
|
+
// Thinking yields to a forced tool choice (#5758 review): the choice is
|
|
61
|
+
// honored and reasoning is turned off, instead of downgrading the choice.
|
|
62
|
+
expect(model.compat.supportsForcedToolChoice).toBe(true);
|
|
63
|
+
expect(model.compat.disableReasoningOnForcedToolChoice).toBe(true);
|
|
44
64
|
});
|
|
45
65
|
|
|
46
|
-
it("
|
|
66
|
+
it("keeps the forced tool choice and omits thinking on Kimi Code's Anthropic endpoint", async () => {
|
|
47
67
|
const model = getBundledModel<"openai-completions">("kimi-code", "kimi-for-coding");
|
|
48
68
|
let payload: MessageCreateParamsStreaming | undefined;
|
|
49
69
|
const stream = streamOpenAIAnthropicShim(
|
|
@@ -67,8 +87,43 @@ describe("Kimi K2.7 Code thinking policy", () => {
|
|
|
67
87
|
|
|
68
88
|
await stream.result();
|
|
69
89
|
|
|
70
|
-
|
|
71
|
-
|
|
90
|
+
// With reasoning disabled the Anthropic wire carries no thinking block,
|
|
91
|
+
// and the forced tool choice survives (thinking yields to the choice).
|
|
92
|
+
expect(payload?.thinking).toBeUndefined();
|
|
93
|
+
expect(payload?.tool_choice).toEqual({ type: "tool", name: "set_title" });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("uses the configured Kimi base URL for Anthropic requests", async () => {
|
|
97
|
+
vi.spyOn(kimiOauth, "getKimiCommonHeaders").mockReturnValue(KIMI_HEADERS);
|
|
98
|
+
const bundledModel = getBundledModel<"openai-completions">("kimi-code", "kimi-for-coding");
|
|
99
|
+
const model = { ...bundledModel, baseUrl: "https://gateway.example.com/v1" };
|
|
100
|
+
let requestedUrl: string | undefined;
|
|
101
|
+
const stream = streamKimi(
|
|
102
|
+
model,
|
|
103
|
+
{
|
|
104
|
+
systemPrompt: [],
|
|
105
|
+
messages: [{ role: "user", content: "Reply OK", timestamp: 0 }],
|
|
106
|
+
tools: [],
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
format: "anthropic",
|
|
110
|
+
apiKey: "gateway-key",
|
|
111
|
+
fetch: async input => {
|
|
112
|
+
requestedUrl = String(input);
|
|
113
|
+
return new Response(
|
|
114
|
+
JSON.stringify({
|
|
115
|
+
type: "error",
|
|
116
|
+
error: { type: "authentication_error", message: "stop after URL capture" },
|
|
117
|
+
}),
|
|
118
|
+
{ status: 401, headers: { "content-type": "application/json" } },
|
|
119
|
+
);
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
await stream.result();
|
|
125
|
+
|
|
126
|
+
expect(requestedUrl).toBe("https://gateway.example.com/v1/messages");
|
|
72
127
|
});
|
|
73
128
|
|
|
74
129
|
it("omits disabled thinking for native Moonshot Kimi K2.7 Code variants", () => {
|
package/src/providers/cursor.ts
CHANGED
|
@@ -352,7 +352,30 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
352
352
|
let heartbeatTimer: NodeJS.Timeout | null = null;
|
|
353
353
|
let debugResponseLogPromise: Promise<RequestDebugResponseLog | undefined> | undefined;
|
|
354
354
|
const h2Completion = Promise.withResolvers<void>();
|
|
355
|
-
let
|
|
355
|
+
let h2Settled = false;
|
|
356
|
+
let sawTurnEnded = false;
|
|
357
|
+
let endStreamError: Error | null = null;
|
|
358
|
+
const settleH2 = (error?: unknown): void => {
|
|
359
|
+
if (h2Settled) return;
|
|
360
|
+
h2Settled = true;
|
|
361
|
+
if (error !== undefined) {
|
|
362
|
+
h2Completion.reject(error);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (endStreamError) {
|
|
366
|
+
h2Completion.reject(endStreamError);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (!sawTurnEnded) {
|
|
370
|
+
h2Completion.reject(
|
|
371
|
+
new AIError.ProviderResponseError("Cursor stream ended before turnEnded", {
|
|
372
|
+
kind: "incomplete-stream",
|
|
373
|
+
}),
|
|
374
|
+
);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
h2Completion.resolve();
|
|
378
|
+
};
|
|
356
379
|
|
|
357
380
|
try {
|
|
358
381
|
const apiKey = options?.apiKey;
|
|
@@ -408,17 +431,17 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
408
431
|
} else {
|
|
409
432
|
h2Client = http2.connect(baseUrl);
|
|
410
433
|
}
|
|
411
|
-
h2Client.on("error",
|
|
434
|
+
h2Client.on("error", error => settleH2(error));
|
|
412
435
|
|
|
413
436
|
h2Request = h2Client.request(requestHeaders);
|
|
414
437
|
|
|
415
438
|
stream.push({ type: "start", partial: output });
|
|
416
439
|
|
|
417
440
|
let pendingBuffer = Buffer.alloc(0);
|
|
418
|
-
let endStreamError: Error | null = null;
|
|
419
441
|
let currentTextBlock: (TextContent & { [kStreamingBlockIndex]: number }) | null = null;
|
|
420
442
|
let currentThinkingBlock: (ThinkingContent & { [kStreamingBlockIndex]: number }) | null = null;
|
|
421
443
|
let currentToolCall: ToolCallState | null = null;
|
|
444
|
+
const resolvedMcpToolCallIds = new Set<string>();
|
|
422
445
|
const usageState: UsageState = { sawTokenDelta: false };
|
|
423
446
|
|
|
424
447
|
const state: BlockState = {
|
|
@@ -431,6 +454,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
431
454
|
get currentToolCall() {
|
|
432
455
|
return currentToolCall;
|
|
433
456
|
},
|
|
457
|
+
resolvedMcpToolCallIds,
|
|
434
458
|
get firstTokenTime() {
|
|
435
459
|
return firstTokenTime;
|
|
436
460
|
},
|
|
@@ -505,12 +529,9 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
505
529
|
log("error", "handleServerMessage", { error: String(error) });
|
|
506
530
|
});
|
|
507
531
|
|
|
508
|
-
//
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
const r = resolveH2;
|
|
512
|
-
resolveH2 = undefined;
|
|
513
|
-
r();
|
|
532
|
+
// Application completion is not protocol success; wait for a clean HTTP/2 end.
|
|
533
|
+
if (isTurnEnded) {
|
|
534
|
+
sawTurnEnded = true;
|
|
514
535
|
}
|
|
515
536
|
} catch (e) {
|
|
516
537
|
log("error", "parseServerMessage", { error: String(e) });
|
|
@@ -537,40 +558,29 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
537
558
|
h2Request.on("trailers", trailers => {
|
|
538
559
|
const status = trailers["grpc-status"];
|
|
539
560
|
const msg = trailers["grpc-message"];
|
|
540
|
-
if (status && status !== "0") {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
{ kind: "envelope" },
|
|
546
|
-
),
|
|
547
|
-
);
|
|
548
|
-
});
|
|
561
|
+
if (status && status !== "0" && !endStreamError) {
|
|
562
|
+
endStreamError = new AIError.ProviderResponseError(
|
|
563
|
+
`gRPC error ${status}: ${decodeURIComponent(String(msg || ""))}`,
|
|
564
|
+
{ kind: "envelope" },
|
|
565
|
+
);
|
|
549
566
|
}
|
|
550
567
|
});
|
|
551
568
|
|
|
552
569
|
h2Request.on("end", () => {
|
|
553
|
-
resolveH2 = undefined;
|
|
554
570
|
void closeDebugLog()
|
|
555
|
-
.then(() =>
|
|
556
|
-
|
|
557
|
-
h2Completion.reject(endStreamError);
|
|
558
|
-
return;
|
|
559
|
-
}
|
|
560
|
-
h2Completion.resolve();
|
|
561
|
-
})
|
|
562
|
-
.catch(h2Completion.reject);
|
|
571
|
+
.then(() => settleH2())
|
|
572
|
+
.catch(error => settleH2(error));
|
|
563
573
|
});
|
|
564
574
|
|
|
565
575
|
h2Request.on("error", error => {
|
|
566
|
-
void closeDebugLog().finally(() =>
|
|
576
|
+
void closeDebugLog().finally(() => settleH2(error));
|
|
567
577
|
});
|
|
568
578
|
|
|
569
579
|
if (options?.signal) {
|
|
570
580
|
options.signal.addEventListener("abort", () => {
|
|
571
581
|
h2Request?.close();
|
|
572
582
|
void closeDebugLog().finally(() => {
|
|
573
|
-
|
|
583
|
+
settleH2(new AIError.AbortError());
|
|
574
584
|
});
|
|
575
585
|
});
|
|
576
586
|
}
|
|
@@ -640,6 +650,8 @@ export interface BlockState {
|
|
|
640
650
|
currentTextBlock: (TextContent & { [kStreamingBlockIndex]: number }) | null;
|
|
641
651
|
currentThinkingBlock: (ThinkingContent & { [kStreamingBlockIndex]: number }) | null;
|
|
642
652
|
currentToolCall: ToolCallState | null;
|
|
653
|
+
/** MCP call IDs executed through Cursor's exec channel before their stream block arrives. */
|
|
654
|
+
resolvedMcpToolCallIds: Set<string>;
|
|
643
655
|
firstTokenTime: number | undefined;
|
|
644
656
|
setTextBlock: (b: (TextContent & { [kStreamingBlockIndex]: number }) | null) => void;
|
|
645
657
|
setThinkingBlock: (b: (ThinkingContent & { [kStreamingBlockIndex]: number }) | null) => void;
|
|
@@ -1292,6 +1304,13 @@ async function handleExecServerMessage(
|
|
|
1292
1304
|
case "mcpArgs": {
|
|
1293
1305
|
const args = execMsg.message.value;
|
|
1294
1306
|
const mcpCall = decodeMcpCall(args);
|
|
1307
|
+
if (execHandlers?.mcp) {
|
|
1308
|
+
if (state.currentToolCall?.id === mcpCall.toolCallId) {
|
|
1309
|
+
state.currentToolCall[kCursorExecResolved] = true;
|
|
1310
|
+
} else {
|
|
1311
|
+
state.resolvedMcpToolCallIds.add(mcpCall.toolCallId);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1295
1314
|
const { execResult } = await resolveExecHandler(
|
|
1296
1315
|
mcpCall,
|
|
1297
1316
|
execHandlers?.mcp?.bind(execHandlers),
|
|
@@ -2217,15 +2236,19 @@ export function processInteractionUpdate(
|
|
|
2217
2236
|
const mcpCall = toolCall.mcpToolCall;
|
|
2218
2237
|
if (mcpCall) {
|
|
2219
2238
|
const args = mcpCall.args || {};
|
|
2239
|
+
const id = args.toolCallId || crypto.randomUUID();
|
|
2220
2240
|
const block: ToolCallState = {
|
|
2221
2241
|
type: "toolCall",
|
|
2222
|
-
id
|
|
2242
|
+
id,
|
|
2223
2243
|
name: args.name || args.toolName || "",
|
|
2224
2244
|
arguments: {},
|
|
2225
2245
|
[kStreamingBlockIndex]: output.content.length,
|
|
2226
2246
|
[kStreamingPartialJson]: "",
|
|
2227
2247
|
[kStreamingBlockKind]: "mcp",
|
|
2228
2248
|
};
|
|
2249
|
+
if (state.resolvedMcpToolCallIds.delete(id)) {
|
|
2250
|
+
block[kCursorExecResolved] = true;
|
|
2251
|
+
}
|
|
2229
2252
|
output.content.push(block);
|
|
2230
2253
|
state.setToolCall(block);
|
|
2231
2254
|
stream.push({ type: "toolcall_start", contentIndex: output.content.length - 1, partial: output });
|
|
@@ -24,6 +24,7 @@ import { normalizeSystemPrompts } from "../utils";
|
|
|
24
24
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
25
25
|
import { toolWireSchema } from "../utils/schema/wire";
|
|
26
26
|
import chatmlHistoryNote from "./gitlab-duo-workflow-chatml-note.md" with { type: "text" };
|
|
27
|
+
import { redactSensitiveCredentials } from "./transform-messages";
|
|
27
28
|
|
|
28
29
|
export const GITLAB_DUO_WORKFLOW_PROVIDER_ID = "gitlab-duo-agent";
|
|
29
30
|
export const GITLAB_DUO_WORKFLOW_API = "gitlab-duo-agent";
|
|
@@ -2581,10 +2582,13 @@ function isGitLabDuoWorkflowChatMlGoal(context: Context): boolean {
|
|
|
2581
2582
|
// conversation sequences the way `Human:`/`Assistant:` are.
|
|
2582
2583
|
function buildGitLabDuoWorkflowGoal(context: Context): string {
|
|
2583
2584
|
const conversation = buildGitLabDuoWorkflowConversationHistory(context.messages);
|
|
2585
|
+
// The goal transcript bypasses transformMessages, so apply the outbound
|
|
2586
|
+
// credential redaction here — the same scrub the flow-config system slot
|
|
2587
|
+
// already receives — before the payload leaves the process.
|
|
2584
2588
|
if (conversation.length <= 1) {
|
|
2585
|
-
return extractLatestUserPrompt(context.messages);
|
|
2589
|
+
return redactSensitiveCredentials(extractLatestUserPrompt(context.messages));
|
|
2586
2590
|
}
|
|
2587
|
-
return renderGitLabDuoWorkflowChatMl(conversation);
|
|
2591
|
+
return redactSensitiveCredentials(renderGitLabDuoWorkflowChatMl(conversation));
|
|
2588
2592
|
}
|
|
2589
2593
|
|
|
2590
2594
|
const GITLAB_DUO_WORKFLOW_CHATML_START = "<|im_start|>";
|
package/src/providers/kimi.ts
CHANGED
|
@@ -20,9 +20,6 @@ import {
|
|
|
20
20
|
|
|
21
21
|
export type KimiApiFormat = OpenAIAnthropicApiFormat;
|
|
22
22
|
|
|
23
|
-
// Note: Anthropic SDK appends /v1/messages, so base URL should not include /v1
|
|
24
|
-
const KIMI_ANTHROPIC_BASE_URL = "https://api.kimi.com/coding";
|
|
25
|
-
|
|
26
23
|
export interface KimiOptions extends OpenAIAnthropicShimOptions {
|
|
27
24
|
/** API format: "openai" or "anthropic". Default: "anthropic" */
|
|
28
25
|
format?: KimiApiFormat;
|
|
@@ -38,7 +35,7 @@ export function streamKimi(
|
|
|
38
35
|
options?: KimiOptions,
|
|
39
36
|
): AssistantMessageEventStream {
|
|
40
37
|
return streamOpenAIAnthropicShim(model, context, options, {
|
|
41
|
-
anthropicBaseUrl:
|
|
38
|
+
anthropicBaseUrl: model.baseUrl.replace(/\/v1\/?$/, ""),
|
|
42
39
|
defaultFormat: "anthropic",
|
|
43
40
|
extraHeaders: getKimiCommonHeaders,
|
|
44
41
|
});
|
|
@@ -269,6 +269,7 @@ function stripImageDetails(input: unknown[]): void {
|
|
|
269
269
|
export interface CodexLiteShapedBody {
|
|
270
270
|
instructions?: unknown;
|
|
271
271
|
tools?: unknown;
|
|
272
|
+
tool_choice?: unknown;
|
|
272
273
|
input?: unknown;
|
|
273
274
|
parallel_tool_calls?: unknown;
|
|
274
275
|
}
|
|
@@ -278,9 +279,14 @@ export interface CodexLiteShapedBody {
|
|
|
278
279
|
* `build_responses_request` with `use_responses_lite`): strips pinned image
|
|
279
280
|
* detail, forces parallel tool calling off, moves tools into a leading
|
|
280
281
|
* `additional_tools` developer item and the base instructions into a
|
|
281
|
-
* developer message, then omits top-level `instructions`/`tools`.
|
|
282
|
-
*
|
|
283
|
-
*
|
|
282
|
+
* developer message, then omits top-level `instructions`/`tools`. Because the
|
|
283
|
+
* rewrite removes top-level `tools`, a forced hosted-tool choice (e.g.
|
|
284
|
+
* `{ type: "web_search" }`) would leave the backend unable to validate the
|
|
285
|
+
* choice against a tools collection and it rejects the request with HTTP 400
|
|
286
|
+
* (#5771). Such choices must fall back to `"auto"`; explicit string constraints
|
|
287
|
+
* such as `"none"` and `"required"` remain valid. Shared by normal turns and
|
|
288
|
+
* both remote-compaction paths — codex-rs routes `/responses/compact` through
|
|
289
|
+
* the same builder.
|
|
284
290
|
*/
|
|
285
291
|
export function applyCodexResponsesLiteShape(body: CodexLiteShapedBody): void {
|
|
286
292
|
const input = Array.isArray(body.input) ? body.input : [];
|
|
@@ -297,6 +303,9 @@ export function applyCodexResponsesLiteShape(body: CodexLiteShapedBody): void {
|
|
|
297
303
|
});
|
|
298
304
|
}
|
|
299
305
|
body.input = [...prefix, ...input];
|
|
306
|
+
if (body.tool_choice !== "none" && body.tool_choice !== "required") {
|
|
307
|
+
body.tool_choice = "auto";
|
|
308
|
+
}
|
|
300
309
|
delete body.instructions;
|
|
301
310
|
delete body.tools;
|
|
302
311
|
}
|
|
@@ -112,7 +112,7 @@ import {
|
|
|
112
112
|
promoteResponsesToolUseStopReason,
|
|
113
113
|
type SequentialCutoffSummaryState,
|
|
114
114
|
} from "./openai-shared";
|
|
115
|
-
import { transformMessages } from "./transform-messages";
|
|
115
|
+
import { redactSensitiveInObject, transformMessages } from "./transform-messages";
|
|
116
116
|
|
|
117
117
|
export interface OpenAICodexResponsesOptions extends StreamOptions {
|
|
118
118
|
reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
@@ -3954,13 +3954,14 @@ function convertMessages(model: Model<"openai-codex-responses">, context: Contex
|
|
|
3954
3954
|
| Array<ResponseInput[number]>
|
|
3955
3955
|
| undefined;
|
|
3956
3956
|
if (historyItems) {
|
|
3957
|
-
|
|
3957
|
+
const redactedHistoryItems = redactSensitiveInObject(historyItems).result as Array<ResponseInput[number]>;
|
|
3958
|
+
for (const item of redactedHistoryItems) {
|
|
3958
3959
|
const maybe = item as { type?: string; call_id?: string };
|
|
3959
3960
|
if (maybe.type === "custom_tool_call" && typeof maybe.call_id === "string") {
|
|
3960
3961
|
customCallIds.add(maybe.call_id);
|
|
3961
3962
|
}
|
|
3962
3963
|
}
|
|
3963
|
-
messages.push(...
|
|
3964
|
+
messages.push(...redactedHistoryItems);
|
|
3964
3965
|
msgIndex += 1;
|
|
3965
3966
|
continue;
|
|
3966
3967
|
}
|
|
@@ -94,9 +94,9 @@ import {
|
|
|
94
94
|
type OpenAIStrictToolsState,
|
|
95
95
|
parseAzureDeploymentNameMap,
|
|
96
96
|
resolveOpenAICompatPolicy,
|
|
97
|
+
resolveOpenAICompletionsOutputClamp,
|
|
97
98
|
resolveOpenAIOutputTokenParam,
|
|
98
99
|
resolveOpenAIRequestSetup,
|
|
99
|
-
resolveZaiReasoningOutputClamp,
|
|
100
100
|
shouldRetryWithoutStrictTools,
|
|
101
101
|
} from "./openai-shared";
|
|
102
102
|
import { transformMessages } from "./transform-messages";
|
|
@@ -1571,7 +1571,7 @@ function buildParams(
|
|
|
1571
1571
|
omitMaxOutputTokens: model.omitMaxOutputTokens ?? false,
|
|
1572
1572
|
isOpenRouterHost: compat.isOpenRouterHost,
|
|
1573
1573
|
alwaysSendMaxTokens: compat.alwaysSendMaxTokens,
|
|
1574
|
-
providerOutputClamp:
|
|
1574
|
+
providerOutputClamp: resolveOpenAICompletionsOutputClamp(model, compat),
|
|
1575
1575
|
});
|
|
1576
1576
|
if (outputToken) {
|
|
1577
1577
|
if (outputToken.field === "max_tokens") {
|
|
@@ -594,7 +594,11 @@ const streamOpenAIResponsesOnce = (
|
|
|
594
594
|
error instanceof Error &&
|
|
595
595
|
/previous[ _]?response/i.test(error.message) &&
|
|
596
596
|
/zero[ _-]?data[ _-]?retention/i.test(error.message);
|
|
597
|
-
|
|
597
|
+
const isPromptBlocked =
|
|
598
|
+
error instanceof Error &&
|
|
599
|
+
((error as { code?: string }).code === "invalid_prompt" ||
|
|
600
|
+
/invalid_prompt|Request blocked/i.test(error.message));
|
|
601
|
+
if (!zdrRejection && !isPromptBlocked && !isOpenAIResponsesStalePreviousResponseError(error)) {
|
|
598
602
|
throw error;
|
|
599
603
|
}
|
|
600
604
|
// Server rejected the chain baseline: reset, count the failure (or
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Effort } from "@oh-my-pi/pi-catalog/effort";
|
|
2
2
|
import { toFirepassWireModelId, toFireworksWireModelId } from "@oh-my-pi/pi-catalog/fireworks-model-id";
|
|
3
|
-
import { isGlm52ReasoningEffortModelId } from "@oh-my-pi/pi-catalog/identity";
|
|
3
|
+
import { isGlm52ReasoningEffortModelId, isKimiK3ModelId } from "@oh-my-pi/pi-catalog/identity";
|
|
4
4
|
import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
|
|
5
5
|
import { calculateCost } from "@oh-my-pi/pi-catalog/models";
|
|
6
6
|
import type {
|
|
@@ -1025,16 +1025,24 @@ function isZaiReasoningEffortDialect(model: Model<"openai-completions">, compat:
|
|
|
1025
1025
|
}
|
|
1026
1026
|
|
|
1027
1027
|
/**
|
|
1028
|
-
*
|
|
1029
|
-
*
|
|
1030
|
-
*
|
|
1031
|
-
*
|
|
1028
|
+
* Provider-specific Chat Completions output clamp.
|
|
1029
|
+
*
|
|
1030
|
+
* Most OpenAI-compatible endpoints retain the conservative 64k ceiling from
|
|
1031
|
+
* {@link resolveOpenAIOutputTokenParam}. Z.AI/GLM-5.2 reasoning and native
|
|
1032
|
+
* Moonshot K3 explicitly accept their full advertised model caps, so those
|
|
1033
|
+
* routes clamp to `model.maxTokens` instead.
|
|
1032
1034
|
*/
|
|
1033
|
-
export function
|
|
1035
|
+
export function resolveOpenAICompletionsOutputClamp(
|
|
1034
1036
|
model: Model<"openai-completions">,
|
|
1035
1037
|
compat: ResolvedOpenAICompat,
|
|
1036
1038
|
): number | undefined {
|
|
1037
|
-
|
|
1039
|
+
if (isZaiReasoningEffortDialect(model, compat)) {
|
|
1040
|
+
return model.maxTokens ?? OPENAI_MAX_OUTPUT_TOKENS;
|
|
1041
|
+
}
|
|
1042
|
+
if (model.provider === "moonshot" && isKimiK3ModelId(model.id)) {
|
|
1043
|
+
return model.maxTokens ?? OPENAI_MAX_OUTPUT_TOKENS;
|
|
1044
|
+
}
|
|
1045
|
+
return undefined;
|
|
1038
1046
|
}
|
|
1039
1047
|
|
|
1040
1048
|
/**
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { renderDemotedThinking } from "../dialect/demotion";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
Api,
|
|
4
|
+
AssistantMessage,
|
|
5
|
+
DeveloperMessage,
|
|
6
|
+
Message,
|
|
7
|
+
Model,
|
|
8
|
+
ToolCall,
|
|
9
|
+
ToolResultMessage,
|
|
10
|
+
UserMessage,
|
|
11
|
+
} from "../types";
|
|
3
12
|
import { isDemotedThinking, kDemotedThinking } from "../utils/block-symbols";
|
|
4
13
|
|
|
5
14
|
const enum ToolCallStatus {
|
|
@@ -286,6 +295,156 @@ function normalizeAnthropicTargetToolCallId<TApi extends Api>(
|
|
|
286
295
|
* - Preserves tool call structure (unlike converting to text summaries)
|
|
287
296
|
* - Injects synthetic "aborted" tool results
|
|
288
297
|
*/
|
|
298
|
+
const SENSITIVE_TOKEN_RE =
|
|
299
|
+
/(?<![a-zA-Z0-9_*-])(gh[opusr]_[a-zA-Z0-9_*]{36,}|github_pat_[a-zA-Z0-9_*]{36,}|glpat-[a-zA-Z0-9_*-]{20,}|sk-proj-[a-zA-Z0-9_*-]{36,}|sk-ant-[a-zA-Z0-9_*-]{36,}|sk-[a-zA-Z0-9_*-]{48,})(?![a-zA-Z0-9_*-])/gi;
|
|
300
|
+
|
|
301
|
+
function hasPlausibleCredentialEntropy(token: string): boolean {
|
|
302
|
+
const lower = token.toLowerCase();
|
|
303
|
+
const prefixLength = lower.startsWith("github_pat_")
|
|
304
|
+
? "github_pat_".length
|
|
305
|
+
: lower.startsWith("glpat-")
|
|
306
|
+
? "glpat-".length
|
|
307
|
+
: lower.startsWith("sk-proj-")
|
|
308
|
+
? "sk-proj-".length
|
|
309
|
+
: lower.startsWith("sk-ant-")
|
|
310
|
+
? "sk-ant-".length
|
|
311
|
+
: lower.startsWith("gh")
|
|
312
|
+
? 4
|
|
313
|
+
: 3;
|
|
314
|
+
const secret = token.slice(prefixLength);
|
|
315
|
+
if (/^\*+$/.test(secret)) return true;
|
|
316
|
+
return [/[a-z]/, /[A-Z]/, /\d/, /[_-]/].filter(pattern => pattern.test(secret)).length >= 2;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function redactSensitiveCredentials(text: string): string {
|
|
320
|
+
return text.replace(SENSITIVE_TOKEN_RE, match => {
|
|
321
|
+
if (!hasPlausibleCredentialEntropy(match)) return match;
|
|
322
|
+
const lower = match.toLowerCase();
|
|
323
|
+
if (lower.startsWith("gh")) {
|
|
324
|
+
return "[github_token_redacted]";
|
|
325
|
+
}
|
|
326
|
+
if (lower.startsWith("gl")) {
|
|
327
|
+
return "[gitlab_token_redacted]";
|
|
328
|
+
}
|
|
329
|
+
if (lower.startsWith("sk-ant-")) {
|
|
330
|
+
return "[anthropic_token_redacted]";
|
|
331
|
+
}
|
|
332
|
+
if (lower.startsWith("sk")) {
|
|
333
|
+
return "[openai_token_redacted]";
|
|
334
|
+
}
|
|
335
|
+
return "[token_redacted]";
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function redactSensitiveInObject(val: unknown): { result: unknown; changed: boolean } {
|
|
340
|
+
if (typeof val === "string") {
|
|
341
|
+
const redacted = redactSensitiveCredentials(val);
|
|
342
|
+
return { result: redacted, changed: redacted !== val };
|
|
343
|
+
}
|
|
344
|
+
if (Array.isArray(val)) {
|
|
345
|
+
let changed = false;
|
|
346
|
+
const result = val.map(item => {
|
|
347
|
+
const res = redactSensitiveInObject(item);
|
|
348
|
+
if (res.changed) changed = true;
|
|
349
|
+
return res.result;
|
|
350
|
+
});
|
|
351
|
+
return { result, changed };
|
|
352
|
+
}
|
|
353
|
+
if (val !== null && typeof val === "object") {
|
|
354
|
+
let changed = false;
|
|
355
|
+
const res: Record<string, unknown> = {};
|
|
356
|
+
for (const [k, v] of Object.entries(val)) {
|
|
357
|
+
const sub = redactSensitiveInObject(v);
|
|
358
|
+
if (sub.changed) changed = true;
|
|
359
|
+
res[k] = sub.result;
|
|
360
|
+
}
|
|
361
|
+
return { result: res, changed };
|
|
362
|
+
}
|
|
363
|
+
return { result: val, changed: false };
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function redactSensitiveCredentialsInMessages(messages: Message[]): Message[] {
|
|
367
|
+
return messages.map((msg): Message => {
|
|
368
|
+
if (msg.role === "user" || msg.role === "developer") {
|
|
369
|
+
const userMsg = msg as UserMessage | DeveloperMessage;
|
|
370
|
+
if (typeof userMsg.content === "string") {
|
|
371
|
+
const redacted = redactSensitiveCredentials(userMsg.content);
|
|
372
|
+
if (redacted === userMsg.content) return msg;
|
|
373
|
+
return { ...userMsg, content: redacted } as Message;
|
|
374
|
+
}
|
|
375
|
+
const contentArray = userMsg.content;
|
|
376
|
+
let changed = false;
|
|
377
|
+
const content = contentArray.map((block): UserMessage["content"][number] => {
|
|
378
|
+
if (block.type === "text") {
|
|
379
|
+
const redacted = redactSensitiveCredentials(block.text);
|
|
380
|
+
if (redacted !== block.text) {
|
|
381
|
+
changed = true;
|
|
382
|
+
return { ...block, text: redacted };
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return block;
|
|
386
|
+
});
|
|
387
|
+
return (changed ? { ...userMsg, content } : userMsg) as Message;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (msg.role === "toolResult") {
|
|
391
|
+
const toolResultMsg = msg as ToolResultMessage;
|
|
392
|
+
let changed = false;
|
|
393
|
+
const content = toolResultMsg.content.map((block): ToolResultMessage["content"][number] => {
|
|
394
|
+
if (block.type === "text") {
|
|
395
|
+
const redacted = redactSensitiveCredentials(block.text);
|
|
396
|
+
if (redacted !== block.text) {
|
|
397
|
+
changed = true;
|
|
398
|
+
return { ...block, text: redacted };
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return block;
|
|
402
|
+
});
|
|
403
|
+
return (changed ? { ...toolResultMsg, content } : toolResultMsg) as Message;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (msg.role === "assistant") {
|
|
407
|
+
const assistantMsg = msg as AssistantMessage;
|
|
408
|
+
let changed = false;
|
|
409
|
+
const content = assistantMsg.content.map((block): AssistantMessage["content"][number] => {
|
|
410
|
+
if (block.type === "text") {
|
|
411
|
+
const redacted = redactSensitiveCredentials(block.text);
|
|
412
|
+
if (redacted !== block.text) {
|
|
413
|
+
changed = true;
|
|
414
|
+
return { ...block, text: redacted };
|
|
415
|
+
}
|
|
416
|
+
} else if (block.type === "thinking") {
|
|
417
|
+
const redacted = redactSensitiveCredentials(block.thinking);
|
|
418
|
+
if (redacted !== block.thinking) {
|
|
419
|
+
changed = true;
|
|
420
|
+
return { ...block, thinking: redacted, thinkingSignature: undefined };
|
|
421
|
+
}
|
|
422
|
+
} else if (block.type === "toolCall") {
|
|
423
|
+
if (block.arguments) {
|
|
424
|
+
const { result: redactedArgs, changed: argsChanged } = redactSensitiveInObject(block.arguments);
|
|
425
|
+
if (argsChanged) {
|
|
426
|
+
changed = true;
|
|
427
|
+
const castArgs =
|
|
428
|
+
redactedArgs && typeof redactedArgs === "object" && !Array.isArray(redactedArgs)
|
|
429
|
+
? (redactedArgs as Record<string, unknown>)
|
|
430
|
+
: undefined;
|
|
431
|
+
return {
|
|
432
|
+
...block,
|
|
433
|
+
arguments: castArgs,
|
|
434
|
+
thoughtSignature: undefined,
|
|
435
|
+
} as AssistantMessage["content"][number];
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return block;
|
|
440
|
+
});
|
|
441
|
+
return (changed ? { ...assistantMsg, content } : assistantMsg) as Message;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
return msg;
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
289
448
|
export function transformMessages<TApi extends Api>(
|
|
290
449
|
messages: Message[],
|
|
291
450
|
model: Model<TApi>,
|
|
@@ -294,6 +453,10 @@ export function transformMessages<TApi extends Api>(
|
|
|
294
453
|
duplicateToolCallIdSuffixPrefix = "_dup",
|
|
295
454
|
targetCompat: Model<TApi>["compat"] = model.compat,
|
|
296
455
|
): Message[] {
|
|
456
|
+
// Redact sensitive credential-like patterns from all outbound messages
|
|
457
|
+
// to prevent security block errors from LLM providers (e.g. invalid_prompt).
|
|
458
|
+
messages = redactSensitiveCredentialsInMessages(messages);
|
|
459
|
+
|
|
297
460
|
// Drop assistant `toolCall` blocks with empty/whitespace `id` or `name`
|
|
298
461
|
// (and their matched `toolResult` messages) before anything else looks at
|
|
299
462
|
// the history. Replays of these would 400 every provider — see
|
package/src/utils.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { $env } from "@oh-my-pi/pi-utils";
|
|
2
2
|
import type { ResponseInput, ResponseInputItem } from "./providers/openai-responses-wire";
|
|
3
|
+
import { redactSensitiveCredentials } from "./providers/transform-messages";
|
|
3
4
|
import type { CacheRetention, OpenAIResponsesHistoryPayload, ProviderPayload } from "./types";
|
|
4
5
|
|
|
5
6
|
type OpenAIResponsesReplayItem = ResponseInput[number];
|
|
@@ -9,7 +10,9 @@ export { isRecord } from "@oh-my-pi/pi-utils";
|
|
|
9
10
|
export function normalizeSystemPrompts(systemPrompt: readonly string[] | string | undefined | null): string[] {
|
|
10
11
|
if (systemPrompt === undefined || systemPrompt === null) return [];
|
|
11
12
|
const prompts = Array.isArray(systemPrompt) ? systemPrompt : typeof systemPrompt === "string" ? [systemPrompt] : [];
|
|
12
|
-
return prompts
|
|
13
|
+
return prompts
|
|
14
|
+
.map(prompt => redactSensitiveCredentials(prompt.toWellFormed()))
|
|
15
|
+
.filter(prompt => prompt.trim().length > 0);
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
export function normalizeToolCallId(id: string): string {
|