Skip to content

Commit 28fd075

Browse files
authored
Merge pull request #718 from code-yeongyu/fix/tool-schema-root-type-and-hard-error-classification
fix(ai): keep tool schemas rooted in an object and stop retrying request-shape rejections
2 parents 0898b5e + 3c4ab33 commit 28fd075

9 files changed

Lines changed: 581 additions & 27 deletions

packages/ai/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010

1111
### Fixed
1212

13+
- Kept tool parameter schemas rooted in an object type. A root `anyOf`/`oneOf`/`allOf` previously had its `type` hoisted into the branches and deleted, so OpenAI-compatible gateways rejected the request with `tools.function.parameters.type is required and must be "object"`, and the Moonshot root-union merge dropped the root's own properties entirely ([#718](https://github.com/code-yeongyu/senpi/pull/718)).
14+
- Sent the real parameters of root-union tool schemas to Anthropic. `convertTools` read top-level `properties` only, so plugin and MCP tools defined as a root union reached the model with no parameters at all ([#718](https://github.com/code-yeongyu/senpi/pull/718)).
15+
- Stopped retrying provider request-shape rejections. Gateways wrap these deterministic failures in 5xx envelopes, so they were classified transient and the identical invalid payload was replayed on the same model before fallback inherited it ([#718](https://github.com/code-yeongyu/senpi/pull/718)).
16+
1317
### Removed
1418

1519
## [2026.8.4-2] - 2026-08-04

packages/ai/src/api/anthropic-messages.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
} from "../utils/server-fallback-receipt.ts";
5050
import { normalizeToolCallId } from "../utils/tool-call-id.ts";
5151
import { isForcedToolChoiceUnsupportedError, omitToolChoiceParam } from "../utils/tool-choice-fallback.ts";
52+
import { resolveRootObjectSchema } from "../utils/tool-schema-compat.ts";
5253
import { demotedToolCallText, demotedToolResultText } from "../utils/unavailable-tool-text.ts";
5354
import { sanitizeAnthropicToolPairs } from "./anthropic-tool-pairs.ts";
5455
import { resolveCloudflareBaseUrl } from "./cloudflare.ts";
@@ -2330,7 +2331,12 @@ function convertTools(
23302331

23312332
return tools.map((tool, index) => {
23322333
const strict = resolveJsonSchemaStrictSampling(tool, supportsStrictTools);
2333-
const schema = tool.parameters as { properties?: unknown; required?: string[] };
2334+
// A root union carries no top-level properties, so reading them directly
2335+
// would advertise the tool to the model as taking no arguments at all.
2336+
const schema = resolveRootObjectSchema(tool.parameters as Record<string, unknown>) as {
2337+
properties?: unknown;
2338+
required?: string[];
2339+
};
23342340
const legacyInputSchema = {
23352341
type: "object" as const,
23362342
properties: schema.properties ?? {},

packages/ai/src/changes.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,49 @@
11
# AI Source Changes
22

3+
## 2026-08-05 - Root-object tool schemas and request-shape error classification
4+
5+
### What changed and why
6+
7+
- `utils/tool-schema-compat.ts` no longer hoists a ROOT schema's `type` into its combiner
8+
branches. A tool's root `parameters` must stay an object schema: OpenAI-compatible gateways
9+
reject a typeless root with `tools.function.parameters.type is required and must be "object"`,
10+
which is exactly how an Apitopia/Kimi turn died on 2026-08-04. `normalizeNode` now takes an
11+
`isRoot` flag so branch-level hoisting (still correct below the root) is unchanged, and
12+
`ensureRootObjectSchema` guarantees the emitted root is always `{"type":"object", ...}`.
13+
- `mergeRootObjectUnion` now merges the root's OWN `properties`/`required` with the branches'
14+
instead of replacing them. It previously returned `{"properties":{},"type":"object"}` for a root
15+
union that declared its properties at the root — silently sending a tool with zero parameters.
16+
Untyped constraint-only branches (`{ required: [...] }` over root properties) are accepted, and
17+
`required` keeps root entries plus only the names every branch shares.
18+
- Both flavors share one root guarantee: `normalizeToolParametersForMoonshot` is now the OpenAI
19+
normalization plus annotation stripping, rather than a second, divergent root-merge path.
20+
- `api/anthropic-messages.ts` resolves a tool's root parameters through the shared
21+
`resolveRootObjectSchema` before building `input_schema`. `convertTools` reads top-level
22+
`properties`/`required` only, so a tool whose parameters are a root union arrived as
23+
`{"properties":{},"required":[]}` — Claude was told the tool takes no arguments. senpi's own
24+
`monitorSchema` was flattened in July to dodge this, but plugin and MCP tools ship root unions
25+
and cannot be flattened by us, so the conversion itself has to handle them.
26+
- `utils/retry.ts` classifies provider request-shape rejections as NON-retryable, and
27+
`NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN` is renamed `NON_RETRYABLE_PROVIDER_ERROR_PATTERN`
28+
because it no longer covers only limits. Gateways wrap these deterministic rejections in 5xx
29+
envelopes (`500 server_error: Invalid request: tools.function.parameters...`), so matching on
30+
status text alone classified a permanent failure as transient: the identical payload was replayed
31+
on the identical model until the turn died. The patterns are anchored on the
32+
`tools.`/`functions.` request path so unrelated prose mentioning tools stays retryable.
33+
34+
### Why this cannot be expressed externally
35+
36+
- Wire-payload schema normalization runs inside the provider adapter, after extension payload
37+
hooks, so no extension can repair the emitted tool schema. Retry classification is consumed by
38+
the agent session's hard-error routing, which lives below any extension seam.
39+
40+
### Expected merge conflict zones
41+
42+
- MEDIUM: `utils/tool-schema-compat.ts` around root handling and `mergeRootObjectUnion`.
43+
- MEDIUM: `utils/retry.ts` in the non-retryable pattern list and its renamed constant.
44+
- LOW: `test/openai-completions-tool-schema-compat.test.ts`, `test/retry.test.ts`.
45+
46+
347
## 2026-08-03 - Hint-aware 429 retry-after propagation
448

549
### What changed and why

packages/ai/src/utils/retry.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ function buildProviderErrorPattern(patterns: readonly string[]): RegExp {
44
return new RegExp(patterns.join("|"), "i");
55
}
66

7-
const NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN = buildProviderErrorPattern([
7+
const NON_RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([
88
// OpenCode Go/free-tier limits returned as 429 JSON error types by OpenCode's
99
// Zen API. These are subscription/account limits, not transient throttles.
1010
"GoUsageLimitError",
@@ -28,6 +28,20 @@ const NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN = buildProviderErrorPattern([
2828
// spend limit, so same-model retries can never recover it.
2929
"credits_required",
3030
"credits are required",
31+
32+
// Request-shape rejections: the provider refused the payload we built, not the
33+
// work it describes. Gateways wrap these in whatever status they like — the
34+
// observed Apitopia/Kimi case arrives as `500 server_error: Invalid request:
35+
// tools.function.parameters.type is required and must be "object"` — so the
36+
// status text alone would classify a permanent failure as transient. The same
37+
// bytes are rejected on every attempt and on every fallback model, so retrying
38+
// can only burn the turn. Anchored on the `tools[...]`/`functions[...]` request
39+
// path so unrelated prose mentioning tools stays retryable.
40+
"invalid request: tools\\.",
41+
"invalid request: functions\\.",
42+
"tools\\.[^ ]*function\\.parameters",
43+
"tools\\.\\d+\\.function\\.parameters",
44+
"invalid tool schema",
3145
]);
3246

3347
const RETRYABLE_PROVIDER_ERROR_PATTERN = buildProviderErrorPattern([
@@ -285,6 +299,6 @@ export function isProviderTimeoutError(message: AssistantMessage): boolean {
285299
*/
286300
export function isRetryableErrorMessage(errorMessage: string): boolean {
287301
if (!errorMessage) return false;
288-
if (NON_RETRYABLE_PROVIDER_LIMIT_ERROR_PATTERN.test(errorMessage)) return false;
302+
if (NON_RETRYABLE_PROVIDER_ERROR_PATTERN.test(errorMessage)) return false;
289303
return RETRYABLE_PROVIDER_ERROR_PATTERN.test(errorMessage);
290304
}

packages/ai/src/utils/tool-schema-compat.ts

Lines changed: 73 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -90,19 +90,34 @@ function collapseConstUnion(node: Record<string, unknown>): void {
9090
node.enum = values;
9191
}
9292

93+
/**
94+
* Collapse a root-level object union into one object schema.
95+
*
96+
* A tool's root parameters schema must be a plain `{"type":"object"}` schema:
97+
* OpenAI-compatible gateways reject a root that only carries `anyOf`/`oneOf`
98+
* with `tools.function.parameters.type is required and must be "object"`. The
99+
* merge is intentionally lossy in the direction of permissiveness — branch
100+
* exclusivity becomes advisory — but it must never lose a declared parameter,
101+
* so the root's own `properties` and `required` are merged with the branches'
102+
* rather than replaced by them.
103+
*/
93104
function mergeRootObjectUnion(schema: Record<string, unknown>): Record<string, unknown> | undefined {
94105
const branches = Array.isArray(schema.anyOf) ? schema.anyOf : Array.isArray(schema.oneOf) ? schema.oneOf : undefined;
95106
if (branches === undefined || branches.length === 0) return undefined;
107+
if (schema.properties !== undefined && !isJsonObject(schema.properties)) return undefined;
108+
if (schema.required !== undefined && !Array.isArray(schema.required)) return undefined;
96109

97110
const objectBranches: Record<string, unknown>[] = [];
98111
for (const branch of branches) {
99-
if (!isJsonObject(branch) || branch.type !== "object") return undefined;
112+
// An untyped branch is a constraint-only variant (e.g. `{ required: [...] }`)
113+
// over the root's own properties, so it merges like an object branch.
114+
if (!isJsonObject(branch) || (branch.type !== "object" && branch.type !== undefined)) return undefined;
100115
if (branch.properties !== undefined && !isJsonObject(branch.properties)) return undefined;
101116
if (branch.required !== undefined && !Array.isArray(branch.required)) return undefined;
102117
objectBranches.push(branch);
103118
}
104119

105-
const properties: Record<string, unknown> = {};
120+
const properties: Record<string, unknown> = isJsonObject(schema.properties) ? { ...schema.properties } : {};
106121
for (const branch of objectBranches) {
107122
if (!isJsonObject(branch.properties)) continue;
108123
for (const [name, propertySchema] of Object.entries(branch.properties)) {
@@ -114,29 +129,36 @@ function mergeRootObjectUnion(schema: Record<string, unknown>): Record<string, u
114129
}
115130
}
116131

117-
const firstRequired = objectBranches[0]?.required;
118-
let commonRequired = Array.isArray(firstRequired)
119-
? firstRequired.filter((name): name is string => typeof name === "string")
132+
// Only names required by EVERY branch stay required; a name required by one
133+
// branch alone would reject payloads the union accepts. Root-level `required`
134+
// applies to all branches, so it is unioned back in.
135+
const rootRequired = Array.isArray(schema.required)
136+
? schema.required.filter((name): name is string => typeof name === "string")
120137
: [];
121-
for (const branch of objectBranches.slice(1)) {
122-
const branchRequired = new Set(
123-
Array.isArray(branch.required)
124-
? branch.required.filter((name): name is string => typeof name === "string")
125-
: [],
126-
);
127-
commonRequired = commonRequired.filter((name) => branchRequired.has(name));
128-
}
138+
const branchRequiredSets = objectBranches.map(
139+
(branch) =>
140+
new Set(
141+
Array.isArray(branch.required)
142+
? branch.required.filter((name): name is string => typeof name === "string")
143+
: [],
144+
),
145+
);
146+
const firstBranchRequired = branchRequiredSets[0];
147+
const commonBranchRequired = firstBranchRequired
148+
? [...firstBranchRequired].filter((name) => branchRequiredSets.every((names) => names.has(name)))
149+
: [];
150+
const required = [...new Set([...rootRequired, ...commonBranchRequired])];
129151

130152
const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = schema;
131153
return {
132154
...rest,
133155
type: "object",
134156
properties,
135-
...(commonRequired.length > 0 ? { required: commonRequired } : {}),
157+
...(required.length > 0 ? { required } : {}),
136158
};
137159
}
138160

139-
function normalizeNode(node: unknown): unknown {
161+
function normalizeNode(node: unknown, isRoot = false): unknown {
140162
if (Array.isArray(node)) {
141163
return node.map((child) => normalizeNode(child));
142164
}
@@ -146,7 +168,9 @@ function normalizeNode(node: unknown): unknown {
146168
}
147169

148170
const hasCombiner = COMBINER_KEYS.some((key) => Array.isArray(node[key]));
149-
if (hasCombiner) {
171+
// The root of a tool's parameters must keep `type: "object"`; hoisting it into
172+
// the branches leaves a typeless root that gateways reject outright.
173+
if (hasCombiner && !isRoot) {
150174
moveTypeIntoCombinerBranches(node);
151175
}
152176

@@ -183,16 +207,46 @@ function normalizeNode(node: unknown): unknown {
183207
* for OpenAI-compatible Chat Completions backends.
184208
*/
185209
export function normalizeToolParametersForOpenAICompat(schema: Record<string, unknown>): Record<string, unknown> {
186-
return normalizeNode(structuredClone(schema)) as Record<string, unknown>;
210+
const normalized = normalizeNode(structuredClone(schema), true) as Record<string, unknown>;
211+
return ensureRootObjectSchema(normalized);
212+
}
213+
214+
/**
215+
* Guarantee the wire shape every OpenAI-compatible backend requires for tool
216+
* parameters: a root object schema. A root union of object shapes is merged into
217+
* one object schema; a root that merely lost its `type` gets it restored.
218+
*
219+
* A root whose branches are not object shapes is left alone: forcing
220+
* `type: "object"` onto a scalar union would assert something the schema
221+
* contradicts, which is worse than the missing keyword. Tool parameters are
222+
* objects in practice, so this only guards against corrupting an exotic schema.
223+
*/
224+
function ensureRootObjectSchema(schema: Record<string, unknown>): Record<string, unknown> {
225+
const merged = mergeRootObjectUnion(schema);
226+
if (merged) return merged;
227+
if (schema.type !== undefined) return schema;
228+
const hasCombiner = COMBINER_KEYS.some((key) => Array.isArray(schema[key]));
229+
if (hasCombiner) return schema;
230+
return { ...schema, type: "object" };
187231
}
188232

189233
/**
190234
* Moonshot-flavored JSON Schema subset: in addition to the OpenAI-compatible
191235
* normalization, drop non-structural annotation keywords that Moonshot rejects.
192236
*/
193237
export function normalizeToolParametersForMoonshot(schema: Record<string, unknown>): Record<string, unknown> {
194-
const normalized = normalizeToolParametersForOpenAICompat(schema);
195-
return stripMoonshotAnnotations(mergeRootObjectUnion(normalized) ?? normalized);
238+
return stripMoonshotAnnotations(normalizeToolParametersForOpenAICompat(schema));
239+
}
240+
241+
/**
242+
* Resolve a tool's root parameters into a single object schema, without the
243+
* OpenAI-specific rewrites. Wire formats that read a tool's parameters from
244+
* top-level `properties`/`required` need this: a root union carries neither, so
245+
* they would otherwise describe the tool to the model as taking no arguments.
246+
* Schemas that are already plain objects are returned untouched.
247+
*/
248+
export function resolveRootObjectSchema(schema: Record<string, unknown>): Record<string, unknown> {
249+
return mergeRootObjectUnion(structuredClone(schema)) ?? schema;
196250
}
197251

198252
function stripMoonshotAnnotations(node: unknown): Record<string, unknown> {
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { getModel } from "../src/compat.ts";
3+
import { streamAnthropic } from "../src/providers/anthropic.ts";
4+
import type { Context, Model } from "../src/types.ts";
5+
6+
interface AnthropicToolPayload {
7+
tools?: Array<{ name: string; input_schema: { properties?: Record<string, unknown>; required?: string[] } }>;
8+
}
9+
10+
interface AnthropicMockState {
11+
createParams: AnthropicToolPayload | undefined;
12+
}
13+
14+
const mockState = vi.hoisted<AnthropicMockState>(() => ({ createParams: undefined }));
15+
16+
vi.mock("@anthropic-ai/sdk", () => {
17+
function createSseResponse(): Response {
18+
const body = [
19+
`event: message_start\ndata: ${JSON.stringify({
20+
type: "message_start",
21+
message: { id: "msg_test", usage: { input_tokens: 10, output_tokens: 0 } },
22+
})}\n`,
23+
`event: message_delta\ndata: ${JSON.stringify({
24+
type: "message_delta",
25+
delta: { stop_reason: "end_turn" },
26+
usage: { output_tokens: 5 },
27+
})}\n`,
28+
`event: message_stop\ndata: ${JSON.stringify({ type: "message_stop" })}\n`,
29+
].join("\n");
30+
31+
return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } });
32+
}
33+
34+
class FakeAnthropic {
35+
messages = {
36+
create: (params: AnthropicToolPayload) => {
37+
mockState.createParams = params;
38+
return { asResponse: async () => createSseResponse() };
39+
},
40+
};
41+
}
42+
43+
return { default: FakeAnthropic };
44+
});
45+
46+
// Plugin and MCP tools ship root unions routinely and senpi cannot flatten a
47+
// schema it does not own. The Anthropic conversion reads top-level properties
48+
// only, so these arrived as {"properties":{},"required":[]}: a parameterless tool.
49+
const rootUnionContext: Context = {
50+
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
51+
tools: [
52+
{
53+
name: "monitor",
54+
description: "Subscribe to a command's output",
55+
parameters: {
56+
anyOf: [
57+
{
58+
type: "object",
59+
properties: { command: { type: "string" }, description: { type: "string" } },
60+
required: ["command", "description"],
61+
},
62+
{
63+
type: "object",
64+
properties: { bash_id: { type: "string" } },
65+
required: ["bash_id"],
66+
},
67+
],
68+
},
69+
},
70+
],
71+
};
72+
73+
async function capturePayload(model: Model<"anthropic-messages">, context: Context): Promise<AnthropicToolPayload> {
74+
await streamAnthropic({ ...model, baseUrl: "http://127.0.0.1:9" }, context, { apiKey: "fake-key" }).result();
75+
if (!mockState.createParams) throw new Error("Expected payload to be captured");
76+
return mockState.createParams;
77+
}
78+
79+
describe("Anthropic root-union tool schemas", () => {
80+
beforeEach(() => {
81+
mockState.createParams = undefined;
82+
});
83+
84+
it("sends the union's parameters instead of an empty schema", async () => {
85+
const payload = await capturePayload(getModel("anthropic", "claude-fable-5"), rootUnionContext);
86+
87+
const schema = payload.tools?.[0]?.input_schema;
88+
expect(Object.keys(schema?.properties ?? {}).sort()).toEqual(["bash_id", "command", "description"]);
89+
});
90+
91+
it("requires only what every branch of the union requires", async () => {
92+
const payload = await capturePayload(getModel("anthropic", "claude-fable-5"), rootUnionContext);
93+
94+
// `command` is required by one branch only, so forcing it globally would
95+
// reject calls the union accepts.
96+
expect(payload.tools?.[0]?.input_schema.required).toEqual([]);
97+
});
98+
99+
it("leaves an ordinary object schema untouched", async () => {
100+
const payload = await capturePayload(getModel("anthropic", "claude-fable-5"), {
101+
messages: [{ role: "user", content: "Hello", timestamp: Date.now() }],
102+
tools: [
103+
{
104+
name: "get_weather",
105+
description: "Get the weather",
106+
parameters: {
107+
type: "object",
108+
properties: { city: { type: "string" } },
109+
required: ["city"],
110+
},
111+
},
112+
],
113+
});
114+
115+
expect(payload.tools?.[0]?.input_schema.properties).toEqual({ city: { type: "string" } });
116+
expect(payload.tools?.[0]?.input_schema.required).toEqual(["city"]);
117+
});
118+
});

0 commit comments

Comments
 (0)