@gajae-code/ai 0.12.19 → 0.12.20

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,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.12.20] - 2026-08-09
6
+
7
+ ### Fixed
8
+
9
+ - OpenAI Responses transports no longer send tool declarations whose names the provider reserves for its own built-ins. OpenCode Zen/Go reject `web_search` as a custom function with `invalid tools in request: custom function name "web_search" is reserved`, and that rejection is request-scoped — one colliding declaration failed the entire tools array before any token streamed, so the bundled `critic`, `planner`, and `architect` agents failed 100% of the time on those providers (#4104). The collision is dropped rather than renamed, because a renamed function tool returns as a `function_call` under the wire alias and that path does not populate `Tool.customWireName`, which would leave the agent-loop dispatcher unable to route the call. `compat.reservedToolNames` overrides the per-provider default.
10
+
5
11
  ## [0.12.19] - 2026-08-08
6
12
 
7
13
  ## [0.12.18] - 2026-08-08
@@ -21,6 +27,7 @@
21
27
  ### Fixed
22
28
 
23
29
  - Codex named-tool fallback now keeps its downgraded request body across later same-turn provider retries and uses an independent one-shot budget, so retries cannot reintroduce `tool_choice` or suppress a later capability downgrade (#3669).
30
+ - `google-generative-ai` and `google-vertex` generate-content streams now consume newline-delimited JSON responses when the response media type declares NDJSON or JSONL, while preserving standard event-stream parsing and diagnostics.
24
31
  - Anthropic requests rejected with `A maximum of 4 blocks with cache_control may be provided. Found N.` now step their generated breakpoints down instead of dying on the first attempt (#3934, supersedes #3943). An Anthropic-compatible gateway may attach its own block-level cache markers before forwarding, and those never appear in the params we serialize, so the total is unpredictable locally and the rejection itself is the only usable signal. Because that rejection says "too many", not "none allowed", recovery gives up one breakpoint at a time: explicit mode normally emits two (a conversation-prefix anchor plus a current-turn refresh point), so the first retry keeps the prefix anchor — the higher-value marker — and only a second rejection disables generated caching entirely. The reduced budget persists for the provider session so later turns neither re-trigger the 400 nor lose more caching than the endpoint requires. Only a genuine breakpoint-overflow `invalid_request_error` is claimed — other `cache_control` complaints, unrelated 400s, non-400 statuses, and our own pre-flight validation failure still surface immediately. The classifier is exported as `isAnthropicCacheBreakpointOverflowError`.
25
32
  ## [0.12.15] - 2026-08-06
26
33
 
@@ -1,6 +1,6 @@
1
1
  import type { Model, OpenAICompat } from "./types";
2
2
  type ResolvedToolStrictMode = NonNullable<OpenAICompat["toolStrictMode"]> | "mixed";
3
- export type ResolvedOpenAICompat = Required<Omit<OpenAICompat, "openRouterRouting" | "vercelGatewayRouting" | "extraBody" | "toolStrictMode" | "toolChoiceSupport" | "supportsResponsesSessionAffinity">> & {
3
+ export type ResolvedOpenAICompat = Required<Omit<OpenAICompat, "openRouterRouting" | "vercelGatewayRouting" | "extraBody" | "toolStrictMode" | "toolChoiceSupport" | "supportsResponsesSessionAffinity" | "reservedToolNames">> & {
4
4
  openRouterRouting?: OpenAICompat["openRouterRouting"];
5
5
  vercelGatewayRouting?: OpenAICompat["vercelGatewayRouting"];
6
6
  extraBody?: OpenAICompat["extraBody"];
@@ -31,4 +31,6 @@ export declare function supportsFreeformApplyPatch(model: Model<"openai-response
31
31
  /** @internal Exported for tests. */
32
32
  export declare function mapOpenAIResponsesToolChoiceForTools(choice: ToolChoice | undefined, tools: Tool[], model: Model<"openai-responses">): OpenAIResponsesToolChoice;
33
33
  /** @internal Exported for tests. */
34
+ export declare function resolveReservedToolNames(model: Model<"openai-responses">): readonly string[];
35
+ /** @internal Exported for tests. */
34
36
  export declare function convertTools(tools: Tool[], strictMode: boolean, model: Model<"openai-responses">): OpenAITool[];
@@ -540,6 +540,22 @@ export type Static<S> = S extends ZodType ? z.infer<S> : S extends {
540
540
  static: infer T;
541
541
  } ? T : unknown;
542
542
  export type RawArgumentRejectionCode = "ask-intent-review-requires-positive-round" | "ask-intent-contract-requires-non-empty-authority" | "ask-deep-interview-metadata-requires-deep-interview-gate" | "todo-write-unknown-root-key" | "todo-write-unknown-op-entry-key" | "todo-write-done-drop-requires-target" | "todo-write-unknown-init-entry-key";
543
+ /**
544
+ * Optional structured detail attached to a raw-argument rejection. The fixed
545
+ * per-code guidance in `RAW_ARGUMENT_REJECTION_MESSAGES` explains the shape;
546
+ * this names what the caller actually sent that was wrong, so a retry can
547
+ * differ from the failed call.
548
+ */
549
+ export interface RawArgumentRejectionDetail {
550
+ /** Offending keys, in payload order. */
551
+ readonly rejectedKeys?: readonly string[];
552
+ /**
553
+ * Correction for a rejected key whose replacement is exact and
554
+ * unambiguous. Never populate this from fuzzy or edit-distance matching:
555
+ * a wrong suggestion costs more turns than no suggestion.
556
+ */
557
+ readonly hint?: string;
558
+ }
543
559
  export type RawArgumentValidationResult = {
544
560
  outcome: "passthrough";
545
561
  } | {
@@ -548,6 +564,7 @@ export type RawArgumentValidationResult = {
548
564
  } | {
549
565
  outcome: "reject";
550
566
  code?: RawArgumentRejectionCode;
567
+ detail?: RawArgumentRejectionDetail;
551
568
  };
552
569
  export interface Tool<TParameters extends TSchema = TSchema> {
553
570
  name: string;
@@ -701,6 +718,27 @@ export interface OpenAICompat extends ToolChoiceCompat {
701
718
  * HTTPS origin automatically; known non-OpenAI providers remain excluded.
702
719
  */
703
720
  supportsResponsesSessionAffinity?: boolean;
721
+ /**
722
+ * Tool names the provider reserves for its own built-ins and refuses to
723
+ * accept as custom function declarations. A colliding tool is **dropped**
724
+ * from the declared tools array rather than renamed: a renamed function
725
+ * tool would come back as a `function_call` under the wire alias, and that
726
+ * path does not populate `Tool.customWireName`, leaving the agent-loop
727
+ * dispatcher unable to route it — trading a loud 400 for a silent
728
+ * unresolvable call. Dropping the declaration is intentionally a loss of
729
+ * capability, leaving the agent in the same state as any provider that
730
+ * simply has no such tool. The filter preserves declaration order and does
731
+ * not mutate the caller's array.
732
+ *
733
+ * Without this, one reserved name rejects the ENTIRE tools array with a
734
+ * single 400 and no tokens ever stream — every agent carrying that tool
735
+ * fails 100% of the time on that provider.
736
+ *
737
+ * Resolution precedence: an explicit array (including `[]`) on the model's
738
+ * `compat` replaces the built-in provider default, so `[]` opts a reserved
739
+ * provider out of the drop entirely.
740
+ */
741
+ reservedToolNames?: string[];
704
742
  /**
705
743
  * Whether the provider's chat-completions endpoint accepts multiple
706
744
  * leading `system`/`developer` messages. When false, ordered system
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/ai",
4
- "version": "0.12.19",
4
+ "version": "0.12.20",
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,7 +40,7 @@
40
40
  "dependencies": {
41
41
  "@anthropic-ai/sdk": "^0.94.0",
42
42
  "@bufbuild/protobuf": "^2.12.0",
43
- "@gajae-code/utils": "0.12.19",
43
+ "@gajae-code/utils": "0.12.20",
44
44
  "openai": "^6.36.0",
45
45
  "partial-json": "^0.1.7",
46
46
  "zod": "4.4.3"
@@ -12,6 +12,7 @@ export type ResolvedOpenAICompat = Required<
12
12
  | "toolStrictMode"
13
13
  | "toolChoiceSupport"
14
14
  | "supportsResponsesSessionAffinity"
15
+ | "reservedToolNames"
15
16
  >
16
17
  > & {
17
18
  openRouterRouting?: OpenAICompat["openRouterRouting"];
@@ -316,10 +316,33 @@ let warnedStopSequencesTrim = false;
316
316
 
317
317
  const ANTHROPIC_PROVIDER_SESSION_STATE_KEY = "anthropic-messages";
318
318
 
319
+ /**
320
+ * Scope of a classified replayed-thinking repair currently applied to this
321
+ * session: `latest` drops native thinking from the newest assistant turn, `all`
322
+ * stops replaying native thinking entirely. Persisted across stream
323
+ * re-invocations so a repair that keeps being rejected is not re-attempted from
324
+ * scratch on every turn (issue #4011), and released again by the first stream
325
+ * that completes. Unclassifiable masked `api_error` repairs remain local to the
326
+ * current stream invocation because a transient masked failure must not degrade
327
+ * later turns.
328
+ */
329
+ type AnthropicThinkingReplayRepairScope = "none" | "latest" | "all";
330
+
331
+ /**
332
+ * Repairs are bounded independently of `PROVIDER_MAX_RETRIES` because they do
333
+ * not consume the provider retry budget: without their own ceiling an
334
+ * unacceptable request shape retries forever (issue #4011). The ceiling spans
335
+ * the session rather than a single stream, and only a completed stream re-arms
336
+ * it — an unacceptable shape never completes, so it can never buy more repairs.
337
+ */
338
+ const ANTHROPIC_MAX_THINKING_REPAIRS = 2;
339
+
319
340
  type AnthropicProviderSessionState = ProviderSessionState & {
320
341
  strictToolsDisabled: boolean;
321
342
  fastModeDisabled: boolean;
322
343
  generatedCacheBudget: GeneratedCacheBudget;
344
+ thinkingReplayRepairScope: AnthropicThinkingReplayRepairScope;
345
+ thinkingReplayRepairAttempts: number;
323
346
  };
324
347
 
325
348
  function createAnthropicProviderSessionState(): AnthropicProviderSessionState {
@@ -327,10 +350,14 @@ function createAnthropicProviderSessionState(): AnthropicProviderSessionState {
327
350
  strictToolsDisabled: false,
328
351
  fastModeDisabled: false,
329
352
  generatedCacheBudget: 2,
353
+ thinkingReplayRepairScope: "none",
354
+ thinkingReplayRepairAttempts: 0,
330
355
  close: () => {
331
356
  state.strictToolsDisabled = false;
332
357
  state.fastModeDisabled = false;
333
358
  state.generatedCacheBudget = 2;
359
+ state.thinkingReplayRepairScope = "none";
360
+ state.thinkingReplayRepairAttempts = 0;
334
361
  },
335
362
  };
336
363
  return state;
@@ -1449,8 +1476,9 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1449
1476
  let strictFallbackErrorMessage: string | undefined;
1450
1477
  let dropFastMode = providerSessionState?.fastModeDisabled ?? false;
1451
1478
  let droppedForcedToolChoice = false;
1452
- let repairLatestAssistantThinking = false;
1453
- let repairAllAssistantThinking = false;
1479
+ let thinkingReplayRepairScope: AnthropicThinkingReplayRepairScope =
1480
+ providerSessionState?.thinkingReplayRepairScope ?? "none";
1481
+ let thinkingReplayRepairAttempts = providerSessionState?.thinkingReplayRepairAttempts ?? 0;
1454
1482
  let generatedCacheBudget: GeneratedCacheBudget = providerSessionState?.generatedCacheBudget ?? 2;
1455
1483
  const prepareParams = async (): Promise<MessageCreateParamsStreaming> => {
1456
1484
  // Degradation state is cumulative: every fallback rebuild must merge all
@@ -1465,7 +1493,10 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1465
1493
  isOAuthToken,
1466
1494
  options,
1467
1495
  disableStrictTools,
1468
- { repairLatestAssistantThinking, repairAllAssistantThinking },
1496
+ {
1497
+ repairLatestAssistantThinking: thinkingReplayRepairScope === "latest",
1498
+ repairAllAssistantThinking: thinkingReplayRepairScope === "all",
1499
+ },
1469
1500
  generatedCacheBudget,
1470
1501
  );
1471
1502
  if (droppedForcedToolChoice) {
@@ -1878,6 +1909,16 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1878
1909
  if (output.stopReason === "aborted" || output.stopReason === "error") {
1879
1910
  throw new Error(output.errorMessage ?? "An unknown error occurred");
1880
1911
  }
1912
+ // The first stream that completes is the only evidence available that this
1913
+ // session is not the #4011 loop, which never produced one. Release the
1914
+ // repair escalation and the budget it consumed: the masked `api_error`
1915
+ // branch above fires on an error nobody can classify, so keeping its
1916
+ // guess would silently strip native thinking replay from every later
1917
+ // turn of the session over what may have been one transient blip.
1918
+ if (providerSessionState && (thinkingReplayRepairScope !== "none" || thinkingReplayRepairAttempts > 0)) {
1919
+ providerSessionState.thinkingReplayRepairScope = "none";
1920
+ providerSessionState.thinkingReplayRepairAttempts = 0;
1921
+ }
1881
1922
  break;
1882
1923
  } catch (streamError) {
1883
1924
  const localAbortReason = activeAbortTracker.getLocalAbortReason();
@@ -1932,31 +1973,47 @@ export const streamAnthropic: StreamFunction<"anthropic-messages"> = (
1932
1973
  continue;
1933
1974
  }
1934
1975
  const thinkingSignatureInvalid = isAnthropicThinkingSignatureInvalidError(streamFailure);
1976
+ const thinkingBlocksImmutable = isAnthropicThinkingBlockMutationError(streamFailure);
1977
+ const maskedProxyRejection = isAnthropicMaskedProxyRejection(streamFailure);
1935
1978
  if (
1936
1979
  !options?.fallbackManaged &&
1937
- !repairAllAssistantThinking &&
1980
+ thinkingReplayRepairScope !== "all" &&
1981
+ thinkingReplayRepairAttempts < ANTHROPIC_MAX_THINKING_REPAIRS &&
1938
1982
  firstTokenTime === undefined &&
1939
1983
  (thinkingSignatureInvalid ||
1940
- isAnthropicThinkingBlockMutationError(streamFailure) ||
1984
+ thinkingBlocksImmutable ||
1941
1985
  // Masked proxy rejection: unclassifiable on its own, so the replayed
1942
1986
  // request shape is the evidence. Without signed thinking blocks in
1943
1987
  // flight there is nothing to repair and the error must surface.
1944
- (isAnthropicMaskedProxyRejection(streamFailure) && hasNativeThinkingBlocks(params.messages)))
1988
+ (maskedProxyRejection && hasNativeThinkingBlocks(params.messages)))
1945
1989
  ) {
1946
- // The mutation 400 blames the "latest assistant message", but its cited
1947
- // `messages.N.content.M` path can point at an EARLIER replayed turn, so the
1948
- // latest-only repair gets rejected identically. Escalate to the full-history
1949
- // repair instead of burning the single retry on one scope.
1950
- const escalateToAll: boolean = thinkingSignatureInvalid || repairLatestAssistantThinking;
1990
+ // "cannot be modified" means the cited blocks must be replayed byte for
1991
+ // byte, so editing that turn again can never converge — the only recovery
1992
+ // is to stop replaying native thinking at all. The invalid-signature 400
1993
+ // cites blocks anywhere in history and needs the same full-history scope.
1994
+ // Only the unclassifiable masked rejection is worth probing latest-first.
1995
+ const nextScope: AnthropicThinkingReplayRepairScope =
1996
+ thinkingSignatureInvalid || thinkingBlocksImmutable || thinkingReplayRepairScope === "latest"
1997
+ ? "all"
1998
+ : "latest";
1999
+ thinkingReplayRepairAttempts++;
1951
2000
  logger.debug("anthropic: repairing assistant thinking replay after provider rejection", {
1952
2001
  model: model.id,
1953
- scope: escalateToAll ? "all" : "latest",
2002
+ scope: nextScope,
2003
+ attempt: thinkingReplayRepairAttempts,
1954
2004
  error: streamFailure instanceof Error ? streamFailure.message : String(streamFailure),
1955
2005
  });
1956
- repairLatestAssistantThinking = !escalateToAll;
1957
- repairAllAssistantThinking = escalateToAll;
2006
+ thinkingReplayRepairScope = nextScope;
2007
+ if (providerSessionState) {
2008
+ providerSessionState.thinkingReplayRepairAttempts = thinkingReplayRepairAttempts;
2009
+ if (!maskedProxyRejection) {
2010
+ providerSessionState.thinkingReplayRepairScope = nextScope;
2011
+ }
2012
+ }
1958
2013
  params = await prepareParams();
1959
- providerRetryAttempt = 0;
2014
+ // The provider retry budget is deliberately NOT reset here: a repair that
2015
+ // keeps being rejected must run out instead of renewing the budget it is
2016
+ // supposed to consume (issue #4011).
1960
2017
  resetOutputForRetry();
1961
2018
  continue;
1962
2019
  }
@@ -2,7 +2,7 @@
2
2
  * Shared utilities for Google Generative AI and Google Cloud Code Assist providers.
3
3
  */
4
4
 
5
- import { extractHttpStatusFromError, readSseJson } from "@gajae-code/utils";
5
+ import { extractHttpStatusFromError, readJsonl, readSseJson } from "@gajae-code/utils";
6
6
  import { calculateCost } from "../models";
7
7
  import type {
8
8
  Api,
@@ -914,13 +914,36 @@ export function streamGoogleGenAI<T extends "google-generative-ai" | "google-ver
914
914
  throw new Error("Google API returned an empty response body");
915
915
  }
916
916
 
917
- const googleStream = readSseJson<GenerateContentResponse>(response.body, options?.signal, event =>
918
- options?.onSseEvent?.(
919
- { event: event.event, data: event.data, raw: [...event.raw] },
920
- model,
921
- options?.attemptScope,
922
- ),
923
- );
917
+ const mediaType = (response.headers.get("content-type") ?? "").split(";", 1)[0]?.trim().toLowerCase() ?? "";
918
+ const isJsonLines =
919
+ mediaType === "application/x-ndjson" ||
920
+ mediaType === "application/ndjson" ||
921
+ mediaType === "application/jsonl" ||
922
+ mediaType === "application/x-jsonl";
923
+ const rawEventObserver = options?.onSseEvent;
924
+ const googleStream = isJsonLines
925
+ ? readJsonl<GenerateContentResponse>(
926
+ response.body,
927
+ options?.signal,
928
+ rawEventObserver
929
+ ? raw => {
930
+ if (raw.trim().length === 0) return;
931
+ rawEventObserver({ event: null, data: raw, raw: [raw] }, model, options?.attemptScope);
932
+ }
933
+ : undefined,
934
+ )
935
+ : readSseJson<GenerateContentResponse>(
936
+ response.body,
937
+ options?.signal,
938
+ rawEventObserver
939
+ ? event =>
940
+ rawEventObserver(
941
+ { event: event.event, data: event.data, raw: [...event.raw] },
942
+ model,
943
+ options?.attemptScope,
944
+ )
945
+ : undefined,
946
+ );
924
947
 
925
948
  stream.push({ type: "start", partial: output });
926
949
  await consumeGoogleStream({
@@ -860,10 +860,37 @@ function isForcedOpenAIResponsesToolChoice(choice: unknown): boolean {
860
860
  return !!choice && choice !== "none" && choice !== "auto";
861
861
  }
862
862
 
863
+ /**
864
+ * Tool names an OpenAI-compatible endpoint reserves for its own built-ins and
865
+ * refuses as custom function declarations.
866
+ *
867
+ * OpenCode Zen/Go reject `web_search` with
868
+ * `invalid tools in request: custom function name "web_search" is reserved`.
869
+ * The rejection is request-scoped: one colliding declaration fails the WHOLE
870
+ * tools array before any token streams, so every agent carrying that tool is
871
+ * permanently broken on the provider rather than losing a single capability.
872
+ *
873
+ * The collision is dropped rather than renamed. A renamed function tool would
874
+ * come back as a `function_call` under the wire alias, and that path does not
875
+ * populate `Tool.customWireName`, so the agent-loop dispatcher could not route
876
+ * it — trading a loud 400 for a silent unresolvable call. Dropping leaves the
877
+ * agent in the same state as any provider that simply has no web search.
878
+ */
879
+ const PROVIDER_RESERVED_TOOL_NAMES: Record<string, readonly string[]> = {
880
+ "opencode-go": ["web_search"],
881
+ "opencode-zen": ["web_search"],
882
+ };
883
+
884
+ /** @internal Exported for tests. */
885
+ export function resolveReservedToolNames(model: Model<"openai-responses">): readonly string[] {
886
+ return model.compat?.reservedToolNames ?? PROVIDER_RESERVED_TOOL_NAMES[model.provider] ?? [];
887
+ }
863
888
  /** @internal Exported for tests. */
864
889
  export function convertTools(tools: Tool[], strictMode: boolean, model: Model<"openai-responses">): OpenAITool[] {
865
890
  const allowFreeform = supportsFreeformApplyPatch(model);
866
- const payloads = tools.map(tool => {
891
+ const reserved = resolveReservedToolNames(model);
892
+ const declarable = reserved.length === 0 ? tools : tools.filter(tool => !reserved.includes(tool.name));
893
+ const payloads = declarable.map(tool => {
867
894
  if (allowFreeform && tool.customFormat) {
868
895
  return {
869
896
  type: "custom",
package/src/types.ts CHANGED
@@ -759,10 +759,27 @@ export type RawArgumentRejectionCode =
759
759
  | "todo-write-done-drop-requires-target"
760
760
  | "todo-write-unknown-init-entry-key";
761
761
 
762
+ /**
763
+ * Optional structured detail attached to a raw-argument rejection. The fixed
764
+ * per-code guidance in `RAW_ARGUMENT_REJECTION_MESSAGES` explains the shape;
765
+ * this names what the caller actually sent that was wrong, so a retry can
766
+ * differ from the failed call.
767
+ */
768
+ export interface RawArgumentRejectionDetail {
769
+ /** Offending keys, in payload order. */
770
+ readonly rejectedKeys?: readonly string[];
771
+ /**
772
+ * Correction for a rejected key whose replacement is exact and
773
+ * unambiguous. Never populate this from fuzzy or edit-distance matching:
774
+ * a wrong suggestion costs more turns than no suggestion.
775
+ */
776
+ readonly hint?: string;
777
+ }
778
+
762
779
  export type RawArgumentValidationResult =
763
780
  | { outcome: "passthrough" }
764
781
  | { outcome: "accept"; arguments: ToolCall["arguments"] }
765
- | { outcome: "reject"; code?: RawArgumentRejectionCode };
782
+ | { outcome: "reject"; code?: RawArgumentRejectionCode; detail?: RawArgumentRejectionDetail };
766
783
 
767
784
  export interface Tool<TParameters extends TSchema = TSchema> {
768
785
  name: string;
@@ -869,6 +886,27 @@ export interface OpenAICompat extends ToolChoiceCompat {
869
886
  * HTTPS origin automatically; known non-OpenAI providers remain excluded.
870
887
  */
871
888
  supportsResponsesSessionAffinity?: boolean;
889
+ /**
890
+ * Tool names the provider reserves for its own built-ins and refuses to
891
+ * accept as custom function declarations. A colliding tool is **dropped**
892
+ * from the declared tools array rather than renamed: a renamed function
893
+ * tool would come back as a `function_call` under the wire alias, and that
894
+ * path does not populate `Tool.customWireName`, leaving the agent-loop
895
+ * dispatcher unable to route it — trading a loud 400 for a silent
896
+ * unresolvable call. Dropping the declaration is intentionally a loss of
897
+ * capability, leaving the agent in the same state as any provider that
898
+ * simply has no such tool. The filter preserves declaration order and does
899
+ * not mutate the caller's array.
900
+ *
901
+ * Without this, one reserved name rejects the ENTIRE tools array with a
902
+ * single 400 and no tokens ever stream — every agent carrying that tool
903
+ * fails 100% of the time on that provider.
904
+ *
905
+ * Resolution precedence: an explicit array (including `[]`) on the model's
906
+ * `compat` replaces the built-in provider default, so `[]` opts a reserved
907
+ * provider out of the drop entirely.
908
+ */
909
+ reservedToolNames?: string[];
872
910
  /**
873
911
  * Whether the provider's chat-completions endpoint accepts multiple
874
912
  * leading `system`/`developer` messages. When false, ordered system
@@ -25,7 +25,7 @@
25
25
  import { structuredCloneJSON } from "@gajae-code/utils";
26
26
  import type { ZodType } from "zod/v4";
27
27
  import type { $ZodIssue as ZodIssue } from "zod/v4/core";
28
- import type { RawArgumentRejectionCode, Tool, ToolCall } from "../types";
28
+ import type { RawArgumentRejectionCode, RawArgumentRejectionDetail, Tool, ToolCall } from "../types";
29
29
  import { upgradeJsonSchemaTo202012 } from "./schema/draft";
30
30
  import {
31
31
  isJsonSchemaValueValid,
@@ -972,6 +972,33 @@ const RAW_ARGUMENT_REJECTION_MESSAGES: Record<RawArgumentRejectionCode, string>
972
972
  "todo-write-unknown-init-entry-key": "todo_write init list entries accept only phase and items keys",
973
973
  };
974
974
 
975
+ /** Bounds on model-supplied text echoed back into a rejection message. */
976
+ const MAX_REJECTED_KEYS = 8;
977
+ const MAX_REJECTED_KEY_LENGTH = 64;
978
+ const MAX_REJECTION_HINT_LENGTH = 200;
979
+
980
+ function clamp(value: string, limit: number): string {
981
+ return value.length > limit ? `${value.slice(0, limit)}…` : value;
982
+ }
983
+
984
+ /**
985
+ * Renders the offending keys (and an exact correction, when the rejecting
986
+ * contract supplied one) as a clause appended to the fixed per-code guidance.
987
+ * Returns undefined when there is nothing concrete to name.
988
+ */
989
+ function formatRejectionDetail(detail: RawArgumentRejectionDetail): string | undefined {
990
+ const keys = Array.isArray(detail.rejectedKeys)
991
+ ? detail.rejectedKeys.filter(key => typeof key === "string" && key.length > 0).slice(0, MAX_REJECTED_KEYS)
992
+ : [];
993
+ const hint = typeof detail.hint === "string" && detail.hint.length > 0 ? detail.hint : undefined;
994
+ if (keys.length === 0) return hint ? clamp(hint, MAX_REJECTION_HINT_LENGTH) : undefined;
995
+
996
+ const label = keys.length === 1 ? "rejected key" : "rejected keys";
997
+ const rendered = keys.map(key => `"${clamp(key, MAX_REJECTED_KEY_LENGTH)}"`).join(", ");
998
+ const suffix = hint ? ` (${clamp(hint, MAX_REJECTION_HINT_LENGTH)})` : "";
999
+ return `${label}: ${rendered}${suffix}`;
1000
+ }
1001
+
975
1002
  /**
976
1003
  * Validates tool call arguments against the tool's schema (Zod or plain JSON
977
1004
  * Schema). Applies LLM-quirk coercions (numeric strings, JSON-string
@@ -989,7 +1016,11 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
989
1016
  typeof code === "string" && Object.hasOwn(RAW_ARGUMENT_REJECTION_MESSAGES, code)
990
1017
  ? RAW_ARGUMENT_REJECTION_MESSAGES[code as RawArgumentRejectionCode]
991
1018
  : undefined;
992
- throw new Error(correction ? `${base}; ${correction}` : base);
1019
+ if (!correction) throw new Error(base);
1020
+ // Detail is only echoed alongside authority-controlled guidance, and only
1021
+ // after clamping — the keys themselves come from the rejected payload.
1022
+ const detail = rawValidation.detail ? formatRejectionDetail(rawValidation.detail) : undefined;
1023
+ throw new Error(detail ? `${base}; ${correction}; ${detail}` : `${base}; ${correction}`);
993
1024
  }
994
1025
  const rawArgs = rawValidation?.outcome === "accept" ? rawValidation.arguments : originalArgs;
995
1026
  const ctx = getValidationContext(tool);