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:
Enrico Carlesso 2026-04-08 10:38:13 -07:00 committed by GitHub
parent eafed9705c
commit 5432fa2689
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 1231 additions and 555 deletions

View file

@ -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"],
},

View file

@ -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}`)
})
})

View file

@ -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)
}

View 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",
}),
)
})
})
})

View 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,
}),
)
})
})

View 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
}

View 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 } : {}),
}
}
}