@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 +7 -0
- package/dist/types/openai-completions-compat.d.ts +1 -1
- package/dist/types/providers/openai-responses.d.ts +2 -0
- package/dist/types/types.d.ts +38 -0
- package/package.json +2 -2
- package/src/openai-completions-compat.ts +1 -0
- package/src/providers/anthropic.ts +72 -15
- package/src/providers/google-shared.ts +31 -8
- package/src/providers/openai-responses.ts +28 -1
- package/src/types.ts +39 -1
- package/src/utils/validation.ts +33 -2
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[];
|
package/dist/types/types.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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"
|
|
@@ -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
|
|
1453
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
1980
|
+
thinkingReplayRepairScope !== "all" &&
|
|
1981
|
+
thinkingReplayRepairAttempts < ANTHROPIC_MAX_THINKING_REPAIRS &&
|
|
1938
1982
|
firstTokenTime === undefined &&
|
|
1939
1983
|
(thinkingSignatureInvalid ||
|
|
1940
|
-
|
|
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
|
-
(
|
|
1988
|
+
(maskedProxyRejection && hasNativeThinkingBlocks(params.messages)))
|
|
1945
1989
|
) {
|
|
1946
|
-
//
|
|
1947
|
-
//
|
|
1948
|
-
//
|
|
1949
|
-
//
|
|
1950
|
-
|
|
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:
|
|
2002
|
+
scope: nextScope,
|
|
2003
|
+
attempt: thinkingReplayRepairAttempts,
|
|
1954
2004
|
error: streamFailure instanceof Error ? streamFailure.message : String(streamFailure),
|
|
1955
2005
|
});
|
|
1956
|
-
|
|
1957
|
-
|
|
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
|
-
|
|
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
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
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
|
|
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
|
package/src/utils/validation.ts
CHANGED
|
@@ -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(
|
|
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);
|