mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-05-02 06:54:35 +08:00
Compare commits
5 Commits
shell-test
...
anthropic-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4577c78bf | ||
|
|
3dea9e91d1 | ||
|
|
f0a12b549c | ||
|
|
c4d804fb48 | ||
|
|
1f279cd2c8 |
@@ -75,7 +75,7 @@ export namespace ProviderTransform {
|
||||
|
||||
if (model.api.id.includes("claude")) {
|
||||
const scrub = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
return msgs.map((msg) => {
|
||||
msgs = msgs.map((msg) => {
|
||||
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||
return {
|
||||
...msg,
|
||||
@@ -101,6 +101,31 @@ export namespace ProviderTransform {
|
||||
return msg
|
||||
})
|
||||
}
|
||||
if (["@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic"].includes(model.api.npm)) {
|
||||
// Anthropic rejects assistant turns where tool_use blocks are followed by non-tool
|
||||
// content, e.g. [tool_use, tool_use, text], with:
|
||||
// `tool_use` ids were found without `tool_result` blocks immediately after...
|
||||
//
|
||||
// Reorder that invalid shape into [text] + [tool_use, tool_use]. Consecutive
|
||||
// assistant messages are later merged by the provider/SDK, so preserving the
|
||||
// original [tool_use...] then [text] order still produces the invalid payload.
|
||||
//
|
||||
// The root cause appears to be somewhere upstream where the stream is originally
|
||||
// processed. We were unable to locate an exact narrower reproduction elsewhere,
|
||||
// so we keep this transform in place for the time being.
|
||||
msgs = msgs.flatMap((msg) => {
|
||||
if (msg.role !== "assistant" || !Array.isArray(msg.content)) return [msg]
|
||||
|
||||
const parts = msg.content
|
||||
const first = parts.findIndex((part) => part.type === "tool-call")
|
||||
if (first === -1) return [msg]
|
||||
if (!parts.slice(first).some((part) => part.type !== "tool-call")) return [msg]
|
||||
return [
|
||||
{ ...msg, content: parts.filter((part) => part.type !== "tool-call") },
|
||||
{ ...msg, content: parts.filter((part) => part.type === "tool-call") },
|
||||
]
|
||||
})
|
||||
}
|
||||
if (
|
||||
model.providerID === "mistral" ||
|
||||
model.api.id.toLowerCase().includes("mistral") ||
|
||||
|
||||
@@ -1271,6 +1271,110 @@ describe("ProviderTransform.message - anthropic empty content filtering", () =>
|
||||
expect(result[0].content).toBe("")
|
||||
expect(result[1].content).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("splits anthropic assistant messages when text trails tool calls", () => {
|
||||
const msgs = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Check my home directory for PDFs" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
|
||||
{ type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
|
||||
{ type: "text", text: "I checked your home directory and looked for PDF files." },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
{ type: "tool-result", toolCallId: "toolu_1", toolName: "read", output: { type: "text", value: "ok" } },
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "toolu_2",
|
||||
toolName: "glob",
|
||||
output: { type: "text", value: "No files found" },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as any[]
|
||||
|
||||
const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[]
|
||||
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "I checked your home directory and looked for PDF files." }],
|
||||
})
|
||||
expect(result[2]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
|
||||
{ type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test("leaves valid anthropic assistant tool ordering unchanged", () => {
|
||||
const msgs = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "I checked your home directory and looked for PDF files." },
|
||||
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
|
||||
{ type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
|
||||
],
|
||||
},
|
||||
] as any[]
|
||||
|
||||
const result = ProviderTransform.message(msgs, anthropicModel, {}) as any[]
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].content).toMatchObject([
|
||||
{ type: "text", text: "I checked your home directory and looked for PDF files." },
|
||||
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
|
||||
{ type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("splits vertex anthropic assistant messages when text trails tool calls", () => {
|
||||
const model = {
|
||||
...anthropicModel,
|
||||
providerID: "google-vertex-anthropic",
|
||||
api: {
|
||||
id: "claude-sonnet-4@20250514",
|
||||
url: "https://us-central1-aiplatform.googleapis.com",
|
||||
npm: "@ai-sdk/google-vertex/anthropic",
|
||||
},
|
||||
}
|
||||
|
||||
const msgs = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
|
||||
{ type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
|
||||
{ type: "text", text: "I checked your home directory and looked for PDF files." },
|
||||
],
|
||||
},
|
||||
] as any[]
|
||||
|
||||
const result = ProviderTransform.message(msgs, model, {}) as any[]
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "I checked your home directory and looked for PDF files." }],
|
||||
})
|
||||
expect(result[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool-call", toolCallId: "toolu_1", toolName: "read", input: { filePath: "/root" } },
|
||||
{ type: "tool-call", toolCallId: "toolu_2", toolName: "glob", input: { pattern: "**/*.pdf" } },
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("ProviderTransform.message - strip openai metadata when store=false", () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ProviderID, ModelID } from "../../src/provider/schema"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import type { Agent } from "../../src/agent/agent"
|
||||
import type { MessageV2 } from "../../src/session/message-v2"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
|
||||
@@ -909,6 +909,269 @@ describe("session.llm.stream", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("sends anthropic tool_use blocks with tool_result immediately after them", async () => {
|
||||
const server = state.server
|
||||
if (!server) {
|
||||
throw new Error("Server not initialized")
|
||||
}
|
||||
|
||||
const source = await loadFixture("anthropic", "claude-opus-4-6")
|
||||
const model = source.model
|
||||
const chunks = [
|
||||
{
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: "msg-tool-order",
|
||||
model: model.id,
|
||||
usage: {
|
||||
input_tokens: 3,
|
||||
cache_creation_input_tokens: null,
|
||||
cache_read_input_tokens: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "text", text: "" },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text: "ok" },
|
||||
},
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
{
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn", stop_sequence: null, container: null },
|
||||
usage: {
|
||||
input_tokens: 3,
|
||||
output_tokens: 2,
|
||||
cache_creation_input_tokens: null,
|
||||
cache_read_input_tokens: null,
|
||||
},
|
||||
},
|
||||
{ type: "message_stop" },
|
||||
]
|
||||
const request = waitRequest("/messages", createEventResponse(chunks))
|
||||
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
enabled_providers: ["anthropic"],
|
||||
provider: {
|
||||
anthropic: {
|
||||
name: "Anthropic",
|
||||
env: ["ANTHROPIC_API_KEY"],
|
||||
npm: "@ai-sdk/anthropic",
|
||||
api: "https://api.anthropic.com/v1",
|
||||
models: {
|
||||
[model.id]: model,
|
||||
},
|
||||
options: {
|
||||
apiKey: "test-anthropic-key",
|
||||
baseURL: `${server.url.origin}/v1`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const resolved = await getModel(ProviderID.make("anthropic"), ModelID.make(model.id))
|
||||
const sessionID = SessionID.make("session-test-anthropic-tools")
|
||||
const agent = {
|
||||
name: "test",
|
||||
mode: "primary",
|
||||
options: {},
|
||||
permission: [{ permission: "*", pattern: "*", action: "allow" }],
|
||||
} satisfies Agent.Info
|
||||
const user = {
|
||||
id: MessageID.make("user-anthropic-tools"),
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: agent.name,
|
||||
model: { providerID: ProviderID.make("anthropic"), modelID: resolved.id, variant: "max" },
|
||||
} satisfies MessageV2.User
|
||||
|
||||
const input = [
|
||||
{
|
||||
info: {
|
||||
id: "msg_user",
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1 },
|
||||
agent: "gentleman",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-6", variant: "max" },
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "p_user",
|
||||
sessionID,
|
||||
messageID: "msg_user",
|
||||
type: "text",
|
||||
text: "Can you check whether there are any PDF files in my home directory?",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
info: {
|
||||
id: "msg_call",
|
||||
sessionID,
|
||||
parentID: "msg_user",
|
||||
role: "assistant",
|
||||
mode: "gentleman",
|
||||
agent: "gentleman",
|
||||
variant: "max",
|
||||
path: { cwd: "/root", root: "/" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
modelID: "claude-opus-4-6",
|
||||
providerID: "anthropic",
|
||||
time: { created: 2, completed: 3 },
|
||||
finish: "tool-calls",
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "p_step",
|
||||
sessionID,
|
||||
messageID: "msg_call",
|
||||
type: "step-start",
|
||||
},
|
||||
{
|
||||
id: "p_read",
|
||||
sessionID,
|
||||
messageID: "msg_call",
|
||||
type: "tool",
|
||||
tool: "read",
|
||||
callID: "toolu_01N8mDEzG8DSTs7UPHFtmgCT",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: "/root" },
|
||||
output: "<path>/root</path>",
|
||||
metadata: {},
|
||||
title: "root",
|
||||
time: { start: 10, end: 11 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "p_glob",
|
||||
sessionID,
|
||||
messageID: "msg_call",
|
||||
type: "tool",
|
||||
tool: "glob",
|
||||
callID: "toolu_01APxrADs7VozN8uWzw9WwHr",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { pattern: "**/*.pdf", path: "/root" },
|
||||
output: "No files found",
|
||||
metadata: {},
|
||||
title: "root",
|
||||
time: { start: 12, end: 13 },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "p_text",
|
||||
sessionID,
|
||||
messageID: "msg_call",
|
||||
type: "text",
|
||||
text: "I checked your home directory and looked for PDF files.",
|
||||
time: { start: 14, end: 15 },
|
||||
},
|
||||
],
|
||||
},
|
||||
] as any[]
|
||||
|
||||
await drain({
|
||||
user,
|
||||
sessionID,
|
||||
model: resolved,
|
||||
agent,
|
||||
system: [],
|
||||
messages: await MessageV2.toModelMessages(input as any, resolved),
|
||||
tools: {
|
||||
read: tool({
|
||||
description: "Stub read tool",
|
||||
inputSchema: z.object({
|
||||
filePath: z.string(),
|
||||
}),
|
||||
execute: async () => ({ output: "stub" }),
|
||||
}),
|
||||
glob: tool({
|
||||
description: "Stub glob tool",
|
||||
inputSchema: z.object({
|
||||
pattern: z.string(),
|
||||
path: z.string().optional(),
|
||||
}),
|
||||
execute: async () => ({ output: "stub" }),
|
||||
}),
|
||||
},
|
||||
})
|
||||
|
||||
const capture = await request
|
||||
const body = capture.body
|
||||
|
||||
expect(capture.url.pathname.endsWith("/messages")).toBe(true)
|
||||
expect(body.messages).toStrictEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Can you check whether there are any PDF files in my home directory?" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "I checked your home directory and looked for PDF files.",
|
||||
},
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_01N8mDEzG8DSTs7UPHFtmgCT",
|
||||
name: "read",
|
||||
input: { filePath: "/root" },
|
||||
},
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_01APxrADs7VozN8uWzw9WwHr",
|
||||
name: "glob",
|
||||
input: { pattern: "**/*.pdf", path: "/root" },
|
||||
cache_control: {
|
||||
type: "ephemeral",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_01N8mDEzG8DSTs7UPHFtmgCT",
|
||||
content: "<path>/root</path>",
|
||||
},
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_01APxrADs7VozN8uWzw9WwHr",
|
||||
content: "No files found",
|
||||
cache_control: {
|
||||
type: "ephemeral",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("sends Google API payload for Gemini models", async () => {
|
||||
const server = state.server
|
||||
if (!server) {
|
||||
|
||||
Reference in New Issue
Block a user