feat: migrate OpenAiHandler to AI SDK (#11351)

Co-authored-by: Roo Code <roomote@roocode.com>
This commit is contained in:
Daniel 2026-02-09 18:49:34 -05:00 committed by GitHub
parent 7a38b99232
commit a4914c438c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 796 additions and 1294 deletions

View file

@ -4,15 +4,27 @@ import OpenAI from "openai"
import { OpenAiHandler } from "../openai"
vi.mock("@ai-sdk/openai-compatible", () => ({
createOpenAICompatible: vi.fn(() => vi.fn((modelId: string) => ({ modelId, provider: "openai-compatible" }))),
}))
vi.mock("@ai-sdk/azure", () => ({
createAzure: vi.fn(() => ({
chat: vi.fn((modelId: string) => ({ modelId, provider: "azure.chat" })),
})),
}))
describe("OpenAiHandler native tools", () => {
it("includes tools in request when tools are provided via metadata (regression test)", async () => {
const mockCreate = vi.fn().mockImplementationOnce(() => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [{ delta: { content: "Test response" } }],
}
},
}))
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
}
mockStreamText.mockReturnValueOnce({
fullStream: mockFullStream(),
usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }),
providerMetadata: Promise.resolve(undefined),
})
// Set openAiCustomModelInfo without any tool capability flags; tools should
// still be passed whenever metadata.tools is present.
@ -26,16 +38,6 @@ describe("OpenAiHandler native tools", () => {
},
} as unknown as import("../../../shared/api").ApiHandlerOptions)
// Patch the OpenAI client call
const mockClient = {
chat: {
completions: {
create: mockCreate,
},
},
} as unknown as OpenAI
;(handler as unknown as { client: OpenAI }).client = mockClient
const tools: OpenAI.Chat.ChatCompletionTool[] = [
{
type: "function",
@ -53,17 +55,12 @@ describe("OpenAiHandler native tools", () => {
})
await stream.next()
expect(mockCreate).toHaveBeenCalledWith(
expect(mockStreamText).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.arrayContaining([
expect.objectContaining({
type: "function",
function: expect.objectContaining({ name: "test_tool" }),
}),
]),
parallel_tool_calls: true,
tools: expect.objectContaining({
test_tool: expect.anything(),
}),
}),
expect.anything(),
)
})
})
@ -92,6 +89,10 @@ vi.mock("@ai-sdk/openai", () => ({
modelId: "gpt-4o",
provider: "openai.responses",
}))
;(provider as any).chat = vi.fn((modelId: string) => ({
modelId,
provider: "openai.chat",
}))
return provider
}),
}))

View file

@ -3,51 +3,34 @@
import { OpenAiHandler } from "../openai"
import { ApiHandlerOptions } from "../../../shared/api"
// Mock the timeout config utility
vitest.mock("../utils/timeout-config", () => ({
getApiRequestTimeout: vitest.fn(),
const mockCreateOpenAI = vi.hoisted(() => vi.fn())
const mockCreateOpenAICompatible = vi.hoisted(() => vi.fn())
const mockCreateAzure = vi.hoisted(() => vi.fn())
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: mockCreateOpenAI.mockImplementation(() => ({
chat: vi.fn(() => ({ modelId: "test", provider: "openai.chat" })),
})),
}))
import { getApiRequestTimeout } from "../utils/timeout-config"
vi.mock("@ai-sdk/openai-compatible", () => ({
createOpenAICompatible: mockCreateOpenAICompatible.mockImplementation(() =>
vi.fn((modelId: string) => ({ modelId, provider: "openai-compatible" })),
),
}))
// Mock OpenAI and AzureOpenAI
const mockOpenAIConstructor = vitest.fn()
const mockAzureOpenAIConstructor = vitest.fn()
vi.mock("@ai-sdk/azure", () => ({
createAzure: mockCreateAzure.mockImplementation(() => ({
chat: vi.fn((modelId: string) => ({ modelId, provider: "azure.chat" })),
})),
}))
vitest.mock("openai", () => {
return {
__esModule: true,
default: vitest.fn().mockImplementation((config) => {
mockOpenAIConstructor(config)
return {
chat: {
completions: {
create: vitest.fn(),
},
},
}
}),
AzureOpenAI: vitest.fn().mockImplementation((config) => {
mockAzureOpenAIConstructor(config)
return {
chat: {
completions: {
create: vitest.fn(),
},
},
}
}),
}
})
describe("OpenAiHandler timeout configuration", () => {
describe("OpenAiHandler provider configuration", () => {
beforeEach(() => {
vitest.clearAllMocks()
vi.clearAllMocks()
})
it("should use default timeout for standard OpenAI", () => {
;(getApiRequestTimeout as any).mockReturnValue(600000)
it("should use createOpenAI for standard OpenAI endpoints", () => {
const options: ApiHandlerOptions = {
apiModelId: "gpt-4",
openAiModelId: "gpt-4",
@ -56,19 +39,15 @@ describe("OpenAiHandler timeout configuration", () => {
new OpenAiHandler(options)
expect(getApiRequestTimeout).toHaveBeenCalled()
expect(mockOpenAIConstructor).toHaveBeenCalledWith(
expect(mockCreateOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "https://api.openai.com/v1",
apiKey: "test-key",
timeout: 600000, // 600 seconds in milliseconds
}),
)
})
it("should use custom timeout for OpenAI-compatible providers", () => {
;(getApiRequestTimeout as any).mockReturnValue(1800000) // 30 minutes
it("should use createOpenAI for custom OpenAI-compatible providers", () => {
const options: ApiHandlerOptions = {
apiModelId: "custom-model",
openAiModelId: "custom-model",
@ -78,17 +57,14 @@ describe("OpenAiHandler timeout configuration", () => {
new OpenAiHandler(options)
expect(mockOpenAIConstructor).toHaveBeenCalledWith(
expect(mockCreateOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
baseURL: "http://localhost:8080/v1",
timeout: 1800000, // 1800 seconds in milliseconds
}),
)
})
it("should use timeout for Azure OpenAI", () => {
;(getApiRequestTimeout as any).mockReturnValue(900000) // 15 minutes
it("should use createAzure for Azure OpenAI", () => {
const options: ApiHandlerOptions = {
apiModelId: "gpt-4",
openAiModelId: "gpt-4",
@ -99,16 +75,16 @@ describe("OpenAiHandler timeout configuration", () => {
new OpenAiHandler(options)
expect(mockAzureOpenAIConstructor).toHaveBeenCalledWith(
expect(mockCreateAzure).toHaveBeenCalledWith(
expect.objectContaining({
timeout: 900000, // 900 seconds in milliseconds
baseURL: "https://myinstance.openai.azure.com/openai",
apiKey: "test-key",
useDeploymentBasedUrls: true,
}),
)
})
it("should use timeout for Azure AI Inference", () => {
;(getApiRequestTimeout as any).mockReturnValue(1200000) // 20 minutes
it("should use createOpenAICompatible for Azure AI Inference", () => {
const options: ApiHandlerOptions = {
apiModelId: "deepseek",
openAiModelId: "deepseek",
@ -118,26 +94,32 @@ describe("OpenAiHandler timeout configuration", () => {
new OpenAiHandler(options)
expect(mockOpenAIConstructor).toHaveBeenCalledWith(
expect(mockCreateOpenAICompatible).toHaveBeenCalledWith(
expect.objectContaining({
timeout: 1200000, // 1200 seconds in milliseconds
baseURL: "https://myinstance.services.ai.azure.com/models",
apiKey: "test-key",
queryParams: expect.objectContaining({
"api-version": expect.any(String),
}),
}),
)
})
it("should handle zero timeout (no timeout)", () => {
;(getApiRequestTimeout as any).mockReturnValue(0)
it("should include custom headers in provider configuration", () => {
const options: ApiHandlerOptions = {
apiModelId: "gpt-4",
openAiModelId: "gpt-4",
openAiApiKey: "test-key",
openAiHeaders: { "X-Custom": "value" },
}
new OpenAiHandler(options)
expect(mockOpenAIConstructor).toHaveBeenCalledWith(
expect(mockCreateOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
timeout: 0, // No timeout
headers: expect.objectContaining({
"X-Custom": "value",
}),
}),
)
})

View file

@ -5,89 +5,38 @@ import { Anthropic } from "@anthropic-ai/sdk"
import { ApiHandlerOptions } from "../../../shared/api"
import { OpenAiHandler } from "../openai"
const mockCreate = vitest.fn()
const { mockStreamText } = vi.hoisted(() => ({
mockStreamText: vi.fn(),
}))
vitest.mock("openai", () => {
vi.mock("ai", async (importOriginal) => {
const actual = await importOriginal<typeof import("ai")>()
return {
__esModule: true,
default: vitest.fn().mockImplementation(() => ({
chat: {
completions: {
create: mockCreate.mockImplementation(async (options) => {
if (!options.stream) {
return {
id: "test-completion",
choices: [
{
message: { role: "assistant", content: "Test response", refusal: null },
finish_reason: "stop",
index: 0,
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
},
}
}
// Return a stream with multiple chunks that include usage metrics
return {
[Symbol.asyncIterator]: async function* () {
// First chunk with partial usage
yield {
choices: [
{
delta: { content: "Test " },
index: 0,
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 2,
total_tokens: 12,
},
}
// Second chunk with updated usage
yield {
choices: [
{
delta: { content: "response" },
index: 0,
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 4,
total_tokens: 14,
},
}
// Final chunk with complete usage
yield {
choices: [
{
delta: {},
index: 0,
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
},
}
},
}
}),
},
},
})),
...actual,
streamText: mockStreamText,
generateText: vi.fn(),
}
})
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => ({
chat: vi.fn(() => ({
modelId: "gpt-4",
provider: "openai.chat",
})),
})),
}))
vi.mock("@ai-sdk/openai-compatible", () => ({
createOpenAICompatible: vi.fn(() => vi.fn((modelId: string) => ({ modelId, provider: "openai-compatible" }))),
}))
vi.mock("@ai-sdk/azure", () => ({
createAzure: vi.fn(() => ({
chat: vi.fn((modelId: string) => ({ modelId, provider: "azure.chat" })),
})),
}))
describe("OpenAiHandler with usage tracking fix", () => {
let handler: OpenAiHandler
let mockOptions: ApiHandlerOptions
@ -99,7 +48,7 @@ describe("OpenAiHandler with usage tracking fix", () => {
openAiBaseUrl: "https://api.openai.com/v1",
}
handler = new OpenAiHandler(mockOptions)
mockCreate.mockClear()
vi.clearAllMocks()
})
describe("usage metrics with streaming", () => {
@ -117,19 +66,31 @@ describe("OpenAiHandler with usage tracking fix", () => {
]
it("should only yield usage metrics once at the end of the stream", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test " }
yield { type: "text-delta", text: "response" }
}
mockStreamText.mockReturnValueOnce({
fullStream: mockFullStream(),
usage: Promise.resolve({
inputTokens: 10,
outputTokens: 5,
}),
providerMetadata: Promise.resolve(undefined),
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Check we have text chunks
const textChunks = chunks.filter((chunk) => chunk.type === "text")
expect(textChunks).toHaveLength(2)
expect(textChunks[0].text).toBe("Test ")
expect(textChunks[1].text).toBe("response")
// Check we only have one usage chunk and it's the last one
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks).toHaveLength(1)
expect(usageChunks[0]).toEqual({
@ -138,49 +99,25 @@ describe("OpenAiHandler with usage tracking fix", () => {
outputTokens: 5,
})
// Check the usage chunk is the last one reported from the API
const lastChunk = chunks[chunks.length - 1]
expect(lastChunk.type).toBe("usage")
expect(lastChunk.inputTokens).toBe(10)
expect(lastChunk.outputTokens).toBe(5)
})
it("should handle case where usage is only in the final chunk", async () => {
// Override the mock for this specific test
mockCreate.mockImplementationOnce(async (options) => {
if (!options.stream) {
return {
id: "test-completion",
choices: [{ message: { role: "assistant", content: "Test response" } }],
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
}
}
it("should handle case where usage is provided after stream completes", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test " }
yield { type: "text-delta", text: "response" }
}
return {
[Symbol.asyncIterator]: async function* () {
// First chunk with no usage
yield {
choices: [{ delta: { content: "Test " }, index: 0 }],
usage: null,
}
// Second chunk with no usage
yield {
choices: [{ delta: { content: "response" }, index: 0 }],
usage: null,
}
// Final chunk with usage data
yield {
choices: [{ delta: {}, index: 0 }],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
},
}
},
}
mockStreamText.mockReturnValueOnce({
fullStream: mockFullStream(),
usage: Promise.resolve({
inputTokens: 10,
outputTokens: 5,
}),
providerMetadata: Promise.resolve(undefined),
})
const stream = handler.createMessage(systemPrompt, messages)
@ -189,7 +126,6 @@ describe("OpenAiHandler with usage tracking fix", () => {
chunks.push(chunk)
}
// Check usage metrics
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks).toHaveLength(1)
expect(usageChunks[0]).toEqual({
@ -200,28 +136,14 @@ describe("OpenAiHandler with usage tracking fix", () => {
})
it("should handle case where no usage is provided", async () => {
// Override the mock for this specific test
mockCreate.mockImplementationOnce(async (options) => {
if (!options.stream) {
return {
id: "test-completion",
choices: [{ message: { role: "assistant", content: "Test response" } }],
usage: null,
}
}
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
}
return {
[Symbol.asyncIterator]: async function* () {
yield {
choices: [{ delta: { content: "Test response" }, index: 0 }],
usage: null,
}
yield {
choices: [{ delta: {}, index: 0 }],
usage: null,
}
},
}
mockStreamText.mockReturnValueOnce({
fullStream: mockFullStream(),
usage: Promise.resolve(undefined),
providerMetadata: Promise.resolve(undefined),
})
const stream = handler.createMessage(systemPrompt, messages)
@ -230,9 +152,81 @@ describe("OpenAiHandler with usage tracking fix", () => {
chunks.push(chunk)
}
// Check we don't have any usage chunks
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks).toHaveLength(0)
})
it("should include reasoningTokens from usage.details", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
}
mockStreamText.mockReturnValueOnce({
fullStream: mockFullStream(),
usage: Promise.resolve({
inputTokens: 10,
outputTokens: 5,
details: {
reasoningTokens: 3,
},
}),
providerMetadata: Promise.resolve(undefined),
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks).toHaveLength(1)
expect(usageChunks[0]).toEqual(
expect.objectContaining({
type: "usage",
inputTokens: 10,
outputTokens: 5,
reasoningTokens: 3,
}),
)
})
it("should extract cache and reasoning tokens from providerMetadata", async () => {
async function* mockFullStream() {
yield { type: "text-delta", text: "Test response" }
}
mockStreamText.mockReturnValueOnce({
fullStream: mockFullStream(),
usage: Promise.resolve({
inputTokens: 100,
outputTokens: 50,
}),
providerMetadata: Promise.resolve({
openai: {
cachedPromptTokens: 80,
reasoningTokens: 20,
},
}),
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
expect(usageChunks).toHaveLength(1)
expect(usageChunks[0]).toEqual(
expect.objectContaining({
type: "usage",
inputTokens: 100,
outputTokens: 50,
cacheReadTokens: 80,
reasoningTokens: 20,
}),
)
})
})
})

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,8 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI, { AzureOpenAI } from "openai"
import { createOpenAI } from "@ai-sdk/openai"
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
import { createAzure } from "@ai-sdk/azure"
import { streamText, generateText, ToolSet, LanguageModel } from "ai"
import axios from "axios"
import {
@ -7,31 +10,35 @@ import {
azureOpenAiDefaultApiVersion,
openAiModelInfoSaneDefaults,
DEEP_SEEK_DEFAULT_TEMPERATURE,
OPENAI_AZURE_AI_INFERENCE_PATH,
} from "@roo-code/types"
import type { ApiHandlerOptions } from "../../shared/api"
import { TagMatcher } from "../../utils/tag-matcher"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { convertToR1Format } from "../transform/r1-format"
import {
convertToAiSdkMessages,
convertToolsForAiSdk,
processAiSdkStreamPart,
mapToolChoice,
handleAiSdkError,
} from "../transform/ai-sdk"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { getModelParams } from "../transform/model-params"
import { DEFAULT_HEADERS } from "./constants"
import { BaseProvider } from "./base-provider"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
import { getApiRequestTimeout } from "./utils/timeout-config"
import { handleOpenAIError } from "./utils/openai-error-handler"
// TODO: Rename this to OpenAICompatibleHandler. Also, I think the
// `OpenAINativeHandler` can subclass from this, since it's obviously
// compatible with the OpenAI API. We can also rename it to `OpenAIHandler`.
export class OpenAiHandler extends BaseProvider implements SingleCompletionHandler {
protected options: ApiHandlerOptions
protected client: OpenAI
private readonly providerName = "OpenAI"
private readonly isAzureAiInference: boolean
private readonly isAzureOpenAi: boolean
private readonly languageModelFactory: (modelId: string) => LanguageModel
constructor(options: ApiHandlerOptions) {
super()
@ -39,243 +46,258 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
const baseURL = this.options.openAiBaseUrl || "https://api.openai.com/v1"
const apiKey = this.options.openAiApiKey ?? "not-provided"
const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl)
const urlHost = this._getUrlHost(this.options.openAiBaseUrl)
const isAzureOpenAi = urlHost === "azure.com" || urlHost.endsWith(".azure.com") || options.openAiUseAzure
this.isAzureAiInference = this._isAzureAiInference(baseURL)
const urlHost = this._getUrlHost(baseURL)
this.isAzureOpenAi =
!this.isAzureAiInference &&
(urlHost === "azure.com" || urlHost.endsWith(".azure.com") || !!options.openAiUseAzure)
const headers = {
...DEFAULT_HEADERS,
...(this.options.openAiHeaders || {}),
}
const timeout = getApiRequestTimeout()
if (isAzureAiInference) {
// Azure AI Inference Service (e.g., for DeepSeek) uses a different path structure
this.client = new OpenAI({
baseURL,
if (this.isAzureAiInference) {
const provider = createOpenAICompatible({
name: "OpenAI",
baseURL: `${baseURL}/models`,
apiKey,
defaultHeaders: headers,
defaultQuery: { "api-version": this.options.azureApiVersion || "2024-05-01-preview" },
timeout,
headers,
queryParams: { "api-version": this.options.azureApiVersion || "2024-05-01-preview" },
})
} else if (isAzureOpenAi) {
// Azure API shape slightly differs from the core API shape:
// https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai
this.client = new AzureOpenAI({
baseURL,
this.languageModelFactory = (modelId: string) => provider(modelId)
} else if (this.isAzureOpenAi) {
const azureBaseURL = baseURL.endsWith("/openai") ? baseURL : `${baseURL}/openai`
const provider = createAzure({
baseURL: azureBaseURL,
apiKey,
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
defaultHeaders: headers,
timeout,
headers,
useDeploymentBasedUrls: true,
})
this.languageModelFactory = (modelId: string) => provider.chat(modelId)
} else {
this.client = new OpenAI({
const provider = createOpenAI({
baseURL,
apiKey,
defaultHeaders: headers,
timeout,
headers,
})
this.languageModelFactory = (modelId: string) => provider.chat(modelId)
}
}
protected getLanguageModel(): LanguageModel {
const { id } = this.getModel()
return this.languageModelFactory(id)
}
override async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
metadata?: ApiHandlerCreateMessageMetadata,
): ApiStream {
const { info: modelInfo, reasoning } = this.getModel()
const modelUrl = this.options.openAiBaseUrl ?? ""
const { info: modelInfo, temperature, reasoning } = this.getModel()
const modelId = this.options.openAiModelId ?? ""
const enabledR1Format = this.options.openAiR1FormatEnabled ?? false
const isAzureAiInference = this._isAzureAiInference(modelUrl)
const deepseekReasoner = modelId.includes("deepseek-reasoner") || enabledR1Format
const isO3Family = modelId.includes("o1") || modelId.includes("o3") || modelId.includes("o4")
if (modelId.includes("o1") || modelId.includes("o3") || modelId.includes("o4")) {
yield* this.handleO3FamilyMessage(modelId, systemPrompt, messages, metadata)
return
const languageModel = this.getLanguageModel()
const aiSdkMessages = convertToAiSdkMessages(messages)
const openAiTools = this.convertToolsForOpenAI(metadata?.tools)
const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined
let effectiveSystemPrompt: string | undefined = systemPrompt
let effectiveTemperature: number | undefined =
this.options.modelTemperature ?? (deepseekReasoner ? DEEP_SEEK_DEFAULT_TEMPERATURE : (temperature ?? 0))
const providerOptions: Record<string, any> = {}
if (isO3Family) {
effectiveSystemPrompt = `Formatting re-enabled\n${systemPrompt}`
effectiveTemperature = undefined
const openaiOpts: Record<string, unknown> = {
systemMessageMode: "developer",
parallelToolCalls: metadata?.parallelToolCalls ?? true,
}
const effort = modelInfo.reasoningEffort as string | undefined
if (effort) {
openaiOpts.reasoningEffort = effort
}
providerOptions.openai = openaiOpts
} else if (reasoning?.reasoning_effort) {
providerOptions.openai = {
reasoningEffort: reasoning.reasoning_effort,
parallelToolCalls: metadata?.parallelToolCalls ?? true,
}
}
let systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
role: "system",
content: systemPrompt,
if (deepseekReasoner) {
effectiveSystemPrompt = undefined
aiSdkMessages.unshift({ role: "user", content: systemPrompt })
}
if (this.options.openAiStreamingEnabled ?? true) {
let convertedMessages
if (deepseekReasoner) {
convertedMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
} else {
if (modelInfo.supportsPromptCache) {
systemMessage = {
role: "system",
content: [
{
type: "text",
text: systemPrompt,
// @ts-ignore-next-line
cache_control: { type: "ephemeral" },
},
],
}
}
convertedMessages = [systemMessage, ...convertToOpenAiMessages(messages)]
if (modelInfo.supportsPromptCache) {
// Note: the following logic is copied from openrouter:
// Add cache_control to the last two user messages
// (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message)
const lastTwoUserMessages = convertedMessages.filter((msg) => msg.role === "user").slice(-2)
lastTwoUserMessages.forEach((msg) => {
if (typeof msg.content === "string") {
msg.content = [{ type: "text", text: msg.content }]
}
if (Array.isArray(msg.content)) {
// NOTE: this is fine since env details will always be added at the end. but if it weren't there, and the user added a image_url type message, it would pop a text part before it and then move it after to the end.
let lastTextPart = msg.content.filter((part) => part.type === "text").pop()
if (!lastTextPart) {
lastTextPart = { type: "text", text: "..." }
msg.content.push(lastTextPart)
}
// @ts-ignore-next-line
lastTextPart["cache_control"] = { type: "ephemeral" }
}
})
}
}
const isGrokXAI = this._isGrokXAI(this.options.openAiBaseUrl)
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
model: modelId,
temperature: this.options.modelTemperature ?? (deepseekReasoner ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0),
messages: convertedMessages,
stream: true as const,
...(isGrokXAI ? {} : { stream_options: { include_usage: true } }),
...(reasoning && reasoning),
tools: this.convertToolsForOpenAI(metadata?.tools),
tool_choice: metadata?.tool_choice,
parallel_tool_calls: metadata?.parallelToolCalls ?? true,
}
// Add max_tokens if needed
this.addMaxTokensIfNeeded(requestOptions, modelInfo)
let stream
try {
stream = await this.client.chat.completions.create(
requestOptions,
isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {},
)
} catch (error) {
throw handleOpenAIError(error, this.providerName)
}
const matcher = new TagMatcher(
"think",
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
text: chunk.data,
}) as const,
yield* this.handleStreaming(
languageModel,
effectiveSystemPrompt,
aiSdkMessages,
effectiveTemperature,
aiSdkTools,
metadata,
providerOptions,
modelInfo,
)
} else {
yield* this.handleNonStreaming(
languageModel,
effectiveSystemPrompt,
aiSdkMessages,
effectiveTemperature,
aiSdkTools,
metadata,
providerOptions,
modelInfo,
)
}
}
let lastUsage
const activeToolCallIds = new Set<string>()
private async *handleStreaming(
languageModel: LanguageModel,
systemPrompt: string | undefined,
messages: ReturnType<typeof convertToAiSdkMessages>,
temperature: number | undefined,
tools: ToolSet | undefined,
metadata: ApiHandlerCreateMessageMetadata | undefined,
providerOptions: Record<string, any>,
modelInfo: ModelInfo,
): ApiStream {
const result = streamText({
model: languageModel,
system: systemPrompt,
messages,
temperature,
maxOutputTokens: this.getMaxOutputTokens(),
tools,
toolChoice: mapToolChoice(metadata?.tool_choice),
providerOptions: Object.keys(providerOptions).length > 0 ? providerOptions : undefined,
})
for await (const chunk of stream) {
const delta = chunk.choices?.[0]?.delta ?? {}
const finishReason = chunk.choices?.[0]?.finish_reason
const matcher = new TagMatcher(
"think",
(chunk) =>
({
type: chunk.matched ? "reasoning" : "text",
text: chunk.data,
}) as const,
)
if (delta.content) {
for (const chunk of matcher.update(delta.content)) {
try {
for await (const part of result.fullStream) {
for (const chunk of processAiSdkStreamPart(part)) {
if (chunk.type === "text") {
for (const matchedChunk of matcher.update(chunk.text)) {
yield matchedChunk
}
} else {
yield chunk
}
}
if ("reasoning_content" in delta && delta.reasoning_content) {
yield {
type: "reasoning",
text: (delta.reasoning_content as string | undefined) || "",
}
}
yield* this.processToolCalls(delta, finishReason, activeToolCallIds)
if (chunk.usage) {
lastUsage = chunk.usage
}
}
for (const chunk of matcher.final()) {
yield chunk
}
if (lastUsage) {
yield this.processUsageMetrics(lastUsage, modelInfo)
}
} else {
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
model: modelId,
messages: deepseekReasoner
? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
: [systemMessage, ...convertToOpenAiMessages(messages)],
// Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS)
tools: this.convertToolsForOpenAI(metadata?.tools),
tool_choice: metadata?.tool_choice,
parallel_tool_calls: metadata?.parallelToolCalls ?? true,
const usage = await result.usage
const providerMetadata = await result.providerMetadata
if (usage) {
yield this.processUsageMetrics(usage, modelInfo, providerMetadata as any)
}
} catch (error) {
throw handleAiSdkError(error, this.providerName)
}
}
// Add max_tokens if needed
this.addMaxTokensIfNeeded(requestOptions, modelInfo)
private async *handleNonStreaming(
languageModel: LanguageModel,
systemPrompt: string | undefined,
messages: ReturnType<typeof convertToAiSdkMessages>,
temperature: number | undefined,
tools: ToolSet | undefined,
metadata: ApiHandlerCreateMessageMetadata | undefined,
providerOptions: Record<string, any>,
modelInfo: ModelInfo,
): ApiStream {
try {
const { text, toolCalls, usage, providerMetadata } = await generateText({
model: languageModel,
system: systemPrompt,
messages,
temperature,
maxOutputTokens: this.getMaxOutputTokens(),
tools,
toolChoice: mapToolChoice(metadata?.tool_choice),
providerOptions: Object.keys(providerOptions).length > 0 ? providerOptions : undefined,
})
let response
try {
response = await this.client.chat.completions.create(
requestOptions,
this._isAzureAiInference(modelUrl) ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {},
)
} catch (error) {
throw handleOpenAIError(error, this.providerName)
}
const message = response.choices?.[0]?.message
if (message?.tool_calls) {
for (const toolCall of message.tool_calls) {
if (toolCall.type === "function") {
yield {
type: "tool_call",
id: toolCall.id,
name: toolCall.function.name,
arguments: toolCall.function.arguments,
}
if (toolCalls && toolCalls.length > 0) {
for (const toolCall of toolCalls) {
yield {
type: "tool_call",
id: toolCall.toolCallId,
name: toolCall.toolName,
arguments: JSON.stringify((toolCall as any).args),
}
}
}
yield {
type: "text",
text: message?.content || "",
text: text || "",
}
yield this.processUsageMetrics(response.usage, modelInfo)
if (usage) {
yield this.processUsageMetrics(usage, modelInfo, providerMetadata as any)
}
} catch (error) {
throw handleAiSdkError(error, this.providerName)
}
}
protected processUsageMetrics(usage: any, _modelInfo?: ModelInfo): ApiStreamUsageChunk {
protected processUsageMetrics(
usage: {
inputTokens?: number
outputTokens?: number
details?: {
cachedInputTokens?: number
reasoningTokens?: number
}
},
_modelInfo?: ModelInfo,
providerMetadata?: {
openai?: {
cachedPromptTokens?: number
reasoningTokens?: number
}
},
): ApiStreamUsageChunk {
// Extract cache and reasoning metrics from OpenAI's providerMetadata when available,
// falling back to usage.details for standard AI SDK fields.
const cacheReadTokens = providerMetadata?.openai?.cachedPromptTokens ?? usage.details?.cachedInputTokens
const reasoningTokens = providerMetadata?.openai?.reasoningTokens ?? usage.details?.reasoningTokens
return {
type: "usage",
inputTokens: usage?.prompt_tokens || 0,
outputTokens: usage?.completion_tokens || 0,
cacheWriteTokens: usage?.cache_creation_input_tokens || undefined,
cacheReadTokens: usage?.cache_read_input_tokens || undefined,
inputTokens: usage.inputTokens || 0,
outputTokens: usage.outputTokens || 0,
cacheReadTokens,
reasoningTokens,
}
}
@ -292,208 +314,37 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
return { id, info, ...params }
}
protected getMaxOutputTokens(): number | undefined {
if (this.options.includeMaxTokens !== true) {
return undefined
}
const { info } = this.getModel()
return this.options.modelMaxTokens || info.maxTokens || undefined
}
async completePrompt(prompt: string): Promise<string> {
try {
const isAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl)
const model = this.getModel()
const modelInfo = model.info
const { temperature } = this.getModel()
const languageModel = this.getLanguageModel()
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
model: model.id,
messages: [{ role: "user", content: prompt }],
}
const { text } = await generateText({
model: languageModel,
prompt,
maxOutputTokens: this.getMaxOutputTokens(),
temperature: this.options.modelTemperature ?? temperature ?? 0,
})
// Add max_tokens if needed
this.addMaxTokensIfNeeded(requestOptions, modelInfo)
let response
try {
response = await this.client.chat.completions.create(
requestOptions,
isAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {},
)
} catch (error) {
throw handleOpenAIError(error, this.providerName)
}
return response.choices?.[0]?.message.content || ""
return text
} catch (error) {
if (error instanceof Error) {
throw new Error(`${this.providerName} completion error: ${error.message}`)
}
throw error
}
}
private async *handleO3FamilyMessage(
modelId: string,
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
metadata?: ApiHandlerCreateMessageMetadata,
): ApiStream {
const modelInfo = this.getModel().info
const methodIsAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl)
if (this.options.openAiStreamingEnabled ?? true) {
const isGrokXAI = this._isGrokXAI(this.options.openAiBaseUrl)
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
model: modelId,
messages: [
{
role: "developer",
content: `Formatting re-enabled\n${systemPrompt}`,
},
...convertToOpenAiMessages(messages),
],
stream: true,
...(isGrokXAI ? {} : { stream_options: { include_usage: true } }),
reasoning_effort: modelInfo.reasoningEffort as "low" | "medium" | "high" | undefined,
temperature: undefined,
// Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS)
tools: this.convertToolsForOpenAI(metadata?.tools),
tool_choice: metadata?.tool_choice,
parallel_tool_calls: metadata?.parallelToolCalls ?? true,
}
// O3 family models do not support the deprecated max_tokens parameter
// but they do support max_completion_tokens (the modern OpenAI parameter)
// This allows O3 models to limit response length when includeMaxTokens is enabled
this.addMaxTokensIfNeeded(requestOptions, modelInfo)
let stream
try {
stream = await this.client.chat.completions.create(
requestOptions,
methodIsAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {},
)
} catch (error) {
throw handleOpenAIError(error, this.providerName)
}
yield* this.handleStreamResponse(stream)
} else {
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
model: modelId,
messages: [
{
role: "developer",
content: `Formatting re-enabled\n${systemPrompt}`,
},
...convertToOpenAiMessages(messages),
],
reasoning_effort: modelInfo.reasoningEffort as "low" | "medium" | "high" | undefined,
temperature: undefined,
// Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS)
tools: this.convertToolsForOpenAI(metadata?.tools),
tool_choice: metadata?.tool_choice,
parallel_tool_calls: metadata?.parallelToolCalls ?? true,
}
// O3 family models do not support the deprecated max_tokens parameter
// but they do support max_completion_tokens (the modern OpenAI parameter)
// This allows O3 models to limit response length when includeMaxTokens is enabled
this.addMaxTokensIfNeeded(requestOptions, modelInfo)
let response
try {
response = await this.client.chat.completions.create(
requestOptions,
methodIsAzureAiInference ? { path: OPENAI_AZURE_AI_INFERENCE_PATH } : {},
)
} catch (error) {
throw handleOpenAIError(error, this.providerName)
}
const message = response.choices?.[0]?.message
if (message?.tool_calls) {
for (const toolCall of message.tool_calls) {
if (toolCall.type === "function") {
yield {
type: "tool_call",
id: toolCall.id,
name: toolCall.function.name,
arguments: toolCall.function.arguments,
}
}
}
}
yield {
type: "text",
text: message?.content || "",
}
yield this.processUsageMetrics(response.usage)
}
}
private async *handleStreamResponse(stream: AsyncIterable<OpenAI.Chat.Completions.ChatCompletionChunk>): ApiStream {
const activeToolCallIds = new Set<string>()
for await (const chunk of stream) {
const delta = chunk.choices?.[0]?.delta
const finishReason = chunk.choices?.[0]?.finish_reason
if (delta) {
if (delta.content) {
yield {
type: "text",
text: delta.content,
}
}
yield* this.processToolCalls(delta, finishReason, activeToolCallIds)
}
if (chunk.usage) {
yield {
type: "usage",
inputTokens: chunk.usage.prompt_tokens || 0,
outputTokens: chunk.usage.completion_tokens || 0,
}
}
}
}
/**
* Helper generator to process tool calls from a stream chunk.
* Tracks active tool call IDs and yields tool_call_partial and tool_call_end events.
* @param delta - The delta object from the stream chunk
* @param finishReason - The finish_reason from the stream chunk
* @param activeToolCallIds - Set to track active tool call IDs (mutated in place)
*/
private *processToolCalls(
delta: OpenAI.Chat.Completions.ChatCompletionChunk.Choice.Delta | undefined,
finishReason: string | null | undefined,
activeToolCallIds: Set<string>,
): Generator<
| { type: "tool_call_partial"; index: number; id?: string; name?: string; arguments?: string }
| { type: "tool_call_end"; id: string }
> {
if (delta?.tool_calls) {
for (const toolCall of delta.tool_calls) {
if (toolCall.id) {
activeToolCallIds.add(toolCall.id)
}
yield {
type: "tool_call_partial",
index: toolCall.index,
id: toolCall.id,
name: toolCall.function?.name,
arguments: toolCall.function?.arguments,
}
}
}
// Emit tool_call_end events when finish_reason is "tool_calls"
// This ensures tool calls are finalized even if the stream doesn't properly close
if (finishReason === "tool_calls" && activeToolCallIds.size > 0) {
for (const id of activeToolCallIds) {
yield { type: "tool_call_end", id }
}
activeToolCallIds.clear()
}
override isAiSdkProvider(): boolean {
return true
}
protected _getUrlHost(baseUrl?: string): string {
@ -504,34 +355,10 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
}
}
private _isGrokXAI(baseUrl?: string): boolean {
const urlHost = this._getUrlHost(baseUrl)
return urlHost.includes("x.ai")
}
protected _isAzureAiInference(baseUrl?: string): boolean {
const urlHost = this._getUrlHost(baseUrl)
return urlHost.endsWith(".services.ai.azure.com")
}
/**
* Adds max_completion_tokens to the request body if needed based on provider configuration
* Note: max_tokens is deprecated in favor of max_completion_tokens as per OpenAI documentation
* O3 family models handle max_tokens separately in handleO3FamilyMessage
*/
protected addMaxTokensIfNeeded(
requestOptions:
| OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming
| OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming,
modelInfo: ModelInfo,
): void {
// Only add max_completion_tokens if includeMaxTokens is true
if (this.options.includeMaxTokens === true) {
// Use user-configured modelMaxTokens if available, otherwise fall back to model's default maxTokens
// Using max_completion_tokens as max_tokens is deprecated
requestOptions.max_completion_tokens = this.options.modelMaxTokens || modelInfo.maxTokens
}
}
}
export async function getOpenAiModels(baseUrl?: string, apiKey?: string, openAiHeaders?: Record<string, string>) {