diff --git a/packages/llm/src/provider/anthropic-messages.ts b/packages/llm/src/provider/anthropic-messages.ts index 70a4019efb..e2888d35da 100644 --- a/packages/llm/src/provider/anthropic-messages.ts +++ b/packages/llm/src/provider/anthropic-messages.ts @@ -206,15 +206,10 @@ const decodeTarget = Schema.decodeUnknownEffect(AnthropicMessagesDraft.pipe(Sche const invalid = ProviderShared.invalidRequest -const baseUrl = (request: LLMRequest) => (request.model.baseURL ?? "https://api.anthropic.com/v1").replace(/\/+$/, "") +const baseUrl = (request: LLMRequest) => ProviderShared.trimBaseUrl(request.model.baseURL ?? "https://api.anthropic.com/v1") const cacheControl = (cache: CacheHint | undefined) => cache?.type === "ephemeral" ? { type: "ephemeral" as const } : undefined -const resultText = (part: ToolResultPart) => { - if (part.result.type === "text" || part.result.type === "error") return String(part.result.value) - return ProviderShared.encodeJson(part.result.value) -} - const lowerTool = (tool: ToolDefinition): AnthropicTool => ({ name: tool.name, description: tool.description, @@ -306,7 +301,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (re content.push({ type: "tool_result", tool_use_id: part.id, - content: resultText(part), + content: ProviderShared.toolResultText(part), is_error: part.result.type === "error" ? true : undefined, }) } diff --git a/packages/llm/src/provider/gemini.ts b/packages/llm/src/provider/gemini.ts index 4994fcb477..486b4b897c 100644 --- a/packages/llm/src/provider/gemini.ts +++ b/packages/llm/src/provider/gemini.ts @@ -1,4 +1,3 @@ -import { Buffer } from "node:buffer" import { Effect, Schema, Stream } from "effect" import type { HttpClientResponse } from "effect/unstable/http" import { Adapter } from "../adapter" @@ -13,7 +12,6 @@ import { type TextPart, type ToolCallPart, type ToolDefinition, - type ToolResultPart, } from "../schema" import { ProviderShared } from "./shared" @@ -153,14 +151,9 @@ const decodeTarget = Schema.decodeUnknownEffect(GeminiDraft.pipe(Schema.decodeTo const invalid = ProviderShared.invalidRequest const baseUrl = (request: LLMRequest) => - (request.model.baseURL ?? "https://generativelanguage.googleapis.com/v1beta").replace(/\/+$/, "") + ProviderShared.trimBaseUrl(request.model.baseURL ?? "https://generativelanguage.googleapis.com/v1beta") -const mediaData = (part: MediaPart) => typeof part.data === "string" ? part.data : Buffer.from(part.data).toString("base64") - -const resultText = (part: ToolResultPart) => { - if (part.result.type === "text" || part.result.type === "error") return String(part.result.value) - return ProviderShared.encodeJson(part.result.value) -} +const mediaData = ProviderShared.mediaBytes const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) @@ -269,7 +262,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR name: part.name, response: { name: part.name, - content: resultText(part), + content: ProviderShared.toolResultText(part), }, }, }) diff --git a/packages/llm/src/provider/openai-chat.ts b/packages/llm/src/provider/openai-chat.ts index 30db07d03d..3f5a4bfb7e 100644 --- a/packages/llm/src/provider/openai-chat.ts +++ b/packages/llm/src/provider/openai-chat.ts @@ -11,7 +11,6 @@ import { type TextPart, type ToolCallPart, type ToolDefinition, - type ToolResultPart, } from "../schema" import { ProviderShared } from "./shared" @@ -165,12 +164,7 @@ const decodeTarget = Schema.decodeUnknownEffect(OpenAIChatDraft.pipe(Schema.deco const invalid = ProviderShared.invalidRequest -const baseUrl = (request: LLMRequest) => (request.model.baseURL ?? "https://api.openai.com/v1").replace(/\/+$/, "") - -const resultText = (part: ToolResultPart) => { - if (part.result.type === "text" || part.result.type === "error") return String(part.result.value) - return ProviderShared.encodeJson(part.result.value) -} +const baseUrl = (request: LLMRequest) => ProviderShared.trimBaseUrl(request.model.baseURL ?? "https://api.openai.com/v1") const lowerTool = (tool: ToolDefinition): OpenAIChatTool => ({ type: "function", @@ -239,7 +233,7 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: for (const part of message.content) { if (part.type !== "tool-result") return yield* invalid(`OpenAI Chat tool messages only support tool-result content`) - messages.push({ role: "tool", tool_call_id: part.id, content: resultText(part) }) + messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) }) } } diff --git a/packages/llm/src/provider/openai-compatible-chat.ts b/packages/llm/src/provider/openai-compatible-chat.ts index 0b1836393a..27e9b18cef 100644 --- a/packages/llm/src/provider/openai-compatible-chat.ts +++ b/packages/llm/src/provider/openai-compatible-chat.ts @@ -32,7 +32,7 @@ const queryParams = (request: LLMRequest) => { const completionUrl = (request: LLMRequest) => { if (!request.model.baseURL) return undefined - const url = new URL(`${request.model.baseURL.replace(/\/+$/, "")}/chat/completions`) + const url = new URL(`${ProviderShared.trimBaseUrl(request.model.baseURL)}/chat/completions`) for (const [key, value] of Object.entries(queryParams(request) ?? {})) url.searchParams.set(key, value) return url.toString() } diff --git a/packages/llm/src/provider/openai-responses.ts b/packages/llm/src/provider/openai-responses.ts index 1ee02c703e..a901c96b40 100644 --- a/packages/llm/src/provider/openai-responses.ts +++ b/packages/llm/src/provider/openai-responses.ts @@ -10,7 +10,6 @@ import { type TextPart, type ToolCallPart, type ToolDefinition, - type ToolResultPart, } from "../schema" import { ProviderShared } from "./shared" @@ -150,12 +149,7 @@ interface ParserState { const invalid = ProviderShared.invalidRequest -const baseUrl = (request: LLMRequest) => (request.model.baseURL ?? "https://api.openai.com/v1").replace(/\/+$/, "") - -const resultText = (part: ToolResultPart) => { - if (part.result.type === "text" || part.result.type === "error") return String(part.result.value) - return ProviderShared.encodeJson(part.result.value) -} +const baseUrl = (request: LLMRequest) => ProviderShared.trimBaseUrl(request.model.baseURL ?? "https://api.openai.com/v1") const lowerTool = (tool: ToolDefinition): OpenAIResponsesTool => ({ type: "function", @@ -216,7 +210,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ for (const part of message.content) { if (part.type !== "tool-result") return yield* invalid(`OpenAI Responses tool messages only support tool-result content`) - input.push({ type: "function_call_output", call_id: part.id, output: resultText(part) }) + input.push({ type: "function_call_output", call_id: part.id, output: ProviderShared.toolResultText(part) }) } } @@ -281,6 +275,9 @@ const finishToolCall = (tools: Record, item: NonNullabl return [{ type: "tool-call" as const, id: item.call_id, name: item.name, input }] }) +const withoutTool = (tools: Record, id: string | undefined) => + id === undefined ? tools : Object.fromEntries(Object.entries(tools).filter(([key]) => key !== id)) + // 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 @@ -360,7 +357,7 @@ const processChunk = (state: ParserState, chunk: OpenAIResponsesChunk) => if (chunk.type === "response.output_item.done" && chunk.item?.type === "function_call") { const events = yield* finishToolCall(state.tools, chunk.item) - return [state, events] as const + return [{ tools: withoutTool(state.tools, chunk.item.id) }, events] as const } if (chunk.type === "response.output_item.done" && chunk.item && isHostedToolItem(chunk.item)) { diff --git a/packages/llm/src/provider/shared.ts b/packages/llm/src/provider/shared.ts index ca4996ede6..88f9b4f0bc 100644 --- a/packages/llm/src/provider/shared.ts +++ b/packages/llm/src/provider/shared.ts @@ -1,7 +1,8 @@ +import { Buffer } from "node:buffer" import { Cause, Effect, Schema, Stream } from "effect" import * as Sse from "effect/unstable/encoding/Sse" import { HttpClientRequest, type HttpClientResponse } from "effect/unstable/http" -import { InvalidRequestError, ProviderChunkError } from "../schema" +import { InvalidRequestError, ProviderChunkError, type MediaPart, type ToolResultPart } from "../schema" export const Json = Schema.fromJsonString(Schema.Unknown) export const decodeJson = Schema.decodeUnknownSync(Json) @@ -34,6 +35,22 @@ export const joinText = (parts: ReadonlyArray<{ readonly text: string }>) => export const parseToolInput = (adapter: string, name: string, raw: string) => parseJson(adapter, raw || "{}", `Invalid JSON input for ${adapter} tool call ${name}`) +/** + * Encode a `MediaPart`'s raw bytes for inclusion in a JSON request body. + * `data: string` is assumed to already be base64 (matches caller convention + * across Gemini / Bedrock); `data: Uint8Array` is base64-encoded here. Used + * by every adapter that supports image / document inputs. + */ +export const mediaBytes = (part: MediaPart) => + typeof part.data === "string" ? part.data : Buffer.from(part.data).toString("base64") + +export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "") + +export const toolResultText = (part: ToolResultPart) => { + if (part.result.type === "text" || part.result.type === "error") return String(part.result.value) + return encodeJson(part.result.value) +} + const streamError = (adapter: string, message: string, cause: Cause.Cause) => { const failed = cause.reasons.find(Cause.isFailReason)?.error if (failed instanceof ProviderChunkError) return failed diff --git a/packages/llm/src/tool-runtime.ts b/packages/llm/src/tool-runtime.ts index 6090a3f1bf..ca5f59a5eb 100644 --- a/packages/llm/src/tool-runtime.ts +++ b/packages/llm/src/tool-runtime.ts @@ -10,7 +10,6 @@ import { type LLMEvent, LLMRequest, type ToolCallPart, - type ToolResultPart, type ToolResultValue, } from "./schema" import { ToolFailure } from "./schema" @@ -63,9 +62,13 @@ export const run = ( const maxSteps = options.maxSteps ?? 10 const concurrency = options.concurrency ?? 10 const tools = options.tools as Tools + const runtimeTools = toDefinitions(tools) const initialRequest = new LLMRequest({ ...options.request, - tools: [...options.request.tools, ...toDefinitions(tools)], + tools: [ + ...options.request.tools.filter((tool) => !runtimeTools.some((runtimeTool) => runtimeTool.name === tool.name)), + ...runtimeTools, + ], }) const loop = (request: LLMRequest, step: number): Stream.Stream => @@ -128,13 +131,12 @@ const accumulate = (state: StepState, event: LLMEvent) => { return } if (event.type === "tool-call") { - const part: ToolCallPart = { - type: "tool-call", + const part = LLM.toolCall({ id: event.id, name: event.name, input: event.input, providerExecuted: event.providerExecuted, - } + }) state.assistantContent.push(part) // Provider-executed tools are dispatched by the provider; the runtime must // not invoke a client handler. The matching `tool-result` event arrives @@ -144,14 +146,12 @@ const accumulate = (state: StepState, event: LLMEvent) => { return } if (event.type === "tool-result" && event.providerExecuted) { - const part: ToolResultPart = { - type: "tool-result", + state.assistantContent.push(LLM.toolResult({ id: event.id, name: event.name, result: event.result, providerExecuted: true, - } - state.assistantContent.push(part) + })) return } if (event.type === "request-finish") { @@ -198,7 +198,10 @@ const decodeAndExecute = (tool: AnyTool, input: unknown): Effect.Effect => result.type === "error" - ? [{ type: "tool-error", id: call.id, name: call.name, message: String(result.value) }] + ? [ + { type: "tool-error", id: call.id, name: call.name, message: String(result.value) }, + { type: "tool-result", id: call.id, name: call.name, result }, + ] : [{ type: "tool-result", id: call.id, name: call.name, result }] export * as ToolRuntime from "./tool-runtime" diff --git a/packages/llm/test/tool-runtime.test.ts b/packages/llm/test/tool-runtime.test.ts index 5e7c81f0d7..d5de805017 100644 --- a/packages/llm/test/tool-runtime.test.ts +++ b/packages/llm/test/tool-runtime.test.ts @@ -82,6 +82,12 @@ describe("ToolRuntime", () => { const toolError = events.find(LLMEvent.guards["tool-error"]) expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "missing_tool" }) expect(toolError?.message).toContain("Unknown tool") + expect(events.find(LLMEvent.guards["tool-result"])).toMatchObject({ + type: "tool-result", + id: "call_1", + name: "missing_tool", + result: { type: "error", value: "Unknown tool: missing_tool" }, + }) }), ) diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index 46dae369dc..5bafdb384f 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -1,11 +1,13 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" +import { AnthropicMessages } from "@opencode-ai/llm" import { client } from "@opencode-ai/llm/adapter" import { OpenAIResponses } from "@opencode-ai/llm/provider/openai-responses" -import { Effect, Schema } from "effect" +import { Cause, Effect, Exit, Layer, Schema } from "effect" import { ModelID, ProviderID } from "../../src/provider/schema" import { LLMNative } from "../../src/session/llm-native" import { MessageID, PartID, SessionID } from "../../src/session/schema" import { ProviderTest } from "../fake/provider" +import { testEffect } from "../lib/effect" import type { MessageV2 } from "../../src/session/message-v2" import type { Provider } from "../../src/provider" import type { Tool } from "../../src/tool" @@ -111,26 +113,26 @@ const lookupTool = { execute: () => Effect.succeed({ title: "", metadata: {}, output: "" }), } satisfies Tool.Def +const it = testEffect(Layer.empty) + describe("LLMNative.request", () => { - test("builds a text-only native LLM request", async () => { + it.effect("builds a text-only native LLM request", () => Effect.gen(function* () { const mdl = model() const provider = ProviderTest.info({ id: ProviderID.openai, key: "openai-key" }, mdl) const userID = MessageID.ascending() const assistantID = MessageID.ascending() - const request = await Effect.runPromise( - LLMNative.request({ - id: "request-1", - provider, - model: mdl, - system: ["You are concise.", ""], - generation: { maxTokens: 123, temperature: 0.2, topP: 0.9 }, - messages: [ - userMessage(mdl, userID, [textPart(userID, "ignored", { ignored: true }), textPart(userID, "Hello")]), - assistantMessage(mdl, assistantID, userID, [textPart(assistantID, "Hi")]), - ], - }), - ) + const request = yield* LLMNative.request({ + id: "request-1", + provider, + model: mdl, + system: ["You are concise.", ""], + generation: { maxTokens: 123, temperature: 0.2, topP: 0.9 }, + messages: [ + userMessage(mdl, userID, [textPart(userID, "ignored", { ignored: true }), textPart(userID, "Hello")]), + assistantMessage(mdl, assistantID, userID, [textPart(assistantID, "Hi")]), + ], + }) expect(request).toMatchObject({ id: "request-1", @@ -148,18 +150,16 @@ describe("LLMNative.request", () => { { id: userID, role: "user", content: [{ type: "text", text: "Hello" }] }, { id: assistantID, role: "assistant", content: [{ type: "text", text: "Hi" }] }, ]) - }) + })) - test("converts native tool definitions", async () => { + it.effect("converts native tool definitions", () => Effect.gen(function* () { const mdl = model() - const request = await Effect.runPromise( - LLMNative.request({ - provider: ProviderTest.info({ id: ProviderID.openai }, mdl), - model: mdl, - messages: [], - tools: [lookupTool], - }), - ) + const request = yield* LLMNative.request({ + provider: ProviderTest.info({ id: ProviderID.openai }, mdl), + model: mdl, + messages: [], + tools: [lookupTool], + }) expect(request.tools).toHaveLength(1) expect(request.tools[0]).toMatchObject({ @@ -179,38 +179,36 @@ describe("LLMNative.request", () => { opencodeToolID: "lookup", }, }) - }) + })) - test("converts assistant reasoning and tool history", async () => { + it.effect("converts assistant reasoning and tool history", () => Effect.gen(function* () { const mdl = model() const provider = ProviderTest.info({ id: ProviderID.openai }, mdl) const userID = MessageID.ascending() const assistantID = MessageID.ascending() - const request = await Effect.runPromise( - LLMNative.request({ - provider, - model: mdl, - messages: [ - userMessage(mdl, userID, [textPart(userID, "Check weather")]), - assistantMessage(mdl, assistantID, userID, [ - reasoningPart(assistantID, "Need a lookup."), - toolPart(assistantID, { - callID: "call_1", - tool: "lookup", - state: { - status: "completed", - input: { query: "weather" }, - output: "sunny", - title: "Weather", - metadata: {}, - time: { start: 1, end: 2 }, - }, - }), - ]), - ], - }), - ) + const request = yield* LLMNative.request({ + provider, + model: mdl, + messages: [ + userMessage(mdl, userID, [textPart(userID, "Check weather")]), + assistantMessage(mdl, assistantID, userID, [ + reasoningPart(assistantID, "Need a lookup."), + toolPart(assistantID, { + callID: "call_1", + tool: "lookup", + state: { + status: "completed", + input: { query: "weather" }, + output: "sunny", + title: "Weather", + metadata: {}, + time: { start: 1, end: 2 }, + }, + }), + ]), + ], + }) expect(request.messages.map((message) => ({ role: message.role, content: message.content }))).toEqual([ { role: "user", content: [{ type: "text", text: "Check weather" }] }, @@ -234,36 +232,34 @@ describe("LLMNative.request", () => { ], }, ]) - }) + })) - test("keeps provider-executed tool results on assistant messages", async () => { + it.effect("keeps provider-executed tool results on assistant messages", () => Effect.gen(function* () { const mdl = model() const userID = MessageID.ascending() const assistantID = MessageID.ascending() - const request = await Effect.runPromise( - LLMNative.request({ - provider: ProviderTest.info({ id: ProviderID.openai }, mdl), - model: mdl, - messages: [ - userMessage(mdl, userID, [textPart(userID, "Search docs")]), - assistantMessage(mdl, assistantID, userID, [ - toolPart(assistantID, { - callID: "ws_1", - tool: "web_search", - metadata: { providerExecuted: true, provider: "openai" }, - state: { - status: "completed", - input: { query: "effect" }, - output: "found", - title: "Search", - metadata: {}, - time: { start: 1, end: 2 }, - }, - }), - ]), - ], - }), - ) + const request = yield* LLMNative.request({ + provider: ProviderTest.info({ id: ProviderID.openai }, mdl), + model: mdl, + messages: [ + userMessage(mdl, userID, [textPart(userID, "Search docs")]), + assistantMessage(mdl, assistantID, userID, [ + toolPart(assistantID, { + callID: "ws_1", + tool: "web_search", + metadata: { providerExecuted: true, provider: "openai" }, + state: { + status: "completed", + input: { query: "effect" }, + output: "found", + title: "Search", + metadata: {}, + time: { start: 1, end: 2 }, + }, + }), + ]), + ], + }) expect(request.messages.map((message) => ({ role: message.role, content: message.content }))).toEqual([ { role: "user", content: [{ type: "text", text: "Search docs" }] }, @@ -289,53 +285,55 @@ describe("LLMNative.request", () => { ], }, ]) - }) + })) - test("fails instead of dropping unsupported native parts", async () => { + it.effect("fails instead of dropping unsupported native parts", () => Effect.gen(function* () { const mdl = model() const userID = MessageID.ascending() + const exit = yield* LLMNative.request({ + provider: ProviderTest.info({ id: ProviderID.openai }, mdl), + model: mdl, + messages: [userMessage(mdl, userID, [filePart(userID)])], + }).pipe(Effect.exit) - await expect( - Effect.runPromise( - LLMNative.request({ - provider: ProviderTest.info({ id: ProviderID.openai }, mdl), - model: mdl, - messages: [userMessage(mdl, userID, [filePart(userID)])], - }), - ), - ).rejects.toThrow(`Native LLM request conversion does not support file parts in message ${userID}`) - }) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) + expect(err).toBeInstanceOf(Error) + if (err instanceof Error) { + expect(err.message).toBe(`Native LLM request conversion does not support file parts in message ${userID}`) + } + } + })) - test("prepares OpenAI Responses text and tool request body", async () => { + it.effect("prepares OpenAI Responses text and tool request body", () => Effect.gen(function* () { const mdl = model() const userID = MessageID.ascending() const assistantID = MessageID.ascending() - const request = await Effect.runPromise( - LLMNative.request({ - provider: ProviderTest.info({ id: ProviderID.openai }, mdl), - model: mdl, - messages: [ - userMessage(mdl, userID, [textPart(userID, "What is the weather?")]), - assistantMessage(mdl, assistantID, userID, [ - toolPart(assistantID, { - callID: "call_1", - tool: "lookup", - state: { - status: "completed", - input: { query: "weather" }, - output: '{"forecast":"sunny"}', - title: "Weather", - metadata: {}, - time: { start: 1, end: 2 }, - }, - }), - ]), - ], - tools: [lookupTool], - toolChoice: "lookup", - }), - ) - const prepared = await Effect.runPromise(client({ adapters: [OpenAIResponses.adapter] }).prepare(request)) + const request = yield* LLMNative.request({ + provider: ProviderTest.info({ id: ProviderID.openai }, mdl), + model: mdl, + messages: [ + userMessage(mdl, userID, [textPart(userID, "What is the weather?")]), + assistantMessage(mdl, assistantID, userID, [ + toolPart(assistantID, { + callID: "call_1", + tool: "lookup", + state: { + status: "completed", + input: { query: "weather" }, + output: '{"forecast":"sunny"}', + title: "Weather", + metadata: {}, + time: { start: 1, end: 2 }, + }, + }), + ]), + ], + tools: [lookupTool], + toolChoice: "lookup", + }) + const prepared = yield* client({ adapters: [OpenAIResponses.adapter] }).prepare(request) expect(prepared.target).toMatchObject({ model: "gpt-5", @@ -359,5 +357,71 @@ describe("LLMNative.request", () => { tool_choice: { type: "function", name: "lookup" }, stream: true, }) - }) + })) + + it.effect("prepares Anthropic Messages text and tool request body", () => Effect.gen(function* () { + const mdl = model({ + id: ModelID.make("claude-sonnet-4-5"), + providerID: ProviderID.make("anthropic"), + api: { id: "claude-sonnet-4-5", url: "https://api.anthropic.com/v1", npm: "@ai-sdk/anthropic" }, + }) + const userID = MessageID.ascending() + const assistantID = MessageID.ascending() + const request = yield* LLMNative.request({ + provider: ProviderTest.info({ id: ProviderID.make("anthropic"), key: "anthropic-key" }, mdl), + model: mdl, + system: ["You are concise."], + generation: { maxTokens: 20, temperature: 0 }, + messages: [ + userMessage(mdl, userID, [textPart(userID, "What is the weather?")]), + assistantMessage(mdl, assistantID, userID, [ + toolPart(assistantID, { + callID: "call_1", + tool: "lookup", + state: { + status: "completed", + input: { query: "weather" }, + output: '{"forecast":"sunny"}', + title: "Weather", + metadata: {}, + time: { start: 1, end: 2 }, + }, + }), + ]), + ], + tools: [lookupTool], + toolChoice: "lookup", + }) + const prepared = yield* client({ adapters: [AnthropicMessages.adapter] }).prepare(request) + + expect(request.model).toMatchObject({ + provider: "anthropic", + protocol: "anthropic-messages", + headers: { "x-api-key": "anthropic-key" }, + }) + expect(prepared.target).toMatchObject({ + model: "claude-sonnet-4-5", + system: [{ type: "text", text: "You are concise." }], + messages: [ + { role: "user", content: [{ type: "text", text: "What is the weather?" }] }, + { role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup", input: { query: "weather" } }] }, + { role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"forecast":"sunny"}' }] }, + ], + tools: [ + { + name: "lookup", + description: "Lookup project data", + input_schema: { + type: "object", + properties: { query: { type: "string", description: "Search query" } }, + required: ["query"], + }, + }, + ], + tool_choice: { type: "tool", name: "lookup" }, + stream: true, + max_tokens: 20, + temperature: 0, + }) + })) })