mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-23 04:38:37 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 18c83be6b9 | |||
| 5c8906fe46 | |||
| 8aec89744a |
@@ -195,6 +195,26 @@ const program = Effect.gen(function* () {
|
||||
|
||||
The hosted result is represented as a provider-executed tool call and tool result. Its image is a `file` content item with a data URI, so retaining `response.message` preserves the generated image for continuation.
|
||||
|
||||
## Freeform Tools
|
||||
|
||||
A tool may declare an alternate string-input representation while retaining one canonical object schema and executor:
|
||||
|
||||
```ts
|
||||
const patch = Tool.make({
|
||||
description: "Apply a patch",
|
||||
parameters: Schema.Struct({ patchText: Schema.String }),
|
||||
success: Schema.String,
|
||||
freeform: {
|
||||
input: "patchText",
|
||||
name: "apply_patch",
|
||||
grammars: [{ dialect: "oai-lark", definition: patchGrammar }],
|
||||
},
|
||||
execute: ({ patchText }) => applyPatch(patchText),
|
||||
})
|
||||
```
|
||||
|
||||
First-party GPT-5 Responses models lower this to an OpenAI `custom` tool and normalize the returned raw string back to `{ patchText }`. Models and protocols without freeform support receive the normal JSON-schema function tool. Grammars are optional; omit `grammars` for unconstrained text. The supported dialects are the OpenAI grammar formats `oai-lark` and `oai-regex`.
|
||||
|
||||
## Public API
|
||||
|
||||
- **`LLM.request({...})`** — build a provider-neutral `LLMRequest`. Accepts ergonomic inputs (`system: string`, `prompt: string`) that normalize into the canonical Schema classes.
|
||||
|
||||
@@ -153,10 +153,8 @@ const OpenResponsesFunctionCallOutputContent = Schema.Union([
|
||||
OpenResponsesInputVideo,
|
||||
])
|
||||
|
||||
const OpenResponsesFunctionCallOutput = Schema.Union([
|
||||
Schema.String,
|
||||
Schema.Array(OpenResponsesFunctionCallOutputContent),
|
||||
])
|
||||
export const FunctionCallOutput = Schema.Union([Schema.String, Schema.Array(OpenResponsesFunctionCallOutputContent)])
|
||||
export type FunctionCallOutput = Schema.Schema.Type<typeof FunctionCallOutput>
|
||||
|
||||
export const CompactionItem = Schema.Struct({
|
||||
type: Schema.Literal("compaction"),
|
||||
@@ -195,7 +193,7 @@ export const InputItem = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.tag("function_call_output"),
|
||||
call_id: Schema.String,
|
||||
output: OpenResponsesFunctionCallOutput,
|
||||
output: FunctionCallOutput,
|
||||
}),
|
||||
HostedToolItem,
|
||||
])
|
||||
@@ -208,6 +206,14 @@ export type HostedToolReplayItem = {
|
||||
type LoweredInputItem =
|
||||
| OpenResponsesInputItem
|
||||
| HostedToolReplayItem
|
||||
| {
|
||||
readonly type: "custom_tool_call"
|
||||
readonly id?: string
|
||||
readonly call_id: string
|
||||
readonly name: string
|
||||
readonly input: string
|
||||
}
|
||||
| { readonly type: "custom_tool_call_output"; readonly call_id: string; readonly output: FunctionCallOutput }
|
||||
| {
|
||||
readonly type: "message"
|
||||
readonly id?: string
|
||||
@@ -318,6 +324,7 @@ export const StreamItem = Schema.StructWithRest(
|
||||
name: Schema.optional(Schema.String),
|
||||
namespace: Schema.optional(Schema.String),
|
||||
arguments: Schema.optional(Schema.String),
|
||||
input: Schema.optional(Schema.String),
|
||||
encrypted_content: optionalNull(Schema.String),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
@@ -372,6 +379,7 @@ export const Event = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.String,
|
||||
delta: Schema.optional(Schema.String),
|
||||
input: Schema.optional(Schema.String),
|
||||
arguments: Schema.optional(Schema.String),
|
||||
text: Schema.optional(Schema.String),
|
||||
item_id: Schema.optional(Schema.String),
|
||||
@@ -414,6 +422,13 @@ export interface ProviderAdapter {
|
||||
readonly request: LLMRequest
|
||||
}) => MediaInput | undefined
|
||||
readonly restoreHostedToolItem?: (item: unknown) => HostedToolReplayItem | undefined
|
||||
readonly freeformTools?: (request: LLMRequest) => ReadonlyArray<FreeformTool>
|
||||
}
|
||||
|
||||
export interface FreeformTool {
|
||||
readonly name: string
|
||||
readonly wireName: string
|
||||
readonly input: string
|
||||
}
|
||||
|
||||
const BASE_ADAPTER: ProviderAdapter = { id: ADAPTER, name: NAME }
|
||||
@@ -425,6 +440,7 @@ export interface ParserState {
|
||||
readonly name: string
|
||||
readonly providerMetadataKey: string
|
||||
readonly tools: ToolStream.State<string>
|
||||
readonly freeformTools: ReadonlyArray<FreeformTool>
|
||||
readonly hasFunctionCall: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly outputItems: Readonly<Record<number, string>>
|
||||
@@ -483,8 +499,29 @@ const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadata
|
||||
return separator > 0 && separator < metadata.itemId.length - 1 ? metadata.itemId : undefined
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
|
||||
const freeformTool = (request: LLMRequest, adapter: ProviderAdapter, name: string) =>
|
||||
adapter.freeformTools?.(request).find((tool) => tool.name === name)
|
||||
|
||||
const lowerToolCall = Effect.fnUntraced(function* (
|
||||
part: ToolCallPart,
|
||||
providerMetadataKey: string,
|
||||
request: LLMRequest,
|
||||
adapter: ProviderAdapter,
|
||||
) {
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
const freeform = freeformTool(request, adapter, part.name)
|
||||
if (freeform) {
|
||||
const input = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.Struct({ [freeform.input]: Schema.String })),
|
||||
)(part.input)
|
||||
return {
|
||||
type: "custom_tool_call",
|
||||
...(id === undefined ? {} : { id }),
|
||||
call_id: part.id,
|
||||
name: freeform.wireName,
|
||||
input: input[freeform.input],
|
||||
} satisfies LoweredInputItem
|
||||
}
|
||||
return {
|
||||
type: "function_call",
|
||||
...(id === undefined ? {} : { id }),
|
||||
@@ -492,8 +529,8 @@ const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenRes
|
||||
name: part.name,
|
||||
namespace: part.namespace,
|
||||
arguments: ProviderShared.encodeJson(part.input),
|
||||
}
|
||||
}
|
||||
} satisfies LoweredInputItem
|
||||
})
|
||||
|
||||
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
|
||||
const metadata = part.providerMetadata?.[providerMetadataKey]
|
||||
@@ -691,7 +728,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
if (part.type === "tool-call") {
|
||||
flushText()
|
||||
if (part.providerExecuted === true) continue
|
||||
input.push(lowerToolCall(part, providerMetadataKey))
|
||||
input.push(yield* lowerToolCall(part, providerMetadataKey, request, adapter))
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-result" && part.providerExecuted === true) {
|
||||
@@ -734,6 +771,14 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["tool-result"]))
|
||||
return yield* ProviderShared.unsupportedContent(adapter.name, "tool", ["tool-result"])
|
||||
if (freeformTool(request, adapter, part.name)) {
|
||||
input.push({
|
||||
type: "custom_tool_call_output",
|
||||
call_id: part.id,
|
||||
output: yield* lowerToolResultOutput(part, request, adapter),
|
||||
})
|
||||
continue
|
||||
}
|
||||
input.push({
|
||||
type: "function_call_output",
|
||||
call_id: part.id,
|
||||
@@ -796,13 +841,18 @@ export const resolveParallelToolCalls = (request: LLMRequest) => {
|
||||
return disabled === undefined ? undefined : !disabled
|
||||
}
|
||||
|
||||
export const allowedToolChoice = (request: LLMRequest) => {
|
||||
export const allowedToolChoice = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADAPTER) => {
|
||||
const allowed = OpenResponsesOptions.resolve(request).allowedTools
|
||||
if (!allowed) return undefined
|
||||
return {
|
||||
type: "allowed_tools" as const,
|
||||
mode: allowed.mode,
|
||||
tools: allowed.toolNames.map((name) => ({ type: "function" as const, name })),
|
||||
tools: allowed.toolNames.map((name) => {
|
||||
const freeform = freeformTool(request, adapter, name)
|
||||
return freeform
|
||||
? ({ type: "custom" as const, name: freeform.wireName } as const)
|
||||
: ({ type: "function" as const, name } as const)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -826,7 +876,7 @@ export const fromRequestWithAdapter = Effect.fn("OpenResponses.fromRequestWithAd
|
||||
),
|
||||
),
|
||||
tool_choice:
|
||||
allowedToolChoice(request) ??
|
||||
allowedToolChoice(projected.request, adapter) ??
|
||||
(request.toolChoice ? yield* lowerToolChoice(adapter.name, request.toolChoice) : undefined),
|
||||
}
|
||||
})
|
||||
@@ -930,6 +980,7 @@ const ITEM_ID_PREFIX: Readonly<Record<string, string>> = {
|
||||
message: "msg",
|
||||
reasoning: "rs",
|
||||
function_call: "fc",
|
||||
custom_tool_call: "ctc",
|
||||
compaction: "cmp",
|
||||
}
|
||||
|
||||
@@ -1090,35 +1141,37 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
|
||||
events,
|
||||
]
|
||||
}
|
||||
if (item.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
|
||||
if (state.tools[item.id] !== undefined) return [state, NO_EVENTS]
|
||||
const metadata = providerMetadata(state, { itemId: item.id })
|
||||
if (item.type !== "function_call" && item.type !== "custom_tool_call") return [state, NO_EVENTS]
|
||||
if (!item.call_id || state.tools[item.id] !== undefined) return [state, NO_EVENTS]
|
||||
const freeform = freeformByWireName(state, item)
|
||||
if (item.type === "custom_tool_call" && !freeform) return [state, NO_EVENTS]
|
||||
const tool = {
|
||||
id: item.call_id,
|
||||
name: freeform?.name ?? item.name ?? "",
|
||||
namespace: freeform ? undefined : item.namespace,
|
||||
providerMetadata: providerMetadata(state, { itemId: item.id }),
|
||||
}
|
||||
const started = ToolStream.start(state.tools, item.id, { ...tool, input: freeform ? "" : (item.arguments ?? "") })
|
||||
// Custom input streams as a bare string, so open the canonical JSON object
|
||||
// here and let later deltas append escaped string content.
|
||||
const seeded = freeform
|
||||
? ToolStream.append(
|
||||
started,
|
||||
item.id,
|
||||
`{${JSON.stringify(freeform.input)}:${JSON.stringify(item.input ?? "").slice(0, -1)}`,
|
||||
)
|
||||
: undefined
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
tools: ToolStream.start(state.tools, item.id, {
|
||||
id: item.call_id,
|
||||
name: item.name ?? "",
|
||||
namespace: item.namespace,
|
||||
input: item.arguments ?? "",
|
||||
providerMetadata: metadata,
|
||||
}),
|
||||
},
|
||||
[
|
||||
...events,
|
||||
LLMEvent.toolInputStart({
|
||||
id: item.call_id,
|
||||
name: item.name ?? "",
|
||||
namespace: item.namespace,
|
||||
providerMetadata: metadata,
|
||||
}),
|
||||
],
|
||||
{ ...state, lifecycle, tools: seeded?.tools ?? started },
|
||||
[...events, LLMEvent.toolInputStart(tool), ...(seeded?.events ?? [])],
|
||||
]
|
||||
}
|
||||
|
||||
const freeformByWireName = (state: ParserState, item: { readonly type: string; readonly name?: string }) =>
|
||||
item.type === "custom_tool_call" ? state.freeformTools.find((tool) => tool.wireName === item.name) : undefined
|
||||
|
||||
const onReasoningSummaryPartAdded = (state: ParserState, event: Event): StepResult => {
|
||||
if (event.item_id === undefined || event.summary_index === undefined) return [state, NO_EVENTS]
|
||||
return startReasoningSummaryPart(state, event.item_id, event.summary_index)
|
||||
@@ -1147,27 +1200,26 @@ const onReasoningSummaryPartDone = (state: ParserState, event: Event): StepResul
|
||||
]
|
||||
}
|
||||
|
||||
const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgumentsDelta")(function* (
|
||||
// A `final` value replaces accumulated arguments unless it merely extends them,
|
||||
// in which case only the missing suffix is emitted as a delta.
|
||||
const onToolArgumentsUpdate = Effect.fn("OpenResponses.onToolArgumentsUpdate")(function* (
|
||||
state: ParserState,
|
||||
event: Event,
|
||||
itemId: string,
|
||||
update: { readonly delta?: string; readonly final?: string },
|
||||
) {
|
||||
if (event.item_id === undefined) return [state, NO_EVENTS] satisfies StepResult
|
||||
const tool = state.tools[event.item_id]
|
||||
const tool = state.tools[itemId]
|
||||
if (!tool) return [state, NO_EVENTS] satisfies StepResult
|
||||
const final = event.type === "response.function_call_arguments.done" ? event.arguments : undefined
|
||||
if (event.type === "response.function_call_arguments.done" && final === undefined)
|
||||
return [state, NO_EVENTS] satisfies StepResult
|
||||
if (final !== undefined && !final.startsWith(tool.input))
|
||||
if (update.final !== undefined && !update.final.startsWith(tool.input))
|
||||
return [
|
||||
{ ...state, tools: ToolStream.start(state.tools, event.item_id, { ...tool, input: final }) },
|
||||
{ ...state, tools: ToolStream.start(state.tools, itemId, { ...tool, input: update.final }) },
|
||||
NO_EVENTS,
|
||||
] satisfies StepResult
|
||||
const delta = final === undefined ? event.delta : final.slice(tool.input.length)
|
||||
const delta = update.final === undefined ? update.delta : update.final.slice(tool.input.length)
|
||||
if (!delta) return [state, NO_EVENTS] satisfies StepResult
|
||||
const result = ToolStream.appendExisting(
|
||||
state.id,
|
||||
state.tools,
|
||||
event.item_id,
|
||||
itemId,
|
||||
delta,
|
||||
`${state.name} tool argument delta is missing its tool call`,
|
||||
)
|
||||
@@ -1178,6 +1230,25 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenResponses.onFunctionCallArgu
|
||||
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
const onFunctionCallArgumentsDelta = (state: ParserState, event: Event, itemId: string) => {
|
||||
if (event.type !== "response.function_call_arguments.done")
|
||||
return onToolArgumentsUpdate(state, itemId, { delta: event.delta })
|
||||
if (event.arguments === undefined) return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
return onToolArgumentsUpdate(state, itemId, { final: event.arguments })
|
||||
}
|
||||
|
||||
// Custom input arrives as raw string content; re-encode it as the canonical
|
||||
// JSON object so the shared argument accumulator can parse it.
|
||||
const onCustomToolInputDelta = (state: ParserState, event: Event, itemId: string) => {
|
||||
if (event.type !== "response.custom_tool_call_input.done")
|
||||
return onToolArgumentsUpdate(state, itemId, {
|
||||
delta: event.delta === undefined ? undefined : JSON.stringify(event.delta).slice(1, -1),
|
||||
})
|
||||
const freeform = state.freeformTools.find((entry) => entry.name === state.tools[itemId]?.name)
|
||||
if (!freeform || event.input === undefined) return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
return onToolArgumentsUpdate(state, itemId, { final: JSON.stringify({ [freeform.input]: event.input }) })
|
||||
}
|
||||
|
||||
const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
state: ParserState,
|
||||
item: NormalizedEvent["item"],
|
||||
@@ -1228,37 +1299,31 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (item.type === "function_call") {
|
||||
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
|
||||
const metadata = providerMetadata(state, { itemId: item.id })
|
||||
if (item.type === "function_call" || item.type === "custom_tool_call") {
|
||||
const freeform = freeformByWireName(state, item)
|
||||
if (!item.call_id || !item.name || (item.type === "custom_tool_call" && !freeform))
|
||||
return [state, NO_EVENTS] satisfies StepResult
|
||||
const tool = {
|
||||
id: item.call_id,
|
||||
name: freeform?.name ?? item.name,
|
||||
namespace: freeform ? undefined : item.namespace,
|
||||
providerMetadata: providerMetadata(state, { itemId: item.id }),
|
||||
}
|
||||
const final = freeform
|
||||
? item.input === undefined
|
||||
? undefined
|
||||
: JSON.stringify({ [freeform.input]: item.input })
|
||||
: item.arguments
|
||||
const registered = state.tools[item.id] !== undefined
|
||||
const tools = registered
|
||||
? state.tools
|
||||
: ToolStream.start(state.tools, item.id, {
|
||||
id: item.call_id,
|
||||
name: item.name,
|
||||
namespace: item.namespace,
|
||||
providerMetadata: metadata,
|
||||
})
|
||||
const tools = registered ? state.tools : ToolStream.start(state.tools, item.id, tool)
|
||||
const result =
|
||||
item.arguments === undefined
|
||||
final === undefined
|
||||
? yield* ToolStream.finish(state.id, tools, item.id)
|
||||
: yield* ToolStream.finishWithInput(state.id, tools, item.id, item.arguments)
|
||||
: yield* ToolStream.finishWithInput(state.id, tools, item.id, final)
|
||||
const events: LLMEvent[] = []
|
||||
const finished = result.events ?? []
|
||||
// A done-only call never streamed a start event, so open its lifecycle here.
|
||||
const resultEvents =
|
||||
registered || finished.length === 0
|
||||
? finished
|
||||
: [
|
||||
LLMEvent.toolInputStart({
|
||||
id: item.call_id,
|
||||
name: item.name,
|
||||
namespace: item.namespace,
|
||||
providerMetadata: metadata,
|
||||
}),
|
||||
...finished,
|
||||
]
|
||||
const resultEvents = registered || finished.length === 0 ? finished : [LLMEvent.toolInputStart(tool), ...finished]
|
||||
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
|
||||
events.push(...resultEvents)
|
||||
return [
|
||||
@@ -1327,7 +1392,8 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
"Cannot recover a compaction checkpoint after output has been emitted",
|
||||
)
|
||||
const recoverable =
|
||||
item.type === "compaction" || (item.type === "function_call" && current.tools[item.id] !== undefined)
|
||||
item.type === "compaction" ||
|
||||
((item.type === "function_call" || item.type === "custom_tool_call") && current.tools[item.id] !== undefined)
|
||||
if (!recoverable) continue
|
||||
const [next, emitted] = yield* onOutputItemDone(current, item)
|
||||
current = next
|
||||
@@ -1454,7 +1520,11 @@ export const step = (state: ParserState, event: NormalizedEvent) => {
|
||||
}
|
||||
if (event.type === "response.function_call_arguments.delta" || event.type === "response.function_call_arguments.done")
|
||||
return event.item_id !== undefined
|
||||
? onFunctionCallArgumentsDelta(state, event)
|
||||
? onFunctionCallArgumentsDelta(state, event, event.item_id)
|
||||
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.custom_tool_call_input.delta" || event.type === "response.custom_tool_call_input.done")
|
||||
return event.item_id !== undefined
|
||||
? onCustomToolInputDelta(state, event, event.item_id)
|
||||
: ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
if (event.type === "response.output_item.done") return onOutputItemDone(state, event.item)
|
||||
if (event.type === "response.completed" || event.type === "response.incomplete") return onResponseFinish(state, event)
|
||||
@@ -1489,6 +1559,7 @@ export const initial = (request: LLMRequest, adapter: ProviderAdapter = BASE_ADA
|
||||
providerMetadataKey: metadataKey(request.model),
|
||||
hasFunctionCall: false,
|
||||
tools: ToolStream.empty<string>(),
|
||||
freeformTools: adapter.freeformTools?.(request) ?? [],
|
||||
lifecycle: Lifecycle.initial(),
|
||||
outputItems: {},
|
||||
message: undefined,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Effect, Encoding, Schema } from "effect"
|
||||
import { Effect, Encoding, Option, Schema } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { Route } from "../route/client.js"
|
||||
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, ToolDefinition, type ToolEntry } from "../schema/index.js"
|
||||
import { OpenResponses } from "./open-responses.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { OpenAIImage } from "./utils/openai-image.js"
|
||||
@@ -42,6 +42,37 @@ const OpenAIResponsesImageGenerationTool = Schema.Struct({
|
||||
size: Schema.optional(OpenAIImage.Size),
|
||||
})
|
||||
|
||||
const OpenAIResponsesCustomTool = Schema.Struct({
|
||||
type: Schema.tag("custom"),
|
||||
name: Schema.String,
|
||||
description: Schema.optional(Schema.String),
|
||||
format: Schema.optional(
|
||||
Schema.Union([
|
||||
Schema.Struct({ type: Schema.tag("text") }),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("grammar"),
|
||||
syntax: Schema.Literals(["lark", "regex"]),
|
||||
definition: Schema.String,
|
||||
}),
|
||||
]),
|
||||
),
|
||||
})
|
||||
|
||||
const OpenAIResponsesCustomToolItem = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.tag("custom_tool_call"),
|
||||
id: Schema.optional(Schema.String),
|
||||
call_id: Schema.String,
|
||||
name: Schema.String,
|
||||
input: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("custom_tool_call_output"),
|
||||
call_id: Schema.String,
|
||||
output: OpenResponses.FunctionCallOutput,
|
||||
}),
|
||||
])
|
||||
|
||||
const OpenAIResponsesHostedToolItem = Schema.Union([
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
@@ -86,17 +117,31 @@ const OpenAIResponsesNamespace = Schema.Struct({
|
||||
const OpenAIResponsesTools = Schema.Union([
|
||||
OpenResponses.Tool,
|
||||
OpenAIResponsesNamespace,
|
||||
OpenAIResponsesCustomTool,
|
||||
OpenAIResponsesImageGenerationTool,
|
||||
])
|
||||
|
||||
const OpenAIResponsesToolChoice = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.tag("allowed_tools"),
|
||||
mode: Schema.Literals(["auto", "none", "required"]),
|
||||
tools: Schema.Array(
|
||||
Schema.Union([
|
||||
Schema.Struct({ type: Schema.tag("function"), name: Schema.String }),
|
||||
Schema.Struct({ type: Schema.tag("custom"), name: Schema.String }),
|
||||
]),
|
||||
),
|
||||
}),
|
||||
OpenResponses.ToolChoice,
|
||||
Schema.Struct({ type: Schema.tag("custom"), name: Schema.String }),
|
||||
Schema.Struct({ type: Schema.tag("image_generation") }),
|
||||
])
|
||||
|
||||
const OpenAIResponsesCoreFields = {
|
||||
...OpenResponses.coreFields,
|
||||
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem])),
|
||||
input: Schema.Array(
|
||||
Schema.Union([OpenResponses.InputItem, OpenAIResponsesCustomToolItem, OpenAIResponsesHostedToolItem]),
|
||||
),
|
||||
tools: optionalArray(OpenAIResponsesTools),
|
||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||
context_management: Schema.optional(
|
||||
@@ -119,7 +164,14 @@ export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
||||
export const CompactionTrigger = Schema.Struct({ type: Schema.Literal("compaction_trigger") })
|
||||
const CheckpointBody = Schema.Struct({
|
||||
...OpenAIResponsesBody.fields,
|
||||
input: Schema.Array(Schema.Union([OpenResponses.InputItem, OpenAIResponsesHostedToolItem, CompactionTrigger])),
|
||||
input: Schema.Array(
|
||||
Schema.Union([
|
||||
OpenResponses.InputItem,
|
||||
OpenAIResponsesCustomToolItem,
|
||||
OpenAIResponsesHostedToolItem,
|
||||
CompactionTrigger,
|
||||
]),
|
||||
),
|
||||
store: Schema.Literal(false),
|
||||
prompt_cache_retention: optionalNull(Schema.String),
|
||||
prompt_cache_options: optionalNull(
|
||||
@@ -131,8 +183,37 @@ const adapter = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
|
||||
freeformTools: (request: LLMRequest) =>
|
||||
withFreeformSupport(request).tools.flatMap((tool) =>
|
||||
tool.type === "tool" && tool.freeform
|
||||
? [{ name: tool.name, wireName: tool.freeform.name ?? tool.name, input: tool.freeform.input }]
|
||||
: [],
|
||||
),
|
||||
} satisfies OpenResponses.ProviderAdapter
|
||||
|
||||
// Custom tools are an OpenAI extension gated per model. Strip the facet once
|
||||
// for unsupported models so lowering keys off `tool.freeform` alone and those
|
||||
// models see the canonical function tool.
|
||||
const withFreeformSupport = (request: LLMRequest) => {
|
||||
if (request.model.compatibility?.supportsFreeformTools === true) return request
|
||||
return LLMRequest.update(request, {
|
||||
tools: request.tools.map((tool) =>
|
||||
tool.type === "tool" && tool.freeform ? new ToolDefinition({ ...tool, freeform: undefined }) : tool,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
// The wrapper object exists only to match the canonical JSON tool contract, so
|
||||
// its schema must be exactly the one required string property.
|
||||
const FreeformInputSchema = (input: string) =>
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("object"),
|
||||
properties: Schema.Struct({ [input]: Schema.Struct({ type: Schema.Literal("string") }) }).annotate({
|
||||
parseOptions: { onExcessProperty: "error" },
|
||||
}),
|
||||
required: Schema.Tuple([Schema.Literal(input)]),
|
||||
})
|
||||
|
||||
const nativeImageToolInput = (tool: ToolDefinition) => {
|
||||
const native = tool.native?.openai
|
||||
return ProviderShared.isRecord(native) && native.type === "image_generation" ? native : undefined
|
||||
@@ -149,6 +230,27 @@ const lowerTool = Effect.fn("OpenAIResponses.lowerTool")(function* (tool: ToolDe
|
||||
if (Schema.is(OpenAIResponsesImageGenerationTool)(native)) return native
|
||||
return yield* ProviderShared.invalidRequest("OpenAI Responses image generation tool options are invalid")
|
||||
}
|
||||
if (tool.freeform) {
|
||||
if (Option.isNone(Schema.decodeUnknownOption(FreeformInputSchema(tool.freeform.input))(tool.inputSchema)))
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
`OpenAI Responses freeform tool ${tool.name} requires exactly one required string input property named ${tool.freeform.input}`,
|
||||
)
|
||||
const grammar = tool.freeform.grammars?.[0]
|
||||
return {
|
||||
type: "custom" as const,
|
||||
name: tool.freeform.name ?? tool.name,
|
||||
description: tool.description,
|
||||
...(grammar
|
||||
? {
|
||||
format: {
|
||||
type: "grammar" as const,
|
||||
syntax: grammar.dialect === "oai-lark" ? ("lark" as const) : ("regex" as const),
|
||||
definition: grammar.definition,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
return yield* OpenResponses.lowerTool(NAME, tool, inputSchema)
|
||||
})
|
||||
|
||||
@@ -177,19 +279,30 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>, tool
|
||||
auto: () => "auto" as const,
|
||||
none: () => "none" as const,
|
||||
required: () => "required" as const,
|
||||
tool: (name) =>
|
||||
tools.some((tool) => tool.type === "tool" && tool.name === name && nativeImageTool(tool) !== undefined)
|
||||
? ({ type: "image_generation" } as const)
|
||||
: { type: "function" as const, name },
|
||||
tool: (name) => {
|
||||
const tool = tools.find((tool): tool is ToolDefinition => tool.type === "tool" && tool.name === name)
|
||||
if (tool && nativeImageTool(tool) !== undefined) return { type: "image_generation" as const }
|
||||
if (tool?.freeform) return { type: "custom" as const, name: tool.freeform.name ?? name }
|
||||
return { type: "function" as const, name }
|
||||
},
|
||||
})
|
||||
|
||||
const decodeBody = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesBody))
|
||||
|
||||
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request: LLMRequest) {
|
||||
const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (input: LLMRequest) {
|
||||
const request = withFreeformSupport(input)
|
||||
const management = yield* ProviderShared.validateWith(
|
||||
Schema.decodeUnknownEffect(Schema.UndefinedOr(ContextManagement)),
|
||||
)(request.providerOptions?.contextManagement)
|
||||
const toolSchemaCompatibility = request.model.compatibility?.toolSchema
|
||||
const names = request.tools.flatMap((tool) =>
|
||||
tool.type === "tool" && tool.freeform ? [tool.freeform.name ?? tool.name] : [],
|
||||
)
|
||||
const duplicate = names.find((name, index) => names.indexOf(name) !== index)
|
||||
if (duplicate)
|
||||
return yield* ProviderShared.invalidRequest(
|
||||
`OpenAI Responses has multiple tools with model-facing name ${duplicate}`,
|
||||
)
|
||||
return yield* decodeBody({
|
||||
...(yield* OpenResponses.lowerConversation(request, adapter)),
|
||||
...OpenResponses.lowerGeneration(request),
|
||||
@@ -199,7 +312,7 @@ const fromRequest = Effect.fn("OpenAIResponses.fromRequest")(function* (request:
|
||||
? undefined
|
||||
: yield* Effect.forEach(request.tools, (tool) => lowerToolEntry(tool, toolSchemaCompatibility)),
|
||||
tool_choice:
|
||||
OpenResponses.allowedToolChoice(request) ??
|
||||
OpenResponses.allowedToolChoice(request, adapter) ??
|
||||
(request.toolChoice ? yield* lowerToolChoice(request.toolChoice, request.tools) : undefined),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -82,6 +82,14 @@ const configuredRoute = <Body, Prepared, Compact extends CompactionOperations |
|
||||
endpoint: { baseURL: input.baseURL, query: input.queryParams },
|
||||
})
|
||||
|
||||
const supportsFreeformTools = (input: Config, modelID: string) => {
|
||||
if (!modelID.toLowerCase().includes("gpt-5")) return false
|
||||
if (input.baseURL === undefined) return true
|
||||
return [OpenAIResponses.DEFAULT_BASE_URL, "https://chatgpt.com/backend-api/codex"].includes(
|
||||
input.baseURL.replace(/\/+$/, ""),
|
||||
)
|
||||
}
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
const responsesRoute = configuredRoute(OpenAIResponses.route, input)
|
||||
const chatRoute = configuredRoute(OpenAIChat.route, input)
|
||||
@@ -89,7 +97,10 @@ export const configure = (input: Config = {}) => {
|
||||
const responses = (id: string | ModelID) =>
|
||||
responsesRoute
|
||||
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
|
||||
.model<OpenAIProviderOptionsInput>({ id })
|
||||
.model<OpenAIProviderOptionsInput>({
|
||||
id,
|
||||
compatibility: supportsFreeformTools(input, id) ? { supportsFreeformTools: true } : undefined,
|
||||
})
|
||||
const chat = (id: string | ModelID) =>
|
||||
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({ id })
|
||||
const image = (modelID: string | ModelID) =>
|
||||
|
||||
@@ -269,11 +269,19 @@ export namespace Message {
|
||||
make({ role: "tool", content: ["type" in result ? result : ToolResultPart.make(result)] })
|
||||
}
|
||||
|
||||
export const ToolFreeformGrammar = Tool.FreeformGrammar
|
||||
export type ToolFreeformGrammar = Schema.Schema.Type<typeof ToolFreeformGrammar>
|
||||
|
||||
export const ToolFreeform = Tool.Freeform
|
||||
export type ToolFreeform = Schema.Schema.Type<typeof ToolFreeform>
|
||||
|
||||
const toolDefinitionFields = {
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
inputSchema: JsonSchema,
|
||||
outputSchema: Schema.optional(JsonSchema),
|
||||
/** Optional string-input representation for protocols and models that support freeform tools. */
|
||||
freeform: Schema.optional(ToolFreeform),
|
||||
cache: Schema.optional(CacheHint),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
native: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
|
||||
@@ -161,6 +161,7 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
|
||||
supportsStore: Schema.optional(Schema.Boolean),
|
||||
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
|
||||
supportsStrictMode: Schema.optional(Schema.Boolean),
|
||||
supportsFreeformTools: Schema.optional(Schema.Boolean),
|
||||
zaiToolStream: Schema.optional(Schema.Boolean),
|
||||
requireSignature: Schema.optional(Schema.Boolean),
|
||||
}) {}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Tool } from "@opencode-ai/schema/tool"
|
||||
import type {
|
||||
ToolCallPart,
|
||||
ToolDefinition as ToolDefinitionClass,
|
||||
ToolFreeform,
|
||||
ToolOutput as ToolOutputType,
|
||||
} from "./schema/index.js"
|
||||
import { ToolDefinition, ToolFailure, ToolOutput } from "./schema/index.js"
|
||||
@@ -13,6 +14,13 @@ import { ToolDefinition, ToolFailure, ToolOutput } from "./schema/index.js"
|
||||
* beyond pure data conversion belongs in the handler closure.
|
||||
*/
|
||||
export type ToolSchema<T> = Schema.Codec<T, any, never, never>
|
||||
type StringKeys<Value> = Extract<
|
||||
{ [Key in keyof Value]-?: Value[Key] extends string ? Key : never }[keyof Value],
|
||||
string
|
||||
>
|
||||
export type Freeform<Parameters extends ToolSchema<any>> = Omit<ToolFreeform, "input"> & {
|
||||
readonly input: unknown extends Schema.Schema.Type<Parameters> ? string : StringKeys<Schema.Schema.Type<Parameters>>
|
||||
}
|
||||
export interface ToolExecuteContext {
|
||||
readonly id: ToolCallPart["id"]
|
||||
readonly name: ToolCallPart["name"]
|
||||
@@ -50,6 +58,7 @@ export interface Definition<Parameters extends ToolSchema<any>, Success extends
|
||||
readonly description: string
|
||||
readonly parameters: Parameters
|
||||
readonly success: Success
|
||||
readonly freeform?: Freeform<Parameters>
|
||||
readonly execute?: ToolExecute<Parameters, Success>
|
||||
readonly toModelOutput?: ToolToModelOutput<Parameters, Success>
|
||||
readonly toStructuredOutput?: (output: Success["Encoded"]) => unknown
|
||||
@@ -86,6 +95,7 @@ type TypedToolConfig = {
|
||||
readonly description: string
|
||||
readonly parameters: ToolSchema<any>
|
||||
readonly success: ToolSchema<any>
|
||||
readonly freeform?: ToolFreeform
|
||||
readonly execute?: ToolExecute<ToolSchema<any>, ToolSchema<any>>
|
||||
readonly toModelOutput?: ToolToModelOutput<ToolSchema<any>, ToolSchema<any>>
|
||||
readonly toStructuredOutput?: (output: unknown) => unknown
|
||||
@@ -95,6 +105,7 @@ type DynamicToolConfig = {
|
||||
readonly description: string
|
||||
readonly jsonSchema: JsonSchema.JsonSchema
|
||||
readonly outputSchema?: JsonSchema.JsonSchema
|
||||
readonly freeform?: ToolFreeform
|
||||
readonly execute?: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
|
||||
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content>
|
||||
readonly toStructuredOutput?: (output: unknown) => unknown
|
||||
@@ -135,6 +146,7 @@ export function make<Parameters extends ToolSchema<any>, Success extends ToolSch
|
||||
readonly description: string
|
||||
readonly parameters: Parameters
|
||||
readonly success: Success
|
||||
readonly freeform?: Freeform<Parameters>
|
||||
readonly execute: ToolExecute<Parameters, Success>
|
||||
readonly toModelOutput?: ToolToModelOutput<Parameters, Success>
|
||||
readonly toStructuredOutput?: (output: Success["Encoded"]) => unknown
|
||||
@@ -143,6 +155,7 @@ export function make<Parameters extends ToolSchema<any>, Success extends ToolSch
|
||||
readonly description: string
|
||||
readonly parameters: Parameters
|
||||
readonly success: Success
|
||||
readonly freeform?: Freeform<Parameters>
|
||||
readonly execute?: undefined
|
||||
readonly toModelOutput?: ToolToModelOutput<Parameters, Success>
|
||||
readonly toStructuredOutput?: (output: Success["Encoded"]) => unknown
|
||||
@@ -151,6 +164,7 @@ export function make(config: {
|
||||
readonly description: string
|
||||
readonly jsonSchema: JsonSchema.JsonSchema
|
||||
readonly outputSchema?: JsonSchema.JsonSchema
|
||||
readonly freeform?: ToolFreeform
|
||||
readonly execute: (params: unknown, context?: ToolExecuteContext) => Effect.Effect<unknown, ToolFailure>
|
||||
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content>
|
||||
readonly toStructuredOutput?: (output: unknown) => unknown
|
||||
@@ -159,6 +173,7 @@ export function make(config: {
|
||||
readonly description: string
|
||||
readonly jsonSchema: JsonSchema.JsonSchema
|
||||
readonly outputSchema?: JsonSchema.JsonSchema
|
||||
readonly freeform?: ToolFreeform
|
||||
readonly execute?: undefined
|
||||
readonly toModelOutput?: (input: ToolModelOutputInput<unknown, unknown>) => ReadonlyArray<Tool.Content>
|
||||
readonly toStructuredOutput?: (output: unknown) => unknown
|
||||
@@ -169,6 +184,7 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
|
||||
description: config.description,
|
||||
parameters: Schema.Unknown as ToolSchema<unknown>,
|
||||
success: Schema.Unknown as ToolSchema<unknown>,
|
||||
freeform: config.freeform,
|
||||
execute: config.execute,
|
||||
toModelOutput: config.toModelOutput,
|
||||
toStructuredOutput: config.toStructuredOutput,
|
||||
@@ -182,6 +198,7 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
|
||||
description: config.description,
|
||||
inputSchema: config.jsonSchema,
|
||||
outputSchema: config.outputSchema,
|
||||
freeform: config.freeform,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -189,6 +206,7 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
|
||||
description: config.description,
|
||||
parameters: config.parameters,
|
||||
success: config.success,
|
||||
freeform: config.freeform,
|
||||
execute: config.execute,
|
||||
toModelOutput: config.toModelOutput,
|
||||
toStructuredOutput: config.toStructuredOutput,
|
||||
@@ -202,6 +220,7 @@ export function make(config: TypedToolConfig | DynamicToolConfig): AnyTool {
|
||||
description: config.description,
|
||||
inputSchema: toJsonSchema(config.parameters),
|
||||
outputSchema: toJsonSchema(config.success),
|
||||
freeform: config.freeform,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -227,6 +246,7 @@ export const toDefinitions = (tools: Tools): ReadonlyArray<ToolDefinitionClass>
|
||||
description: item._definition.description,
|
||||
inputSchema: item._definition.inputSchema,
|
||||
outputSchema: item._definition.outputSchema,
|
||||
freeform: item._definition.freeform,
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -93,6 +93,23 @@ const request = LLM.request({
|
||||
generation: { maxTokens: 20, temperature: 0 },
|
||||
})
|
||||
|
||||
const freeformModel = LanguageModel.update(model, { compatibility: { supportsFreeformTools: true } })
|
||||
const patchTool = ToolDefinition.make({
|
||||
name: "patch",
|
||||
description: "Apply a patch",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { patchText: { type: "string" } },
|
||||
required: ["patchText"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
freeform: {
|
||||
input: "patchText",
|
||||
name: "apply_patch",
|
||||
grammars: [{ dialect: "oai-lark", definition: 'start: "*** Begin Patch" /(.|\\n)+/' }],
|
||||
},
|
||||
})
|
||||
|
||||
const configEnv = (env: Record<string, string>) => Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))
|
||||
|
||||
type OpenAIToolOutput = Extract<
|
||||
@@ -109,6 +126,212 @@ const expectToolOutput = (body: OpenAIResponses.OpenAIResponsesBody): OpenAITool
|
||||
}
|
||||
|
||||
describe("OpenAI Responses route", () => {
|
||||
it.effect("lowers supported freeform tools to OpenAI custom tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: freeformModel,
|
||||
prompt: "Patch the file.",
|
||||
tools: [patchTool],
|
||||
toolChoice: "patch",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.tools).toEqual([
|
||||
{
|
||||
type: "custom",
|
||||
name: "apply_patch",
|
||||
description: "Apply a patch",
|
||||
format: {
|
||||
type: "grammar",
|
||||
syntax: "lark",
|
||||
definition: 'start: "*** Begin Patch" /(.|\\n)+/',
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(prepared.body.tool_choice).toEqual({ type: "custom", name: "apply_patch" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("enables freeform tools for first-party GPT-5 Responses models", () =>
|
||||
Effect.gen(function* () {
|
||||
const toolType = (model: LanguageModel) =>
|
||||
compileRequest(LLM.request({ model, prompt: "Patch the file.", tools: [patchTool] })).pipe(
|
||||
Effect.map((prepared) => prepared.body.tools?.[0]?.type),
|
||||
)
|
||||
|
||||
expect(yield* toolType(OpenAI.configure({ apiKey: "test" }).responses("gpt-5.2"))).toBe("custom")
|
||||
expect(
|
||||
yield* toolType(OpenAI.configure({ apiKey: "test", baseURL: "https://proxy.test/v1" }).responses("gpt-5.2")),
|
||||
).toBe("function")
|
||||
expect(
|
||||
yield* toolType(
|
||||
OpenAI.configure({ apiKey: "test", baseURL: "https://chatgpt.com/backend-api/codex" }).responses(
|
||||
"gpt-5.2-codex",
|
||||
),
|
||||
),
|
||||
).toBe("custom")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to the canonical function tool when freeform tools are unsupported", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({ model, prompt: "Patch the file.", tools: [patchTool], toolChoice: "patch" }),
|
||||
)
|
||||
|
||||
expect(prepared.body.tools).toEqual([
|
||||
{
|
||||
type: "function",
|
||||
name: "patch",
|
||||
description: "Apply a patch",
|
||||
parameters: patchTool.inputSchema,
|
||||
strict: false,
|
||||
},
|
||||
])
|
||||
expect(prepared.body.tool_choice).toEqual({ type: "function", name: "patch" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses custom wire names in allowed tool choices", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: freeformModel,
|
||||
prompt: "Patch the file.",
|
||||
tools: [patchTool],
|
||||
providerOptions: { allowedTools: { toolNames: ["patch"], mode: "required" } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.tool_choice).toEqual({
|
||||
type: "allowed_tools",
|
||||
mode: "required",
|
||||
tools: [{ type: "custom", name: "apply_patch" }],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects freeform representations that cannot encode the canonical input", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: freeformModel,
|
||||
prompt: "Patch the file.",
|
||||
tools: [
|
||||
ToolDefinition.make({
|
||||
...patchTool,
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { patchText: { type: "string" }, dryRun: { type: "boolean" } },
|
||||
required: ["patchText"],
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("requires exactly one required string input property named patchText")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects duplicate model-facing freeform tool names", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: freeformModel,
|
||||
prompt: "Patch the file.",
|
||||
tools: [
|
||||
patchTool,
|
||||
ToolDefinition.make({
|
||||
...patchTool,
|
||||
name: "other_patch",
|
||||
freeform: { input: "patchText", name: "apply_patch" },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain("multiple tools with model-facing name apply_patch")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays canonical calls using the selected freeform representation", () =>
|
||||
Effect.gen(function* () {
|
||||
const messages = [
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "call_patch", name: "patch", input: { patchText: "*** Begin Patch" } }),
|
||||
]),
|
||||
Message.tool(ToolResultPart.make({ id: "call_patch", name: "patch", result: "Success", resultType: "text" })),
|
||||
]
|
||||
const prepared = yield* compileRequest(LLM.request({ model: freeformModel, tools: [patchTool], messages }))
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "custom_tool_call",
|
||||
call_id: "call_patch",
|
||||
name: "apply_patch",
|
||||
input: "*** Begin Patch",
|
||||
},
|
||||
{ type: "custom_tool_call_output", call_id: "call_patch", output: "Success" },
|
||||
])
|
||||
|
||||
const fallback = yield* compileRequest(LLM.request({ model, tools: [patchTool], messages }))
|
||||
expect(fallback.body.input).toEqual([
|
||||
{ type: "function_call", call_id: "call_patch", name: "patch", arguments: '{"patchText":"*** Begin Patch"}' },
|
||||
{ type: "function_call_output", call_id: "call_patch", output: "Success" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects malformed canonical input when replaying a freeform call", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: freeformModel,
|
||||
tools: [patchTool],
|
||||
messages: [
|
||||
Message.assistant([ToolCallPart.make({ id: "call_patch", name: "patch", input: { patchText: 1 } })]),
|
||||
],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toContain('Expected string\n at ["patchText"]')
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves media in custom tool outputs", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: freeformModel,
|
||||
tools: [patchTool],
|
||||
messages: [
|
||||
Message.tool(
|
||||
ToolResultPart.make({
|
||||
id: "call_patch",
|
||||
name: "patch",
|
||||
resultType: "content",
|
||||
result: [{ type: "file", uri: "data:image/png;base64,AAAA", mime: "image/png" }],
|
||||
}),
|
||||
),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "custom_tool_call_output",
|
||||
call_id: "call_patch",
|
||||
output: [{ type: "input_image", image_url: "data:image/png;base64,AAAA" }],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prepares OpenAI Responses target", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(request)
|
||||
@@ -3888,6 +4111,144 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes streamed custom tool input to the canonical object", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "custom_tool_call", id: "ctc_1", call_id: "call_patch", name: "apply_patch", input: "" },
|
||||
},
|
||||
{ type: "response.custom_tool_call_input.delta", item_id: "ctc_1", delta: "*** Begin Patch\n" },
|
||||
{ type: "response.custom_tool_call_input.delta", item_id: "ctc_1", delta: "*** End Patch" },
|
||||
{
|
||||
type: "response.custom_tool_call_input.done",
|
||||
item_id: "ctc_1",
|
||||
input: "*** Begin Patch\n*** End Patch",
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "custom_tool_call",
|
||||
id: "ctc_1",
|
||||
call_id: "call_patch",
|
||||
name: "apply_patch",
|
||||
input: "*** Begin Patch\n*** End Patch",
|
||||
},
|
||||
},
|
||||
{ type: "response.completed", response: { usage: { input_tokens: 5, output_tokens: 1 } } },
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({ model: freeformModel, prompt: "Patch the file.", tools: [patchTool] }),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
const deltas = response.events.filter(LLMEvent.is.toolInputDelta)
|
||||
const call = response.events.find(LLMEvent.is.toolCall)
|
||||
|
||||
expect(deltas.map((event) => event.text).join("")).toBe(
|
||||
JSON.stringify({ patchText: "*** Begin Patch\n*** End Patch" }),
|
||||
)
|
||||
expect(call).toMatchObject({
|
||||
type: "tool-call",
|
||||
id: "call_patch",
|
||||
name: "patch",
|
||||
input: { patchText: "*** Begin Patch\n*** End Patch" },
|
||||
providerMetadata: { openai: { itemId: "ctc_1" } },
|
||||
})
|
||||
expect(response.finishReason?.normalized).toBe("tool-calls")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("finalizes custom input at each completion boundary", () =>
|
||||
Effect.gen(function* () {
|
||||
const added = {
|
||||
type: "response.output_item.added",
|
||||
item: { type: "custom_tool_call", id: "ctc_1", call_id: "call_patch", name: "apply_patch", input: "" },
|
||||
}
|
||||
const scenarios = [
|
||||
{
|
||||
expected: "*** Begin Patch",
|
||||
events: [
|
||||
added,
|
||||
{ type: "response.custom_tool_call_input.delta", item_id: "ctc_1", delta: "*** Begin" },
|
||||
{ type: "response.custom_tool_call_input.done", item_id: "ctc_1", input: "*** Begin Patch" },
|
||||
{ type: "response.completed", response: {} },
|
||||
],
|
||||
},
|
||||
{
|
||||
expected: "authoritative",
|
||||
events: [
|
||||
added,
|
||||
{ type: "response.custom_tool_call_input.delta", item_id: "ctc_1", delta: "partial" },
|
||||
{ type: "response.completed", response: { output: [{ ...added.item, input: "authoritative" }] } },
|
||||
],
|
||||
},
|
||||
{
|
||||
expected: "done only",
|
||||
events: [
|
||||
{ type: "response.output_item.done", item: { ...added.item, id: undefined, input: "done only" } },
|
||||
{ type: "response.completed", response: {} },
|
||||
],
|
||||
},
|
||||
{ expected: "", events: [added, { type: "response.completed", response: {} }] },
|
||||
]
|
||||
for (const scenario of scenarios) {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({ model: freeformModel, prompt: "Patch the file.", tools: [patchTool] }),
|
||||
).pipe(Effect.provide(fixedResponse(sseEvents(...scenario.events))))
|
||||
|
||||
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
|
||||
name: "patch",
|
||||
input: { patchText: scenario.expected },
|
||||
})
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("mints and replays an item id for a completed function call without one", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
|
||||
expect.objectContaining({ id: "call_1", name: "lookup", input: { query: "weather" } }),
|
||||
])
|
||||
expect(response.events.find(LLMEvent.is.toolCall)?.providerMetadata).toEqual({
|
||||
openai: { itemId: expect.stringMatching(/^fc_/) },
|
||||
})
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
response.message,
|
||||
Message.tool({ id: "call_1", name: "lookup", resultType: "json", result: { forecast: "sunny" } }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "function_call",
|
||||
id: expect.stringMatching(/^fc_/),
|
||||
call_id: "call_1",
|
||||
name: "lookup",
|
||||
arguments: '{"query":"weather"}',
|
||||
},
|
||||
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits only missing function arguments from the arguments done event", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
|
||||
@@ -399,6 +399,27 @@ describe("LLMClient tools", () => {
|
||||
expect(dynamic?.outputSchema).toEqual(schema)
|
||||
})
|
||||
|
||||
test("preserves freeform representations in generated definitions", () => {
|
||||
const freeform = {
|
||||
input: "patchText",
|
||||
name: "apply_patch",
|
||||
grammars: [{ dialect: "oai-regex" as const, definition: "\\*\\*\\* Begin Patch(.|\\n)+" }],
|
||||
}
|
||||
const [definition] = toDefinitions({
|
||||
patch: Tool.make({
|
||||
description: "Apply a patch.",
|
||||
jsonSchema: {
|
||||
type: "object",
|
||||
properties: { patchText: { type: "string" } },
|
||||
required: ["patchText"],
|
||||
},
|
||||
freeform,
|
||||
}),
|
||||
})
|
||||
|
||||
expect(definition?.freeform).toEqual(freeform)
|
||||
})
|
||||
|
||||
it.effect("preserves content tool results from dynamic tools", () =>
|
||||
Effect.gen(function* () {
|
||||
const screenshot = Tool.make({
|
||||
|
||||
@@ -1377,6 +1377,7 @@ export type ModelCompatibility = {
|
||||
maxTokensField?: ModelMaxTokensField
|
||||
requireFinishReason?: boolean
|
||||
requireAssistantAfterTool?: boolean
|
||||
supportsFreeformTools?: boolean
|
||||
}
|
||||
|
||||
export type ModelCost = {
|
||||
|
||||
@@ -213,9 +213,9 @@ function runtime(
|
||||
const child = definition(registration)
|
||||
getNode(root, qualifiedName(registration)).tool = Tool.make({
|
||||
description: child.description,
|
||||
input: child.inputSchema,
|
||||
input: child.freeform ? Schema.String : child.inputSchema,
|
||||
output: child.outputSchema ?? Schema.NullOr(Schema.String),
|
||||
execute: (input) => executeTool(name, registration, input),
|
||||
execute: (input) => executeTool(name, registration, child.freeform ? { [child.freeform.input]: input } : input),
|
||||
})
|
||||
}
|
||||
const tools = renderTools(root)
|
||||
|
||||
@@ -81,6 +81,7 @@ export const Plugin = {
|
||||
description: DESCRIPTION,
|
||||
input: Input,
|
||||
output: Output,
|
||||
freeform: { input: "patchText", name: "apply_patch" },
|
||||
execute: (input, context) => {
|
||||
const applied: Array<typeof Applied.Type> = []
|
||||
const parsed = Patch.parse(input.patchText)
|
||||
|
||||
@@ -23,6 +23,7 @@ export const definition = (tool: Tool.Info<any, any>): ToolDefinition => ({
|
||||
description: tool.description,
|
||||
inputSchema: inputJsonSchema(tool.input),
|
||||
...(tool.output === undefined ? {} : { outputSchema: outputJsonSchema(tool.output) }),
|
||||
...(tool.freeform === undefined ? {} : { freeform: tool.freeform }),
|
||||
})
|
||||
|
||||
export const execute = (tool: Tool.Info<any, any>, input: unknown, context: Tool.Context) =>
|
||||
|
||||
@@ -3,6 +3,9 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Tool } from "@opencode-ai/core/tool"
|
||||
import { Agent } from "@opencode-ai/core/agent"
|
||||
import { Session } from "@opencode-ai/core/session"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { it } from "./lib/effect"
|
||||
|
||||
@@ -20,6 +23,14 @@ describe("CodeMode", () => {
|
||||
options: { pinned: true },
|
||||
execute: ({ text }) => Effect.succeed({ output: text }),
|
||||
})
|
||||
editor.add({
|
||||
name: "script",
|
||||
description: "Run script text",
|
||||
input: Schema.Struct({ source: Schema.String }),
|
||||
output: Schema.String,
|
||||
freeform: { input: "source" },
|
||||
execute: ({ source }) => Effect.succeed({ output: source }),
|
||||
})
|
||||
})
|
||||
|
||||
const snapshot = yield* tools.snapshot()
|
||||
@@ -39,8 +50,27 @@ describe("CodeMode", () => {
|
||||
description: "No tools registered yet",
|
||||
tools: [],
|
||||
},
|
||||
{
|
||||
type: "tool",
|
||||
name: "script",
|
||||
description: "Run script text",
|
||||
signature: "tools.script(input: string): Promise<string>",
|
||||
pinned: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
const result = yield* snapshot.execute({
|
||||
sessionID: Session.ID.make("ses_codemode_freeform"),
|
||||
agent: Agent.ID.make("build"),
|
||||
messageID: SessionMessage.ID.make("msg_codemode_freeform"),
|
||||
call: {
|
||||
type: "tool-call",
|
||||
id: "call-codemode-freeform",
|
||||
name: "execute",
|
||||
input: { code: 'return await tools.script("hello")' },
|
||||
},
|
||||
})
|
||||
expect(result.output).toMatchObject({ output: "hello" })
|
||||
}).pipe(
|
||||
Effect.scoped,
|
||||
Effect.provide(
|
||||
|
||||
@@ -60,6 +60,7 @@ export const Compatibility = Schema.Struct({
|
||||
maxTokensField: MaxTokensField.pipe(optional),
|
||||
requireFinishReason: Schema.Boolean.pipe(optional),
|
||||
requireAssistantAfterTool: Schema.Boolean.pipe(optional),
|
||||
supportsFreeformTools: Schema.Boolean.pipe(optional),
|
||||
}).annotate({ identifier: "Model.Compatibility" })
|
||||
|
||||
export interface Capabilities extends Schema.Schema.Type<typeof Capabilities> {}
|
||||
|
||||
@@ -43,6 +43,19 @@ export type Options = BaseOptions &
|
||||
|
||||
export type ValueSchema<A = unknown> = Schema.Codec<A, any> | StandardSchemaV1<any, A> | JsonSchema.JsonSchema
|
||||
|
||||
export const FreeformGrammar = Schema.Struct({
|
||||
dialect: Schema.Literals(["oai-lark", "oai-regex"]),
|
||||
definition: Schema.String,
|
||||
})
|
||||
export type FreeformGrammar = typeof FreeformGrammar.Type
|
||||
|
||||
export const Freeform = Schema.Struct({
|
||||
input: Schema.String,
|
||||
name: Schema.optional(Schema.String),
|
||||
grammars: Schema.optional(Schema.Array(FreeformGrammar)),
|
||||
})
|
||||
export type Freeform = typeof Freeform.Type
|
||||
|
||||
type InputValue<S> = 0 extends 1 & S
|
||||
? any
|
||||
: S extends Schema.Codec<infer A, any>
|
||||
@@ -98,5 +111,6 @@ export type Info<
|
||||
readonly description: string
|
||||
readonly execute: (input: InputValue<Input>, context: Context) => Effect.Effect<Result<Output>, Error>
|
||||
readonly output?: Output
|
||||
readonly freeform?: Freeform
|
||||
readonly options?: Options
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user