Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 30 additions & 23 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,25 +149,27 @@ export namespace LLM {

const tools = await resolveTools(input)

// LiteLLM and some Anthropic proxies require the tools parameter to be present
// when message history contains tool calls, even if no tools are being used.
// Add a dummy tool that is never called to satisfy this validation.
// This is enabled for:
// 1. Providers with "litellm" in their ID or API ID (auto-detected)
// 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways)
const isLiteLLMProxy =
provider.options?.["litellmProxy"] === true ||
input.model.providerID.toLowerCase().includes("litellm") ||
input.model.api.id.toLowerCase().includes("litellm")

if (isLiteLLMProxy && Object.keys(tools).length === 0 && hasToolCalls(input.messages)) {
tools["_noop"] = tool({
description:
"Placeholder for LiteLLM/Anthropic proxy compatibility - required when message history contains tool calls but no active tools are needed",
inputSchema: jsonSchema({ type: "object", properties: {} }),
execute: async () => ({ output: "", title: "", metadata: {} }),
})
// altimate_change start — ensure tool definitions exist for all tool_use blocks in history
// The Anthropic API (and proxies like LiteLLM) require every tool_use block in
// message history to have a matching tool definition. When agents switch (Plan→Builder),
// MCP tools disconnect, or tools are filtered by permissions, the history may reference
// tools absent from the current set. Add stub definitions for any missing tools.
// Fixes: https://github.com/AltimateAI/altimate-code/issues/678
const referencedTools = toolNamesFromMessages(input.messages)
for (const name of referencedTools) {
if (!Object.hasOwn(tools, name)) {
tools[name] = tool({
description: `[Historical] Tool no longer available in this session`,
inputSchema: jsonSchema({ type: "object", properties: {} }),
execute: async () => ({
output: "This tool is no longer available. Please use an alternative approach.",
title: "",
metadata: {},
}),
})
}
}
// altimate_change end

return streamText({
onError(error) {
Expand Down Expand Up @@ -265,15 +267,20 @@ export namespace LLM {
return input.tools
}

// Check if messages contain any tool-call content
// Used to determine if a dummy tool should be added for LiteLLM proxy compatibility
export function hasToolCalls(messages: ModelMessage[]): boolean {
// altimate_change start — collect tool names from message history to prevent API validation errors
// Anthropic API requires every tool_use block in message history to have a matching tool
// definition. When agents switch (e.g. Plan→Builder) or MCP tools disconnect, the history
// may reference tools no longer in the active set. This function extracts those names so
// stub definitions can be added. Fixes #678.
export function toolNamesFromMessages(messages: ModelMessage[]): Set<string> {
const names = new Set<string>()
for (const msg of messages) {
if (!Array.isArray(msg.content)) continue
for (const part of msg.content) {
if (part.type === "tool-call" || part.type === "tool-result") return true
if (part.type === "tool-call" || part.type === "tool-result") names.add(part.toolName)
}
}
return false
return names
}
// altimate_change end
}
88 changes: 35 additions & 53 deletions packages/opencode/test/session/llm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,90 +14,72 @@ import type { Agent } from "../../src/agent/agent"
import type { MessageV2 } from "../../src/session/message-v2"
import { SessionID, MessageID } from "../../src/session/schema"

describe("session.llm.hasToolCalls", () => {
test("returns false for empty messages array", () => {
expect(LLM.hasToolCalls([])).toBe(false)
describe("session.llm.toolNamesFromMessages", () => {
test("returns empty set for empty messages", () => {
expect(LLM.toolNamesFromMessages([])).toEqual(new Set())
})

test("returns false for messages with only text content", () => {
test("returns empty set for messages with no tool calls", () => {
const messages: ModelMessage[] = [
{
role: "user",
content: [{ type: "text", text: "Hello" }],
},
{ role: "user", content: [{ type: "text", text: "Hello" }] },
{ role: "assistant", content: [{ type: "text", text: "Hi" }] },
]
expect(LLM.toolNamesFromMessages(messages)).toEqual(new Set())
})

test("extracts tool names from tool-call blocks", () => {
const messages = [
{
role: "assistant",
content: [{ type: "text", text: "Hi there" }],
content: [
{ type: "tool-call", toolCallId: "call-1", toolName: "bash" },
{ type: "tool-call", toolCallId: "call-2", toolName: "read" },
],
},
]
expect(LLM.hasToolCalls(messages)).toBe(false)
] as ModelMessage[]
expect(LLM.toolNamesFromMessages(messages)).toEqual(new Set(["bash", "read"]))
})

test("returns true when messages contain tool-call", () => {
test("deduplicates tool names across messages", () => {
const messages = [
{
role: "user",
content: [{ type: "text", text: "Run a command" }],
role: "assistant",
content: [{ type: "tool-call", toolCallId: "call-1", toolName: "bash" }],
},
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call-123",
toolName: "bash",
},
],
content: [{ type: "tool-call", toolCallId: "call-2", toolName: "bash" }],
},
] as ModelMessage[]
expect(LLM.hasToolCalls(messages)).toBe(true)
expect(LLM.toolNamesFromMessages(messages)).toEqual(new Set(["bash"]))
})

test("returns true when messages contain tool-result", () => {
test("extracts tool names from tool-result blocks", () => {
const messages = [
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call-123",
toolName: "bash",
},
],
content: [{ type: "tool-result", toolCallId: "call-1", toolName: "bash" }],
},
] as ModelMessage[]
expect(LLM.hasToolCalls(messages)).toBe(true)
expect(LLM.toolNamesFromMessages(messages)).toEqual(new Set(["bash"]))
})

test("returns false for messages with string content", () => {
const messages: ModelMessage[] = [
test("extracts from both tool-call and tool-result blocks", () => {
const messages = [
{
role: "user",
content: "Hello world",
role: "assistant",
content: [{ type: "tool-call", toolCallId: "call-1", toolName: "bash" }],
},
{
role: "assistant",
content: "Hi there",
role: "tool",
content: [{ type: "tool-result", toolCallId: "call-1", toolName: "bash" }],
},
]
expect(LLM.hasToolCalls(messages)).toBe(false)
})

test("returns true when tool-call is mixed with text content", () => {
const messages = [
{
role: "assistant",
content: [
{ type: "text", text: "Let me run that command" },
{
type: "tool-call",
toolCallId: "call-456",
toolName: "read",
},
],
role: "tool",
content: [{ type: "tool-result", toolCallId: "call-2", toolName: "read" }],
},
] as ModelMessage[]
expect(LLM.hasToolCalls(messages)).toBe(true)
expect(LLM.toolNamesFromMessages(messages)).toEqual(new Set(["bash", "read"]))
})
})

Expand Down
Loading