mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
fix(ai): apply Gemini schema handling to Gemini on any route
The toolSchema "gemini" mode still used the legacy OpenAPI projection, and nothing enabled it, so Gemini reached through OpenAI-compatible endpoints and gateways got schemas unchanged. Point the mode at the JSON Schema normalizer, delete the old projection, and select the mode from the model name when no tool schema mode is set explicitly. The native Gemini protocol always applies the normalizer, including for tuned endpoints whose IDs do not name Gemini.
This commit is contained in:
@@ -1034,7 +1034,6 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
const format = outputConfig?.format ?? undefined
|
||||
const updates = resolveEffortUpdates(request, options.effort ?? outputConfig?.effort ?? undefined)
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
// Allocate the 4-breakpoint budget in invalidation order: tools → system →
|
||||
// messages. Tools live highest in the cache hierarchy, so when callers
|
||||
// over-mark we keep their tool hints and shed the message-tail ones first.
|
||||
@@ -1044,11 +1043,7 @@ const fromRequest = Effect.fn("AnthropicMessages.fromRequest")(function* (reques
|
||||
flattened.tools.length === 0
|
||||
? undefined
|
||||
: flattened.tools.map((tool) =>
|
||||
lowerTool(
|
||||
breakpoints,
|
||||
tool,
|
||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
|
||||
),
|
||||
lowerTool(breakpoints, tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model)),
|
||||
)
|
||||
// Anthropic rejects tool_choice when tools are absent; "none" is only meaningful with tools present.
|
||||
const toolChoice = tools === undefined || !request.toolChoice ? undefined : yield* lowerToolChoice(request.toolChoice)
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type FinishReasonDetails,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type LanguageModelToolSchemaCompatibility,
|
||||
type LanguageModel,
|
||||
type ProviderMetadata,
|
||||
type ReasoningPart,
|
||||
type ToolCallPart,
|
||||
@@ -230,13 +230,13 @@ const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockTo
|
||||
})
|
||||
|
||||
const lowerTools = (
|
||||
compatibility: LanguageModelToolSchemaCompatibility | undefined,
|
||||
model: LanguageModel,
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
tools: ReadonlyArray<ToolDefinition>,
|
||||
): BedrockTool[] => {
|
||||
const result: BedrockTool[] = []
|
||||
for (const tool of tools) {
|
||||
result.push(lowerToolSpec(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, compatibility)))
|
||||
result.push(lowerToolSpec(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, model)))
|
||||
const cachePoint = BedrockCache.block(breakpoints, tool.cache)
|
||||
if (cachePoint) result.push(cachePoint)
|
||||
}
|
||||
@@ -440,7 +440,7 @@ const fromRequest = Effect.fn("BedrockConverse.fromRequest")(function* (request:
|
||||
const toolConfig = (() => {
|
||||
if (flattened.tools.length === 0) return undefined
|
||||
return {
|
||||
tools: lowerTools(request.model.compatibility?.toolSchema, breakpoints, flattened.tools),
|
||||
tools: lowerTools(request.model, breakpoints, flattened.tools),
|
||||
// Converse has no native "none". Keep definitions stable for prompt
|
||||
// caching and omit only the unsupported choice.
|
||||
toolChoice,
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
LLMEvent,
|
||||
Usage,
|
||||
type FinishReason,
|
||||
type JsonSchema,
|
||||
type LLMRequest,
|
||||
type MediaPart,
|
||||
type ProviderMetadata,
|
||||
@@ -25,7 +24,6 @@ import { JsonObject, knownString, lenient, optionalArray, optionalNull, Provider
|
||||
import { GeminiGenerateContent } from "./utils/gemini-generate-content.js"
|
||||
import { GeminiJsonSchema } from "./utils/gemini-json-schema.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
|
||||
const ADAPTER = "gemini"
|
||||
// Google documents this sentinel for replaying Gemini 3 function calls after their original signature was lost.
|
||||
@@ -270,11 +268,12 @@ interface ParserState {
|
||||
// Request Lowering
|
||||
// =============================================================================
|
||||
// Tool schemas go in `parametersJsonSchema`, which accepts standard JSON Schema.
|
||||
// `utils/gemini-json-schema` rewrites only the few shapes Gemini still rejects.
|
||||
const lowerTool = (tool: ToolDefinition, inputSchema: JsonSchema) => ({
|
||||
// `utils/gemini-json-schema` rewrites only the few shapes Gemini still rejects. This
|
||||
// protocol always applies it, including for tuned endpoints whose IDs do not name Gemini.
|
||||
const lowerTool = (tool: ToolDefinition) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parametersJsonSchema: GeminiJsonSchema.normalize(inputSchema),
|
||||
parametersJsonSchema: GeminiJsonSchema.normalize(tool.inputSchema),
|
||||
})
|
||||
|
||||
const lowerToolConfig = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
|
||||
@@ -444,7 +443,6 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
const hasTools = flattened.tools.length > 0
|
||||
const generation = request.generation
|
||||
const options = yield* decodeOptions(request.providerOptions ?? {})
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const generationConfig = {
|
||||
maxOutputTokens: generation?.maxTokens,
|
||||
temperature: generation?.temperature,
|
||||
@@ -470,9 +468,7 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
tools: hasTools
|
||||
? [
|
||||
{
|
||||
functionDeclarations: flattened.tools.map((tool) =>
|
||||
lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility)),
|
||||
),
|
||||
functionDeclarations: flattened.tools.map(lowerTool),
|
||||
},
|
||||
]
|
||||
: undefined,
|
||||
|
||||
@@ -107,7 +107,7 @@ const fromRequest = Effect.fn("MetaResponses.fromRequest")(function* (request: L
|
||||
return yield* OpenResponses.lowerTool(
|
||||
NAME,
|
||||
tool,
|
||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model.compatibility?.toolSchema),
|
||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model),
|
||||
)
|
||||
return yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(NativeTool))(tool.native.meta)
|
||||
}),
|
||||
|
||||
@@ -818,7 +818,6 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
const projected = ProviderShared.flattenToolRequest(request)
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
return {
|
||||
...(yield* lowerConversation(projected.request, adapter)),
|
||||
...lowerGeneration(request),
|
||||
@@ -828,11 +827,7 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
|
||||
: yield* Effect.forEach(projected.tools, (tool) =>
|
||||
tool.native !== undefined && adapter.nativeTool
|
||||
? adapter.nativeTool(tool.native)
|
||||
: lowerTool(
|
||||
adapter.name,
|
||||
tool,
|
||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
|
||||
),
|
||||
: lowerTool(adapter.name, tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model)),
|
||||
),
|
||||
tool_choice:
|
||||
allowedToolChoice(request) ??
|
||||
|
||||
@@ -803,7 +803,6 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
`OpenAI Chat reasoning field conflicts with reserved field ${reasoningField}`,
|
||||
)
|
||||
const generation = request.generation
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const flattened = ProviderShared.flattenToolRequest(request)
|
||||
const provider = String(request.model.provider)
|
||||
const baseURL = request.model.route.endpoint.baseURL
|
||||
@@ -829,7 +828,7 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
: flattened.tools.map((tool) =>
|
||||
lowerTool(
|
||||
tool,
|
||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, toolSchemaCompatibility),
|
||||
ToolSchemaProjection.modelCompatibility(tool.inputSchema, request.model),
|
||||
options,
|
||||
supportsStrictMode,
|
||||
),
|
||||
|
||||
@@ -5,7 +5,14 @@ import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { LLMRequest, mergeJsonRecords, type JsonSchema, type ToolDefinition, type ToolEntry } from "../schema/index.js"
|
||||
import {
|
||||
LLMRequest,
|
||||
mergeJsonRecords,
|
||||
type JsonSchema,
|
||||
type LanguageModel,
|
||||
type ToolDefinition,
|
||||
type ToolEntry,
|
||||
} from "../schema/index.js"
|
||||
import { resolveEffortUpdates } from "../effort-updates.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
|
||||
@@ -170,12 +177,9 @@ const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDe
|
||||
|
||||
// Native namespaces hold only function tools, so deeper levels flatten into
|
||||
// the leaf names the same way non-native protocols flatten the whole tree.
|
||||
const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* (
|
||||
tool: ToolEntry,
|
||||
compatibility: Parameters<typeof ToolSchemaProjection.modelCompatibility>[1],
|
||||
) {
|
||||
const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* (tool: ToolEntry, model: LanguageModel) {
|
||||
if (tool.type === "tool")
|
||||
return yield* lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, compatibility))
|
||||
return yield* lowerTool(tool, ToolSchemaProjection.modelCompatibility(tool.inputSchema, model))
|
||||
// OpenAI requires a namespace description; fall back to a generic one so a
|
||||
// missing description never blocks the request.
|
||||
return {
|
||||
@@ -183,7 +187,7 @@ const lowerToolEntry = Effect.fn("OpenAIResponses.lowerToolEntry")(function* (
|
||||
name: tool.name,
|
||||
description: tool.description ?? `Tools in the ${tool.name} namespace.`,
|
||||
tools: yield* Effect.forEach(ProviderShared.flattenTools(tool.tools), (leaf) =>
|
||||
OpenResponses.lowerTool(NAME, leaf, ToolSchemaProjection.modelCompatibility(leaf.inputSchema, compatibility)),
|
||||
OpenResponses.lowerTool(NAME, leaf, ToolSchemaProjection.modelCompatibility(leaf.inputSchema, model)),
|
||||
),
|
||||
}
|
||||
})
|
||||
@@ -207,7 +211,6 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
)(request.providerOptions?.contextManagement)
|
||||
const options = OpenResponsesOptions.resolve(request)
|
||||
const updates = resolveEffortUpdates(request, options.reasoningEffort)
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
return yield* decodeBody({
|
||||
...(yield* OpenResponses.lowerConversation(updates.request, adapter)),
|
||||
...OpenResponses.lowerGeneration(request, { ...options, reasoningEffort: updates.effort }),
|
||||
@@ -215,7 +218,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
tools:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
: yield* Effect.forEach(request.tools, (tool) => lowerToolEntry(tool, toolSchemaCompatibility)),
|
||||
: yield* Effect.forEach(request.tools, (tool) => lowerToolEntry(tool, request.model)),
|
||||
tool_choice:
|
||||
request.tools.length === 0
|
||||
? undefined
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import { isRecord } from "../../utils/record.js"
|
||||
|
||||
// Gemini accepts a JSON Schema-like dialect for tool parameters, but rejects a
|
||||
// handful of common JSON Schema shapes. Keep this projection isolated so the
|
||||
// Gemini protocol file still reads like the other protocol modules.
|
||||
const SCHEMA_INTENT_KEYS = [
|
||||
"type",
|
||||
"properties",
|
||||
"items",
|
||||
"prefixItems",
|
||||
"enum",
|
||||
"const",
|
||||
"$ref",
|
||||
"additionalProperties",
|
||||
"patternProperties",
|
||||
"required",
|
||||
"not",
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
]
|
||||
|
||||
const hasCombiner = (schema: unknown) =>
|
||||
isRecord(schema) && (Array.isArray(schema.anyOf) || Array.isArray(schema.oneOf) || Array.isArray(schema.allOf))
|
||||
|
||||
const hasSchemaIntent = (schema: unknown) =>
|
||||
isRecord(schema) && (hasCombiner(schema) || SCHEMA_INTENT_KEYS.some((key) => key in schema))
|
||||
|
||||
const sanitizeNode = (schema: unknown): unknown => {
|
||||
if (!isRecord(schema)) return Array.isArray(schema) ? schema.map(sanitizeNode) : schema
|
||||
|
||||
const result: Record<string, unknown> = Object.fromEntries(
|
||||
Object.entries(schema).map(([key, value]) => [
|
||||
key,
|
||||
key === "enum" && Array.isArray(value) ? value.map(String) : sanitizeNode(value),
|
||||
]),
|
||||
)
|
||||
|
||||
if (Array.isArray(result.enum) && (result.type === "integer" || result.type === "number")) result.type = "string"
|
||||
|
||||
const properties = result.properties
|
||||
if (result.type === "object" && isRecord(properties) && Array.isArray(result.required)) {
|
||||
result.required = result.required.filter((field) => typeof field === "string" && field in properties)
|
||||
}
|
||||
|
||||
if (result.type === "array" && !hasCombiner(result)) {
|
||||
result.items = result.items ?? {}
|
||||
if (isRecord(result.items) && !hasSchemaIntent(result.items)) result.items = { ...result.items, type: "string" }
|
||||
}
|
||||
|
||||
if (typeof result.type === "string" && result.type !== "object" && !hasCombiner(result)) {
|
||||
delete result.properties
|
||||
delete result.required
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const emptyObjectSchema = (schema: Record<string, unknown>) =>
|
||||
schema.type === "object" &&
|
||||
(!isRecord(schema.properties) || Object.keys(schema.properties).length === 0) &&
|
||||
!schema.additionalProperties
|
||||
|
||||
const projectNode = (schema: unknown, nested = false): Record<string, unknown> | undefined => {
|
||||
if (!isRecord(schema)) return undefined
|
||||
if (!nested && emptyObjectSchema(schema)) return undefined
|
||||
const types = Array.isArray(schema.type) ? schema.type.filter((type) => type !== "null") : undefined
|
||||
const anyOf = Array.isArray(schema.anyOf) ? schema.anyOf : undefined
|
||||
const hasNullAnyOf = anyOf?.some((item) => isRecord(item) && item.type === "null") ?? false
|
||||
const anyOfTypes = hasNullAnyOf ? anyOf?.filter((item) => !isRecord(item) || item.type !== "null") : anyOf
|
||||
const flattenedAnyOf = hasNullAnyOf && anyOfTypes?.length === 1 ? projectNode(anyOfTypes[0], true) : undefined
|
||||
const result = Object.fromEntries(
|
||||
[
|
||||
["description", schema.description],
|
||||
["required", schema.required],
|
||||
["format", schema.format],
|
||||
["type", types ? (types.length === 0 ? "null" : undefined) : schema.type],
|
||||
[
|
||||
"nullable",
|
||||
(Array.isArray(schema.type) && schema.type.includes("null") && types && types.length > 0) || hasNullAnyOf
|
||||
? true
|
||||
: undefined,
|
||||
],
|
||||
["enum", schema.const !== undefined ? [schema.const] : schema.enum],
|
||||
[
|
||||
"properties",
|
||||
isRecord(schema.properties)
|
||||
? Object.fromEntries(Object.entries(schema.properties).map(([key, value]) => [key, projectNode(value, true)]))
|
||||
: undefined,
|
||||
],
|
||||
[
|
||||
"items",
|
||||
Array.isArray(schema.items)
|
||||
? schema.items.map((item) => projectNode(item, true))
|
||||
: schema.items === undefined
|
||||
? undefined
|
||||
: projectNode(schema.items, true),
|
||||
],
|
||||
["allOf", Array.isArray(schema.allOf) ? schema.allOf.map((item) => projectNode(item, true)) : undefined],
|
||||
[
|
||||
"anyOf",
|
||||
anyOfTypes
|
||||
? hasNullAnyOf && anyOfTypes.length === 1
|
||||
? undefined
|
||||
: anyOfTypes.map((item) => projectNode(item, true))
|
||||
: types && types.length > 0
|
||||
? types.map((type) => ({ type }))
|
||||
: undefined,
|
||||
],
|
||||
["oneOf", Array.isArray(schema.oneOf) ? schema.oneOf.map((item) => projectNode(item, true)) : undefined],
|
||||
["minLength", schema.minLength],
|
||||
].filter((entry) => entry[1] !== undefined),
|
||||
)
|
||||
return flattenedAnyOf ? { ...result, ...flattenedAnyOf } : result
|
||||
}
|
||||
|
||||
export const convert = (schema: unknown) => projectNode(sanitizeNode(schema))
|
||||
|
||||
export * as GeminiToolSchema from "./gemini-tool-schema.js"
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { JsonSchema, LanguageModelToolSchemaCompatibility } from "../../schema/index.js"
|
||||
import type { JsonSchema, LanguageModel } from "../../schema/index.js"
|
||||
import { isRecord } from "../../utils/record.js"
|
||||
import { GeminiToolSchema } from "./gemini-tool-schema.js"
|
||||
import { GeminiJsonSchema } from "./gemini-json-schema.js"
|
||||
|
||||
const tupleItemsSchema = (items: ReadonlyArray<unknown>) => {
|
||||
const projected = items.map(moonshotNode)
|
||||
@@ -46,18 +46,18 @@ const moonshot = (schema: JsonSchema): JsonSchema => {
|
||||
const openAI = (schema: JsonSchema): JsonSchema => schema
|
||||
const responses = openAI
|
||||
|
||||
const gemini = (schema: JsonSchema): JsonSchema => GeminiToolSchema.convert(schema) ?? {}
|
||||
const gemini = GeminiJsonSchema.normalize
|
||||
|
||||
const modelCompatibility = (
|
||||
schema: JsonSchema,
|
||||
compatibility: LanguageModelToolSchemaCompatibility | undefined,
|
||||
): JsonSchema => {
|
||||
if (compatibility === undefined) return schema
|
||||
switch (compatibility) {
|
||||
// An explicit compatibility setting wins. Otherwise a Gemini model name selects Gemini's rules,
|
||||
// so Gemini reached through gateways and OpenAI-compatible endpoints gets the same schema handling.
|
||||
const modelCompatibility = (schema: JsonSchema, model: LanguageModel): JsonSchema => {
|
||||
switch (model.compatibility?.toolSchema ?? (/gemini/i.test(model.id) ? "gemini" : undefined)) {
|
||||
case "gemini":
|
||||
return gemini(schema)
|
||||
case "moonshot":
|
||||
return moonshot(schema)
|
||||
case undefined:
|
||||
return schema
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -755,6 +755,39 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes tool schemas for tuned endpoints whose IDs do not name Gemini", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: Gemini.route
|
||||
.with({
|
||||
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
|
||||
auth: Auth.header("x-goog-api-key", "test"),
|
||||
})
|
||||
.model({ id: "projects/p/locations/global/endpoints/123" }),
|
||||
prompt: "Use the tool.",
|
||||
tools: [
|
||||
{
|
||||
name: "lookup",
|
||||
description: "Lookup data",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
required: ["query", "missing"],
|
||||
properties: { query: { type: "string" } },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.tools?.[0]?.functionDeclarations[0]?.parametersJsonSchema).toEqual({
|
||||
type: "object",
|
||||
required: ["query"],
|
||||
properties: { query: { type: "string" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves nested empty object tool schemas", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
|
||||
@@ -60,27 +60,37 @@ describe("tool schema projections", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("gemini handles numeric enums, dangling required fields, untyped arrays, and scalar object keys", () => {
|
||||
expect(
|
||||
ToolSchemaProjection.gemini({
|
||||
it.effect("selects Gemini schema handling from the model name unless compatibility is explicit", () =>
|
||||
Effect.gen(function* () {
|
||||
const route = OpenAIChat.route.with({
|
||||
endpoint: { baseURL: "https://api.openai.test/v1/" },
|
||||
auth: Auth.bearer("test"),
|
||||
})
|
||||
const original = {
|
||||
type: "object",
|
||||
required: ["status", "missing"],
|
||||
properties: {
|
||||
status: { type: "integer", enum: [1, 2] },
|
||||
tags: { type: "array" },
|
||||
name: { type: "string", properties: { ignored: { type: "string" } }, required: ["ignored"] },
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
type: "object",
|
||||
required: ["status"],
|
||||
properties: {
|
||||
status: { type: "string", enum: ["1", "2"] },
|
||||
tags: { type: "array", items: { type: "string" } },
|
||||
name: { type: "string" },
|
||||
},
|
||||
})
|
||||
})
|
||||
required: ["mode", "missing"],
|
||||
properties: { mode: { enum: ["fast", "safe"] } },
|
||||
}
|
||||
const parameters = (model: ReturnType<typeof route.model>) =>
|
||||
compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "Use the tool.",
|
||||
tools: [{ name: "lookup", description: "Lookup data.", inputSchema: original }],
|
||||
}),
|
||||
).pipe(Effect.map((prepared) => prepared.body.tools?.[0]?.function.parameters))
|
||||
const gemini = { ...original, required: ["mode"] }
|
||||
|
||||
expect(yield* parameters(route.model({ id: "google/Gemini-3.8-Flash" }))).toEqual(gemini)
|
||||
expect(
|
||||
yield* parameters(route.model({ id: "my-tuned-endpoint", compatibility: { toolSchema: "gemini" } })),
|
||||
).toEqual(gemini)
|
||||
expect(
|
||||
yield* parameters(route.model({ id: "google/gemini-3.8-flash", compatibility: { toolSchema: "moonshot" } })),
|
||||
).toEqual({ ...original, properties: { mode: { type: "string", enum: ["fast", "safe"] } } })
|
||||
expect(yield* parameters(route.model({ id: "gpt-6-luna" }))).toEqual(original)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("applies model compatibility without changing schema semantics", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
Reference in New Issue
Block a user