feat: add GPT-OSS tool-calling support for OpenRouter

- Add support for handling tool calls in OpenRouter streaming responses
- Handle tool calls within reasoning/thinking blocks for GPT-OSS models
- Add ApiStreamToolCallChunk type to stream definitions
- Add comprehensive tests for GPT-OSS tool-calling scenarios

Fixes #6814
This commit is contained in:
Roo Code 2025-08-07 15:34:32 +00:00
parent 6b4ac52d00
commit f4a0fc3b4c
3 changed files with 524 additions and 2 deletions

View file

@ -45,6 +45,51 @@ vitest.mock("../fetchers/modelCache", () => ({
}),
}))
// Mock XmlMatcher
vitest.mock("../../../utils/xml-matcher", () => ({
XmlMatcher: vitest.fn().mockImplementation((tagName, transform) => {
return {
update: vitest.fn((chunk) => {
const results = []
// Simple mock implementation for testing
const toolCallRegex = new RegExp(`<${tagName}>(.+?)</${tagName}>`, "g")
let lastIndex = 0
let match
while ((match = toolCallRegex.exec(chunk)) !== null) {
// Add text before the match
if (match.index > lastIndex) {
results.push({
type: tagName,
data: chunk.substring(lastIndex, match.index),
matched: false,
})
}
// Add the matched content
results.push({
type: tagName,
data: match[1],
matched: true,
})
lastIndex = toolCallRegex.lastIndex
}
// Add remaining text
if (lastIndex < chunk.length) {
results.push({
type: tagName,
data: chunk.substring(lastIndex),
matched: false,
})
}
return transform ? results.map(transform) : results
}),
final: vitest.fn(() => []),
}
}),
}))
describe("OpenRouterHandler", () => {
const mockOptions: ApiHandlerOptions = {
openRouterApiKey: "test-key",
@ -320,4 +365,352 @@ describe("OpenRouterHandler", () => {
await expect(handler.completePrompt("test prompt")).rejects.toThrow("Unexpected error")
})
})
describe("GPT-OSS tool calling support", () => {
it("handles standard OpenAI-style tool calls in stream", async () => {
const handler = new OpenRouterHandler({
...mockOptions,
openRouterModelId: "openai/gpt-oss-120b",
})
const mockStream = {
async *[Symbol.asyncIterator]() {
// Simulate tool call chunks
yield {
id: "test-id",
choices: [
{
delta: {
tool_calls: [
{
id: "tool_1",
function: { name: "get_weather" },
},
],
},
},
],
}
yield {
id: "test-id",
choices: [
{
delta: {
tool_calls: [
{
function: { arguments: '{"location": ' },
},
],
},
},
],
}
yield {
id: "test-id",
choices: [
{
delta: {
tool_calls: [
{
function: { arguments: '"San Francisco"}' },
},
],
},
},
],
}
yield {
id: "test-id",
choices: [{ delta: {} }],
usage: { prompt_tokens: 10, completion_tokens: 20 },
}
},
}
const mockCreate = vitest.fn().mockResolvedValue(mockStream)
;(OpenAI as any).prototype.chat = {
completions: { create: mockCreate },
} as any
const generator = handler.createMessage("test", [])
const chunks = []
for await (const chunk of generator) {
chunks.push(chunk)
}
// Should have tool call chunk and usage chunk
expect(chunks).toHaveLength(2)
expect(chunks[0]).toEqual({
type: "tool_call",
id: "tool_1",
name: "get_weather",
arguments: '{"location": "San Francisco"}',
})
expect(chunks[1]).toEqual({
type: "usage",
inputTokens: 10,
outputTokens: 20,
cacheReadTokens: undefined,
reasoningTokens: undefined,
totalCost: 0,
})
})
it("handles tool calls within reasoning blocks for GPT-OSS models", async () => {
const handler = new OpenRouterHandler({
...mockOptions,
openRouterModelId: "openai/gpt-oss-20b",
})
const mockStream = {
async *[Symbol.asyncIterator]() {
// Simulate reasoning with embedded tool call
yield {
id: "test-id",
choices: [
{
delta: {
reasoning:
'Let me check the weather. <tool_call><name>get_weather</name><arguments>{"location": "New York"}</arguments></tool_call>',
},
},
],
}
yield {
id: "test-id",
choices: [
{
delta: {
content: "The weather in New York is sunny.",
},
},
],
}
yield {
id: "test-id",
choices: [{ delta: {} }],
usage: { prompt_tokens: 15, completion_tokens: 25 },
}
},
}
const mockCreate = vitest.fn().mockResolvedValue(mockStream)
;(OpenAI as any).prototype.chat = {
completions: { create: mockCreate },
} as any
const generator = handler.createMessage("test", [])
const chunks = []
for await (const chunk of generator) {
chunks.push(chunk)
}
// Should have reasoning, tool call, text, and usage chunks
expect(chunks).toHaveLength(4)
expect(chunks[0]).toEqual({
type: "reasoning",
text: "Let me check the weather. ",
})
expect(chunks[1]).toEqual({
type: "tool_call",
id: "tool_1",
name: "get_weather",
arguments: '{"location": "New York"}',
})
expect(chunks[2]).toEqual({
type: "text",
text: "The weather in New York is sunny.",
})
expect(chunks[3]).toEqual({
type: "usage",
inputTokens: 15,
outputTokens: 25,
cacheReadTokens: undefined,
reasoningTokens: undefined,
totalCost: 0,
})
})
it("handles multiple tool calls in reasoning for GPT-OSS", async () => {
const handler = new OpenRouterHandler({
...mockOptions,
openRouterModelId: "openai/gpt-oss-120b",
})
const mockStream = {
async *[Symbol.asyncIterator]() {
yield {
id: "test-id",
choices: [
{
delta: {
reasoning:
'I\'ll check multiple things. <tool_call><name>get_weather</name><arguments>{"location": "LA"}</arguments></tool_call> and then <tool_call><name>get_time</name><arguments>{"timezone": "PST"}</arguments></tool_call>',
},
},
],
}
yield {
id: "test-id",
choices: [{ delta: {} }],
usage: { prompt_tokens: 20, completion_tokens: 30 },
}
},
}
const mockCreate = vitest.fn().mockResolvedValue(mockStream)
;(OpenAI as any).prototype.chat = {
completions: { create: mockCreate },
} as any
const generator = handler.createMessage("test", [])
const chunks = []
for await (const chunk of generator) {
chunks.push(chunk)
}
// Should have reasoning text, two tool calls, more reasoning text, and usage
expect(chunks).toHaveLength(5)
expect(chunks[0]).toEqual({
type: "reasoning",
text: "I'll check multiple things. ",
})
expect(chunks[1]).toEqual({
type: "tool_call",
id: "tool_1",
name: "get_weather",
arguments: '{"location": "LA"}',
})
expect(chunks[2]).toEqual({
type: "reasoning",
text: " and then ",
})
expect(chunks[3]).toEqual({
type: "tool_call",
id: "tool_2",
name: "get_time",
arguments: '{"timezone": "PST"}',
})
expect(chunks[4]).toEqual({
type: "usage",
inputTokens: 20,
outputTokens: 30,
cacheReadTokens: undefined,
reasoningTokens: undefined,
totalCost: 0,
})
})
it("handles non-GPT-OSS models without tool call parsing in reasoning", async () => {
const handler = new OpenRouterHandler({
...mockOptions,
openRouterModelId: "anthropic/claude-sonnet-4",
})
const mockStream = {
async *[Symbol.asyncIterator]() {
yield {
id: "test-id",
choices: [
{
delta: {
reasoning: "This contains <tool_call> but should not be parsed as tool call",
},
},
],
}
yield {
id: "test-id",
choices: [{ delta: {} }],
usage: { prompt_tokens: 5, completion_tokens: 10 },
}
},
}
const mockCreate = vitest.fn().mockResolvedValue(mockStream)
;(OpenAI as any).prototype.chat = {
completions: { create: mockCreate },
} as any
const generator = handler.createMessage("test", [])
const chunks = []
for await (const chunk of generator) {
chunks.push(chunk)
}
// Should only have reasoning and usage chunks, no tool call parsing
expect(chunks).toHaveLength(2)
expect(chunks[0]).toEqual({
type: "reasoning",
text: "This contains <tool_call> but should not be parsed as tool call",
})
expect(chunks[1]).toEqual({
type: "usage",
inputTokens: 5,
outputTokens: 10,
cacheReadTokens: undefined,
reasoningTokens: undefined,
totalCost: 0,
})
})
it("handles malformed tool calls gracefully", async () => {
const handler = new OpenRouterHandler({
...mockOptions,
openRouterModelId: "openai/gpt-oss-20b",
})
const mockStream = {
async *[Symbol.asyncIterator]() {
yield {
id: "test-id",
choices: [
{
delta: {
reasoning: "Invalid tool call: <tool_call>missing closing tag",
},
},
],
}
yield {
id: "test-id",
choices: [
{
delta: {
reasoning: " and another <tool_call><name>no_args</name></tool_call>",
},
},
],
}
yield {
id: "test-id",
choices: [{ delta: {} }],
usage: { prompt_tokens: 5, completion_tokens: 10 },
}
},
}
const mockCreate = vitest.fn().mockResolvedValue(mockStream)
;(OpenAI as any).prototype.chat = {
completions: { create: mockCreate },
} as any
const generator = handler.createMessage("test", [])
const chunks = []
for await (const chunk of generator) {
chunks.push(chunk)
}
// Should handle malformed tool calls gracefully
// The first malformed one should be treated as reasoning text
// The second one without arguments should be ignored
expect(chunks.some((chunk) => chunk.type === "reasoning")).toBe(true)
expect(chunks[chunks.length - 1].type).toBe("usage")
})
})
})

View file

@ -18,6 +18,7 @@ import { addCacheBreakpoints as addAnthropicCacheBreakpoints } from "../transfor
import { addCacheBreakpoints as addGeminiCacheBreakpoints } from "../transform/caching/gemini"
import type { OpenRouterReasoningParams } from "../transform/reasoning"
import { getModelParams } from "../transform/model-params"
import { XmlMatcher } from "../../utils/xml-matcher"
import { getModels } from "./fetchers/modelCache"
import { getModelEndpoints } from "./fetchers/modelEndpointCache"
@ -138,6 +139,20 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
let lastUsage: CompletionUsage | undefined = undefined
// For GPT-OSS models, we need to handle tool calls that may appear within reasoning blocks
const isGptOss = modelId.includes("gpt-oss")
const toolCallMatcher = isGptOss
? new XmlMatcher("tool_call", (chunk) => ({
type: "tool_call" as const,
data: chunk.data,
matched: chunk.matched,
}))
: null
// Track accumulated tool call data for streaming
let currentToolCall: { id?: string; name?: string; arguments?: string } | null = null
let toolCallIdCounter = 0
for await (const chunk of stream) {
// OpenRouter returns an error object instead of the OpenAI SDK throwing an error.
if ("error" in chunk) {
@ -148,19 +163,121 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
const delta = chunk.choices[0]?.delta
// Handle reasoning content (which may contain tool calls for GPT-OSS)
if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") {
yield { type: "reasoning", text: delta.reasoning }
if (isGptOss && toolCallMatcher) {
// Process reasoning content through the tool call matcher
for (const matchChunk of toolCallMatcher.update(delta.reasoning)) {
if (matchChunk.type === "tool_call" && matchChunk.matched) {
// Parse the tool call from the matched content
try {
const toolCallContent = matchChunk.data
// Extract tool name and arguments from the XML-like format
const nameMatch = toolCallContent.match(/<name>([^<]+)<\/name>/)
const argsMatch = toolCallContent.match(/<arguments>([\s\S]*?)<\/arguments>/)
if (nameMatch && argsMatch) {
// Emit a tool call chunk
yield {
type: "tool_call" as const,
id: `tool_${++toolCallIdCounter}`,
name: nameMatch[1],
arguments: argsMatch[1],
}
}
} catch (e) {
console.warn("Failed to parse tool call from reasoning:", e)
// If parsing fails, treat it as regular reasoning text
yield { type: "reasoning", text: delta.reasoning }
}
} else {
// Regular reasoning text
yield { type: "reasoning", text: matchChunk.data }
}
}
} else {
// Non-GPT-OSS models or no tool call matching
yield { type: "reasoning", text: delta.reasoning }
}
}
// Handle regular content
if (delta?.content) {
yield { type: "text", text: delta.content }
}
// Handle standard OpenAI-style tool calls (if they appear in the stream)
if (delta?.tool_calls) {
for (const toolCall of delta.tool_calls) {
if (toolCall.id) {
// Start of a new tool call
if (currentToolCall) {
// Emit the previous tool call if it exists
if (currentToolCall.id && currentToolCall.name && currentToolCall.arguments) {
yield {
type: "tool_call" as const,
id: currentToolCall.id,
name: currentToolCall.name,
arguments: currentToolCall.arguments,
}
}
}
currentToolCall = {
id: toolCall.id,
name: toolCall.function?.name || "",
arguments: toolCall.function?.arguments || "",
}
} else if (currentToolCall && toolCall.function) {
// Continue accumulating the current tool call
if (toolCall.function.name) {
currentToolCall.name = (currentToolCall.name || "") + toolCall.function.name
}
if (toolCall.function.arguments) {
currentToolCall.arguments = (currentToolCall.arguments || "") + toolCall.function.arguments
}
}
}
}
if (chunk.usage) {
lastUsage = chunk.usage
}
}
// Finalize any remaining tool call matcher content
if (isGptOss && toolCallMatcher) {
for (const matchChunk of toolCallMatcher.final()) {
if (matchChunk.type === "tool_call" && matchChunk.matched) {
try {
const toolCallContent = matchChunk.data
const nameMatch = toolCallContent.match(/<name>([^<]+)<\/name>/)
const argsMatch = toolCallContent.match(/<arguments>([\s\S]*?)<\/arguments>/)
if (nameMatch && argsMatch) {
yield {
type: "tool_call" as const,
id: `tool_${++toolCallIdCounter}`,
name: nameMatch[1],
arguments: argsMatch[1],
}
}
} catch (e) {
console.warn("Failed to parse tool call from reasoning:", e)
}
}
}
}
// Emit any remaining accumulated tool call
if (currentToolCall && currentToolCall.id && currentToolCall.name && currentToolCall.arguments) {
yield {
type: "tool_call" as const,
id: currentToolCall.id,
name: currentToolCall.name,
arguments: currentToolCall.arguments,
}
}
if (lastUsage) {
yield {
type: "usage",

View file

@ -1,6 +1,11 @@
export type ApiStream = AsyncGenerator<ApiStreamChunk>
export type ApiStreamChunk = ApiStreamTextChunk | ApiStreamUsageChunk | ApiStreamReasoningChunk | ApiStreamError
export type ApiStreamChunk =
| ApiStreamTextChunk
| ApiStreamUsageChunk
| ApiStreamReasoningChunk
| ApiStreamToolCallChunk
| ApiStreamError
export interface ApiStreamError {
type: "error"
@ -18,6 +23,13 @@ export interface ApiStreamReasoningChunk {
text: string
}
export interface ApiStreamToolCallChunk {
type: "tool_call"
id: string
name: string
arguments: string
}
export interface ApiStreamUsageChunk {
type: "usage"
inputTokens: number