- Strip store field from request body for local providers (Ollama, vLLM) that reject unknown JSON fields with 400 errors - Add Gemini 3.x model context windows and output token limits (gemini-3-flash-preview, gemini-3.1-pro-preview, google/ OpenRouter variants) - Preserve reasoning_content on assistant tool-call message replays for providers that require it (Kimi k2.5, DeepSeek reasoner) - Use conservative max_output_tokens fallback (4096/16384) for unknown 3P models to prevent vLLM/Ollama 400 errors from exceeding max_model_len Consolidates fixes from: #258, #268, #237, #643, #666, #677 Co-authored-by: auriti <auriti@users.noreply.github.com> Co-authored-by: Gustavo-Falci <Gustavo-Falci@users.noreply.github.com> Co-authored-by: lttlin <lttlin@users.noreply.github.com> Co-authored-by: Durannd <Durannd@users.noreply.github.com>
3141 lines
84 KiB
TypeScript
3141 lines
84 KiB
TypeScript
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
|
import { createOpenAIShimClient } from './openaiShim.ts'
|
|
|
|
type FetchType = typeof globalThis.fetch
|
|
|
|
const originalEnv = {
|
|
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
|
|
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
|
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
|
CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB,
|
|
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
|
|
GH_TOKEN: process.env.GH_TOKEN,
|
|
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
|
|
CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI,
|
|
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
|
|
GOOGLE_API_KEY: process.env.GOOGLE_API_KEY,
|
|
GEMINI_ACCESS_TOKEN: process.env.GEMINI_ACCESS_TOKEN,
|
|
GEMINI_AUTH_MODE: process.env.GEMINI_AUTH_MODE,
|
|
GEMINI_BASE_URL: process.env.GEMINI_BASE_URL,
|
|
GEMINI_MODEL: process.env.GEMINI_MODEL,
|
|
GOOGLE_CLOUD_PROJECT: process.env.GOOGLE_CLOUD_PROJECT,
|
|
ANTHROPIC_CUSTOM_HEADERS: process.env.ANTHROPIC_CUSTOM_HEADERS,
|
|
}
|
|
|
|
const originalFetch = globalThis.fetch
|
|
|
|
function restoreEnv(key: string, value: string | undefined): void {
|
|
if (value === undefined) {
|
|
delete process.env[key]
|
|
} else {
|
|
process.env[key] = value
|
|
}
|
|
}
|
|
|
|
type OpenAIShimClient = {
|
|
beta: {
|
|
messages: {
|
|
create: (
|
|
params: Record<string, unknown>,
|
|
options?: Record<string, unknown>,
|
|
) => Promise<unknown> & {
|
|
withResponse: () => Promise<{ data: AsyncIterable<Record<string, unknown>> }>
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function makeSseResponse(lines: string[]): Response {
|
|
const encoder = new TextEncoder()
|
|
return new Response(
|
|
new ReadableStream({
|
|
start(controller) {
|
|
for (const line of lines) {
|
|
controller.enqueue(encoder.encode(line))
|
|
}
|
|
controller.close()
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'text/event-stream',
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
function makeStreamChunks(chunks: unknown[]): string[] {
|
|
return [
|
|
...chunks.map(chunk => `data: ${JSON.stringify(chunk)}\n\n`),
|
|
'data: [DONE]\n\n',
|
|
]
|
|
}
|
|
|
|
beforeEach(() => {
|
|
process.env.OPENAI_BASE_URL = 'http://example.test/v1'
|
|
process.env.OPENAI_API_KEY = 'test-key'
|
|
delete process.env.OPENAI_MODEL
|
|
delete process.env.CLAUDE_CODE_USE_GITHUB
|
|
delete process.env.GITHUB_TOKEN
|
|
delete process.env.GH_TOKEN
|
|
delete process.env.CLAUDE_CODE_USE_OPENAI
|
|
delete process.env.CLAUDE_CODE_USE_GEMINI
|
|
delete process.env.GEMINI_API_KEY
|
|
delete process.env.GOOGLE_API_KEY
|
|
delete process.env.GEMINI_ACCESS_TOKEN
|
|
delete process.env.GEMINI_AUTH_MODE
|
|
delete process.env.GEMINI_BASE_URL
|
|
delete process.env.GEMINI_MODEL
|
|
delete process.env.GOOGLE_CLOUD_PROJECT
|
|
delete process.env.ANTHROPIC_CUSTOM_HEADERS
|
|
})
|
|
|
|
afterEach(() => {
|
|
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
|
|
restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY)
|
|
restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
|
|
restoreEnv('CLAUDE_CODE_USE_GITHUB', originalEnv.CLAUDE_CODE_USE_GITHUB)
|
|
restoreEnv('GITHUB_TOKEN', originalEnv.GITHUB_TOKEN)
|
|
restoreEnv('GH_TOKEN', originalEnv.GH_TOKEN)
|
|
restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI)
|
|
restoreEnv('CLAUDE_CODE_USE_GEMINI', originalEnv.CLAUDE_CODE_USE_GEMINI)
|
|
restoreEnv('GEMINI_API_KEY', originalEnv.GEMINI_API_KEY)
|
|
restoreEnv('GOOGLE_API_KEY', originalEnv.GOOGLE_API_KEY)
|
|
restoreEnv('GEMINI_ACCESS_TOKEN', originalEnv.GEMINI_ACCESS_TOKEN)
|
|
restoreEnv('GEMINI_AUTH_MODE', originalEnv.GEMINI_AUTH_MODE)
|
|
restoreEnv('GEMINI_BASE_URL', originalEnv.GEMINI_BASE_URL)
|
|
restoreEnv('GEMINI_MODEL', originalEnv.GEMINI_MODEL)
|
|
restoreEnv('GOOGLE_CLOUD_PROJECT', originalEnv.GOOGLE_CLOUD_PROJECT)
|
|
restoreEnv('ANTHROPIC_CUSTOM_HEADERS', originalEnv.ANTHROPIC_CUSTOM_HEADERS)
|
|
globalThis.fetch = originalFetch
|
|
})
|
|
|
|
test('strips canonical Anthropic headers from direct shim defaultHeaders', async () => {
|
|
let capturedHeaders: Headers | undefined
|
|
|
|
globalThis.fetch = (async (_input, init) => {
|
|
capturedHeaders = new Headers(init?.headers)
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-1',
|
|
model: 'gpt-4o',
|
|
choices: [
|
|
{
|
|
message: {
|
|
role: 'assistant',
|
|
content: 'ok',
|
|
},
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 8,
|
|
completion_tokens: 3,
|
|
total_tokens: 11,
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({
|
|
defaultHeaders: {
|
|
'anthropic-version': '2023-06-01',
|
|
'anthropic-beta': 'prompt-caching-2024-07-31',
|
|
'x-anthropic-additional-protection': 'true',
|
|
'x-claude-remote-session-id': 'remote-123',
|
|
'x-app': 'cli',
|
|
'x-client-app': 'sdk',
|
|
'x-safe-header': 'keep-me',
|
|
},
|
|
}) as OpenAIShimClient
|
|
|
|
await client.beta.messages.create({
|
|
model: 'gpt-4o',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
})
|
|
|
|
expect(capturedHeaders?.get('anthropic-version')).toBeNull()
|
|
expect(capturedHeaders?.get('anthropic-beta')).toBeNull()
|
|
expect(capturedHeaders?.get('x-anthropic-additional-protection')).toBeNull()
|
|
expect(capturedHeaders?.get('x-claude-remote-session-id')).toBeNull()
|
|
expect(capturedHeaders?.get('x-app')).toBeNull()
|
|
expect(capturedHeaders?.get('x-client-app')).toBeNull()
|
|
expect(capturedHeaders?.get('x-safe-header')).toBe('keep-me')
|
|
})
|
|
|
|
test('strips canonical Anthropic headers from per-request shim headers too', async () => {
|
|
let capturedHeaders: Headers | undefined
|
|
|
|
globalThis.fetch = (async (_input, init) => {
|
|
capturedHeaders = new Headers(init?.headers)
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-1',
|
|
model: 'gpt-4o',
|
|
choices: [
|
|
{
|
|
message: {
|
|
role: 'assistant',
|
|
content: 'ok',
|
|
},
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 8,
|
|
completion_tokens: 3,
|
|
total_tokens: 11,
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
await client.beta.messages.create(
|
|
{
|
|
model: 'gpt-4o',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
},
|
|
{
|
|
headers: {
|
|
'anthropic-version': '2023-06-01',
|
|
'anthropic-beta': 'prompt-caching-2024-07-31',
|
|
'x-safe-header': 'keep-me',
|
|
},
|
|
},
|
|
)
|
|
|
|
expect(capturedHeaders?.get('anthropic-version')).toBeNull()
|
|
expect(capturedHeaders?.get('anthropic-beta')).toBeNull()
|
|
expect(capturedHeaders?.get('x-safe-header')).toBe('keep-me')
|
|
})
|
|
|
|
test('strips Anthropic-specific headers on GitHub Codex transport requests', async () => {
|
|
let capturedHeaders: Headers | undefined
|
|
|
|
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
|
process.env.OPENAI_API_KEY = 'github-test-key'
|
|
delete process.env.OPENAI_BASE_URL
|
|
delete process.env.OPENAI_MODEL
|
|
|
|
globalThis.fetch = (async (_input, init) => {
|
|
capturedHeaders = new Headers(init?.headers)
|
|
|
|
return new Response('', {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'text/event-stream',
|
|
},
|
|
})
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
await client.beta.messages.create(
|
|
{
|
|
model: 'github:gpt-5-codex',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
max_tokens: 64,
|
|
stream: true,
|
|
},
|
|
{
|
|
headers: {
|
|
'anthropic-version': '2023-06-01',
|
|
'anthropic-beta': 'prompt-caching-2024-07-31',
|
|
'x-anthropic-additional-protection': 'true',
|
|
'x-safe-header': 'keep-me',
|
|
},
|
|
},
|
|
)
|
|
|
|
expect(capturedHeaders?.get('anthropic-version')).toBeNull()
|
|
expect(capturedHeaders?.get('anthropic-beta')).toBeNull()
|
|
expect(capturedHeaders?.get('x-anthropic-additional-protection')).toBeNull()
|
|
expect(capturedHeaders?.get('x-safe-header')).toBe('keep-me')
|
|
expect(capturedHeaders?.get('authorization')).toBe('Bearer github-test-key')
|
|
expect(capturedHeaders?.get('editor-plugin-version')).toBe('copilot-chat/0.26.7')
|
|
})
|
|
|
|
test('strips Anthropic-specific headers on GitHub Codex transport with providerOverride API key', async () => {
|
|
let capturedHeaders: Headers | undefined
|
|
|
|
process.env.CLAUDE_CODE_USE_GITHUB = '1'
|
|
process.env.OPENAI_API_KEY = 'env-should-not-win'
|
|
delete process.env.OPENAI_BASE_URL
|
|
delete process.env.OPENAI_MODEL
|
|
|
|
globalThis.fetch = (async (_input, init) => {
|
|
capturedHeaders = new Headers(init?.headers)
|
|
|
|
return new Response('', {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'text/event-stream',
|
|
},
|
|
})
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({
|
|
providerOverride: {
|
|
model: 'github:gpt-5-codex',
|
|
baseURL: 'https://api.githubcopilot.com',
|
|
apiKey: 'provider-override-key',
|
|
},
|
|
}) as OpenAIShimClient
|
|
|
|
await client.beta.messages.create(
|
|
{
|
|
model: 'ignored',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
max_tokens: 64,
|
|
stream: true,
|
|
},
|
|
{
|
|
headers: {
|
|
'anthropic-version': '2023-06-01',
|
|
'x-claude-remote-session-id': 'remote-123',
|
|
'x-safe-header': 'keep-me',
|
|
},
|
|
},
|
|
)
|
|
|
|
expect(capturedHeaders?.get('anthropic-version')).toBeNull()
|
|
expect(capturedHeaders?.get('x-claude-remote-session-id')).toBeNull()
|
|
expect(capturedHeaders?.get('x-safe-header')).toBe('keep-me')
|
|
expect(capturedHeaders?.get('authorization')).toBe('Bearer provider-override-key')
|
|
expect(capturedHeaders?.get('editor-plugin-version')).toBe('copilot-chat/0.26.7')
|
|
})
|
|
|
|
test('preserves usage from final OpenAI stream chunk with empty choices', async () => {
|
|
globalThis.fetch = (async (_input, init) => {
|
|
const url = typeof _input === 'string' ? _input : _input.url
|
|
expect(url).toBe('http://example.test/v1/chat/completions')
|
|
|
|
const body = JSON.parse(String(init?.body))
|
|
expect(body.stream).toBe(true)
|
|
expect(body.stream_options).toEqual({ include_usage: true })
|
|
|
|
const chunks = makeStreamChunks([
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'fake-model',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: { role: 'assistant', content: 'hello world' },
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'fake-model',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {},
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'fake-model',
|
|
choices: [],
|
|
usage: {
|
|
prompt_tokens: 123,
|
|
completion_tokens: 45,
|
|
total_tokens: 168,
|
|
},
|
|
},
|
|
])
|
|
|
|
return makeSseResponse(chunks)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
const result = await client.beta.messages
|
|
.create({
|
|
model: 'fake-model',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
max_tokens: 64,
|
|
stream: true,
|
|
})
|
|
.withResponse()
|
|
|
|
const events: Array<Record<string, unknown>> = []
|
|
for await (const event of result.data) {
|
|
events.push(event)
|
|
}
|
|
|
|
const usageEvent = events.find(
|
|
event => event.type === 'message_delta' && typeof event.usage === 'object' && event.usage !== null,
|
|
) as { usage?: { input_tokens?: number; output_tokens?: number } } | undefined
|
|
|
|
expect(usageEvent).toBeDefined()
|
|
expect(usageEvent?.usage?.input_tokens).toBe(123)
|
|
expect(usageEvent?.usage?.output_tokens).toBe(45)
|
|
})
|
|
|
|
test('uses max_tokens instead of max_completion_tokens for local providers', async () => {
|
|
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
|
|
|
|
globalThis.fetch = (async (_input, init) => {
|
|
const body = JSON.parse(String(init?.body))
|
|
expect(body.max_tokens).toBe(64)
|
|
expect(body.max_completion_tokens).toBeUndefined()
|
|
expect(body.stream_options).toBeUndefined()
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-1',
|
|
model: 'llama3.1:8b',
|
|
choices: [
|
|
{
|
|
message: {
|
|
role: 'assistant',
|
|
content: 'hello',
|
|
},
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 5,
|
|
completion_tokens: 1,
|
|
total_tokens: 6,
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
await client.beta.messages.create({
|
|
model: 'llama3.1:8b',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
})
|
|
})
|
|
|
|
test('keeps max_completion_tokens for non-local non-github providers', async () => {
|
|
process.env.OPENAI_BASE_URL = 'https://api.openai.com/v1'
|
|
|
|
globalThis.fetch = (async (_input, init) => {
|
|
const body = JSON.parse(String(init?.body))
|
|
expect(body.max_completion_tokens).toBe(64)
|
|
expect(body.max_tokens).toBeUndefined()
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-1',
|
|
model: 'gpt-4o',
|
|
choices: [
|
|
{
|
|
message: {
|
|
role: 'assistant',
|
|
content: 'hello',
|
|
},
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 5,
|
|
completion_tokens: 1,
|
|
total_tokens: 6,
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
await client.beta.messages.create({
|
|
model: 'gpt-4o',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
})
|
|
})
|
|
|
|
test('preserves Gemini tool call extra_content in follow-up requests', async () => {
|
|
let requestBody: Record<string, unknown> | undefined
|
|
|
|
globalThis.fetch = (async (_input, init) => {
|
|
requestBody = JSON.parse(String(init?.body))
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-1',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
message: {
|
|
role: 'assistant',
|
|
content: 'done',
|
|
},
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 12,
|
|
completion_tokens: 4,
|
|
total_tokens: 16,
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
await client.beta.messages.create({
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
system: 'test system',
|
|
messages: [
|
|
{ role: 'user', content: 'Use Bash' },
|
|
{
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'tool_use',
|
|
id: 'call_1',
|
|
name: 'Bash',
|
|
input: { command: 'pwd' },
|
|
extra_content: {
|
|
google: {
|
|
thought_signature: 'sig-123',
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'tool_result',
|
|
tool_use_id: 'call_1',
|
|
content: 'D:\\repo',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
})
|
|
|
|
const assistantWithToolCall = (requestBody?.messages as Array<Record<string, unknown>>).find(
|
|
message => Array.isArray(message.tool_calls),
|
|
) as { tool_calls?: Array<Record<string, unknown>> } | undefined
|
|
|
|
expect(assistantWithToolCall?.tool_calls?.[0]).toMatchObject({
|
|
id: 'call_1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'Bash',
|
|
arguments: JSON.stringify({ command: 'pwd' }),
|
|
},
|
|
extra_content: {
|
|
google: {
|
|
thought_signature: 'sig-123',
|
|
},
|
|
},
|
|
})
|
|
})
|
|
|
|
test('preserves Grep tool pattern field in OpenAI-compatible schemas', async () => {
|
|
let requestBody: Record<string, unknown> | undefined
|
|
|
|
globalThis.fetch = (async (_input, init) => {
|
|
requestBody = JSON.parse(String(init?.body))
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-grep-schema',
|
|
model: 'qwen/qwen3.6-plus',
|
|
choices: [
|
|
{
|
|
message: {
|
|
role: 'assistant',
|
|
content: 'done',
|
|
},
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 12,
|
|
completion_tokens: 4,
|
|
total_tokens: 16,
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
await client.beta.messages.create({
|
|
model: 'qwen/qwen3.6-plus',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'Use Grep' }],
|
|
tools: [
|
|
{
|
|
name: 'Grep',
|
|
description: 'Search file contents',
|
|
input_schema: {
|
|
type: 'object',
|
|
properties: {
|
|
pattern: { type: 'string', description: 'Search pattern' },
|
|
path: { type: 'string' },
|
|
},
|
|
required: ['pattern'],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
})
|
|
|
|
const tools = requestBody?.tools as Array<Record<string, unknown>> | undefined
|
|
const grepTool = tools?.find(tool => (tool.function as Record<string, unknown>)?.name === 'Grep') as
|
|
| { function?: { parameters?: { properties?: Record<string, unknown>; required?: string[] } } }
|
|
| undefined
|
|
|
|
expect(Object.keys(grepTool?.function?.parameters?.properties ?? {})).toContain('pattern')
|
|
expect(grepTool?.function?.parameters?.required).toContain('pattern')
|
|
})
|
|
|
|
test('does not infer Gemini mode from OPENAI_BASE_URL path substrings', async () => {
|
|
let capturedAuthorization: string | null = null
|
|
|
|
process.env.OPENAI_BASE_URL =
|
|
'https://evil.example/generativelanguage.googleapis.com/v1beta/openai'
|
|
delete process.env.OPENAI_API_KEY
|
|
process.env.GEMINI_API_KEY = 'gemini-secret'
|
|
|
|
globalThis.fetch = (async (_input, init) => {
|
|
const headers = init?.headers as Record<string, string> | undefined
|
|
capturedAuthorization =
|
|
headers?.Authorization ?? headers?.authorization ?? null
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-1',
|
|
model: 'fake-model',
|
|
choices: [
|
|
{
|
|
message: {
|
|
role: 'assistant',
|
|
content: 'ok',
|
|
},
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 12,
|
|
completion_tokens: 4,
|
|
total_tokens: 16,
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
await client.beta.messages.create({
|
|
model: 'fake-model',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
})
|
|
|
|
expect(capturedAuthorization).toBeNull()
|
|
})
|
|
|
|
test('preserves image tool results as placeholders in follow-up requests', async () => {
|
|
let requestBody: Record<string, unknown> | undefined
|
|
|
|
globalThis.fetch = (async (_input, init) => {
|
|
requestBody = JSON.parse(String(init?.body))
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-1',
|
|
model: 'qwen/qwen3.6-plus',
|
|
choices: [
|
|
{
|
|
message: {
|
|
role: 'assistant',
|
|
content: 'done',
|
|
},
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 12,
|
|
completion_tokens: 4,
|
|
total_tokens: 16,
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
await client.beta.messages.create({
|
|
model: 'qwen/qwen3.6-plus',
|
|
system: 'test system',
|
|
messages: [
|
|
{ role: 'user', content: 'Read this screenshot' },
|
|
{
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'tool_use',
|
|
id: 'call_image_1',
|
|
name: 'Read',
|
|
input: { file_path: 'C:\\temp\\screenshot.png' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'tool_result',
|
|
tool_use_id: 'call_image_1',
|
|
content: [
|
|
{
|
|
type: 'image',
|
|
source: {
|
|
type: 'base64',
|
|
media_type: 'image/png',
|
|
data: 'ZmFrZQ==',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
})
|
|
|
|
const toolMessage = (requestBody?.messages as Array<Record<string, unknown>>).find(
|
|
message => message.role === 'tool',
|
|
) as {
|
|
content?: Array<{
|
|
type: string
|
|
text?: string
|
|
image_url?: { url: string }
|
|
}> | string
|
|
} | undefined
|
|
|
|
expect(Array.isArray(toolMessage?.content)).toBe(true)
|
|
const parts = toolMessage?.content as Array<{
|
|
type: string
|
|
text?: string
|
|
image_url?: { url: string }
|
|
}>
|
|
const imagePart = parts.find(part => part.type === 'image_url')
|
|
expect(imagePart?.image_url?.url).toBe('data:image/png;base64,ZmFrZQ==')
|
|
})
|
|
|
|
test('preserves mixed text and image tool results as multipart content', async () => {
|
|
let requestBody: Record<string, unknown> | undefined
|
|
|
|
globalThis.fetch = (async (_input, init) => {
|
|
requestBody = JSON.parse(String(init?.body))
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-1',
|
|
model: 'gpt-4o',
|
|
choices: [
|
|
{
|
|
message: {
|
|
role: 'assistant',
|
|
content: 'done',
|
|
},
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 12,
|
|
completion_tokens: 4,
|
|
total_tokens: 16,
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
await client.beta.messages.create({
|
|
model: 'gpt-4o',
|
|
system: 'test system',
|
|
messages: [
|
|
{ role: 'user', content: 'Read this screenshot' },
|
|
{
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'tool_use',
|
|
id: 'call_image_2',
|
|
name: 'Read',
|
|
input: { file_path: 'C:\\temp\\screenshot.png' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'tool_result',
|
|
tool_use_id: 'call_image_2',
|
|
content: [
|
|
{ type: 'text', text: 'Screenshot captured' },
|
|
{
|
|
type: 'image',
|
|
source: {
|
|
type: 'base64',
|
|
media_type: 'image/png',
|
|
data: 'ZmFrZQ==',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
})
|
|
|
|
const toolMessage = (requestBody?.messages as Array<Record<string, unknown>>).find(
|
|
message => message.role === 'tool',
|
|
) as {
|
|
content?: Array<{
|
|
type: string
|
|
text?: string
|
|
image_url?: { url: string }
|
|
}>
|
|
} | undefined
|
|
|
|
expect(Array.isArray(toolMessage?.content)).toBe(true)
|
|
const parts = toolMessage?.content ?? []
|
|
expect(parts[0]).toEqual({ type: 'text', text: 'Screenshot captured' })
|
|
expect(parts[1]).toEqual({
|
|
type: 'image_url',
|
|
image_url: { url: 'data:image/png;base64,ZmFrZQ==' },
|
|
})
|
|
})
|
|
|
|
test('uses GEMINI_ACCESS_TOKEN for Gemini OpenAI-compatible requests', async () => {
|
|
let capturedAuthorization: string | null = null
|
|
let capturedProject: string | null = null
|
|
let requestUrl: string | undefined
|
|
|
|
process.env.CLAUDE_CODE_USE_GEMINI = '1'
|
|
process.env.GEMINI_AUTH_MODE = 'access-token'
|
|
process.env.GEMINI_ACCESS_TOKEN = 'gemini-access-token'
|
|
process.env.GOOGLE_CLOUD_PROJECT = 'gemini-project'
|
|
process.env.GEMINI_BASE_URL =
|
|
'https://generativelanguage.googleapis.com/v1beta/openai'
|
|
process.env.GEMINI_MODEL = 'gemini-2.0-flash'
|
|
delete process.env.OPENAI_BASE_URL
|
|
delete process.env.OPENAI_API_KEY
|
|
delete process.env.GEMINI_API_KEY
|
|
delete process.env.GOOGLE_API_KEY
|
|
|
|
globalThis.fetch = (async (input, init) => {
|
|
requestUrl = typeof input === 'string' ? input : input.url
|
|
const headers = init?.headers as Record<string, string> | undefined
|
|
capturedAuthorization =
|
|
headers?.Authorization ?? headers?.authorization ?? null
|
|
capturedProject =
|
|
headers?.['x-goog-user-project'] ??
|
|
headers?.['X-Goog-User-Project'] ??
|
|
null
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-gemini',
|
|
model: 'gemini-2.0-flash',
|
|
choices: [
|
|
{
|
|
message: {
|
|
role: 'assistant',
|
|
content: 'ok',
|
|
},
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 3,
|
|
completion_tokens: 1,
|
|
total_tokens: 4,
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
await client.beta.messages.create({
|
|
model: 'gemini-2.0-flash',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
max_tokens: 32,
|
|
stream: false,
|
|
})
|
|
|
|
expect(requestUrl).toBe(
|
|
'https://generativelanguage.googleapis.com/v1beta/openai/chat/completions',
|
|
)
|
|
expect(capturedAuthorization).toBe('Bearer gemini-access-token')
|
|
expect(capturedProject).toBe('gemini-project')
|
|
})
|
|
|
|
test('preserves Gemini tool call extra_content from streaming chunks', async () => {
|
|
globalThis.fetch = (async (_input, _init) => {
|
|
const chunks = makeStreamChunks([
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {
|
|
role: 'assistant',
|
|
tool_calls: [
|
|
{
|
|
index: 0,
|
|
id: 'function-call-1',
|
|
type: 'function',
|
|
extra_content: {
|
|
google: {
|
|
thought_signature: 'sig-stream',
|
|
},
|
|
},
|
|
function: {
|
|
name: 'Bash',
|
|
arguments: '{"command":"pwd"}',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {},
|
|
finish_reason: 'tool_calls',
|
|
},
|
|
],
|
|
},
|
|
])
|
|
|
|
return makeSseResponse(chunks)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
const result = await client.beta.messages
|
|
.create({
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'Use Bash' }],
|
|
max_tokens: 64,
|
|
stream: true,
|
|
})
|
|
.withResponse()
|
|
|
|
const events: Array<Record<string, unknown>> = []
|
|
for await (const event of result.data) {
|
|
events.push(event)
|
|
}
|
|
|
|
const toolStart = events.find(
|
|
event =>
|
|
event.type === 'content_block_start' &&
|
|
typeof event.content_block === 'object' &&
|
|
event.content_block !== null &&
|
|
(event.content_block as Record<string, unknown>).type === 'tool_use',
|
|
) as { content_block?: Record<string, unknown> } | undefined
|
|
|
|
expect(toolStart?.content_block).toMatchObject({
|
|
type: 'tool_use',
|
|
id: 'function-call-1',
|
|
name: 'Bash',
|
|
extra_content: {
|
|
google: {
|
|
thought_signature: 'sig-stream',
|
|
},
|
|
},
|
|
})
|
|
})
|
|
|
|
test('normalizes plain string Bash tool arguments from OpenAI-compatible responses', async () => {
|
|
globalThis.fetch = (async (_input, _init) => {
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-1',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
message: {
|
|
role: 'assistant',
|
|
tool_calls: [
|
|
{
|
|
id: 'function-call-1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'Bash',
|
|
arguments: 'pwd',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finish_reason: 'tool_calls',
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 12,
|
|
completion_tokens: 4,
|
|
total_tokens: 16,
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
const message = await client.beta.messages.create({
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'Use Bash' }],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
}) as {
|
|
stop_reason?: string
|
|
content?: Array<Record<string, unknown>>
|
|
}
|
|
|
|
expect(message.stop_reason).toBe('tool_use')
|
|
expect(message.content).toEqual([
|
|
{
|
|
type: 'tool_use',
|
|
id: 'function-call-1',
|
|
name: 'Bash',
|
|
input: { command: 'pwd' },
|
|
},
|
|
])
|
|
})
|
|
|
|
test('normalizes Bash tool arguments that are valid JSON strings', async () => {
|
|
globalThis.fetch = (async (_input, _init) => {
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-1',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
message: {
|
|
role: 'assistant',
|
|
tool_calls: [
|
|
{
|
|
id: 'function-call-1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'Bash',
|
|
arguments: '"pwd"',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finish_reason: 'tool_calls',
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 12,
|
|
completion_tokens: 4,
|
|
total_tokens: 16,
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
const message = await client.beta.messages.create({
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'Use Bash' }],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
}) as {
|
|
content?: Array<Record<string, unknown>>
|
|
}
|
|
|
|
expect(message.content).toEqual([
|
|
{
|
|
type: 'tool_use',
|
|
id: 'function-call-1',
|
|
name: 'Bash',
|
|
input: { command: 'pwd' },
|
|
},
|
|
])
|
|
})
|
|
|
|
test.each([
|
|
['false', false],
|
|
['null', null],
|
|
['[]', []],
|
|
])(
|
|
'preserves malformed Bash JSON literals as parsed values in non-streaming responses: %s',
|
|
async (argumentsValue, expectedInput) => {
|
|
globalThis.fetch = (async (_input, _init) => {
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-1',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
message: {
|
|
role: 'assistant',
|
|
tool_calls: [
|
|
{
|
|
id: 'function-call-1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'Bash',
|
|
arguments: argumentsValue,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finish_reason: 'tool_calls',
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 12,
|
|
completion_tokens: 4,
|
|
total_tokens: 16,
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
const message = await client.beta.messages.create({
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'Use Bash' }],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
}) as {
|
|
content?: Array<Record<string, unknown>>
|
|
}
|
|
|
|
expect(message.content).toEqual([
|
|
{
|
|
type: 'tool_use',
|
|
id: 'function-call-1',
|
|
name: 'Bash',
|
|
input: expectedInput,
|
|
},
|
|
])
|
|
},
|
|
)
|
|
|
|
test('keeps terminal empty Bash tool arguments invalid in non-streaming responses', async () => {
|
|
globalThis.fetch = (async (_input, _init) => {
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-1',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
message: {
|
|
role: 'assistant',
|
|
tool_calls: [
|
|
{
|
|
id: 'function-call-1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'Bash',
|
|
arguments: '',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finish_reason: 'tool_calls',
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 12,
|
|
completion_tokens: 4,
|
|
total_tokens: 16,
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
const message = await client.beta.messages.create({
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'Use Bash' }],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
}) as {
|
|
content?: Array<Record<string, unknown>>
|
|
}
|
|
|
|
expect(message.content).toEqual([
|
|
{
|
|
type: 'tool_use',
|
|
id: 'function-call-1',
|
|
name: 'Bash',
|
|
input: {},
|
|
},
|
|
])
|
|
})
|
|
|
|
test('normalizes plain string Bash tool arguments in streaming responses', async () => {
|
|
globalThis.fetch = (async (_input, _init) => {
|
|
const chunks = makeStreamChunks([
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {
|
|
role: 'assistant',
|
|
tool_calls: [
|
|
{
|
|
index: 0,
|
|
id: 'function-call-1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'Bash',
|
|
arguments: 'pwd',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {},
|
|
finish_reason: 'tool_calls',
|
|
},
|
|
],
|
|
},
|
|
])
|
|
|
|
return makeSseResponse(chunks)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
const result = await client.beta.messages
|
|
.create({
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'Use Bash' }],
|
|
max_tokens: 64,
|
|
stream: true,
|
|
})
|
|
.withResponse()
|
|
|
|
const events: Array<Record<string, unknown>> = []
|
|
for await (const event of result.data) {
|
|
events.push(event)
|
|
}
|
|
|
|
const normalizedInput = events
|
|
.filter(
|
|
event =>
|
|
event.type === 'content_block_delta' &&
|
|
typeof event.delta === 'object' &&
|
|
event.delta !== null &&
|
|
(event.delta as Record<string, unknown>).type === 'input_json_delta',
|
|
)
|
|
.map(event => (event.delta as Record<string, unknown>).partial_json)
|
|
.join('')
|
|
|
|
expect(normalizedInput).toBe('{"command":"pwd"}')
|
|
})
|
|
|
|
test('normalizes plain string Bash tool arguments when streaming starts with an empty chunk', async () => {
|
|
globalThis.fetch = (async (_input, _init) => {
|
|
const chunks = makeStreamChunks([
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {
|
|
role: 'assistant',
|
|
tool_calls: [
|
|
{
|
|
index: 0,
|
|
id: 'function-call-1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'Bash',
|
|
arguments: '',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {
|
|
tool_calls: [
|
|
{
|
|
index: 0,
|
|
type: 'function',
|
|
function: {
|
|
arguments: 'pwd',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {},
|
|
finish_reason: 'tool_calls',
|
|
},
|
|
],
|
|
},
|
|
])
|
|
|
|
return makeSseResponse(chunks)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
const result = await client.beta.messages
|
|
.create({
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'Use Bash' }],
|
|
max_tokens: 64,
|
|
stream: true,
|
|
})
|
|
.withResponse()
|
|
|
|
const events: Array<Record<string, unknown>> = []
|
|
for await (const event of result.data) {
|
|
events.push(event)
|
|
}
|
|
|
|
const normalizedInput = events
|
|
.filter(
|
|
event =>
|
|
event.type === 'content_block_delta' &&
|
|
typeof event.delta === 'object' &&
|
|
event.delta !== null &&
|
|
(event.delta as Record<string, unknown>).type === 'input_json_delta',
|
|
)
|
|
.map(event => (event.delta as Record<string, unknown>).partial_json)
|
|
.join('')
|
|
|
|
expect(normalizedInput).toBe('{"command":"pwd"}')
|
|
})
|
|
|
|
test('normalizes plain string Bash tool arguments when streaming starts with whitespace', async () => {
|
|
globalThis.fetch = (async (_input, _init) => {
|
|
const chunks = makeStreamChunks([
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {
|
|
role: 'assistant',
|
|
tool_calls: [
|
|
{
|
|
index: 0,
|
|
id: 'function-call-1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'Bash',
|
|
arguments: ' ',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {
|
|
tool_calls: [
|
|
{
|
|
index: 0,
|
|
type: 'function',
|
|
function: {
|
|
arguments: 'pwd',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {},
|
|
finish_reason: 'tool_calls',
|
|
},
|
|
],
|
|
},
|
|
])
|
|
|
|
return makeSseResponse(chunks)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
const result = await client.beta.messages
|
|
.create({
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'Use Bash' }],
|
|
max_tokens: 64,
|
|
stream: true,
|
|
})
|
|
.withResponse()
|
|
|
|
const events: Array<Record<string, unknown>> = []
|
|
for await (const event of result.data) {
|
|
events.push(event)
|
|
}
|
|
|
|
const normalizedInput = events
|
|
.filter(
|
|
event =>
|
|
event.type === 'content_block_delta' &&
|
|
typeof event.delta === 'object' &&
|
|
event.delta !== null &&
|
|
(event.delta as Record<string, unknown>).type === 'input_json_delta',
|
|
)
|
|
.map(event => (event.delta as Record<string, unknown>).partial_json)
|
|
.join('')
|
|
|
|
expect(normalizedInput).toBe('{"command":" pwd"}')
|
|
})
|
|
|
|
test('keeps terminal whitespace-only Bash arguments invalid in streaming responses', async () => {
|
|
globalThis.fetch = (async (_input, _init) => {
|
|
const chunks = makeStreamChunks([
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {
|
|
role: 'assistant',
|
|
tool_calls: [
|
|
{
|
|
index: 0,
|
|
id: 'function-call-1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'Bash',
|
|
arguments: ' ',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {},
|
|
finish_reason: 'tool_calls',
|
|
},
|
|
],
|
|
},
|
|
])
|
|
|
|
return makeSseResponse(chunks)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
const result = await client.beta.messages
|
|
.create({
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'Use Bash' }],
|
|
max_tokens: 64,
|
|
stream: true,
|
|
})
|
|
.withResponse()
|
|
|
|
const events: Array<Record<string, unknown>> = []
|
|
for await (const event of result.data) {
|
|
events.push(event)
|
|
}
|
|
|
|
const normalizedInput = events
|
|
.filter(
|
|
event =>
|
|
event.type === 'content_block_delta' &&
|
|
typeof event.delta === 'object' &&
|
|
event.delta !== null &&
|
|
(event.delta as Record<string, unknown>).type === 'input_json_delta',
|
|
)
|
|
.map(event => (event.delta as Record<string, unknown>).partial_json)
|
|
.join('')
|
|
|
|
expect(normalizedInput).toBe('{}')
|
|
})
|
|
|
|
test('normalizes streaming Bash arguments that begin with bracket syntax', async () => {
|
|
globalThis.fetch = (async (_input, _init) => {
|
|
const chunks = makeStreamChunks([
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {
|
|
role: 'assistant',
|
|
tool_calls: [
|
|
{
|
|
index: 0,
|
|
id: 'function-call-1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'Bash',
|
|
arguments: '[ -f package.json ] && pwd',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {},
|
|
finish_reason: 'tool_calls',
|
|
},
|
|
],
|
|
},
|
|
])
|
|
|
|
return makeSseResponse(chunks)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
const result = await client.beta.messages
|
|
.create({
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'Use Bash' }],
|
|
max_tokens: 64,
|
|
stream: true,
|
|
})
|
|
.withResponse()
|
|
|
|
const events: Array<Record<string, unknown>> = []
|
|
for await (const event of result.data) {
|
|
events.push(event)
|
|
}
|
|
|
|
const normalizedInput = events
|
|
.filter(
|
|
event =>
|
|
event.type === 'content_block_delta' &&
|
|
typeof event.delta === 'object' &&
|
|
event.delta !== null &&
|
|
(event.delta as Record<string, unknown>).type === 'input_json_delta',
|
|
)
|
|
.map(event => (event.delta as Record<string, unknown>).partial_json)
|
|
.join('')
|
|
|
|
expect(normalizedInput).toBe('{"command":"[ -f package.json ] && pwd"}')
|
|
})
|
|
|
|
test('normalizes streaming Bash arguments when the first chunk is only an opening brace', async () => {
|
|
globalThis.fetch = (async (_input, _init) => {
|
|
const chunks = makeStreamChunks([
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {
|
|
role: 'assistant',
|
|
tool_calls: [
|
|
{
|
|
index: 0,
|
|
id: 'function-call-1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'Bash',
|
|
arguments: '{',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {
|
|
tool_calls: [
|
|
{
|
|
index: 0,
|
|
type: 'function',
|
|
function: {
|
|
arguments: ' pwd; }',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {},
|
|
finish_reason: 'tool_calls',
|
|
},
|
|
],
|
|
},
|
|
])
|
|
|
|
return makeSseResponse(chunks)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
const result = await client.beta.messages
|
|
.create({
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'Use Bash' }],
|
|
max_tokens: 64,
|
|
stream: true,
|
|
})
|
|
.withResponse()
|
|
|
|
const events: Array<Record<string, unknown>> = []
|
|
for await (const event of result.data) {
|
|
events.push(event)
|
|
}
|
|
|
|
const normalizedInput = events
|
|
.filter(
|
|
event =>
|
|
event.type === 'content_block_delta' &&
|
|
typeof event.delta === 'object' &&
|
|
event.delta !== null &&
|
|
(event.delta as Record<string, unknown>).type === 'input_json_delta',
|
|
)
|
|
.map(event => (event.delta as Record<string, unknown>).partial_json)
|
|
.join('')
|
|
|
|
expect(normalizedInput).toBe('{"command":"{ pwd; }"}')
|
|
})
|
|
|
|
test('repairs truncated structured Bash JSON in streaming responses', async () => {
|
|
globalThis.fetch = (async (_input, _init) => {
|
|
const chunks = makeStreamChunks([
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {
|
|
role: 'assistant',
|
|
tool_calls: [
|
|
{
|
|
index: 0,
|
|
id: 'function-call-1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'Bash',
|
|
arguments: '{"command":"pwd"',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {},
|
|
finish_reason: 'tool_calls',
|
|
},
|
|
],
|
|
},
|
|
])
|
|
|
|
return makeSseResponse(chunks)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
const result = await client.beta.messages
|
|
.create({
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'Use Bash' }],
|
|
max_tokens: 64,
|
|
stream: true,
|
|
})
|
|
.withResponse()
|
|
|
|
const events: Array<Record<string, unknown>> = []
|
|
for await (const event of result.data) {
|
|
events.push(event)
|
|
}
|
|
|
|
const normalizedInput = events
|
|
.filter(
|
|
event =>
|
|
event.type === 'content_block_delta' &&
|
|
typeof event.delta === 'object' &&
|
|
event.delta !== null &&
|
|
(event.delta as Record<string, unknown>).type === 'input_json_delta',
|
|
)
|
|
.map(event => (event.delta as Record<string, unknown>).partial_json)
|
|
.join('')
|
|
|
|
expect(normalizedInput).toBe('{"command":"pwd"}')
|
|
})
|
|
|
|
test('does not normalize incomplete streamed Bash commands when finish_reason is length', async () => {
|
|
globalThis.fetch = (async (_input, _init) => {
|
|
const chunks = makeStreamChunks([
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {
|
|
role: 'assistant',
|
|
tool_calls: [
|
|
{
|
|
index: 0,
|
|
id: 'function-call-1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'Bash',
|
|
arguments: 'rg --fi',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {},
|
|
finish_reason: 'length',
|
|
},
|
|
],
|
|
},
|
|
])
|
|
|
|
return makeSseResponse(chunks)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
const result = await client.beta.messages
|
|
.create({
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'Use Bash' }],
|
|
max_tokens: 64,
|
|
stream: true,
|
|
})
|
|
.withResponse()
|
|
|
|
const events: Array<Record<string, unknown>> = []
|
|
for await (const event of result.data) {
|
|
events.push(event)
|
|
}
|
|
|
|
const streamedInput = events
|
|
.filter(
|
|
event =>
|
|
event.type === 'content_block_delta' &&
|
|
typeof event.delta === 'object' &&
|
|
event.delta !== null &&
|
|
(event.delta as Record<string, unknown>).type === 'input_json_delta',
|
|
)
|
|
.map(event => (event.delta as Record<string, unknown>).partial_json)
|
|
.join('')
|
|
|
|
expect(streamedInput).toBe('rg --fi')
|
|
})
|
|
|
|
test('repairs truncated JSON objects even without command field', async () => {
|
|
globalThis.fetch = (async (_input, _init) => {
|
|
const chunks = makeStreamChunks([
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {
|
|
role: 'assistant',
|
|
tool_calls: [
|
|
{
|
|
index: 0,
|
|
id: 'function-call-1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'Bash',
|
|
arguments: '{"cwd":"/tmp"',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {},
|
|
finish_reason: 'tool_calls',
|
|
},
|
|
],
|
|
},
|
|
])
|
|
|
|
return makeSseResponse(chunks)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
const result = await client.beta.messages
|
|
.create({
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'Use Bash' }],
|
|
max_tokens: 64,
|
|
stream: true,
|
|
})
|
|
.withResponse()
|
|
|
|
const events: Array<Record<string, unknown>> = []
|
|
for await (const event of result.data) {
|
|
events.push(event)
|
|
}
|
|
|
|
const streamedInput = events
|
|
.filter(
|
|
event =>
|
|
event.type === 'content_block_delta' &&
|
|
typeof event.delta === 'object' &&
|
|
event.delta !== null &&
|
|
(event.delta as Record<string, unknown>).type === 'input_json_delta',
|
|
)
|
|
.map(event => (event.delta as Record<string, unknown>).partial_json)
|
|
.join('')
|
|
|
|
expect(streamedInput).toBe('{"cwd":"/tmp"}')
|
|
})
|
|
|
|
test('preserves raw input for unknown plain string tool arguments', async () => {
|
|
globalThis.fetch = (async (_input, _init) => {
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-1',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
message: {
|
|
role: 'assistant',
|
|
tool_calls: [
|
|
{
|
|
id: 'function-call-1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'UnknownTool',
|
|
arguments: 'pwd',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finish_reason: 'tool_calls',
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 12,
|
|
completion_tokens: 4,
|
|
total_tokens: 16,
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
const message = await client.beta.messages.create({
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'Use tool' }],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
}) as {
|
|
content?: Array<Record<string, unknown>>
|
|
}
|
|
|
|
expect(message.content).toEqual([
|
|
{
|
|
type: 'tool_use',
|
|
id: 'function-call-1',
|
|
name: 'UnknownTool',
|
|
input: {},
|
|
},
|
|
])
|
|
})
|
|
|
|
test('preserves parsed string input for unknown JSON string tool arguments', async () => {
|
|
globalThis.fetch = (async (_input, _init) => {
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-1',
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
choices: [
|
|
{
|
|
message: {
|
|
role: 'assistant',
|
|
tool_calls: [
|
|
{
|
|
id: 'function-call-1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'UnknownTool',
|
|
arguments: '"pwd"',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finish_reason: 'tool_calls',
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 12,
|
|
completion_tokens: 4,
|
|
total_tokens: 16,
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
const message = await client.beta.messages.create({
|
|
model: 'google/gemini-3.1-pro-preview',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'Use tool' }],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
}) as {
|
|
content?: Array<Record<string, unknown>>
|
|
}
|
|
|
|
expect(message.content).toEqual([
|
|
{
|
|
type: 'tool_use',
|
|
id: 'function-call-1',
|
|
name: 'UnknownTool',
|
|
input: 'pwd',
|
|
},
|
|
])
|
|
})
|
|
|
|
test('sanitizes malformed MCP tool schemas before sending them to OpenAI', async () => {
|
|
let requestBody: Record<string, unknown> | undefined
|
|
|
|
globalThis.fetch = (async (_input, init) => {
|
|
requestBody = JSON.parse(String(init?.body))
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-1',
|
|
model: 'gpt-4o',
|
|
choices: [
|
|
{
|
|
message: {
|
|
role: 'assistant',
|
|
content: 'ok',
|
|
},
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 10,
|
|
completion_tokens: 1,
|
|
total_tokens: 11,
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
await client.beta.messages.create({
|
|
model: 'gpt-4o',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
tools: [
|
|
{
|
|
name: 'mcp__clientry__create_task',
|
|
description: 'Create a task',
|
|
input_schema: {
|
|
type: 'object',
|
|
properties: {
|
|
priority: {
|
|
type: 'integer',
|
|
description: 'Priority: 0=low, 1=medium, 2=high, 3=urgent',
|
|
default: true,
|
|
enum: [false, 0, 1, 2, 3],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
})
|
|
|
|
const parameters = (
|
|
requestBody?.tools as Array<{ function?: { parameters?: Record<string, unknown> } }>
|
|
)?.[0]?.function?.parameters
|
|
const properties = parameters?.properties as
|
|
| Record<string, { default?: unknown; enum?: unknown[]; type?: string }>
|
|
| undefined
|
|
|
|
expect(parameters?.additionalProperties).toBe(false)
|
|
// No required[] in the original schema → none added (optional properties must not be forced required)
|
|
expect(parameters?.required).toEqual([])
|
|
expect(properties?.priority?.type).toBe('integer')
|
|
expect(properties?.priority?.enum).toEqual([0, 1, 2, 3])
|
|
expect(properties?.priority).not.toHaveProperty('default')
|
|
})
|
|
|
|
test('optional tool properties are not added to required[] — fixes Groq/Azure 400 tool_use_failed', async () => {
|
|
// Regression test for: all optional properties being sent as required in strict mode,
|
|
// causing providers like Groq to reject valid tool calls where the model omits optional args.
|
|
let requestBody: Record<string, unknown> | undefined
|
|
|
|
globalThis.fetch = (async (_input, init) => {
|
|
requestBody = JSON.parse(String(init?.body))
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-4',
|
|
model: 'gpt-4o',
|
|
choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
|
|
usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 },
|
|
}),
|
|
{ headers: { 'Content-Type': 'application/json' } },
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
await client.beta.messages.create({
|
|
model: 'gpt-4o',
|
|
messages: [{ role: 'user', content: 'read a file' }],
|
|
tools: [
|
|
{
|
|
name: 'Read',
|
|
description: 'Read a file',
|
|
input_schema: {
|
|
type: 'object',
|
|
properties: {
|
|
file_path: { type: 'string', description: 'Absolute path to file' },
|
|
offset: { type: 'number', description: 'Line to start from' },
|
|
limit: { type: 'number', description: 'Max lines to read' },
|
|
pages: { type: 'string', description: 'Page range for PDFs' },
|
|
},
|
|
required: ['file_path'],
|
|
},
|
|
},
|
|
],
|
|
max_tokens: 16,
|
|
stream: false,
|
|
})
|
|
|
|
const parameters = (
|
|
requestBody?.tools as Array<{ function?: { parameters?: Record<string, unknown> } }>
|
|
)?.[0]?.function?.parameters
|
|
|
|
expect(parameters?.required).toEqual(['file_path'])
|
|
|
|
const required = parameters?.required as string[] | undefined
|
|
expect(required).not.toContain('offset')
|
|
expect(required).not.toContain('limit')
|
|
expect(required).not.toContain('pages')
|
|
expect(parameters?.additionalProperties).toBe(false)
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Issue #202 — consecutive role coalescing (Devstral, Mistral strict templates)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function makeNonStreamResponse(content = 'ok'): Response {
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-test',
|
|
model: 'test-model',
|
|
choices: [{ message: { role: 'assistant', content }, finish_reason: 'stop' }],
|
|
usage: { prompt_tokens: 5, completion_tokens: 1, total_tokens: 6 },
|
|
}),
|
|
{ headers: { 'Content-Type': 'application/json' } },
|
|
)
|
|
}
|
|
|
|
test('coalesces consecutive user messages to avoid alternation errors (issue #202)', async () => {
|
|
let sentMessages: Array<{ role: string; content: unknown }> | undefined
|
|
|
|
globalThis.fetch = (async (_input: unknown, init: RequestInit | undefined) => {
|
|
sentMessages = JSON.parse(String(init?.body)).messages
|
|
return makeNonStreamResponse()
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
await client.beta.messages.create({
|
|
model: 'test-model',
|
|
system: 'sys',
|
|
messages: [
|
|
{ role: 'user', content: 'first message' },
|
|
{ role: 'user', content: 'second message' },
|
|
],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
})
|
|
|
|
expect(sentMessages?.length).toBe(2)
|
|
expect(sentMessages?.[0]?.role).toBe('system')
|
|
expect(sentMessages?.[1]?.role).toBe('user')
|
|
const userContent = sentMessages?.[1]?.content as string
|
|
expect(userContent).toContain('first message')
|
|
expect(userContent).toContain('second message')
|
|
})
|
|
|
|
test('coalesces consecutive assistant messages preserving tool_calls (issue #202)', async () => {
|
|
let sentMessages: Array<{ role: string; content: unknown; tool_calls?: unknown[] }> | undefined
|
|
|
|
globalThis.fetch = (async (_input: unknown, init: RequestInit | undefined) => {
|
|
sentMessages = JSON.parse(String(init?.body)).messages
|
|
return makeNonStreamResponse()
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
await client.beta.messages.create({
|
|
model: 'test-model',
|
|
system: 'sys',
|
|
messages: [
|
|
{ role: 'user', content: 'go' },
|
|
{ role: 'assistant', content: 'thinking...' },
|
|
{
|
|
role: 'assistant',
|
|
content: [{ type: 'tool_use', id: 'call_1', name: 'Bash', input: { command: 'ls' } }],
|
|
},
|
|
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_1', content: 'file.txt' }] },
|
|
],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
})
|
|
|
|
const assistantMsgs = sentMessages?.filter(m => m.role === 'assistant')
|
|
expect(assistantMsgs?.length).toBe(1)
|
|
expect(assistantMsgs?.[0]?.tool_calls?.length).toBeGreaterThan(0)
|
|
})
|
|
|
|
test('non-streaming: reasoning_content emitted as thinking block only when content is null', async () => {
|
|
globalThis.fetch = (async (_input, _init) => {
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-1',
|
|
model: 'glm-5',
|
|
choices: [
|
|
{
|
|
message: {
|
|
role: 'assistant',
|
|
content: null,
|
|
reasoning_content: 'Let me think about this step by step.',
|
|
},
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 10,
|
|
completion_tokens: 20,
|
|
total_tokens: 30,
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
const result = (await client.beta.messages.create({
|
|
model: 'glm-5',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
})) as { content: Array<Record<string, unknown>> }
|
|
|
|
expect(result.content).toEqual([
|
|
{ type: 'thinking', thinking: 'Let me think about this step by step.' },
|
|
])
|
|
})
|
|
|
|
test('non-streaming: empty string content does not fall through to reasoning_content as text', async () => {
|
|
globalThis.fetch = (async (_input, _init) => {
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-1',
|
|
model: 'glm-5',
|
|
choices: [
|
|
{
|
|
message: {
|
|
role: 'assistant',
|
|
content: '',
|
|
reasoning_content: 'Chain of thought here.',
|
|
},
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 10,
|
|
completion_tokens: 20,
|
|
total_tokens: 30,
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
const result = (await client.beta.messages.create({
|
|
model: 'glm-5',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
})) as { content: Array<Record<string, unknown>> }
|
|
|
|
expect(result.content).toEqual([
|
|
{ type: 'thinking', thinking: 'Chain of thought here.' },
|
|
])
|
|
})
|
|
|
|
test('non-streaming: real content takes precedence over reasoning_content', async () => {
|
|
globalThis.fetch = (async (_input, _init) => {
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-1',
|
|
model: 'glm-5',
|
|
choices: [
|
|
{
|
|
message: {
|
|
role: 'assistant',
|
|
content: 'The answer is 42.',
|
|
reasoning_content: 'I need to calculate this.',
|
|
},
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 10,
|
|
completion_tokens: 20,
|
|
total_tokens: 30,
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
const result = (await client.beta.messages.create({
|
|
model: 'glm-5',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
})) as { content: Array<Record<string, unknown>> }
|
|
|
|
expect(result.content).toEqual([
|
|
{ type: 'thinking', thinking: 'I need to calculate this.' },
|
|
{ type: 'text', text: 'The answer is 42.' },
|
|
])
|
|
})
|
|
|
|
test('non-streaming: strips <think> tag block from assistant content', async () => {
|
|
globalThis.fetch = (async () => {
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-1',
|
|
model: 'gpt-5-mini',
|
|
choices: [
|
|
{
|
|
message: {
|
|
role: 'assistant',
|
|
content:
|
|
'<think>user wants a greeting, respond briefly</think>Hey! How can I help you today?',
|
|
},
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 10,
|
|
completion_tokens: 20,
|
|
total_tokens: 30,
|
|
},
|
|
}),
|
|
{ headers: { 'Content-Type': 'application/json' } },
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
const result = (await client.beta.messages.create({
|
|
model: 'gpt-5-mini',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'hey' }],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
})) as { content: Array<Record<string, unknown>> }
|
|
|
|
expect(result.content).toEqual([
|
|
{ type: 'text', text: 'Hey! How can I help you today?' },
|
|
])
|
|
})
|
|
|
|
test('streaming: thinking block closed before tool call', async () => {
|
|
globalThis.fetch = (async (_input, _init) => {
|
|
const chunks = makeStreamChunks([
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'glm-5',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: { role: 'assistant', reasoning_content: 'Thinking...' },
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'glm-5',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {
|
|
tool_calls: [
|
|
{
|
|
index: 0,
|
|
id: 'call-1',
|
|
type: 'function',
|
|
function: {
|
|
name: 'Bash',
|
|
arguments: '{"command":"ls"}',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'glm-5',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {},
|
|
finish_reason: 'tool_calls',
|
|
},
|
|
],
|
|
},
|
|
])
|
|
|
|
return makeSseResponse(chunks)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
const result = await client.beta.messages
|
|
.create({
|
|
model: 'glm-5',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'Run ls' }],
|
|
max_tokens: 64,
|
|
stream: true,
|
|
})
|
|
.withResponse()
|
|
|
|
const events: Array<Record<string, unknown>> = []
|
|
for await (const event of result.data) {
|
|
events.push(event)
|
|
}
|
|
|
|
const types = events.map(e => e.type)
|
|
|
|
const thinkingStartIdx = types.indexOf('content_block_start')
|
|
const firstStopIdx = types.indexOf('content_block_stop')
|
|
const toolStartIdx = types.indexOf(
|
|
'content_block_start',
|
|
thinkingStartIdx + 1,
|
|
)
|
|
|
|
expect(thinkingStartIdx).toBeGreaterThanOrEqual(0)
|
|
expect(firstStopIdx).toBeGreaterThan(thinkingStartIdx)
|
|
expect(toolStartIdx).toBeGreaterThan(firstStopIdx)
|
|
|
|
const thinkingStart = events[thinkingStartIdx] as {
|
|
content_block?: Record<string, unknown>
|
|
}
|
|
expect(thinkingStart?.content_block?.type).toBe('thinking')
|
|
})
|
|
|
|
test('streaming: strips <think> tag block from assistant content deltas', async () => {
|
|
globalThis.fetch = (async () => {
|
|
const chunks = makeStreamChunks([
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'gpt-5-mini',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {
|
|
role: 'assistant',
|
|
content:
|
|
'<think>user wants a greeting, respond briefly</think>Hey! How can I help you today?',
|
|
},
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'gpt-5-mini',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {},
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
},
|
|
])
|
|
|
|
return makeSseResponse(chunks)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
const result = await client.beta.messages
|
|
.create({
|
|
model: 'gpt-5-mini',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'hey' }],
|
|
max_tokens: 64,
|
|
stream: true,
|
|
})
|
|
.withResponse()
|
|
|
|
const textDeltas: string[] = []
|
|
for await (const event of result.data) {
|
|
const delta = (event as { delta?: { type?: string; text?: string } }).delta
|
|
if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
|
|
textDeltas.push(delta.text)
|
|
}
|
|
}
|
|
|
|
expect(textDeltas.join('')).toBe('Hey! How can I help you today?')
|
|
})
|
|
|
|
test('streaming: strips <think> tag split across multiple content chunks', async () => {
|
|
globalThis.fetch = (async () => {
|
|
const chunks = makeStreamChunks([
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'gpt-5-mini',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {
|
|
role: 'assistant',
|
|
content: '<think>user wants a greeting,',
|
|
},
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'gpt-5-mini',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {
|
|
content: ' respond briefly</th',
|
|
},
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'gpt-5-mini',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {
|
|
content: 'ink>Hey! How can I help you today?',
|
|
},
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'gpt-5-mini',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {},
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
},
|
|
])
|
|
|
|
return makeSseResponse(chunks)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
const result = await client.beta.messages
|
|
.create({
|
|
model: 'gpt-5-mini',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'hey' }],
|
|
max_tokens: 64,
|
|
stream: true,
|
|
})
|
|
.withResponse()
|
|
|
|
const textDeltas: string[] = []
|
|
for await (const event of result.data) {
|
|
const delta = (event as { delta?: { type?: string; text?: string } }).delta
|
|
if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
|
|
textDeltas.push(delta.text)
|
|
}
|
|
}
|
|
|
|
expect(textDeltas.join('')).toBe('Hey! How can I help you today?')
|
|
})
|
|
|
|
test('streaming: preserves prose without tags (no phrase-based false positive)', async () => {
|
|
// Regression: older phrase-based sanitizer would strip "I should..." prose.
|
|
// The tag-based approach leaves legitimate assistant output alone.
|
|
globalThis.fetch = (async () => {
|
|
const chunks = makeStreamChunks([
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'gpt-5-mini',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {
|
|
role: 'assistant',
|
|
content:
|
|
'I should note that the user role requires a briefly concise friendly response format.',
|
|
},
|
|
finish_reason: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion.chunk',
|
|
model: 'gpt-5-mini',
|
|
choices: [
|
|
{
|
|
index: 0,
|
|
delta: {},
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
},
|
|
])
|
|
|
|
return makeSseResponse(chunks)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
const result = await client.beta.messages
|
|
.create({
|
|
model: 'gpt-5-mini',
|
|
system: 'test system',
|
|
messages: [{ role: 'user', content: 'hey' }],
|
|
max_tokens: 64,
|
|
stream: true,
|
|
})
|
|
.withResponse()
|
|
|
|
const textDeltas: string[] = []
|
|
for await (const event of result.data) {
|
|
const delta = (event as { delta?: { type?: string; text?: string } }).delta
|
|
if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
|
|
textDeltas.push(delta.text)
|
|
}
|
|
}
|
|
|
|
expect(textDeltas.join('')).toBe(
|
|
'I should note that the user role requires a briefly concise friendly response format.',
|
|
)
|
|
})
|
|
|
|
test('classifies localhost transport failures with actionable category marker', async () => {
|
|
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
|
|
|
|
const transportError = Object.assign(new TypeError('fetch failed'), {
|
|
code: 'ECONNREFUSED',
|
|
})
|
|
|
|
globalThis.fetch = (async () => {
|
|
throw transportError
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
await expect(
|
|
client.beta.messages.create({
|
|
model: 'qwen2.5-coder:7b',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
}),
|
|
).rejects.toThrow('openai_category=connection_refused')
|
|
|
|
await expect(
|
|
client.beta.messages.create({
|
|
model: 'qwen2.5-coder:7b',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
}),
|
|
).rejects.toThrow('local server is running')
|
|
})
|
|
|
|
test('propagates AbortError without wrapping it as transport failure', async () => {
|
|
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
|
|
|
|
const abortError = new DOMException('The operation was aborted.', 'AbortError')
|
|
globalThis.fetch = (async () => {
|
|
throw abortError
|
|
}) as FetchType
|
|
|
|
const controller = new AbortController()
|
|
controller.abort()
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
await expect(
|
|
client.beta.messages.create(
|
|
{
|
|
model: 'qwen2.5-coder:7b',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
},
|
|
{ signal: controller.signal },
|
|
),
|
|
).rejects.toBe(abortError)
|
|
})
|
|
|
|
test('classifies chat-completions endpoint 404 failures with endpoint_not_found marker', async () => {
|
|
process.env.OPENAI_BASE_URL = 'http://localhost:11434'
|
|
|
|
globalThis.fetch = (async () =>
|
|
new Response('Not Found', {
|
|
status: 404,
|
|
headers: {
|
|
'Content-Type': 'text/plain',
|
|
},
|
|
})) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
await expect(
|
|
client.beta.messages.create({
|
|
model: 'qwen2.5-coder:7b',
|
|
messages: [{ role: 'user', content: 'hello' }],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
}),
|
|
).rejects.toThrow('openai_category=endpoint_not_found')
|
|
})
|
|
|
|
test('preserves valid tool_result and drops orphan tool_result', async () => {
|
|
let requestBody: Record<string, unknown> | undefined
|
|
|
|
globalThis.fetch = (async (_input, init) => {
|
|
requestBody = JSON.parse(String(init?.body))
|
|
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-1',
|
|
model: 'mistral-large-latest',
|
|
choices: [
|
|
{
|
|
message: {
|
|
role: 'assistant',
|
|
content: 'done',
|
|
},
|
|
finish_reason: 'stop',
|
|
},
|
|
],
|
|
usage: {
|
|
prompt_tokens: 12,
|
|
completion_tokens: 4,
|
|
total_tokens: 16,
|
|
},
|
|
}),
|
|
{
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
},
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({}) as OpenAIShimClient
|
|
|
|
await client.beta.messages.create({
|
|
model: 'mistral-large-latest',
|
|
system: 'test system',
|
|
messages: [
|
|
{ role: 'user', content: 'Search and then I will interrupt' },
|
|
{
|
|
role: 'assistant',
|
|
content: [
|
|
{
|
|
type: 'tool_use',
|
|
id: 'valid_call_1',
|
|
name: 'Search',
|
|
input: { query: 'openclaude' },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{
|
|
type: 'tool_result',
|
|
tool_use_id: 'valid_call_1',
|
|
content: 'Found it!',
|
|
},
|
|
{
|
|
type: 'tool_result',
|
|
tool_use_id: 'orphan_call_2',
|
|
content: 'Interrupted result',
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: 'What happened?',
|
|
}
|
|
],
|
|
},
|
|
],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
})
|
|
|
|
const messages = requestBody?.messages as Array<Record<string, unknown>>
|
|
|
|
// Should have: system, user, assistant (tool_use), tool (valid_call_1), user
|
|
// Should NOT have: tool (orphan_call_2)
|
|
|
|
const toolMessages = messages.filter(m => m.role === 'tool')
|
|
expect(toolMessages.length).toBe(1)
|
|
expect(toolMessages[0].tool_call_id).toBe('valid_call_1')
|
|
|
|
const orphanMessage = toolMessages.find(m => m.tool_call_id === 'orphan_call_2')
|
|
expect(orphanMessage).toBeUndefined()
|
|
})
|
|
|
|
test('request body does not contain store field for local providers', async () => {
|
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
|
process.env.OPENAI_BASE_URL = 'http://localhost:11434/v1'
|
|
let requestBody: Record<string, unknown> | undefined
|
|
|
|
globalThis.fetch = (async (_input, init) => {
|
|
requestBody = JSON.parse(String(init?.body))
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion',
|
|
model: 'test-model',
|
|
choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
|
|
usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 },
|
|
}),
|
|
{ headers: { 'Content-Type': 'application/json' } },
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({ defaultHeaders: {} }) as unknown as OpenAIShimClient
|
|
await client.beta.messages.create({
|
|
model: 'some-model',
|
|
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
})
|
|
|
|
expect(requestBody).toBeDefined()
|
|
expect('store' in requestBody!).toBe(false)
|
|
})
|
|
|
|
test('preserves reasoning_content on assistant messages with tool_calls during replay', async () => {
|
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
|
let requestBody: Record<string, unknown> | undefined
|
|
|
|
globalThis.fetch = (async (_input, init) => {
|
|
requestBody = JSON.parse(String(init?.body))
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion',
|
|
model: 'test-model',
|
|
choices: [{ index: 0, message: { role: 'assistant', content: 'done' }, finish_reason: 'stop' }],
|
|
usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 },
|
|
}),
|
|
{ headers: { 'Content-Type': 'application/json' } },
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({ defaultHeaders: {} }) as unknown as OpenAIShimClient
|
|
await client.beta.messages.create({
|
|
model: 'kimi-k2.5',
|
|
messages: [
|
|
{ role: 'user', content: [{ type: 'text', text: 'read file' }] },
|
|
{
|
|
role: 'assistant',
|
|
content: [
|
|
{ type: 'thinking', thinking: 'I should use the read tool' },
|
|
{ type: 'tool_use', id: 'call_1', name: 'Read', input: { file_path: 'test.ts' } },
|
|
],
|
|
},
|
|
{
|
|
role: 'user',
|
|
content: [
|
|
{ type: 'tool_result', tool_use_id: 'call_1', content: 'file contents here' },
|
|
],
|
|
},
|
|
],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
})
|
|
|
|
const messages = requestBody?.messages as Array<Record<string, unknown>>
|
|
const assistantMsg = messages.find(m => m.role === 'assistant' && m.tool_calls)
|
|
expect(assistantMsg).toBeDefined()
|
|
expect(assistantMsg!.reasoning_content).toBe('I should use the read tool')
|
|
})
|
|
|
|
test('does not add reasoning_content on assistant messages without tool_calls', async () => {
|
|
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
|
let requestBody: Record<string, unknown> | undefined
|
|
|
|
globalThis.fetch = (async (_input, init) => {
|
|
requestBody = JSON.parse(String(init?.body))
|
|
return new Response(
|
|
JSON.stringify({
|
|
id: 'chatcmpl-1',
|
|
object: 'chat.completion',
|
|
model: 'test-model',
|
|
choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
|
|
usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 },
|
|
}),
|
|
{ headers: { 'Content-Type': 'application/json' } },
|
|
)
|
|
}) as FetchType
|
|
|
|
const client = createOpenAIShimClient({ defaultHeaders: {} }) as unknown as OpenAIShimClient
|
|
await client.beta.messages.create({
|
|
model: 'deepseek-reasoner',
|
|
messages: [
|
|
{ role: 'user', content: [{ type: 'text', text: 'explain' }] },
|
|
{
|
|
role: 'assistant',
|
|
content: [
|
|
{ type: 'thinking', thinking: 'Let me think about this' },
|
|
{ type: 'text', text: 'Here is the explanation' },
|
|
],
|
|
},
|
|
{ role: 'user', content: [{ type: 'text', text: 'thanks' }] },
|
|
],
|
|
max_tokens: 64,
|
|
stream: false,
|
|
})
|
|
|
|
const messages = requestBody?.messages as Array<Record<string, unknown>>
|
|
const assistantMsg = messages.find(m => m.role === 'assistant' && !m.tool_calls)
|
|
expect(assistantMsg).toBeDefined()
|
|
expect(assistantMsg!.reasoning_content).toBeUndefined()
|
|
}) |