mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-13 03:58:14 +00:00
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
1282 lines
36 KiB
TypeScript
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"])
|
|
})
|
|
})
|