Compare commits

...

6 Commits

2 changed files with 218 additions and 2 deletions
+100 -2
View File
@@ -368,6 +368,9 @@ function toAiSdkMessages(
function toAiSdkTools(
request: GatewayStreamRequest,
options: {
sanitizeOpenAISchemaPatterns?: boolean;
} = {},
): Record<string, unknown> | undefined {
if (!request.tools?.length) {
return undefined;
@@ -384,13 +387,105 @@ function toAiSdkTools(
{
description: definition.description,
inputSchema: jsonSchema(
normalizeAiSdkToolInputSchema(definition.inputSchema),
normalizeAiSdkToolInputSchema(
options.sanitizeOpenAISchemaPatterns === true
? sanitizeOpenAIUnsupportedToolSchemaPatterns(
definition.inputSchema,
)
: definition.inputSchema,
),
) as never,
} as unknown,
]),
);
}
const OPENAI_UNSUPPORTED_REGEX_LOOKAROUNDS = new Set(["=", "!", "<=", "<!"]);
function hasOpenAIUnsupportedRegexLookaround(pattern: string): boolean {
let groupStart = pattern.indexOf("(?");
while (groupStart !== -1) {
let escapeCount = 0;
for (let i = groupStart - 1; i >= 0 && pattern[i] === "\\"; i -= 1) {
escapeCount += 1;
}
if (escapeCount % 2 === 0) {
const operator =
pattern[groupStart + 2] === "<"
? pattern.slice(groupStart + 2, groupStart + 4)
: pattern[groupStart + 2];
if (OPENAI_UNSUPPORTED_REGEX_LOOKAROUNDS.has(operator)) {
return true;
}
}
groupStart = pattern.indexOf("(?", groupStart + 2);
}
return false;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function sanitizePatternProperties(
schemaMap: Record<string, unknown>,
): Record<string, unknown> {
const output: Record<string, unknown> = {};
for (const [pattern, schema] of Object.entries(schemaMap)) {
if (hasOpenAIUnsupportedRegexLookaround(pattern)) {
continue;
}
output[pattern] = sanitizeOpenAIUnsupportedSchemaNode(schema);
}
return output;
}
function sanitizeOpenAIUnsupportedSchemaNode(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(sanitizeOpenAIUnsupportedSchemaNode);
}
if (!isRecord(value)) {
return value;
}
const output: Record<string, unknown> = {};
for (const [key, child] of Object.entries(value)) {
if (
key === "pattern" &&
typeof child === "string" &&
hasOpenAIUnsupportedRegexLookaround(child)
) {
continue;
}
if (key === "patternProperties" && isRecord(child)) {
output[key] = sanitizePatternProperties(child);
continue;
}
output[key] = sanitizeOpenAIUnsupportedSchemaNode(child);
}
return output;
}
function sanitizeOpenAIUnsupportedToolSchemaPatterns(
inputSchema: Record<string, unknown>,
): Record<string, unknown> {
const sanitized = sanitizeOpenAIUnsupportedSchemaNode(inputSchema);
return isRecord(sanitized) ? sanitized : inputSchema;
}
function shouldSanitizeOpenAIToolSchemaPatterns(
kind: ProviderModuleKind,
): boolean {
switch (kind) {
case "openai":
case "openai-compatible":
case "openai-codex":
return true;
default:
return false;
}
}
interface RepairableToolCall {
toolCallId: string;
toolName: string;
@@ -1160,7 +1255,10 @@ function createAiSdkProvider(kind: ProviderModuleKind): GatewayProviderFactory {
);
const tools = providerDisablesExternalToolExecution(context)
? undefined
: toAiSdkTools(request);
: toAiSdkTools(request, {
sanitizeOpenAISchemaPatterns:
shouldSanitizeOpenAIToolSchemaPatterns(kind),
});
const systemPrompt = resolveAiSdkSystemPrompt(request);
const useSystemOption =
typeof systemPrompt === "string" && systemPrompt.trim().length > 0;
@@ -2363,6 +2363,124 @@ describe("sdk-gateway", () => {
});
});
it("strips OpenAI-unsupported regex lookaround patterns from Cline tool schemas", async () => {
streamTextSpy.mockReturnValue({
fullStream: makeStreamParts([
{ type: "finish", usage: { inputTokens: 1, outputTokens: 1 } },
]),
});
const gateway = createGateway({
providerConfigs: [{ providerId: "cline", apiKey: "cline-key" }],
});
await collect(
await gateway.stream({
providerId: "cline",
modelId: "openai/gpt-5.5",
messages: baseMessages,
tools: [
{
name: "read_handoff",
description: "Read handoff content",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/-]+$",
},
slug: {
type: "string",
pattern: "^[a-z0-9_-]+$",
},
literal: {
type: "string",
pattern: "\\(?!literal",
},
},
patternProperties: {
"^(?!secret_)": { type: "string" },
".*": { type: "boolean" },
"^public_": { type: "number" },
},
propertyNames: { pattern: "^(?!invalid$)" },
required: ["path"],
},
},
],
}),
);
const call = streamTextSpy.mock.calls[0]?.[0] as
| { tools?: Record<string, { inputSchema?: { jsonSchema?: unknown } }> }
| undefined;
const schema = await call?.tools?.read_handoff.inputSchema?.jsonSchema;
expect(schema).toEqual({
type: "object",
properties: {
path: { type: "string" },
slug: { type: "string", pattern: "^[a-z0-9_-]+$" },
literal: { type: "string", pattern: "\\(?!literal" },
},
patternProperties: {
".*": { type: "boolean" },
"^public_": { type: "number" },
},
propertyNames: {},
required: ["path"],
});
});
it("preserves regex lookaround patterns for non-OpenAI tool schemas", async () => {
streamTextSpy.mockReturnValue({
fullStream: makeStreamParts([
{ type: "finish", usage: { inputTokens: 1, outputTokens: 1 } },
]),
});
const gateway = createGateway({
providerConfigs: [{ providerId: "anthropic", apiKey: "anthropic-key" }],
});
await collect(
await gateway.stream({
providerId: "anthropic",
modelId: "claude-sonnet-4-5",
messages: baseMessages,
tools: [
{
name: "read_handoff",
description: "Read handoff content",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/-]+$",
},
},
},
},
],
}),
);
const call = streamTextSpy.mock.calls[0]?.[0] as
| { tools?: Record<string, { inputSchema?: { jsonSchema?: unknown } }> }
| undefined;
const schema = await call?.tools?.read_handoff.inputSchema?.jsonSchema;
expect(schema).toEqual({
type: "object",
properties: {
path: {
type: "string",
pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/-]+$",
},
},
});
});
it("passes reasoning effort through to Anthropic provider options", async () => {
streamTextSpy.mockReturnValue({
fullStream: makeStreamParts([