refactor(llm): polish openai-responses hosted tools and body schemas
Three small cleanups in the OpenAI Responses protocol:
- Unify `HOSTED_TOOL_NAMES` + `hostedToolInput` into one `HOSTED_TOOLS`
record per tool: `{ name, input: (item) => unknown }`. Adding a new
hosted tool is now a single entry instead of two parallel switches that
must stay in sync.
- Tighten `isHostedToolItem`'s narrowing to include the `type` field, so
callers know they're dealing with a known hosted-tool shape (not just
"has an id"). Drives a cleaner `hostedToolEvents` signature.
- Split the body schema into a shared `OpenAIResponsesCoreFields` record
used by both the HTTP body (adds `stream: true`) and the WebSocket
`response.create` message (adds `type`). Removes the destructure-and-
strip dance at schema definition time. Runtime conversion in
`webSocketMessage` still strips `stream` because OpenAI's WebSocket API
doesn't expect it on the wire.
Plus a tiny fix in bedrock-converse.ts: explicit `Route.model<BedrockConverseModelInput>`
type argument so the mapInput overload selects properly (was inferring
to the narrower `RouteModelInput`).
This commit is contained in:
@@ -103,7 +103,7 @@ type BedrockAssistantBlock = Schema.Schema.Type<typeof BedrockAssistantBlock>
|
||||
const BedrockMessage = Schema.Union([
|
||||
Schema.Struct({ role: Schema.Literal("user"), content: Schema.Array(BedrockUserBlock) }),
|
||||
Schema.Struct({ role: Schema.Literal("assistant"), content: Schema.Array(BedrockAssistantBlock) }),
|
||||
])
|
||||
]).pipe(Schema.toTaggedUnion("role"))
|
||||
type BedrockMessage = Schema.Schema.Type<typeof BedrockMessage>
|
||||
|
||||
const BedrockSystemBlock = Schema.Union([BedrockTextBlock, BedrockCache.CachePointBlock])
|
||||
@@ -212,8 +212,6 @@ const BedrockEvent = Schema.Struct({
|
||||
})
|
||||
type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
|
||||
|
||||
const invalid = ProviderShared.invalidRequest
|
||||
|
||||
// =============================================================================
|
||||
// Request Lowering
|
||||
// =============================================================================
|
||||
@@ -502,7 +500,9 @@ export const route = Route.make({
|
||||
// the provider helper from credentials) and the validated modelId in the
|
||||
// path. We read the validated body so the URL matches the body that gets
|
||||
// signed.
|
||||
endpoint: Endpoint.path<BedrockConverseBody>(({ body }) => `/model/${encodeURIComponent(body.modelId)}/converse-stream`),
|
||||
endpoint: Endpoint.path<BedrockConverseBody>(
|
||||
({ body }) => `/model/${encodeURIComponent(body.modelId)}/converse-stream`,
|
||||
),
|
||||
auth: BedrockAuth.auth,
|
||||
framing,
|
||||
})
|
||||
|
||||
@@ -69,12 +69,15 @@ const OpenAIResponsesToolChoice = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("function"), name: Schema.String }),
|
||||
])
|
||||
|
||||
const OpenAIResponsesBodyFields = {
|
||||
// Fields shared between the HTTP body and the WebSocket `response.create`
|
||||
// message. The HTTP body adds `stream: true`; the WebSocket message adds
|
||||
// `type: "response.create"`. Defining the shared shape once keeps the two
|
||||
// transports in sync without a destructure-and-strip dance.
|
||||
const OpenAIResponsesCoreFields = {
|
||||
model: Schema.String,
|
||||
input: Schema.Array(OpenAIResponsesInputItem),
|
||||
tools: optionalArray(OpenAIResponsesTool),
|
||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||
stream: Schema.Literal(true),
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
prompt_cache_key: Schema.optional(Schema.String),
|
||||
include: optionalArray(Schema.Literal("reasoning.encrypted_content")),
|
||||
@@ -93,14 +96,17 @@ const OpenAIResponsesBodyFields = {
|
||||
temperature: Schema.optional(Schema.Number),
|
||||
top_p: Schema.optional(Schema.Number),
|
||||
}
|
||||
const OpenAIResponsesBody = Schema.Struct(OpenAIResponsesBodyFields)
|
||||
|
||||
const OpenAIResponsesBody = Schema.Struct({
|
||||
...OpenAIResponsesCoreFields,
|
||||
stream: Schema.Literal(true),
|
||||
})
|
||||
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
||||
|
||||
const { stream: _stream, ...OpenAIResponsesWebSocketMessageFields } = OpenAIResponsesBodyFields
|
||||
const OpenAIResponsesWebSocketMessage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("response.create"),
|
||||
...OpenAIResponsesWebSocketMessageFields,
|
||||
...OpenAIResponsesCoreFields,
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
@@ -293,39 +299,39 @@ const mapFinishReason = (event: OpenAIResponsesEvent, hasFunctionCall: boolean):
|
||||
|
||||
const openaiMetadata = (metadata: Record<string, unknown>): ProviderMetadata => ({ openai: metadata })
|
||||
|
||||
// Hosted tool items (provider-executed) ship their typed input + status + result
|
||||
// fields all in one item. We expose them as a `tool-call` + `tool-result` pair
|
||||
// so consumers can treat them uniformly with client tools, only differentiated
|
||||
// by `providerExecuted: true`.
|
||||
// Hosted tool items (provider-executed) ship their typed input + status +
|
||||
// result fields all in one item. We expose them as a `tool-call` +
|
||||
// `tool-result` pair so consumers can treat them uniformly with client tools,
|
||||
// only differentiated by `providerExecuted: true`.
|
||||
//
|
||||
// item.type → tool name. Each entry is the OpenAI Responses item type that
|
||||
// represents a hosted (provider-executed) tool call.
|
||||
const HOSTED_TOOL_NAMES: Record<string, string> = {
|
||||
web_search_call: "web_search",
|
||||
web_search_preview_call: "web_search_preview",
|
||||
file_search_call: "file_search",
|
||||
code_interpreter_call: "code_interpreter",
|
||||
computer_use_call: "computer_use",
|
||||
image_generation_call: "image_generation",
|
||||
mcp_call: "mcp",
|
||||
local_shell_call: "local_shell",
|
||||
}
|
||||
// One record per OpenAI Responses item type that represents a hosted
|
||||
// (provider-executed) tool call: the common name we surface, plus an `input`
|
||||
// extractor that picks the fields the model actually populated for that tool.
|
||||
// Falling back to `{}` when an entry isn't fully typed keeps unknown tools
|
||||
// observable without rolling a per-tool schema.
|
||||
const HOSTED_TOOLS = {
|
||||
web_search_call: { name: "web_search", input: (item) => item.action ?? {} },
|
||||
web_search_preview_call: { name: "web_search_preview", input: (item) => item.action ?? {} },
|
||||
file_search_call: { name: "file_search", input: (item) => ({ queries: item.queries ?? [] }) },
|
||||
code_interpreter_call: {
|
||||
name: "code_interpreter",
|
||||
input: (item) => ({ code: item.code, container_id: item.container_id }),
|
||||
},
|
||||
computer_use_call: { name: "computer_use", input: (item) => item.action ?? {} },
|
||||
image_generation_call: { name: "image_generation", input: () => ({}) },
|
||||
mcp_call: {
|
||||
name: "mcp",
|
||||
input: (item) => ({ server_label: item.server_label, name: item.name, arguments: item.arguments }),
|
||||
},
|
||||
local_shell_call: { name: "local_shell", input: (item) => item.action ?? {} },
|
||||
} as const satisfies Record<string, { readonly name: string; readonly input: (item: OpenAIResponsesStreamItem) => unknown }>
|
||||
|
||||
const isHostedToolItem = (item: OpenAIResponsesStreamItem): item is OpenAIResponsesStreamItem & { id: string } =>
|
||||
item.type in HOSTED_TOOL_NAMES && typeof item.id === "string" && item.id.length > 0
|
||||
type HostedToolType = keyof typeof HOSTED_TOOLS
|
||||
|
||||
// Pick the input fields the model actually populated when invoking the tool.
|
||||
// The shape is tool-specific. Keep this list explicit so each tool's input is
|
||||
// reviewable at a glance — fall back to `{}` for tools we haven't typed yet.
|
||||
const hostedToolInput = (item: OpenAIResponsesStreamItem): unknown => {
|
||||
if (item.type === "web_search_call" || item.type === "web_search_preview_call") return item.action ?? {}
|
||||
if (item.type === "file_search_call") return { queries: item.queries ?? [] }
|
||||
if (item.type === "code_interpreter_call") return { code: item.code, container_id: item.container_id }
|
||||
if (item.type === "computer_use_call") return item.action ?? {}
|
||||
if (item.type === "local_shell_call") return item.action ?? {}
|
||||
if (item.type === "mcp_call") return { server_label: item.server_label, name: item.name, arguments: item.arguments }
|
||||
return {}
|
||||
}
|
||||
const isHostedToolItem = (
|
||||
item: OpenAIResponsesStreamItem,
|
||||
): item is OpenAIResponsesStreamItem & { type: HostedToolType; id: string } =>
|
||||
item.type in HOSTED_TOOLS && typeof item.id === "string" && item.id.length > 0
|
||||
|
||||
// Round-trip the full item as the structured result so consumers can extract
|
||||
// outputs / sources / status without re-decoding.
|
||||
@@ -334,15 +340,17 @@ const hostedToolResult = (item: OpenAIResponsesStreamItem) => {
|
||||
return isError ? { type: "error" as const, value: item.error } : { type: "json" as const, value: item }
|
||||
}
|
||||
|
||||
const hostedToolEvents = (item: OpenAIResponsesStreamItem & { id: string }): ReadonlyArray<LLMEvent> => {
|
||||
const name = HOSTED_TOOL_NAMES[item.type]
|
||||
const hostedToolEvents = (
|
||||
item: OpenAIResponsesStreamItem & { type: HostedToolType; id: string },
|
||||
): ReadonlyArray<LLMEvent> => {
|
||||
const tool = HOSTED_TOOLS[item.type]
|
||||
const providerMetadata = openaiMetadata({ itemId: item.id })
|
||||
return [
|
||||
{ type: "tool-call", id: item.id, name, input: hostedToolInput(item), providerExecuted: true, providerMetadata },
|
||||
{ type: "tool-call", id: item.id, name: tool.name, input: tool.input(item), providerExecuted: true, providerMetadata },
|
||||
{
|
||||
type: "tool-result",
|
||||
id: item.id,
|
||||
name,
|
||||
name: tool.name,
|
||||
result: hostedToolResult(item),
|
||||
providerExecuted: true,
|
||||
providerMetadata,
|
||||
|
||||
Reference in New Issue
Block a user