@linxiraos/pi-ai 1.2.0 → 1.3.0
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 +83 -0
- package/README.md +4 -2
- package/THIRD-PARTY-NOTICES.txt +55 -5
- package/dist/types/auth/cascade.d.ts +5 -3
- package/dist/types/auth/pool.d.ts +3 -3
- package/dist/types/auth/select.d.ts +5 -1
- package/dist/types/auth/types.d.ts +15 -5
- package/dist/types/auth/usage-cache.d.ts +3 -1
- package/dist/types/auth-gateway/clients.d.ts +67 -0
- package/dist/types/auth-gateway/dispatch.d.ts +28 -10
- package/dist/types/auth-gateway/http.d.ts +2 -0
- package/dist/types/auth-gateway/index.d.ts +1 -0
- package/dist/types/auth-gateway/server.d.ts +9 -4
- package/dist/types/auth-gateway/session-state.d.ts +6 -0
- package/dist/types/auth-gateway/types.d.ts +16 -2
- package/dist/types/dialect/coercion.d.ts +6 -0
- package/dist/types/error/classes.d.ts +1 -1
- package/dist/types/error/flags.d.ts +15 -0
- package/dist/types/error/index.d.ts +1 -0
- package/dist/types/error/network-cause.d.ts +40 -0
- package/dist/types/images/shared.d.ts +26 -2
- package/dist/types/index.d.ts +1 -0
- package/dist/types/judgment/native.d.ts +51 -3
- package/dist/types/judgment/systemone.d.ts +9 -2
- package/dist/types/judgment/text.d.ts +11 -5
- package/dist/types/judgment/types.d.ts +5 -1
- package/dist/types/judgment/wire.d.ts +16 -3
- package/dist/types/provider-session-state.d.ts +2 -2
- package/dist/types/providers/anthropic-images.d.ts +7 -0
- package/dist/types/providers/anthropic-signature.d.ts +12 -2
- package/dist/types/providers/anthropic-state.d.ts +7 -0
- package/dist/types/providers/anthropic.d.ts +21 -6
- package/dist/types/providers/connection-prewarm.d.ts +15 -0
- package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
- package/dist/types/providers/cursor-pi-args.d.ts +13 -0
- package/dist/types/providers/cursor.d.ts +7 -0
- package/dist/types/providers/openai-codex-responses.d.ts +6 -0
- package/dist/types/providers/openai-responses.d.ts +9 -0
- package/dist/types/providers/openai-shared.d.ts +30 -0
- package/dist/types/providers/systemone-server.d.ts +7 -3
- package/dist/types/providers/transform-messages.d.ts +26 -5
- package/dist/types/registry/oauth/google-antigravity.d.ts +1 -1
- package/dist/types/stream.d.ts +0 -2
- package/dist/types/types.d.ts +33 -9
- package/dist/types/utils/dsml-leak.d.ts +4 -2
- package/dist/types/utils/narration.d.ts +9 -0
- package/dist/types/utils/openai-http.d.ts +2 -0
- package/dist/types/utils/request-body.d.ts +11 -0
- package/dist/types/utils/request-compression.d.ts +21 -0
- package/dist/types/utils/tool-call-arguments.d.ts +2 -1
- package/dist/types/utils/transport-fetch.d.ts +8 -0
- package/dist/types/utils.d.ts +1 -1
- package/package.json +8 -8
- package/src/auth/cascade.ts +23 -13
- package/src/auth/pool.ts +3 -3
- package/src/auth/refresh.ts +39 -12
- package/src/auth/select.ts +8 -2
- package/src/auth/types.ts +16 -5
- package/src/auth/usage-cache.ts +23 -7
- package/src/auth/usage.ts +2 -4
- package/src/auth-broker/remote-store.ts +31 -6
- package/src/auth-gateway/clients.ts +318 -0
- package/src/auth-gateway/dispatch.ts +66 -35
- package/src/auth-gateway/http.ts +11 -5
- package/src/auth-gateway/index.ts +1 -0
- package/src/auth-gateway/routes/embeddings.ts +4 -10
- package/src/auth-gateway/routes/images.ts +6 -10
- package/src/auth-gateway/routes/rerank.ts +4 -10
- package/src/auth-gateway/routes/speech.ts +10 -10
- package/src/auth-gateway/routes/systemone.ts +22 -12
- package/src/auth-gateway/routes/transcriptions.ts +4 -2
- package/src/auth-gateway/routes/video.ts +12 -15
- package/src/auth-gateway/server.ts +136 -53
- package/src/auth-gateway/session-state.ts +14 -0
- package/src/auth-gateway/types.ts +16 -2
- package/src/dialect/coercion.ts +16 -0
- package/src/dialect/hermes.ts +7 -11
- package/src/dialect/qwen3.ts +3 -11
- package/src/error/classes.ts +3 -2
- package/src/error/flags.ts +24 -3
- package/src/error/index.ts +1 -0
- package/src/error/network-cause.ts +179 -0
- package/src/images/codex-format-directive.md +3 -0
- package/src/images/google-antigravity.ts +2 -1
- package/src/images/google-generative-ai.ts +4 -2
- package/src/images/openai-hosted.ts +25 -2
- package/src/images/openai-images.ts +11 -6
- package/src/images/shared.ts +77 -3
- package/src/index.ts +1 -0
- package/src/judgment/chat.ts +17 -1
- package/src/judgment/native.ts +182 -12
- package/src/judgment/openai-decisions.ts +27 -6
- package/src/judgment/systemone.ts +131 -10
- package/src/judgment/text.ts +20 -7
- package/src/judgment/types.ts +5 -1
- package/src/judgment/wire.ts +17 -3
- package/src/provider-session-state.ts +2 -2
- package/src/providers/amazon-bedrock.ts +4 -1
- package/src/providers/anthropic-client.ts +1 -1
- package/src/providers/anthropic-images.ts +263 -0
- package/src/providers/anthropic-messages-server.ts +84 -1
- package/src/providers/anthropic-signature.ts +35 -8
- package/src/providers/anthropic-state.ts +13 -0
- package/src/providers/anthropic.ts +479 -397
- package/src/providers/connection-prewarm.ts +78 -0
- package/src/providers/cowork-fetch.ts +5 -1
- package/src/providers/cursor/exec-modern.ts +1 -0
- package/src/providers/cursor-pi-args.ts +19 -0
- package/src/providers/cursor.ts +53 -13
- package/src/providers/devin.ts +10 -4
- package/src/providers/factory-droid/gemini.ts +9 -3
- package/src/providers/google-gemini-cli.ts +11 -1
- package/src/providers/google-shared.ts +2 -6
- package/src/providers/images-server.ts +6 -1
- package/src/providers/ollama.ts +15 -5
- package/src/providers/openai-anthropic-shim.ts +1 -0
- package/src/providers/openai-chat-server.ts +29 -0
- package/src/providers/openai-codex/access-programs.ts +2 -1
- package/src/providers/openai-codex-responses.ts +49 -82
- package/src/providers/openai-completions.ts +117 -19
- package/src/providers/openai-responses.ts +25 -2
- package/src/providers/openai-shared.ts +36 -5
- package/src/providers/pi-native-server.ts +1 -0
- package/src/providers/systemone-server.ts +72 -12
- package/src/providers/transform-messages.ts +95 -51
- package/src/registry/oauth/google-antigravity.ts +1 -1
- package/src/stream.ts +13 -73
- package/src/types.ts +33 -9
- package/src/usage/claude.ts +2 -9
- package/src/usage/github-copilot.ts +26 -4
- package/src/usage/openai-codex.ts +13 -7
- package/src/usage/opencode-go.ts +7 -8
- package/src/usage/xai-oauth.ts +2 -9
- package/src/utils/dsml-leak.ts +30 -14
- package/src/utils/leaked-thinking-stream.ts +10 -0
- package/src/utils/narration.ts +18 -0
- package/src/utils/openai-http.ts +27 -18
- package/src/utils/request-body.ts +27 -0
- package/src/utils/request-compression.ts +202 -0
- package/src/utils/request-debug.ts +23 -9
- package/src/utils/schema/dereference.ts +51 -18
- package/src/utils/schema/draft.ts +10 -2
- package/src/utils/schema/json-schema-validator.ts +4 -10
- package/src/utils/schema/normalize.ts +6 -9
- package/src/utils/tool-call-arguments.ts +2 -1
- package/src/utils/transport-fetch.ts +37 -0
- package/src/utils/validation.ts +26 -16
- package/src/utils.ts +38 -23
package/src/types.ts
CHANGED
|
@@ -40,7 +40,7 @@ import type { ApiKey } from "./auth-retry";
|
|
|
40
40
|
import type { OAuthRequestIdentity } from "./auth/types";
|
|
41
41
|
import type { BedrockOptions } from "./providers/amazon-bedrock";
|
|
42
42
|
import type { AnthropicOptions } from "./providers/anthropic";
|
|
43
|
-
import type { FallbackParam, StopDetails } from "./providers/anthropic-wire";
|
|
43
|
+
import type { FallbackParam, MessageCreateParamsStreaming, StopDetails } from "./providers/anthropic-wire";
|
|
44
44
|
import type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses";
|
|
45
45
|
import type { CursorOptions } from "./providers/cursor";
|
|
46
46
|
import type { AppleFoundationModelsOptions } from "./providers/apple-foundation-models";
|
|
@@ -497,6 +497,11 @@ export interface StreamOptions {
|
|
|
497
497
|
frequencyPenalty?: number;
|
|
498
498
|
maxTokens?: number;
|
|
499
499
|
signal?: AbortSignal;
|
|
500
|
+
/**
|
|
501
|
+
* OpenAI chat-completions HTTP attempt budget. When set, transient provider
|
|
502
|
+
* errors are not replayed above the transport, so retries share this limit.
|
|
503
|
+
*/
|
|
504
|
+
maxHttpAttempts?: number;
|
|
500
505
|
apiKey?: string;
|
|
501
506
|
/** @internal Stored credential row serving this request, when known. */
|
|
502
507
|
credentialId?: number;
|
|
@@ -628,8 +633,6 @@ export interface StreamOptions {
|
|
|
628
633
|
liveSteering?: LiveSteering;
|
|
629
634
|
/** Canonical Codex compaction classification; ignored by other providers. */
|
|
630
635
|
codexCompaction?: CodexCompactionRequestContext;
|
|
631
|
-
/** Codex Code Mode tool exposure snapshot emitted as `tool_namespaces_info` turn metadata; ignored by other providers. */
|
|
632
|
-
toolNamespacesInfo?: unknown;
|
|
633
636
|
/**
|
|
634
637
|
* Optional per-provider concurrent request cap for LLM stream calls. Keys are
|
|
635
638
|
* provider ids (`model.provider`); positive numeric values cap in-flight
|
|
@@ -893,6 +896,13 @@ export interface ThinkingContent {
|
|
|
893
896
|
* replay natively on another host.
|
|
894
897
|
*/
|
|
895
898
|
summary?: boolean;
|
|
899
|
+
/**
|
|
900
|
+
* `true`: the block is narration — prose the model addressed to the user
|
|
901
|
+
* between tool calls, which the provider returned as a signed thinking block
|
|
902
|
+
* (Anthropic connector-text summarization). It stays a thinking block for
|
|
903
|
+
* replay; user-facing surfaces present it as answer text.
|
|
904
|
+
*/
|
|
905
|
+
narration?: boolean;
|
|
896
906
|
}
|
|
897
907
|
|
|
898
908
|
export interface RedactedThinkingContent {
|
|
@@ -902,11 +912,12 @@ export interface RedactedThinkingContent {
|
|
|
902
912
|
|
|
903
913
|
/**
|
|
904
914
|
* Anthropic server-side-fallback boundary marker persisted on assistant
|
|
905
|
-
* turns whose
|
|
906
|
-
* `AnthropicOptions.fallbacks
|
|
907
|
-
*
|
|
908
|
-
*
|
|
909
|
-
*
|
|
915
|
+
* turns whose response switched models mid-stream, whether the request
|
|
916
|
+
* opted into `AnthropicOptions.fallbacks` or a gateway opted in on its
|
|
917
|
+
* behalf. Consumers other than the Anthropic provider MUST ignore it —
|
|
918
|
+
* `transformMessages` strips the block on any non-Anthropic hop, and
|
|
919
|
+
* `convertAnthropicMessages` only puts it on the wire for an opted-in
|
|
920
|
+
* request to the official endpoint.
|
|
910
921
|
*/
|
|
911
922
|
export interface AnthropicFallbackContent {
|
|
912
923
|
type: "fallback";
|
|
@@ -1230,6 +1241,12 @@ export type AssistantRetryRecovery =
|
|
|
1230
1241
|
export interface ContextSnapshot {
|
|
1231
1242
|
promptTokens: number; // authoritative provider prompt/input tokens
|
|
1232
1243
|
nonMessageTokens: number; // estimated non-message total at send time
|
|
1244
|
+
/**
|
|
1245
|
+
* Estimated tool-schema tokens included in `nonMessageTokens` (0 when the
|
|
1246
|
+
* model defers schemas server-side). Lets a later model whose schema policy
|
|
1247
|
+
* differs rebase the provider anchor instead of inheriting its schemas.
|
|
1248
|
+
*/
|
|
1249
|
+
toolSchemaTokens?: number;
|
|
1233
1250
|
/** Estimated prompt tokens removed by local history rewrites after this provider snapshot was recorded. */
|
|
1234
1251
|
historyRewriteTokensRemoved?: number;
|
|
1235
1252
|
/**
|
|
@@ -1287,6 +1304,12 @@ export interface AssistantMessage {
|
|
|
1287
1304
|
*/
|
|
1288
1305
|
serviceTier?: ServiceTier;
|
|
1289
1306
|
usage: Usage;
|
|
1307
|
+
/**
|
|
1308
|
+
* Visible output tokens a history rewrite (shake) has since removed from
|
|
1309
|
+
* `content`. `usage.output` still counts them, so a replay estimate subtracts
|
|
1310
|
+
* them to keep the turn's reasoning without recharging the removed text.
|
|
1311
|
+
*/
|
|
1312
|
+
elidedOutputTokens?: number;
|
|
1290
1313
|
stopReason: StopReason;
|
|
1291
1314
|
stopDetails?: StopDetails | null;
|
|
1292
1315
|
errorMessage?: string;
|
|
@@ -1661,7 +1684,8 @@ export type AssistantMessageEvent =
|
|
|
1661
1684
|
export interface AnthropicFallbackCreditHandle {
|
|
1662
1685
|
token: string;
|
|
1663
1686
|
prefillClaim?: boolean | null;
|
|
1664
|
-
|
|
1687
|
+
/** The refused request's body, replayed with the credit token on the fallback model. */
|
|
1688
|
+
params: MessageCreateParamsStreaming;
|
|
1665
1689
|
betas?: readonly string[];
|
|
1666
1690
|
betaHeader?: string;
|
|
1667
1691
|
expiresAt: number;
|
package/src/usage/claude.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { classifyModel } from "@linxiraos/pi-catalog/compat/taxonomy";
|
|
2
2
|
import { isAnthropicOAuthToken, toNumber } from "@linxiraos/pi-catalog/utils";
|
|
3
|
+
import { slugify } from "@linxiraos/pi-utils";
|
|
3
4
|
import * as AIError from "../error";
|
|
4
5
|
import {
|
|
5
6
|
type CredentialRankingContext,
|
|
@@ -545,14 +546,6 @@ function buildUsageLimit(args: {
|
|
|
545
546
|
};
|
|
546
547
|
}
|
|
547
548
|
|
|
548
|
-
function slugifyClaudeLimitDisplayName(displayName: string): string {
|
|
549
|
-
return displayName
|
|
550
|
-
.trim()
|
|
551
|
-
.toLowerCase()
|
|
552
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
553
|
-
.replace(/^-+|-+$/g, "");
|
|
554
|
-
}
|
|
555
|
-
|
|
556
549
|
/**
|
|
557
550
|
* Scoped weekly rows are per-model-family counters, not account-wide windows.
|
|
558
551
|
* They deliberately leave `scope.shared` unset so credential-wide exhaustion
|
|
@@ -564,7 +557,7 @@ function buildScopedWeeklyUsageLimits(entries: readonly ParsedApiLimitEntry[]):
|
|
|
564
557
|
const limits: UsageLimit[] = [];
|
|
565
558
|
for (const entry of entries) {
|
|
566
559
|
if (entry.kind !== "weekly_scoped" || !entry.displayName) continue;
|
|
567
|
-
const slug =
|
|
560
|
+
const slug = slugify(entry.displayName);
|
|
568
561
|
if (!slug || seenSlugs.has(slug)) continue;
|
|
569
562
|
seenSlugs.add(slug);
|
|
570
563
|
const limit = buildUsageLimit({
|
|
@@ -9,6 +9,7 @@ import { COPILOT_GITHUB_HEADERS } from "@linxiraos/pi-catalog/wire/github-copilo
|
|
|
9
9
|
import * as AIError from "../error";
|
|
10
10
|
import type {
|
|
11
11
|
UsageAmount,
|
|
12
|
+
UsageUnit,
|
|
12
13
|
UsageFetchContext,
|
|
13
14
|
UsageFetchParams,
|
|
14
15
|
UsageLimit,
|
|
@@ -47,7 +48,7 @@ type BillingUsageItem = {
|
|
|
47
48
|
product: string;
|
|
48
49
|
sku: string;
|
|
49
50
|
model?: string;
|
|
50
|
-
unitType
|
|
51
|
+
unitType?: string;
|
|
51
52
|
grossQuantity: number;
|
|
52
53
|
netQuantity: number;
|
|
53
54
|
limit?: number;
|
|
@@ -266,6 +267,23 @@ function normalizeQuotaSnapshots(
|
|
|
266
267
|
return { limits, window };
|
|
267
268
|
}
|
|
268
269
|
|
|
270
|
+
/**
|
|
271
|
+
* The unit GitHub's billing API metered one usage item in, plus the label
|
|
272
|
+
* that names it. Only AI credits is recognised: any other value keeps the
|
|
273
|
+
* legacy wording, since an unrecognised unit is not evidence about how the
|
|
274
|
+
* account is billed.
|
|
275
|
+
*/
|
|
276
|
+
function resolveBillingUnit(unitType: string | undefined): { unit: UsageUnit; label: string } {
|
|
277
|
+
if (typeof unitType !== "string") return { unit: "requests", label: "Premium Requests" };
|
|
278
|
+
// "AI_CREDITS", "ai-credits" and "Ai Credits" all name the same unit.
|
|
279
|
+
return unitType
|
|
280
|
+
.toUpperCase()
|
|
281
|
+
.replace(/[^A-Z]/g, "")
|
|
282
|
+
.includes("CREDIT")
|
|
283
|
+
? { unit: "credits", label: "AI Credits" }
|
|
284
|
+
: { unit: "requests", label: "Premium Requests" };
|
|
285
|
+
}
|
|
286
|
+
|
|
269
287
|
function normalizeBillingUsage(data: BillingUsageResponse): UsageLimit[] {
|
|
270
288
|
const limits: UsageLimit[] = [];
|
|
271
289
|
const periodLabel = data.timePeriod.month
|
|
@@ -279,12 +297,15 @@ function normalizeBillingUsage(data: BillingUsageResponse): UsageLimit[] {
|
|
|
279
297
|
const premiumItems = data.usageItems.filter(
|
|
280
298
|
item => item.sku === "Copilot Premium Request" || item.sku.includes("Premium"),
|
|
281
299
|
);
|
|
300
|
+
// The allowance is metered in one unit, and the items name it. Take it from
|
|
301
|
+
// the first item that states a unit rather than assuming the legacy one.
|
|
302
|
+
const allowanceUnit = resolveBillingUnit(premiumItems.find(item => item.unitType !== undefined)?.unitType);
|
|
282
303
|
const totalUsed = premiumItems.reduce((sum, item) => sum + item.grossQuantity, 0);
|
|
283
304
|
const totalLimit = premiumItems.reduce((sum, item) => sum + (item.limit ?? 0), 0) || undefined;
|
|
284
|
-
const totalAmount = buildAmount(totalUsed, totalLimit,
|
|
305
|
+
const totalAmount = buildAmount(totalUsed, totalLimit, allowanceUnit.unit);
|
|
285
306
|
limits.push({
|
|
286
307
|
id: "copilot:premium",
|
|
287
|
-
label:
|
|
308
|
+
label: allowanceUnit.label,
|
|
288
309
|
scope: {
|
|
289
310
|
provider: "github-copilot",
|
|
290
311
|
accountId: data.user,
|
|
@@ -298,7 +319,8 @@ function normalizeBillingUsage(data: BillingUsageResponse): UsageLimit[] {
|
|
|
298
319
|
for (const item of data.usageItems) {
|
|
299
320
|
if (!item.model) continue;
|
|
300
321
|
if (item.grossQuantity <= 0) continue;
|
|
301
|
-
|
|
322
|
+
// Each model row is metered in its own item's unit, not the legacy default.
|
|
323
|
+
const amount = buildAmount(item.grossQuantity, item.limit, resolveBillingUnit(item.unitType).unit);
|
|
302
324
|
limits.push({
|
|
303
325
|
id: `copilot:model:${item.model}`,
|
|
304
326
|
label: `Model ${item.model}`,
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
2
|
import { planRequirementFor, quotaTierFor } from "@linxiraos/pi-catalog/compat/behavior";
|
|
3
3
|
import { toNumber } from "@linxiraos/pi-catalog/utils";
|
|
4
|
-
import { USER_AGENT } from "@linxiraos/pi-utils";
|
|
4
|
+
import { slugify, USER_AGENT } from "@linxiraos/pi-utils";
|
|
5
|
+
import { ProviderHttpError } from "../error";
|
|
5
6
|
import type {
|
|
6
7
|
CredentialRankingContext,
|
|
7
8
|
CredentialRankingStrategy,
|
|
@@ -393,12 +394,7 @@ function additionalLimitSlug(args: { limitName?: string; meteredFeature?: string
|
|
|
393
394
|
const probe = `${args.limitName ?? ""} ${args.meteredFeature ?? ""}`.toLowerCase();
|
|
394
395
|
if (probe.includes("spark") || probe.includes("bengalfox")) return "spark";
|
|
395
396
|
const source = (args.meteredFeature ?? args.limitName ?? "extra").toLowerCase();
|
|
396
|
-
return (
|
|
397
|
-
source
|
|
398
|
-
.replace(/^codex[-_]/, "")
|
|
399
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
400
|
-
.replace(/^-+|-+$/g, "") || "extra"
|
|
401
|
-
);
|
|
397
|
+
return slugify(source.replace(/^codex[-_]/, "")) || "extra";
|
|
402
398
|
}
|
|
403
399
|
|
|
404
400
|
function additionalDisplayName(slug: string, limitName?: string): string {
|
|
@@ -542,11 +538,21 @@ export const openaiCodexUsageProvider: UsageProvider = {
|
|
|
542
538
|
try {
|
|
543
539
|
const response = await ctx.fetch(url, { headers, signal: params.signal });
|
|
544
540
|
if (!response.ok) {
|
|
541
|
+
// A rejected token must purge the cached report and fail the health
|
|
542
|
+
// check: only a thrown auth status does that, while `null` is the
|
|
543
|
+
// transient-failure path that keeps serving last-good quota.
|
|
544
|
+
if (response.status === 401 || response.status === 403) {
|
|
545
|
+
throw new ProviderHttpError(
|
|
546
|
+
`Codex usage endpoint returned ${response.status} ${response.statusText}`.trim(),
|
|
547
|
+
response.status,
|
|
548
|
+
);
|
|
549
|
+
}
|
|
545
550
|
ctx.logger?.warn("Codex usage request failed", { status: response.status, provider: params.provider });
|
|
546
551
|
return null;
|
|
547
552
|
}
|
|
548
553
|
payload = await response.json();
|
|
549
554
|
} catch (error) {
|
|
555
|
+
if (error instanceof ProviderHttpError) throw error;
|
|
550
556
|
ctx.logger?.warn("Codex usage request error", { provider: params.provider, error: String(error) });
|
|
551
557
|
return null;
|
|
552
558
|
}
|
package/src/usage/opencode-go.ts
CHANGED
|
@@ -15,13 +15,12 @@ import { DAY_MS, HOUR_MS } from "./shared";
|
|
|
15
15
|
|
|
16
16
|
const OPENCODE_GO_PROVIDER = "opencode-go";
|
|
17
17
|
const DEFAULT_ENDPOINT = "https://opencode.ai/zen/go";
|
|
18
|
+
const MODEL_ENDPOINT = "https://opencode.ai/inference/go";
|
|
18
19
|
const USAGE_PATH = "/v1/usage";
|
|
19
20
|
|
|
20
21
|
/**
|
|
21
|
-
* `GET /zen/go/v1/usage` response windows. The
|
|
22
|
-
*
|
|
23
|
-
* and its shape changed once on merge day, so each window is decoded
|
|
24
|
-
* defensively and malformed windows are skipped rather than failing the report.
|
|
22
|
+
* `GET /zen/go/v1/usage` response windows. The account endpoint remains on
|
|
23
|
+
* Zen even though inference and model discovery use `/inference/go/v1`.
|
|
25
24
|
*
|
|
26
25
|
* Per window: `status` is `"ok" | "rate-limited"`, `percent` is a floored,
|
|
27
26
|
* clamped integer 0-100, and `resetsAt` is an ISO timestamp computed server
|
|
@@ -36,11 +35,11 @@ const OPENCODE_GO_WINDOWS = [
|
|
|
36
35
|
|
|
37
36
|
function normalizeBaseUrl(baseUrl?: string): string {
|
|
38
37
|
if (!baseUrl?.trim()) return DEFAULT_ENDPOINT;
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// preserving any path-mounted gateway prefix.
|
|
38
|
+
// Preserve custom gateway prefixes while mapping first-party model URLs
|
|
39
|
+
// back to the separate Zen account endpoint.
|
|
42
40
|
const withoutTrailingSlash = baseUrl.trim().replace(/\/+$/, "");
|
|
43
|
-
|
|
41
|
+
const normalized = withoutTrailingSlash.replace(/\/v1$/i, "");
|
|
42
|
+
return normalized === MODEL_ENDPOINT ? DEFAULT_ENDPOINT : normalized || DEFAULT_ENDPOINT;
|
|
44
43
|
}
|
|
45
44
|
|
|
46
45
|
function resolveStatus(windowStatus: unknown, usedFraction: number): UsageStatus {
|
package/src/usage/xai-oauth.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { toNumber } from "@linxiraos/pi-catalog/utils";
|
|
13
|
+
import { slugify } from "@linxiraos/pi-utils";
|
|
13
14
|
import { isUsageLimitExhausted } from "../auth/usage-report";
|
|
14
15
|
import {
|
|
15
16
|
buildXAICliBillingUrl,
|
|
@@ -95,14 +96,6 @@ function buildPercentAmount(usagePercent: number): UsageAmount {
|
|
|
95
96
|
};
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
function slugifyProduct(product: string): string {
|
|
99
|
-
return product
|
|
100
|
-
.trim()
|
|
101
|
-
.toLowerCase()
|
|
102
|
-
.replace(/[^a-z0-9]+/g, "-")
|
|
103
|
-
.replace(/^-+|-+$/g, "");
|
|
104
|
-
}
|
|
105
|
-
|
|
106
99
|
function buildPeriodWindow(period: XaiBillingPeriod): UsageWindow {
|
|
107
100
|
return {
|
|
108
101
|
id: "1w",
|
|
@@ -262,7 +255,7 @@ function buildLimits(config: XaiBillingConfig, accountId: string | undefined): U
|
|
|
262
255
|
|
|
263
256
|
for (const item of config.productUsage) {
|
|
264
257
|
const amount = buildPercentAmount(item.usagePercent);
|
|
265
|
-
const slug =
|
|
258
|
+
const slug = slugify(item.product);
|
|
266
259
|
if (!slug) continue;
|
|
267
260
|
limits.push({
|
|
268
261
|
id: `${PROVIDER_ID}:product:${slug}:1w`,
|
package/src/utils/dsml-leak.ts
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
import type { AssistantMessage, Model } from "../types";
|
|
14
14
|
|
|
15
15
|
// Structural DSML tags in either pipe spelling (fullwidth `|` U+FF5C or ASCII),
|
|
16
|
-
//
|
|
17
|
-
const DSML_TAG_PATTERN = /<\s*\/?\s*[||]\s*DSML\s*[||]\s*(?:tool_calls|invoke|parameter)\b[^<>]*>?/gu;
|
|
16
|
+
// including doubled delimiters and the observed malformed ` calls` wrapper.
|
|
17
|
+
const DSML_TAG_PATTERN = /<\s*\/?\s*[||]{1,2}\s*DSML\s*[||]{1,2}\s*(?:tool_calls|invoke|parameter|calls)\b[^<>]*>?/gu;
|
|
18
18
|
// Code the model deliberately quoted (fences, inline spans) is not a leak.
|
|
19
19
|
const QUOTED_CODE_PATTERN = /(```|~~~)[\s\S]*?(?:\1|$)|`[^`\n]*`/g;
|
|
20
20
|
// The identifier-only line a degraded model writes in place of
|
|
@@ -89,21 +89,37 @@ export function stripDsmlToolMarkup(text: string): string | undefined {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
|
-
* Remove leaked DSML tool-call markup from
|
|
93
|
-
*
|
|
92
|
+
* Remove leaked DSML tool-call markup from an assistant message before commit,
|
|
93
|
+
* including reasoning blocks and turns with valid structured calls. Rewritten
|
|
94
|
+
* thinking with an opaque signature is discarded; OpenAI-compatible reasoning
|
|
95
|
+
* field names are replay markers, not signatures, and survive cleanup.
|
|
94
96
|
*/
|
|
95
97
|
export function removeDsmlToolMarkupLeak(message: AssistantMessage): boolean {
|
|
96
|
-
let
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
content.push(block);
|
|
98
|
+
let content: AssistantMessage["content"] | undefined;
|
|
99
|
+
for (let index = 0; index < message.content.length; index++) {
|
|
100
|
+
const block = message.content[index]!;
|
|
101
|
+
if (block.type !== "text" && block.type !== "thinking") {
|
|
102
|
+
content?.push(block);
|
|
102
103
|
continue;
|
|
103
104
|
}
|
|
104
|
-
|
|
105
|
-
if (stripped
|
|
105
|
+
const stripped = stripDsmlToolMarkup(block.type === "text" ? block.text : block.thinking);
|
|
106
|
+
if (stripped === undefined) {
|
|
107
|
+
content?.push(block);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
content ??= message.content.slice(0, index);
|
|
111
|
+
if (block.type === "text") {
|
|
112
|
+
if (stripped.trim().length > 0) content.push({ ...block, text: stripped });
|
|
113
|
+
} else {
|
|
114
|
+
const signature = block.thinkingSignature;
|
|
115
|
+
const reasoningField =
|
|
116
|
+
(message.api === "openai-completions" || message.api === "openrouter") &&
|
|
117
|
+
(signature === "reasoning_content" || signature === "reasoning" || signature === "reasoning_text");
|
|
118
|
+
if ((!signature || reasoningField) && stripped.trim().length > 0) {
|
|
119
|
+
content.push({ ...block, thinking: stripped });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
106
122
|
}
|
|
107
|
-
if (
|
|
108
|
-
return
|
|
123
|
+
if (content) message.content = content;
|
|
124
|
+
return content !== undefined;
|
|
109
125
|
}
|
|
@@ -141,6 +141,16 @@ export function wrapLeakedThinkingStream(inner: AssistantMessageEventStream): As
|
|
|
141
141
|
return;
|
|
142
142
|
}
|
|
143
143
|
case "error": {
|
|
144
|
+
// An error message with empty content means the provider
|
|
145
|
+
// discarded the partial turn; forward it as-is rather than
|
|
146
|
+
// rebuild it from #partial, which would resurrect exactly what
|
|
147
|
+
// was discarded. Non-empty error content is still healed: a
|
|
148
|
+
// provider that reports partial output alongside the error did
|
|
149
|
+
// send that output.
|
|
150
|
+
if (projector !== undefined && event.error.content.length === 0) {
|
|
151
|
+
out.push({ type: "error", reason: event.reason, error: event.error });
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
144
154
|
projector ??= new LeakedThinkingProjector(out, event.error);
|
|
145
155
|
const content = projector.finish(event.error);
|
|
146
156
|
out.push({ type: "error", reason: event.reason, error: { ...event.error, content } });
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AssistantMessage } from "../types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The message as a user-facing surface should present it: every narration
|
|
5
|
+
* thinking block (see `ThinkingContent.narration`) becomes a text block, so it
|
|
6
|
+
* renders as part of the answer instead of folding into reasoning. Returns the
|
|
7
|
+
* input unchanged when it holds no narration. Display-only: replay must keep
|
|
8
|
+
* the original thinking blocks and their signatures.
|
|
9
|
+
*/
|
|
10
|
+
export function presentNarrationAsAnswer(message: AssistantMessage): AssistantMessage {
|
|
11
|
+
if (!message.content.some(block => block.type === "thinking" && block.narration)) return message;
|
|
12
|
+
return {
|
|
13
|
+
...message,
|
|
14
|
+
content: message.content.map(block =>
|
|
15
|
+
block.type === "thinking" && block.narration ? { type: "text", text: block.thinking } : block,
|
|
16
|
+
),
|
|
17
|
+
};
|
|
18
|
+
}
|
package/src/utils/openai-http.ts
CHANGED
|
@@ -68,6 +68,8 @@ export interface OpenAIStreamRequestInit {
|
|
|
68
68
|
body: unknown;
|
|
69
69
|
signal: AbortSignal;
|
|
70
70
|
fetch?: FetchImpl;
|
|
71
|
+
/** Override the default transport attempt budget for this request. */
|
|
72
|
+
maxAttempts?: number;
|
|
71
73
|
/** Optional caller-specific gate composed with shared transport retry exclusions. */
|
|
72
74
|
shouldRetryResponse?: (response: Response, bodyText: string) => boolean | Promise<boolean>;
|
|
73
75
|
/**
|
|
@@ -96,24 +98,31 @@ export interface OpenAIStreamHandle<TEvent> {
|
|
|
96
98
|
* watchdog timers and abort-reason bookkeeping.
|
|
97
99
|
*/
|
|
98
100
|
export async function postOpenAIStream<TEvent>(init: OpenAIStreamRequestInit): Promise<OpenAIStreamHandle<TEvent>> {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
101
|
+
let response: Response;
|
|
102
|
+
try {
|
|
103
|
+
response = await fetchWithRetry(init.url, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers: { "Content-Type": "application/json", Accept: "text/event-stream", ...init.headers },
|
|
106
|
+
body: JSON.stringify(init.body),
|
|
107
|
+
signal: init.signal,
|
|
108
|
+
fetch: init.fetch,
|
|
109
|
+
maxAttempts: init.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
|
110
|
+
// A proxy concurrency-admission 429 (`rate_limit_type: max_parallel_requests`)
|
|
111
|
+
// surfaces immediately instead of being slept-and-retried here; session
|
|
112
|
+
// recovery owns its backoff/fallback (issue #8854).
|
|
113
|
+
shouldRetryResponse: async (response, bodyText) =>
|
|
114
|
+
!isConcurrencyAdmissionRejection(response, bodyText) &&
|
|
115
|
+
(init.shouldRetryResponse === undefined || (await init.shouldRetryResponse(response, bodyText))),
|
|
116
|
+
// Bun's native fetch enforces a hard ~300s pre-response timeout (issue #2422).
|
|
117
|
+
// Cold large-context streams legitimately exceed it; the caller's
|
|
118
|
+
// `firstEventTimeoutMs`/`AbortSignal` already govern stuck requests.
|
|
119
|
+
timeout: false,
|
|
120
|
+
});
|
|
121
|
+
} catch (error) {
|
|
122
|
+
// Transport rejection after the retry budget: name the host and the cause
|
|
123
|
+
// instead of surfacing the runtime's bare `getaddrinfo …` text.
|
|
124
|
+
throw AIError.networkFailureError(error, init.url);
|
|
125
|
+
}
|
|
117
126
|
if (!response.ok) {
|
|
118
127
|
throw await captureOpenAIHttpError(response);
|
|
119
128
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bytes of a request body as the server reads them: the `gzip`/`zstd`
|
|
3
|
+
* `Content-Encoding` applied by request compression is undone, any other
|
|
4
|
+
* encoding is returned untouched.
|
|
5
|
+
*/
|
|
6
|
+
export function decodeRequestBodyBytes(bytes: Uint8Array, contentEncoding: string | null): Uint8Array {
|
|
7
|
+
switch (contentEncoding?.trim().toLowerCase()) {
|
|
8
|
+
case "gzip":
|
|
9
|
+
return Bun.gunzipSync(bytes as Uint8Array<ArrayBuffer>);
|
|
10
|
+
case "zstd":
|
|
11
|
+
return Bun.zstdDecompressSync(bytes);
|
|
12
|
+
default:
|
|
13
|
+
return bytes;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Text of a string or byte request body after undoing its content encoding;
|
|
19
|
+
* `undefined` when the request has no such body (none, stream, form data).
|
|
20
|
+
*/
|
|
21
|
+
export function requestBodyText(init: RequestInit | undefined): string | undefined {
|
|
22
|
+
const body = init?.body;
|
|
23
|
+
if (typeof body === "string") return body;
|
|
24
|
+
if (!(body instanceof Uint8Array)) return undefined;
|
|
25
|
+
const encoding = new Headers(init?.headers).get("content-encoding");
|
|
26
|
+
return new TextDecoder().decode(decodeRequestBodyBytes(body, encoding));
|
|
27
|
+
}
|