Roo-Code/src/api/providers/__tests__/openai.spec.ts
Roo Code e6713b07ca fix: do not enforce temperature of 0 for OpenAI compatible provider
When no custom temperature is set and the model is not a DeepSeek
reasoner, the OpenAI compatible provider was hardcoding temperature=0.
This overrode whatever server-side default the provider may have.

Changed the fallback to undefined so the temperature field is omitted
from the API request, letting the remote provider use its own default.

Fixes #12042
2026-04-01 21:07:37 +00:00

1282 lines
36 KiB
TypeScript

// npx vitest run api/providers/__tests__/openai.spec.ts
import { OpenAiHandler, getOpenAiModels } from "../openai"
import { ApiHandlerOptions } from "../../../shared/api"
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { openAiModelInfoSaneDefaults } from "@roo-code/types"
import { Package } from "../../../shared/package"
import axios from "axios"
const mockCreate = vitest.fn()
vitest.mock("openai", () => {
const mockConstructor = vitest.fn()
return {
__esModule: true,
default: mockConstructor.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 {
[Symbol.asyncIterator]: async function* () {
yield {
choices: [
{
delta: { content: "Test response" },
index: 0,
},
],
usage: null,
}
yield {
choices: [
{
delta: {},
index: 0,
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
},
}
},
}
}),
},
},
})),
}
})
// Mock axios for getOpenAiModels tests
vitest.mock("axios", () => ({
default: {
get: vitest.fn(),
},
}))
describe("OpenAiHandler", () => {
let handler: OpenAiHandler
let mockOptions: ApiHandlerOptions
beforeEach(() => {
mockOptions = {
openAiApiKey: "test-api-key",
openAiModelId: "gpt-4",
openAiBaseUrl: "https://api.openai.com/v1",
}
handler = new OpenAiHandler(mockOptions)
mockCreate.mockClear()
})
describe("constructor", () => {
it("should initialize with provided options", () => {
expect(handler).toBeInstanceOf(OpenAiHandler)
expect(handler.getModel().id).toBe(mockOptions.openAiModelId)
})
it("should use custom base URL if provided", () => {
const customBaseUrl = "https://custom.openai.com/v1"
const handlerWithCustomUrl = new OpenAiHandler({
...mockOptions,
openAiBaseUrl: customBaseUrl,
})
expect(handlerWithCustomUrl).toBeInstanceOf(OpenAiHandler)
})
it("should set default headers correctly", () => {
// Check that the OpenAI constructor was called with correct parameters
expect(vi.mocked(OpenAI)).toHaveBeenCalledWith({
baseURL: expect.any(String),
apiKey: expect.any(String),
defaultHeaders: {
"HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline",
"X-Title": "Roo Code",
"User-Agent": `RooCode/${Package.version}`,
},
timeout: expect.any(Number),
})
})
})
describe("createMessage", () => {
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "text" as const,
text: "Hello!",
},
],
},
]
it("should handle non-streaming mode", async () => {
const handler = new OpenAiHandler({
...mockOptions,
openAiStreamingEnabled: false,
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(chunks.length).toBeGreaterThan(0)
const textChunk = chunks.find((chunk) => chunk.type === "text")
const usageChunk = chunks.find((chunk) => chunk.type === "usage")
expect(textChunk).toBeDefined()
expect(textChunk?.text).toBe("Test response")
expect(usageChunk).toBeDefined()
expect(usageChunk?.inputTokens).toBe(10)
expect(usageChunk?.outputTokens).toBe(5)
})
it("should handle tool calls in non-streaming mode", async () => {
mockCreate.mockResolvedValueOnce({
choices: [
{
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "call_1",
type: "function",
function: {
name: "test_tool",
arguments: '{"arg":"value"}',
},
},
],
},
finish_reason: "tool_calls",
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
},
})
const handler = new OpenAiHandler({
...mockOptions,
openAiStreamingEnabled: false,
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call")
expect(toolCallChunks).toHaveLength(1)
expect(toolCallChunks[0]).toEqual({
type: "tool_call",
id: "call_1",
name: "test_tool",
arguments: '{"arg":"value"}',
})
})
it("should handle streaming responses", async () => {
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(chunks.length).toBeGreaterThan(0)
const textChunks = chunks.filter((chunk) => chunk.type === "text")
expect(textChunks).toHaveLength(1)
expect(textChunks[0].text).toBe("Test response")
})
it("should handle tool calls in streaming responses", async () => {
mockCreate.mockImplementation(async (options) => {
return {
[Symbol.asyncIterator]: async function* () {
yield {
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: "call_1",
function: { name: "test_tool", arguments: "" },
},
],
},
finish_reason: null,
},
],
}
yield {
choices: [
{
delta: {
tool_calls: [{ index: 0, function: { arguments: '{"arg":' } }],
},
finish_reason: null,
},
],
}
yield {
choices: [
{
delta: {
tool_calls: [{ index: 0, function: { arguments: '"value"}' } }],
},
finish_reason: "tool_calls",
},
],
}
},
}
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Provider now yields tool_call_partial chunks, NativeToolCallParser handles reassembly
const toolCallPartialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial")
expect(toolCallPartialChunks).toHaveLength(3)
// First chunk has id and name
expect(toolCallPartialChunks[0]).toEqual({
type: "tool_call_partial",
index: 0,
id: "call_1",
name: "test_tool",
arguments: "",
})
// Subsequent chunks have arguments
expect(toolCallPartialChunks[1]).toEqual({
type: "tool_call_partial",
index: 0,
id: undefined,
name: undefined,
arguments: '{"arg":',
})
expect(toolCallPartialChunks[2]).toEqual({
type: "tool_call_partial",
index: 0,
id: undefined,
name: undefined,
arguments: '"value"}',
})
// Verify tool_call_end event is emitted when finish_reason is "tool_calls"
const toolCallEndChunks = chunks.filter((chunk) => chunk.type === "tool_call_end")
expect(toolCallEndChunks).toHaveLength(1)
})
it("should yield tool calls even when finish_reason is not set (fallback behavior)", async () => {
mockCreate.mockImplementation(async (options) => {
return {
[Symbol.asyncIterator]: async function* () {
yield {
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: "call_fallback",
function: { name: "fallback_tool", arguments: '{"test":"fallback"}' },
},
],
},
finish_reason: null,
},
],
}
// Stream ends without finish_reason being set to "tool_calls"
yield {
choices: [
{
delta: {},
finish_reason: "stop", // Different finish reason
},
],
}
},
}
})
const stream = handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Provider now yields tool_call_partial chunks, NativeToolCallParser handles reassembly
const toolCallPartialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial")
expect(toolCallPartialChunks).toHaveLength(1)
expect(toolCallPartialChunks[0]).toEqual({
type: "tool_call_partial",
index: 0,
id: "call_fallback",
name: "fallback_tool",
arguments: '{"test":"fallback"}',
})
})
it("should include reasoning_effort when reasoning effort is enabled", async () => {
const reasoningOptions: ApiHandlerOptions = {
...mockOptions,
enableReasoningEffort: true,
openAiCustomModelInfo: {
contextWindow: 128_000,
supportsPromptCache: false,
supportsReasoningEffort: true,
reasoningEffort: "high",
},
}
const reasoningHandler = new OpenAiHandler(reasoningOptions)
const stream = reasoningHandler.createMessage(systemPrompt, messages)
// Consume the stream to trigger the API call
for await (const _chunk of stream) {
}
// Assert the mockCreate was called with reasoning_effort
expect(mockCreate).toHaveBeenCalled()
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs.reasoning_effort).toBe("high")
})
it("should not include reasoning_effort when reasoning effort is disabled", async () => {
const noReasoningOptions: ApiHandlerOptions = {
...mockOptions,
enableReasoningEffort: false,
openAiCustomModelInfo: { contextWindow: 128_000, supportsPromptCache: false },
}
const noReasoningHandler = new OpenAiHandler(noReasoningOptions)
const stream = noReasoningHandler.createMessage(systemPrompt, messages)
// Consume the stream to trigger the API call
for await (const _chunk of stream) {
}
// Assert the mockCreate was called without reasoning_effort
expect(mockCreate).toHaveBeenCalled()
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs.reasoning_effort).toBeUndefined()
})
it("should include max_tokens when includeMaxTokens is true", async () => {
const optionsWithMaxTokens: ApiHandlerOptions = {
...mockOptions,
includeMaxTokens: true,
openAiCustomModelInfo: {
contextWindow: 128_000,
maxTokens: 4096,
supportsPromptCache: false,
},
}
const handlerWithMaxTokens = new OpenAiHandler(optionsWithMaxTokens)
const stream = handlerWithMaxTokens.createMessage(systemPrompt, messages)
// Consume the stream to trigger the API call
for await (const _chunk of stream) {
}
// Assert the mockCreate was called with max_tokens
expect(mockCreate).toHaveBeenCalled()
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs.max_completion_tokens).toBe(4096)
})
it("should not include max_tokens when includeMaxTokens is false", async () => {
const optionsWithoutMaxTokens: ApiHandlerOptions = {
...mockOptions,
includeMaxTokens: false,
openAiCustomModelInfo: {
contextWindow: 128_000,
maxTokens: 4096,
supportsPromptCache: false,
},
}
const handlerWithoutMaxTokens = new OpenAiHandler(optionsWithoutMaxTokens)
const stream = handlerWithoutMaxTokens.createMessage(systemPrompt, messages)
// Consume the stream to trigger the API call
for await (const _chunk of stream) {
}
// Assert the mockCreate was called without max_tokens
expect(mockCreate).toHaveBeenCalled()
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs.max_completion_tokens).toBeUndefined()
})
it("should not include max_tokens when includeMaxTokens is undefined", async () => {
const optionsWithUndefinedMaxTokens: ApiHandlerOptions = {
...mockOptions,
// includeMaxTokens is not set, should not include max_tokens
openAiCustomModelInfo: {
contextWindow: 128_000,
maxTokens: 4096,
supportsPromptCache: false,
},
}
const handlerWithDefaultMaxTokens = new OpenAiHandler(optionsWithUndefinedMaxTokens)
const stream = handlerWithDefaultMaxTokens.createMessage(systemPrompt, messages)
// Consume the stream to trigger the API call
for await (const _chunk of stream) {
}
// Assert the mockCreate was called without max_tokens
expect(mockCreate).toHaveBeenCalled()
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs.max_completion_tokens).toBeUndefined()
})
it("should use user-configured modelMaxTokens instead of model default maxTokens", async () => {
const optionsWithUserMaxTokens: ApiHandlerOptions = {
...mockOptions,
includeMaxTokens: true,
modelMaxTokens: 32000, // User-configured value
openAiCustomModelInfo: {
contextWindow: 128_000,
maxTokens: 4096, // Model's default value (should not be used)
supportsPromptCache: false,
},
}
const handlerWithUserMaxTokens = new OpenAiHandler(optionsWithUserMaxTokens)
const stream = handlerWithUserMaxTokens.createMessage(systemPrompt, messages)
// Consume the stream to trigger the API call
for await (const _chunk of stream) {
}
// Assert the mockCreate was called with user-configured modelMaxTokens (32000), not model default maxTokens (4096)
expect(mockCreate).toHaveBeenCalled()
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs.max_completion_tokens).toBe(32000)
})
it("should fallback to model default maxTokens when user modelMaxTokens is not set", async () => {
const optionsWithoutUserMaxTokens: ApiHandlerOptions = {
...mockOptions,
includeMaxTokens: true,
// modelMaxTokens is not set
openAiCustomModelInfo: {
contextWindow: 128_000,
maxTokens: 4096, // Model's default value (should be used as fallback)
supportsPromptCache: false,
},
}
const handlerWithoutUserMaxTokens = new OpenAiHandler(optionsWithoutUserMaxTokens)
const stream = handlerWithoutUserMaxTokens.createMessage(systemPrompt, messages)
// Consume the stream to trigger the API call
for await (const _chunk of stream) {
}
// Assert the mockCreate was called with model default maxTokens (4096) as fallback
expect(mockCreate).toHaveBeenCalled()
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs.max_completion_tokens).toBe(4096)
})
})
describe("error handling", () => {
const testMessages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "text" as const,
text: "Hello",
},
],
},
]
it("should handle API errors", async () => {
mockCreate.mockRejectedValueOnce(new Error("API Error"))
const stream = handler.createMessage("system prompt", testMessages)
await expect(async () => {
for await (const _chunk of stream) {
// Should not reach here
}
}).rejects.toThrow("API Error")
})
it("should handle rate limiting", async () => {
const rateLimitError = new Error("Rate limit exceeded")
rateLimitError.name = "Error"
;(rateLimitError as any).status = 429
mockCreate.mockRejectedValueOnce(rateLimitError)
const stream = handler.createMessage("system prompt", testMessages)
await expect(async () => {
for await (const _chunk of stream) {
// Should not reach here
}
}).rejects.toThrow("Rate limit exceeded")
})
})
describe("completePrompt", () => {
it("should complete prompt successfully", async () => {
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("Test response")
expect(mockCreate).toHaveBeenCalledWith(
{
model: mockOptions.openAiModelId,
messages: [{ role: "user", content: "Test prompt" }],
},
{},
)
})
it("should handle API errors", async () => {
mockCreate.mockRejectedValueOnce(new Error("API Error"))
await expect(handler.completePrompt("Test prompt")).rejects.toThrow("OpenAI completion error: API Error")
})
it("should handle empty response", async () => {
mockCreate.mockImplementationOnce(() => ({
choices: [{ message: { content: "" } }],
}))
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("")
})
})
describe("getModel", () => {
it("should return model info with sane defaults", () => {
const model = handler.getModel()
expect(model.id).toBe(mockOptions.openAiModelId)
expect(model.info).toBeDefined()
expect(model.info.contextWindow).toBe(128_000)
expect(model.info.supportsImages).toBe(true)
})
it("should handle undefined model ID", () => {
const handlerWithoutModel = new OpenAiHandler({
...mockOptions,
openAiModelId: undefined,
})
const model = handlerWithoutModel.getModel()
expect(model.id).toBe("")
expect(model.info).toBeDefined()
})
})
describe("Azure AI Inference Service", () => {
const azureOptions = {
...mockOptions,
openAiBaseUrl: "https://test.services.ai.azure.com",
openAiModelId: "deepseek-v3",
azureApiVersion: "2024-05-01-preview",
}
it("should initialize with Azure AI Inference Service configuration", () => {
const azureHandler = new OpenAiHandler(azureOptions)
expect(azureHandler).toBeInstanceOf(OpenAiHandler)
expect(azureHandler.getModel().id).toBe(azureOptions.openAiModelId)
})
it("should handle streaming responses with Azure AI Inference Service", async () => {
const azureHandler = new OpenAiHandler(azureOptions)
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: "Hello!",
},
]
const stream = azureHandler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(chunks.length).toBeGreaterThan(0)
const textChunks = chunks.filter((chunk) => chunk.type === "text")
expect(textChunks).toHaveLength(1)
expect(textChunks[0].text).toBe("Test response")
// Verify the API call was made with correct Azure AI Inference Service path
expect(mockCreate).toHaveBeenCalledWith(
{
model: azureOptions.openAiModelId,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: "Hello!" },
],
stream: true,
stream_options: { include_usage: true },
temperature: undefined,
tools: undefined,
tool_choice: undefined,
parallel_tool_calls: true,
},
{ path: "/models/chat/completions" },
)
// Verify max_tokens is NOT included when not explicitly set
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs).not.toHaveProperty("max_completion_tokens")
})
it("should handle non-streaming responses with Azure AI Inference Service", async () => {
const azureHandler = new OpenAiHandler({
...azureOptions,
openAiStreamingEnabled: false,
})
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: "Hello!",
},
]
const stream = azureHandler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(chunks.length).toBeGreaterThan(0)
const textChunk = chunks.find((chunk) => chunk.type === "text")
const usageChunk = chunks.find((chunk) => chunk.type === "usage")
expect(textChunk).toBeDefined()
expect(textChunk?.text).toBe("Test response")
expect(usageChunk).toBeDefined()
expect(usageChunk?.inputTokens).toBe(10)
expect(usageChunk?.outputTokens).toBe(5)
// Verify the API call was made with correct Azure AI Inference Service path
expect(mockCreate).toHaveBeenCalledWith(
{
model: azureOptions.openAiModelId,
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: "Hello!" },
],
tools: undefined,
tool_choice: undefined,
parallel_tool_calls: true,
},
{ path: "/models/chat/completions" },
)
// Verify max_tokens is NOT included when not explicitly set
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs).not.toHaveProperty("max_completion_tokens")
})
it("should handle completePrompt with Azure AI Inference Service", async () => {
const azureHandler = new OpenAiHandler(azureOptions)
const result = await azureHandler.completePrompt("Test prompt")
expect(result).toBe("Test response")
expect(mockCreate).toHaveBeenCalledWith(
{
model: azureOptions.openAiModelId,
messages: [{ role: "user", content: "Test prompt" }],
},
{ path: "/models/chat/completions" },
)
// Verify max_tokens is NOT included when includeMaxTokens is not set
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs).not.toHaveProperty("max_completion_tokens")
})
})
describe("Grok xAI Provider", () => {
const grokOptions = {
...mockOptions,
openAiBaseUrl: "https://api.x.ai/v1",
openAiModelId: "grok-1",
}
it("should initialize with Grok xAI configuration", () => {
const grokHandler = new OpenAiHandler(grokOptions)
expect(grokHandler).toBeInstanceOf(OpenAiHandler)
expect(grokHandler.getModel().id).toBe(grokOptions.openAiModelId)
})
it("should exclude stream_options when streaming with Grok xAI", async () => {
const grokHandler = new OpenAiHandler(grokOptions)
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: "Hello!",
},
]
const stream = grokHandler.createMessage(systemPrompt, messages)
await stream.next()
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
model: grokOptions.openAiModelId,
stream: true,
}),
{},
)
const mockCalls = mockCreate.mock.calls
const lastCall = mockCalls[mockCalls.length - 1]
expect(lastCall[0]).not.toHaveProperty("stream_options")
})
})
describe("O3 Family Models", () => {
const o3Options = {
...mockOptions,
openAiModelId: "o3-mini",
openAiCustomModelInfo: {
contextWindow: 128_000,
maxTokens: 65536,
supportsPromptCache: false,
reasoningEffort: "medium" as "low" | "medium" | "high",
},
}
it("should handle O3 model with streaming and include max_completion_tokens when includeMaxTokens is true", async () => {
const o3Handler = new OpenAiHandler({
...o3Options,
includeMaxTokens: true,
modelMaxTokens: 32000,
modelTemperature: 0.5,
})
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: "Hello!",
},
]
const stream = o3Handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
model: "o3-mini",
messages: [
{
role: "developer",
content: "Formatting re-enabled\nYou are a helpful assistant.",
},
{ role: "user", content: "Hello!" },
],
stream: true,
stream_options: { include_usage: true },
reasoning_effort: "medium",
temperature: undefined,
// O3 models do not support deprecated max_tokens but do support max_completion_tokens
max_completion_tokens: 32000,
}),
{},
)
})
it("should handle tool calls with O3 model in streaming mode", async () => {
const o3Handler = new OpenAiHandler(o3Options)
mockCreate.mockImplementation(async (options) => {
return {
[Symbol.asyncIterator]: async function* () {
yield {
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: "call_1",
function: { name: "test_tool", arguments: "" },
},
],
},
finish_reason: null,
},
],
}
yield {
choices: [
{
delta: {
tool_calls: [{ index: 0, function: { arguments: "{}" } }],
},
finish_reason: "tool_calls",
},
],
}
},
}
})
const stream = o3Handler.createMessage("system", [])
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Provider now yields tool_call_partial chunks, NativeToolCallParser handles reassembly
const toolCallPartialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial")
expect(toolCallPartialChunks).toHaveLength(2)
expect(toolCallPartialChunks[0]).toEqual({
type: "tool_call_partial",
index: 0,
id: "call_1",
name: "test_tool",
arguments: "",
})
expect(toolCallPartialChunks[1]).toEqual({
type: "tool_call_partial",
index: 0,
id: undefined,
name: undefined,
arguments: "{}",
})
// Verify tool_call_end event is emitted when finish_reason is "tool_calls"
const toolCallEndChunks = chunks.filter((chunk) => chunk.type === "tool_call_end")
expect(toolCallEndChunks).toHaveLength(1)
})
it("should yield tool calls for O3 model even when finish_reason is not set (fallback behavior)", async () => {
const o3Handler = new OpenAiHandler(o3Options)
mockCreate.mockImplementation(async (options) => {
return {
[Symbol.asyncIterator]: async function* () {
yield {
choices: [
{
delta: {
tool_calls: [
{
index: 0,
id: "call_o3_fallback",
function: { name: "o3_fallback_tool", arguments: '{"o3":"test"}' },
},
],
},
finish_reason: null,
},
],
}
// Stream ends with different finish reason
yield {
choices: [
{
delta: {},
finish_reason: "length", // Different finish reason
},
],
}
},
}
})
const stream = o3Handler.createMessage("system", [])
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Provider now yields tool_call_partial chunks, NativeToolCallParser handles reassembly
const toolCallPartialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial")
expect(toolCallPartialChunks).toHaveLength(1)
expect(toolCallPartialChunks[0]).toEqual({
type: "tool_call_partial",
index: 0,
id: "call_o3_fallback",
name: "o3_fallback_tool",
arguments: '{"o3":"test"}',
})
})
it("should handle O3 model with streaming and exclude max_tokens when includeMaxTokens is false", async () => {
const o3Handler = new OpenAiHandler({
...o3Options,
includeMaxTokens: false,
modelTemperature: 0.7,
})
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: "Hello!",
},
]
const stream = o3Handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
model: "o3-mini",
messages: [
{
role: "developer",
content: "Formatting re-enabled\nYou are a helpful assistant.",
},
{ role: "user", content: "Hello!" },
],
stream: true,
stream_options: { include_usage: true },
reasoning_effort: "medium",
temperature: undefined,
}),
{},
)
// Verify max_tokens is NOT included
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs).not.toHaveProperty("max_completion_tokens")
})
it("should handle O3 model non-streaming with reasoning_effort and max_completion_tokens when includeMaxTokens is true", async () => {
const o3Handler = new OpenAiHandler({
...o3Options,
openAiStreamingEnabled: false,
includeMaxTokens: true,
modelTemperature: 0.3,
})
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: "Hello!",
},
]
const stream = o3Handler.createMessage(systemPrompt, messages)
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
model: "o3-mini",
messages: [
{
role: "developer",
content: "Formatting re-enabled\nYou are a helpful assistant.",
},
{ role: "user", content: "Hello!" },
],
reasoning_effort: "medium",
temperature: undefined,
// O3 models do not support deprecated max_tokens but do support max_completion_tokens
max_completion_tokens: 65536, // Using default maxTokens from o3Options
}),
{},
)
// Verify stream is not set
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs).not.toHaveProperty("stream")
})
it("should handle tool calls with O3 model in non-streaming mode", async () => {
const o3Handler = new OpenAiHandler({
...o3Options,
openAiStreamingEnabled: false,
})
mockCreate.mockResolvedValueOnce({
choices: [
{
message: {
role: "assistant",
content: null,
tool_calls: [
{
id: "call_1",
type: "function",
function: {
name: "test_tool",
arguments: "{}",
},
},
],
},
finish_reason: "tool_calls",
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
},
})
const stream = o3Handler.createMessage("system", [])
const chunks: any[] = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call")
expect(toolCallChunks).toHaveLength(1)
expect(toolCallChunks[0]).toEqual({
type: "tool_call",
id: "call_1",
name: "test_tool",
arguments: "{}",
})
})
it("should use default temperature of 0 when not specified for O3 models", async () => {
const o3Handler = new OpenAiHandler({
...o3Options,
// No modelTemperature specified
})
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: "Hello!",
},
]
const stream = o3Handler.createMessage(systemPrompt, messages)
await stream.next()
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
temperature: undefined, // Temperature is not supported for O3 models
}),
{},
)
})
it("should handle O3 model with Azure AI Inference Service respecting includeMaxTokens", async () => {
const o3AzureHandler = new OpenAiHandler({
...o3Options,
openAiBaseUrl: "https://test.services.ai.azure.com",
includeMaxTokens: false, // Should NOT include max_tokens
})
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: "Hello!",
},
]
const stream = o3AzureHandler.createMessage(systemPrompt, messages)
await stream.next()
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
model: "o3-mini",
}),
{ path: "/models/chat/completions" },
)
// Verify max_tokens is NOT included when includeMaxTokens is false
const callArgs = mockCreate.mock.calls[0][0]
expect(callArgs).not.toHaveProperty("max_completion_tokens")
})
it("should NOT include max_tokens for O3 model with Azure AI Inference Service even when includeMaxTokens is true", async () => {
const o3AzureHandler = new OpenAiHandler({
...o3Options,
openAiBaseUrl: "https://test.services.ai.azure.com",
includeMaxTokens: true, // Should include max_tokens
})
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: "Hello!",
},
]
const stream = o3AzureHandler.createMessage(systemPrompt, messages)
await stream.next()
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
model: "o3-mini",
// O3 models do not support max_tokens
}),
{ path: "/models/chat/completions" },
)
})
})
})
describe("getOpenAiModels", () => {
beforeEach(() => {
vi.mocked(axios.get).mockClear()
})
it("should return empty array when baseUrl is not provided", async () => {
const result = await getOpenAiModels(undefined, "test-key")
expect(result).toEqual([])
expect(axios.get).not.toHaveBeenCalled()
})
it("should return empty array when baseUrl is empty string", async () => {
const result = await getOpenAiModels("", "test-key")
expect(result).toEqual([])
expect(axios.get).not.toHaveBeenCalled()
})
it("should trim whitespace from baseUrl", async () => {
const mockResponse = {
data: {
data: [{ id: "gpt-4" }, { id: "gpt-3.5-turbo" }],
},
}
vi.mocked(axios.get).mockResolvedValueOnce(mockResponse)
const result = await getOpenAiModels(" https://api.openai.com/v1 ", "test-key")
expect(axios.get).toHaveBeenCalledWith("https://api.openai.com/v1/models", expect.any(Object))
expect(result).toEqual(["gpt-4", "gpt-3.5-turbo"])
})
it("should handle baseUrl with trailing spaces", async () => {
const mockResponse = {
data: {
data: [{ id: "model-1" }, { id: "model-2" }],
},
}
vi.mocked(axios.get).mockResolvedValueOnce(mockResponse)
const result = await getOpenAiModels("https://api.example.com/v1 ", "test-key")
expect(axios.get).toHaveBeenCalledWith("https://api.example.com/v1/models", expect.any(Object))
expect(result).toEqual(["model-1", "model-2"])
})
it("should handle baseUrl with leading spaces", async () => {
const mockResponse = {
data: {
data: [{ id: "model-1" }],
},
}
vi.mocked(axios.get).mockResolvedValueOnce(mockResponse)
const result = await getOpenAiModels(" https://api.example.com/v1", "test-key")
expect(axios.get).toHaveBeenCalledWith("https://api.example.com/v1/models", expect.any(Object))
expect(result).toEqual(["model-1"])
})
it("should return empty array for invalid URL after trimming", async () => {
const result = await getOpenAiModels(" not-a-valid-url ", "test-key")
expect(result).toEqual([])
expect(axios.get).not.toHaveBeenCalled()
})
it("should include authorization header when apiKey is provided", async () => {
const mockResponse = {
data: {
data: [{ id: "model-1" }],
},
}
vi.mocked(axios.get).mockResolvedValueOnce(mockResponse)
await getOpenAiModels("https://api.example.com/v1", "test-api-key")
expect(axios.get).toHaveBeenCalledWith(
"https://api.example.com/v1/models",
expect.objectContaining({
headers: expect.objectContaining({
Authorization: "Bearer test-api-key",
}),
}),
)
})
it("should include custom headers when provided", async () => {
const mockResponse = {
data: {
data: [{ id: "model-1" }],
},
}
vi.mocked(axios.get).mockResolvedValueOnce(mockResponse)
const customHeaders = {
"X-Custom-Header": "custom-value",
}
await getOpenAiModels("https://api.example.com/v1", "test-key", customHeaders)
expect(axios.get).toHaveBeenCalledWith(
"https://api.example.com/v1/models",
expect.objectContaining({
headers: expect.objectContaining({
"X-Custom-Header": "custom-value",
Authorization: "Bearer test-key",
}),
}),
)
})
it("should handle API errors gracefully", async () => {
vi.mocked(axios.get).mockRejectedValueOnce(new Error("Network error"))
const result = await getOpenAiModels("https://api.example.com/v1", "test-key")
expect(result).toEqual([])
})
it("should handle malformed response data", async () => {
vi.mocked(axios.get).mockResolvedValueOnce({ data: null })
const result = await getOpenAiModels("https://api.example.com/v1", "test-key")
expect(result).toEqual([])
})
it("should deduplicate model IDs", async () => {
const mockResponse = {
data: {
data: [{ id: "gpt-4" }, { id: "gpt-4" }, { id: "gpt-3.5-turbo" }, { id: "gpt-4" }],
},
}
vi.mocked(axios.get).mockResolvedValueOnce(mockResponse)
const result = await getOpenAiModels("https://api.example.com/v1", "test-key")
expect(result).toEqual(["gpt-4", "gpt-3.5-turbo"])
})
})