@gajae-code/ai 0.17.2 → 0.17.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/dist/types/auth-gateway/server.d.ts +23 -1
  3. package/dist/types/auth-storage.d.ts +12 -1
  4. package/dist/types/providers/anthropic.d.ts +1 -1
  5. package/dist/types/providers/cursor.d.ts +10 -0
  6. package/dist/types/providers/openai-completions.d.ts +9 -1
  7. package/dist/types/types.d.ts +16 -0
  8. package/dist/types/utils/discovery/openai-compatible.d.ts +10 -0
  9. package/dist/types/utils/fallback-transport.d.ts +4 -0
  10. package/dist/types/utils/h2-fetch.d.ts +8 -7
  11. package/dist/types/utils/stream-repetition-guard.d.ts +107 -0
  12. package/dist/types/utils/tool-call-healing.d.ts +4 -0
  13. package/dist/types/utils/tool-fence-strip.d.ts +27 -0
  14. package/package.json +3 -3
  15. package/src/auth-gateway/server.ts +48 -9
  16. package/src/auth-storage.ts +194 -52
  17. package/src/model-pricing.ts +22 -0
  18. package/src/model-thinking.ts +40 -4
  19. package/src/models.json +10060 -3164
  20. package/src/providers/anthropic.d.ts +1 -1
  21. package/src/providers/anthropic.ts +1 -1
  22. package/src/providers/cursor.d.ts +10 -0
  23. package/src/providers/cursor.ts +144 -24
  24. package/src/providers/kiro-api-key.ts +7 -0
  25. package/src/providers/openai-completions.d.ts +9 -1
  26. package/src/providers/openai-completions.ts +410 -150
  27. package/src/providers/transform-messages.ts +54 -1
  28. package/src/stream.ts +7 -0
  29. package/src/types.d.ts +16 -0
  30. package/src/types.ts +17 -0
  31. package/src/utils/discovery/openai-compatible.ts +16 -2
  32. package/src/utils/fallback-transport.d.ts +4 -0
  33. package/src/utils/fallback-transport.ts +11 -0
  34. package/src/utils/h2-fetch.ts +65 -26
  35. package/src/utils/http-inspector.ts +2 -0
  36. package/src/utils/idle-iterator.ts +109 -96
  37. package/src/utils/json-parse.ts +12 -4
  38. package/src/utils/stream-repetition-guard.d.ts +107 -0
  39. package/src/utils/stream-repetition-guard.ts +290 -0
  40. package/src/utils/tool-call-healing.d.ts +4 -0
  41. package/src/utils/tool-call-healing.ts +4 -0
  42. package/src/utils/tool-fence-strip.d.ts +27 -0
  43. package/src/utils/tool-fence-strip.ts +64 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,124 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.17.5] - 2026-09-24
6
+
7
+ ### Added
8
+
9
+ - `packages/ai/src/models.json` was regenerated against models.dev, bundling Claude Opus 5.5 across Anthropic (`claude-opus-5-5`), Bedrock (`anthropic.claude-opus-5-5` plus the `us.`/`eu.`/`au.`/`jp.`/`global.` inference profiles), OpenRouter, Vercel AI Gateway, Kilo, and Venice, alongside the rest of the upstream catalog drift (Grok 4.7, GLM 5.3 variants, Xiaomi MiMo V2.6 Flash/Pro/Pro-Ultraspeed, DeepSeek/Qwen token-plan rows). `VISION_CORRECTED_CLAUDE_OPUS_GENERATIONS` declares generation 5.5 so the new rows pass the Opus vision tripwire instead of silently bypassing the generator correction.
10
+ - Kiro bundles `claude-opus-5.5` (1M input / 128K output, text input only). Kiro's API-key request serializer drops images, so this model does not advertise image support until that transport can serialize them. Kiro's Anthropic model list is hand-maintained in `kiro-api-key.ts` rather than sourced from models.dev, so a regeneration alone left Kiro stranded on Opus 5 while every models.dev-backed provider advanced; the entry follows the existing dual dotted/dashed id convention already used for `claude-opus-4.5` through `4.8`.
11
+
12
+ ### Fixed
13
+
14
+ - openai-completions treats a `compat.extraBody` `tool_choice` as an endpoint default instead of an override: it fills the gap only on ordinary turns that offer tools and resolved no directive of their own, leaving forced-tool directives and deliberate no-tools turns untouched (strict backends reject `tool_choice` with an empty `tools` list). The default also passes through the provider's existing tool-choice reasoning suppression so endpoint defaults do not bypass compatibility flags.
15
+
16
+ - Cross-model assistant-history replay now collapses pathological runs of exact consecutive thinking paragraphs into one copy with the exact repeat count, preventing repeated reasoning from inflating custom OpenAI-compatible prompts while preserving stored history, final answers, tool content, and native same-model reasoning (#5805).
17
+
18
+ - Keep provider evidence stable when the same runtime, configured, or environment API key is resolved.
19
+
20
+ ## [0.17.4] - 2026-09-23
21
+
22
+ ### Added
23
+
24
+ - The Codex catalog now bundles `gpt-6-sol` and `gpt-6-luna` alongside `gpt-6-astra`, so both models are selectable the day they ship instead of waiting for an upstream `models.dev` refresh. Tiered pricing is declared from the published rates — Sol `$2`/`$10` per 1M (cached input `$0.20`, cached output `$2.50`) and Luna `$0.10`/`$0.50` (cached `$0.01`/`$0.125`) — including the above-272K multipliers (input 2x, output 1.5x).
25
+
26
+ ### Changed
27
+
28
+ - Updated the Claude Code-compatible `claude-cli` header version to `2.1.280`, matching the published Claude Code npm release so Anthropic does not reject requests carrying a stale client version.
29
+
30
+ ## [0.17.3] - 2026-09-22
31
+
32
+ ### Added
33
+
34
+ - Alibaba Token Plan now bundles `deepseek-v4.1-flash`, `deepseek-v4-pro-0813`, and `glm-5.3` with the reviewed sibling envelopes (DeepSeek V4 1M/384K, GLM 1M/128K) and low/high/max reasoning efforts, so Token Plan subscribers can select the current Model Studio IDs without waiting for authenticated catalog discovery.
35
+
36
+ ### Fixed
37
+
38
+ - Stop a runaway reasoning stream instead of rendering every repeat. When an
39
+ openai-compatible model falls into a decode loop and emits the same line — or
40
+ the same short token run — 12 times in a row on the reasoning channel, the turn
41
+ is now cut short with `stopReason: "error"` and
42
+ `errorCode: "repetition_guard_tripped"` rather than dumping dozens of identical
43
+ lines into the terminal. Tool calls in the same message still stream and
44
+ execute normally, including ones the model emits *after* the repeats.
45
+
46
+ The stop is classified as a provider error rather than `aborted`, so the auth
47
+ gateway renders it as HTTP 502 `upstream_error` and telemetry no longer counts
48
+ it as a user cancellation. A genuine caller abort still wins and still reports
49
+ `aborted`. The trip is terminal and is not auto-retried: a decode loop is
50
+ deterministic for the submitted context, so replaying it would re-trip the
51
+ guard and re-bill the full context on every attempt.
52
+
53
+ The guard applies to the reasoning channel only. Visible text is opt-in via the
54
+ new `repetitionGuard` option (`{ thinking?: number | false; text?: number |
55
+ false }`), because visible output is a deliverable and intentional repetition
56
+ there — log dumps, fixtures, tables, generated code — must survive byte for
57
+ byte.
58
+ - Keep a provider stall or transport error that lands *after* a repetition trip
59
+ classified as what it actually is. The guard drains the stream briefly after
60
+ tripping, and a fault arriving inside that window was being reported as a
61
+ decode loop — discarding the real error message, `errorStatus` and
62
+ `transportFailure`, and marking a retryable provider fault as terminal. The
63
+ guard's own abort is now tracked explicitly, so only it claims the trip.
64
+ - Keep the repeated sample out of error payloads. The guard's `errorMessage`
65
+ interpolated the repeated unit, the channel and the repeat count, and the auth
66
+ gateway forwards `errorMessage` to API clients on the streaming path — so raw
67
+ model output was published verbatim, and a repeated `quota` or `forbidden` in
68
+ the sample could steer the HTTP status the gateway picked. The message is now
69
+ a fixed literal at the provider, the gateway substitutes the same bounded
70
+ envelope its non-streaming path already used, and the sample survives only in
71
+ local `logger.debug` diagnostics.
72
+ - Classify a runaway turn whose final repeat arrives without a trailing newline.
73
+ The guard closed a line only on `\n` and a token only on whitespace, so a
74
+ stream that ended mid-unit left the last copy uncounted and the turn reported a
75
+ healthy completion. The guard is now finalized at end of stream — on the
76
+ normal-completion path only, so a stream that threw mid-repeat still keeps its
77
+ own transport facts.
78
+ - Bound the post-trip drain on *every* consumed chunk. The drain budget was only
79
+ spent by chunks that carried a usable `choices[0]`; usage-only, keepalive-shaped,
80
+ `choices`-less and malformed frames skipped the check entirely, so a provider
81
+ answering a tripped stream with those frames held the request open with no
82
+ bound at all. The check now runs exactly once per consumed chunk, still after
83
+ that chunk is fully processed so late tool-call frames are never cut mid-flight.
84
+ - Make `repetitionGuard` reachable from the public API. The option existed only
85
+ on the openai-completions provider type and was dropped by the
86
+ `streamSimple`/`completeSimple` options mapping, so callers on the normal path
87
+ could neither disable a channel nor change its threshold. It is now part of
88
+ `SimpleStreamOptions` (as the shared `RepetitionGuardOptions` type) and is
89
+ forwarded to the transport; defaults and semantics are unchanged.
90
+ - Validate the repetition threshold before it sizes the guard's state. Now that
91
+ `repetitionGuard` is public, a caller could pass `NaN` — which made every
92
+ comparison false and silently disabled detection with no error — or `Infinity`
93
+ or a huge value, which left detection permanently off *and* made the guard's
94
+ token retention unbounded on a long stream. A fractional threshold was also
95
+ never reached exactly by an integer repeat counter. The threshold is now
96
+ normalized at the constructor: non-finite values fall back to the default,
97
+ fractions are floored, and the result is clamped into `[2,
98
+ MAX_REPETITION_THRESHOLD]`, so tracking capacity is finite by construction.
99
+ Bad input normalizes rather than throwing — a failed request would be worse
100
+ than the guard running at its default.
101
+ - Stop persisting raw repeated model output in the default logs. The trip
102
+ diagnostic logged the repeated sample, and the default log transport is a
103
+ rotating file that JSON-stringifies metadata verbatim with no redaction, so a
104
+ model that looped on a secret or a private fragment of the prompt wrote it to
105
+ disk and into log rotation, support bundles and backups. The diagnostic now
106
+ carries bounded, derived metadata only (`sampleLength`, a number). The sample
107
+ remains on the in-memory trip object for callers.
108
+ - Strip leaked chat-template tool fences (`<|tool_call_end|>` and friends) from
109
+ rendered thinking, including fences split across streaming chunk boundaries.
110
+ The visible text channel is deliberately untouched, so a fence token the
111
+ assistant mentions in prose still survives as text.
112
+
113
+ - Serialize BigInt values safely at every Cursor payload and conversation-identity boundary without dropping generic tool-schema fields or truncating large schemas and contexts.
114
+
115
+ - Track the current Claude Code release in the spoofed version constant (`2.1.273` → `2.1.278`). A stale `claude-cli/<version>` is rejected by the model with an HTTP 400, so the constant is not cosmetic. The daily spoofed-version drift guard had been red since 2026-09-16.
116
+
117
+ - Preserve observed HTTP/2 reset and native error codes on Cursor failures without mutating native errors or replacing the first terminal diagnostic.
118
+
119
+ ### Performance
120
+
121
+ - Drain completion-only stream events instead of retaining them, close idle-iterator sources once on early exit, and avoid repeated suffix scans in escape-dense JSON.
122
+
5
123
  ## [0.17.2] - 2026-09-18
6
124
 
7
125
  ### Added
@@ -18,7 +18,7 @@
18
18
  * POST /v1/responses → OpenAI Responses in/out
19
19
  */
20
20
  import type { AuthStorage } from "../auth-storage";
21
- import type { Api, AssistantMessageEventStream, Model, Provider, SimpleStreamOptions } from "../types";
21
+ import type { Api, AssistantMessage, AssistantMessageEventStream, Model, Provider, SimpleStreamOptions } from "../types";
22
22
  import type { AuthGatewayServerHandle, AuthGatewayServerOptions, AuthGatewayParsedRequest as ParsedFormatRequest } from "./types";
23
23
  export type ModelResolver = (modelId: string) => Model<Api> | undefined;
24
24
  export interface AuthGatewayBootOptions extends AuthGatewayServerOptions {
@@ -68,5 +68,27 @@ export declare function createAuthGatewayModelCatalog(provider: Provider, models
68
68
  export declare function releaseGatewayCredentialLeaseOnAdmission(events: Pick<AssistantMessageEventStream, "result">, release: () => void, signal?: AbortSignal): void;
69
69
  /** Test seam for verifying translated gateway requests never acquire agent-owned provider identity. */
70
70
  export declare function buildAuthGatewayStreamOptionsForTest(parsed: ParsedFormatRequest, api: Api): SimpleStreamOptions;
71
+ /**
72
+ * Classify a terminal {@link AssistantMessage} that failed into a wire envelope.
73
+ *
74
+ * Shared by both non-streaming handlers so a repetition stop cannot be reported
75
+ * as a client cancellation on one path and an upstream error on the other.
76
+ * Exported for tests.
77
+ */
78
+ export declare function classifyGatewayMessageFailure(message: Pick<AssistantMessage, "stopReason" | "errorCode">, safeErrorMessage: string): {
79
+ status: number;
80
+ type: string;
81
+ message: string;
82
+ };
83
+ /**
84
+ * Protect all gateway SSE encoders from upstream error text. Provider wire
85
+ * modules format `error` events themselves, so sanitize at this boundary and
86
+ * also convert iterator failures to bounded errors before their catch blocks.
87
+ *
88
+ * Exported for tests: this is the only place the streaming path scrubs error
89
+ * text, so the assertion that a repetition sample never reaches the wire has to
90
+ * drive it directly.
91
+ */
92
+ export declare function redactGatewayStream(events: AssistantMessageEventStream): AssistantMessageEventStream;
71
93
  export declare function startAuthGateway(opts: AuthGatewayBootOptions): AuthGatewayServerHandle;
72
94
  export declare function isSafeProviderScope(provider: unknown): provider is string;
@@ -292,6 +292,10 @@ export interface AuthCredentialStore {
292
292
  includeExpired?: boolean;
293
293
  }): string | null;
294
294
  setCache(key: string, value: string, expiresAtSec: number): void;
295
+ /** Atomically claim a cross-process usage poll lease for a bounded interval. */
296
+ tryAcquireUsageFetchLease?(key: string, owner: string, nowMs: number, leaseMs: number): boolean | undefined;
297
+ /** Release a usage poll lease owned by this process. */
298
+ releaseUsageFetchLease?(key: string, owner: string): void;
295
299
  /** Atomically allocate a durable sequence for broker restart epochs. */
296
300
  allocateMonotonicSequence(key: string, expiresAtSec: number): number;
297
301
  deleteCachePrefix?(prefix: string): void;
@@ -856,10 +860,15 @@ export declare class AuthStorage {
856
860
  fetchUsageReports(options?: {
857
861
  provider?: Provider;
858
862
  baseUrlResolver?: (provider: Provider) => string | undefined;
859
- /** Caller's cancel signal; only rejects this caller, never the shared upstream fetch. */
863
+ /** Caller’s cancel signal; only rejects this caller, never the shared upstream fetch. */
860
864
  signal?: AbortSignal;
861
865
  /** Disable provider/account/error logging for secret-safe control surfaces. */
862
866
  logDetails?: boolean;
867
+ /**
868
+ * Enable identity correlation diagnostics. Values are one-way hashed and
869
+ * URLs are reduced to their origin; the default logs provider/type/count only.
870
+ */
871
+ logIdentity?: boolean;
863
872
  }): Promise<UsageReport[] | null>;
864
873
  /**
865
874
  * Probe each stored credential against its provider's auth-verifying usage
@@ -1061,6 +1070,8 @@ export declare class SqliteAuthCredentialStore implements AuthCredentialStore {
1061
1070
  includeExpired?: boolean;
1062
1071
  }): string | null;
1063
1072
  setCache(key: string, value: string, expiresAtSec: number): void;
1073
+ tryAcquireUsageFetchLease(key: string, owner: string, nowMs: number, leaseMs: number): boolean | undefined;
1074
+ releaseUsageFetchLease(key: string, owner: string): void;
1064
1075
  allocateMonotonicSequence(key: string, expiresAtSec: number): number;
1065
1076
  deleteCachePrefix(prefix: string): void;
1066
1077
  cleanExpiredCache(): void;
@@ -107,7 +107,7 @@ export interface CpaToolAliasRestoreFailure {
107
107
  */
108
108
  export declare function parseCpaToolAliasRestoreFailure(error: unknown): CpaToolAliasRestoreFailure | undefined;
109
109
  export declare function isCpaToolAliasRestoreFailure(error: unknown): boolean;
110
- export declare const claudeCodeVersion = "2.1.273";
110
+ export declare const claudeCodeVersion = "2.1.280";
111
111
  export declare const claudeCodeEntrypoint = "sdk-cli";
112
112
  export declare const claudeToolPrefix: string;
113
113
  export declare const claudeCodeSystemInstruction = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
@@ -73,6 +73,8 @@ export declare function createCursorMessageQueueForTest(onError?: (error: unknow
73
73
  };
74
74
  /** Exported for direct regression coverage of the JSON-safety boundary. */
75
75
  export declare function cursorJsonSafeValueForTest(value: unknown): unknown;
76
+ /** Exported for direct regression coverage of the Cursor serialization boundary. */
77
+ export declare function cursorJsonSafeStringifyForTest(value: unknown): string;
76
78
  export declare function buildNativeToolCallBlock(toolCall: Record<string, unknown>, callId: string, index: number): ToolCallState | null;
77
79
  /** Derive prompt usage from Cursor's whole-conversation checkpoint total. */
78
80
  export declare function finalizeCursorUsage(output: AssistantMessage, usageState: UsageState): void;
@@ -103,6 +105,14 @@ export declare function finalizeCursorUsageForTest(usedTokens: number, outputTok
103
105
  export declare function buildCursorSystemPromptJsons(systemPrompt: readonly string[] | undefined, modelId?: string): string[];
104
106
  /** Exported for regression coverage of the tool usage-cache identity boundary. */
105
107
  export declare function buildCursorUsageToolsKeyForTest(tools: Tool[]): string;
108
+ /** Exported for regression coverage of the generic tool-schema wire boundary. */
109
+ export declare function buildCursorWireToolIdentitiesForTest(tools: Tool[]): Array<{
110
+ name: string;
111
+ description: string;
112
+ inputSchema: JsonValue;
113
+ }>;
114
+ /** Exported for regression coverage of lossless conversation identity hashing. */
115
+ export declare function hashCursorConversationValueForTest(value: unknown): string;
106
116
  /** Exported for tests: decodes Cursor history blobs built from conversation messages. */
107
117
  export declare function buildCursorHistoryForTest(messages: Message[]): {
108
118
  rootPromptMessagesJson: unknown[];
@@ -1,5 +1,5 @@
1
1
  import type { ChatCompletionMessageParam } from "openai/resources/chat/completions";
2
- import { type AssistantMessage, type Context, type Model, type ServiceTier, type StreamFunction, type StreamOptions, type ToolChoice } from "../types";
2
+ import { type AssistantMessage, type Context, type Model, type RepetitionGuardOptions, type ServiceTier, type StreamFunction, type StreamOptions, type ToolChoice } from "../types";
3
3
  import { type ResolvedOpenAICompat } from "./openai-completions-compat";
4
4
  /** Test seam: the provider base URL as resolved from trusted env. */
5
5
  export declare function resolveOpenAICompletionsBaseUrlForTest(baseUrl: string | undefined, authCredentialType: "api_key" | "oauth" | undefined): string;
@@ -23,6 +23,14 @@ export interface OpenAICompletionsOptions extends StreamOptions {
23
23
  /** Force-disable reasoning where supported, or request the lowest effort on generic effort endpoints. */
24
24
  disableReasoning?: boolean;
25
25
  serviceTier?: ServiceTier;
26
+ /**
27
+ * Runaway-repetition guard thresholds, per stream channel. A number sets the
28
+ * consecutive-repeat threshold; `false` disables the channel's guard.
29
+ * Defaults: thinking = DEFAULT_REPETITION_THRESHOLD, text = false — visible
30
+ * output is a deliverable and intentional repetition there (logs, fixtures,
31
+ * tables, generated code) must survive byte for byte (#5627).
32
+ */
33
+ repetitionGuard?: RepetitionGuardOptions;
26
34
  }
27
35
  export declare const streamOpenAICompletions: StreamFunction<"openai-completions">;
28
36
  export declare function parseChunkUsage(rawUsage: object, model: Model<"openai-completions">, premiumRequests: number | undefined): AssistantMessage["usage"];
@@ -338,6 +338,14 @@ export interface AttemptScopeRef {
338
338
  readonly generation: number;
339
339
  readonly lineage: string;
340
340
  }
341
+ /**
342
+ * Runaway-repetition guard thresholds, per stream channel. A number sets the
343
+ * consecutive-repeat threshold; `false` disables that channel's guard.
344
+ */
345
+ export interface RepetitionGuardOptions {
346
+ thinking?: number | false;
347
+ text?: number | false;
348
+ }
341
349
  export interface SimpleStreamOptions extends StreamOptions {
342
350
  reasoning?: Effort;
343
351
  /**
@@ -373,6 +381,14 @@ export interface SimpleStreamOptions extends StreamOptions {
373
381
  syntheticApiFormat?: "openai" | "anthropic";
374
382
  /** Hint that websocket transport should be preferred when supported by the provider implementation. */
375
383
  preferWebsockets?: boolean;
384
+ /**
385
+ * Runaway-repetition guard thresholds, per stream channel. Honoured by the
386
+ * openai-completions transport; ignored by providers without a guard.
387
+ * Defaults: thinking = DEFAULT_REPETITION_THRESHOLD, text = false — visible
388
+ * output is a deliverable and intentional repetition there (logs, fixtures,
389
+ * tables, generated code) must survive byte for byte (#5627).
390
+ */
391
+ repetitionGuard?: RepetitionGuardOptions;
376
392
  }
377
393
  export type StreamFunction<TApi extends Api> = (model: Model<TApi>, context: Context, options: OptionsForApi<TApi>) => AssistantMessageEventStream;
378
394
  export interface TextSignatureV1 {
@@ -1,4 +1,12 @@
1
1
  import type { Api, FetchImpl, Model, Provider } from "../../types";
2
+ /**
3
+ * Shared `/models` request deadline for setup-time probing and runtime
4
+ * discovery. One policy so an endpoint that passes setup validation cannot
5
+ * be unreachable under the runtime deadline (previously 10s vs 5s: a 6s
6
+ * endpoint passed setup, saved discovery-only config, then stayed
7
+ * unavailable at runtime with no recovery hint).
8
+ */
9
+ export declare const MODELS_LIST_REQUEST_TIMEOUT_MS = 10000;
2
10
  /** Catalog identities are rendered and used for routing; unsafe values are dropped, never rewritten. */
3
11
  export declare function isSafeCatalogModelId(value: unknown): value is string;
4
12
  /**
@@ -100,3 +108,5 @@ export declare function resolveLoopbackOpenAIBaseUrl(value: string | undefined,
100
108
  * Returns `[]` only when the endpoint responds successfully with no usable models.
101
109
  */
102
110
  export declare function fetchOpenAICompatibleModels<TApi extends Api>(options: FetchOpenAICompatibleModelsOptions<TApi>): Promise<Model<TApi>[] | null>;
111
+ /** Bounded JSON body reader for `/models` responses (shared by runtime and setup probe). */
112
+ export declare function readBoundedModelsJson(response: Response): Promise<unknown>;
@@ -43,6 +43,10 @@ export type TransportHeaders = Headers | Record<string, string | undefined>;
43
43
  */
44
44
  export interface TransportFailureFacts {
45
45
  kind: "transport";
46
+ /** Diagnostic HTTP/2 reset code; not HTTP status or retry authority. */
47
+ http2RstCode?: number;
48
+ /** Native HTTP/2 error code; diagnostic only, not retry authority. */
49
+ nativeErrorCode?: string;
46
50
  status?: number;
47
51
  /** Canonical provider error code used for fallback classification. */
48
52
  providerCode?: string;
@@ -11,13 +11,14 @@
11
11
  * Some HTTPS endpoints (e.g. corporate API gateways behind reverse proxies)
12
12
  * advertise h2 via ALPN but then refuse or reset the connection at the HTTP/2
13
13
  * framing layer. Bun surfaces these as `ConnectionRefused`, `ConnectionReset`,
14
- * or `ConnectionClosed` rather than `HTTP2Unsupported`, so we treat those
15
- * codes as h2-fallback triggers as well. `ConnectionRefused` is raised before
16
- * the request is written, but a reset or a close does not prove the peer never
17
- * consumed the body — it may have processed the request and died before
18
- * answering. Replaying those two on h1 would duplicate the side effect, so
19
- * `ConnectionReset` and `ConnectionClosed` fall back only for replay-safe
20
- * methods; anything else rethrows the original error.
14
+ * `ConnectionClosed`, or `HTTP2StreamReset` rather than `HTTP2Unsupported`.
15
+ * `ConnectionRefused` is raised before the request is written, while
16
+ * `HTTP2RefusedStream` is the explicit HTTP/2 promise that a stream was never
17
+ * processed (including a stream above a graceful GOAWAY last-stream-id).
18
+ * `ConnectionReset`, `ConnectionClosed`, and a generic `HTTP2StreamReset` do
19
+ * not carry that promise: the peer may have consumed the body before the
20
+ * connection failed. Retrying those codes would duplicate non-idempotent side
21
+ * effects, so they preserve the original error instead of falling back.
21
22
  *
22
23
  * ALPN-refusing hosts (notably zcode.z.ai, the GLM ZCode OAuth broker) abort
23
24
  * the TLS handshake entirely when the client offers ALPN h2. Bun reports that
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Runaway-repetition detector for a model's streamed text or thinking channel.
3
+ *
4
+ * Some models fall into a decode loop and emit the same sentence — or the same
5
+ * short token run — until the turn's budget runs out. Nothing errors: the tool
6
+ * calls in the same message still execute, and the transcript just fills with
7
+ * dozens of identical lines (#5624).
8
+ *
9
+ * This is a pure state machine with no provider knowledge: `feed()` takes a
10
+ * chunk of streamed text and returns the prefix that is safe to emit. Until it
11
+ * trips, that prefix is the chunk itself, so a healthy stream passes through
12
+ * byte for byte. Once it trips, it emits nothing further and {@link takeTrip}
13
+ * hands the caller a one-shot signal to abort the request.
14
+ *
15
+ * Use one instance per stream **per channel**. Interleaving the visible-text
16
+ * and reasoning channels through a single instance would splice unrelated
17
+ * tokens into the same window and manufacture patterns that were never
18
+ * streamed.
19
+ */
20
+ /** Consecutive repeats of one unit that trip the guard. */
21
+ export declare const DEFAULT_REPETITION_THRESHOLD = 12;
22
+ /**
23
+ * Largest accepted repetition threshold.
24
+ *
25
+ * Token retention is `MAX_NGRAM_TOKENS * (threshold + 1)`, so capping the
26
+ * threshold is what makes the guard's memory bounded: at this cap it retains
27
+ * at most 64 * 129 = 8256 tokens, against 832 at the default. That is roughly
28
+ * ten times the default's headroom — generous for a caller who genuinely wants
29
+ * a laxer guard — while keeping a hostile or buggy option from turning the
30
+ * detector into an unbounded buffer (#5627 review r6).
31
+ */
32
+ export declare const MAX_REPETITION_THRESHOLD = 128;
33
+ /**
34
+ * `errorCode` stamped on a turn this guard stopped. A bounded classifier, never
35
+ * raw model text — consumers branch on it to tell a local decode-loop stop from
36
+ * a client cancellation or a transport fault (#5627).
37
+ */
38
+ export declare const REPETITION_GUARD_ERROR_CODE = "repetition_guard_tripped";
39
+ /**
40
+ * Wire-safe `errorMessage` for a turn this guard stopped. A literal with zero
41
+ * interpolation — not the sample, not the channel, not the repeat count.
42
+ *
43
+ * The auth gateway forwards `errorMessage` to API clients on the streaming path
44
+ * (`redactGatewayMessage` only strips credential-shaped text), so anything
45
+ * interpolated here is raw model output published verbatim. It also reaches
46
+ * `classifyGatewayError`, which keyword-matches on message text, so a repeated
47
+ * `quota` or `forbidden` in a sample could pick the HTTP status (#5627 r5).
48
+ *
49
+ * The repeated unit is not logged either: the provider logs bounded metadata
50
+ * only, because the default log transport persists metadata verbatim to a
51
+ * rotating file on disk (#5627 review r6). {@link StreamRepetitionTrip.sample}
52
+ * stays in memory for callers that want it.
53
+ */
54
+ export declare const REPETITION_GUARD_STOP_MESSAGE = "Stopped the turn: the model produced runaway repeated output.";
55
+ export type RepetitionUnitKind = "line" | "ngram";
56
+ export interface StreamRepetitionTrip {
57
+ /** Whether the repeats were whole lines or an n-gram inside one line. */
58
+ readonly kind: RepetitionUnitKind;
59
+ /** Consecutive repeats observed when the guard tripped. */
60
+ readonly repeats: number;
61
+ /** Normalized, truncated sample of the repeated unit, for diagnostics. */
62
+ readonly sample: string;
63
+ }
64
+ export interface StreamRepetitionGuardOptions {
65
+ /**
66
+ * Consecutive repeats that trip the guard. Defaults to 12. Normalized by
67
+ * {@link normalizeThreshold} — non-finite values fall back to the default,
68
+ * fractional values are floored, and the result is clamped into
69
+ * `[2, MAX_REPETITION_THRESHOLD]`.
70
+ */
71
+ readonly threshold?: number;
72
+ }
73
+ export declare class StreamRepetitionGuard {
74
+ #private;
75
+ constructor(options?: StreamRepetitionGuardOptions);
76
+ /**
77
+ * The threshold actually in force — the caller's option after
78
+ * {@link normalizeThreshold}, which may differ from what was passed.
79
+ */
80
+ get threshold(): number;
81
+ get tripped(): boolean;
82
+ get trip(): StreamRepetitionTrip | undefined;
83
+ /**
84
+ * Returns the trip exactly once, then `undefined` forever. Callers drive a
85
+ * one-shot side effect (aborting the request) off this, so the once-only
86
+ * latch lives here rather than being re-implemented at each call site.
87
+ */
88
+ takeTrip(): StreamRepetitionTrip | undefined;
89
+ /**
90
+ * Feed a chunk of streamed text. Returns the portion safe to emit: the whole
91
+ * chunk while healthy, the prefix up to the repeat that tripped the guard on
92
+ * the chunk that trips it, and nothing at all after that.
93
+ */
94
+ feed(text: string): string;
95
+ /**
96
+ * Close the in-progress unit at end of stream and run detection once more.
97
+ *
98
+ * `feed()` only closes a token on whitespace and a line on `\n`, so a stream
99
+ * whose final repeat arrives without a trailing newline left the last copy
100
+ * uncounted and the turn read as a healthy completion (#5627 review r5).
101
+ *
102
+ * Emits nothing — everything `feed()` returned has already been rendered by
103
+ * the time this runs. A trip found here therefore classifies the turn while
104
+ * the last copy is already on screen; that is intended. Idempotent.
105
+ */
106
+ finalize(): void;
107
+ }
@@ -17,6 +17,10 @@
17
17
  * the end of a chunk is held back until the next chunk arrives.
18
18
  */
19
19
  import { type UnicodeEscapeEvidence } from "./json-parse";
20
+ declare const TOKENS: readonly ["<|tool_calls_section_begin|>", "<|tool_calls_section_end|>", "<|tool_call_begin|>", "<|tool_call_end|>", "<|tool_call_argument_begin|>"];
21
+ /** Maximum buffered partial-token length before we give up holding back. */
22
+ declare const MAX_PARTIAL_HOLD = 64;
23
+ export { MAX_PARTIAL_HOLD as MAX_TOOL_FENCE_PARTIAL_HOLD, TOKENS as TOOL_FENCE_TOKENS };
20
24
  export interface HealedToolCall {
21
25
  readonly id: string;
22
26
  readonly name: string;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Streaming-safe removal of chat-template tool-call fence tokens.
3
+ *
4
+ * Unlike {@link ToolCallHealer}, this reconstructs nothing — it only deletes
5
+ * the markers. That makes it safe for the **reasoning channel**, where a leaked
6
+ * `<|tool_call_end|>` is pure noise: the structured `tool_calls` payload is the
7
+ * single source of truth, and the healer's doc comment warns that feeding the
8
+ * reasoning channel into its accumulator corrupts the holdback buffer (#5624).
9
+ *
10
+ * Deliberately NOT applied to the visible text channel: a fence token the
11
+ * assistant *talks about* in prose, outside an active section, must survive as
12
+ * text (see packages/ai/CHANGELOG.md:1094).
13
+ */
14
+ /** Remove every complete fence token from `text`. Pure; no stream state. */
15
+ export declare function stripToolFenceTokens(text: string): string;
16
+ /**
17
+ * Stateful wrapper around {@link stripToolFenceTokens} that holds back a
18
+ * partial token at the end of a chunk until the next chunk arrives, so a fence
19
+ * split across a streaming boundary is still removed. One instance per stream.
20
+ */
21
+ export declare class ToolFenceStripper {
22
+ #private;
23
+ /** Feed a chunk; returns the stripped text safe to emit now. */
24
+ feed(text: string): string;
25
+ /** Drain any held-back partial at end of stream. It never completed, so emit it. */
26
+ flush(): string;
27
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/ai",
4
- "version": "0.17.2",
4
+ "version": "0.17.5",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -41,8 +41,8 @@
41
41
  "@agentclientprotocol/sdk": "1.3.0",
42
42
  "@anthropic-ai/sdk": "^0.94.0",
43
43
  "@bufbuild/protobuf": "^2.12.0",
44
- "@gajae-code/natives": "0.17.2",
45
- "@gajae-code/utils": "0.17.2",
44
+ "@gajae-code/natives": "0.17.5",
45
+ "@gajae-code/utils": "0.17.5",
46
46
  "openai": "^6.36.0",
47
47
  "partial-json": "^0.1.7",
48
48
  "zod": "4.4.3"
@@ -40,6 +40,7 @@ import type {
40
40
  } from "../types";
41
41
  import { beginAttempt, classifyFallbackTrigger } from "../utils/fallback-transport";
42
42
  import { assertAuthenticatedOrLoopback, parseBind } from "../utils/parse-bind";
43
+ import { REPETITION_GUARD_ERROR_CODE } from "../utils/stream-repetition-guard";
43
44
  import {
44
45
  captureRequestHeaders,
45
46
  corsHeaders,
@@ -520,6 +521,38 @@ function classifyGatewayError(err: unknown): { status: number; type: string; mes
520
521
  return { status: 502, type: "upstream_error", message };
521
522
  }
522
523
 
524
+ /**
525
+ * Fixed envelope for a turn the streamed repetition guard stopped. The message
526
+ * is a literal on purpose: {@link classifyGatewayError} keyword-matches on
527
+ * message text (`invalid`, `quota`, `rate`, `forbidden`, …) and the guard's
528
+ * diagnostic carries a sample of raw model output, so routing it through that
529
+ * regex would let a repeated word in the sample pick the HTTP status (#5627).
530
+ */
531
+ const REPETITION_GUARD_GATEWAY_ERROR = {
532
+ status: 502,
533
+ type: "upstream_error",
534
+ message: "Upstream model produced runaway repeated output and the turn was stopped",
535
+ } as const;
536
+
537
+ /**
538
+ * Classify a terminal {@link AssistantMessage} that failed into a wire envelope.
539
+ *
540
+ * Shared by both non-streaming handlers so a repetition stop cannot be reported
541
+ * as a client cancellation on one path and an upstream error on the other.
542
+ * Exported for tests.
543
+ */
544
+ export function classifyGatewayMessageFailure(
545
+ message: Pick<AssistantMessage, "stopReason" | "errorCode">,
546
+ safeErrorMessage: string,
547
+ ): { status: number; type: string; message: string } {
548
+ // Checked before `aborted` as well as before the keyword classifier: a
549
+ // local decode-loop stop is never a user cancellation, whatever stop reason
550
+ // the provider chose to carry it on.
551
+ if (message.errorCode === REPETITION_GUARD_ERROR_CODE) return { ...REPETITION_GUARD_GATEWAY_ERROR };
552
+ if (message.stopReason === "aborted") return { status: 499, type: "request_aborted", message: safeErrorMessage };
553
+ return classifyGatewayError(new Error(safeErrorMessage));
554
+ }
555
+
523
556
  function redactGatewayError(error: unknown): Error {
524
557
  const redacted = new Error(cleanReason(error) ?? "Upstream request failed");
525
558
  if (error instanceof Error) {
@@ -531,6 +564,14 @@ function redactGatewayError(error: unknown): Error {
531
564
  }
532
565
 
533
566
  function redactGatewayMessage(message: AssistantMessage): AssistantMessage {
567
+ // A repetition stop is replaced wholesale, before `cleanReason` ever sees it.
568
+ // `cleanReason` only strips credential-shaped text, so ordinary model prose
569
+ // survives it verbatim — and this message's free-form half is a sample of raw
570
+ // model output. Reuses the fixed envelope the non-streaming path already
571
+ // returns, so both paths publish the same bounded string (#5627 review r5).
572
+ if (message.errorCode === REPETITION_GUARD_ERROR_CODE) {
573
+ return { ...message, errorMessage: REPETITION_GUARD_GATEWAY_ERROR.message };
574
+ }
534
575
  if (message.errorMessage === undefined) return message;
535
576
  return { ...message, errorMessage: cleanReason(message.errorMessage) ?? "Upstream request failed" };
536
577
  }
@@ -539,8 +580,12 @@ function redactGatewayMessage(message: AssistantMessage): AssistantMessage {
539
580
  * Protect all gateway SSE encoders from upstream error text. Provider wire
540
581
  * modules format `error` events themselves, so sanitize at this boundary and
541
582
  * also convert iterator failures to bounded errors before their catch blocks.
583
+ *
584
+ * Exported for tests: this is the only place the streaming path scrubs error
585
+ * text, so the assertion that a repetition sample never reaches the wire has to
586
+ * drive it directly.
542
587
  */
543
- function redactGatewayStream(events: AssistantMessageEventStream): AssistantMessageEventStream {
588
+ export function redactGatewayStream(events: AssistantMessageEventStream): AssistantMessageEventStream {
544
589
  async function* redactedEvents(): AsyncGenerator<AssistantMessageEvent> {
545
590
  try {
546
591
  for await (const event of events) {
@@ -914,10 +959,7 @@ async function handleFormatEndpoint(
914
959
  error: safeErrorMessage,
915
960
  peer,
916
961
  });
917
- if (message.stopReason === "aborted") {
918
- return route.module.formatError(499, "request_aborted", safeErrorMessage);
919
- }
920
- const classified = classifyGatewayError(new Error(safeErrorMessage));
962
+ const classified = classifyGatewayMessageFailure(message, safeErrorMessage);
921
963
  return route.module.formatError(classified.status, classified.type, classified.message);
922
964
  }
923
965
  return json(200, route.module.encodeResponse(message, parsed.modelId));
@@ -1130,10 +1172,7 @@ async function handlePiNative(
1130
1172
  error: safeErrorMessage,
1131
1173
  peer,
1132
1174
  });
1133
- if (message.stopReason === "aborted") {
1134
- return piNative.formatError(499, "request_aborted", safeErrorMessage);
1135
- }
1136
- const classified = classifyGatewayError(new Error(safeErrorMessage));
1175
+ const classified = classifyGatewayMessageFailure(message, safeErrorMessage);
1137
1176
  return piNative.formatError(classified.status, classified.type, classified.message);
1138
1177
  }
1139
1178
  return json(200, { message });