@oh-my-pi/pi-ai 17.0.0 → 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 +28 -0
- package/dist/types/auth-broker/wire-schemas.d.ts +13 -0
- package/dist/types/auth-storage.d.ts +2 -0
- package/dist/types/error/auth-classify.d.ts +2 -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 +12 -6
- package/dist/types/providers/transform-messages.d.ts +5 -9
- package/dist/types/types.d.ts +6 -1
- package/dist/types/utils/empty-completion-retry.d.ts +3 -3
- package/dist/types/utils/schema/normalize.d.ts +3 -1
- package/package.json +4 -4
- package/src/auth-broker/wire-schemas.ts +1 -0
- package/src/auth-retry.ts +2 -2
- package/src/auth-storage.ts +120 -79
- package/src/dialect/owned-stream.ts +11 -0
- package/src/dialect/thinking.ts +186 -16
- package/src/error/auth-classify.ts +13 -0
- package/src/error/flags.ts +2 -2
- package/src/error/rate-limit.ts +28 -9
- package/src/providers/__tests__/kimi-code-thinking.test.ts +62 -7
- package/src/providers/anthropic.ts +82 -25
- 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 +22 -4
- package/src/providers/openai-completions.ts +28 -22
- package/src/providers/openai-responses.ts +5 -1
- package/src/providers/openai-shared.ts +66 -14
- package/src/providers/transform-messages.ts +171 -1
- package/src/stream.ts +2 -0
- package/src/types.ts +9 -1
- package/src/utils/empty-completion-retry.ts +6 -3
- package/src/utils/leaked-thinking-stream.ts +19 -1
- package/src/utils/proxy.ts +1 -1
- package/src/utils/schema/CONSTRAINTS.md +1 -0
- package/src/utils/schema/fields.ts +3 -0
- package/src/utils/schema/normalize.ts +94 -24
- package/src/utils.ts +4 -1
package/src/error/rate-limit.ts
CHANGED
|
@@ -19,6 +19,8 @@ const SERVER_ERROR_BACKOFF_MS = 20 * 1000; // 20s
|
|
|
19
19
|
const ACCOUNT_RATE_LIMIT_PATTERN =
|
|
20
20
|
/\baccount(?:'s)?\b[^\n]{0,80}\brate.?limit\b|\brate.?limit\b[^\n]{0,80}\baccount\b/i;
|
|
21
21
|
const INSUFFICIENT_BALANCE_PATTERN = /insufficient.?balance/i;
|
|
22
|
+
const SPEND_LIMIT_PATTERN = /spend.?limit/i;
|
|
23
|
+
const OPENROUTER_DAILY_FREE_LIMIT_PATTERN = /\bfree[-_ ]models[-_ ]per[-_ ]day\b/i;
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* Classify a rate-limit error message into a reason category.
|
|
@@ -54,6 +56,14 @@ export function parseRateLimitReason(errorMessage: string): RateLimitReason {
|
|
|
54
56
|
return "QUOTA_EXHAUSTED";
|
|
55
57
|
}
|
|
56
58
|
|
|
59
|
+
if (SPEND_LIMIT_PATTERN.test(errorMessage)) {
|
|
60
|
+
return "QUOTA_EXHAUSTED";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (OPENROUTER_DAILY_FREE_LIMIT_PATTERN.test(errorMessage)) {
|
|
64
|
+
return "QUOTA_EXHAUSTED";
|
|
65
|
+
}
|
|
66
|
+
|
|
57
67
|
if (
|
|
58
68
|
lower.includes("per minute") ||
|
|
59
69
|
lower.includes("rate limit") ||
|
|
@@ -106,16 +116,19 @@ export function calculateRateLimitBackoffMs(reason: RateLimitReason): number {
|
|
|
106
116
|
|
|
107
117
|
/** Detect usage/quota limit errors in error messages (persistent, requires credential switch). */
|
|
108
118
|
const USAGE_LIMIT_PATTERN =
|
|
109
|
-
/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;
|
|
110
120
|
|
|
111
121
|
/**
|
|
112
122
|
* HTTP status codes that, absent richer body classification, represent an
|
|
113
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.
|
|
114
127
|
* Always combine with {@link isUsageLimitOutcome} when a message is available
|
|
115
128
|
* — a 429 carrying transient rate-limit wording is NOT a usage cap.
|
|
116
129
|
*/
|
|
117
130
|
export function isUsageLimitStatus(status: number | undefined): boolean {
|
|
118
|
-
return status === 429;
|
|
131
|
+
return status === 429 || status === 402;
|
|
119
132
|
}
|
|
120
133
|
|
|
121
134
|
/**
|
|
@@ -125,7 +138,7 @@ export function isUsageLimitStatus(status: number | undefined): boolean {
|
|
|
125
138
|
* 1. Body matches {@link isUsageLimitError} (Codex `usage_limit_reached`,
|
|
126
139
|
* Anthropic account rate-limit, Google `resource_exhausted`, OpenAI
|
|
127
140
|
* `insufficient_quota`, …) → rotate.
|
|
128
|
-
* 2. Status is not 429 → backoff (caller's domain).
|
|
141
|
+
* 2. Status is not a usage-limit status (429/402) → backoff (caller's domain).
|
|
129
142
|
* 3. Body is absent or {@link isOpaqueStatusBody opaque} (just the status,
|
|
130
143
|
* empty JSON, HTTP framing only) → rotate conservatively: the server
|
|
131
144
|
* gave us nothing else to go on.
|
|
@@ -144,14 +157,15 @@ export function isUsageLimitOutcome(status: number | undefined, message: string
|
|
|
144
157
|
}
|
|
145
158
|
|
|
146
159
|
/**
|
|
147
|
-
* A
|
|
148
|
-
* empty, whitespace-only, the status digits with HTTP/JSON
|
|
149
|
-
* generic punctuation. Anything else (retry hints, capacity
|
|
150
|
-
* 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.
|
|
151
165
|
*/
|
|
152
166
|
export function isOpaqueStatusBody(message: string): boolean {
|
|
153
167
|
const cleaned = message
|
|
154
|
-
.replace(/\
|
|
168
|
+
.replace(/\b(?:429|402)\b/g, "")
|
|
155
169
|
.replace(/\b(?:http|https|status|error|code|response|message)\b/gi, "");
|
|
156
170
|
return !/[a-z\d]{3,}/i.test(cleaned);
|
|
157
171
|
}
|
|
@@ -163,5 +177,10 @@ export function isOpaqueStatusBody(message: string): boolean {
|
|
|
163
177
|
* {@link isUsageLimitOutcome} uses it for the account-rotation decision.
|
|
164
178
|
*/
|
|
165
179
|
export function matchesUsageLimitText(errorMessage: string): boolean {
|
|
166
|
-
return
|
|
180
|
+
return (
|
|
181
|
+
USAGE_LIMIT_PATTERN.test(errorMessage) ||
|
|
182
|
+
SPEND_LIMIT_PATTERN.test(errorMessage) ||
|
|
183
|
+
ACCOUNT_RATE_LIMIT_PATTERN.test(errorMessage) ||
|
|
184
|
+
OPENROUTER_DAILY_FREE_LIMIT_PATTERN.test(errorMessage)
|
|
185
|
+
);
|
|
167
186
|
}
|
|
@@ -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", () => {
|
|
@@ -1167,6 +1167,25 @@ function resolveAnthropicBaseUrl(model: Model<"anthropic-messages">, apiKey?: st
|
|
|
1167
1167
|
return normalizeAnthropicBaseUrl(model.baseUrl);
|
|
1168
1168
|
}
|
|
1169
1169
|
|
|
1170
|
+
function resolveEagerToolInputStreamingSupport(
|
|
1171
|
+
model: Model<"anthropic-messages">,
|
|
1172
|
+
effectiveBaseUrl: string | undefined,
|
|
1173
|
+
): boolean {
|
|
1174
|
+
if (!model.compat.supportsEagerToolInputStreaming) return false;
|
|
1175
|
+
// First-party Anthropic endpoints accept the per-tool flag.
|
|
1176
|
+
if (isOfficialAnthropicApiUrl(effectiveBaseUrl)) return true;
|
|
1177
|
+
// Non-official effective endpoint. `supportsEagerToolInputStreaming` may be
|
|
1178
|
+
// stale-true here because compat is materialized once at build time and is
|
|
1179
|
+
// never rebuilt for a baseUrl-only reroute — either a runtime provider
|
|
1180
|
+
// override (`pi.registerProvider("anthropic", { baseUrl })`) or Foundry
|
|
1181
|
+
// (`CLAUDE_CODE_USE_FOUNDRY`). Both leave the canonical model's resolved
|
|
1182
|
+
// compat in place. `officialEndpoint` records whether compat was built for
|
|
1183
|
+
// the canonical Anthropic URL, so only endpoints whose compat was authored
|
|
1184
|
+
// for a non-official host (an explicit `compat.supportsEagerToolInputStreaming`
|
|
1185
|
+
// opt-in on a custom `baseUrl`) still send the field.
|
|
1186
|
+
return !model.compat.officialEndpoint;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1170
1189
|
function parseAnthropicCustomHeaders(rawHeaders: string | undefined): Record<string, string> | undefined {
|
|
1171
1190
|
const source = rawHeaders?.trim();
|
|
1172
1191
|
if (!source) return undefined;
|
|
@@ -1741,6 +1760,7 @@ const streamAnthropicOnce = (
|
|
|
1741
1760
|
}
|
|
1742
1761
|
const apiKey = options?.apiKey ?? getEnvApiKey(model.provider) ?? "";
|
|
1743
1762
|
const baseUrl = resolveAnthropicBaseUrl(model, apiKey) ?? "https://api.anthropic.com";
|
|
1763
|
+
const supportsEagerToolInputStreaming = resolveEagerToolInputStreamingSupport(model, baseUrl);
|
|
1744
1764
|
const providerSessionState = getAnthropicProviderSessionState(
|
|
1745
1765
|
options?.providerSessionState,
|
|
1746
1766
|
baseUrl,
|
|
@@ -1752,6 +1772,23 @@ const streamAnthropicOnce = (
|
|
|
1752
1772
|
let forceDemoteUnsignedThinking = providerSessionState?.replayUnsignedThinkingDisabled ?? false;
|
|
1753
1773
|
const mergedCallerHeaders = mergeHeaders(model.headers, options?.headers);
|
|
1754
1774
|
const umansGatewayWebSearchHeader = getUmansWebSearchHeader(model, mergedCallerHeaders);
|
|
1775
|
+
// Keep fallback payloads aligned with the top-level Vertex effort gate:
|
|
1776
|
+
// no nested effort field means the fallback scan cannot re-add its beta.
|
|
1777
|
+
let fallbacks = options?.fallbacks;
|
|
1778
|
+
if (
|
|
1779
|
+
model.provider === "google-vertex" &&
|
|
1780
|
+
fallbacks?.some(entry => entry.output_config?.effort !== undefined)
|
|
1781
|
+
) {
|
|
1782
|
+
fallbacks = fallbacks.map(entry => {
|
|
1783
|
+
const outputConfig = entry.output_config;
|
|
1784
|
+
if (outputConfig?.effort === undefined) return entry;
|
|
1785
|
+
return {
|
|
1786
|
+
...entry,
|
|
1787
|
+
output_config:
|
|
1788
|
+
outputConfig.task_budget === undefined ? undefined : { task_budget: outputConfig.task_budget },
|
|
1789
|
+
};
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1755
1792
|
|
|
1756
1793
|
let client: AnthropicMessagesClientLike;
|
|
1757
1794
|
let isOAuthToken: boolean;
|
|
@@ -1776,6 +1813,10 @@ const streamAnthropicOnce = (
|
|
|
1776
1813
|
// the toggle cannot 400); the beta must accompany the field in both.
|
|
1777
1814
|
// MiniMax uses `thinking.type:"adaptive"` itself as the control surface,
|
|
1778
1815
|
// so the sentinel "adaptive" value intentionally sends no output_config.
|
|
1816
|
+
// Skip Vertex rawPredict: that adapter needs betas in the body
|
|
1817
|
+
// (`anthropic_beta`), not as an `anthropic-beta` HTTP header, so the
|
|
1818
|
+
// effort field is dropped from the body there too (see buildParams) and
|
|
1819
|
+
// advertising the beta would only earn a 400 (#5614).
|
|
1779
1820
|
const sendsAdaptiveEffortPin =
|
|
1780
1821
|
options?.thinkingEnabled === false &&
|
|
1781
1822
|
model.thinking?.mode === "anthropic-adaptive" &&
|
|
@@ -1783,6 +1824,7 @@ const streamAnthropicOnce = (
|
|
|
1783
1824
|
!usesAdaptiveThinkingTagOnly(model);
|
|
1784
1825
|
if (
|
|
1785
1826
|
model.reasoning &&
|
|
1827
|
+
model.provider !== "google-vertex" &&
|
|
1786
1828
|
((options?.thinkingEnabled && options.effort !== "adaptive") || sendsAdaptiveEffortPin) &&
|
|
1787
1829
|
!extraBetas.includes(effortBeta)
|
|
1788
1830
|
) {
|
|
@@ -1816,11 +1858,11 @@ const streamAnthropicOnce = (
|
|
|
1816
1858
|
// `output_config.task_budget`) reuse the same top-level betas
|
|
1817
1859
|
// Anthropic requires for the primary request, so scan the chain
|
|
1818
1860
|
// and add every companion beta the fallback entries touch.
|
|
1819
|
-
if (
|
|
1861
|
+
if (fallbacks?.length) {
|
|
1820
1862
|
if (!extraBetas.includes(serverSideFallbackBeta)) {
|
|
1821
1863
|
extraBetas.push(serverSideFallbackBeta);
|
|
1822
1864
|
}
|
|
1823
|
-
for (const entry of
|
|
1865
|
+
for (const entry of fallbacks) {
|
|
1824
1866
|
if (entry.speed === "fast" && !extraBetas.includes(fastModeBeta)) {
|
|
1825
1867
|
extraBetas.push(fastModeBeta);
|
|
1826
1868
|
}
|
|
@@ -1854,15 +1896,13 @@ const streamAnthropicOnce = (
|
|
|
1854
1896
|
}
|
|
1855
1897
|
const preparedContext = await prepareAnthropicManyImageContext(context, model.input.includes("image"));
|
|
1856
1898
|
const prepareParams = async (): Promise<MessageCreateParamsStreaming> => {
|
|
1857
|
-
let nextParams = buildParams(
|
|
1858
|
-
model,
|
|
1859
|
-
preparedContext,
|
|
1860
|
-
isOAuthToken,
|
|
1861
|
-
options,
|
|
1899
|
+
let nextParams = buildParams(model, preparedContext, isOAuthToken, options, {
|
|
1862
1900
|
disableStrictTools,
|
|
1863
|
-
umansGatewayWebSearchHeader !== undefined,
|
|
1901
|
+
useUmansGatewayWebSearch: umansGatewayWebSearchHeader !== undefined,
|
|
1864
1902
|
forceDemoteUnsignedThinking,
|
|
1865
|
-
|
|
1903
|
+
supportsEagerToolInputStreaming,
|
|
1904
|
+
fallbacks,
|
|
1905
|
+
});
|
|
1866
1906
|
if (disableStrictTools) {
|
|
1867
1907
|
dropAnthropicStrictTools(nextParams);
|
|
1868
1908
|
}
|
|
@@ -1888,9 +1928,9 @@ const streamAnthropicOnce = (
|
|
|
1888
1928
|
|
|
1889
1929
|
// Opt-in flag: the response parser only honors `fallback` content
|
|
1890
1930
|
// blocks and `usage.iterations` when the current request opted into
|
|
1891
|
-
//
|
|
1892
|
-
//
|
|
1893
|
-
const serverSideFallback = !!
|
|
1931
|
+
// server-side-fallback beta chain. Leaving `fallbacks` unset preserves
|
|
1932
|
+
// the pre-fallback stream shape on every event.
|
|
1933
|
+
const serverSideFallback = !!fallbacks?.length;
|
|
1894
1934
|
type Block = (
|
|
1895
1935
|
| ThinkingContent
|
|
1896
1936
|
| RedactedThinkingContent
|
|
@@ -2682,9 +2722,11 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
|
|
2682
2722
|
const compat = model.compat;
|
|
2683
2723
|
const disableStrictTools = disableStrictToolsOverride ?? compat.disableStrictTools;
|
|
2684
2724
|
const needsInterleavedBeta = interleavedThinking && !model.thinking?.supportsDisplay;
|
|
2685
|
-
const needsFineGrainedToolStreamingBeta = hasTools && !compat.supportsEagerToolInputStreaming;
|
|
2686
2725
|
const oauthToken = isOAuth ?? isAnthropicOAuthToken(apiKey);
|
|
2687
2726
|
const baseUrl = resolveAnthropicBaseUrl(model, apiKey);
|
|
2727
|
+
const supportsEagerToolInputStreaming = resolveEagerToolInputStreamingSupport(model, baseUrl);
|
|
2728
|
+
const needsFineGrainedToolStreamingBeta =
|
|
2729
|
+
hasTools && isOfficialAnthropicApiUrl(baseUrl) && !supportsEagerToolInputStreaming;
|
|
2688
2730
|
const foundryCustomHeaders = resolveAnthropicCustomHeaders(model);
|
|
2689
2731
|
const tlsFetchOptions = buildClaudeCodeTlsFetchOptions(model, baseUrl);
|
|
2690
2732
|
// Disable Bun's native ~300s pre-response fetch timeout (issue #2422).
|
|
@@ -2699,9 +2741,7 @@ export function buildAnthropicClientOptions(args: AnthropicClientOptionsArgs): A
|
|
|
2699
2741
|
if (model.provider === "github-copilot") {
|
|
2700
2742
|
const copilotApiKey = parseGitHubCopilotApiKey(apiKey).accessToken;
|
|
2701
2743
|
// The GitHub Copilot Anthropic proxy doesn't accept Anthropic beta
|
|
2702
|
-
// features
|
|
2703
|
-
// = false` for this host, so `needsFineGrainedToolStreamingBeta` is true
|
|
2704
|
-
// whenever tools are present). Forward only caller-supplied betas.
|
|
2744
|
+
// features. Forward only caller-supplied betas.
|
|
2705
2745
|
const betaFeatures = [...extraBetas];
|
|
2706
2746
|
const defaultHeaders = mergeHeaders(
|
|
2707
2747
|
{
|
|
@@ -3129,15 +3169,29 @@ function extractClaudeCodeFirstUserMessageText(messages: readonly Message[]): st
|
|
|
3129
3169
|
return "";
|
|
3130
3170
|
}
|
|
3131
3171
|
|
|
3172
|
+
type AnthropicParamBuildOptions = {
|
|
3173
|
+
disableStrictTools: boolean;
|
|
3174
|
+
useUmansGatewayWebSearch: boolean;
|
|
3175
|
+
forceDemoteUnsignedThinking: boolean;
|
|
3176
|
+
supportsEagerToolInputStreaming: boolean;
|
|
3177
|
+
/** Sanitized server-side fallback entries; defaults to `options?.fallbacks` when omitted. */
|
|
3178
|
+
fallbacks?: AnthropicOptions["fallbacks"];
|
|
3179
|
+
};
|
|
3180
|
+
|
|
3132
3181
|
function buildParams(
|
|
3133
3182
|
model: Model<"anthropic-messages">,
|
|
3134
3183
|
context: Context,
|
|
3135
3184
|
isOAuthToken: boolean,
|
|
3136
|
-
options
|
|
3137
|
-
|
|
3138
|
-
useUmansGatewayWebSearch = false,
|
|
3139
|
-
forceDemoteUnsignedThinking = false,
|
|
3185
|
+
options: AnthropicOptions | undefined,
|
|
3186
|
+
buildOptions: AnthropicParamBuildOptions,
|
|
3140
3187
|
): MessageCreateParamsStreaming {
|
|
3188
|
+
const {
|
|
3189
|
+
disableStrictTools,
|
|
3190
|
+
useUmansGatewayWebSearch,
|
|
3191
|
+
forceDemoteUnsignedThinking,
|
|
3192
|
+
supportsEagerToolInputStreaming,
|
|
3193
|
+
fallbacks = options?.fallbacks,
|
|
3194
|
+
} = buildOptions;
|
|
3141
3195
|
// A session-scoped auto-demote (learned from a live signing 400) clones the
|
|
3142
3196
|
// resolved compat with `replayUnsignedThinking: false` so every subsequent
|
|
3143
3197
|
// downstream read (convertAnthropicMessages, transformMessages) sees the
|
|
@@ -3165,7 +3219,7 @@ function buildParams(
|
|
|
3165
3219
|
context.tools,
|
|
3166
3220
|
isOAuthToken,
|
|
3167
3221
|
disableStrictTools || model.provider === "github-copilot",
|
|
3168
|
-
|
|
3222
|
+
supportsEagerToolInputStreaming,
|
|
3169
3223
|
model.compat.escapeBuiltinToolNames,
|
|
3170
3224
|
useUmansGatewayWebSearch,
|
|
3171
3225
|
);
|
|
@@ -3255,9 +3309,12 @@ function buildParams(
|
|
|
3255
3309
|
? { edits: [{ type: "clear_thinking_20251015" as const, keep: "all" as const }] }
|
|
3256
3310
|
: undefined;
|
|
3257
3311
|
|
|
3258
|
-
// Pre-compute output_config.
|
|
3312
|
+
// Pre-compute output_config. Skip `effort` on Vertex rawPredict: it requires
|
|
3313
|
+
// the `effort-2025-11-24` beta, which that adapter can only accept in the body
|
|
3314
|
+
// (`anthropic_beta`), never as the `anthropic-beta` HTTP header this path sets
|
|
3315
|
+
// — so the field is dropped alongside the beta to avoid a 400 (#5614).
|
|
3259
3316
|
const outputConfigEntries: AnthropicOutputConfig = {};
|
|
3260
|
-
if (outputConfigEffort) outputConfigEntries.effort = outputConfigEffort;
|
|
3317
|
+
if (outputConfigEffort && model.provider !== "google-vertex") outputConfigEntries.effort = outputConfigEffort;
|
|
3261
3318
|
if (options?.taskBudget) outputConfigEntries.task_budget = options.taskBudget;
|
|
3262
3319
|
const outputConfig = Object.keys(outputConfigEntries).length ? outputConfigEntries : undefined;
|
|
3263
3320
|
|
|
@@ -3272,7 +3329,7 @@ function buildParams(
|
|
|
3272
3329
|
const params: MessageCreateParamsStreaming = {
|
|
3273
3330
|
model: options?.requestModelId ?? model.requestModelId ?? model.id,
|
|
3274
3331
|
messages: convertAnthropicMessages(context.messages, effectiveModel, isOAuthToken, {
|
|
3275
|
-
serverSideFallbackEnabled: !!
|
|
3332
|
+
serverSideFallbackEnabled: !!fallbacks?.length,
|
|
3276
3333
|
}),
|
|
3277
3334
|
...(systemBlocks && { system: systemBlocks }),
|
|
3278
3335
|
...(tools !== undefined && { tools }),
|
|
@@ -3281,7 +3338,7 @@ function buildParams(
|
|
|
3281
3338
|
...(thinking && { thinking }),
|
|
3282
3339
|
...(contextManagement && { context_management: contextManagement }),
|
|
3283
3340
|
...(outputConfig && { output_config: outputConfig }),
|
|
3284
|
-
...(
|
|
3341
|
+
...(fallbacks?.length ? { fallbacks } : {}),
|
|
3285
3342
|
stream: true,
|
|
3286
3343
|
};
|
|
3287
3344
|
|
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
|
}
|