mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: migrate xAI provider to Responses API with reusable transform utils (#11962)
Co-authored-by: Enrico Carlesso <ecarlesso@twitter.com> Co-authored-by: Roo Code <roomote@roocode.com>
This commit is contained in:
parent
eafed9705c
commit
5432fa2689
7 changed files with 1231 additions and 555 deletions
|
|
@ -3,10 +3,10 @@ import type { ModelInfo } from "../model.js"
|
|||
// https://docs.x.ai/docs/api-reference
|
||||
export type XAIModelId = keyof typeof xaiModels
|
||||
|
||||
export const xaiDefaultModelId: XAIModelId = "grok-4.20-beta-0309-reasoning"
|
||||
export const xaiDefaultModelId: XAIModelId = "grok-4.20"
|
||||
|
||||
export const xaiModels = {
|
||||
"grok-4.20-beta-0309-reasoning": {
|
||||
"grok-4.20": {
|
||||
maxTokens: 65_536,
|
||||
contextWindow: 2_000_000,
|
||||
supportsImages: true,
|
||||
|
|
@ -15,21 +15,7 @@ export const xaiModels = {
|
|||
outputPrice: 6.0,
|
||||
cacheWritesPrice: 0.5,
|
||||
cacheReadsPrice: 0.5,
|
||||
description:
|
||||
"xAI's Grok 4.20 reasoning model with 2M context. Reasoning is internal (not exposed via Chat Completions API).",
|
||||
includedTools: ["search_replace"],
|
||||
excludedTools: ["apply_diff"],
|
||||
},
|
||||
"grok-4.20-beta-0309-non-reasoning": {
|
||||
maxTokens: 65_536,
|
||||
contextWindow: 2_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
inputPrice: 2.0,
|
||||
outputPrice: 6.0,
|
||||
cacheWritesPrice: 0.5,
|
||||
cacheReadsPrice: 0.5,
|
||||
description: "xAI's Grok 4.20 non-reasoning model - faster inference with 2M context.",
|
||||
description: "xAI's flagship Grok 4.20 model with 2M context and reasoning support via Responses API.",
|
||||
includedTools: ["search_replace"],
|
||||
excludedTools: ["apply_diff"],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -10,14 +10,16 @@ vitest.mock("@roo-code/telemetry", () => ({
|
|||
},
|
||||
}))
|
||||
|
||||
const mockCreate = vitest.fn()
|
||||
const mockResponsesCreate = vitest.fn()
|
||||
|
||||
vitest.mock("openai", () => {
|
||||
const mockConstructor = vitest.fn()
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: mockConstructor.mockImplementation(() => ({ chat: { completions: { create: mockCreate } } })),
|
||||
default: mockConstructor.mockImplementation(() => ({
|
||||
responses: { create: mockResponsesCreate },
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -28,16 +30,30 @@ import { xaiDefaultModelId, xaiModels } from "@roo-code/types"
|
|||
|
||||
import { XAIHandler } from "../xai"
|
||||
|
||||
// Helper to create an async iterable from events
|
||||
function mockStream(events: any[]) {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => {
|
||||
let index = 0
|
||||
return {
|
||||
async next() {
|
||||
if (index < events.length) {
|
||||
return { done: false, value: events[index++] }
|
||||
}
|
||||
return { done: true, value: undefined }
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("XAIHandler", () => {
|
||||
let handler: XAIHandler
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
vi.clearAllMocks()
|
||||
mockCreate.mockClear()
|
||||
mockResponsesCreate.mockClear()
|
||||
mockCaptureException.mockClear()
|
||||
|
||||
// Create handler with mock
|
||||
handler = new XAIHandler({})
|
||||
})
|
||||
|
||||
|
|
@ -50,14 +66,9 @@ describe("XAIHandler", () => {
|
|||
})
|
||||
|
||||
it("should use the provided API key", () => {
|
||||
// Clear mocks before this specific test
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Create a handler with our API key
|
||||
const xaiApiKey = "test-api-key"
|
||||
new XAIHandler({ xaiApiKey })
|
||||
|
||||
// Verify the OpenAI constructor was called with our API key
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
apiKey: xaiApiKey,
|
||||
|
|
@ -71,111 +82,41 @@ describe("XAIHandler", () => {
|
|||
expect(model.info).toEqual(xaiModels[xaiDefaultModelId])
|
||||
})
|
||||
|
||||
test("should return specified model when valid model is provided", () => {
|
||||
it("should return specified model when valid model is provided", () => {
|
||||
const testModelId = "grok-3"
|
||||
const handlerWithModel = new XAIHandler({ apiModelId: testModelId })
|
||||
const model = handlerWithModel.getModel()
|
||||
|
||||
expect(model.id).toBe(testModelId)
|
||||
expect(model.info).toEqual(xaiModels[testModelId])
|
||||
})
|
||||
|
||||
it("should include reasoning_effort parameter for mini models", async () => {
|
||||
const miniModelHandler = new XAIHandler({
|
||||
apiModelId: "grok-3-mini",
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
it("should use Responses API (client.responses.create)", async () => {
|
||||
mockResponsesCreate.mockResolvedValueOnce(mockStream([]))
|
||||
|
||||
// Setup mock for streaming response
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
async next() {
|
||||
return { done: true }
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
const stream = handler.createMessage("test prompt", [])
|
||||
await stream.next()
|
||||
|
||||
// Start generating a message
|
||||
const messageGenerator = miniModelHandler.createMessage("test prompt", [])
|
||||
await messageGenerator.next() // Start the generator
|
||||
|
||||
// Check that reasoning_effort was included
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect(mockResponsesCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
reasoning_effort: "high",
|
||||
model: xaiDefaultModelId,
|
||||
instructions: "test prompt",
|
||||
stream: true,
|
||||
store: false,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should not include reasoning_effort parameter for non-mini models", async () => {
|
||||
const regularModelHandler = new XAIHandler({
|
||||
apiModelId: "grok-3",
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
|
||||
// Setup mock for streaming response
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
async next() {
|
||||
return { done: true }
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
// Start generating a message
|
||||
const messageGenerator = regularModelHandler.createMessage("test prompt", [])
|
||||
await messageGenerator.next() // Start the generator
|
||||
|
||||
// Check call args for reasoning_effort
|
||||
const calls = mockCreate.mock.calls
|
||||
const lastCall = calls[calls.length - 1][0]
|
||||
expect(lastCall).not.toHaveProperty("reasoning_effort")
|
||||
})
|
||||
|
||||
it("completePrompt method should return text from OpenAI API", async () => {
|
||||
const expectedResponse = "This is a test response"
|
||||
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] })
|
||||
|
||||
const result = await handler.completePrompt("test prompt")
|
||||
expect(result).toBe(expectedResponse)
|
||||
})
|
||||
|
||||
it("should handle errors in completePrompt", async () => {
|
||||
const errorMessage = "API error"
|
||||
mockCreate.mockRejectedValueOnce(new Error(errorMessage))
|
||||
|
||||
await expect(handler.completePrompt("test prompt")).rejects.toThrow(`xAI completion error: ${errorMessage}`)
|
||||
})
|
||||
|
||||
it("createMessage should yield text content from stream", async () => {
|
||||
const testContent = "This is test content"
|
||||
|
||||
// Setup mock for streaming response
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: {
|
||||
choices: [{ delta: { content: testContent } }],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
mockResponsesCreate.mockResolvedValueOnce(
|
||||
mockStream([{ type: "response.output_text.delta", delta: testContent }]),
|
||||
)
|
||||
|
||||
// Create and consume the stream
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
|
||||
// Verify the content
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toEqual({
|
||||
type: "text",
|
||||
|
|
@ -186,28 +127,13 @@ describe("XAIHandler", () => {
|
|||
it("createMessage should yield reasoning content from stream", async () => {
|
||||
const testReasoning = "Test reasoning content"
|
||||
|
||||
// Setup mock for streaming response
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: {
|
||||
choices: [{ delta: { reasoning_content: testReasoning } }],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
mockResponsesCreate.mockResolvedValueOnce(
|
||||
mockStream([{ type: "response.reasoning_text.delta", delta: testReasoning }]),
|
||||
)
|
||||
|
||||
// Create and consume the stream
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
|
||||
// Verify the reasoning content
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toEqual({
|
||||
type: "reasoning",
|
||||
|
|
@ -215,373 +141,158 @@ describe("XAIHandler", () => {
|
|||
})
|
||||
})
|
||||
|
||||
it("createMessage should yield usage data from stream", async () => {
|
||||
// Setup mock for streaming response that includes usage data
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: {
|
||||
choices: [{ delta: {} }], // Needs to have choices array to avoid error
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 20,
|
||||
cache_read_input_tokens: 5,
|
||||
cache_creation_input_tokens: 15,
|
||||
},
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
it("createMessage should yield usage data from response.completed", async () => {
|
||||
mockResponsesCreate.mockResolvedValueOnce(
|
||||
mockStream([
|
||||
{
|
||||
type: "response.completed",
|
||||
response: {
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 20,
|
||||
input_tokens_details: { cached_tokens: 5 },
|
||||
output_tokens_details: { reasoning_tokens: 8 },
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
// Create and consume the stream
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
|
||||
// Verify the usage data
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toEqual({
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
cacheReadTokens: 5,
|
||||
cacheWriteTokens: 15,
|
||||
})
|
||||
})
|
||||
|
||||
it("createMessage should pass correct parameters to OpenAI client", async () => {
|
||||
// Setup a handler with specific model
|
||||
const modelId = "grok-3"
|
||||
const modelInfo = xaiModels[modelId]
|
||||
const handlerWithModel = new XAIHandler({ apiModelId: modelId })
|
||||
|
||||
// Setup mock for streaming response
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
async next() {
|
||||
return { done: true }
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
// System prompt and messages
|
||||
const systemPrompt = "Test system prompt"
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message" }]
|
||||
|
||||
// Start generating a message
|
||||
const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages)
|
||||
await messageGenerator.next() // Start the generator
|
||||
|
||||
// Check that all parameters were passed correctly
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect(firstChunk.value).toEqual(
|
||||
expect.objectContaining({
|
||||
model: modelId,
|
||||
max_tokens: modelInfo.maxTokens,
|
||||
temperature: 0,
|
||||
messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]),
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
cacheReadTokens: 5,
|
||||
reasoningTokens: 8,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Native Tool Calling", () => {
|
||||
it("createMessage should yield tool_call from output_item.done", async () => {
|
||||
mockResponsesCreate.mockResolvedValueOnce(
|
||||
mockStream([
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
call_id: "call_123",
|
||||
name: "test_tool",
|
||||
arguments: '{"arg1":"value"}',
|
||||
},
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toEqual({
|
||||
type: "tool_call",
|
||||
id: "call_123",
|
||||
name: "test_tool",
|
||||
arguments: '{"arg1":"value"}',
|
||||
})
|
||||
})
|
||||
|
||||
it("should include tools in Responses API format", async () => {
|
||||
const testTools = [
|
||||
{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "test_tool",
|
||||
description: "A test tool",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
arg1: { type: "string", description: "First argument" },
|
||||
},
|
||||
required: ["arg1"],
|
||||
},
|
||||
parameters: { type: "object", properties: { arg1: { type: "string" } }, required: ["arg1"] },
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
it("should include tools in request when model supports native tools and tools are provided (native is default)", async () => {
|
||||
const handlerWithTools = new XAIHandler({ apiModelId: "grok-3" })
|
||||
mockResponsesCreate.mockResolvedValueOnce(mockStream([]))
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
async next() {
|
||||
return { done: true }
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const messageGenerator = handlerWithTools.createMessage("test prompt", [], {
|
||||
taskId: "test-task-id",
|
||||
tools: testTools,
|
||||
})
|
||||
await messageGenerator.next()
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "function",
|
||||
function: expect.objectContaining({
|
||||
name: "test_tool",
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
parallel_tool_calls: true,
|
||||
}),
|
||||
)
|
||||
const stream = handler.createMessage("test prompt", [], {
|
||||
taskId: "test-task-id",
|
||||
tools: testTools,
|
||||
})
|
||||
await stream.next()
|
||||
|
||||
it("should include tool_choice when provided", async () => {
|
||||
const handlerWithTools = new XAIHandler({ apiModelId: "grok-3" })
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
async next() {
|
||||
return { done: true }
|
||||
},
|
||||
expect(mockResponsesCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: [
|
||||
expect.objectContaining({
|
||||
type: "function",
|
||||
name: "test_tool",
|
||||
description: "A test tool",
|
||||
strict: true,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const messageGenerator = handlerWithTools.createMessage("test prompt", [], {
|
||||
taskId: "test-task-id",
|
||||
tools: testTools,
|
||||
],
|
||||
tool_choice: "auto",
|
||||
})
|
||||
await messageGenerator.next()
|
||||
parallel_tool_calls: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tool_choice: "auto",
|
||||
it("completePrompt should return text from Responses API", async () => {
|
||||
const expectedResponse = "This is a test response"
|
||||
mockResponsesCreate.mockResolvedValueOnce({
|
||||
output_text: expectedResponse,
|
||||
})
|
||||
|
||||
const result = await handler.completePrompt("test prompt")
|
||||
expect(result).toBe(expectedResponse)
|
||||
})
|
||||
|
||||
it("should handle errors in completePrompt", async () => {
|
||||
const errorMessage = "API error"
|
||||
mockResponsesCreate.mockRejectedValueOnce(new Error(errorMessage))
|
||||
|
||||
await expect(handler.completePrompt("test prompt")).rejects.toThrow(`xAI completion error: ${errorMessage}`)
|
||||
})
|
||||
|
||||
it("should include reasoning_effort for mini models", async () => {
|
||||
const miniModelHandler = new XAIHandler({
|
||||
apiModelId: "grok-3-mini",
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
|
||||
mockResponsesCreate.mockResolvedValueOnce(mockStream([]))
|
||||
|
||||
const stream = miniModelHandler.createMessage("test prompt", [])
|
||||
await stream.next()
|
||||
|
||||
expect(mockResponsesCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
reasoning: expect.objectContaining({
|
||||
reasoning_effort: "high",
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should not include reasoning for non-mini models", async () => {
|
||||
const regularHandler = new XAIHandler({
|
||||
apiModelId: "grok-3",
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
|
||||
it("should always include tools and tool_choice (tools are guaranteed to be present after ALWAYS_AVAILABLE_TOOLS)", async () => {
|
||||
const handlerWithTools = new XAIHandler({ apiModelId: "grok-3" })
|
||||
mockResponsesCreate.mockResolvedValueOnce(mockStream([]))
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
async next() {
|
||||
return { done: true }
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
const stream = regularHandler.createMessage("test prompt", [])
|
||||
await stream.next()
|
||||
|
||||
const messageGenerator = handlerWithTools.createMessage("test prompt", [], {
|
||||
taskId: "test-task-id",
|
||||
})
|
||||
await messageGenerator.next()
|
||||
const callArgs = mockResponsesCreate.mock.calls[mockResponsesCreate.mock.calls.length - 1][0]
|
||||
expect(callArgs).not.toHaveProperty("reasoning")
|
||||
})
|
||||
|
||||
// Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS)
|
||||
const callArgs = mockCreate.mock.calls[mockCreate.mock.calls.length - 1][0]
|
||||
expect(callArgs).toHaveProperty("tools")
|
||||
expect(callArgs).toHaveProperty("tool_choice")
|
||||
expect(callArgs).toHaveProperty("parallel_tool_calls", true)
|
||||
})
|
||||
it("should handle errors in createMessage", async () => {
|
||||
const errorMessage = "Stream error"
|
||||
mockResponsesCreate.mockRejectedValueOnce(new Error(errorMessage))
|
||||
|
||||
it("should yield tool_call_partial chunks during streaming", async () => {
|
||||
const handlerWithTools = new XAIHandler({ apiModelId: "grok-3" })
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_123",
|
||||
function: {
|
||||
name: "test_tool",
|
||||
arguments: '{"arg1":',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
function: {
|
||||
arguments: '"value"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const stream = handlerWithTools.createMessage("test prompt", [], {
|
||||
taskId: "test-task-id",
|
||||
tools: testTools,
|
||||
})
|
||||
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks).toContainEqual({
|
||||
type: "tool_call_partial",
|
||||
index: 0,
|
||||
id: "call_123",
|
||||
name: "test_tool",
|
||||
arguments: '{"arg1":',
|
||||
})
|
||||
|
||||
expect(chunks).toContainEqual({
|
||||
type: "tool_call_partial",
|
||||
index: 0,
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
arguments: '"value"}',
|
||||
})
|
||||
})
|
||||
|
||||
it("should set parallel_tool_calls based on metadata", async () => {
|
||||
const handlerWithTools = new XAIHandler({ apiModelId: "grok-3" })
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
async next() {
|
||||
return { done: true }
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const messageGenerator = handlerWithTools.createMessage("test prompt", [], {
|
||||
taskId: "test-task-id",
|
||||
tools: testTools,
|
||||
parallelToolCalls: true,
|
||||
})
|
||||
await messageGenerator.next()
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
parallel_tool_calls: true,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should yield tool_call_end events when finish_reason is tool_calls", async () => {
|
||||
// Import NativeToolCallParser to set up state
|
||||
const { NativeToolCallParser } = await import("../../../core/assistant-message/NativeToolCallParser")
|
||||
|
||||
// Clear any previous state
|
||||
NativeToolCallParser.clearRawChunkState()
|
||||
|
||||
const handlerWithTools = new XAIHandler({ apiModelId: "grok-3" })
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: {
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id: "call_xai_test",
|
||||
function: {
|
||||
name: "test_tool",
|
||||
arguments: '{"arg1":"value"}',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: {
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
finish_reason: "tool_calls",
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const stream = handlerWithTools.createMessage("test prompt", [], {
|
||||
taskId: "test-task-id",
|
||||
tools: testTools,
|
||||
})
|
||||
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
// Simulate what Task.ts does: when we receive tool_call_partial,
|
||||
// process it through NativeToolCallParser to populate rawChunkTracker
|
||||
if (chunk.type === "tool_call_partial") {
|
||||
NativeToolCallParser.processRawChunk({
|
||||
index: chunk.index,
|
||||
id: chunk.id,
|
||||
name: chunk.name,
|
||||
arguments: chunk.arguments,
|
||||
})
|
||||
}
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Should have tool_call_partial and tool_call_end
|
||||
const partialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial")
|
||||
const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end")
|
||||
|
||||
expect(partialChunks).toHaveLength(1)
|
||||
expect(endChunks).toHaveLength(1)
|
||||
expect(endChunks[0].id).toBe("call_xai_test")
|
||||
})
|
||||
const stream = handler.createMessage("test prompt", [])
|
||||
await expect(stream.next()).rejects.toThrow(`xAI completion error: ${errorMessage}`)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,17 +4,18 @@ import OpenAI from "openai"
|
|||
import { type XAIModelId, xaiDefaultModelId, xaiModels, ApiProviderError } from "@roo-code/types"
|
||||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
|
||||
import { NativeToolCallParser } from "../../core/assistant-message/NativeToolCallParser"
|
||||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { convertToResponsesApiInput } from "../transform/responses-api-input"
|
||||
import { processResponsesApiStream, createUsageNormalizer } from "../transform/responses-api-stream"
|
||||
import { getModelParams } from "../transform/model-params"
|
||||
|
||||
import { DEFAULT_HEADERS } from "./constants"
|
||||
import { BaseProvider } from "./base-provider"
|
||||
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
|
||||
import { handleOpenAIError } from "./utils/openai-error-handler"
|
||||
import { isMcpTool } from "../../utils/mcp-name"
|
||||
|
||||
const XAI_DEFAULT_TEMPERATURE = 0
|
||||
|
||||
|
|
@ -53,118 +54,108 @@ export class XAIHandler extends BaseProvider implements SingleCompletionHandler
|
|||
return { id, info, ...params }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert tools from OpenAI Chat Completions format to Responses API format.
|
||||
* Chat Completions: { type: "function", function: { name, description, parameters } }
|
||||
* Responses API: { type: "function", name, description, parameters }
|
||||
*
|
||||
* Uses base provider's convertToolSchemaForOpenAI() for schema hardening
|
||||
* (additionalProperties: false, ensureAllRequired) and handles MCP tools.
|
||||
*/
|
||||
private mapResponseTools(tools?: any[]): any[] | undefined {
|
||||
const converted = this.convertToolsForOpenAI(tools)
|
||||
if (!converted?.length) {
|
||||
return undefined
|
||||
}
|
||||
return converted
|
||||
.filter((tool) => tool?.type === "function")
|
||||
.map((tool) => {
|
||||
const isMcp = isMcpTool(tool.function.name)
|
||||
return {
|
||||
type: "function",
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
parameters: isMcp
|
||||
? tool.function.parameters
|
||||
: this.convertToolSchemaForOpenAI(tool.function.parameters),
|
||||
strict: !isMcp,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override async *createMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
const { id: modelId, info: modelInfo, reasoning } = this.getModel()
|
||||
const model = this.getModel()
|
||||
|
||||
// Use the OpenAI-compatible API.
|
||||
const requestOptions = {
|
||||
model: modelId,
|
||||
max_tokens: modelInfo.maxTokens,
|
||||
temperature: this.options.modelTemperature ?? XAI_DEFAULT_TEMPERATURE,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
] as OpenAI.Chat.ChatCompletionMessageParam[],
|
||||
stream: true as const,
|
||||
stream_options: { include_usage: true },
|
||||
...(reasoning && reasoning),
|
||||
tools: this.convertToolsForOpenAI(metadata?.tools),
|
||||
tool_choice: metadata?.tool_choice,
|
||||
parallel_tool_calls: metadata?.parallelToolCalls ?? true,
|
||||
// Convert directly from Anthropic format to Responses API input format
|
||||
const input = convertToResponsesApiInput(messages)
|
||||
const responseTools = this.mapResponseTools(metadata?.tools)
|
||||
|
||||
// Build request options
|
||||
const requestBody: Record<string, any> = {
|
||||
model: model.id,
|
||||
instructions: systemPrompt,
|
||||
input: input,
|
||||
stream: true,
|
||||
store: false, // Don't store responses server-side for privacy
|
||||
include: ["reasoning.encrypted_content"],
|
||||
}
|
||||
|
||||
let stream
|
||||
if (model.maxTokens) {
|
||||
requestBody.max_output_tokens = model.maxTokens
|
||||
}
|
||||
|
||||
if (model.temperature !== undefined) {
|
||||
requestBody.temperature = model.temperature
|
||||
}
|
||||
|
||||
if (responseTools) {
|
||||
requestBody.tools = responseTools
|
||||
// Cast tool_choice since metadata uses Chat Completions types but Responses API has its own type
|
||||
requestBody.tool_choice = (metadata?.tool_choice ?? "auto") as any
|
||||
requestBody.parallel_tool_calls = metadata?.parallelToolCalls ?? true
|
||||
}
|
||||
|
||||
// Pass reasoning effort for models that support it (e.g., mini models)
|
||||
if (model.reasoning) {
|
||||
requestBody.reasoning = model.reasoning
|
||||
}
|
||||
|
||||
let stream: AsyncIterable<any>
|
||||
try {
|
||||
stream = await this.client.chat.completions.create(requestOptions)
|
||||
stream = (await this.client.responses.create({
|
||||
...requestBody,
|
||||
stream: true,
|
||||
} as any)) as unknown as AsyncIterable<any>
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "createMessage")
|
||||
const apiError = new ApiProviderError(errorMessage, this.providerName, model.id, "createMessage")
|
||||
TelemetryService.instance.captureException(apiError)
|
||||
throw handleOpenAIError(error, this.providerName)
|
||||
}
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta
|
||||
const finishReason = chunk.choices[0]?.finish_reason
|
||||
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
|
||||
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
text: delta.reasoning_content as string,
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tool calls in stream - emit partial chunks for NativeToolCallParser
|
||||
if (delta?.tool_calls) {
|
||||
for (const toolCall of delta.tool_calls) {
|
||||
yield {
|
||||
type: "tool_call_partial",
|
||||
index: toolCall.index,
|
||||
id: toolCall.id,
|
||||
name: toolCall.function?.name,
|
||||
arguments: toolCall.function?.arguments,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process finish_reason to emit tool_call_end events
|
||||
// This ensures tool calls are finalized even if the stream doesn't properly close
|
||||
if (finishReason) {
|
||||
const endEvents = NativeToolCallParser.processFinishReason(finishReason)
|
||||
for (const event of endEvents) {
|
||||
yield event
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
// Extract detailed token information if available
|
||||
// First check for prompt_tokens_details structure (real API response)
|
||||
const promptDetails = "prompt_tokens_details" in chunk.usage ? chunk.usage.prompt_tokens_details : null
|
||||
const cachedTokens = promptDetails && "cached_tokens" in promptDetails ? promptDetails.cached_tokens : 0
|
||||
|
||||
// Fall back to direct fields in usage (used in test mocks)
|
||||
const readTokens =
|
||||
cachedTokens ||
|
||||
("cache_read_input_tokens" in chunk.usage ? (chunk.usage as any).cache_read_input_tokens : 0)
|
||||
const writeTokens =
|
||||
"cache_creation_input_tokens" in chunk.usage ? (chunk.usage as any).cache_creation_input_tokens : 0
|
||||
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
cacheReadTokens: readTokens,
|
||||
cacheWriteTokens: writeTokens,
|
||||
}
|
||||
}
|
||||
}
|
||||
const normalizeUsage = createUsageNormalizer()
|
||||
yield* processResponsesApiStream(stream, normalizeUsage)
|
||||
}
|
||||
|
||||
async completePrompt(prompt: string): Promise<string> {
|
||||
const { id: modelId, reasoning } = this.getModel()
|
||||
const model = this.getModel()
|
||||
|
||||
try {
|
||||
const response = await this.client.chat.completions.create({
|
||||
model: modelId,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
...(reasoning && reasoning),
|
||||
const response = await this.client.responses.create({
|
||||
model: model.id,
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: prompt }] }],
|
||||
store: false,
|
||||
})
|
||||
|
||||
return response.choices[0]?.message.content || ""
|
||||
// output_text is a convenience field on the Responses API response
|
||||
return response.output_text || ""
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
const apiError = new ApiProviderError(errorMessage, this.providerName, modelId, "completePrompt")
|
||||
const apiError = new ApiProviderError(errorMessage, this.providerName, model.id, "completePrompt")
|
||||
TelemetryService.instance.captureException(apiError)
|
||||
throw handleOpenAIError(error, this.providerName)
|
||||
}
|
||||
|
|
|
|||
338
src/api/transform/__tests__/responses-api-input.spec.ts
Normal file
338
src/api/transform/__tests__/responses-api-input.spec.ts
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { convertToResponsesApiInput } from "../responses-api-input"
|
||||
|
||||
describe("convertToResponsesApiInput", () => {
|
||||
it("should return empty array for empty messages", () => {
|
||||
expect(convertToResponsesApiInput([])).toEqual([])
|
||||
})
|
||||
|
||||
describe("string content messages", () => {
|
||||
it("should convert string content to input_text", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
|
||||
|
||||
const result = convertToResponsesApiInput(messages)
|
||||
|
||||
expect(result).toEqual([{ role: "user", content: [{ type: "input_text", text: "Hello" }] }])
|
||||
})
|
||||
|
||||
it("should convert assistant string content to output_text message format", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "assistant", content: "Hi there" }]
|
||||
|
||||
const result = convertToResponsesApiInput(messages)
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Hi there" }],
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("user messages with content blocks", () => {
|
||||
it("should convert text blocks to input_text", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "What is this?" }],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToResponsesApiInput(messages)
|
||||
|
||||
expect(result).toEqual([{ role: "user", content: [{ type: "input_text", text: "What is this?" }] }])
|
||||
})
|
||||
|
||||
it("should convert image blocks to input_image", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "abc123" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToResponsesApiInput(messages)
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "input_image",
|
||||
detail: "auto",
|
||||
image_url: "data:image/png;base64,abc123",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should convert tool_result to function_call_output", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_123",
|
||||
content: "Result text",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToResponsesApiInput(messages)
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: "function_call_output",
|
||||
call_id: "tool_123",
|
||||
output: "Result text",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should use (empty) for empty tool_result content", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_123",
|
||||
content: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToResponsesApiInput(messages)
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: "function_call_output",
|
||||
call_id: "tool_123",
|
||||
output: "(empty)",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should extract text from array tool_result content", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_123",
|
||||
content: [
|
||||
{ type: "text", text: "Line 1" },
|
||||
{ type: "text", text: "Line 2" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToResponsesApiInput(messages)
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: "function_call_output",
|
||||
call_id: "tool_123",
|
||||
output: "Line 1\nLine 2",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should flush pending user content before tool_result", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Here is context" },
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_123",
|
||||
content: "Done",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToResponsesApiInput(messages)
|
||||
|
||||
expect(result).toEqual([
|
||||
{ role: "user", content: [{ type: "input_text", text: "Here is context" }] },
|
||||
{ type: "function_call_output", call_id: "tool_123", output: "Done" },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("assistant messages with content blocks", () => {
|
||||
it("should convert text blocks to output_text messages", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Here is my response" }],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToResponsesApiInput(messages)
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Here is my response" }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should convert tool_use to function_call", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "call_abc",
|
||||
name: "read_file",
|
||||
input: { path: "/tmp/test.txt" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToResponsesApiInput(messages)
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
type: "function_call",
|
||||
call_id: "call_abc",
|
||||
name: "read_file",
|
||||
arguments: '{"path":"/tmp/test.txt"}',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should handle tool_use with string input", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "call_abc",
|
||||
name: "run_command",
|
||||
input: '{"cmd":"ls"}' as any,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToResponsesApiInput(messages)
|
||||
|
||||
expect(result[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "function_call",
|
||||
arguments: '{"cmd":"ls"}',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should handle mixed text and tool_use in assistant message", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Let me read that file" },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "call_abc",
|
||||
name: "read_file",
|
||||
input: { path: "/tmp/test.txt" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToResponsesApiInput(messages)
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Let me read that file" }],
|
||||
}),
|
||||
)
|
||||
expect(result[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "function_call",
|
||||
name: "read_file",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("multi-turn conversations", () => {
|
||||
it("should handle a complete tool use cycle", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{ role: "user", content: "Read /tmp/test.txt" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "call_1",
|
||||
name: "read_file",
|
||||
input: { path: "/tmp/test.txt" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "call_1",
|
||||
content: "file contents here",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "The file contains: file contents here" }],
|
||||
},
|
||||
]
|
||||
|
||||
const result = convertToResponsesApiInput(messages)
|
||||
|
||||
expect(result).toHaveLength(4)
|
||||
expect(result[0]).toEqual({ role: "user", content: [{ type: "input_text", text: "Read /tmp/test.txt" }] })
|
||||
expect(result[1]).toEqual(
|
||||
expect.objectContaining({ type: "function_call", call_id: "call_1", name: "read_file" }),
|
||||
)
|
||||
expect(result[2]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "function_call_output",
|
||||
call_id: "call_1",
|
||||
output: "file contents here",
|
||||
}),
|
||||
)
|
||||
expect(result[3]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
392
src/api/transform/__tests__/responses-api-stream.spec.ts
Normal file
392
src/api/transform/__tests__/responses-api-stream.spec.ts
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
import { processResponsesApiStream, createUsageNormalizer } from "../responses-api-stream"
|
||||
|
||||
// Helper to create an async iterable from events
|
||||
async function* mockStream(events: any[]) {
|
||||
for (const event of events) {
|
||||
yield event
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to collect all chunks from a stream
|
||||
async function collectChunks(stream: AsyncGenerator<any>) {
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
const noopUsage = () => undefined
|
||||
|
||||
describe("processResponsesApiStream", () => {
|
||||
describe("text deltas", () => {
|
||||
it("should yield text chunk for response.output_text.delta", async () => {
|
||||
const stream = mockStream([{ type: "response.output_text.delta", delta: "Hello world" }])
|
||||
|
||||
const chunks = await collectChunks(processResponsesApiStream(stream, noopUsage))
|
||||
|
||||
expect(chunks).toEqual([{ type: "text", text: "Hello world" }])
|
||||
})
|
||||
|
||||
it("should yield text chunk for response.text.delta", async () => {
|
||||
const stream = mockStream([{ type: "response.text.delta", delta: "Hello" }])
|
||||
|
||||
const chunks = await collectChunks(processResponsesApiStream(stream, noopUsage))
|
||||
|
||||
expect(chunks).toEqual([{ type: "text", text: "Hello" }])
|
||||
})
|
||||
|
||||
it("should skip text delta with empty delta", async () => {
|
||||
const stream = mockStream([{ type: "response.output_text.delta", delta: "" }])
|
||||
|
||||
const chunks = await collectChunks(processResponsesApiStream(stream, noopUsage))
|
||||
|
||||
expect(chunks).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("reasoning deltas", () => {
|
||||
it("should yield reasoning chunk for response.reasoning_text.delta", async () => {
|
||||
const stream = mockStream([{ type: "response.reasoning_text.delta", delta: "Let me think..." }])
|
||||
|
||||
const chunks = await collectChunks(processResponsesApiStream(stream, noopUsage))
|
||||
|
||||
expect(chunks).toEqual([{ type: "reasoning", text: "Let me think..." }])
|
||||
})
|
||||
|
||||
it("should yield reasoning chunk for response.reasoning.delta", async () => {
|
||||
const stream = mockStream([{ type: "response.reasoning.delta", delta: "Step 1" }])
|
||||
|
||||
const chunks = await collectChunks(processResponsesApiStream(stream, noopUsage))
|
||||
|
||||
expect(chunks).toEqual([{ type: "reasoning", text: "Step 1" }])
|
||||
})
|
||||
|
||||
it("should yield reasoning chunk for response.reasoning_summary_text.delta", async () => {
|
||||
const stream = mockStream([{ type: "response.reasoning_summary_text.delta", delta: "Summary" }])
|
||||
|
||||
const chunks = await collectChunks(processResponsesApiStream(stream, noopUsage))
|
||||
|
||||
expect(chunks).toEqual([{ type: "reasoning", text: "Summary" }])
|
||||
})
|
||||
|
||||
it("should yield reasoning chunk for response.reasoning_summary.delta", async () => {
|
||||
const stream = mockStream([{ type: "response.reasoning_summary.delta", delta: "Summary" }])
|
||||
|
||||
const chunks = await collectChunks(processResponsesApiStream(stream, noopUsage))
|
||||
|
||||
expect(chunks).toEqual([{ type: "reasoning", text: "Summary" }])
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool calls", () => {
|
||||
it("should yield tool_call for function_call in output_item.done", async () => {
|
||||
const stream = mockStream([
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
call_id: "call_123",
|
||||
name: "read_file",
|
||||
arguments: '{"path":"/tmp/test.txt"}',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const chunks = await collectChunks(processResponsesApiStream(stream, noopUsage))
|
||||
|
||||
expect(chunks).toEqual([
|
||||
{
|
||||
type: "tool_call",
|
||||
id: "call_123",
|
||||
name: "read_file",
|
||||
arguments: '{"path":"/tmp/test.txt"}',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should yield tool_call for tool_call type in output_item.done", async () => {
|
||||
const stream = mockStream([
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "tool_call",
|
||||
tool_call_id: "call_456",
|
||||
name: "write_file",
|
||||
arguments: '{"path":"/tmp/out.txt"}',
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const chunks = await collectChunks(processResponsesApiStream(stream, noopUsage))
|
||||
|
||||
expect(chunks).toEqual([
|
||||
{
|
||||
type: "tool_call",
|
||||
id: "call_456",
|
||||
name: "write_file",
|
||||
arguments: '{"path":"/tmp/out.txt"}',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it("should handle object arguments by JSON.stringifying", async () => {
|
||||
const stream = mockStream([
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
call_id: "call_789",
|
||||
name: "test",
|
||||
input: { key: "value" },
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
const chunks = await collectChunks(processResponsesApiStream(stream, noopUsage))
|
||||
|
||||
expect(chunks[0].arguments).toBe('{"key":"value"}')
|
||||
})
|
||||
|
||||
it("should skip tool_call with missing call_id or name", async () => {
|
||||
const stream = mockStream([
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "function_call", call_id: "", name: "test", arguments: "{}" },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "function_call", call_id: "call_1", name: "", arguments: "{}" },
|
||||
},
|
||||
])
|
||||
|
||||
const chunks = await collectChunks(processResponsesApiStream(stream, noopUsage))
|
||||
|
||||
expect(chunks).toEqual([])
|
||||
})
|
||||
|
||||
it("should yield tool_call_partial for function_call_arguments.delta", async () => {
|
||||
const stream = mockStream([
|
||||
{
|
||||
type: "response.function_call_arguments.delta",
|
||||
call_id: "call_123",
|
||||
name: "read_file",
|
||||
delta: '{"path":',
|
||||
index: 0,
|
||||
},
|
||||
])
|
||||
|
||||
const chunks = await collectChunks(processResponsesApiStream(stream, noopUsage))
|
||||
|
||||
expect(chunks).toEqual([
|
||||
{
|
||||
type: "tool_call_partial",
|
||||
index: 0,
|
||||
id: "call_123",
|
||||
name: "read_file",
|
||||
arguments: '{"path":',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("completion and usage", () => {
|
||||
it("should yield usage from response.completed", async () => {
|
||||
const mockNormalize = (usage: any) => ({
|
||||
type: "usage" as const,
|
||||
inputTokens: usage.input_tokens,
|
||||
outputTokens: usage.output_tokens,
|
||||
})
|
||||
|
||||
const stream = mockStream([
|
||||
{
|
||||
type: "response.completed",
|
||||
response: { usage: { input_tokens: 100, output_tokens: 50 } },
|
||||
},
|
||||
])
|
||||
|
||||
const chunks = await collectChunks(processResponsesApiStream(stream, mockNormalize))
|
||||
|
||||
expect(chunks).toEqual([{ type: "usage", inputTokens: 100, outputTokens: 50 }])
|
||||
})
|
||||
|
||||
it("should yield usage from response.done", async () => {
|
||||
const mockNormalize = (usage: any) => ({
|
||||
type: "usage" as const,
|
||||
inputTokens: usage.input_tokens,
|
||||
outputTokens: usage.output_tokens,
|
||||
})
|
||||
|
||||
const stream = mockStream([
|
||||
{
|
||||
type: "response.done",
|
||||
response: { usage: { input_tokens: 200, output_tokens: 100 } },
|
||||
},
|
||||
])
|
||||
|
||||
const chunks = await collectChunks(processResponsesApiStream(stream, mockNormalize))
|
||||
|
||||
expect(chunks).toEqual([{ type: "usage", inputTokens: 200, outputTokens: 100 }])
|
||||
})
|
||||
|
||||
it("should not yield usage when normalizer returns undefined", async () => {
|
||||
const stream = mockStream([
|
||||
{
|
||||
type: "response.completed",
|
||||
response: { usage: null },
|
||||
},
|
||||
])
|
||||
|
||||
const chunks = await collectChunks(processResponsesApiStream(stream, noopUsage))
|
||||
|
||||
expect(chunks).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("unknown events", () => {
|
||||
it("should silently ignore unknown event types", async () => {
|
||||
const stream = mockStream([
|
||||
{ type: "response.created" },
|
||||
{ type: "response.in_progress" },
|
||||
{ type: "response.output_item.added", item: { type: "message" } },
|
||||
{ type: "response.content_part.added" },
|
||||
])
|
||||
|
||||
const chunks = await collectChunks(processResponsesApiStream(stream, noopUsage))
|
||||
|
||||
expect(chunks).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("full conversation stream", () => {
|
||||
it("should handle a complete stream with reasoning, text, and usage", async () => {
|
||||
const mockNormalize = (usage: any) => ({
|
||||
type: "usage" as const,
|
||||
inputTokens: usage.input_tokens,
|
||||
outputTokens: usage.output_tokens,
|
||||
})
|
||||
|
||||
const stream = mockStream([
|
||||
{ type: "response.reasoning_text.delta", delta: "Thinking..." },
|
||||
{ type: "response.reasoning_text.delta", delta: " done." },
|
||||
{ type: "response.output_text.delta", delta: "The answer is " },
|
||||
{ type: "response.output_text.delta", delta: "42." },
|
||||
{
|
||||
type: "response.completed",
|
||||
response: { usage: { input_tokens: 50, output_tokens: 30 } },
|
||||
},
|
||||
])
|
||||
|
||||
const chunks = await collectChunks(processResponsesApiStream(stream, mockNormalize))
|
||||
|
||||
expect(chunks).toEqual([
|
||||
{ type: "reasoning", text: "Thinking..." },
|
||||
{ type: "reasoning", text: " done." },
|
||||
{ type: "text", text: "The answer is " },
|
||||
{ type: "text", text: "42." },
|
||||
{ type: "usage", inputTokens: 50, outputTokens: 30 },
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("createUsageNormalizer", () => {
|
||||
it("should return undefined for null/undefined usage", () => {
|
||||
const normalize = createUsageNormalizer()
|
||||
expect(normalize(null)).toBeUndefined()
|
||||
expect(normalize(undefined)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should extract input and output tokens", () => {
|
||||
const normalize = createUsageNormalizer()
|
||||
|
||||
const result = normalize({ input_tokens: 100, output_tokens: 50 })
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
type: "usage",
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should extract cached tokens from input_tokens_details", () => {
|
||||
const normalize = createUsageNormalizer()
|
||||
|
||||
const result = normalize({
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
input_tokens_details: { cached_tokens: 30 },
|
||||
})
|
||||
|
||||
expect(result?.cacheReadTokens).toBe(30)
|
||||
})
|
||||
|
||||
it("should extract cache write tokens", () => {
|
||||
const normalize = createUsageNormalizer()
|
||||
|
||||
const result = normalize({
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_creation_input_tokens: 15,
|
||||
})
|
||||
|
||||
expect(result?.cacheWriteTokens).toBe(15)
|
||||
})
|
||||
|
||||
it("should extract reasoning tokens from output_tokens_details", () => {
|
||||
const normalize = createUsageNormalizer()
|
||||
|
||||
const result = normalize({
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
output_tokens_details: { reasoning_tokens: 20 },
|
||||
})
|
||||
|
||||
expect(result?.reasoningTokens).toBe(20)
|
||||
})
|
||||
|
||||
it("should not include reasoningTokens when not present", () => {
|
||||
const normalize = createUsageNormalizer()
|
||||
|
||||
const result = normalize({ input_tokens: 100, output_tokens: 50 })
|
||||
|
||||
expect(result).not.toHaveProperty("reasoningTokens")
|
||||
})
|
||||
|
||||
it("should compute totalCost when calculateCost is provided", () => {
|
||||
const calculateCost = (input: number, output: number, cached: number) => 0.42
|
||||
const normalize = createUsageNormalizer(calculateCost)
|
||||
|
||||
const result = normalize({ input_tokens: 100, output_tokens: 50 })
|
||||
|
||||
expect(result?.totalCost).toBe(0.42)
|
||||
})
|
||||
|
||||
it("should not include totalCost when calculateCost is not provided", () => {
|
||||
const normalize = createUsageNormalizer()
|
||||
|
||||
const result = normalize({ input_tokens: 100, output_tokens: 50 })
|
||||
|
||||
expect(result).not.toHaveProperty("totalCost")
|
||||
})
|
||||
|
||||
it("should handle Chat Completions style field names as fallback", () => {
|
||||
const normalize = createUsageNormalizer()
|
||||
|
||||
const result = normalize({
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 50,
|
||||
prompt_tokens_details: { cached_tokens: 10 },
|
||||
})
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheReadTokens: 10,
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
118
src/api/transform/responses-api-input.ts
Normal file
118
src/api/transform/responses-api-input.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
/**
|
||||
* Converts Anthropic-format messages to the OpenAI Responses API input format.
|
||||
*
|
||||
* Key differences from Chat Completions format:
|
||||
* - Content parts use { type: "input_text" } instead of { type: "text" }
|
||||
* - Images use { type: "input_image" } instead of { type: "image_url" }
|
||||
* - Tool results use { type: "function_call_output", call_id } instead of { role: "tool", tool_call_id }
|
||||
* - Tool uses become { type: "function_call", call_id, name, arguments } items
|
||||
* - System prompt goes via the `instructions` parameter, not as a message
|
||||
*
|
||||
* @param messages - Array of Anthropic MessageParam objects
|
||||
* @returns Array of Responses API input items
|
||||
*/
|
||||
export function convertToResponsesApiInput(messages: Anthropic.Messages.MessageParam[]): any[] {
|
||||
const input: any[] = []
|
||||
|
||||
for (const message of messages) {
|
||||
if (typeof message.content === "string") {
|
||||
if (message.role === "assistant") {
|
||||
// Assistant messages use output_text in the Responses API format
|
||||
input.push({
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: message.content }],
|
||||
})
|
||||
} else {
|
||||
input.push({
|
||||
role: message.role,
|
||||
content: [{ type: "input_text", text: message.content }],
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (message.role === "assistant") {
|
||||
for (const part of message.content) {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
input.push({
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: part.text }],
|
||||
})
|
||||
break
|
||||
case "tool_use":
|
||||
input.push({
|
||||
type: "function_call",
|
||||
call_id: part.id,
|
||||
name: part.name,
|
||||
arguments: typeof part.input === "string" ? part.input : JSON.stringify(part.input ?? {}),
|
||||
})
|
||||
break
|
||||
case "thinking":
|
||||
// Include reasoning if it has content
|
||||
if ((part as any).thinking && (part as any).thinking.trim().length > 0) {
|
||||
input.push({
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: `[Thinking] ${(part as any).thinking}` }],
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// User messages
|
||||
const contentParts: any[] = []
|
||||
for (const part of message.content) {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
contentParts.push({ type: "input_text", text: part.text })
|
||||
break
|
||||
case "image":
|
||||
contentParts.push({
|
||||
type: "input_image",
|
||||
detail: "auto",
|
||||
image_url: `data:${part.source.media_type};base64,${part.source.data}`,
|
||||
})
|
||||
break
|
||||
case "tool_result": {
|
||||
// Flush any pending user content before the tool result
|
||||
if (contentParts.length > 0) {
|
||||
input.push({ role: "user", content: [...contentParts] })
|
||||
contentParts.length = 0
|
||||
}
|
||||
// Convert tool result content
|
||||
let output: string
|
||||
if (typeof part.content === "string") {
|
||||
output = part.content || "(empty)"
|
||||
} else if (Array.isArray(part.content)) {
|
||||
output =
|
||||
part.content
|
||||
.filter((c): c is Anthropic.TextBlockParam => c.type === "text")
|
||||
.map((c) => c.text)
|
||||
.join("\n") || "(empty)"
|
||||
} else {
|
||||
output = "(empty)"
|
||||
}
|
||||
input.push({
|
||||
type: "function_call_output",
|
||||
call_id: part.tool_use_id,
|
||||
output,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Flush remaining user content
|
||||
if (contentParts.length > 0) {
|
||||
input.push({ role: "user", content: contentParts })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return input
|
||||
}
|
||||
140
src/api/transform/responses-api-stream.ts
Normal file
140
src/api/transform/responses-api-stream.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import type { ApiStream, ApiStreamUsageChunk } from "./stream"
|
||||
|
||||
/**
|
||||
* Processes Responses API stream events and yields ApiStreamChunks.
|
||||
*
|
||||
* This is a shared utility for providers that use OpenAI's Responses API
|
||||
* (POST /v1/responses with stream: true). It handles the core event types:
|
||||
*
|
||||
* - Text deltas (response.output_text.delta)
|
||||
* - Reasoning deltas (response.reasoning_text.delta, response.reasoning_summary_text.delta)
|
||||
* - Tool/function calls (response.output_item.done with function_call type)
|
||||
* - Usage data (response.completed)
|
||||
*
|
||||
* Provider-specific concerns (WebSocket mode, SSE fallback, duplicate detection,
|
||||
* pending tool tracking) are intentionally left to individual providers.
|
||||
*
|
||||
* @param stream - AsyncIterable of Responses API stream events
|
||||
* @param normalizeUsage - Provider-specific function to normalize usage data into ApiStreamUsageChunk
|
||||
*/
|
||||
export async function* processResponsesApiStream(
|
||||
stream: AsyncIterable<any>,
|
||||
normalizeUsage: (usage: any) => ApiStreamUsageChunk | undefined,
|
||||
): ApiStream {
|
||||
for await (const event of stream) {
|
||||
// Text content deltas
|
||||
if (event?.type === "response.output_text.delta" || event?.type === "response.text.delta") {
|
||||
if (event?.delta) {
|
||||
yield { type: "text", text: event.delta }
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Reasoning deltas
|
||||
if (
|
||||
event?.type === "response.reasoning_text.delta" ||
|
||||
event?.type === "response.reasoning.delta" ||
|
||||
event?.type === "response.reasoning_summary_text.delta" ||
|
||||
event?.type === "response.reasoning_summary.delta"
|
||||
) {
|
||||
if (event?.delta) {
|
||||
yield { type: "reasoning", text: event.delta }
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Output item events — handle completed function calls and fallback text
|
||||
if (event?.type === "response.output_item.done") {
|
||||
const item = event?.item
|
||||
if (item?.type === "function_call" || item?.type === "tool_call") {
|
||||
const callId = item.call_id || item.tool_call_id || item.id
|
||||
const name = item.name || item.function?.name
|
||||
const argsRaw = item.arguments || item.function?.arguments || item.input
|
||||
const args =
|
||||
typeof argsRaw === "string"
|
||||
? argsRaw
|
||||
: argsRaw && typeof argsRaw === "object"
|
||||
? JSON.stringify(argsRaw)
|
||||
: ""
|
||||
|
||||
if (typeof callId === "string" && callId.length > 0 && typeof name === "string" && name.length > 0) {
|
||||
yield {
|
||||
type: "tool_call",
|
||||
id: callId,
|
||||
name,
|
||||
arguments: args,
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Function call argument deltas (for streaming tool calls)
|
||||
if (
|
||||
event?.type === "response.function_call_arguments.delta" ||
|
||||
event?.type === "response.tool_call_arguments.delta"
|
||||
) {
|
||||
const callId = event.call_id || event.tool_call_id || event.id || event.item_id
|
||||
const name = event.name || event.function_name
|
||||
if (typeof callId === "string" && callId.length > 0) {
|
||||
yield {
|
||||
type: "tool_call_partial",
|
||||
index: event.index ?? 0,
|
||||
id: callId,
|
||||
name,
|
||||
arguments: typeof event.delta === "string" ? event.delta : "",
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Completion events — extract usage
|
||||
if (event?.type === "response.completed" || event?.type === "response.done") {
|
||||
const usage = event?.response?.usage || event?.usage
|
||||
const usageData = normalizeUsage(usage)
|
||||
if (usageData) {
|
||||
yield usageData
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a standard usage normalizer for providers with per-token pricing.
|
||||
* Extracts input/output tokens, cache tokens, reasoning tokens, and computes cost.
|
||||
*
|
||||
* @param calculateCost - Optional function to compute total cost from token counts
|
||||
*/
|
||||
export function createUsageNormalizer(
|
||||
calculateCost?: (inputTokens: number, outputTokens: number, cacheReadTokens: number) => number,
|
||||
): (usage: any) => ApiStreamUsageChunk | undefined {
|
||||
return (usage: any): ApiStreamUsageChunk | undefined => {
|
||||
if (!usage) return undefined
|
||||
|
||||
const inputDetails = usage.input_tokens_details ?? usage.prompt_tokens_details
|
||||
const cachedTokens = inputDetails?.cached_tokens ?? 0
|
||||
|
||||
const inputTokens = usage.input_tokens ?? usage.prompt_tokens ?? 0
|
||||
const outputTokens = usage.output_tokens ?? usage.completion_tokens ?? 0
|
||||
const cacheReadTokens = usage.cache_read_input_tokens ?? cachedTokens ?? 0
|
||||
const cacheWriteTokens = usage.cache_creation_input_tokens ?? usage.cache_write_tokens ?? 0
|
||||
|
||||
const reasoningTokens =
|
||||
typeof usage.output_tokens_details?.reasoning_tokens === "number"
|
||||
? usage.output_tokens_details.reasoning_tokens
|
||||
: undefined
|
||||
|
||||
const totalCost = calculateCost ? calculateCost(inputTokens, outputTokens, cacheReadTokens) : undefined
|
||||
|
||||
return {
|
||||
type: "usage",
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheWriteTokens,
|
||||
cacheReadTokens,
|
||||
...(typeof reasoningTokens === "number" ? { reasoningTokens } : {}),
|
||||
...(typeof totalCost === "number" ? { totalCost } : {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue