@oh-my-pi/pi-ai 17.0.7 → 17.0.9

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 CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.0.9] - 2026-07-23
6
+
7
+ ### Added
8
+
9
+ - Added Synthetic (synthetic.new) usage provider: `/usage` now reports the rolling 5-hour request limit and weekly credit quota via `GET /v2/quotas`, including per-tick regeneration rates in the window labels.
10
+ - Added optional `UsageWindow.resetLabel` so rolling windows can render their countdown with an accurate verb (e.g. "tick in 12m" / "regen in 51m" instead of "resets in") — both quota windows on Synthetic regenerate incrementally rather than hard-resetting.
11
+
12
+ ### Fixed
13
+
14
+ - Fixed GitHub Copilot OpenAI-compatible requests being rejected when the session's native OpenAI service tier was set to `priority` ([#5160](https://github.com/can1357/oh-my-pi/pull/5160) by [@audreyt](https://github.com/audreyt)).
15
+ - Fixed OpenAI Responses token-cap truncations suppressing fully streamed function and custom tool calls whose inputs are complete.
16
+ - Added SuperGrok (`xai-oauth`) usage tracking for weekly credits, product limits, and positive on-demand caps.
17
+
18
+ ## [17.0.8] - 2026-07-22
19
+
20
+ ### Fixed
21
+
22
+ - Fixed Gemini Flash Cloud Code Assist empty-response retries when responses contain only intercepted planning-leak JSON.
23
+ - Fixed Antigravity auto-routing to correctly fail over to the sandbox endpoint when the daily endpoint exhausts its retries.
24
+ - Fixed OpenAI-compatible providers configured with auth: none incorrectly sending an Authorization: Bearer N/A header, which broke custom endpoints using alternative authentication headers.
25
+ - Fixed auth-gateway model listings exposing duplicate or ambiguous model IDs by ensuring only provider-qualified routing IDs are advertised.
26
+ - Improved connection error handling by classifying generic connection failures as transient, allowing them to be retried, while keeping explicit authentication rejections non-retryable.
27
+ - Fixed custom Anthropic base URLs losing native thinking signatures during continuation requests.
28
+ - Fixed Alibaba Coding Plan Custom login rejecting valid API keys on endpoints that do not serve the default validation model by validating against the model catalog instead.
29
+
5
30
  ## [17.0.6] - 2026-07-20
6
31
 
7
32
  ### Fixed
@@ -73,7 +73,13 @@ export declare function classifyMessage(message: {
73
73
  export declare function attach<E extends object>(error: E, id: number): E;
74
74
  export declare function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean;
75
75
  export declare function stringify(id: number | undefined): string;
76
- /** Transient stream corruption where the response was truncated mid-JSON. */
76
+ /**
77
+ * Transient stream corruption where the response was truncated mid-JSON.
78
+ *
79
+ * Strings (persisted `stopDetails.explanation`/`errorMessage` diagnostics) are matched with the
80
+ * stricter {@link STREAM_PARSE_DIAGNOSTIC_PATTERN} — bare "truncated"/"end of file" text is too
81
+ * low-signal to trust once detached from a live transport `Error`, which keeps the broad pattern.
82
+ */
77
83
  export declare function isTransientStreamParseError(error: unknown): boolean;
78
84
  /** Any malformed stream-envelope error (prefix-tagged or out-of-order events). */
79
85
  export declare function isStreamEnvelopeError(error: unknown): boolean;
@@ -39,6 +39,8 @@ export * from "./usage/ollama.js";
39
39
  export * from "./usage/openai-codex.js";
40
40
  export * from "./usage/openai-codex-reset.js";
41
41
  export * from "./usage/opencode-go.js";
42
+ export * from "./usage/synthetic.js";
43
+ export * from "./usage/xai-oauth.js";
42
44
  export * from "./usage/zai.js";
43
45
  export * from "./utils/anthropic-auth.js";
44
46
  export * from "./utils/event-stream.js";
@@ -7,6 +7,15 @@ import type { CapturedHttpErrorResponse } from "../utils/http-inspector.js";
7
7
  import type { ChatCompletionCreateParamsStreaming } from "./openai-chat-wire.js";
8
8
  import type { InputItem } from "./openai-codex/request-transformer.js";
9
9
  import type { ResponseContentPartAddedEvent, ResponseCreateParamsStreaming, ResponseInput, ResponseInputContent, ResponseInputItem, ResponseOutputItem, ResponseOutputMessage, ResponseReasoningItem, ResponseStatus, ResponseStreamEvent } from "./openai-responses-wire.js";
10
+ /**
11
+ * Keyless-provider sentinel. Custom providers configured with `auth: none`
12
+ * (models.yml) have no credential, so the coding-agent resolves their API key
13
+ * to this literal instead of a real secret. Providers must treat it as "no
14
+ * credential" and suppress any credential-bearing header (e.g. `Authorization:
15
+ * Bearer …`) rather than forwarding the sentinel on the wire. See #6188; the
16
+ * google-vertex and amazon-bedrock transports apply the same guard inline.
17
+ */
18
+ export declare const NO_AUTH_SENTINEL = "N/A";
10
19
  export interface OpenAIModelIdentity {
11
20
  provider: string;
12
21
  id: string;
@@ -482,7 +491,7 @@ export declare function finalizePendingResponsesToolCalls(output: AssistantMessa
482
491
  * re-samples instead of ending. Callers set `output.stopReason` from the wire
483
492
  * status first via {@link mapOpenAIResponsesStopReason}.
484
493
  */
485
- export declare function promoteResponsesToolUseStopReason(output: AssistantMessage, endTurn: boolean | undefined): void;
494
+ export declare function promoteResponsesToolUseStopReason(output: AssistantMessage, endTurn: boolean | undefined, promoteIncompleteToolUse?: boolean): void;
486
495
  /** Initial empty `AssistantMessage` that streaming providers accumulate into. */
487
496
  export declare function createInitialResponsesAssistantMessage(api: Api, provider: string, modelId: string): AssistantMessage;
488
497
  /** Extension fields we add on top of `ResponseCreateParamsStreaming` across the Responses-family providers. */
@@ -1,16 +1,13 @@
1
1
  import type { FetchImpl } from "../../types.js";
2
2
  import type { OAuthController, OAuthCredentials } from "./types.js";
3
+ export declare function validateXAIEndpoint(url: string, field: string): string;
3
4
  /**
4
- * Validate an xAI OIDC endpoint against its scheme and host.
5
- *
6
- * The discovery response is long-lived and its token endpoint receives every
7
- * future refresh token. Rejecting non-HTTPS or non-`x.ai` / `*.x.ai` hosts
8
- * pins that endpoint to the xAI auth origin.
9
- *
10
- * @throws Error with message `Invalid xAI <field>: <url>` when the URL fails
11
- * either scheme or host validation.
5
+ * Pin SuperGrok billing URLs to HTTPS `grok.com` / `*.grok.com`.
6
+ * The CLI billing proxy is intentionally not on `*.x.ai`.
12
7
  */
13
- export declare function validateXAIEndpoint(url: string, field: string): string;
8
+ export declare function validateXAIBillingEndpoint(url: string, field?: string): string;
9
+ /** Decode an xAI access-token JWT payload without verifying its signature. */
10
+ export declare function parseXAIAccessTokenPayload(jwt: string): Record<string, unknown> | null;
14
11
  /**
15
12
  * Check whether a JWT access token is at or past its `exp` claim (with an
16
13
  * optional refresh-skew margin).
@@ -19,6 +16,25 @@ export declare function validateXAIEndpoint(url: string, field: string): string;
19
16
  * not token validation.
20
17
  */
21
18
  export declare function isXAIAccessTokenExpiring(jwt: string, skewSeconds?: number): boolean;
19
+ /** Extract the stable xAI subject UUID from an access token. */
20
+ export declare function extractXAIAccessTokenSubject(jwt: string): string | undefined;
21
+ export interface XAIOAuthIdentity {
22
+ accountId?: string;
23
+ email?: string;
24
+ name?: string;
25
+ }
26
+ /** Fetch optional OIDC userinfo for a valid xAI access token. */
27
+ export declare function fetchXAIOAuthIdentity(accessToken: string, fetchOverride?: FetchImpl, signal?: AbortSignal): Promise<XAIOAuthIdentity | null>;
28
+ /** Build the SuperGrok CLI billing URL. */
29
+ export declare function buildXAICliBillingUrl(format?: string): string;
30
+ /**
31
+ * Headers for SuperGrok CLI billing (`cli-chat-proxy.grok.com`).
32
+ * Official Grok CLI also sends `X-XAI-Token-Auth: xai-grok-cli` on this host;
33
+ * include it so billing stays on the same product gate as chat inference.
34
+ */
35
+ export declare function getXAICliBillingHeaders(options: {
36
+ accessToken: string;
37
+ }): Record<string, string>;
22
38
  /** Log in to xAI Grok with the RFC 8628 device authorization grant. */
23
39
  export declare function loginXAIOAuth(ctrl: OAuthController): Promise<OAuthCredentials>;
24
40
  /**
@@ -108,7 +108,8 @@ type ServiceTierModel = Pick<Model, "provider" | "api" | "id">;
108
108
  * `openai/`); Claude on Bedrock/Vertex (api `anthropic-messages`) is the
109
109
  * anthropic family even though its provider is `amazon-bedrock`/`google-vertex`.
110
110
  * Custom OpenAI-compatible relays that serve OpenAI model ids are OpenAI family
111
- * too unless that provider owns a separate tier control such as Fireworks.
111
+ * too unless the provider owns a separate tier control (Fireworks) or rejects
112
+ * OpenAI's service-tier field (GitHub Copilot).
112
113
  */
113
114
  export declare function serviceTierFamily(model: ServiceTierModel): ServiceTierFamily | undefined;
114
115
  /**
@@ -0,0 +1,2 @@
1
+ import type { UsageProvider } from "../usage.js";
2
+ export declare const syntheticUsageProvider: UsageProvider;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * SuperGrok (`xai-oauth`) subscription usage provider.
3
+ *
4
+ * Reads weekly credit and product utilization from the Grok CLI billing
5
+ * endpoint. Only OAuth access credentials are accepted; paid API keys are a
6
+ * separate product and must never be sent here.
7
+ */
8
+ import type { UsageProvider } from "../usage.js";
9
+ export declare const xaiOauthUsageProvider: UsageProvider;
@@ -11,6 +11,12 @@ export interface UsageWindow {
11
11
  durationMs?: number;
12
12
  /** Absolute reset timestamp in milliseconds since epoch. */
13
13
  resetsAt?: number;
14
+ /**
15
+ * Verb rendered before the {@link resetsAt} countdown (e.g. "tick", "regen").
16
+ * Defaults to "resets" — override for rolling windows where the timestamp is
17
+ * an incremental regeneration step rather than a full window reset.
18
+ */
19
+ resetLabel?: string;
14
20
  }
15
21
  /** Quantitative usage data. */
16
22
  export interface UsageAmount {
@@ -157,6 +163,7 @@ export declare const usageWindowSchema: import("arktype/internal/variants/object
157
163
  label: string;
158
164
  durationMs?: number | undefined;
159
165
  resetsAt?: number | undefined;
166
+ resetLabel?: string | undefined;
160
167
  }, {}>;
161
168
  export declare const usageAmountSchema: import("arktype/internal/variants/object.ts").ObjectType<{
162
169
  used?: number | undefined;
@@ -194,6 +201,7 @@ export declare const usageLimitSchema: import("arktype/internal/variants/object.
194
201
  label: string;
195
202
  durationMs?: number | undefined;
196
203
  resetsAt?: number | undefined;
204
+ resetLabel?: string | undefined;
197
205
  } | undefined;
198
206
  amount: {
199
207
  used?: number | undefined;
@@ -240,6 +248,7 @@ export declare const usageReportSchema: import("arktype/internal/variants/object
240
248
  label: string;
241
249
  durationMs?: number | undefined;
242
250
  resetsAt?: number | undefined;
251
+ resetLabel?: string | undefined;
243
252
  } | undefined;
244
253
  amount: {
245
254
  used?: number | undefined;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "17.0.7",
4
+ "version": "17.0.9",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,9 +38,9 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.1",
41
- "@oh-my-pi/pi-catalog": "17.0.7",
42
- "@oh-my-pi/pi-utils": "17.0.7",
43
- "@oh-my-pi/pi-wire": "17.0.7",
41
+ "@oh-my-pi/pi-catalog": "17.0.9",
42
+ "@oh-my-pi/pi-utils": "17.0.9",
43
+ "@oh-my-pi/pi-wire": "17.0.9",
44
44
  "arktype": "2.2.3",
45
45
  "zod": "^4"
46
46
  },
@@ -726,13 +726,19 @@ async function handleCredentialsCheck(storage: AuthStorage, signal: AbortSignal)
726
726
  }
727
727
 
728
728
  function handleModelsList(opts: AuthGatewayBootOptions): Response {
729
- const list = opts.listModels ? Array.from(opts.listModels()) : [];
730
- const data = list.map(model => ({
731
- id: model.id,
732
- object: "model" as const,
733
- owned_by: model.provider,
734
- api: model.api,
735
- }));
729
+ const seen = new Set<string>();
730
+ const data: Array<{ id: string; object: "model"; owned_by: string; api: Api }> = [];
731
+ for (const model of opts.listModels?.() ?? []) {
732
+ const id = `${model.provider}/${model.id}`;
733
+ if (seen.has(id)) continue;
734
+ seen.add(id);
735
+ data.push({
736
+ id,
737
+ object: "model",
738
+ owned_by: model.provider,
739
+ api: model.api,
740
+ });
741
+ }
736
742
  return json(200, { object: "list", data });
737
743
  }
738
744
 
@@ -11,7 +11,7 @@ import { Database, type Statement } from "bun:sqlite";
11
11
  import { createHash } from "node:crypto";
12
12
  import * as fs from "node:fs/promises";
13
13
  import * as path from "node:path";
14
- import { getAgentDbPath, logger } from "@oh-my-pi/pi-utils";
14
+ import { $env, getAgentDbPath, logger } from "@oh-my-pi/pi-utils";
15
15
  import type { ApiKeyResolver } from "./auth-retry";
16
16
  import * as AIError from "./error";
17
17
  import { isUsageLimitOutcome } from "./error/rate-limit";
@@ -57,6 +57,8 @@ import {
57
57
  listCodexResetCredits,
58
58
  } from "./usage/openai-codex-reset";
59
59
  import { opencodeGoUsageProvider } from "./usage/opencode-go";
60
+ import { syntheticUsageProvider } from "./usage/synthetic";
61
+ import { xaiOauthUsageProvider } from "./usage/xai-oauth";
60
62
  import { zaiRankingStrategy, zaiUsageProvider } from "./usage/zai";
61
63
 
62
64
  const USAGE_RANKING_METRIC_EPSILON = 1e-9;
@@ -596,6 +598,8 @@ const DEFAULT_USAGE_PROVIDERS: UsageProvider[] = [
596
598
  opencodeGoUsageProvider,
597
599
  githubCopilotUsageProvider,
598
600
  cursorUsageProvider,
601
+ syntheticUsageProvider,
602
+ xaiOauthUsageProvider,
599
603
  ];
600
604
 
601
605
  const DEFAULT_USAGE_PROVIDER_MAP = new Map<Provider, UsageProvider>(
@@ -3227,6 +3231,29 @@ export class AuthStorage {
3227
3231
  entries = dedupedEntries;
3228
3232
  }
3229
3233
 
3234
+ // SuperGrok billing only accepts OAuth bearers. Catalog envVars for
3235
+ // xai-oauth are [XAI_OAUTH_TOKEN, XAI_API_KEY], so the generic path
3236
+ // would (a) build api_key usage requests from stored keys / the paid
3237
+ // API env var and (b) never fall through to XAI_OAUTH_TOKEN when a
3238
+ // non-OAuth row is the only stored credential. Skip api_key material
3239
+ // and only env-fallback to the dedicated OAuth bearer.
3240
+ if (providerId === "xai-oauth") {
3241
+ let hasUsableStoredOAuthCredential = false;
3242
+ for (const entry of entries) {
3243
+ if (entry.credential.type !== "oauth") continue;
3244
+ const request = this.#buildUsageRequestForOauth(provider, entry.credential, baseUrl);
3245
+ if (providerImpl.supports && !providerImpl.supports(request)) continue;
3246
+ requests.push(request);
3247
+ hasUsableStoredOAuthCredential = true;
3248
+ }
3249
+ const oauthToken = $env.XAI_OAUTH_TOKEN?.trim();
3250
+ if (!hasUsableStoredOAuthCredential && oauthToken) {
3251
+ const request = this.#buildUsageRequest(provider, { type: "oauth", accessToken: oauthToken }, baseUrl);
3252
+ if (!providerImpl.supports || providerImpl.supports(request)) requests.push(request);
3253
+ }
3254
+ continue;
3255
+ }
3256
+
3230
3257
  if (entries.length === 0) {
3231
3258
  const runtimeKey = this.#runtimeOverrides.get(providerId);
3232
3259
  const envKey = getEnvApiKey(providerId);
@@ -90,7 +90,7 @@ const TRANSIENT_ENVELOPE_PATTERN = /anthropic stream envelope error:/i;
90
90
  const TRANSIENT_ENVELOPE_BEFORE_START_PATTERN = /before message_start/i;
91
91
  export const STREAM_READ_ERROR_PATTERN = /stream[_ -]?read[_ -]?error/i;
92
92
  export const TRANSIENT_TRANSPORT_PATTERN =
93
- /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|malformed.?function.?call/i;
93
+ /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|unable.?to.?connect\.\s*is the computer able to access the url\?|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|malformed.?function.?call/i;
94
94
  const AUTH_FAILURE_PATTERN =
95
95
  /\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i;
96
96
  const MALFORMED_FUNCTION_CALL_PATTERN = /\bmalformed.?function.?call\b/i;
@@ -505,10 +505,19 @@ export function stringify(id: number | undefined): string {
505
505
 
506
506
  const STREAM_PARSE_TRUNCATION_PATTERN =
507
507
  /unterminated string|unexpected end of json input|unexpected end of data|unexpected eof|end of file|eof while parsing|truncated/i;
508
+ const STREAM_PARSE_DIAGNOSTIC_PATTERN =
509
+ /(?:json parse error:\s*(?:unterminated string|unexpected end of json input|unexpected end of data|unexpected eof|end of file|eof while parsing|truncated)|json\.parse:\s*(?:unterminated string|unexpected end of data)|unexpected end of json input|unexpected eof|eof while parsing)/i;
508
510
  const STREAM_EVENT_ORDER_PATTERN = /stream event order|before message_start/i;
509
511
 
510
- /** Transient stream corruption where the response was truncated mid-JSON. */
512
+ /**
513
+ * Transient stream corruption where the response was truncated mid-JSON.
514
+ *
515
+ * Strings (persisted `stopDetails.explanation`/`errorMessage` diagnostics) are matched with the
516
+ * stricter {@link STREAM_PARSE_DIAGNOSTIC_PATTERN} — bare "truncated"/"end of file" text is too
517
+ * low-signal to trust once detached from a live transport `Error`, which keeps the broad pattern.
518
+ */
511
519
  export function isTransientStreamParseError(error: unknown): boolean {
520
+ if (typeof error === "string") return STREAM_PARSE_DIAGNOSTIC_PATTERN.test(error);
512
521
  return error instanceof Error && STREAM_PARSE_TRUNCATION_PATTERN.test(error.message);
513
522
  }
514
523
 
package/src/index.ts CHANGED
@@ -39,6 +39,8 @@ export * from "./usage/ollama";
39
39
  export * from "./usage/openai-codex";
40
40
  export * from "./usage/openai-codex-reset";
41
41
  export * from "./usage/opencode-go";
42
+ export * from "./usage/synthetic";
43
+ export * from "./usage/xai-oauth";
42
44
  export * from "./usage/zai";
43
45
  export * from "./utils/anthropic-auth";
44
46
  export * from "./utils/event-stream";
@@ -0,0 +1,39 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { NO_AUTH_SENTINEL, resolveOpenAIRequestSetup } from "../openai-shared";
3
+
4
+ describe("resolveOpenAIRequestSetup keyless auth", () => {
5
+ test("omits Authorization for the keyless (auth: none) sentinel, keeping custom headers", () => {
6
+ const setup = resolveOpenAIRequestSetup(
7
+ {
8
+ provider: "qwen",
9
+ id: "Qwen3.6-35B-A3B",
10
+ baseUrl: "http://localhost:8788",
11
+ headers: { "x-api-key": "real-key" },
12
+ },
13
+ { apiKey: NO_AUTH_SENTINEL, messages: [] },
14
+ );
15
+ expect(setup.headers.Authorization).toBeUndefined();
16
+ expect(setup.headers["x-api-key"]).toBe("real-key");
17
+ });
18
+
19
+ test("still injects Bearer for a real key", () => {
20
+ const setup = resolveOpenAIRequestSetup(
21
+ { provider: "custom", id: "m", baseUrl: "http://localhost:8788" },
22
+ { apiKey: "sk-real", messages: [] },
23
+ );
24
+ expect(setup.headers.Authorization).toBe("Bearer sk-real");
25
+ });
26
+
27
+ test("caller-supplied Authorization in model.headers is preserved even when keyless", () => {
28
+ const setup = resolveOpenAIRequestSetup(
29
+ {
30
+ provider: "qwen",
31
+ id: "m",
32
+ baseUrl: "http://localhost:8788",
33
+ headers: { Authorization: "Bearer custom-token" },
34
+ },
35
+ { apiKey: NO_AUTH_SENTINEL, messages: [] },
36
+ );
37
+ expect(setup.headers.Authorization).toBe("Bearer custom-token");
38
+ });
39
+ });
@@ -161,7 +161,7 @@ describe("Kimi K3 thinking transport", () => {
161
161
  it("downgrades named tool choice to required for K3 thinking", async () => {
162
162
  vi.spyOn(kimiOauth, "getKimiCommonHeaders").mockReturnValue(KIMI_HEADERS);
163
163
  const bundledModel = getBundledModel<"openai-completions">("kimi-code", "k3");
164
- expect(bundledModel.compat.thinkingFormat).toBe("zai");
164
+ expect(bundledModel.compat.thinkingFormat).toBe("kimi");
165
165
  let payload: unknown;
166
166
  const capturePayload = async (
167
167
  model: Model<"openai-completions">,
@@ -815,9 +815,6 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
815
815
  if (isBuffering) {
816
816
  const buffered = consumePlanningBuffer(textBuffer, toolNames);
817
817
  if (buffered.kind !== "incomplete") {
818
- if (buffered.kind === "leak") {
819
- sawLeak = true;
820
- }
821
818
  const visibleSignature = bufferedTextSignature;
822
819
  isBuffering = false;
823
820
  textBuffer = "";
@@ -896,9 +893,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
896
893
 
897
894
  if (isBuffering && textBuffer !== "") {
898
895
  const buffered = consumePlanningBuffer(textBuffer, toolNames, true);
899
- if (buffered.kind === "leak") {
900
- sawLeak = true;
901
- }
896
+
902
897
  if (buffered.kind !== "incomplete") {
903
898
  feedVisibleText(buffered.visibleText, bufferedTextSignature);
904
899
  }
@@ -910,11 +905,10 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
910
905
  flushVisibleText(bufferedTextSignature);
911
906
  endCurrentBlock();
912
907
 
913
- return hasMeaningfulGoogleContent(output) || sawLeak;
908
+ return hasMeaningfulGoogleContent(output);
914
909
  };
915
910
 
916
911
  let receivedContent = false;
917
- let sawLeak = false;
918
912
 
919
913
  for (let i = 0; i < endpoints.length; i++) {
920
914
  const endpoint = endpoints[i];
@@ -1055,10 +1049,15 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
1055
1049
  break;
1056
1050
  } catch (error) {
1057
1051
  const status = extractHttpStatusFromError(error);
1058
- if (AIError.isTransientStatus(status)) {
1059
- if (!isLastEndpoint && !started) {
1060
- continue;
1061
- }
1052
+ if (
1053
+ !isLastEndpoint &&
1054
+ !started &&
1055
+ (AIError.isTransientStatus(status) ||
1056
+ (status === undefined &&
1057
+ !(error instanceof AIError.ProviderResponseError && error.kind === "output") &&
1058
+ AIError.retriable(AIError.classify(error))))
1059
+ ) {
1060
+ continue;
1062
1061
  }
1063
1062
  throw error;
1064
1063
  }
@@ -101,6 +101,16 @@ import type {
101
101
  import { transformMessages } from "./transform-messages";
102
102
  import { joinTextWithImagePlaceholder, NON_VISION_IMAGE_PLACEHOLDER, partitionVisionContent } from "./vision-guard";
103
103
 
104
+ /**
105
+ * Keyless-provider sentinel. Custom providers configured with `auth: none`
106
+ * (models.yml) have no credential, so the coding-agent resolves their API key
107
+ * to this literal instead of a real secret. Providers must treat it as "no
108
+ * credential" and suppress any credential-bearing header (e.g. `Authorization:
109
+ * Bearer …`) rather than forwarding the sentinel on the wire. See #6188; the
110
+ * google-vertex and amazon-bedrock transports apply the same guard inline.
111
+ */
112
+ export const NO_AUTH_SENTINEL = "N/A";
113
+
104
114
  export interface OpenAIModelIdentity {
105
115
  provider: string;
106
116
  id: string;
@@ -279,7 +289,15 @@ export function resolveOpenAIRequestSetup(
279
289
  baseUrl = baseUrl ?? ($env.OPENAI_BASE_URL?.trim() || options.defaultBaseUrl);
280
290
  }
281
291
  const requestHeaders = { ...headers };
282
- headers.Authorization ??= `Bearer ${apiKey}`;
292
+ // A keyless provider (`auth: none` in models.yml) resolves to the `N/A`
293
+ // sentinel rather than a real key. Injecting `Authorization: Bearer N/A`
294
+ // breaks custom endpoints that authenticate via their own headers (e.g.
295
+ // `headers.x-api-key`) and reject the bogus bearer — mirror the sentinel
296
+ // guards in google-vertex / amazon-bedrock and send no Authorization here
297
+ // (#6188). A caller-supplied Authorization in `model.headers` still wins.
298
+ if (apiKey !== NO_AUTH_SENTINEL) {
299
+ headers.Authorization ??= `Bearer ${apiKey}`;
300
+ }
283
301
  return { copilotPremiumRequests, baseUrl, headers, query, requestHeaders };
284
302
  }
285
303
 
@@ -2483,6 +2501,7 @@ export async function processResponsesStream<TApi extends Api>(
2483
2501
  const entry = lookupOpenToolCallAlias(event, "custom_tool_call");
2484
2502
  if (entry?.item.type === "custom_tool_call" && entry.block.type === "toolCall") {
2485
2503
  finalizeCustomToolCallInputDone(entry.block, event.input);
2504
+ entry.block[kStreamingArgumentsDone] = true;
2486
2505
  }
2487
2506
  } else if (event.type === "response.output_item.done") {
2488
2507
  const item = structuredCloneJSON(event.item);
@@ -2602,6 +2621,10 @@ export async function processResponsesStream<TApi extends Api>(
2602
2621
  }
2603
2622
  } else if (terminalEvent) {
2604
2623
  const response = terminalEvent.response;
2624
+ const shouldPromoteIncompleteToolUse =
2625
+ response?.status === "incomplete" &&
2626
+ response.incomplete_details?.reason === "max_output_tokens" &&
2627
+ hasExecutableIncompleteResponsesToolCalls(output);
2605
2628
  finalizePendingResponsesToolCalls(output);
2606
2629
  if (response?.id) {
2607
2630
  output.responseId = response.id;
@@ -2638,7 +2661,11 @@ export async function processResponsesStream<TApi extends Api>(
2638
2661
  kind: "content-blocked",
2639
2662
  });
2640
2663
  }
2641
- promoteResponsesToolUseStopReason(output, (response as { end_turn?: boolean } | undefined)?.end_turn);
2664
+ promoteResponsesToolUseStopReason(
2665
+ output,
2666
+ (response as { end_turn?: boolean } | undefined)?.end_turn,
2667
+ shouldPromoteIncompleteToolUse,
2668
+ );
2642
2669
  options?.onCompleted?.();
2643
2670
  // `response.completed`/`response.incomplete`/`response.done` is the last event of a
2644
2671
  // Responses stream. Stop pulling instead of waiting for the server to
@@ -2694,6 +2721,28 @@ export function mapOpenAIResponsesStopReason(status: ResponseStatus | undefined)
2694
2721
  }
2695
2722
  }
2696
2723
 
2724
+ function hasExecutableIncompleteResponsesToolCalls(output: AssistantMessage): boolean {
2725
+ let hasToolCall = false;
2726
+ for (const block of output.content) {
2727
+ if (block.type !== "toolCall") continue;
2728
+ hasToolCall = true;
2729
+ const pending = block as ToolCall & {
2730
+ [kStreamingPartialJson]?: string;
2731
+ [kStreamingArgumentsDone]?: boolean;
2732
+ };
2733
+ const rawArguments = pending[kStreamingPartialJson];
2734
+ // `output_item.done` is not positive completion proof: our Responses
2735
+ // compatibility encoder force-closes still-open calls before forwarding an
2736
+ // upstream `length` stop. Only an explicit arguments/input-done event sets
2737
+ // this marker; an open ordinary call can instead prove completion with its
2738
+ // retained strict-complete JSON.
2739
+ if (pending[kStreamingArgumentsDone]) continue;
2740
+ if (pending.customWireName !== undefined || rawArguments === undefined) return false;
2741
+ if (classifyJsonPrefix(rawArguments) !== "complete") return false;
2742
+ }
2743
+ return hasToolCall;
2744
+ }
2745
+
2697
2746
  /**
2698
2747
  * Finalize any streamed toolCall block whose `output_item.done` never arrived
2699
2748
  * (lossy proxy, or a terminal event that raced the per-item done): parse the
@@ -2727,8 +2776,15 @@ export function finalizePendingResponsesToolCalls(output: AssistantMessage): voi
2727
2776
  * re-samples instead of ending. Callers set `output.stopReason` from the wire
2728
2777
  * status first via {@link mapOpenAIResponsesStopReason}.
2729
2778
  */
2730
- export function promoteResponsesToolUseStopReason(output: AssistantMessage, endTurn: boolean | undefined): void {
2731
- if (output.content.some(block => block.type === "toolCall") && output.stopReason === "stop") {
2779
+ export function promoteResponsesToolUseStopReason(
2780
+ output: AssistantMessage,
2781
+ endTurn: boolean | undefined,
2782
+ promoteIncompleteToolUse = false,
2783
+ ): void {
2784
+ if (
2785
+ output.content.some(block => block.type === "toolCall") &&
2786
+ (output.stopReason === "stop" || (promoteIncompleteToolUse && output.stopReason === "length"))
2787
+ ) {
2732
2788
  output.stopReason = "toolUse";
2733
2789
  }
2734
2790
  if (endTurn === false && output.stopReason === "stop") {
@@ -71,13 +71,22 @@ export async function loginAlibabaCodingPlan(options: OAuthController): Promise<
71
71
  }
72
72
 
73
73
  options.onProgress?.("Validating API key...");
74
- await apiKeyValidation.validateOpenAICompatibleApiKey({
75
- provider: "Alibaba Coding Plan",
76
- apiKey: trimmed,
77
- baseUrl,
78
- model: VALIDATION_MODEL,
79
- signal: options.signal,
80
- });
74
+ if (choice === "3") {
75
+ await apiKeyValidation.validateApiKeyAgainstModelsEndpoint({
76
+ provider: "Alibaba Coding Plan",
77
+ apiKey: trimmed,
78
+ modelsUrl: `${baseUrl}/models`,
79
+ signal: options.signal,
80
+ });
81
+ } else {
82
+ await apiKeyValidation.validateOpenAICompatibleApiKey({
83
+ provider: "Alibaba Coding Plan",
84
+ apiKey: trimmed,
85
+ baseUrl,
86
+ model: VALIDATION_MODEL,
87
+ signal: options.signal,
88
+ });
89
+ }
81
90
 
82
91
  return {
83
92
  access: trimmed,