@gajae-code/ai 0.17.1 → 0.17.4

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 (47) hide show
  1. package/CHANGELOG.md +117 -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/model-thinking.d.ts +10 -6
  5. package/dist/types/provider-models/openai-compat.d.ts +2 -2
  6. package/dist/types/providers/anthropic.d.ts +1 -1
  7. package/dist/types/providers/cursor.d.ts +10 -0
  8. package/dist/types/providers/openai-completions.d.ts +9 -1
  9. package/dist/types/types.d.ts +16 -0
  10. package/dist/types/utils/discovery/openai-compatible.d.ts +10 -0
  11. package/dist/types/utils/fallback-transport.d.ts +4 -0
  12. package/dist/types/utils/h2-fetch.d.ts +8 -2
  13. package/dist/types/utils/stream-repetition-guard.d.ts +107 -0
  14. package/dist/types/utils/tool-call-healing.d.ts +4 -0
  15. package/dist/types/utils/tool-fence-strip.d.ts +27 -0
  16. package/package.json +3 -3
  17. package/src/auth-gateway/server.ts +48 -9
  18. package/src/auth-storage.ts +185 -48
  19. package/src/model-manager.ts +11 -8
  20. package/src/model-pricing.ts +22 -0
  21. package/src/model-thinking.d.ts +10 -6
  22. package/src/model-thinking.ts +93 -11
  23. package/src/models.json +241 -15
  24. package/src/provider-models/openai-compat.ts +27 -19
  25. package/src/providers/anthropic.d.ts +1 -1
  26. package/src/providers/anthropic.ts +10 -2
  27. package/src/providers/cursor.d.ts +10 -0
  28. package/src/providers/cursor.ts +176 -31
  29. package/src/providers/openai-completions.d.ts +9 -1
  30. package/src/providers/openai-completions.ts +379 -128
  31. package/src/providers/openai-opencodex-responses.ts +15 -5
  32. package/src/stream.ts +24 -1
  33. package/src/types.d.ts +16 -0
  34. package/src/types.ts +17 -0
  35. package/src/utils/discovery/openai-compatible.ts +16 -2
  36. package/src/utils/fallback-transport.d.ts +4 -0
  37. package/src/utils/fallback-transport.ts +11 -0
  38. package/src/utils/h2-fetch.ts +70 -7
  39. package/src/utils/http-inspector.ts +4 -2
  40. package/src/utils/idle-iterator.ts +109 -96
  41. package/src/utils/json-parse.ts +12 -4
  42. package/src/utils/stream-repetition-guard.d.ts +107 -0
  43. package/src/utils/stream-repetition-guard.ts +290 -0
  44. package/src/utils/tool-call-healing.d.ts +4 -0
  45. package/src/utils/tool-call-healing.ts +4 -0
  46. package/src/utils/tool-fence-strip.d.ts +27 -0
  47. package/src/utils/tool-fence-strip.ts +64 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,123 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.17.4] - 2026-09-23
6
+
7
+ ### Added
8
+
9
+ - 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).
10
+
11
+ ### Changed
12
+
13
+ - 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.
14
+
15
+ ## [0.17.3] - 2026-09-22
16
+
17
+ ### Added
18
+
19
+ - 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.
20
+
21
+ ### Fixed
22
+
23
+ - Stop a runaway reasoning stream instead of rendering every repeat. When an
24
+ openai-compatible model falls into a decode loop and emits the same line — or
25
+ the same short token run — 12 times in a row on the reasoning channel, the turn
26
+ is now cut short with `stopReason: "error"` and
27
+ `errorCode: "repetition_guard_tripped"` rather than dumping dozens of identical
28
+ lines into the terminal. Tool calls in the same message still stream and
29
+ execute normally, including ones the model emits *after* the repeats.
30
+
31
+ The stop is classified as a provider error rather than `aborted`, so the auth
32
+ gateway renders it as HTTP 502 `upstream_error` and telemetry no longer counts
33
+ it as a user cancellation. A genuine caller abort still wins and still reports
34
+ `aborted`. The trip is terminal and is not auto-retried: a decode loop is
35
+ deterministic for the submitted context, so replaying it would re-trip the
36
+ guard and re-bill the full context on every attempt.
37
+
38
+ The guard applies to the reasoning channel only. Visible text is opt-in via the
39
+ new `repetitionGuard` option (`{ thinking?: number | false; text?: number |
40
+ false }`), because visible output is a deliverable and intentional repetition
41
+ there — log dumps, fixtures, tables, generated code — must survive byte for
42
+ byte.
43
+ - Keep a provider stall or transport error that lands *after* a repetition trip
44
+ classified as what it actually is. The guard drains the stream briefly after
45
+ tripping, and a fault arriving inside that window was being reported as a
46
+ decode loop — discarding the real error message, `errorStatus` and
47
+ `transportFailure`, and marking a retryable provider fault as terminal. The
48
+ guard's own abort is now tracked explicitly, so only it claims the trip.
49
+ - Keep the repeated sample out of error payloads. The guard's `errorMessage`
50
+ interpolated the repeated unit, the channel and the repeat count, and the auth
51
+ gateway forwards `errorMessage` to API clients on the streaming path — so raw
52
+ model output was published verbatim, and a repeated `quota` or `forbidden` in
53
+ the sample could steer the HTTP status the gateway picked. The message is now
54
+ a fixed literal at the provider, the gateway substitutes the same bounded
55
+ envelope its non-streaming path already used, and the sample survives only in
56
+ local `logger.debug` diagnostics.
57
+ - Classify a runaway turn whose final repeat arrives without a trailing newline.
58
+ The guard closed a line only on `\n` and a token only on whitespace, so a
59
+ stream that ended mid-unit left the last copy uncounted and the turn reported a
60
+ healthy completion. The guard is now finalized at end of stream — on the
61
+ normal-completion path only, so a stream that threw mid-repeat still keeps its
62
+ own transport facts.
63
+ - Bound the post-trip drain on *every* consumed chunk. The drain budget was only
64
+ spent by chunks that carried a usable `choices[0]`; usage-only, keepalive-shaped,
65
+ `choices`-less and malformed frames skipped the check entirely, so a provider
66
+ answering a tripped stream with those frames held the request open with no
67
+ bound at all. The check now runs exactly once per consumed chunk, still after
68
+ that chunk is fully processed so late tool-call frames are never cut mid-flight.
69
+ - Make `repetitionGuard` reachable from the public API. The option existed only
70
+ on the openai-completions provider type and was dropped by the
71
+ `streamSimple`/`completeSimple` options mapping, so callers on the normal path
72
+ could neither disable a channel nor change its threshold. It is now part of
73
+ `SimpleStreamOptions` (as the shared `RepetitionGuardOptions` type) and is
74
+ forwarded to the transport; defaults and semantics are unchanged.
75
+ - Validate the repetition threshold before it sizes the guard's state. Now that
76
+ `repetitionGuard` is public, a caller could pass `NaN` — which made every
77
+ comparison false and silently disabled detection with no error — or `Infinity`
78
+ or a huge value, which left detection permanently off *and* made the guard's
79
+ token retention unbounded on a long stream. A fractional threshold was also
80
+ never reached exactly by an integer repeat counter. The threshold is now
81
+ normalized at the constructor: non-finite values fall back to the default,
82
+ fractions are floored, and the result is clamped into `[2,
83
+ MAX_REPETITION_THRESHOLD]`, so tracking capacity is finite by construction.
84
+ Bad input normalizes rather than throwing — a failed request would be worse
85
+ than the guard running at its default.
86
+ - Stop persisting raw repeated model output in the default logs. The trip
87
+ diagnostic logged the repeated sample, and the default log transport is a
88
+ rotating file that JSON-stringifies metadata verbatim with no redaction, so a
89
+ model that looped on a secret or a private fragment of the prompt wrote it to
90
+ disk and into log rotation, support bundles and backups. The diagnostic now
91
+ carries bounded, derived metadata only (`sampleLength`, a number). The sample
92
+ remains on the in-memory trip object for callers.
93
+ - Strip leaked chat-template tool fences (`<|tool_call_end|>` and friends) from
94
+ rendered thinking, including fences split across streaming chunk boundaries.
95
+ The visible text channel is deliberately untouched, so a fence token the
96
+ assistant mentions in prose still survives as text.
97
+
98
+ - Serialize BigInt values safely at every Cursor payload and conversation-identity boundary without dropping generic tool-schema fields or truncating large schemas and contexts.
99
+
100
+ - 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.
101
+
102
+ - Preserve observed HTTP/2 reset and native error codes on Cursor failures without mutating native errors or replacing the first terminal diagnostic.
103
+
104
+ ### Performance
105
+
106
+ - 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.
107
+
108
+ ## [0.17.2] - 2026-09-18
109
+
110
+ ### Added
111
+
112
+ - Union Alpha Free on OpenCode Go and Zen with Anthropic Messages routing, image input, reasoning, and the published free-tier limits.
113
+
114
+ ### Fixed
115
+
116
+ - Share Cursor HTTP/2 write error and close listeners across pending frames to avoid listener-limit warnings during write bursts while preserving write-failure and drain-timeout handling.
117
+
118
+ - Discover local OpenCodex models through the public `/v1/models` endpoint instead of the admin-only management API, preserving public context, input, and reasoning capabilities.
119
+
120
+ - Preserve OpenCode protocol-specific base URLs during model discovery and recover reviewed Union Alpha limits from pre-catalogue discovery caches.
121
+
5
122
  ## [0.17.1] - 2026-09-17
6
123
 
7
124
  ## [0.17.0] - 2026-09-17
@@ -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;
@@ -28,14 +28,18 @@ export declare function enrichModelThinking<TApi extends Api>(model: ApiModel<TA
28
28
  * canonical rules, replacing any existing `thinking`.
29
29
  */
30
30
  export declare function refreshModelThinking<TApi extends Api>(model: ApiModel<TApi>): ApiModel<TApi>;
31
+ /**
32
+ * Native MiniMax thinking semantics, scoped to first-party regional routes.
33
+ * M3 supports adaptive/disabled; M2.x always thinks, even when disabled is sent.
34
+ * https://platform.minimax.io/docs/api-reference/text-openai-api#thinking-control
35
+ * https://platform.minimax.io/docs/api-reference/text-anthropic-api#thinking-control
36
+ */
37
+ export declare function getMiniMaxThinkingMode(model: ApiModel<Api>, resolvedBaseUrl?: string): "toggle" | "always-on" | undefined;
31
38
  /**
32
39
  * Returns whether the configured transport has an audited user-facing reasoning control.
33
- *
34
- * Custom OpenAI-compatible endpoints fail closed: declaring a model as reasoning-capable
35
- * is not enough to prove that the proxy accepts OpenAI reasoning parameters. Unknown
36
- * endpoints must opt in with `compat.supportsReasoningEffort: true`; providers using a
37
- * non-OpenAI request shape must also declare `compat.thinkingFormat`. Bundled providers
38
- * remain governed by their catalog and compatibility metadata.
40
+ * Custom OpenAI-compatible endpoints must opt in with supportsReasoningEffort and,
41
+ * for non-OpenAI request shapes, thinkingFormat. Native MiniMax switches are
42
+ * separate from reasoning_effort, which those endpoints do not support.
39
43
  */
40
44
  export declare function modelSupportsReasoningControl<TApi extends Api>(model: ApiModel<TApi>, resolvedBaseUrl?: string): boolean;
41
45
  /**
@@ -97,8 +97,8 @@ export interface OpenCodeModelManagerConfig {
97
97
  apiKey?: string;
98
98
  baseUrl?: string;
99
99
  }
100
- export declare function opencodeZenModelManagerOptions(config?: OpenCodeModelManagerConfig): ModelManagerOptions<"openai-completions">;
101
- export declare function opencodeGoModelManagerOptions(config?: OpenCodeModelManagerConfig): ModelManagerOptions<"openai-completions">;
100
+ export declare function opencodeZenModelManagerOptions(config?: OpenCodeModelManagerConfig): ModelManagerOptions<Api>;
101
+ export declare function opencodeGoModelManagerOptions(config?: OpenCodeModelManagerConfig): ModelManagerOptions<Api>;
102
102
  export declare function commandCodeModelManagerOptions(config?: OpenCodeModelManagerConfig): ModelManagerOptions<Api>;
103
103
  export interface OllamaModelManagerConfig {
104
104
  apiKey?: string;
@@ -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,8 +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.
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.
16
22
  *
17
23
  * ALPN-refusing hosts (notably zcode.z.ai, the GLM ZCode OAuth broker) abort
18
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.1",
4
+ "version": "0.17.4",
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.1",
45
- "@gajae-code/utils": "0.17.1",
44
+ "@gajae-code/natives": "0.17.4",
45
+ "@gajae-code/utils": "0.17.4",
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 });