Skip to content
This repository was archived by the owner on Feb 28, 2026. It is now read-only.

Commit 022201a

Browse files
authored
fix: use schema-aware normalization for tool call arguments (#12)
This fixes the issue where JSON-formatted string parameters (like the 'content' field in the 'write' tool) were incorrectly parsed into objects, causing OpenCode validation to fail with 'expected string, received object'. The fix introduces a tool schema cache that stores parameter type information during request transformation. When normalizing tool call arguments in responses, the new normalizeToolCallArgs function: - Preserves string parameters as-is (only processing escape sequences) - Parses JSON strings only when the schema expects array/object types - Falls back to conservative behavior when schema info is unavailable This affects both Gemini and Claude models. Fixes #11
1 parent c4c72de commit 022201a

6 files changed

Lines changed: 268 additions & 2 deletions

File tree

src/plugin/request-helpers.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { randomUUID } from "node:crypto";
2+
import { getParamType } from "./tool-schema-cache";
23

34
const SESSION_ID = `-${Math.floor(Math.random() * 9_000_000_000_000_000)}`;
45

@@ -344,3 +345,69 @@ export function recursivelyParseJsonStrings(value: unknown): unknown {
344345

345346
return value;
346347
}
348+
349+
/**
350+
* Processes a value by only unescaping control characters (like \n, \t).
351+
* It does NOT attempt to parse JSON objects from strings.
352+
*/
353+
export function processEscapeSequencesOnly(value: unknown): unknown {
354+
if (typeof value !== "string") {
355+
return value;
356+
}
357+
358+
const hasControlCharEscapes = value.includes("\\n") || value.includes("\\t");
359+
const hasIntentionalEscapes = value.includes('\\"') || value.includes("\\\\");
360+
361+
if (hasControlCharEscapes && !hasIntentionalEscapes) {
362+
try {
363+
// Use JSON.parse to correctly handle unescaping control characters
364+
const unescaped = JSON.parse(`"${value.replaceAll('"', '\\"')}"`);
365+
if (typeof unescaped === "string") {
366+
return unescaped;
367+
}
368+
} catch {
369+
// Fall back to original
370+
}
371+
}
372+
373+
return value;
374+
}
375+
376+
/**
377+
* Normalizes tool call arguments based on their schema.
378+
* - If schema says string: only unescape control characters, don't parse as JSON.
379+
* - If schema says array/object: attempt to parse string as JSON.
380+
* - If no schema: fallback to processEscapeSequencesOnly.
381+
*/
382+
export function normalizeToolCallArgs(args: unknown, toolName: string): unknown {
383+
if (!args || typeof args !== "object") {
384+
return args;
385+
}
386+
387+
const record = args as Record<string, unknown>;
388+
const result: Record<string, unknown> = {};
389+
390+
for (const [key, value] of Object.entries(record)) {
391+
const expectedType = getParamType(toolName, key);
392+
393+
if (expectedType === "string") {
394+
result[key] = processEscapeSequencesOnly(value);
395+
} else if (typeof value === "string" && (expectedType === "array" || expectedType === "object")) {
396+
// If we expect an array/object but got a string, try to parse it
397+
try {
398+
const parsed = JSON.parse(value);
399+
result[key] = parsed;
400+
} catch {
401+
result[key] = processEscapeSequencesOnly(value);
402+
}
403+
} else if (expectedType === undefined) {
404+
// No schema info: be conservative and only unescape control characters
405+
result[key] = processEscapeSequencesOnly(value);
406+
} else {
407+
// For other types, or if it's already an object, just process escapes
408+
result[key] = processEscapeSequencesOnly(value);
409+
}
410+
}
411+
412+
return result;
413+
}

src/plugin/request.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
generateRequestId,
99
getSessionId,
1010
parseGeminiApiBody,
11+
normalizeToolCallArgs,
1112
recursivelyParseJsonStrings,
1213
rewriteGeminiPreviewAccessError,
1314
rewriteGeminiRateLimitError,
@@ -321,7 +322,7 @@ function normalizeToolArgsInResponse(response: unknown): void {
321322
const functionCall = (part as any)?.functionCall;
322323
if (functionCall && "args" in functionCall) {
323324
const beforeArgs = functionCall.args;
324-
const afterArgs = recursivelyParseJsonStrings(beforeArgs);
325+
const afterArgs = normalizeToolCallArgs(beforeArgs, functionCall.name);
325326
functionCall.args = afterArgs;
326327

327328
if (typeof beforeArgs === "string" && beforeArgs !== afterArgs) {

src/plugin/tool-schema-cache.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
2+
/**
3+
* Info about a tool parameter schema.
4+
*/
5+
export type SchemaInfo = {
6+
type: string;
7+
items?: SchemaInfo;
8+
properties?: Record<string, SchemaInfo>;
9+
};
10+
11+
/**
12+
* Cache structure: Map<toolName, Map<paramName, SchemaInfo>>
13+
*/
14+
const toolSchemaCache = new Map<string, Map<string, SchemaInfo>>();
15+
16+
/**
17+
* Sanitizes tool names for Gemini API compatibility.
18+
* This should match the logic in gemini.ts.
19+
*/
20+
function sanitizeToolName(name: string): string {
21+
if (/^[0-9]/.test(name)) {
22+
return `t_${name}`;
23+
}
24+
return name;
25+
}
26+
27+
/**
28+
* Recursively extracts schema info from a JSON schema object.
29+
*/
30+
function extractSchemaInfo(schema: any): SchemaInfo {
31+
const type = schema?.type || "unknown";
32+
const info: SchemaInfo = { type };
33+
34+
if (type === "array" && schema.items) {
35+
info.items = extractSchemaInfo(schema.items);
36+
} else if (type === "object" && schema.properties) {
37+
info.properties = {};
38+
for (const [key, value] of Object.entries(schema.properties)) {
39+
info.properties[key] = extractSchemaInfo(value);
40+
}
41+
}
42+
43+
return info;
44+
}
45+
46+
/**
47+
* Caches tool schemas from a request payload.
48+
*/
49+
export function cacheToolSchemas(tools: any[] | undefined): void {
50+
if (!Array.isArray(tools)) return;
51+
52+
for (const tool of tools) {
53+
const funcDecls = tool.functionDeclarations;
54+
if (!Array.isArray(funcDecls)) continue;
55+
56+
for (const funcDecl of funcDecls) {
57+
const originalName = funcDecl.name;
58+
if (typeof originalName !== "string") continue;
59+
60+
const sanitizedName = sanitizeToolName(originalName);
61+
const schema = (funcDecl.parametersJsonSchema ?? funcDecl.parameters) as any;
62+
63+
if (!schema || typeof schema !== "object") continue;
64+
65+
const properties = schema.properties;
66+
if (!properties || typeof properties !== "object") continue;
67+
68+
const paramMap = new Map<string, SchemaInfo>();
69+
for (const [paramName, paramSchema] of Object.entries(properties)) {
70+
paramMap.set(paramName, extractSchemaInfo(paramSchema));
71+
}
72+
73+
toolSchemaCache.set(sanitizedName, paramMap);
74+
// Also cache with original name to be safe
75+
if (sanitizedName !== originalName) {
76+
toolSchemaCache.set(originalName, paramMap);
77+
}
78+
}
79+
}
80+
}
81+
82+
/**
83+
* Gets the expected type for a tool parameter.
84+
*/
85+
export function getParamType(toolName: string, paramName: string): string | undefined {
86+
return toolSchemaCache.get(toolName)?.get(paramName)?.type;
87+
}
88+
89+
/**
90+
* Clears the tool schema cache.
91+
*/
92+
export function clearToolSchemaCache(): void {
93+
toolSchemaCache.clear();
94+
}

src/plugin/transform/claude.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
22
import { cacheSignature, getCachedSignature } from "../cache";
33
import { createLogger } from "../logger";
44
import { normalizeThinkingConfig } from "../request-helpers";
5+
import { cacheToolSchemas } from "../tool-schema-cache";
56
import type { RequestPayload, TransformContext, TransformResult } from "./types";
67

78
const log = createLogger("transform.claude");
@@ -187,6 +188,9 @@ export function transformClaudeRequest(
187188
delete requestPayload.model;
188189
}
189190

191+
// Cache tool schemas for response normalization
192+
cacheToolSchemas(requestPayload.tools as any[]);
193+
190194
const tools = requestPayload.tools as Array<Record<string, unknown>> | undefined;
191195
if (Array.isArray(tools)) {
192196
for (const tool of tools) {

src/plugin/transform/gemini.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { getCachedSignature } from "../cache";
22
import { createLogger } from "../logger";
33
import { normalizeThinkingConfig } from "../request-helpers";
4+
import { cacheToolSchemas } from "../tool-schema-cache";
45
import type { RequestPayload, TransformContext, TransformResult } from "./types";
56

67
const log = createLogger("transform.gemini");
@@ -516,6 +517,9 @@ export function transformGeminiRequest(
516517
// Sanitize tool names to ensure Gemini API compatibility
517518
sanitizeToolNames(requestPayload);
518519

520+
// Cache tool schemas for response normalization
521+
cacheToolSchemas(requestPayload.tools as any[]);
522+
519523
augmentToolDescriptionsWithStrictParams(requestPayload);
520524
injectSystemInstructionIfNeeded(requestPayload);
521525
scrubConversationArtifactsFromModelHistory(requestPayload);

src/plugin/transform/signature.test.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { describe, expect, it } from "bun:test";
22

33
import { cacheSignature } from "../cache";
4-
import { recursivelyParseJsonStrings } from "../request-helpers";
4+
import { normalizeToolCallArgs, recursivelyParseJsonStrings } from "../request-helpers";
5+
import { cacheToolSchemas, clearToolSchemaCache } from "../tool-schema-cache";
56
import { transformClaudeRequest } from "./claude";
67
import { transformGeminiRequest } from "./gemini";
78
import type { ModelFamily, RequestPayload, TransformContext } from "./types";
@@ -205,6 +206,101 @@ describe("thoughtSignature handling", () => {
205206
});
206207
});
207208

209+
describe("normalizeToolCallArgs (Schema-Aware)", () => {
210+
it("preserves JSON string when schema says string", () => {
211+
clearToolSchemaCache();
212+
cacheToolSchemas([
213+
{
214+
functionDeclarations: [
215+
{
216+
name: "write",
217+
parameters: {
218+
type: "object",
219+
properties: {
220+
content: { type: "string" },
221+
filePath: { type: "string" }
222+
}
223+
}
224+
}
225+
]
226+
}
227+
]);
228+
229+
const args = {
230+
content: '{"name": "test", "version": "1.0.0"}',
231+
filePath: "test.json"
232+
};
233+
234+
const normalized = normalizeToolCallArgs(args, "write") as any;
235+
expect(typeof normalized.content).toBe("string");
236+
expect(normalized.content).toBe('{"name": "test", "version": "1.0.0"}');
237+
});
238+
239+
it("parses JSON string when schema says array", () => {
240+
clearToolSchemaCache();
241+
cacheToolSchemas([
242+
{
243+
functionDeclarations: [
244+
{
245+
name: "read",
246+
parameters: {
247+
type: "object",
248+
properties: {
249+
files: { type: "array" }
250+
}
251+
}
252+
}
253+
]
254+
}
255+
]);
256+
257+
const args = {
258+
files: '[{"path": "a.txt"}]'
259+
};
260+
261+
const normalized = normalizeToolCallArgs(args, "read") as any;
262+
expect(Array.isArray(normalized.files)).toBe(true);
263+
expect(normalized.files[0].path).toBe("a.txt");
264+
});
265+
266+
it("handles sanitized tool names correctly", () => {
267+
clearToolSchemaCache();
268+
// Tool name starting with number
269+
cacheToolSchemas([
270+
{
271+
functionDeclarations: [
272+
{
273+
name: "21st-dev-magic",
274+
parameters: {
275+
type: "object",
276+
properties: {
277+
code: { type: "string" }
278+
}
279+
}
280+
}
281+
]
282+
}
283+
]);
284+
285+
const args = {
286+
code: '{"foo": "bar"}'
287+
};
288+
289+
// Try with sanitized name
290+
const normalized = normalizeToolCallArgs(args, "t_21st-dev-magic") as any;
291+
expect(typeof normalized.code).toBe("string");
292+
expect(normalized.code).toBe('{"foo": "bar"}');
293+
});
294+
295+
it("unescapes control characters in strings", () => {
296+
const args = {
297+
text: "line1\\nline2"
298+
};
299+
const normalized = normalizeToolCallArgs(args, "any") as any;
300+
expect(normalized.text).toBe("line1\nline2");
301+
});
302+
});
303+
208304
describe("claude transformer", () => {
209305
it("keeps thinking blocks with valid signatures (length > 50)", () => {
210306
const payload: RequestPayload = {

0 commit comments

Comments
 (0)