mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 03:19:59 -04:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f4577c78bf | |||
| 3dea9e91d1 | |||
| f0a12b549c | |||
| c4d804fb48 | |||
| 1f279cd2c8 |
@@ -75,7 +75,7 @@ export namespace ProviderTransform {
|
|||||||
|
|
||||||
if (model.api.id.includes("claude")) {
|
if (model.api.id.includes("claude")) {
|
||||||
const scrub = (id: string) => id.replace(/[^a-zA-Z0-9_-]/g, "_")
|
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)) {
|
if (msg.role === "assistant" && Array.isArray(msg.content)) {
|
||||||
return {
|
return {
|
||||||
...msg,
|
...msg,
|
||||||
@@ -101,6 +101,31 @@ export namespace ProviderTransform {
|
|||||||
return msg
|
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 (
|
if (
|
||||||
model.providerID === "mistral" ||
|
model.providerID === "mistral" ||
|
||||||
model.api.id.toLowerCase().includes("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[0].content).toBe("")
|
||||||
expect(result[1].content).toHaveLength(1)
|
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", () => {
|
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 { Filesystem } from "../../src/util/filesystem"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import type { Agent } from "../../src/agent/agent"
|
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 { SessionID, MessageID } from "../../src/session/schema"
|
||||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
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 () => {
|
test("sends Google API payload for Gemini models", async () => {
|
||||||
const server = state.server
|
const server = state.server
|
||||||
if (!server) {
|
if (!server) {
|
||||||
|
|||||||
Reference in New Issue
Block a user