@oh-my-pi/pi-ai 17.0.1 → 17.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- 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 +16 -0
- package/dist/types/providers/kimi.d.ts +3 -3
- package/dist/types/providers/openai-anthropic-shim.d.ts +3 -1
- package/dist/types/providers/openai-codex/request-transformer.d.ts +9 -3
- package/dist/types/providers/openai-shared.d.ts +9 -5
- package/dist/types/providers/transform-messages.d.ts +5 -9
- package/dist/types/types.d.ts +1 -1
- package/dist/types/utils/event-stream.d.ts +2 -0
- 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 +180 -8
- package/src/providers/cursor.ts +82 -30
- package/src/providers/gitlab-duo-workflow.ts +6 -2
- package/src/providers/kimi.ts +6 -8
- package/src/providers/openai-anthropic-shim.ts +7 -1
- 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 +41 -11
- package/src/providers/transform-messages.ts +164 -1
- package/src/stream.ts +9 -4
- package/src/types.ts +1 -1
- package/src/usage/claude.ts +10 -24
- package/src/utils/event-stream.ts +5 -0
- package/src/utils.ts +4 -1
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,13 @@
|
|
|
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
|
|
3
|
+
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
|
4
|
+
import { Effort } from "@oh-my-pi/pi-catalog/effort";
|
|
5
|
+
import type { ModelSpec } from "@oh-my-pi/pi-catalog/types";
|
|
6
|
+
import * as kimiOauth from "../../registry/oauth/kimi";
|
|
7
|
+
import { streamSimple } from "../../stream";
|
|
8
|
+
import type { Context, Model } from "../../types";
|
|
4
9
|
import type { MessageCreateParamsStreaming } from "../anthropic-wire";
|
|
10
|
+
import { type KimiApiFormat, streamKimi } from "../kimi";
|
|
5
11
|
import { streamOpenAIAnthropicShim } from "../openai-anthropic-shim";
|
|
6
12
|
import {
|
|
7
13
|
applyChatCompletionsCompatPolicy,
|
|
@@ -10,6 +16,15 @@ import {
|
|
|
10
16
|
} from "../openai-shared";
|
|
11
17
|
|
|
12
18
|
const BASE_CHAT_COMPLETIONS_PARAMS: OpenAICompletionsParams = { messages: [], model: "unused", stream: true };
|
|
19
|
+
const KIMI_HEADERS = Object.freeze({
|
|
20
|
+
"User-Agent": "KimiCLI/test",
|
|
21
|
+
"X-Msh-Platform": "kimi_cli",
|
|
22
|
+
"X-Msh-Version": "test",
|
|
23
|
+
"X-Msh-Device-Name": "test",
|
|
24
|
+
"X-Msh-Device-Model": "test",
|
|
25
|
+
"X-Msh-Os-Version": "test",
|
|
26
|
+
"X-Msh-Device-Id": "test",
|
|
27
|
+
});
|
|
13
28
|
const TITLE_CONTEXT: Context = {
|
|
14
29
|
systemPrompt: ["Generate a title."],
|
|
15
30
|
messages: [{ role: "user", content: "Explain the login failure", timestamp: 0 }],
|
|
@@ -27,8 +42,125 @@ const TITLE_CONTEXT: Context = {
|
|
|
27
42
|
],
|
|
28
43
|
};
|
|
29
44
|
|
|
45
|
+
const K3_MODEL = buildModel({
|
|
46
|
+
id: "k3",
|
|
47
|
+
name: "K3",
|
|
48
|
+
api: "openai-completions",
|
|
49
|
+
provider: "kimi-code",
|
|
50
|
+
baseUrl: "https://api.kimi.com/coding/v1",
|
|
51
|
+
reasoning: true,
|
|
52
|
+
input: ["text"],
|
|
53
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
54
|
+
contextWindow: 1_048_576,
|
|
55
|
+
maxTokens: 32_000,
|
|
56
|
+
thinking: {
|
|
57
|
+
mode: "effort",
|
|
58
|
+
efforts: [Effort.Low, Effort.High, Effort.Max],
|
|
59
|
+
defaultLevel: Effort.Max,
|
|
60
|
+
requiresEffort: true,
|
|
61
|
+
},
|
|
62
|
+
compat: {
|
|
63
|
+
thinkingFormat: "kimi",
|
|
64
|
+
kimiApiFormat: "openai",
|
|
65
|
+
reasoningContentField: "reasoning_content",
|
|
66
|
+
supportsDeveloperRole: false,
|
|
67
|
+
},
|
|
68
|
+
} satisfies ModelSpec<"openai-completions">);
|
|
69
|
+
|
|
70
|
+
async function captureKimiPayload(
|
|
71
|
+
model: Model<"openai-completions">,
|
|
72
|
+
reasoning: Effort,
|
|
73
|
+
format?: KimiApiFormat,
|
|
74
|
+
): Promise<unknown> {
|
|
75
|
+
let payload: unknown;
|
|
76
|
+
const stream = streamKimi(
|
|
77
|
+
model,
|
|
78
|
+
{
|
|
79
|
+
systemPrompt: [],
|
|
80
|
+
messages: [{ role: "user", content: "Reply OK", timestamp: 0 }],
|
|
81
|
+
tools: [],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
...(format ? { format } : {}),
|
|
85
|
+
apiKey: "test-key",
|
|
86
|
+
reasoning,
|
|
87
|
+
onPayload: body => {
|
|
88
|
+
payload = body;
|
|
89
|
+
throw new Error("stop after payload capture");
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
);
|
|
93
|
+
await stream.result();
|
|
94
|
+
if (payload === undefined) throw new Error("Kimi request payload was not captured");
|
|
95
|
+
return payload;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
afterEach(() => {
|
|
99
|
+
vi.restoreAllMocks();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("Kimi K3 thinking transport", () => {
|
|
103
|
+
it("sends every live named effort through Kimi's native thinking object by default", async () => {
|
|
104
|
+
vi.spyOn(kimiOauth, "getKimiCommonHeaders").mockReturnValue(KIMI_HEADERS);
|
|
105
|
+
|
|
106
|
+
for (const effort of [Effort.Low, Effort.High, Effort.Max]) {
|
|
107
|
+
const payload = await captureKimiPayload(K3_MODEL, effort);
|
|
108
|
+
expect(payload).toMatchObject({ thinking: { type: "enabled", effort } });
|
|
109
|
+
expect(payload).not.toHaveProperty("reasoning_effort");
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("uses adaptive named effort rather than a token budget for an explicit Anthropic override", async () => {
|
|
114
|
+
vi.spyOn(kimiOauth, "getKimiCommonHeaders").mockReturnValue(KIMI_HEADERS);
|
|
115
|
+
|
|
116
|
+
const payload = await captureKimiPayload(K3_MODEL, Effort.Max, "anthropic");
|
|
117
|
+
|
|
118
|
+
expect(payload).toMatchObject({
|
|
119
|
+
thinking: { type: "adaptive" },
|
|
120
|
+
output_config: { effort: Effort.Max },
|
|
121
|
+
});
|
|
122
|
+
expect(payload).not.toHaveProperty("thinking.budget_tokens");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("keeps the legacy K2 default on the Anthropic transport", async () => {
|
|
126
|
+
vi.spyOn(kimiOauth, "getKimiCommonHeaders").mockReturnValue(KIMI_HEADERS);
|
|
127
|
+
const model = getBundledModel<"openai-completions">("kimi-code", "kimi-for-coding");
|
|
128
|
+
|
|
129
|
+
const payload = await captureKimiPayload(model, Effort.High);
|
|
130
|
+
|
|
131
|
+
expect(payload).toMatchObject({ thinking: { type: "enabled" } });
|
|
132
|
+
expect(payload).toHaveProperty("thinking.budget_tokens");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("clamps disabled thinking to the lowest effort for a mandatory-thinking K3", async () => {
|
|
136
|
+
vi.spyOn(kimiOauth, "getKimiCommonHeaders").mockReturnValue(KIMI_HEADERS);
|
|
137
|
+
|
|
138
|
+
let payload: unknown;
|
|
139
|
+
const stream = streamSimple(
|
|
140
|
+
K3_MODEL,
|
|
141
|
+
{
|
|
142
|
+
systemPrompt: [],
|
|
143
|
+
messages: [{ role: "user", content: "Reply OK", timestamp: 0 }],
|
|
144
|
+
tools: [],
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
apiKey: "test-key",
|
|
148
|
+
disableReasoning: true,
|
|
149
|
+
onPayload: body => {
|
|
150
|
+
payload = body;
|
|
151
|
+
throw new Error("stop after payload capture");
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
await stream.result();
|
|
156
|
+
|
|
157
|
+
expect(payload).toMatchObject({ thinking: { type: "enabled", effort: Effort.Low } });
|
|
158
|
+
expect(payload).not.toMatchObject({ thinking: { type: "disabled" } });
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
30
162
|
describe("Kimi K2.7 Code thinking policy", () => {
|
|
31
|
-
it("
|
|
163
|
+
it("expresses disabled thinking explicitly for title-generator-style Kimi Code requests", () => {
|
|
32
164
|
const model = getBundledModel<"openai-completions">("kimi-code", "kimi-for-coding");
|
|
33
165
|
const policy = resolveOpenAICompatPolicy(model, {
|
|
34
166
|
endpoint: "chat-completions",
|
|
@@ -39,11 +171,16 @@ describe("Kimi K2.7 Code thinking policy", () => {
|
|
|
39
171
|
|
|
40
172
|
applyChatCompletionsCompatPolicy(params, policy);
|
|
41
173
|
|
|
42
|
-
|
|
43
|
-
|
|
174
|
+
// Kimi's native hosts speak the z.ai binary thinking field: a disabled
|
|
175
|
+
// request carries `{ type: "disabled" }` rather than omitting the block.
|
|
176
|
+
expect((params as Record<string, unknown>).thinking).toEqual({ type: "disabled" });
|
|
177
|
+
// Thinking yields to a forced tool choice (#5758 review): the choice is
|
|
178
|
+
// honored and reasoning is turned off, instead of downgrading the choice.
|
|
179
|
+
expect(model.compat.supportsForcedToolChoice).toBe(true);
|
|
180
|
+
expect(model.compat.disableReasoningOnForcedToolChoice).toBe(true);
|
|
44
181
|
});
|
|
45
182
|
|
|
46
|
-
it("
|
|
183
|
+
it("keeps the forced tool choice and omits thinking on Kimi Code's Anthropic endpoint", async () => {
|
|
47
184
|
const model = getBundledModel<"openai-completions">("kimi-code", "kimi-for-coding");
|
|
48
185
|
let payload: MessageCreateParamsStreaming | undefined;
|
|
49
186
|
const stream = streamOpenAIAnthropicShim(
|
|
@@ -67,8 +204,43 @@ describe("Kimi K2.7 Code thinking policy", () => {
|
|
|
67
204
|
|
|
68
205
|
await stream.result();
|
|
69
206
|
|
|
70
|
-
|
|
71
|
-
|
|
207
|
+
// With reasoning disabled the Anthropic wire carries no thinking block,
|
|
208
|
+
// and the forced tool choice survives (thinking yields to the choice).
|
|
209
|
+
expect(payload?.thinking).toBeUndefined();
|
|
210
|
+
expect(payload?.tool_choice).toEqual({ type: "tool", name: "set_title" });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("uses the configured Kimi base URL for Anthropic requests", async () => {
|
|
214
|
+
vi.spyOn(kimiOauth, "getKimiCommonHeaders").mockReturnValue(KIMI_HEADERS);
|
|
215
|
+
const bundledModel = getBundledModel<"openai-completions">("kimi-code", "kimi-for-coding");
|
|
216
|
+
const model = { ...bundledModel, baseUrl: "https://gateway.example.com/v1" };
|
|
217
|
+
let requestedUrl: string | undefined;
|
|
218
|
+
const stream = streamKimi(
|
|
219
|
+
model,
|
|
220
|
+
{
|
|
221
|
+
systemPrompt: [],
|
|
222
|
+
messages: [{ role: "user", content: "Reply OK", timestamp: 0 }],
|
|
223
|
+
tools: [],
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
format: "anthropic",
|
|
227
|
+
apiKey: "gateway-key",
|
|
228
|
+
fetch: async input => {
|
|
229
|
+
requestedUrl = String(input);
|
|
230
|
+
return new Response(
|
|
231
|
+
JSON.stringify({
|
|
232
|
+
type: "error",
|
|
233
|
+
error: { type: "authentication_error", message: "stop after URL capture" },
|
|
234
|
+
}),
|
|
235
|
+
{ status: 401, headers: { "content-type": "application/json" } },
|
|
236
|
+
);
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
await stream.result();
|
|
242
|
+
|
|
243
|
+
expect(requestedUrl).toBe("https://gateway.example.com/v1/messages");
|
|
72
244
|
});
|
|
73
245
|
|
|
74
246
|
it("omits disabled thinking for native Moonshot Kimi K2.7 Code variants", () => {
|
package/src/providers/cursor.ts
CHANGED
|
@@ -213,6 +213,34 @@ function parseConnectEndStream(data: Uint8Array): Error | null {
|
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
/**
|
|
217
|
+
* Maps an opaque HTTP/2 negotiation failure into an actionable error.
|
|
218
|
+
*
|
|
219
|
+
* bun only opens an HTTP/2 session when TLS-ALPN negotiates `h2`. Behind a
|
|
220
|
+
* TLS-intercepting proxy that strips ALPN (e.g. Zscaler), the handshake yields
|
|
221
|
+
* no `h2` protocol and bun throws `ERR_HTTP2_ERROR: h2 is not supported`. The
|
|
222
|
+
* Cursor run RPC is HTTP/2-only (the ALB rejects HTTP/1.1 with 464), so there
|
|
223
|
+
* is no h1 fallback the way model discovery has one — the run simply cannot
|
|
224
|
+
* proceed. Replace the opaque message with one that names the cause and points
|
|
225
|
+
* at the `providers.cursor.baseUrl` workaround.
|
|
226
|
+
*
|
|
227
|
+
* Non-ALPN errors pass through untouched.
|
|
228
|
+
*/
|
|
229
|
+
export function mapH2TransportError(error: unknown, baseUrl: string): unknown {
|
|
230
|
+
const code = (error as { code?: unknown } | null)?.code;
|
|
231
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
232
|
+
if (code === "ERR_HTTP2_ERROR" && /h2 is not supported/i.test(message)) {
|
|
233
|
+
return new AIError.ProviderResponseError(
|
|
234
|
+
`Cursor run transport could not negotiate HTTP/2 with ${baseUrl}: "h2 is not supported". ` +
|
|
235
|
+
"This host serves the run RPC over HTTP/2 only, and the TLS handshake did not negotiate " +
|
|
236
|
+
"h2 via ALPN — typically an ALPN-stripping TLS-intercepting proxy (e.g. Zscaler). " +
|
|
237
|
+
"Front the provider with a local HTTP/2 bridge and set providers.cursor.baseUrl to it.",
|
|
238
|
+
{ provider: "cursor", kind: "runtime", cause: error },
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return error;
|
|
242
|
+
}
|
|
243
|
+
|
|
216
244
|
function debugBytes(bytes: Uint8Array, asHex: boolean): string {
|
|
217
245
|
if (asHex) {
|
|
218
246
|
return Buffer.from(bytes).toString("hex");
|
|
@@ -352,7 +380,30 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
352
380
|
let heartbeatTimer: NodeJS.Timeout | null = null;
|
|
353
381
|
let debugResponseLogPromise: Promise<RequestDebugResponseLog | undefined> | undefined;
|
|
354
382
|
const h2Completion = Promise.withResolvers<void>();
|
|
355
|
-
let
|
|
383
|
+
let h2Settled = false;
|
|
384
|
+
let sawTurnEnded = false;
|
|
385
|
+
let endStreamError: Error | null = null;
|
|
386
|
+
const settleH2 = (error?: unknown): void => {
|
|
387
|
+
if (h2Settled) return;
|
|
388
|
+
h2Settled = true;
|
|
389
|
+
if (error !== undefined) {
|
|
390
|
+
h2Completion.reject(error);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (endStreamError) {
|
|
394
|
+
h2Completion.reject(endStreamError);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (!sawTurnEnded) {
|
|
398
|
+
h2Completion.reject(
|
|
399
|
+
new AIError.ProviderResponseError("Cursor stream ended before turnEnded", {
|
|
400
|
+
kind: "incomplete-stream",
|
|
401
|
+
}),
|
|
402
|
+
);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
h2Completion.resolve();
|
|
406
|
+
};
|
|
356
407
|
|
|
357
408
|
try {
|
|
358
409
|
const apiKey = options?.apiKey;
|
|
@@ -408,17 +459,17 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
408
459
|
} else {
|
|
409
460
|
h2Client = http2.connect(baseUrl);
|
|
410
461
|
}
|
|
411
|
-
h2Client.on("error",
|
|
462
|
+
h2Client.on("error", error => settleH2(mapH2TransportError(error, baseUrl)));
|
|
412
463
|
|
|
413
464
|
h2Request = h2Client.request(requestHeaders);
|
|
414
465
|
|
|
415
466
|
stream.push({ type: "start", partial: output });
|
|
416
467
|
|
|
417
468
|
let pendingBuffer = Buffer.alloc(0);
|
|
418
|
-
let endStreamError: Error | null = null;
|
|
419
469
|
let currentTextBlock: (TextContent & { [kStreamingBlockIndex]: number }) | null = null;
|
|
420
470
|
let currentThinkingBlock: (ThinkingContent & { [kStreamingBlockIndex]: number }) | null = null;
|
|
421
471
|
let currentToolCall: ToolCallState | null = null;
|
|
472
|
+
const resolvedMcpToolCallIds = new Set<string>();
|
|
422
473
|
const usageState: UsageState = { sawTokenDelta: false };
|
|
423
474
|
|
|
424
475
|
const state: BlockState = {
|
|
@@ -431,6 +482,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
431
482
|
get currentToolCall() {
|
|
432
483
|
return currentToolCall;
|
|
433
484
|
},
|
|
485
|
+
resolvedMcpToolCallIds,
|
|
434
486
|
get firstTokenTime() {
|
|
435
487
|
return firstTokenTime;
|
|
436
488
|
},
|
|
@@ -505,12 +557,9 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
505
557
|
log("error", "handleServerMessage", { error: String(error) });
|
|
506
558
|
});
|
|
507
559
|
|
|
508
|
-
//
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
const r = resolveH2;
|
|
512
|
-
resolveH2 = undefined;
|
|
513
|
-
r();
|
|
560
|
+
// Application completion is not protocol success; wait for a clean HTTP/2 end.
|
|
561
|
+
if (isTurnEnded) {
|
|
562
|
+
sawTurnEnded = true;
|
|
514
563
|
}
|
|
515
564
|
} catch (e) {
|
|
516
565
|
log("error", "parseServerMessage", { error: String(e) });
|
|
@@ -537,40 +586,30 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
537
586
|
h2Request.on("trailers", trailers => {
|
|
538
587
|
const status = trailers["grpc-status"];
|
|
539
588
|
const msg = trailers["grpc-message"];
|
|
540
|
-
if (status && status !== "0") {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
{ kind: "envelope" },
|
|
546
|
-
),
|
|
547
|
-
);
|
|
548
|
-
});
|
|
589
|
+
if (status && status !== "0" && !endStreamError) {
|
|
590
|
+
endStreamError = new AIError.ProviderResponseError(
|
|
591
|
+
`gRPC error ${status}: ${decodeURIComponent(String(msg || ""))}`,
|
|
592
|
+
{ kind: "envelope" },
|
|
593
|
+
);
|
|
549
594
|
}
|
|
550
595
|
});
|
|
551
596
|
|
|
552
597
|
h2Request.on("end", () => {
|
|
553
|
-
resolveH2 = undefined;
|
|
554
598
|
void closeDebugLog()
|
|
555
|
-
.then(() =>
|
|
556
|
-
|
|
557
|
-
h2Completion.reject(endStreamError);
|
|
558
|
-
return;
|
|
559
|
-
}
|
|
560
|
-
h2Completion.resolve();
|
|
561
|
-
})
|
|
562
|
-
.catch(h2Completion.reject);
|
|
599
|
+
.then(() => settleH2())
|
|
600
|
+
.catch(error => settleH2(error));
|
|
563
601
|
});
|
|
564
602
|
|
|
565
603
|
h2Request.on("error", error => {
|
|
566
|
-
|
|
604
|
+
const mapped = mapH2TransportError(error, baseUrl);
|
|
605
|
+
void closeDebugLog().finally(() => settleH2(mapped));
|
|
567
606
|
});
|
|
568
607
|
|
|
569
608
|
if (options?.signal) {
|
|
570
609
|
options.signal.addEventListener("abort", () => {
|
|
571
610
|
h2Request?.close();
|
|
572
611
|
void closeDebugLog().finally(() => {
|
|
573
|
-
|
|
612
|
+
settleH2(new AIError.AbortError());
|
|
574
613
|
});
|
|
575
614
|
});
|
|
576
615
|
}
|
|
@@ -640,6 +679,8 @@ export interface BlockState {
|
|
|
640
679
|
currentTextBlock: (TextContent & { [kStreamingBlockIndex]: number }) | null;
|
|
641
680
|
currentThinkingBlock: (ThinkingContent & { [kStreamingBlockIndex]: number }) | null;
|
|
642
681
|
currentToolCall: ToolCallState | null;
|
|
682
|
+
/** MCP call IDs executed through Cursor's exec channel before their stream block arrives. */
|
|
683
|
+
resolvedMcpToolCallIds: Set<string>;
|
|
643
684
|
firstTokenTime: number | undefined;
|
|
644
685
|
setTextBlock: (b: (TextContent & { [kStreamingBlockIndex]: number }) | null) => void;
|
|
645
686
|
setThinkingBlock: (b: (ThinkingContent & { [kStreamingBlockIndex]: number }) | null) => void;
|
|
@@ -1292,6 +1333,13 @@ async function handleExecServerMessage(
|
|
|
1292
1333
|
case "mcpArgs": {
|
|
1293
1334
|
const args = execMsg.message.value;
|
|
1294
1335
|
const mcpCall = decodeMcpCall(args);
|
|
1336
|
+
if (execHandlers?.mcp) {
|
|
1337
|
+
if (state.currentToolCall?.id === mcpCall.toolCallId) {
|
|
1338
|
+
state.currentToolCall[kCursorExecResolved] = true;
|
|
1339
|
+
} else {
|
|
1340
|
+
state.resolvedMcpToolCallIds.add(mcpCall.toolCallId);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1295
1343
|
const { execResult } = await resolveExecHandler(
|
|
1296
1344
|
mcpCall,
|
|
1297
1345
|
execHandlers?.mcp?.bind(execHandlers),
|
|
@@ -2217,15 +2265,19 @@ export function processInteractionUpdate(
|
|
|
2217
2265
|
const mcpCall = toolCall.mcpToolCall;
|
|
2218
2266
|
if (mcpCall) {
|
|
2219
2267
|
const args = mcpCall.args || {};
|
|
2268
|
+
const id = args.toolCallId || crypto.randomUUID();
|
|
2220
2269
|
const block: ToolCallState = {
|
|
2221
2270
|
type: "toolCall",
|
|
2222
|
-
id
|
|
2271
|
+
id,
|
|
2223
2272
|
name: args.name || args.toolName || "",
|
|
2224
2273
|
arguments: {},
|
|
2225
2274
|
[kStreamingBlockIndex]: output.content.length,
|
|
2226
2275
|
[kStreamingPartialJson]: "",
|
|
2227
2276
|
[kStreamingBlockKind]: "mcp",
|
|
2228
2277
|
};
|
|
2278
|
+
if (state.resolvedMcpToolCallIds.delete(id)) {
|
|
2279
|
+
block[kCursorExecResolved] = true;
|
|
2280
|
+
}
|
|
2229
2281
|
output.content.push(block);
|
|
2230
2282
|
state.setToolCall(block);
|
|
2231
2283
|
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
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* - OpenAI: https://api.kimi.com/coding/v1/chat/completions
|
|
6
6
|
* - Anthropic: https://api.kimi.com/coding/v1/messages
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* Each discovered model selects its server-declared protocol; legacy models
|
|
9
|
+
* without protocol metadata retain the Anthropic-compatible default.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { getKimiCommonHeaders } from "../registry/oauth/kimi";
|
|
@@ -20,11 +20,8 @@ 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
|
-
/** API format
|
|
24
|
+
/** Explicit API format override. Defaults to the model's discovered protocol. */
|
|
28
25
|
format?: KimiApiFormat;
|
|
29
26
|
}
|
|
30
27
|
|
|
@@ -38,8 +35,9 @@ export function streamKimi(
|
|
|
38
35
|
options?: KimiOptions,
|
|
39
36
|
): AssistantMessageEventStream {
|
|
40
37
|
return streamOpenAIAnthropicShim(model, context, options, {
|
|
41
|
-
anthropicBaseUrl:
|
|
42
|
-
defaultFormat: "anthropic",
|
|
38
|
+
anthropicBaseUrl: model.baseUrl.replace(/\/v1\/?$/, ""),
|
|
39
|
+
defaultFormat: model.compat.kimiApiFormat ?? "anthropic",
|
|
40
|
+
anthropicThinkingMode: model.compat.thinkingFormat === "kimi" ? "anthropic-adaptive" : undefined,
|
|
43
41
|
extraHeaders: getKimiCommonHeaders,
|
|
44
42
|
});
|
|
45
43
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { buildModel } from "@oh-my-pi/pi-catalog/build";
|
|
12
12
|
import { ANTHROPIC_THINKING, mapAnthropicToolChoice } from "../stream";
|
|
13
|
-
import type { Context, Model, ModelSpec, SimpleStreamOptions } from "../types";
|
|
13
|
+
import type { Context, Model, ModelSpec, SimpleStreamOptions, ThinkingControlMode } from "../types";
|
|
14
14
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
15
15
|
import { createProviderErrorMessage } from "./error-message";
|
|
16
16
|
import { streamAnthropic, streamOpenAICompletions } from "./register-builtins";
|
|
@@ -29,6 +29,8 @@ export interface OpenAIAnthropicShimConfig {
|
|
|
29
29
|
openaiBaseUrl?: string;
|
|
30
30
|
/** Default API format when caller does not specify one. */
|
|
31
31
|
defaultFormat: OpenAIAnthropicApiFormat;
|
|
32
|
+
/** Thinking transport used when this provider's Anthropic endpoint differs from generic budget semantics. */
|
|
33
|
+
anthropicThinkingMode?: ThinkingControlMode;
|
|
32
34
|
/** Provider-specific headers (e.g. auth/session) merged ahead of user-supplied headers. */
|
|
33
35
|
extraHeaders?: () => Record<string, string>;
|
|
34
36
|
}
|
|
@@ -67,6 +69,9 @@ export function streamOpenAIAnthropicShim(
|
|
|
67
69
|
contextWindow: model.contextWindow,
|
|
68
70
|
maxTokens: model.maxTokens,
|
|
69
71
|
reasoning: model.reasoning,
|
|
72
|
+
...(config.anthropicThinkingMode && model.thinking
|
|
73
|
+
? { thinking: { ...model.thinking, mode: config.anthropicThinkingMode } }
|
|
74
|
+
: {}),
|
|
70
75
|
input: model.input,
|
|
71
76
|
cost: model.cost,
|
|
72
77
|
} as ModelSpec<"anthropic-messages">);
|
|
@@ -95,6 +100,7 @@ export function streamOpenAIAnthropicShim(
|
|
|
95
100
|
fetch: options?.fetch,
|
|
96
101
|
thinkingEnabled,
|
|
97
102
|
thinkingBudgetTokens: thinkingBudget,
|
|
103
|
+
reasoning: config.anthropicThinkingMode ? reasoningEffort : undefined,
|
|
98
104
|
toolChoice: mapAnthropicToolChoice(options?.toolChoice),
|
|
99
105
|
serviceTier: options?.serviceTier,
|
|
100
106
|
});
|
|
@@ -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") {
|