@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 +25 -0
- package/dist/types/error/flags.d.ts +7 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/providers/__tests__/keyless-auth-header.test.d.ts +1 -0
- package/dist/types/providers/openai-shared.d.ts +10 -1
- package/dist/types/registry/oauth/xai-oauth.d.ts +25 -9
- package/dist/types/types.d.ts +2 -1
- package/dist/types/usage/synthetic.d.ts +2 -0
- package/dist/types/usage/xai-oauth.d.ts +9 -0
- package/dist/types/usage.d.ts +9 -0
- package/package.json +4 -4
- package/src/auth-gateway/server.ts +13 -7
- package/src/auth-storage.ts +28 -1
- package/src/error/flags.ts +11 -2
- package/src/index.ts +2 -0
- package/src/providers/__tests__/keyless-auth-header.test.ts +39 -0
- package/src/providers/__tests__/kimi-code-thinking.test.ts +1 -1
- package/src/providers/google-gemini-cli.ts +11 -12
- package/src/providers/openai-shared.ts +60 -4
- package/src/registry/alibaba-coding-plan.ts +16 -7
- package/src/registry/oauth/__tests__/xai-oauth.test.ts +213 -5
- package/src/registry/oauth/xai-oauth.ts +146 -17
- package/src/types.ts +9 -4
- package/src/usage/synthetic.ts +180 -0
- package/src/usage/xai-oauth.ts +256 -0
- package/src/usage.ts +7 -0
- package/src/utils/leaked-thinking-stream.ts +58 -9
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
|
-
/**
|
|
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;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
-
*
|
|
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
|
|
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
|
/**
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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
|
|
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,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;
|
package/dist/types/usage.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
42
|
-
"@oh-my-pi/pi-utils": "17.0.
|
|
43
|
-
"@oh-my-pi/pi-wire": "17.0.
|
|
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
|
|
730
|
-
const data
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
|
package/src/auth-storage.ts
CHANGED
|
@@ -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);
|
package/src/error/flags.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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("
|
|
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
|
-
|
|
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)
|
|
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 (
|
|
1059
|
-
|
|
1060
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
2731
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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,
|