@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/types/auth-broker/wire-schemas.d.ts +13 -0
  3. package/dist/types/auth-storage.d.ts +2 -0
  4. package/dist/types/error/auth-classify.d.ts +2 -0
  5. package/dist/types/error/rate-limit.d.ts +9 -5
  6. package/dist/types/providers/cursor.d.ts +2 -0
  7. package/dist/types/providers/openai-codex/request-transformer.d.ts +9 -3
  8. package/dist/types/providers/openai-shared.d.ts +12 -6
  9. package/dist/types/providers/transform-messages.d.ts +5 -9
  10. package/dist/types/types.d.ts +6 -1
  11. package/dist/types/utils/empty-completion-retry.d.ts +3 -3
  12. package/dist/types/utils/schema/normalize.d.ts +3 -1
  13. package/package.json +4 -4
  14. package/src/auth-broker/wire-schemas.ts +1 -0
  15. package/src/auth-retry.ts +2 -2
  16. package/src/auth-storage.ts +120 -79
  17. package/src/dialect/owned-stream.ts +11 -0
  18. package/src/dialect/thinking.ts +186 -16
  19. package/src/error/auth-classify.ts +13 -0
  20. package/src/error/flags.ts +2 -2
  21. package/src/error/rate-limit.ts +28 -9
  22. package/src/providers/__tests__/kimi-code-thinking.test.ts +62 -7
  23. package/src/providers/anthropic.ts +82 -25
  24. package/src/providers/cursor.ts +53 -30
  25. package/src/providers/gitlab-duo-workflow.ts +6 -2
  26. package/src/providers/kimi.ts +1 -4
  27. package/src/providers/openai-codex/request-transformer.ts +12 -3
  28. package/src/providers/openai-codex-responses.ts +22 -4
  29. package/src/providers/openai-completions.ts +28 -22
  30. package/src/providers/openai-responses.ts +5 -1
  31. package/src/providers/openai-shared.ts +66 -14
  32. package/src/providers/transform-messages.ts +171 -1
  33. package/src/stream.ts +2 -0
  34. package/src/types.ts +9 -1
  35. package/src/utils/empty-completion-retry.ts +6 -3
  36. package/src/utils/leaked-thinking-stream.ts +19 -1
  37. package/src/utils/proxy.ts +1 -1
  38. package/src/utils/schema/CONSTRAINTS.md +1 -0
  39. package/src/utils/schema/fields.ts +3 -0
  40. package/src/utils/schema/normalize.ts +94 -24
  41. package/src/utils.ts +4 -1
@@ -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 429 body is opaque when it carries no signal beyond the status itself —
148
- * empty, whitespace-only, the status digits with HTTP/JSON framing, or
149
- * generic punctuation. Anything else (retry hints, capacity wording, error
150
- * descriptions) is informative enough to defer to the classifier.
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(/\b429\b/g, "")
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 USAGE_LIMIT_PATTERN.test(errorMessage) || ACCOUNT_RATE_LIMIT_PATTERN.test(errorMessage);
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("omits disabled thinking for title-generator-style Kimi Code requests", () => {
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
- expect("thinking" in params).toBe(false);
43
- expect(model.compat.supportsForcedToolChoice).toBe(false);
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("enables thinking and downgrades forced tool choice on Kimi Code's Anthropic endpoint", async () => {
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
- expect(payload?.thinking?.type).toBe("enabled");
71
- expect(payload?.tool_choice).toEqual({ type: "auto" });
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 (options?.fallbacks?.length) {
1861
+ if (fallbacks?.length) {
1820
1862
  if (!extraBetas.includes(serverSideFallbackBeta)) {
1821
1863
  extraBetas.push(serverSideFallbackBeta);
1822
1864
  }
1823
- for (const entry of options.fallbacks) {
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
- // the server-side-fallback beta chain. Leaving `options.fallbacks`
1892
- // unset preserves the pre-fallback stream shape on every event.
1893
- const serverSideFallback = !!options?.fallbacks?.length;
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 (and the catalog already forces `supportsEagerToolInputStreaming
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?: AnthropicOptions,
3137
- disableStrictTools = false,
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
- model.compat.supportsEagerToolInputStreaming,
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: !!options?.fallbacks?.length,
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
- ...(options?.fallbacks?.length ? { fallbacks: options.fallbacks } : {}),
3341
+ ...(fallbacks?.length ? { fallbacks } : {}),
3285
3342
  stream: true,
3286
3343
  };
3287
3344
 
@@ -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 resolveH2: (() => void) | undefined = h2Completion.resolve;
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", h2Completion.reject);
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
- // Resolve only on explicit turnEnded. stopReason defaults to "stop"
509
- // and is not a reliable signal for stream completion.
510
- if (isTurnEnded && resolveH2) {
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
- void closeDebugLog().finally(() => {
542
- h2Completion.reject(
543
- new AIError.ProviderResponseError(
544
- `gRPC error ${status}: ${decodeURIComponent(String(msg || ""))}`,
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
- if (endStreamError) {
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(() => h2Completion.reject(error));
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
- h2Completion.reject(new AIError.AbortError());
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: args.toolCallId || crypto.randomUUID(),
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|>";
@@ -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: KIMI_ANTHROPIC_BASE_URL,
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`. Shared by
282
- * normal turns and both remote-compaction paths — codex-rs routes
283
- * `/responses/compact` through the same builder.
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
  }