@gajae-code/ai 0.15.2 → 0.15.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.15.3] - 2026-08-27
6
+
7
+ ### Fixed
8
+
9
+ - Valid JSON `\uXXXX` tool arguments now execute as their canonical decoded strings instead of entering the escaped-non-ASCII resample loop. Provider adapters retain guard metadata only for malformed JSON, duplicate/deep evidence, and unpaired UTF-16 surrogates, so those cases remain fail-closed while standard escaped Hangul, emoji surrogate pairs, and printable ASCII no longer consume retries or terminate managed runs.
10
+ - Explicit positive `maxTokens` values declared for custom `models.yml` models
11
+ and model overrides now reach the provider request across the shared stream
12
+ mapping (`max_tokens`, `max_completion_tokens`, and `max_output_tokens`).
13
+ Built-in and discovered metadata retain the conservative 32,000-token default,
14
+ while positive per-request overrides keep precedence; `compat.extraBody` is
15
+ not required to alter the wire budget.
16
+ - `/login vllm` no longer stores the `vllm-local` no-auth sentinel as a persisted API key. `AuthStorage.getApiKey()` resolves stored `api_key` credentials before environment variables, so a stored sentinel from an empty login could outrank a real `VLLM_API_KEY` env var and reach normal inference. `/login vllm` now requires a real API key (empty input throws `vLLM API key is required; local no-auth servers are discovered automatically`); local no-auth servers remain discovered automatically via the descriptor's `allowUnauthenticated` flag and need no login.
17
+ - `classifyContextOverflow` no longer misclassifies Anthropic context overflow as a terminal request error. Anthropic reports overflow inside a generic `invalid_request_error` envelope (`{"type":"invalid_request_error","message":"prompt is too long: 1158066 tokens > 1000000 maximum"}`), and that envelope type was listed as an authoritative non-overflow code, so it vetoed classification before the `prompt is too long` match could run. Overflow was then treated as a terminal 4xx and auto-compaction never ran. The envelope is now separated from cause-naming codes (auth, quota, rate limit), which keep full veto authority. To preserve the existing hostile-prose defense, only Anthropic's self-verifying measured form (`<used> tokens > <limit> maximum`, where the reported usage actually exceeds the reported maximum) can override the envelope; loose prose such as `too many tokens` still cannot flip a typed transport classification.
18
+ - The OpenAI↔Anthropic compatibility shim (Kimi Code, Synthetic) no longer drops the model's `thinking` metadata when routing an OpenAI-compatible model through the Anthropic Messages API. The shim rebuilt the Anthropic model descriptor field-by-field and omitted `thinking`, so a reasoning-capable model such as `kimi-code/k3` failed every effort-qualified request with `Model kimi-code/k3 is missing thinking metadata` (`getSupportedEfforts` throws when a reasoning model lacks thinking metadata). The descriptor now copies `model.thinking` through, restoring effort validation and the effort→`budget_tokens` mapping for the Anthropic-format route.
19
+
5
20
  ## [0.15.2] - 2026-08-25
6
21
 
7
22
  ### Changed
@@ -17,7 +32,9 @@
17
32
 
18
33
  ### Fixed
19
34
 
35
+ - Exact ID-selected stored literal API keys can now authorize existing model-cache provenance without resolving or exposing secret bytes; command-backed rows, stored environment references, OAuth, runtime overrides, and config overrides remain ineligible, while an explicitly selected stored row keeps precedence over provider environment fallback.
20
36
  - Tool-call parsing now preserves bounded, payload-free raw `\uXXXX` position/scalar evidence across provider adapters. Printable ASCII escapes are included because a one-nibble mutation can move an intended non-ASCII scalar into ASCII before decoded-value validation. Process-keyed scalar/path tags, raw and decoded offsets, value ordinals, duplicate-key/depth rejection, total-position accounting, and a process-local integrity tag make partial, altered, malformed, or overflowed evidence explicitly fail-closed without retaining recoverable argument characters or field names (#4927).
37
+ - Hid the non-callable `google-antigravity/gemini-3.7-flash-{low,medium,high}` selectors from bundled, dynamic, and cached catalogs after live Cloud Code Assist calls returned HTTP 404; `google-antigravity/gemini-3.7-flash-tiered` remains available and callable.
21
38
  - Ollama Cloud discovery now keeps curated output limits authoritative and gives unknown models a bounded 32,000-token fallback capped by their discovered context window. This replaces the truncation-prone 8,192 fallback without treating the server context length as a verified 131,072-token output capability or sending unbounded `num_predict` requests. Hosted cold starts and long prefills also receive a 300-second first-event window while explicit timeout overrides keep precedence (#4921).
22
39
  - Direct model selections now retry zero-token empty OpenAI-compatible responses.
23
40
  - OpenAI-compatible chat streams now replay an exact `finish_reason: "network_error"` only when no text, reasoning, refusal, or tool-call delta has been exposed. Retries honor `streamMaxRetries`, exponential backoff, caller cancellation, and managed-fallback ownership; failed-attempt usage, cost, response IDs, and partial chunks are discarded while terminal error wording remains compatible (#4918).
package/README.md CHANGED
@@ -1074,7 +1074,7 @@ The quickest way to authenticate:
1074
1074
  ```bash
1075
1075
  bunx @gajae-code/ai login # interactive provider selection
1076
1076
  bunx @gajae-code/ai login anthropic # login to specific provider
1077
- bunx @gajae-code/ai login vllm # store vLLM API key (or placeholder for local no-auth)
1077
+ bunx @gajae-code/ai login vllm # store vLLM API key (local no-auth servers are discovered automatically)
1078
1078
  bunx @gajae-code/ai login xai # sign in with xAI/Grok OAuth
1079
1079
  bunx @gajae-code/ai list # list available providers
1080
1080
  ```
@@ -580,6 +580,8 @@ export declare class AuthStorage {
580
580
  hasSessionCredentialAuto(provider: string, scopeId?: string): boolean;
581
581
  /** Resolve the effective selector precedence for a provider/scope. */
582
582
  resolveEffectiveCredentialSelector(provider: string, scopeId?: string, explicitSelector?: AuthCredentialSelector): AuthCredentialSelector | undefined;
583
+ /** @internal Return cache provenance for an exact stored literal API-key row without resolving its value. */
584
+ getStoredLiteralApiKeyEvidenceGeneration(provider: string, selector: AuthCredentialSelector): string | undefined;
583
585
  /** Validate and canonicalize an OAuth-only selector for account pinning. */
584
586
  resolveOAuthPinTarget(provider: string, selector: AuthCredentialSelector): OAuthPinTarget;
585
587
  /** Return all local inventory rows, including soft-disabled metadata, without payloads. */
@@ -1,4 +1,4 @@
1
- export declare const RETIRED_MODEL_KEYS: readonly ["google-antigravity/gemini-3.1-pro-high"];
1
+ export declare const RETIRED_MODEL_KEYS: readonly ["google-antigravity/gemini-3.1-pro-high", "google-antigravity/gemini-3.7-flash-high", "google-antigravity/gemini-3.7-flash-low", "google-antigravity/gemini-3.7-flash-medium"];
2
2
  export declare function isRetiredModelKey(provider: string, modelId: string): boolean;
3
3
  export declare function isRetiredModel(model: {
4
4
  provider: string;
@@ -41,3 +41,4 @@ export declare function completeSimple<TApi extends Api>(model: Model<TApi>, con
41
41
  export declare const OUTPUT_FALLBACK_BUFFER = 4000;
42
42
  export declare const ANTHROPIC_THINKING: Record<Effort, number>;
43
43
  export declare function mapAnthropicToolChoice(choice?: ToolChoice): AnthropicOptions["toolChoice"];
44
+ export declare function resolveDefaultRequestMaxTokens<TApi extends Api>(model: Model<TApi>, requested?: number): number;
@@ -405,11 +405,12 @@ export interface ToolCall {
405
405
  * Such a payload parses cleanly but
406
406
  * is unverifiable: one mistyped hex digit decodes to a different, equally
407
407
  * valid character, so the text can be silently wrong with no in-band evidence.
408
- * The agent loop resamples the turn unconditionally a bounded number of
409
- * times and then rejects the call instead of executing it. The single
410
- * bounded after-budget exception is a tool that enumerated its display
411
- * fields (`displaySafeEscapedArgFields`) whose non-ASCII content is benign
412
- * typographic punctuation — rendered question text, never executable
408
+ * The agent loop resamples the turn a bounded number of times and then
409
+ * rejects the call instead of executing it. The single bounded exception
410
+ * is a tool that enumerated its display-only fields
411
+ * (`displaySafeEscapedArgFields`): when every escaped scalar corroborates
412
+ * a decoded non-ASCII character inside those fields, the call executes
413
+ * with a warning instead — rendered question text, never executable
413
414
  * content, ids, or durable metadata.
414
415
  * Escapes that are required (control characters) or unavoidable (lone
415
416
  * surrogates) never set this.
@@ -972,6 +973,8 @@ export interface ModelRequestTransform {
972
973
  /** Extra request body fields merged after provider defaults; protected core request keys are ignored. */
973
974
  extraBody?: Record<string, unknown>;
974
975
  }
976
+ /** Provenance used when resolving a model's default request output budget. */
977
+ export type ModelMaxTokensSource = "configured" | "discovered";
975
978
  export interface ModelCost {
976
979
  input: number;
977
980
  output: number;
@@ -1006,6 +1009,8 @@ export interface Model<TApi extends Api = any> {
1006
1009
  premiumMultiplier?: number;
1007
1010
  contextWindow: number;
1008
1011
  maxTokens: number;
1012
+ /** Explicit models.yml/model-override authority; absent keeps the safe transport default. */
1013
+ maxTokensSource?: ModelMaxTokensSource;
1009
1014
  headers?: Record<string, string>;
1010
1015
  /**
1011
1016
  * Streaming transport override. When `"pi-native"`, `streamSimple` routes
@@ -64,7 +64,15 @@ export declare function findUnnecessaryUnicodeEscape(json: string): string | und
64
64
  * intentionally omitted to preserve `questions.question`-style field matching.
65
65
  */
66
66
  export declare function collectUnicodeEscapeEvidence(json: string): UnicodeEscapeEvidence | undefined;
67
- /** Attach bounded raw evidence while preserving the existing call-level guard flag. */
67
+ /**
68
+ * Return evidence only when decoded tool arguments are unsafe to execute.
69
+ *
70
+ * Valid JSON escapes and literal UTF-8 have the same canonical decoded value.
71
+ * Malformed JSON, duplicate/deep evidence, and unpaired UTF-16 surrogates keep
72
+ * the existing fail-closed path.
73
+ */
74
+ export declare function collectUnsafeUnicodeEscapeEvidence(json: string): UnicodeEscapeEvidence | undefined;
75
+ /** Attach unsafe raw evidence while preserving the existing call-level guard flag. */
68
76
  export declare function captureUnicodeEscapeEvidence(target: UnicodeEscapeEvidenceTarget, json: string): boolean;
69
77
  /** Attach evidence as transient, non-enumerable metadata excluded from serialization. */
70
78
  export declare function attachUnicodeEscapeEvidence(target: UnicodeEscapeEvidenceTarget, evidence: UnicodeEscapeEvidence): void;
@@ -10,7 +10,7 @@ import type { OAuthController } from "./types";
10
10
  /**
11
11
  * Login to vLLM.
12
12
  *
13
- * Opens vLLM OpenAI-compatible auth docs, prompts for an optional token,
14
- * and returns a stored key value.
13
+ * Opens vLLM OpenAI-compatible auth docs, prompts for a bearer token,
14
+ * and returns a stored key value. Local no-auth servers need no login.
15
15
  */
16
16
  export declare function loginVllm(options: OAuthController): Promise<string>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/ai",
4
- "version": "0.15.2",
4
+ "version": "0.15.3",
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",
@@ -40,8 +40,8 @@
40
40
  "dependencies": {
41
41
  "@anthropic-ai/sdk": "^0.94.0",
42
42
  "@bufbuild/protobuf": "^2.12.0",
43
- "@gajae-code/natives": "0.15.2",
44
- "@gajae-code/utils": "0.15.2",
43
+ "@gajae-code/natives": "0.15.3",
44
+ "@gajae-code/utils": "0.15.3",
45
45
  "openai": "^6.36.0",
46
46
  "partial-json": "^0.1.7",
47
47
  "zod": "4.4.3"
@@ -1359,6 +1359,24 @@ export class AuthStorage {
1359
1359
  getProviderEvidenceGeneration(provider: string, resolvedApiKey?: string): string {
1360
1360
  const storageProvider = resolveOAuthStorageProvider(provider);
1361
1361
  const evidenceApiKey = resolvedApiKey;
1362
+ const storedLiteral =
1363
+ evidenceApiKey === undefined ||
1364
+ this.#runtimeOverrides.has(storageProvider) ||
1365
+ this.#configOverrides.has(storageProvider)
1366
+ ? undefined
1367
+ : this.#getCredentialsForProvider(storageProvider).find(
1368
+ (credential): credential is Extract<AuthCredential, { type: "api_key" }> =>
1369
+ credential.type === "api_key" &&
1370
+ credential.key === evidenceApiKey &&
1371
+ !credential.key.startsWith("!") &&
1372
+ process.env[credential.key] === undefined,
1373
+ );
1374
+ if (storedLiteral) {
1375
+ return crypto
1376
+ .createHash("sha256")
1377
+ .update(`stored-literal\u0000${storageProvider}\u0000${storedLiteral.key}`)
1378
+ .digest("hex");
1379
+ }
1362
1380
  let selectedCredential: ({ index: number } & StoredCredential) | undefined;
1363
1381
  try {
1364
1382
  selectedCredential = this.#resolveSelectedStoredCredential(provider, undefined, undefined);
@@ -1588,6 +1606,18 @@ export class AuthStorage {
1588
1606
  );
1589
1607
  }
1590
1608
 
1609
+ /** @internal Return cache provenance for an exact stored literal API-key row without resolving its value. */
1610
+ getStoredLiteralApiKeyEvidenceGeneration(provider: string, selector: AuthCredentialSelector): string | undefined {
1611
+ if (selector.kind !== "id") return undefined;
1612
+ const storageProvider = resolveOAuthStorageProvider(provider);
1613
+ if (this.#runtimeOverrides.has(storageProvider) || this.#configOverrides.has(storageProvider)) return undefined;
1614
+ const selected = this.#findCredentialBySelector(storageProvider, selector);
1615
+ if (selected?.credential.type !== "api_key") return undefined;
1616
+ const key = selected.credential.key;
1617
+ if (!key || key.startsWith("!") || process.env[key] !== undefined) return undefined;
1618
+ return this.getProviderEvidenceGeneration(storageProvider, key);
1619
+ }
1620
+
1591
1621
  /** Validate and canonicalize an OAuth-only selector for account pinning. */
1592
1622
  resolveOAuthPinTarget(provider: string, selector: AuthCredentialSelector): OAuthPinTarget {
1593
1623
  const storageProvider = resolveOAuthStorageProvider(provider);
@@ -1,4 +1,4 @@
1
- export declare const RETIRED_MODEL_KEYS: readonly ["google-antigravity/gemini-3.1-pro-high"];
1
+ export declare const RETIRED_MODEL_KEYS: readonly ["google-antigravity/gemini-3.1-pro-high", "google-antigravity/gemini-3.7-flash-high", "google-antigravity/gemini-3.7-flash-low", "google-antigravity/gemini-3.7-flash-medium"];
2
2
  export declare function isRetiredModelKey(provider: string, modelId: string): boolean;
3
3
  export declare function isRetiredModel(model: {
4
4
  provider: string;
@@ -1,6 +1,12 @@
1
- // Retired from advertised catalogs because Cloud Code Assist rejects live calls
2
- // with HTTP 400. The callable high-thinking path is gemini-3.1-pro-low:high.
3
- export const RETIRED_MODEL_KEYS = ["google-antigravity/gemini-3.1-pro-high"] as const;
1
+ // Retired from advertised catalogs because Cloud Code Assist rejects live calls.
2
+ // Keep the callable alternatives visible: gemini-3.1-pro-low:high and
3
+ // gemini-3.7-flash-tiered.
4
+ export const RETIRED_MODEL_KEYS = [
5
+ "google-antigravity/gemini-3.1-pro-high",
6
+ "google-antigravity/gemini-3.7-flash-high",
7
+ "google-antigravity/gemini-3.7-flash-low",
8
+ "google-antigravity/gemini-3.7-flash-medium",
9
+ ] as const;
4
10
 
5
11
  const RETIRED_MODEL_KEY_SET = new Set<string>(RETIRED_MODEL_KEYS);
6
12
 
package/src/models.json CHANGED
@@ -15785,31 +15785,6 @@
15785
15785
  "maxLevel": "high"
15786
15786
  }
15787
15787
  },
15788
- "gemini-3.7-flash-high": {
15789
- "id": "gemini-3.7-flash-high",
15790
- "name": "Gemini 3.7 Flash (High) (Antigravity)",
15791
- "api": "google-gemini-cli",
15792
- "provider": "google-antigravity",
15793
- "baseUrl": "https://daily-cloudcode-pa.sandbox.googleapis.com",
15794
- "reasoning": true,
15795
- "input": [
15796
- "text",
15797
- "image"
15798
- ],
15799
- "cost": {
15800
- "input": 0,
15801
- "output": 0,
15802
- "cacheRead": 0,
15803
- "cacheWrite": 0
15804
- },
15805
- "contextWindow": 1048576,
15806
- "maxTokens": 65536,
15807
- "thinking": {
15808
- "mode": "google-level",
15809
- "minLevel": "low",
15810
- "maxLevel": "high"
15811
- }
15812
- },
15813
15788
  "gemini-3.6-flash-low": {
15814
15789
  "id": "gemini-3.6-flash-low",
15815
15790
  "name": "Gemini 3.6 Flash (Low) (Antigravity)",
@@ -15835,31 +15810,6 @@
15835
15810
  "maxLevel": "high"
15836
15811
  }
15837
15812
  },
15838
- "gemini-3.7-flash-low": {
15839
- "id": "gemini-3.7-flash-low",
15840
- "name": "Gemini 3.7 Flash (Low) (Antigravity)",
15841
- "api": "google-gemini-cli",
15842
- "provider": "google-antigravity",
15843
- "baseUrl": "https://daily-cloudcode-pa.sandbox.googleapis.com",
15844
- "reasoning": true,
15845
- "input": [
15846
- "text",
15847
- "image"
15848
- ],
15849
- "cost": {
15850
- "input": 0,
15851
- "output": 0,
15852
- "cacheRead": 0,
15853
- "cacheWrite": 0
15854
- },
15855
- "contextWindow": 1048576,
15856
- "maxTokens": 65536,
15857
- "thinking": {
15858
- "mode": "google-level",
15859
- "minLevel": "low",
15860
- "maxLevel": "high"
15861
- }
15862
- },
15863
15813
  "gemini-3.6-flash-medium": {
15864
15814
  "id": "gemini-3.6-flash-medium",
15865
15815
  "name": "Gemini 3.6 Flash (Medium) (Antigravity)",
@@ -15885,31 +15835,6 @@
15885
15835
  "maxLevel": "high"
15886
15836
  }
15887
15837
  },
15888
- "gemini-3.7-flash-medium": {
15889
- "id": "gemini-3.7-flash-medium",
15890
- "name": "Gemini 3.7 Flash (Medium) (Antigravity)",
15891
- "api": "google-gemini-cli",
15892
- "provider": "google-antigravity",
15893
- "baseUrl": "https://daily-cloudcode-pa.sandbox.googleapis.com",
15894
- "reasoning": true,
15895
- "input": [
15896
- "text",
15897
- "image"
15898
- ],
15899
- "cost": {
15900
- "input": 0,
15901
- "output": 0,
15902
- "cacheRead": 0,
15903
- "cacheWrite": 0
15904
- },
15905
- "contextWindow": 1048576,
15906
- "maxTokens": 65536,
15907
- "thinking": {
15908
- "mode": "google-level",
15909
- "minLevel": "low",
15910
- "maxLevel": "high"
15911
- }
15912
- },
15913
15838
  "gemini-3.6-flash-tiered": {
15914
15839
  "id": "gemini-3.6-flash-tiered",
15915
15840
  "name": "gemini-3.6-flash-tiered",
@@ -93690,4 +93615,4 @@
93690
93615
  }
93691
93616
  }
93692
93617
  }
93693
- }
93618
+ }
@@ -3320,7 +3320,22 @@ function ensureMaxTokensForThinking(params: MessageCreateParamsStreaming, model:
3320
3320
  const maxTokens = params.max_tokens ?? 0;
3321
3321
  const requiredMaxTokens = budgetTokens + OUTPUT_FALLBACK_BUFFER;
3322
3322
  if (maxTokens < requiredMaxTokens) {
3323
- params.max_tokens = Math.min(requiredMaxTokens, model.maxTokens);
3323
+ const modelMaxTokens =
3324
+ Number.isSafeInteger(model.maxTokens) && model.maxTokens > 0
3325
+ ? model.maxTokens
3326
+ : Math.max(maxTokens, requiredMaxTokens);
3327
+ params.max_tokens = Math.min(requiredMaxTokens, modelMaxTokens);
3328
+ }
3329
+ // Anthropic requires budget_tokens strictly below max_tokens; when the cap
3330
+ // cannot fit the requested budget plus the output buffer, shrink the budget
3331
+ // (or disable thinking) instead of sending an invalid pair.
3332
+ const cappedBudget = params.max_tokens - OUTPUT_FALLBACK_BUFFER;
3333
+ if (cappedBudget < budgetTokens) {
3334
+ if (cappedBudget <= 0) {
3335
+ params.thinking = { type: "disabled" };
3336
+ } else {
3337
+ params.thinking = { type: "enabled", budget_tokens: cappedBudget };
3338
+ }
3324
3339
  }
3325
3340
  }
3326
3341
 
@@ -64,6 +64,7 @@ export function streamOpenAIAnthropicShim(
64
64
  contextWindow: model.contextWindow,
65
65
  maxTokens: model.maxTokens,
66
66
  reasoning: model.reasoning,
67
+ thinking: model.thinking,
67
68
  input: model.input,
68
69
  cost: model.cost,
69
70
  };
@@ -1637,7 +1637,14 @@ function buildParams(
1637
1637
  }
1638
1638
 
1639
1639
  if (compat.extraBody) {
1640
- Object.assign(params, compat.extraBody);
1640
+ // The resolved output limit owns the selected wire field; extraBody is a
1641
+ // free-form compatibility escape hatch and must not add a competing
1642
+ // max-token field or overwrite the resolved budget.
1643
+ const { max_tokens, max_completion_tokens, max_output_tokens, ...restExtra } = compat.extraBody as Record<
1644
+ string,
1645
+ unknown
1646
+ >;
1647
+ Object.assign(params, restExtra);
1641
1648
  }
1642
1649
  applyOpenAIRequestTransformBody(params, model.requestTransform);
1643
1650
  if (!supportsReasoningParams) {
package/src/stream.d.ts CHANGED
@@ -41,3 +41,4 @@ export declare function completeSimple<TApi extends Api>(model: Model<TApi>, con
41
41
  export declare const OUTPUT_FALLBACK_BUFFER = 4000;
42
42
  export declare const ANTHROPIC_THINKING: Record<Effort, number>;
43
43
  export declare function mapAnthropicToolChoice(choice?: ToolChoice): AnthropicOptions["toolChoice"];
44
+ export declare function resolveDefaultRequestMaxTokens<TApi extends Api>(model: Model<TApi>, requested?: number): number;
package/src/stream.ts CHANGED
@@ -320,6 +320,14 @@ export function stream<TApi extends Api>(
320
320
  if (options?.fallbackManaged) {
321
321
  options = { ...options, requestMaxRetries: 0, streamMaxRetries: 0 } as OptionsForApi<TApi>;
322
322
  }
323
+ // Canonical low-level boundary: the request budget must be a positive safe
324
+ // integer. Provider options arrive here unvalidated (unlike `streamSimple`,
325
+ // whose resolver already normalizes), so an unsafe value is dropped to
326
+ // unspecified here once for every dispatch below — integer-only provider
327
+ // fields can never receive a fractional or MAX_SAFE_INTEGER+1 budget.
328
+ if (options?.maxTokens !== undefined && !(Number.isSafeInteger(options.maxTokens) && options.maxTokens > 0)) {
329
+ options = { ...options, maxTokens: undefined } as OptionsForApi<TApi>;
330
+ }
323
331
  // Check custom API registry first (extension-provided APIs like "vertex-Anthropic model-api")
324
332
  const customApiProvider = getCustomApi(model.api);
325
333
  if (customApiProvider) {
@@ -613,17 +621,18 @@ export function streamSimple<TApi extends Api>(
613
621
  // the gateway bearer instead. Comes BEFORE the custom-API check so
614
622
  // extension-registered APIs can't accidentally override a configured
615
623
  // pi-native transport.
624
+ const resolvedRequestMaxTokens = resolveDefaultRequestMaxTokens(model, options?.maxTokens);
616
625
  if (model.transport === "pi-native") {
617
626
  return streamFromLazyImport(async () => {
618
627
  const { streamPiNative } = await import("./providers/pi-native-client");
619
- return streamPiNative(model, context, options);
628
+ return streamPiNative(model, context, { ...options, maxTokens: resolvedRequestMaxTokens });
620
629
  }, options?.signal);
621
630
  }
622
631
 
623
632
  // Check custom API registry (extension-provided APIs)
624
633
  const customApiProvider = getCustomApi(model.api);
625
634
  if (customApiProvider) {
626
- return customApiProvider.streamSimple(model, context, options);
635
+ return customApiProvider.streamSimple(model, context, { ...options, maxTokens: resolvedRequestMaxTokens });
627
636
  }
628
637
 
629
638
  // Vertex AI uses Application Default Credentials, not API keys
@@ -643,6 +652,7 @@ export function streamSimple<TApi extends Api>(
643
652
  const adapterOptions = isProviderSafetyStopModelTrusted(model)
644
653
  ? withProviderSafetyStopAdapterInvocation(options ?? {})
645
654
  : options;
655
+ const resolvedSpecialProviderMaxTokens = resolvedRequestMaxTokens;
646
656
 
647
657
  // GitLab Duo - wraps Anthropic/OpenAI behind GitLab AI Gateway direct access tokens
648
658
  if (model.provider === "gitlab-duo") {
@@ -654,6 +664,7 @@ export function streamSimple<TApi extends Api>(
654
664
  copyProviderSafetyStopAdapterInvocation(adapterOptions, {
655
665
  ...adapterOptions,
656
666
  apiKey,
667
+ maxTokens: resolvedSpecialProviderMaxTokens,
657
668
  }),
658
669
  );
659
670
  }, options?.signal);
@@ -670,6 +681,7 @@ export function streamSimple<TApi extends Api>(
670
681
  copyProviderSafetyStopAdapterInvocation(adapterOptions, {
671
682
  ...adapterOptions,
672
683
  apiKey,
684
+ maxTokens: resolvedSpecialProviderMaxTokens,
673
685
  format: options?.kimiApiFormat ?? "anthropic",
674
686
  }),
675
687
  );
@@ -687,6 +699,7 @@ export function streamSimple<TApi extends Api>(
687
699
  copyProviderSafetyStopAdapterInvocation(adapterOptions, {
688
700
  ...adapterOptions,
689
701
  apiKey,
702
+ maxTokens: resolvedSpecialProviderMaxTokens,
690
703
  format: options?.syntheticApiFormat ?? "openai", // Default to OpenAI format
691
704
  }),
692
705
  );
@@ -707,6 +720,7 @@ export async function completeSimple<TApi extends Api>(
707
720
  }
708
721
 
709
722
  const MIN_OUTPUT_TOKENS = 1024;
723
+ const DEFAULT_REQUEST_MAX_TOKENS = 32000;
710
724
  export const OUTPUT_FALLBACK_BUFFER = 4000;
711
725
  const ANTHROPIC_USE_INTERLEAVED_THINKING = Bun.env.PI_NO_INTERLEAVED_THINKING !== "1";
712
726
 
@@ -804,6 +818,16 @@ function resolveOpenAiReasoningEffort<TApi extends Api>(
804
818
 
805
819
  const castApi = <TApi extends Api>(api: OptionsForApi<TApi>): OptionsForApi<Api> => api as OptionsForApi<Api>;
806
820
 
821
+ export function resolveDefaultRequestMaxTokens<TApi extends Api>(model: Model<TApi>, requested?: number): number {
822
+ if (requested !== undefined && Number.isSafeInteger(requested) && requested > 0) return requested;
823
+ if (model.maxTokensSource === "configured" && Number.isSafeInteger(model.maxTokens) && model.maxTokens > 0) {
824
+ return model.maxTokens;
825
+ }
826
+ return Number.isSafeInteger(model.maxTokens) && model.maxTokens > 0
827
+ ? Math.min(model.maxTokens, DEFAULT_REQUEST_MAX_TOKENS)
828
+ : DEFAULT_REQUEST_MAX_TOKENS;
829
+ }
830
+
807
831
  function mapOptionsForApi<TApi extends Api>(
808
832
  model: Model<TApi>,
809
833
  options?: SimpleStreamOptions,
@@ -816,7 +840,7 @@ function mapOptionsForApi<TApi extends Api>(
816
840
  minP: options?.minP,
817
841
  presencePenalty: options?.presencePenalty,
818
842
  repetitionPenalty: options?.repetitionPenalty,
819
- maxTokens: options?.maxTokens || Math.min(model.maxTokens, 32000),
843
+ maxTokens: resolveDefaultRequestMaxTokens(model, options?.maxTokens),
820
844
  signal: options?.signal,
821
845
  apiKey: apiKey || options?.apiKey,
822
846
  fallbackManaged: options?.fallbackManaged,
@@ -888,8 +912,13 @@ function mapOptionsForApi<TApi extends Api>(
888
912
  });
889
913
  }
890
914
 
891
- // Caller's maxTokens is the desired output; add thinking budget on top, capped at model limit
892
- const maxTokens = Math.min((base.maxTokens || 0) + thinkingBudget, model.maxTokens);
915
+ // Caller's maxTokens is the desired output; add thinking budget on top,
916
+ // capped at the model limit. `base.maxTokens` is already resolver-sanitized,
917
+ // so only a finite positive model cap participates (malformed metadata
918
+ // cannot reintroduce NaN into the wire budget).
919
+ const modelCap =
920
+ Number.isSafeInteger(model.maxTokens) && model.maxTokens > 0 ? model.maxTokens : base.maxTokens;
921
+ const maxTokens = Math.min((base.maxTokens || 0) + thinkingBudget, modelCap);
893
922
 
894
923
  // If not enough room for thinking + output, reduce thinking budget
895
924
  if (maxTokens <= thinkingBudget) {
@@ -1048,12 +1077,17 @@ function mapOptionsForApi<TApi extends Api>(
1048
1077
 
1049
1078
  let thinkingBudget = options.thinkingBudgets?.[effort] ?? GOOGLE_THINKING[effort];
1050
1079
 
1051
- // Caller's maxTokens is the desired output; add thinking budget on top, capped at model limit
1052
- const maxTokens = Math.min((base.maxTokens || 0) + thinkingBudget, model.maxTokens);
1080
+ // Caller's maxTokens is the desired output; add thinking budget on top,
1081
+ // capped at the model limit. `base.maxTokens` is already resolver-sanitized,
1082
+ // so only a finite positive model cap participates (malformed metadata
1083
+ // cannot reintroduce NaN into the wire budget).
1084
+ const modelCap =
1085
+ Number.isSafeInteger(model.maxTokens) && model.maxTokens > 0 ? model.maxTokens : base.maxTokens;
1086
+ const maxTokens = Math.min((base.maxTokens || 0) + thinkingBudget, modelCap);
1053
1087
 
1054
1088
  // If not enough room for thinking + output, reduce thinking budget
1055
1089
  if (maxTokens <= thinkingBudget) {
1056
- thinkingBudget = Math.max(0, maxTokens - MIN_OUTPUT_TOKENS) ?? 0;
1090
+ thinkingBudget = Math.max(0, maxTokens - MIN_OUTPUT_TOKENS);
1057
1091
  }
1058
1092
 
1059
1093
  // If thinking budget is too low, disable thinking
package/src/types.d.ts CHANGED
@@ -405,11 +405,12 @@ export interface ToolCall {
405
405
  * Such a payload parses cleanly but
406
406
  * is unverifiable: one mistyped hex digit decodes to a different, equally
407
407
  * valid character, so the text can be silently wrong with no in-band evidence.
408
- * The agent loop resamples the turn unconditionally a bounded number of
409
- * times and then rejects the call instead of executing it. The single
410
- * bounded after-budget exception is a tool that enumerated its display
411
- * fields (`displaySafeEscapedArgFields`) whose non-ASCII content is benign
412
- * typographic punctuation — rendered question text, never executable
408
+ * The agent loop resamples the turn a bounded number of times and then
409
+ * rejects the call instead of executing it. The single bounded exception
410
+ * is a tool that enumerated its display-only fields
411
+ * (`displaySafeEscapedArgFields`): when every escaped scalar corroborates
412
+ * a decoded non-ASCII character inside those fields, the call executes
413
+ * with a warning instead — rendered question text, never executable
413
414
  * content, ids, or durable metadata.
414
415
  * Escapes that are required (control characters) or unavoidable (lone
415
416
  * surrogates) never set this.
@@ -972,6 +973,8 @@ export interface ModelRequestTransform {
972
973
  /** Extra request body fields merged after provider defaults; protected core request keys are ignored. */
973
974
  extraBody?: Record<string, unknown>;
974
975
  }
976
+ /** Provenance used when resolving a model's default request output budget. */
977
+ export type ModelMaxTokensSource = "configured" | "discovered";
975
978
  export interface ModelCost {
976
979
  input: number;
977
980
  output: number;
@@ -1006,6 +1009,8 @@ export interface Model<TApi extends Api = any> {
1006
1009
  premiumMultiplier?: number;
1007
1010
  contextWindow: number;
1008
1011
  maxTokens: number;
1012
+ /** Explicit models.yml/model-override authority; absent keeps the safe transport default. */
1013
+ maxTokensSource?: ModelMaxTokensSource;
1009
1014
  headers?: Record<string, string>;
1010
1015
  /**
1011
1016
  * Streaming transport override. When `"pi-native"`, `streamSimple` routes
package/src/types.ts CHANGED
@@ -626,11 +626,12 @@ export interface ToolCall {
626
626
  * Such a payload parses cleanly but
627
627
  * is unverifiable: one mistyped hex digit decodes to a different, equally
628
628
  * valid character, so the text can be silently wrong with no in-band evidence.
629
- * The agent loop resamples the turn unconditionally a bounded number of
630
- * times and then rejects the call instead of executing it. The single
631
- * bounded after-budget exception is a tool that enumerated its display
632
- * fields (`displaySafeEscapedArgFields`) whose non-ASCII content is benign
633
- * typographic punctuation — rendered question text, never executable
629
+ * The agent loop resamples the turn a bounded number of times and then
630
+ * rejects the call instead of executing it. The single bounded exception
631
+ * is a tool that enumerated its display-only fields
632
+ * (`displaySafeEscapedArgFields`): when every escaped scalar corroborates
633
+ * a decoded non-ASCII character inside those fields, the call executes
634
+ * with a warning instead — rendered question text, never executable
634
635
  * content, ids, or durable metadata.
635
636
  * Escapes that are required (control characters) or unavoidable (lone
636
637
  * surrogates) never set this.
@@ -1178,6 +1179,9 @@ export interface ModelRequestTransform {
1178
1179
  extraBody?: Record<string, unknown>;
1179
1180
  }
1180
1181
 
1182
+ /** Provenance used when resolving a model's default request output budget. */
1183
+ export type ModelMaxTokensSource = "configured" | "discovered";
1184
+
1181
1185
  export interface ModelCost {
1182
1186
  input: number; // $/million tokens
1183
1187
  output: number; // $/million tokens
@@ -1214,6 +1218,8 @@ export interface Model<TApi extends Api = any> {
1214
1218
  premiumMultiplier?: number;
1215
1219
  contextWindow: number;
1216
1220
  maxTokens: number;
1221
+ /** Explicit models.yml/model-override authority; absent keeps the safe transport default. */
1222
+ maxTokensSource?: ModelMaxTokensSource;
1217
1223
  headers?: Record<string, string>;
1218
1224
  /**
1219
1225
  * Streaming transport override. When `"pi-native"`, `streamSimple` routes
@@ -64,7 +64,15 @@ export declare function findUnnecessaryUnicodeEscape(json: string): string | und
64
64
  * intentionally omitted to preserve `questions.question`-style field matching.
65
65
  */
66
66
  export declare function collectUnicodeEscapeEvidence(json: string): UnicodeEscapeEvidence | undefined;
67
- /** Attach bounded raw evidence while preserving the existing call-level guard flag. */
67
+ /**
68
+ * Return evidence only when decoded tool arguments are unsafe to execute.
69
+ *
70
+ * Valid JSON escapes and literal UTF-8 have the same canonical decoded value.
71
+ * Malformed JSON, duplicate/deep evidence, and unpaired UTF-16 surrogates keep
72
+ * the existing fail-closed path.
73
+ */
74
+ export declare function collectUnsafeUnicodeEscapeEvidence(json: string): UnicodeEscapeEvidence | undefined;
75
+ /** Attach unsafe raw evidence while preserving the existing call-level guard flag. */
68
76
  export declare function captureUnicodeEscapeEvidence(target: UnicodeEscapeEvidenceTarget, json: string): boolean;
69
77
  /** Attach evidence as transient, non-enumerable metadata excluded from serialization. */
70
78
  export declare function attachUnicodeEscapeEvidence(target: UnicodeEscapeEvidenceTarget, evidence: UnicodeEscapeEvidence): void;
@@ -484,9 +484,54 @@ export function collectUnicodeEscapeEvidence(json: string): UnicodeEscapeEvidenc
484
484
  return createUnicodeEscapeEvidence(positions, totalPositions, truncated, false);
485
485
  }
486
486
 
487
- /** Attach bounded raw evidence while preserving the existing call-level guard flag. */
487
+ function hasUnpairedUnicodeSurrogate(value: unknown): boolean {
488
+ const pending: unknown[] = [value];
489
+ const seen = new WeakSet<object>();
490
+ while (pending.length > 0) {
491
+ const current = pending.pop();
492
+ if (typeof current === "string") {
493
+ if (!current.isWellFormed()) return true;
494
+ continue;
495
+ }
496
+ if (typeof current !== "object" || current === null || seen.has(current)) continue;
497
+ seen.add(current);
498
+ if (Array.isArray(current)) {
499
+ for (const child of current) pending.push(child);
500
+ continue;
501
+ }
502
+ for (const [key, child] of Object.entries(current)) {
503
+ if (!key.isWellFormed()) return true;
504
+ pending.push(child);
505
+ }
506
+ }
507
+ return false;
508
+ }
509
+
510
+ /**
511
+ * Return evidence only when decoded tool arguments are unsafe to execute.
512
+ *
513
+ * Valid JSON escapes and literal UTF-8 have the same canonical decoded value.
514
+ * Malformed JSON, duplicate/deep evidence, and unpaired UTF-16 surrogates keep
515
+ * the existing fail-closed path.
516
+ */
517
+ export function collectUnsafeUnicodeEscapeEvidence(json: string): UnicodeEscapeEvidence | undefined {
518
+ const hasUnicodeEscape = json.includes("\\u");
519
+ if (!hasUnicodeEscape && json.isWellFormed()) return undefined;
520
+ const evidence = hasUnicodeEscape ? collectUnicodeEscapeEvidence(json) : undefined;
521
+ if (evidence?.malformed) return evidence;
522
+ try {
523
+ if (hasUnpairedUnicodeSurrogate(JSON.parse(json))) {
524
+ return createUnicodeEscapeEvidence([], 0, false, true);
525
+ }
526
+ } catch {
527
+ return createUnicodeEscapeEvidence([], 0, false, true);
528
+ }
529
+ return undefined;
530
+ }
531
+
532
+ /** Attach unsafe raw evidence while preserving the existing call-level guard flag. */
488
533
  export function captureUnicodeEscapeEvidence(target: UnicodeEscapeEvidenceTarget, json: string): boolean {
489
- const evidence = collectUnicodeEscapeEvidence(json);
534
+ const evidence = collectUnsafeUnicodeEscapeEvidence(json);
490
535
  if (!evidence) return false;
491
536
  attachUnicodeEscapeEvidence(target, evidence);
492
537
  return true;
@@ -12,12 +12,11 @@ import type { OAuthController, OAuthProvider } from "./types";
12
12
  const PROVIDER_ID: OAuthProvider = "vllm";
13
13
  const AUTH_URL = "https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html";
14
14
  const DEFAULT_LOCAL_BASE_URL = "http://127.0.0.1:8000/v1";
15
- const DEFAULT_LOCAL_TOKEN = "vllm-local";
16
15
  /**
17
16
  * Login to vLLM.
18
17
  *
19
- * Opens vLLM OpenAI-compatible auth docs, prompts for an optional token,
20
- * and returns a stored key value.
18
+ * Opens vLLM OpenAI-compatible auth docs, prompts for a bearer token,
19
+ * and returns a stored key value. Local no-auth servers need no login.
21
20
  */
22
21
  export async function loginVllm(options: OAuthController): Promise<string> {
23
22
  if (!options.onPrompt) {
@@ -25,16 +24,19 @@ export async function loginVllm(options: OAuthController): Promise<string> {
25
24
  }
26
25
  options.onAuth?.({
27
26
  url: AUTH_URL,
28
- instructions: `Paste your vLLM API key if your server requires auth. Leave empty for local no-auth mode (default base URL: ${DEFAULT_LOCAL_BASE_URL}).`,
27
+ instructions: `Paste the API key configured with vLLM's --api-key option. Local no-auth servers at ${DEFAULT_LOCAL_BASE_URL} are discovered automatically and do not need /login.`,
29
28
  });
30
29
  const apiKey = await options.onPrompt({
31
- message: "Paste your vLLM API key (optional for local no-auth)",
32
- placeholder: DEFAULT_LOCAL_TOKEN,
33
- allowEmpty: true,
30
+ message: "Paste your vLLM API key",
31
+ placeholder: "vLLM API key",
32
+ allowEmpty: false,
34
33
  });
35
34
  if (options.signal?.aborted) {
36
35
  throw new Error("Login cancelled");
37
36
  }
38
37
  const trimmed = apiKey.trim();
39
- return trimmed || DEFAULT_LOCAL_TOKEN;
38
+ if (!trimmed) {
39
+ throw new Error("vLLM API key is required; local no-auth servers are discovered automatically");
40
+ }
41
+ return trimmed;
40
42
  }
@@ -127,8 +127,12 @@ const EMPTY_RESPONSE_USAGE_THRESHOLD = 5;
127
127
  * misleading error text.
128
128
  */
129
129
  const OVERFLOW_PROVIDER_CODES = new Set(["context_length_exceeded", "request_too_large"]);
130
+ /**
131
+ * Codes that name a specific non-overflow *cause*. These are authoritative and
132
+ * can never be upgraded by error prose. Generic HTTP envelope types belong in
133
+ * {@link GENERIC_ENVELOPE_PROVIDER_CODES} instead.
134
+ */
130
135
  const NON_OVERFLOW_PROVIDER_CODES = new Set([
131
- "invalid_request_error",
132
136
  "authentication_error",
133
137
  "invalid_api_key",
134
138
  "invalid_token",
@@ -158,6 +162,53 @@ function hasTypedNonOverflowCode(transportFailure: TransportFailureFacts | undef
158
162
  return transportCodes(transportFailure).some(code => NON_OVERFLOW_PROVIDER_CODES.has(code));
159
163
  }
160
164
 
165
+ /**
166
+ * Generic envelope codes that name the HTTP error *category*, not its cause.
167
+ *
168
+ * Anthropic reports context overflow through this envelope:
169
+ *
170
+ * {"type":"error","error":{"type":"invalid_request_error",
171
+ * "message":"prompt is too long: 1158066 tokens > 1000000 maximum"}}
172
+ *
173
+ * Treating the envelope as an authoritative non-overflow cause vetoed the
174
+ * overflow classification, so auto-compaction never ran and the session died on
175
+ * the very overflow it was supposed to absorb.
176
+ *
177
+ * Unlike {@link NON_OVERFLOW_PROVIDER_CODES} (auth, quota, rate limit), this
178
+ * envelope names no cause, so it must not veto an overflow the provider stated
179
+ * quantitatively. It still vetoes free-form prose: only the self-verifying
180
+ * measured form below can override it.
181
+ */
182
+ const GENERIC_ENVELOPE_PROVIDER_CODES = new Set(["invalid_request_error"]);
183
+
184
+ /**
185
+ * Anthropic's measured overflow report: `<used> tokens > <limit> maximum`.
186
+ *
187
+ * Deliberately far narrower than {@link OVERFLOW_PATTERNS}. Those patterns
188
+ * include loose prose (`too many tokens`, `token limit exceeded`) that a tool
189
+ * result or a model-authored string can trivially contain, so they must never
190
+ * be able to flip a typed transport classification. This form carries its own
191
+ * arithmetic proof and is verified below, so injected text cannot satisfy it
192
+ * without also asserting a real overage.
193
+ */
194
+ const ANTHROPIC_MEASURED_OVERFLOW_PATTERN = /prompt is too long:\s*(\d+)\s*tokens?\s*>\s*(\d+)\s*maximum/i;
195
+
196
+ /**
197
+ * True only for a provider-measured overflow that verifies against itself:
198
+ * the reported usage must actually exceed the reported maximum.
199
+ */
200
+ function hasSelfVerifyingOverflowMeasurement(message: AssistantMessage): boolean {
201
+ if (message.stopReason !== "error") return false;
202
+ const errorMessage = message.errorMessage;
203
+ if (!errorMessage) return false;
204
+ const match = ANTHROPIC_MEASURED_OVERFLOW_PATTERN.exec(errorMessage);
205
+ if (!match) return false;
206
+ const used = Number(match[1]);
207
+ const maximum = Number(match[2]);
208
+ if (!Number.isFinite(used) || !Number.isFinite(maximum) || maximum <= 0) return false;
209
+ return used > maximum;
210
+ }
211
+
161
212
  function isTypedNoBodyOverflow(
162
213
  message: AssistantMessage,
163
214
  transportFailure: TransportFailureFacts | undefined,
@@ -174,7 +225,18 @@ export function classifyContextOverflow(
174
225
  if (transportFailure?.status === 429) return false;
175
226
  const typedCodes = transportCodes(transportFailure);
176
227
  if (typedCodes.some(code => OVERFLOW_PROVIDER_CODES.has(code))) return true;
228
+ // A specific non-overflow cause (auth, quota, rate limit) is authoritative
229
+ // and can never be upgraded by error prose.
177
230
  if (hasTypedNonOverflowCode(transportFailure)) return false;
231
+ // A generic envelope (`invalid_request_error`) names no cause. It still
232
+ // vetoes free-form overflow prose, but must not veto a provider-measured,
233
+ // self-verifying overflow report — that is how Anthropic reports overflow.
234
+ if (
235
+ typedCodes.some(code => GENERIC_ENVELOPE_PROVIDER_CODES.has(code)) &&
236
+ !hasSelfVerifyingOverflowMeasurement(message)
237
+ ) {
238
+ return false;
239
+ }
178
240
  if (isTypedNoBodyOverflow(message, transportFailure)) return true;
179
241
 
180
242
  const errorMessage = message.errorMessage;
@@ -19,7 +19,7 @@
19
19
 
20
20
  import {
21
21
  attachUnicodeEscapeEvidence,
22
- collectUnicodeEscapeEvidence,
22
+ collectUnsafeUnicodeEscapeEvidence,
23
23
  parseJsonWithRepair,
24
24
  type UnicodeEscapeEvidence,
25
25
  } from "./json-parse";
@@ -243,9 +243,11 @@ export class ToolCallHealer {
243
243
  const name = normalizeFunctionName(rawId);
244
244
  const id = generateHealedToolCallId();
245
245
 
246
- // Sample the raw payload first: the round-trip below decodes `\uXXXX` into
247
- // literal characters, so checking `argsJson` afterwards always reports clean.
248
- const escapedUnicodeArgumentEvidence = collectUnicodeEscapeEvidence(rawArgs);
246
+ // Sample unsafe raw payloads first: the round-trip below decodes `\uXXXX`
247
+ // into literal characters, so malformed escape data must be retained before
248
+ // normalization. Valid escapes need no evidence because their decoded value
249
+ // is canonical.
250
+ const escapedUnicodeArgumentEvidence = collectUnsafeUnicodeEscapeEvidence(rawArgs);
249
251
  const escapedNonAsciiArguments = escapedUnicodeArgumentEvidence !== undefined;
250
252
 
251
253
  let argsJson = rawArgs;