Files
claude-code-haha/src/server/__tests__/providers.test.ts
T
程序员阿江-Relakkes d0ba3d3bd0 fix(server): send images to OpenAI Chat models until the upstream rejects them (#1458)
The OpenAI Chat proxy decided image support from host and model names:
opencode.ai and DeepSeek hosts replaced every image with a text-only
placeholder unless the model id contained "vision" or was allowlisted.
Each new vision model on OpenCode Go (Kimi K3, Space Bunny, DeepSeek Flash)
therefore told users the endpoint could not read images.

Offer images to every model instead. When the upstream refuses a request
that carried images with a recognizable image-input rejection, resend it
once without images and, only if that succeeds, remember the endpoint and
model as text-only for 30 minutes and record an info diagnostic. Failures
about a particular image (format, MIME type, size, animation, image count)
surface unchanged so one bad image cannot disable images for the model.

The rejection matcher moves to a shared module used by the CLI error mapper
and the proxy, and now also recognizes vLLM and OpenAI wording.
2026-10-05 21:12:25 +08:00

4143 lines
161 KiB
TypeScript

/**
* Unit tests for ProviderService and Providers REST API
*/
import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test'
import * as fs from 'fs/promises'
import * as path from 'path'
import * as os from 'os'
import { ProviderService } from '../services/providerService.js'
import { readActiveProviderManagedEnv } from '../services/providerRuntimeEnv.js'
import { handleProvidersApi } from '../api/providers.js'
import { handleProxyRequest } from '../proxy/handler.js'
import { resetOpenAIChatImageSupportForTests } from '../proxy/openaiChatImageSupport.js'
import { diagnosticsService } from '../services/diagnosticsService.js'
import {
clearTraceCaptureStateForTests,
drainTraceCaptureForTests,
setTraceAppendBeforeWriteHookForTests,
traceCaptureService,
} from '../services/traceCaptureService.js'
import type { CreateProviderInput } from '../types/provider.js'
import { buildComputerUseTools } from '../../vendor/computer-use-mcp/tools.js'
// ─── Test helpers ─────────────────────────────────────────────────────────────
let tmpDir: string
let originalConfigDir: string | undefined
let originalHome: string | undefined
async function setup() {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'provider-test-'))
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
originalHome = process.env.HOME
process.env.CLAUDE_CONFIG_DIR = tmpDir
process.env.HOME = tmpDir
clearTraceCaptureStateForTests()
resetOpenAIChatImageSupportForTests()
}
async function teardown() {
await drainTraceCaptureForTests()
clearTraceCaptureStateForTests()
// Diagnostics resolve their path when the queued write runs; flush them
// while CLAUDE_CONFIG_DIR still points at the temp dir.
await diagnosticsService.drainForMigration()
if (originalConfigDir !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
} else {
delete process.env.CLAUDE_CONFIG_DIR
}
if (originalHome !== undefined) {
process.env.HOME = originalHome
} else {
delete process.env.HOME
}
// The background trace projection may still hold a handle briefly (first
// index builds are slower); retry the removal instead of failing the test
// on Windows.
for (let attempt = 0; attempt < 60; attempt++) {
try {
await fs.rm(tmpDir, { recursive: true, force: true })
return
} catch {
await new Promise(resolve => setTimeout(resolve, 25))
}
}
await fs.rm(tmpDir, { recursive: true, force: true })
}
/** Create a mock Request */
function makeRequest(
method: string,
urlStr: string,
body?: Record<string, unknown>,
): { req: Request; url: URL; segments: string[] } {
const url = new URL(urlStr, 'http://localhost:3456')
const init: RequestInit = { method }
if (body) {
init.headers = { 'Content-Type': 'application/json' }
init.body = JSON.stringify(body)
}
const req = new Request(url.toString(), init)
const segments = url.pathname.split('/').filter(Boolean)
return { req, url, segments }
}
/** A sample provider input for reuse across tests */
function sampleInput(overrides?: Partial<CreateProviderInput>): CreateProviderInput {
return {
presetId: 'custom',
name: 'Test Provider',
baseUrl: 'https://api.example.com',
apiKey: 'sk-test-key-123',
apiFormat: 'anthropic',
models: {
main: 'model-main',
haiku: 'model-haiku',
sonnet: 'model-sonnet',
opus: 'model-opus',
},
...overrides,
}
}
/** Read the settings.json written to the temp config dir */
async function readSettings(): Promise<Record<string, unknown>> {
const raw = await fs.readFile(path.join(tmpDir, 'cc-haha', 'settings.json'), 'utf-8')
return JSON.parse(raw) as Record<string, unknown>
}
/** Read the providers.json written to the temp config dir */
async function readProvidersConfig(): Promise<Record<string, unknown>> {
const raw = await fs.readFile(path.join(tmpDir, 'cc-haha', 'providers.json'), 'utf-8')
return JSON.parse(raw) as Record<string, unknown>
}
async function waitForCompletedProxyTrace(sessionId: string) {
for (let attempt = 0; attempt < 100; attempt += 1) {
const trace = await traceCaptureService.getSessionTrace(sessionId)
if (
trace.calls.some((call) => call.response) &&
trace.events.some((event) => event.phase === 'upstream_fetch_completed')
) {
return trace
}
await new Promise((resolve) => setTimeout(resolve, 5))
}
return traceCaptureService.getSessionTrace(sessionId)
}
function blockNextTraceAppend() {
let releaseWrite: () => void = () => {}
const blockedWrite = new Promise<void>((resolve) => {
releaseWrite = resolve
})
let signalBlocked: () => void = () => {}
const writeBlocked = new Promise<void>((resolve) => {
signalBlocked = resolve
})
setTraceAppendBeforeWriteHookForTests(async () => {
setTraceAppendBeforeWriteHookForTests(null)
signalBlocked()
await blockedWrite
})
return { releaseWrite, writeBlocked }
}
async function settlesBeforeBlockedTraceWrite<T>(promise: Promise<T>): Promise<T | null> {
return Promise.race([
promise,
// The trace hook is already blocked, so this timeout is only a failure
// bound. Keep it generous enough that a busy CI worker cannot masquerade
// as response/trace coupling.
new Promise<null>((resolve) => setTimeout(() => resolve(null), 1_000)),
])
}
async function captureOpenAIChatRequest(options: {
contentSource?: 'user' | 'tool'
baseUrl: string
model: string
content: Array<Record<string, unknown>>
}): Promise<Record<string, unknown>> {
const originalFetch = globalThis.fetch
const calls: Array<{ body: Record<string, unknown> }> = []
globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
calls.push({ body: JSON.parse(String(init?.body)) as Record<string, unknown> })
return new Response(JSON.stringify({
id: 'chatcmpl-computer-use',
object: 'chat.completion',
created: 0,
model: options.model,
choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as typeof fetch
try {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
apiFormat: 'openai_chat',
baseUrl: options.baseUrl,
models: {
main: options.model,
haiku: options.model,
sonnet: options.model,
opus: options.model,
},
}))
await svc.activateProvider(provider.id)
const req = new Request('http://localhost:3456/proxy/v1/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: options.model,
max_tokens: 64,
messages: [{
role: 'user',
content: options.contentSource === 'user' ? options.content : [{
type: 'tool_result',
tool_use_id: 'computer_1',
content: options.content,
}],
}],
}),
})
const res = await handleProxyRequest(req, new URL(req.url))
expect(res.status).toBe(200)
expect(calls).toHaveLength(1)
return calls[0].body
} finally {
globalThis.fetch = originalFetch
}
}
// =============================================================================
// ProviderService
// =============================================================================
describe('ProviderService', () => {
beforeEach(setup)
afterEach(teardown)
// ─── listProviders ───────────────────────────────────────────────────────
describe('listProviders', () => {
test('should return empty array when no providers exist', async () => {
const svc = new ProviderService()
const result = await svc.listProviders()
expect(result).toEqual({
providers: [],
activeId: null,
providerOrder: ['claude-official', 'openai-official', 'grok-official'],
})
})
test('should recover from a malformed providers index after an upgrade', async () => {
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
await fs.writeFile(path.join(tmpDir, 'cc-haha', 'providers.json'), '{not json', 'utf-8')
const svc = new ProviderService()
const result = await svc.listProviders()
const files = await fs.readdir(path.join(tmpDir, 'cc-haha'))
expect(result).toEqual({
providers: [],
activeId: null,
providerOrder: ['claude-official', 'openai-official', 'grok-official'],
})
expect(files.some((name) => name.startsWith('providers.json.invalid-'))).toBe(true)
})
test('should normalize a legacy activeProviderId field', async () => {
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
const provider = {
id: 'legacy-provider',
...sampleInput({ name: 'Legacy Provider' }),
}
await fs.writeFile(
path.join(tmpDir, 'cc-haha', 'providers.json'),
JSON.stringify({ activeProviderId: provider.id, providers: [provider] }),
'utf-8',
)
const svc = new ProviderService()
const result = await svc.listProviders()
expect(result.activeId).toBe(provider.id)
expect(result.providers).toHaveLength(1)
expect(result.providers[0].name).toBe('Legacy Provider')
})
test('should return all added providers', async () => {
const svc = new ProviderService()
await svc.addProvider(sampleInput({ name: 'Provider A' }))
await svc.addProvider(sampleInput({ name: 'Provider B' }))
const { providers, activeId } = await svc.listProviders()
expect(providers).toHaveLength(2)
expect(providers[0].name).toBe('Provider A')
expect(providers[1].name).toBe('Provider B')
expect(activeId).toBeNull()
})
})
// ─── addProvider ─────────────────────────────────────────────────────────
describe('addProvider', () => {
test('should add a provider and return it with generated fields', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput())
expect(provider.id).toBeDefined()
expect(provider.name).toBe('Test Provider')
expect(provider.baseUrl).toBe('https://api.example.com')
expect(provider.apiKey).toBe('sk-test-key-123')
expect(provider.models.main).toBe('model-main')
})
test('should normalize empty model mappings to the main model when adding a provider', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
models: {
main: 'gpt-5.5',
haiku: '',
sonnet: ' ',
opus: '',
},
}))
expect(provider.models).toEqual({
main: 'gpt-5.5',
haiku: 'gpt-5.5',
sonnet: 'gpt-5.5',
opus: 'gpt-5.5',
})
const config = await readProvidersConfig()
expect((config.providers as Array<{ models: unknown }>)[0]?.models).toEqual(provider.models)
})
test('new providers should not be auto-activated', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput())
expect(provider.id).toBeDefined()
const { activeId } = await svc.listProviders()
expect(activeId).toBeNull()
})
test('adding a provider should not sync settings until activated', async () => {
const svc = new ProviderService()
await svc.addProvider(sampleInput())
await expect(fs.readFile(path.join(tmpDir, 'cc-haha', 'settings.json'), 'utf-8')).rejects.toThrow()
})
test('custom providers keep thinking compatibility without narrowing CLI effort', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
models: {
main: 'deepseek-ai/DeepSeek-V4-Pro',
haiku: 'deepseek-ai/DeepSeek-V4-Pro',
sonnet: 'deepseek-ai/DeepSeek-V4-Pro',
opus: 'deepseek-ai/DeepSeek-V4-Pro',
},
}))
await svc.activateProvider(provider.id)
const settings = await readSettings()
const env = settings.env as Record<string, string>
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('deepseek-ai/DeepSeek-V4-Pro')
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES).toBe(
'thinking,effort,adaptive_thinking,xhigh_effort,max_effort',
)
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES).toBe(
'thinking,effort,adaptive_thinking,xhigh_effort,max_effort',
)
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES).toBe(
'thinking,effort,adaptive_thinking,xhigh_effort,max_effort',
)
})
test('Xiaomi MiMo custom Anthropic providers keep the compatibility effort fallback', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
name: 'Xiaomi MiMo Custom',
baseUrl: 'https://token-plan-sgp.xiaomimimo.com/anthropic',
models: {
main: 'mimo-v2.5-pro[1m]',
haiku: 'mimo-v2.5-pro[1m]',
sonnet: 'mimo-v2.5-pro[1m]',
opus: 'mimo-v2.5-pro[1m]',
},
}))
await svc.activateProvider(provider.id)
const settings = await readSettings()
const env = settings.env as Record<string, string>
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES).toBe(
'thinking,effort,adaptive_thinking,xhigh_effort,max_effort',
)
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES).toBe(
'thinking,effort,adaptive_thinking,xhigh_effort,max_effort',
)
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES).toBe(
'thinking,effort,adaptive_thinking,xhigh_effort,max_effort',
)
})
test('custom providers can mark main and role models as 1M-capable', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
models: {
main: 'claude-sonnet-4-6',
haiku: 'claude-haiku-4-5',
sonnet: 'claude-sonnet-4-6',
opus: 'claude-opus-4-7',
},
model1mSupport: {
main: true,
haiku: false,
sonnet: true,
opus: true,
},
}))
await svc.activateProvider(provider.id)
const settings = await readSettings()
const env = settings.env as Record<string, string>
expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6[1m]')
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5')
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6[1m]')
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-7[1m]')
const runtimeEnv = await svc.getProviderRuntimeEnv(provider.id)
expect(runtimeEnv.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6[1m]')
expect(runtimeEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5')
expect(runtimeEnv.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6[1m]')
expect(runtimeEnv.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-7[1m]')
})
test('preserves a Fable-only 1M setting after saving and reloading a provider', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
models: {
main: 'claude-sonnet-4-6',
fable: 'claude-opus-4-7',
haiku: 'claude-haiku-4-5',
sonnet: 'claude-sonnet-4-6',
opus: 'claude-opus-4-7',
},
model1mSupport: {
main: false,
fable: true,
haiku: false,
sonnet: false,
opus: false,
},
}))
const initiallySaved = await readProvidersConfig()
const initialProvider = (initiallySaved.providers as Array<{ model1mSupport?: { fable?: boolean } }>)[0]
expect(initialProvider.model1mSupport?.fable).toBe(true)
await svc.activateProvider(provider.id)
const saved = await readProvidersConfig()
const savedProvider = (saved.providers as Array<{ model1mSupport?: { fable?: boolean } }>)[0]
expect(savedProvider.model1mSupport?.fable).toBe(true)
const reloaded = await new ProviderService().getProvider(provider.id)
expect(reloaded.model1mSupport?.fable).toBe(true)
expect(readActiveProviderManagedEnv(tmpDir)?.ANTHROPIC_DEFAULT_FABLE_MODEL)
.toBe('claude-opus-4-7[1m]')
})
test('DeepSeek preset follows the global thinking toggle instead of forcing disabled thinking', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
presetId: 'deepseek',
name: 'DeepSeek',
baseUrl: 'https://api.deepseek.com/anthropic',
models: {
main: 'deepseek-v4-pro',
haiku: 'deepseek-v4-flash',
sonnet: 'deepseek-v4-pro',
opus: 'deepseek-v4-pro',
},
}))
await svc.activateProvider(provider.id)
const settings = await readSettings()
const env = settings.env as Record<string, string>
expect(env.CC_HAHA_SEND_DISABLED_THINKING).toBeUndefined()
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL_SUPPORTED_CAPABILITIES).toBe(
'thinking,effort,adaptive_thinking,xhigh_effort,max_effort',
)
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL_SUPPORTED_CAPABILITIES).toBe(
'thinking,effort,adaptive_thinking,xhigh_effort,max_effort',
)
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL_SUPPORTED_CAPABILITIES).toBe(
'thinking,effort,adaptive_thinking,xhigh_effort,max_effort',
)
})
test('adding additional providers should keep activeId unchanged', async () => {
const svc = new ProviderService()
await svc.addProvider(sampleInput({ name: 'First' }))
const second = await svc.addProvider(sampleInput({ name: 'Second' }))
expect(second.id).toBeDefined()
const { activeId } = await svc.listProviders()
expect(activeId).toBeNull()
})
test('should preserve optional notes field', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({ notes: 'dev environment' }))
expect(provider.notes).toBe('dev environment')
})
test('should preserve optional auto compact window', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({ autoCompactWindow: 64000 }))
expect(provider.autoCompactWindow).toBe(64000)
})
test('should preserve optional model context windows', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
modelContextWindows: {
'model-main': 300000,
'model-haiku': 128000,
},
}))
expect(provider.modelContextWindows).toEqual({
'model-main': 300000,
'model-haiku': 128000,
})
})
test('should persist an optional image provider without placing it in the skill', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
imageGeneration: {
model: 'image-model',
baseUrl: 'https://images.example.test/v1',
apiKey: 'image-secret',
},
}))
expect(provider.imageGeneration).toEqual({
model: 'image-model',
baseUrl: 'https://images.example.test/v1',
apiKey: 'image-secret',
})
const config = await readProvidersConfig()
expect((config.providers as Array<{ imageGeneration?: unknown }>)[0]?.imageGeneration)
.toEqual(provider.imageGeneration)
})
})
// ─── getProvider ─────────────────────────────────────────────────────────
describe('getProvider', () => {
test('should return the provider by id', async () => {
const svc = new ProviderService()
const added = await svc.addProvider(sampleInput())
const fetched = await svc.getProvider(added.id)
expect(fetched.id).toBe(added.id)
expect(fetched.name).toBe(added.name)
})
describe('ChatGPT Official provider metadata', () => {
test('normalizes the built-in ChatGPT provider as an active provider id', async () => {
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
await fs.writeFile(
path.join(tmpDir, 'cc-haha', 'providers.json'),
JSON.stringify({ activeId: 'openai-official', providers: [] }),
'utf-8',
)
const svc = new ProviderService()
const result = await svc.listProviders()
expect(result.activeId).toBe('openai-official')
expect(result.providers).toEqual([])
})
test('returns built-in ChatGPT provider metadata without persisting secrets', async () => {
const svc = new ProviderService()
const provider = await svc.getProvider('openai-official')
expect(provider).toMatchObject({
id: 'openai-official',
presetId: 'openai-official',
name: 'ChatGPT Official',
apiKey: '',
apiFormat: 'openai_responses',
runtimeKind: 'openai_oauth',
models: {
main: 'gpt-6-sol',
haiku: 'gpt-6-luna',
sonnet: 'gpt-6-sol',
opus: 'gpt-6-astra',
},
})
})
test('persists OAuth model mappings and restores them when the provider is activated again', async () => {
const svc = new ProviderService()
const configured = {
main: 'gpt-6-astra',
fable: 'gpt-6-luna',
haiku: 'gpt-6-luna',
sonnet: 'gpt-6-astra',
opus: 'gpt-6-astra',
}
await svc.updateOfficialProviderModels('openai-official', configured)
await svc.activateProvider('openai-official')
expect(await svc.getOfficialProviderModels('openai-official')).toEqual(configured)
expect((await readProvidersConfig()).officialProviderModels).toEqual({
'openai-official': configured,
})
const settings = await readSettings()
expect(settings.model).toBe('gpt-6-astra')
expect(settings.env).toMatchObject({
ANTHROPIC_MODEL: 'gpt-6-astra',
ANTHROPIC_DEFAULT_FABLE_MODEL: 'gpt-6-luna',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-6-luna',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-6-astra',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-6-astra',
})
expect(readActiveProviderManagedEnv(tmpDir)).toMatchObject({
ANTHROPIC_MODEL: 'gpt-6-astra',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-6-astra',
})
})
test('updates Claude OAuth aliases without replacing unrelated user env', async () => {
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({ env: { USER_CUSTOM_ENV: 'keep-me' }, futureSetting: true }),
)
const svc = new ProviderService()
await svc.updateOfficialProviderModels('claude-official', {
main: 'claude-sonnet-5',
fable: 'claude-fable-5-1',
haiku: 'claude-haiku-4-5',
sonnet: 'claude-sonnet-5',
opus: 'claude-opus-5-5',
})
const userSettings = JSON.parse(
await fs.readFile(path.join(tmpDir, 'settings.json'), 'utf8'),
) as Record<string, unknown>
expect(userSettings).toMatchObject({
model: 'claude-sonnet-5',
futureSetting: true,
env: {
USER_CUSTOM_ENV: 'keep-me',
ANTHROPIC_MODEL: 'claude-sonnet-5',
ANTHROPIC_DEFAULT_FABLE_MODEL: 'claude-fable-5-1',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-5-5',
},
})
})
test('activating ChatGPT Official writes OpenAI OAuth runtime env without Anthropic auth or proxy env', async () => {
const svc = new ProviderService()
await svc.activateProvider('openai-official')
const config = await readProvidersConfig()
const settings = await readSettings()
expect(config.activeId).toBe('openai-official')
const env = settings.env as Record<string, string>
expect(env.CC_HAHA_OPENAI_OAUTH_PROVIDER).toBe('1')
expect(env.OPENAI_CODEX_OAUTH_FILE).toBe(
path.join(tmpDir, 'cc-haha', 'openai-oauth.json'),
)
expect(env.ANTHROPIC_MODEL).toBe('gpt-6-sol')
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-6-luna')
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-6-sol')
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-6-astra')
expect(typeof env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS).toBe('string')
expect(JSON.parse(env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toEqual({
'gpt-6-sol': 258_400,
'gpt-6.1-sol': 258_400,
'gpt-6-luna': 258_400,
'gpt-5.6-sol': 353_400,
'gpt-5.6-terra': 353_400,
'gpt-5.6-luna': 353_400,
'gpt-5.3-codex': 258_400,
'gpt-5.4': 950_000,
'gpt-5.5': 258_400,
'gpt-5.4-mini': 258_400,
'gpt-6-astra': 258_400,
})
expect(env.ANTHROPIC_BASE_URL).toBeUndefined()
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
})
test('activating ChatGPT Official clears stale managed provider env', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
apiFormat: 'openai_responses',
baseUrl: 'https://api.example.com/openai',
models: {
main: 'provider-main',
haiku: 'provider-haiku',
sonnet: 'provider-sonnet',
opus: 'provider-opus',
},
}))
await svc.activateProvider(provider.id)
expect(((await readSettings()).env as Record<string, string>).ANTHROPIC_BASE_URL).toContain('/proxy')
await svc.activateProvider('openai-official')
const settings = await readSettings()
const env = settings.env as Record<string, string>
expect(env.CC_HAHA_OPENAI_OAUTH_PROVIDER).toBe('1')
expect(env.OPENAI_CODEX_OAUTH_FILE).toBe(
path.join(tmpDir, 'cc-haha', 'openai-oauth.json'),
)
expect(env.ANTHROPIC_BASE_URL).toBeUndefined()
expect(env.ANTHROPIC_API_KEY).toBeUndefined()
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
})
test('auth status reports ChatGPT Official from the desktop OpenAI token file', async () => {
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
await fs.writeFile(
path.join(tmpDir, 'cc-haha', 'openai-oauth.json'),
JSON.stringify({
accessToken: 'openai-access',
refreshToken: 'openai-refresh',
expiresAt: Date.now() + 60 * 60_000,
email: 'user@example.com',
accountId: 'acct_123',
}),
'utf-8',
)
const svc = new ProviderService()
await svc.activateProvider('openai-official')
await expect(svc.checkAuthStatus()).resolves.toMatchObject({
hasAuth: true,
source: 'openai-oauth',
activeProvider: 'ChatGPT Official',
})
})
test('auth status reports Claude Official from the desktop Claude token file', async () => {
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
await fs.writeFile(
path.join(tmpDir, 'cc-haha', 'oauth.json'),
JSON.stringify({
accessToken: 'claude-access',
refreshToken: 'claude-refresh',
expiresAt: Date.now() + 60 * 60_000,
scopes: [],
subscriptionType: 'pro',
}),
'utf-8',
)
const svc = new ProviderService()
await expect(svc.checkAuthStatus()).resolves.toMatchObject({
hasAuth: true,
source: 'claude-oauth',
activeProvider: 'Claude Official',
})
})
test('auth status reports ChatGPT Official as unauthenticated when the OpenAI token file is missing', async () => {
const svc = new ProviderService()
await svc.activateProvider('openai-official')
await expect(svc.checkAuthStatus()).resolves.toMatchObject({
hasAuth: false,
source: 'none',
activeProvider: 'ChatGPT Official',
})
})
test('activating another provider clears ChatGPT Official runtime markers', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput())
await svc.activateProvider('openai-official')
await svc.activateProvider(provider.id)
const env = (await readSettings()).env as Record<string, string>
expect(env.CC_HAHA_OPENAI_OAUTH_PROVIDER).toBeUndefined()
expect(env.OPENAI_CODEX_OAUTH_FILE).toBeUndefined()
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.example.com')
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-test-key-123')
})
})
describe('Grok Official provider metadata', () => {
test('normalizes the built-in Grok provider and appends it to legacy provider order', async () => {
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
await fs.writeFile(
path.join(tmpDir, 'cc-haha', 'providers.json'),
JSON.stringify({
activeId: 'grok-official',
providers: [],
providerOrder: ['claude-official', 'openai-official'],
}),
'utf-8',
)
const svc = new ProviderService()
const result = await svc.listProviders()
expect(result.activeId).toBe('grok-official')
expect(result.providers).toEqual([])
expect(result.providerOrder).toEqual([
'claude-official',
'openai-official',
'grok-official',
])
})
test('returns and activates built-in Grok metadata while clearing OpenAI OAuth runtime env', async () => {
const svc = new ProviderService()
const provider = await svc.getProvider('grok-official')
expect(provider).toMatchObject({
id: 'grok-official',
presetId: 'grok-official',
name: 'Grok Official',
apiKey: '',
apiFormat: 'openai_chat',
runtimeKind: 'grok_oauth',
models: {
main: 'grok-4.7',
haiku: 'grok-4.7',
sonnet: 'grok-4.7',
opus: 'grok-4.7',
},
})
await svc.activateProvider('openai-official')
await svc.activateProvider('grok-official')
const config = await readProvidersConfig()
const env = (await readSettings()).env as Record<string, string>
expect(config.activeId).toBe('grok-official')
expect(env.CC_HAHA_GROK_OAUTH_PROVIDER).toBe('1')
expect(env.GROK_OAUTH_FILE).toBe(
path.join(tmpDir, 'cc-haha', 'grok-oauth.json'),
)
expect(env.ANTHROPIC_MODEL).toBe('grok-4.7')
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('grok-4.7')
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('grok-4.7')
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('grok-4.7')
expect(env.CC_HAHA_OPENAI_OAUTH_PROVIDER).toBeUndefined()
expect(env.OPENAI_CODEX_OAUTH_FILE).toBeUndefined()
})
test('auth status reports Grok Official from the isolated Grok token file', async () => {
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
await fs.writeFile(
path.join(tmpDir, 'cc-haha', 'grok-oauth.json'),
JSON.stringify({
accessToken: 'grok-access',
refreshToken: 'grok-refresh',
expiresAt: Date.now() + 60 * 60_000,
email: 'grok@example.com',
clientId: 'grok-client',
}),
'utf-8',
)
const svc = new ProviderService()
await svc.activateProvider('grok-official')
await expect(svc.checkAuthStatus()).resolves.toMatchObject({
hasAuth: true,
source: 'grok-oauth',
activeProvider: 'Grok Official',
})
})
test('auth status reports Grok Official as unauthenticated without an isolated token file', async () => {
const svc = new ProviderService()
await svc.activateProvider('grok-official')
await expect(svc.checkAuthStatus()).resolves.toMatchObject({
hasAuth: false,
source: 'none',
activeProvider: 'Grok Official',
})
})
})
test('should throw 404 for non-existent id', async () => {
const svc = new ProviderService()
try {
await svc.getProvider('non-existent-id')
expect(true).toBe(false) // should not reach here
} catch (err: unknown) {
const apiErr = err as { statusCode: number }
expect(apiErr.statusCode).toBe(404)
}
})
})
// ─── updateProvider ──────────────────────────────────────────────────────
describe('updateProvider', () => {
test('should update provider fields', async () => {
const svc = new ProviderService()
const added = await svc.addProvider(sampleInput())
const updated = await svc.updateProvider(added.id, {
name: 'Updated Name',
baseUrl: 'https://new-api.example.com',
})
expect(updated.name).toBe('Updated Name')
expect(updated.baseUrl).toBe('https://new-api.example.com')
// unchanged fields preserved
expect(updated.apiKey).toBe('sk-test-key-123')
})
test('should throw 404 for non-existent provider', async () => {
const svc = new ProviderService()
try {
await svc.updateProvider('non-existent-id', { name: 'X' })
expect(true).toBe(false)
} catch (err: unknown) {
const apiErr = err as { statusCode: number }
expect(apiErr.statusCode).toBe(404)
}
})
test('updating active provider should re-sync settings.json', async () => {
const svc = new ProviderService()
const added = await svc.addProvider(sampleInput())
await svc.activateProvider(added.id)
await svc.updateProvider(added.id, {
baseUrl: 'https://new-api.example.com',
apiKey: 'sk-new-key',
})
const settings = await readSettings()
const env = settings.env as Record<string, string>
expect(env.ANTHROPIC_BASE_URL).toBe('https://new-api.example.com')
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-new-key')
expect(env.ANTHROPIC_API_KEY).toBe('')
expect(env.ANTHROPIC_MODEL).toBe('model-main')
})
test('editing an existing provider persists supportsNestedToolResultMedia and reroutes it through the proxy', async () => {
const svc = new ProviderService()
const added = await svc.addProvider(sampleInput())
await svc.activateProvider(added.id)
// Default: nested media preserved, direct connection.
let settings = await readSettings()
let env = settings.env as Record<string, string>
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.example.com')
const updated = await svc.updateProvider(added.id, { supportsNestedToolResultMedia: false })
expect(updated.supportsNestedToolResultMedia).toBe(false)
settings = await readSettings()
env = settings.env as Record<string, string>
expect(env.ANTHROPIC_BASE_URL).toContain('127.0.0.1')
expect(env.ANTHROPIC_API_KEY).toBe('proxy-managed')
// Editing back to nested media restores the direct connection.
const reverted = await svc.updateProvider(added.id, { supportsNestedToolResultMedia: true })
expect(reverted.supportsNestedToolResultMedia).toBe(true)
settings = await readSettings()
env = settings.env as Record<string, string>
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.example.com')
})
test('updating active provider should override and clear auto compact window', async () => {
const svc = new ProviderService()
const added = await svc.addProvider(sampleInput({ autoCompactWindow: 64000 }))
await svc.activateProvider(added.id)
let settings = await readSettings()
let env = settings.env as Record<string, string>
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('64000')
await svc.updateProvider(added.id, { autoCompactWindow: 32000 })
settings = await readSettings()
env = settings.env as Record<string, string>
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('32000')
await svc.updateProvider(added.id, { autoCompactWindow: null })
settings = await readSettings()
env = settings.env as Record<string, string>
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined()
})
test('should normalize empty model mappings before syncing settings', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
models: {
main: 'gpt-5.5',
haiku: '',
sonnet: '',
opus: '',
},
}))
await svc.activateProvider(provider.id)
const settings = await readSettings()
const env = settings.env as Record<string, string>
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.5')
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.5')
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.5')
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.5')
})
test('updating active provider should override and clear model context windows', async () => {
const svc = new ProviderService()
const added = await svc.addProvider(sampleInput({
modelContextWindows: { 'model-main': 300000 },
}))
await svc.activateProvider(added.id)
let settings = await readSettings()
let env = settings.env as Record<string, string>
expect(JSON.parse(env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toEqual({
'model-main': 300000,
})
await svc.updateProvider(added.id, {
modelContextWindows: { 'model-main': 500000 },
})
settings = await readSettings()
env = settings.env as Record<string, string>
expect(JSON.parse(env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toEqual({
'model-main': 500000,
})
await svc.updateProvider(added.id, { modelContextWindows: null })
settings = await readSettings()
env = settings.env as Record<string, string>
expect(env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS).toBeUndefined()
})
test('updating an active provider drives image routing in both directions', async () => {
const svc = new ProviderService()
const added = await svc.addProvider(sampleInput())
await svc.activateProvider(added.id)
await svc.updateProvider(added.id, {
imageGeneration: {
model: 'image-model',
baseUrl: 'https://images.example.test/v1',
apiKey: 'image-secret',
},
})
let settings = await readSettings()
let env = settings.env as Record<string, string>
expect(env).toMatchObject({
CC_HAHA_IMAGE_PROVIDER_KIND: 'openai_images',
CC_HAHA_IMAGE_PROVIDER_ID: added.id,
CC_HAHA_IMAGE_BASE_URL: 'https://images.example.test/v1',
CC_HAHA_IMAGE_API_KEY: 'image-secret',
CC_HAHA_IMAGE_MODEL: 'image-model',
})
const updated = await svc.updateProvider(added.id, { imageGeneration: null })
expect(updated.imageGeneration).toBeUndefined()
settings = await readSettings()
env = settings.env as Record<string, string>
expect(env.CC_HAHA_IMAGE_PROVIDER_KIND).toBeUndefined()
expect(env.CC_HAHA_IMAGE_API_KEY).toBeUndefined()
})
})
// ─── deleteProvider ──────────────────────────────────────────────────────
describe('deleteProvider', () => {
test('should delete an inactive provider', async () => {
const svc = new ProviderService()
await svc.addProvider(sampleInput({ name: 'First' }))
const second = await svc.addProvider(sampleInput({ name: 'Second' }))
// Second is inactive, so deletion should succeed
await svc.deleteProvider(second.id)
const { providers } = await svc.listProviders()
expect(providers).toHaveLength(1)
expect(providers[0].name).toBe('First')
})
test('should throw 409 when deleting an active provider', async () => {
const svc = new ProviderService()
const active = await svc.addProvider(sampleInput())
await svc.activateProvider(active.id)
try {
await svc.deleteProvider(active.id)
expect(true).toBe(false)
} catch (err: unknown) {
const apiErr = err as { statusCode: number }
expect(apiErr.statusCode).toBe(409)
}
})
test('should throw 404 when deleting non-existent provider', async () => {
const svc = new ProviderService()
try {
await svc.deleteProvider('non-existent-id')
expect(true).toBe(false)
} catch (err: unknown) {
const apiErr = err as { statusCode: number }
expect(apiErr.statusCode).toBe(404)
}
})
})
// ─── reorderProviders ────────────────────────────────────────────────────
describe('reorderProviders', () => {
test('should reorder providers to match the given id order and persist it', async () => {
const svc = new ProviderService()
const a = await svc.addProvider(sampleInput({ name: 'A' }))
const b = await svc.addProvider(sampleInput({ name: 'B' }))
const c = await svc.addProvider(sampleInput({ name: 'C' }))
const result = await svc.reorderProviders([c.id, a.id, b.id])
expect(result.providers.map((p) => p.name)).toEqual(['C', 'A', 'B'])
// Persisted order survives a fresh read
const { providers } = await svc.listProviders()
expect(providers.map((p) => p.name)).toEqual(['C', 'A', 'B'])
const config = await readProvidersConfig()
expect((config.providers as Array<{ name: string }>).map((p) => p.name)).toEqual(['C', 'A', 'B'])
})
test('should persist display order including built-in official providers', async () => {
const svc = new ProviderService()
const a = await svc.addProvider(sampleInput({ name: 'A' }))
const b = await svc.addProvider(sampleInput({ name: 'B' }))
const result = await svc.reorderProviders([
'openai-official',
b.id,
'claude-official',
a.id,
'grok-official',
])
expect(result.providerOrder).toEqual([
'openai-official',
b.id,
'claude-official',
a.id,
'grok-official',
])
expect(result.providers.map((p) => p.id)).toEqual([b.id, a.id])
const listed = await svc.listProviders()
expect(listed.providerOrder).toEqual([
'openai-official',
b.id,
'claude-official',
a.id,
'grok-official',
])
const config = await readProvidersConfig()
expect(config.providerOrder).toEqual([
'openai-official',
b.id,
'claude-official',
a.id,
'grok-official',
])
})
test('should not change activeId when reordering', async () => {
const svc = new ProviderService()
const a = await svc.addProvider(sampleInput({ name: 'A' }))
const b = await svc.addProvider(sampleInput({ name: 'B' }))
await svc.activateProvider(a.id)
await svc.reorderProviders([b.id, a.id])
const { activeId } = await svc.listProviders()
expect(activeId).toBe(a.id)
})
test('should throw 400 when orderedIds is missing a provider', async () => {
const svc = new ProviderService()
const a = await svc.addProvider(sampleInput({ name: 'A' }))
await svc.addProvider(sampleInput({ name: 'B' }))
try {
await svc.reorderProviders([a.id])
expect(true).toBe(false)
} catch (err: unknown) {
const apiErr = err as { statusCode: number }
expect(apiErr.statusCode).toBe(400)
}
})
test('should throw 400 when orderedIds contains an unknown id', async () => {
const svc = new ProviderService()
const a = await svc.addProvider(sampleInput({ name: 'A' }))
const b = await svc.addProvider(sampleInput({ name: 'B' }))
try {
await svc.reorderProviders([a.id, b.id, 'ghost-id'])
expect(true).toBe(false)
} catch (err: unknown) {
const apiErr = err as { statusCode: number }
expect(apiErr.statusCode).toBe(400)
}
})
test('should throw 400 when orderedIds contains duplicates', async () => {
const svc = new ProviderService()
const a = await svc.addProvider(sampleInput({ name: 'A' }))
await svc.addProvider(sampleInput({ name: 'B' }))
try {
await svc.reorderProviders([a.id, a.id])
expect(true).toBe(false)
} catch (err: unknown) {
const apiErr = err as { statusCode: number }
expect(apiErr.statusCode).toBe(400)
}
})
})
// ─── activateProvider ────────────────────────────────────────────────────
describe('activateProvider', () => {
test('should activate a provider with a valid model', async () => {
const svc = new ProviderService()
const first = await svc.addProvider(sampleInput({ name: 'First' }))
const second = await svc.addProvider(
sampleInput({
name: 'Second',
baseUrl: 'https://second-api.example.com',
apiKey: 'sk-second-key',
}),
)
await svc.activateProvider(second.id)
// Second should now be active
const { activeId, providers } = await svc.listProviders()
expect(activeId).toBe(second.id)
expect(providers.find((p) => p.id === first.id)).toBeDefined()
expect(providers.find((p) => p.id === second.id)).toBeDefined()
})
test('should write correct settings.json on activation', async () => {
const svc = new ProviderService()
await svc.addProvider(sampleInput({ name: 'First' }))
const second = await svc.addProvider(
sampleInput({
name: 'Second',
baseUrl: 'https://second-api.example.com',
apiKey: 'sk-second-key',
}),
)
await svc.activateProvider(second.id)
const settings = await readSettings()
const env = settings.env as Record<string, string>
expect(env.ANTHROPIC_BASE_URL).toBe('https://second-api.example.com')
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-second-key')
expect(env.ANTHROPIC_API_KEY).toBe('')
expect(second.toolSearchEnabled).toBe(false)
expect(env.ENABLE_TOOL_SEARCH).toBe('false')
expect(env.ANTHROPIC_MODEL).toBe('model-main')
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('model-haiku')
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('model-sonnet')
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('model-opus')
expect(env.CLAUDE_CODE_ATTRIBUTION_HEADER).toBe('0')
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined()
})
test('should persist explicitly enabled tool search for native Anthropic providers', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
toolSearchEnabled: true,
}))
await svc.activateProvider(provider.id)
const settings = await readSettings()
const env = settings.env as Record<string, string>
expect(env.ENABLE_TOOL_SEARCH).toBe('true')
const runtimeEnv = await svc.getProviderRuntimeEnv(provider.id)
expect(runtimeEnv.ENABLE_TOOL_SEARCH).toBe('true')
})
test('should persist disabled experimental betas on activation and runtime env', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
disableExperimentalBetas: true,
}))
expect(provider.disableExperimentalBetas).toBe(true)
const config = await readProvidersConfig()
expect((config.providers as Array<Record<string, unknown>>)[0]?.disableExperimentalBetas).toBe(true)
await svc.activateProvider(provider.id)
const settings = await readSettings()
const env = settings.env as Record<string, string>
expect(env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS).toBe('1')
const runtimeEnv = await svc.getProviderRuntimeEnv(provider.id)
expect(runtimeEnv.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS).toBe('1')
const updated = await svc.updateProvider(provider.id, { disableExperimentalBetas: false })
expect(updated.disableExperimentalBetas).toBeUndefined()
const clearedSettings = await readSettings()
const clearedEnv = clearedSettings.env as Record<string, string>
expect(clearedEnv.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS).toBeUndefined()
const clearedRuntimeEnv = await svc.getProviderRuntimeEnv(provider.id)
expect(clearedRuntimeEnv.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS).toBeUndefined()
})
test('should preserve attribution header for Claude-prefixed provider models', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
models: {
main: 'Claude Sonnet 4.6',
haiku: 'Claude Haiku 4.5',
sonnet: 'Claude Sonnet 4.6',
opus: 'Claude Opus 4.7',
},
}))
await svc.activateProvider(provider.id)
const settings = await readSettings()
const env = settings.env as Record<string, string>
expect(env.CLAUDE_CODE_ATTRIBUTION_HEADER).toBe('1')
const runtimeEnv = await svc.getProviderRuntimeEnv(provider.id)
expect(runtimeEnv.CLAUDE_CODE_ATTRIBUTION_HEADER).toBe('1')
})
test('should honor provider auth env strategies on activation and runtime env', async () => {
const svc = new ProviderService()
const apiKeyProvider = await svc.addProvider(sampleInput({
apiKey: 'sk-api-key',
authStrategy: 'api_key',
}))
await svc.activateProvider(apiKeyProvider.id)
let env = (await readSettings()).env as Record<string, string>
expect(env.ANTHROPIC_API_KEY).toBe('sk-api-key')
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
const bearerProvider = await svc.addProvider(sampleInput({
apiKey: 'sk-bearer',
authStrategy: 'auth_token_empty_api_key',
}))
await svc.activateProvider(bearerProvider.id)
env = (await readSettings()).env as Record<string, string>
expect(env.ANTHROPIC_API_KEY).toBe('')
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-bearer')
const dualProvider = await svc.addProvider(sampleInput({
apiKey: 'sk-dual',
authStrategy: 'dual_same_token',
}))
const runtimeEnv = await svc.getProviderRuntimeEnv(dualProvider.id)
expect(runtimeEnv.ANTHROPIC_API_KEY).toBe('sk-dual')
expect(runtimeEnv.ANTHROPIC_AUTH_TOKEN).toBe('sk-dual')
const dummyProvider = await svc.addProvider(sampleInput({
apiKey: '',
authStrategy: 'dual_dummy',
}))
const dummyRuntimeEnv = await svc.getProviderRuntimeEnv(dummyProvider.id)
expect(dummyRuntimeEnv.ANTHROPIC_API_KEY).toBe('dummy')
expect(dummyRuntimeEnv.ANTHROPIC_AUTH_TOKEN).toBe('dummy')
})
test('proxy providers keep transient desktop auth out of persisted settings', async () => {
const originalLocalAccessToken = process.env.CC_HAHA_LOCAL_ACCESS_TOKEN
process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'desktop-local-secret'
try {
const svc = new ProviderService()
for (const apiFormat of ['openai_chat', 'openai_responses'] as const) {
const provider = await svc.addProvider(sampleInput({
apiFormat,
authStrategy: 'auth_token',
}))
await svc.activateProvider(provider.id)
const settings = await readSettings()
const env = settings.env as Record<string, string>
expect(env.ANTHROPIC_API_KEY).toBe('proxy-managed')
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined()
expect(env.ENABLE_TOOL_SEARCH).toBeUndefined()
expect(JSON.stringify(settings)).not.toContain('desktop-local-secret')
}
} finally {
if (originalLocalAccessToken === undefined) {
delete process.env.CC_HAHA_LOCAL_ACCESS_TOKEN
} else {
process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = originalLocalAccessToken
}
}
})
test.each([
['xuanshuapi', 'https://www.xuanshuapi.com', 'claude-sonnet-5'],
['fennoai', 'https://api.fenno.ai', 'claude-sonnet-5'],
['qiniuai', 'https://api.qnaigc.com', 'deepseek/deepseek-v4-pro'],
])('keeps legacy %s providers editable and usable after retirement', async (presetId, baseUrl, model) => {
// This is an old on-disk record: auth/context fields were not always persisted.
const legacyProvider = {
id: `saved-${presetId}`,
...sampleInput({ presetId, baseUrl, models: { main: model, haiku: model, sonnet: model, opus: model } }),
}
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
await fs.writeFile(path.join(tmpDir, 'cc-haha', 'providers.json'), JSON.stringify({
providers: [legacyProvider],
activeId: legacyProvider.id,
}))
const svc = new ProviderService()
expect((await svc.listProviders()).providers).toEqual([expect.objectContaining(legacyProvider)])
await svc.updateProvider(legacyProvider.id, { name: 'Renamed saved provider' })
await svc.activateProvider(legacyProvider.id)
const restarted = new ProviderService()
expect(await restarted.getProvider(legacyProvider.id)).toMatchObject({
...legacyProvider,
name: 'Renamed saved provider',
})
expect((await restarted.listProviders()).activeId).toBe(legacyProvider.id)
const runtimeEnv = await restarted.getProviderRuntimeEnv(legacyProvider.id)
const settingsEnv = (await readSettings()).env as Record<string, string>
for (const env of [runtimeEnv, settingsEnv]) {
expect(env).toMatchObject({
ANTHROPIC_BASE_URL: baseUrl,
ANTHROPIC_AUTH_TOKEN: legacyProvider.apiKey,
ANTHROPIC_API_KEY: '',
ANTHROPIC_MODEL: model,
})
expect(JSON.parse(env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)[model]).toBe(1000000)
if (presetId === 'xuanshuapi') {
expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe('claude-sonnet-5')
}
}
})
test('should include preset default env on activation and runtime env', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
presetId: 'shengsuanyun',
baseUrl: 'https://router.shengsuanyun.com/api',
}))
await svc.activateProvider(provider.id)
const settings = await readSettings()
const env = settings.env as Record<string, string>
expect(env.API_TIMEOUT_MS).toBe('3000000')
expect(env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC).toBe('1')
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined()
expect(JSON.parse(env.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toEqual({
'anthropic/claude-sonnet-4.6': 1000000,
'anthropic/claude-haiku-4.5:thinking': 200000,
'anthropic/claude-opus-4.7': 1000000,
})
const runtimeEnv = await svc.getProviderRuntimeEnv(provider.id)
expect(runtimeEnv.API_TIMEOUT_MS).toBe('3000000')
expect(runtimeEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC).toBe('1')
expect(runtimeEnv.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined()
expect(JSON.parse(runtimeEnv.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS)).toEqual({
'anthropic/claude-sonnet-4.6': 1000000,
'anthropic/claude-haiku-4.5:thinking': 200000,
'anthropic/claude-opus-4.7': 1000000,
})
await svc.activateOfficial()
const clearedSettings = await readSettings()
const clearedEnv = (clearedSettings.env as Record<string, string> | undefined) ?? {}
expect(clearedEnv.API_TIMEOUT_MS).toBeUndefined()
expect(clearedEnv.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC).toBeUndefined()
expect(clearedEnv.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBeUndefined()
expect(clearedEnv.CLAUDE_CODE_ATTRIBUTION_HEADER).toBeUndefined()
expect(clearedEnv.CLAUDE_CODE_MODEL_CONTEXT_WINDOWS).toBeUndefined()
})
test('auth status treats preset default auth as active provider auth', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
presetId: 'lmstudio',
apiKey: '',
authStrategy: 'auth_token_empty_api_key',
models: {
main: 'lmstudio-model',
haiku: 'lmstudio-model',
sonnet: 'lmstudio-model',
opus: 'lmstudio-model',
},
}))
await svc.activateProvider(provider.id)
const status = await svc.checkAuthStatus()
expect(status).toEqual({
hasAuth: true,
source: 'cc-haha-provider',
activeProvider: provider.name,
})
})
test('auth status treats dummy proxy auth as active provider auth', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
apiKey: '',
apiFormat: 'openai_chat',
}))
await svc.activateProvider(provider.id)
const status = await svc.checkAuthStatus()
expect(status).toEqual({
hasAuth: true,
source: 'cc-haha-provider',
activeProvider: provider.name,
})
})
test('provider auto compact window should override preset default env on activation and runtime env', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
presetId: 'custom',
autoCompactWindow: 32000,
}))
await svc.activateProvider(provider.id)
const settings = await readSettings()
const env = settings.env as Record<string, string>
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('32000')
const runtimeEnv = await svc.getProviderRuntimeEnv(provider.id)
expect(runtimeEnv.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe('32000')
})
test('should preserve existing settings.json fields on activation', async () => {
// Pre-seed settings with an extra field
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
await fs.writeFile(
path.join(tmpDir, 'cc-haha', 'settings.json'),
JSON.stringify({ theme: 'dark', env: { CUSTOM_VAR: 'keep-me' } }),
)
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput())
// Re-activate to verify merge behavior
await svc.activateProvider(provider.id)
const settings = await readSettings()
expect(settings.theme).toBe('dark')
const env = settings.env as Record<string, string>
expect(env.CUSTOM_VAR).toBe('keep-me')
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.example.com')
})
test('should recover malformed managed settings before activation sync', async () => {
await fs.mkdir(path.join(tmpDir, 'cc-haha'), { recursive: true })
await fs.writeFile(path.join(tmpDir, 'cc-haha', 'settings.json'), '{not json', 'utf-8')
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput())
await svc.activateProvider(provider.id)
const settings = await readSettings()
const env = settings.env as Record<string, string>
const files = await fs.readdir(path.join(tmpDir, 'cc-haha'))
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.example.com')
expect(files.some((name) => name.startsWith('settings.json.invalid-'))).toBe(true)
})
test('should throw 404 for non-existent provider id', async () => {
const svc = new ProviderService()
try {
await svc.activateProvider('non-existent-id')
expect(true).toBe(false)
} catch (err: unknown) {
const apiErr = err as { statusCode: number }
expect(apiErr.statusCode).toBe(404)
}
})
test('activeId should be persisted in providers.json', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput())
await svc.activateProvider(provider.id)
const config = await readProvidersConfig()
expect(config.activeId).toBe(provider.id)
})
})
// ─── getProviderForProxy ─────────────────────────────────────────────────
describe('getProviderForProxy', () => {
test('should return null when no provider is active', async () => {
const svc = new ProviderService()
const active = await svc.getProviderForProxy()
expect(active).toBeNull()
})
test('should return null for explicit ChatGPT Official proxy lookup', async () => {
const svc = new ProviderService()
const active = await svc.getProviderForProxy('openai-official')
expect(active).toBeNull()
})
test('should return null for explicit Grok Official proxy lookup', async () => {
const svc = new ProviderService()
const active = await svc.getProviderForProxy('grok-official')
expect(active).toBeNull()
})
test('should return the active provider proxy config', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput())
await svc.activateProvider(provider.id)
const active = await svc.getProviderForProxy()
expect(active).not.toBeNull()
expect(active!.baseUrl).toBe(provider.baseUrl)
expect(active!.apiKey).toBe(provider.apiKey)
expect(active!.apiFormat).toBe('anthropic')
})
test('should resolve preset default auth for a no-key proxy provider', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
presetId: 'lmstudio',
apiKey: '',
apiFormat: 'anthropic',
supportsNestedToolResultMedia: false,
}))
const config = await svc.getProviderForProxy(provider.id)
expect(config?.apiKey).toBe('lmstudio')
expect(config?.authStrategy).toBe('auth_token_empty_api_key')
})
test('should return null when ChatGPT Official is the active provider', async () => {
const svc = new ProviderService()
await svc.activateProvider('openai-official')
const active = await svc.getProviderForProxy()
expect(active).toBeNull()
})
test('should return null when Grok Official is the active provider', async () => {
const svc = new ProviderService()
await svc.activateProvider('grok-official')
const active = await svc.getProviderForProxy()
expect(active).toBeNull()
})
})
describe('handleProxyRequest', () => {
test('preserves optional Computer Use parameters in the final Responses proxy request', async () => {
const originalFetch = globalThis.fetch
const computerTools = buildComputerUseTools().filter(tool =>
['get_app_state', 'click'].includes(tool.name),
)
const originalSchemas = structuredClone(computerTools.map(tool => tool.inputSchema))
const calls: Array<{ url: string; body: Record<string, unknown> }> = []
globalThis.fetch = mock(async (input: string | URL | Request, init?: RequestInit) => {
calls.push({ url: String(input), body: JSON.parse(String(init?.body)) })
return Response.json({
id: 'resp_computer_schema',
object: 'response',
created_at: 0,
model: 'gpt-6-astra',
status: 'completed',
output: [],
})
}) as typeof fetch
try {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({ apiFormat: 'openai_responses' }))
await svc.activateProvider(provider.id)
const req = new Request('http://localhost:3456/proxy/v1/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-6-astra',
max_tokens: 64,
messages: [{ role: 'user', content: 'Inspect Blender' }],
tools: computerTools.map(tool => ({
name: tool.name,
description: tool.description,
input_schema: tool.inputSchema,
})),
}),
})
const response = await handleProxyRequest(req, new URL(req.url))
expect(response.status).toBe(200)
await response.text()
expect(calls).toHaveLength(1)
expect(calls[0].url).toBe('https://api.example.com/v1/responses')
const outboundTools = calls[0].body.tools as Array<{
name: string
strict?: boolean
parameters: Record<string, unknown>
}>
expect(outboundTools).toHaveLength(computerTools.length)
for (const [index, tool] of outboundTools.entries()) {
expect(tool.name).toBe(computerTools[index].name)
expect(tool.strict).toBe(false)
expect(tool.parameters).toEqual(originalSchemas[index])
expect(tool.parameters.required).toEqual(['app'])
}
expect(computerTools.map(tool => tool.inputSchema)).toEqual(originalSchemas)
} finally {
globalThis.fetch = originalFetch
}
})
test('records a session trace for proxied OpenAI Chat calls', async () => {
const originalFetch = globalThis.fetch
const upstreamHeaders: Headers[] = []
globalThis.fetch = mock(async (_input: string | URL | Request, init?: RequestInit) => {
upstreamHeaders.push(new Headers(init?.headers))
return new Response(JSON.stringify({
id: 'chatcmpl-trace',
object: 'chat.completion',
created: 0,
model: 'gpt-4',
choices: [{ index: 0, message: { role: 'assistant', content: 'trace ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 11, completion_tokens: 3, total_tokens: 14 },
}), {
status: 200,
headers: { 'Content-Type': 'application/json', 'x-request-id': 'req-trace' },
})
}) as typeof fetch
try {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({ apiFormat: 'openai_chat', name: 'Trace Provider' }))
await svc.activateProvider(provider.id)
const req = new Request('http://localhost:3456/proxy/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer desktop-local-secret',
'X-Claude-Code-Session-Id': 'session-proxy-trace',
},
body: JSON.stringify({
model: 'gpt-4',
max_tokens: 64,
messages: [{ role: 'user', content: 'capture this call' }],
}),
})
const res = await handleProxyRequest(req, new URL(req.url))
const trace = await waitForCompletedProxyTrace('session-proxy-trace')
expect(res.status).toBe(200)
expect(trace.summary.apiCalls).toBe(1)
expect(trace.calls[0]).toMatchObject({
source: 'proxy',
provider: {
id: provider.id,
name: 'Trace Provider',
format: 'openai_chat',
},
model: 'gpt-4',
})
expect(trace.calls[0].request.body.preview).toContain('capture this call')
expect(trace.calls[0].response.body.preview).toContain('chatcmpl-trace')
expect(upstreamHeaders[0].get('Authorization')).toBe('Bearer sk-test-key-123')
expect(upstreamHeaders[0].get('Authorization')).not.toContain('desktop-local-secret')
} finally {
globalThis.fetch = originalFetch
}
})
test('records DeepSeek Computer Use request semantics independently of the truncated raw body', async () => {
const originalFetch = globalThis.fetch
globalThis.fetch = mock(async () => new Response(JSON.stringify({
id: 'chatcmpl-trace-deepseek-computer-use',
object: 'chat.completion',
created: 0,
model: 'deepseek-v4-flash-vision-exp',
choices: [{ index: 0, message: { role: 'assistant', content: 'validated' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 119_000, completion_tokens: 12, total_tokens: 119_012 },
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})) as typeof fetch
const screenshotData = 'AQID'.repeat(80_000)
const injectedContext = '<system-reminder>\n# Project rules\nUse the repository instructions.\n</system-reminder>'
try {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
apiFormat: 'openai_chat',
baseUrl: 'https://opencode.ai/zen',
name: 'DeepSeek V4 Flash',
models: {
main: 'deepseek-v4-flash-vision-exp',
haiku: 'deepseek-v4-flash-vision-exp',
sonnet: 'deepseek-v4-flash-vision-exp',
opus: 'deepseek-v4-flash-vision-exp',
},
}))
await svc.activateProvider(provider.id)
const req = new Request('http://localhost:3456/proxy/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Claude-Code-Session-Id': 'session-deepseek-computer-use-trace',
},
body: JSON.stringify({
model: 'deepseek-v4-flash-vision-exp',
max_tokens: 64,
system: [{ type: 'text', text: 'You are Claude Code.' }],
messages: [
{
role: 'user',
content: [
{ type: 'text', text: injectedContext },
{ type: 'text', text: '创建并校验 large-200.json 文件' },
],
},
{
role: 'assistant',
content: [{
type: 'tool_use',
id: 'computer_1',
name: 'computer',
input: { action: 'screenshot' },
}],
},
{
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: 'computer_1',
content: [
{ type: 'text', text: 'Computer Use state' },
{
type: 'image',
source: { type: 'base64', media_type: 'image/jpeg', data: screenshotData },
},
],
}],
},
],
}),
})
const response = await handleProxyRequest(req, new URL(req.url))
const trace = await waitForCompletedProxyTrace('session-deepseek-computer-use-trace')
const call = trace.calls[0]
expect(response.status).toBe(200)
expect(call.request.body.truncated).toBe(true)
expect(() => JSON.parse(call.request.body.preview)).toThrow()
expect(call.request.semantic).toMatchObject({
version: 1,
request: {
model: 'deepseek-v4-flash-vision-exp',
system: [{ type: 'text', text: 'You are Claude Code.' }],
messages: [
{
role: 'user',
content: [
{ type: 'text', text: injectedContext },
{ type: 'text', text: '创建并校验 large-200.json 文件' },
],
},
{
role: 'assistant',
content: [{
type: 'tool_use',
id: 'computer_1',
name: 'computer',
input: { action: 'screenshot' },
}],
},
{
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: 'computer_1',
content: [
{ type: 'text', text: 'Computer Use state' },
{
type: 'image',
source: {
type: 'base64',
media_type: 'image/jpeg',
bytes: 240_000,
sha256: expect.stringMatching(/^[0-9a-f]{64}$/),
},
},
],
}],
},
],
},
})
const semanticMessages = call.request.semantic?.request.messages as Array<{
content: Array<Record<string, unknown>>
}>
expect(semanticMessages[2]?.content[0]).toMatchObject({
type: 'tool_result',
tool_use_id: 'computer_1',
})
expect(JSON.stringify(call.request.semantic)).not.toContain(screenshotData.slice(0, 64))
} finally {
globalThis.fetch = originalFetch
}
})
test('returns a non-streaming proxy response before trace persistence finishes', async () => {
const originalFetch = globalThis.fetch
globalThis.fetch = mock(async () => new Response(JSON.stringify({
id: 'chatcmpl-trace-background',
object: 'chat.completion',
created: 0,
model: 'gpt-4',
choices: [{ index: 0, message: { role: 'assistant', content: 'background trace ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 },
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})) as typeof fetch
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({ apiFormat: 'openai_chat' }))
await svc.activateProvider(provider.id)
const { releaseWrite, writeBlocked } = blockNextTraceAppend()
let released = false
let responsePromise: Promise<Response> | undefined
try {
const req = new Request('http://localhost:3456/proxy/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Claude-Code-Session-Id': 'session-non-stream-background-trace',
},
body: JSON.stringify({
model: 'gpt-4',
max_tokens: 64,
messages: [{ role: 'user', content: 'return before trace persistence' }],
}),
})
responsePromise = handleProxyRequest(req, new URL(req.url))
await writeBlocked
const response = await settlesBeforeBlockedTraceWrite(responsePromise)
expect(response).not.toBeNull()
expect(response?.status).toBe(200)
await expect(response?.json()).resolves.toMatchObject({
content: [{ text: 'background trace ok' }],
})
releaseWrite()
released = true
const trace = await waitForCompletedProxyTrace('session-non-stream-background-trace')
expect(trace.calls[0]?.response?.body.preview).toContain('chatcmpl-trace-background')
expect(trace.events.at(-1)?.phase).toBe('upstream_fetch_completed')
} finally {
if (!released) releaseWrite()
await responsePromise?.catch(() => undefined)
globalThis.fetch = originalFetch
}
})
test('delivers streaming EOF before trace persistence finishes', async () => {
const originalFetch = globalThis.fetch
const encoder = new TextEncoder()
globalThis.fetch = mock(async () => new Response(new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode([
'data: {"id":"chatcmpl-stream-trace","object":"chat.completion.chunk","model":"gpt-4","choices":[{"index":0,"delta":{"role":"assistant","content":"streamed"},"finish_reason":null}]}',
'',
'data: {"id":"chatcmpl-stream-trace","object":"chat.completion.chunk","model":"gpt-4","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}',
'',
'data: [DONE]',
'',
].join('\n')))
controller.close()
},
}), {
status: 200,
headers: { 'Content-Type': 'text/event-stream' },
})) as typeof fetch
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({ apiFormat: 'openai_chat' }))
await svc.activateProvider(provider.id)
const { releaseWrite, writeBlocked } = blockNextTraceAppend()
let released = false
let bodyPromise: Promise<string> | undefined
try {
const req = new Request('http://localhost:3456/proxy/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Claude-Code-Session-Id': 'session-stream-background-trace',
},
body: JSON.stringify({
model: 'gpt-4',
max_tokens: 64,
stream: true,
messages: [{ role: 'user', content: 'finish before trace persistence' }],
}),
})
const response = await handleProxyRequest(req, new URL(req.url))
bodyPromise = response.text()
await writeBlocked
const body = await settlesBeforeBlockedTraceWrite(bodyPromise)
expect(body).not.toBeNull()
expect(body).toContain('message_stop')
releaseWrite()
released = true
const trace = await waitForCompletedProxyTrace('session-stream-background-trace')
expect(trace.calls[0]?.response?.body.preview).toContain('message_stop')
expect(trace.events.at(-1)?.phase).toBe('upstream_fetch_completed')
} finally {
if (!released) releaseWrite()
await bodyPromise?.catch(() => undefined)
globalThis.fetch = originalFetch
}
})
test('strips leading billing attribution instead of injecting it for OpenAI-compatible upstreams', async () => {
const originalFetch = globalThis.fetch
const calls: Array<{ body: Record<string, unknown> }> = []
globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
calls.push({ body: JSON.parse(String(init?.body)) as Record<string, unknown> })
return new Response(JSON.stringify({
id: 'chatcmpl-1',
object: 'chat.completion',
created: 0,
model: 'gpt-4',
choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as typeof fetch
try {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({ apiFormat: 'openai_chat' }))
await svc.activateProvider(provider.id)
const req = new Request('http://localhost:3456/proxy/v1/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gpt-4',
max_tokens: 64,
system: [
{ type: 'text', text: 'x-anthropic-billing-header: cc_version=2.1.220.693; cc_entrypoint=cli; cch=00000;' },
{ type: 'text', text: 'You are a helpful assistant.' },
],
messages: [{ role: 'user', content: 'hello from proxy' }],
}),
})
const res = await handleProxyRequest(req, new URL(req.url))
expect(res.status).toBe(200)
// The rotating billing header would change the prompt prefix on every
// request and defeat upstream prefix caching — it must not be forwarded.
const messages = calls[0].body.messages as Array<Record<string, string>>
expect(messages[0].role).toBe('system')
expect(messages[0].content).toBe('You are a helpful assistant.')
expect(JSON.stringify(calls[0].body)).not.toContain('x-anthropic-billing-header')
} finally {
globalThis.fetch = originalFetch
}
})
test('round-trips DeepSeek reasoning by model on generic OpenAI Chat hosts', async () => {
const originalFetch = globalThis.fetch
const calls: Array<Record<string, unknown>> = []
globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
calls.push(body)
const messages = body.messages as Array<Record<string, unknown>>
const assistantMessage = messages.find(message => message.role === 'assistant')
if (body.model === 'deepseek-v4-flash' && assistantMessage?.reasoning_content === undefined) {
return new Response(JSON.stringify({
error: {
message: 'The reasoning_content in the thinking mode must be passed back to the API',
},
}), {
status: 400,
headers: { 'Content-Type': 'application/json' },
})
}
return new Response(JSON.stringify({
id: 'chatcmpl-reasoning-round-trip',
object: 'chat.completion',
created: 0,
model: body.model,
choices: [{ index: 0, message: { role: 'assistant', content: 'done' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as typeof fetch
try {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
apiFormat: 'openai_chat',
baseUrl: 'https://api.b.ai',
}))
await svc.activateProvider(provider.id)
const makeFollowUpRequest = (model: string) => new Request('http://localhost:3456/proxy/v1/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
max_tokens: 64,
messages: [
{
role: 'assistant',
content: [
{ type: 'thinking', thinking: 'Need to inspect the repository first.' },
{ type: 'tool_use', id: 'call_1', name: 'read_file', input: { path: 'README.md' } },
],
},
{
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'call_1', content: 'Repository contents' }],
},
],
}),
})
const deepSeekRequest = makeFollowUpRequest('deepseek-v4-flash')
const deepSeekResponse = await handleProxyRequest(deepSeekRequest, new URL(deepSeekRequest.url))
expect(deepSeekResponse.status).toBe(200)
const genericRequest = makeFollowUpRequest('generic-chat-model')
const genericResponse = await handleProxyRequest(genericRequest, new URL(genericRequest.url))
expect(genericResponse.status).toBe(200)
const deepSeekMessages = calls[0].messages as Array<Record<string, unknown>>
const deepSeekAssistant = deepSeekMessages.find(message => message.role === 'assistant')
expect(deepSeekAssistant?.reasoning_content).toBe('Need to inspect the repository first.')
const genericMessages = calls[1].messages as Array<Record<string, unknown>>
const genericAssistant = genericMessages.find(message => message.role === 'assistant')
expect(genericAssistant?.reasoning_content).toBeUndefined()
} finally {
globalThis.fetch = originalFetch
}
})
test('forwards a stable prompt_cache_key from client session metadata for OpenAI Responses upstreams', async () => {
const originalFetch = globalThis.fetch
const calls: Array<{ body: Record<string, unknown>; headers: Headers }> = []
globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
calls.push({
body: JSON.parse(String(init?.body)) as Record<string, unknown>,
headers: new Headers(init?.headers),
})
return new Response(JSON.stringify({
id: 'resp-1',
object: 'response',
created_at: 0,
model: 'gpt-5.4',
status: 'completed',
output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'ok' }] }],
usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 },
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as typeof fetch
try {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({ apiFormat: 'openai_responses' }))
await svc.activateProvider(provider.id)
const req = new Request('http://localhost:3456/proxy/v1/messages', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer desktop-local-secret',
},
body: JSON.stringify({
model: 'gpt-5.4',
max_tokens: 64,
metadata: { user_id: 'user_3f7a_account_9b2c_session_sess-42aa' },
messages: [{ role: 'user', content: 'hello from proxy' }],
}),
})
const res = await handleProxyRequest(req, new URL(req.url))
expect(res.status).toBe(200)
expect(calls[0].body.prompt_cache_key).toBe('sess-42aa')
expect(calls[0].headers.get('Authorization')).toBe('Bearer sk-test-key-123')
expect(calls[0].headers.get('Authorization')).not.toContain('desktop-local-secret')
} finally {
globalThis.fetch = originalFetch
}
})
test.each([
'https://opencode.ai/zen',
'https://api.deepseek.com',
'https://gateway.example.test',
])('preserves Computer Use tool images for explicit vision models at %s', async (baseUrl) => {
const body = await captureOpenAIChatRequest({
baseUrl,
model: 'deepseek-v4-flash-vision-exp',
content: [
{ type: 'text', text: 'Computer Use state' },
{ type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: '/9j/AA==' } },
{ type: 'text', text: 'After screenshot' },
],
})
const messages = body.messages as Array<Record<string, unknown>>
expect(messages).toEqual([
{
role: 'tool',
tool_call_id: 'computer_1',
content: 'Computer Use stateAfter screenshot',
},
{
role: 'user',
content: [
{ type: 'text', text: '[Media content for tool call computer_1]' },
{ type: 'image_url', image_url: { url: 'data:image/jpeg;base64,/9j/AA==' } },
],
},
])
})
test.each([
{
name: 'DeepSeek schema error relayed by OpenCode Go',
baseUrl: 'https://opencode.ai/zen/go/v1',
model: 'deepseek-v4-flash',
status: 400,
error: 'Error from provider (DeepSeek): Failed to deserialize the JSON body into the target type: messages[0]: unknown variant `image_url`, expected `text` at line 1 column 120',
},
{
name: 'classic DeepSeek text model',
baseUrl: 'https://api.deepseek.com',
model: 'deepseek-v4-pro',
status: 400,
error: 'This model does not support image input',
},
{
name: 'generic gateway validation error',
baseUrl: 'https://gateway.example.test/v1',
model: 'text-model',
status: 422,
error: "messages.0.content.1.type: Input should be 'text'; received 'image_url'",
},
])('retries without images once $name rejects them, then stays text-only for that model', async ({ baseUrl, model, status, error }) => {
const originalFetch = globalThis.fetch
const calls: Array<Record<string, unknown>> = []
globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
calls.push(body)
if (JSON.stringify(body).includes('image_url')) {
return Response.json({ error: { type: 'invalid_request_error', message: error } }, { status })
}
return Response.json({
id: 'chatcmpl-text-only',
object: 'chat.completion',
created: 0,
model: body.model,
choices: [{ index: 0, message: { role: 'assistant', content: 'described from text' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
})
}) as typeof fetch
try {
const otherModel = `${model}-sibling`
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
apiFormat: 'openai_chat',
baseUrl,
models: { main: model, haiku: otherModel, sonnet: model, opus: model },
}))
await svc.activateProvider(provider.id)
const send = (requestModel: string) => {
const req = new Request('http://localhost:3456/proxy/v1/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Claude-Code-Session-Id': `image-retry-${requestModel}` },
body: JSON.stringify({
model: requestModel,
max_tokens: 64,
messages: [{
role: 'user',
content: [
{ type: 'text', text: 'Describe this picture.' },
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'rejected-picture' } },
],
}],
}),
})
return handleProxyRequest(req, new URL(req.url))
}
const first = await send(model)
expect(first.status).toBe(200)
expect(await first.json()).toMatchObject({ content: [{ type: 'text', text: 'described from text' }] })
expect(calls).toHaveLength(2)
expect(JSON.stringify(calls[0])).toContain('data:image/png;base64,rejected-picture')
expect(calls[1].messages).toEqual([{
role: 'user',
content: 'Describe this picture.\n[Image omitted: the upstream rejected image input for this model.]\n',
}])
const trace = await waitForCompletedProxyTrace(`image-retry-${model}`)
expect(trace.calls.map(call => call.response?.status).sort()).toEqual([200, status].sort())
await diagnosticsService.drainForMigration()
const [learned] = (await diagnosticsService.readRecentEvents(20))
.filter(event => event.type === 'openai_chat_image_input_disabled')
expect(learned).toMatchObject({ severity: 'info', details: { model, httpStatus: status } })
expect(JSON.stringify(learned)).not.toContain('sk-test-key-123')
// The rejection is remembered: later turns skip the failing attempt.
const second = await send(model)
expect(second.status).toBe(200)
expect(calls).toHaveLength(3)
expect(JSON.stringify(calls[2])).not.toContain('image_url')
expect(JSON.stringify(calls[2])).not.toContain('rejected-picture')
// The learned limit belongs to that model, not to the whole endpoint.
await send(otherModel)
expect(calls[3].model).toBe(otherModel)
expect(JSON.stringify(calls[3])).toContain('data:image/png;base64,rejected-picture')
} finally {
globalThis.fetch = originalFetch
}
})
test.each([
{ name: 'an oversized image', status: 400, error: 'image exceeds maximum allowed size', withImage: true },
{
name: 'an unsupported image format',
status: 400,
error: "You uploaded an unsupported image. Please make sure your image has of one the following formats: ['png', 'jpeg', 'gif', 'webp'].",
withImage: true,
},
{ name: 'an unsupported image MIME type', status: 400, error: "Invalid image_url: unsupported MIME type 'image/heic'", withImage: true },
{ name: 'a server failure', status: 500, error: 'image input is not supported right now', withImage: true },
{ name: 'a request without images', status: 400, error: 'This model does not support image input', withImage: false },
])('surfaces $name without dropping images from later requests', async ({ status, error, withImage }) => {
const originalFetch = globalThis.fetch
const calls: Array<Record<string, unknown>> = []
globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
calls.push(JSON.parse(String(init?.body)) as Record<string, unknown>)
return Response.json({ error: { type: 'invalid_request_error', message: error } }, { status })
}) as typeof fetch
try {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
apiFormat: 'openai_chat',
baseUrl: 'https://opencode.ai/zen/go/v1',
models: { main: 'deepseek-flash', haiku: 'deepseek-flash', sonnet: 'deepseek-flash', opus: 'deepseek-flash' },
}))
await svc.activateProvider(provider.id)
const send = (content: unknown) => {
const req = new Request('http://localhost:3456/proxy/v1/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'deepseek-flash', max_tokens: 64, messages: [{ role: 'user', content }] }),
})
return handleProxyRequest(req, new URL(req.url))
}
const image = { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'kept-picture' } }
const res = await send(withImage ? [{ type: 'text', text: 'Look.' }, image] : 'Look.')
expect(res.status).toBe(status)
expect(JSON.stringify(await res.json())).toContain(error)
expect(calls).toHaveLength(1)
// The next turn still offers its image to the model first.
await send([{ type: 'text', text: 'Look again.' }, image])
expect(JSON.stringify(calls[1])).toContain('data:image/png;base64,kept-picture')
} finally {
globalThis.fetch = originalFetch
}
})
test.each([
'https://api.deepseek.com',
'https://opencode.ai/zen/v1',
'https://opencode.ai/zen/go/v1',
'https://gateway.example.test/v1',
].flatMap(baseUrl => ['deepseek-v4-pro', 'deepseek-flash', 'glm-5.3'].map(model => ({ baseUrl, model }))))(
'offers images to $model at $baseUrl before any rejection',
async ({ baseUrl, model }) => {
// Guard: image support must never be predicted from host or model names.
const body = await captureOpenAIChatRequest({
baseUrl,
model,
contentSource: 'user',
content: [
{ type: 'text', text: 'Look.' },
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'guard-picture' } },
],
})
expect(JSON.stringify(body)).toContain('data:image/png;base64,guard-picture')
expect(JSON.stringify(body)).not.toContain('Image omitted:')
},
)
test('keeps offering images when the text-only resend fails for another reason', async () => {
const originalFetch = globalThis.fetch
const calls: Array<Record<string, unknown>> = []
globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
calls.push(body)
return JSON.stringify(body).includes('image_url')
? Response.json({ error: { message: 'This model does not support image input' } }, { status: 400 })
: Response.json({ error: { message: 'Rate limit reached' } }, { status: 429 })
}) as typeof fetch
try {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
apiFormat: 'openai_chat',
baseUrl: 'https://opencode.ai/zen/go/v1',
models: { main: 'deepseek-flash', haiku: 'deepseek-flash', sonnet: 'deepseek-flash', opus: 'deepseek-flash' },
}))
await svc.activateProvider(provider.id)
const send = () => {
const req = new Request('http://localhost:3456/proxy/v1/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'deepseek-flash',
max_tokens: 64,
messages: [{
role: 'user',
content: [
{ type: 'text', text: 'Look.' },
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'retry-picture' } },
],
}],
}),
})
return handleProxyRequest(req, new URL(req.url))
}
const first = await send()
expect(first.status).toBe(429)
expect(calls).toHaveLength(2)
await send()
expect(JSON.stringify(calls[2])).toContain('data:image/png;base64,retry-picture')
} finally {
globalThis.fetch = originalFetch
}
})
test('resends a streaming request without images after an image rejection', async () => {
const originalFetch = globalThis.fetch
const encoder = new TextEncoder()
const calls: Array<Record<string, unknown>> = []
globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
const body = JSON.parse(String(init?.body)) as Record<string, unknown>
calls.push(body)
if (JSON.stringify(body).includes('image_url')) {
return Response.json({
error: { message: 'Error from provider (DeepSeek): unknown variant `image_url`, expected `text`' },
}, { status: 400 })
}
return new Response(new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode([
'data: {"id":"chatcmpl-stream-retry","object":"chat.completion.chunk","model":"deepseek-flash","choices":[{"index":0,"delta":{"role":"assistant","content":"text answer"},"finish_reason":null}]}',
'',
'data: {"id":"chatcmpl-stream-retry","object":"chat.completion.chunk","model":"deepseek-flash","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}',
'',
'data: [DONE]',
'',
].join('\n')))
controller.close()
},
}), { status: 200, headers: { 'Content-Type': 'text/event-stream' } })
}) as typeof fetch
try {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
apiFormat: 'openai_chat',
baseUrl: 'https://opencode.ai/zen/go/v1',
models: { main: 'deepseek-flash', haiku: 'deepseek-flash', sonnet: 'deepseek-flash', opus: 'deepseek-flash' },
}))
await svc.activateProvider(provider.id)
const req = new Request('http://localhost:3456/proxy/v1/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'deepseek-flash[1m]',
max_tokens: 64,
stream: true,
messages: [{
role: 'user',
content: [
{ type: 'text', text: 'Look.' },
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'stream-picture' } },
],
}],
}),
})
const res = await handleProxyRequest(req, new URL(req.url))
expect(res.status).toBe(200)
expect(res.headers.get('Content-Type')).toBe('text/event-stream')
const text = await res.text()
expect(text).toContain('text answer')
expect(text).toContain('message_stop')
expect(calls).toHaveLength(2)
expect(calls[1].stream).toBe(true)
expect(JSON.stringify(calls[1])).not.toContain('stream-picture')
} finally {
globalThis.fetch = originalFetch
}
})
test.each([
{ baseUrl: 'https://api.deepseek.com', model: 'deepseek-v4-flash-vision-exp' },
{ baseUrl: 'https://gateway.example.test', model: 'deepseek-v4-flash-vision-exp' },
{ baseUrl: 'https://opencode.ai/zen', model: 'deepseek-v4-flash-vision-exp' },
])('forwards chat attachments for $model at $baseUrl (#1304)', async ({ baseUrl, model }) => {
const body = await captureOpenAIChatRequest({
baseUrl,
model,
contentSource: 'user',
content: [
{ type: 'text', text: 'Describe this picture.' },
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'picture-data' } },
],
})
expect(body.messages).toEqual([{
role: 'user',
content: [
{ type: 'text', text: 'Describe this picture.' },
{ type: 'image_url', image_url: { url: 'data:image/png;base64,picture-data' } },
],
}])
expect(JSON.stringify(body)).not.toContain('Image omitted:')
})
test.each([
{ model: 'kimi-k3', contentSource: 'user' },
{ model: 'kimi-k3', contentSource: 'tool' },
{ model: 'space-bunny-free', contentSource: 'user' },
{ model: 'space-bunny-free', contentSource: 'tool' },
{ model: 'space-bunny-free[1m]', contentSource: 'user' },
{ model: 'space-bunny-free[1m]', contentSource: 'tool' },
{ model: 'deepseek-flash', contentSource: 'user' },
{ model: 'deepseek-flash', contentSource: 'tool' },
{ model: 'deepseek-flash[1m]', contentSource: 'user' },
{ model: 'some-future-model', contentSource: 'tool' },
] as const)('forwards OpenCode Go images for $model from $contentSource content', async ({ model, contentSource }) => {
const body = await captureOpenAIChatRequest({
baseUrl: 'https://opencode.ai/zen/go/v1',
model,
contentSource,
content: [
{ type: 'text', text: 'Describe these pictures.' },
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'k3-picture-one' } },
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'k3-picture-two' } },
],
})
const images = ['one', 'two'].map(suffix => ({
type: 'image_url', image_url: { url: `data:image/png;base64,k3-picture-${suffix}` },
}))
expect(body.model).toBe(model.replace(/\[1m\]$/, ''))
expect(body.messages).toEqual(contentSource === 'user'
? [{ role: 'user', content: [{ type: 'text', text: 'Describe these pictures.' }, ...images] }]
: [
{ role: 'tool', tool_call_id: 'computer_1', content: 'Describe these pictures.' },
{ role: 'user', content: [{ type: 'text', text: '[Media content for tool call computer_1]' }, ...images] },
])
})
test('keeps generic OpenAI Chat providers vision-capable by default', async () => {
const body = await captureOpenAIChatRequest({
baseUrl: 'https://chat.example.test',
model: 'custom-text-named-model',
content: [{
type: 'image',
source: { type: 'base64', media_type: 'image/png', data: 'generic-image-data' },
}],
})
const messages = body.messages as Array<Record<string, unknown>>
expect(messages).toEqual([
{
role: 'tool',
tool_call_id: 'computer_1',
content: 'Media result attached after this tool result.',
},
{
role: 'user',
content: [
{ type: 'text', text: '[Media content for tool call computer_1]' },
{ type: 'image_url', image_url: { url: 'data:image/png;base64,generic-image-data' } },
],
},
])
})
test('normalizes context-window suffixes before forwarding OpenAI Chat proxy requests', async () => {
const originalFetch = globalThis.fetch
const calls: Array<{ body: Record<string, unknown> }> = []
globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
calls.push({ body: JSON.parse(String(init?.body)) as Record<string, unknown> })
return new Response(JSON.stringify({
id: 'chatcmpl-1',
object: 'chat.completion',
created: 0,
model: 'mimo-v2.5-pro',
choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as typeof fetch
try {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({ apiFormat: 'openai_chat' }))
await svc.activateProvider(provider.id)
const req = new Request('http://localhost:3456/proxy/v1/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'mimo-v2.5-pro[1m]',
max_tokens: 64,
messages: [{ role: 'user', content: 'hello from proxy' }],
}),
})
const res = await handleProxyRequest(req, new URL(req.url))
expect(res.status).toBe(200)
expect(calls[0].body.model).toBe('mimo-v2.5-pro')
} finally {
globalThis.fetch = originalFetch
}
})
})
describe('testProvider', () => {
test('should use preset default auth for saved no-key Anthropic-compatible providers', async () => {
const originalFetch = globalThis.fetch
const calls: Array<{ headers: Record<string, string> }> = []
globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
calls.push({ headers: init?.headers as Record<string, string> })
return new Response(JSON.stringify({
type: 'message',
model: 'lmstudio-model',
content: [],
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as typeof fetch
try {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
presetId: 'lmstudio',
apiKey: '',
authStrategy: 'auth_token_empty_api_key',
models: {
main: 'lmstudio-model',
haiku: 'lmstudio-model',
sonnet: 'lmstudio-model',
opus: 'lmstudio-model',
},
}))
const result = await svc.testProvider(provider.id)
expect(result.connectivity.success).toBe(true)
expect(calls[0].headers.Authorization).toBe('Bearer lmstudio')
expect(calls[0].headers['x-api-key']).toBeUndefined()
} finally {
globalThis.fetch = originalFetch
}
})
test('rejects destination and auth overrides before testing with a saved key', async () => {
const originalFetch = globalThis.fetch
const calls: string[] = []
globalThis.fetch = mock(async (url: string | URL | Request) => {
calls.push(String(url))
return new Response('{}', { status: 200 })
}) as typeof fetch
try {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput())
const { req, url, segments } = makeRequest(
'POST',
`/api/providers/${provider.id}/test`,
{
baseUrl: 'https://override.example.com',
apiFormat: 'openai_chat',
authStrategy: 'auth_token',
},
)
const response = await handleProvidersApi(req, url, segments)
expect(response.status).toBe(400)
expect(calls).toEqual([])
} finally {
globalThis.fetch = originalFetch
}
})
test('accepts a model-only override without changing a saved provider destination', async () => {
const originalFetch = globalThis.fetch
const calls: string[] = []
globalThis.fetch = mock(async (url: string | URL | Request) => {
calls.push(String(url))
return new Response(JSON.stringify({
type: 'message',
model: 'alternate-model',
content: [],
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as typeof fetch
try {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput({
baseUrl: 'https://saved.example.com',
}))
const { req, url, segments } = makeRequest(
'POST',
`/api/providers/${provider.id}/test`,
{ modelId: 'alternate-model' },
)
const response = await handleProvidersApi(req, url, segments)
expect(response.status).toBe(200)
expect(calls[0]).toContain('https://saved.example.com')
} finally {
globalThis.fetch = originalFetch
}
})
test('keeps explicit draft provider tests independent from saved credentials', async () => {
const originalFetch = globalThis.fetch
const calls: Array<{ url: string; authorization: string | null }> = []
globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
calls.push({
url: String(url),
authorization: new Headers(init?.headers).get('authorization'),
})
return new Response(JSON.stringify({
type: 'message',
model: 'draft-model',
content: [],
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as typeof fetch
try {
const { req, url, segments } = makeRequest(
'POST',
'/api/providers/test',
{
baseUrl: 'https://draft.example.com',
apiKey: 'draft-explicit-key',
modelId: 'draft-model',
apiFormat: 'anthropic',
authStrategy: 'auth_token',
},
)
const response = await handleProvidersApi(req, url, segments)
expect(response.status).toBe(200)
expect(calls).toEqual([{
url: 'https://draft.example.com/v1/messages',
authorization: 'Bearer draft-explicit-key',
}])
} finally {
globalThis.fetch = originalFetch
}
})
})
describe('testProviderConfig', () => {
for (const [basePath, messagePath] of [
['', '/v1/messages'],
['/', '/v1/messages'],
['/v1', '/v1/messages'],
['/v1///', '/v1/messages'],
['/anthropic', '/anthropic/v1/messages'],
['/anthropic/v1/', '/anthropic/v1/messages'],
['/v1/tenant', '/v1/tenant/v1/messages'],
]) {
test(`Anthropic base URL ${basePath || '(root)'} works in connectivity and proxy requests (#1279)`, async () => {
const requests: Array<{ path: string; apiKey: string | null; version: string | null }> = []
const server = Bun.serve({
hostname: '127.0.0.1',
port: 0,
async fetch(req) {
const requestPath = new URL(req.url).pathname
requests.push({
path: requestPath,
apiKey: req.headers.get('x-api-key'),
version: req.headers.get('anthropic-version'),
})
if (requestPath !== messagePath) {
return Response.json({ error: { message: 'Unknown endpoint' } }, { status: 404 })
}
const body = await req.json() as { stream?: boolean }
if (body.stream) {
return new Response('event: message_stop\ndata: {"type":"message_stop"}\n\n', {
headers: { 'Content-Type': 'text/event-stream' },
})
}
return Response.json({ type: 'message', model: 'model-main', content: [{ type: 'text', text: 'ok' }] })
},
})
try {
const svc = new ProviderService()
const baseUrl = `http://127.0.0.1:${server.port}${basePath}`
const provider = await svc.addProvider(sampleInput({
baseUrl,
authStrategy: 'api_key',
supportsNestedToolResultMedia: false,
}))
const result = await svc.testProvider(provider.id)
expect(requests[0]?.path).toBe(messagePath)
expect(result.connectivity.success).toBe(true)
expect(result.proxy?.success).toBe(true)
for (const stream of [false, true]) {
const req = new Request(`http://localhost/proxy/providers/${provider.id}/v1/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' },
body: JSON.stringify({ model: 'model-main', max_tokens: 16, stream, messages: [{ role: 'user', content: 'hello' }] }),
})
const response = await handleProxyRequest(req, new URL(req.url))
expect(response.status).toBe(200)
if (stream) expect(await response.text()).toContain('event: message_stop')
else expect(await response.json()).toMatchObject({ type: 'message' })
}
expect(requests).toEqual(Array.from({ length: 4 }, () => ({
path: messagePath,
apiKey: 'sk-test-key-123',
version: '2023-06-01',
})))
expect((await svc.getProvider(provider.id))?.baseUrl).toBe(baseUrl)
} finally {
server.stop(true)
}
})
}
test('should use auth strategy headers for Anthropic-compatible tests', async () => {
const originalFetch = globalThis.fetch
const calls: Array<{ url: string; headers: Record<string, string> }> = []
globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
calls.push({
url: String(url),
headers: init?.headers as Record<string, string>,
})
return new Response(JSON.stringify({
type: 'message',
model: 'model-main',
content: [],
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as typeof fetch
try {
const svc = new ProviderService()
await svc.testProviderConfig({
baseUrl: 'https://api.example.com/anthropic',
apiKey: 'sk-bearer',
modelId: 'model-main',
authStrategy: 'auth_token',
apiFormat: 'anthropic',
})
await svc.testProviderConfig({
baseUrl: 'https://api.example.com/anthropic',
apiKey: 'sk-api',
modelId: 'model-main',
authStrategy: 'api_key',
apiFormat: 'anthropic',
})
await svc.testProviderConfig({
baseUrl: 'https://api.example.com/anthropic',
apiKey: 'sk-dual',
modelId: 'model-main',
authStrategy: 'dual_same_token',
apiFormat: 'anthropic',
})
expect(calls[0].headers.Authorization).toBe('Bearer sk-bearer')
expect(calls[0].headers['x-api-key']).toBeUndefined()
expect(calls[1].headers['x-api-key']).toBe('sk-api')
expect(calls[1].headers.Authorization).toBeUndefined()
expect(calls[2].headers['x-api-key']).toBe('sk-dual')
expect(calls[2].headers.Authorization).toBe('Bearer sk-dual')
} finally {
globalThis.fetch = originalFetch
}
})
test('tests the proxy path for Anthropic providers that require media hoisting', async () => {
const originalFetch = globalThis.fetch
const calls: Array<{ body: Record<string, unknown> }> = []
globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
calls.push({ body: JSON.parse(String(init?.body)) as Record<string, unknown> })
return new Response(JSON.stringify({
type: 'message',
model: 'model-main',
content: [{ type: 'text', text: 'ok' }],
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as typeof fetch
try {
const svc = new ProviderService()
const result = await svc.testProviderConfig({
baseUrl: 'https://api.example.com/anthropic',
apiKey: 'sk-api',
modelId: 'model-main',
authStrategy: 'api_key',
apiFormat: 'anthropic',
supportsNestedToolResultMedia: false,
})
expect(result.connectivity.success).toBe(true)
expect(result.proxy?.success).toBe(true)
expect(calls).toHaveLength(2)
} finally {
globalThis.fetch = originalFetch
}
})
test('normalizes context-window suffixes for Anthropic-compatible connectivity tests', async () => {
const originalFetch = globalThis.fetch
const calls: Array<{ body: Record<string, unknown> }> = []
globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
calls.push({ body: JSON.parse(String(init?.body)) as Record<string, unknown> })
return new Response(JSON.stringify({
type: 'message',
model: 'mimo-v2.5-pro',
content: [],
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as typeof fetch
try {
const svc = new ProviderService()
const result = await svc.testProviderConfig({
baseUrl: 'https://api.xiaomimimo.com/anthropic',
apiKey: 'sk-api',
modelId: 'mimo-v2.5-pro[1m]',
authStrategy: 'auth_token',
apiFormat: 'anthropic',
})
expect(result.connectivity.success).toBe(true)
expect(result.connectivity.modelUsed).toBe('mimo-v2.5-pro')
expect(calls[0].body.model).toBe('mimo-v2.5-pro')
} finally {
globalThis.fetch = originalFetch
}
})
test('normalizes context-window suffixes for provider proxy pipeline tests', async () => {
const originalFetch = globalThis.fetch
const calls: Array<{ body: Record<string, unknown> }> = []
globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
calls.push({ body: JSON.parse(String(init?.body)) as Record<string, unknown> })
return new Response(JSON.stringify({
id: 'chatcmpl-1',
object: 'chat.completion',
created: 0,
model: 'mimo-v2.5-pro',
choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as typeof fetch
try {
const svc = new ProviderService()
const result = await svc.testProviderConfig({
baseUrl: 'https://api.example.com',
apiKey: 'sk-api',
modelId: 'mimo-v2.5-pro[1m]',
authStrategy: 'api_key',
apiFormat: 'openai_chat',
})
expect(result.connectivity.success).toBe(true)
expect(result.proxy?.success).toBe(true)
expect(result.connectivity.modelUsed).toBe('mimo-v2.5-pro')
expect(result.proxy?.modelUsed).toBe('mimo-v2.5-pro')
expect(calls.map((call) => call.body.model)).toEqual(['mimo-v2.5-pro', 'mimo-v2.5-pro'])
} finally {
globalThis.fetch = originalFetch
}
})
test('requests non-stream OpenAI Chat responses during provider tests', async () => {
const originalFetch = globalThis.fetch
const calls: Array<{ body: Record<string, unknown> }> = []
globalThis.fetch = mock(async (_url: string | URL | Request, init?: RequestInit) => {
calls.push({ body: JSON.parse(String(init?.body)) as Record<string, unknown> })
return new Response(JSON.stringify({
id: 'chatcmpl-1',
object: 'chat.completion',
created: 0,
model: 'deepseek-v4-flash',
choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as typeof fetch
try {
const svc = new ProviderService()
const result = await svc.testProviderConfig({
baseUrl: 'https://api.example.com',
apiKey: 'sk-api',
modelId: 'deepseek-v4-flash',
authStrategy: 'api_key',
apiFormat: 'openai_chat',
})
expect(result.connectivity.success).toBe(true)
expect(result.proxy?.success).toBe(true)
expect(calls.map((call) => call.body.stream)).toEqual([false, false])
} finally {
globalThis.fetch = originalFetch
}
})
test('bypasses manual proxy options when testing loopback provider endpoints', async () => {
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({
network: {
proxy: { mode: 'manual', url: 'http://127.0.0.1:1181' },
},
}),
'utf-8',
)
const originalFetch = globalThis.fetch
const calls: Array<{ url: string; proxy?: string }> = []
globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
calls.push({
url: String(url),
proxy: (init as RequestInit & { proxy?: string } | undefined)?.proxy,
})
return new Response(JSON.stringify({
id: 'chatcmpl-1',
object: 'chat.completion',
created: 0,
model: 'local-model',
choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as typeof fetch
try {
const svc = new ProviderService()
const result = await svc.testProviderConfig({
baseUrl: 'http://127.0.0.1:11434',
apiKey: 'local-key',
modelId: 'local-model',
authStrategy: 'api_key',
apiFormat: 'openai_chat',
})
expect(result.connectivity.success).toBe(true)
expect(result.proxy?.success).toBe(true)
expect(calls.map((call) => call.url)).toEqual([
'http://127.0.0.1:11434/v1/chat/completions',
'http://127.0.0.1:11434/v1/chat/completions',
])
expect(calls.map((call) => call.proxy)).toEqual([undefined, undefined])
} finally {
globalThis.fetch = originalFetch
}
})
test('bypasses inherited system proxy when testing direct provider endpoints', async () => {
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({
network: {
proxy: { mode: 'direct', url: '' },
},
}),
'utf-8',
)
const originalFetch = globalThis.fetch
const originalHttpProxy = process.env.HTTP_PROXY
const originalHttpsProxy = process.env.HTTPS_PROXY
const originalLowerHttpProxy = process.env.http_proxy
const originalLowerHttpsProxy = process.env.https_proxy
const calls: Array<{ url: string; proxy?: string }> = []
process.env.HTTP_PROXY = 'http://127.0.0.1:1181'
process.env.HTTPS_PROXY = 'http://127.0.0.1:1181'
delete process.env.http_proxy
delete process.env.https_proxy
globalThis.fetch = mock(async (url: string | URL | Request, init?: RequestInit) => {
calls.push({
url: String(url),
proxy: (init as RequestInit & { proxy?: string } | undefined)?.proxy,
})
return new Response(JSON.stringify({
id: 'chatcmpl-direct',
object: 'chat.completion',
created: 0,
model: 'remote-model',
choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as typeof fetch
try {
const svc = new ProviderService()
const result = await svc.testProviderConfig({
baseUrl: 'https://api.example.com',
apiKey: 'remote-key',
modelId: 'remote-model',
authStrategy: 'api_key',
apiFormat: 'openai_chat',
})
expect(result.connectivity.success).toBe(true)
expect(result.proxy?.success).toBe(true)
expect(calls.map((call) => call.url)).toEqual([
'https://api.example.com/v1/chat/completions',
'https://api.example.com/v1/chat/completions',
])
expect(calls.map((call) => call.proxy)).toEqual([undefined, undefined])
} finally {
globalThis.fetch = originalFetch
if (originalHttpProxy === undefined) delete process.env.HTTP_PROXY
else process.env.HTTP_PROXY = originalHttpProxy
if (originalHttpsProxy === undefined) delete process.env.HTTPS_PROXY
else process.env.HTTPS_PROXY = originalHttpsProxy
if (originalLowerHttpProxy === undefined) delete process.env.http_proxy
else process.env.http_proxy = originalLowerHttpProxy
if (originalLowerHttpsProxy === undefined) delete process.env.https_proxy
else process.env.https_proxy = originalLowerHttpsProxy
}
})
test.each([180_000, 14_400_000, 21_600_000])('should use the configured network timeout for provider tests (%i ms)', async timeoutMs => {
await fs.writeFile(
path.join(tmpDir, 'settings.json'),
JSON.stringify({
network: {
aiRequestTimeoutMs: timeoutMs,
proxy: { mode: 'system', url: '' },
},
}),
'utf-8',
)
const originalFetch = globalThis.fetch
const originalTimeout = AbortSignal.timeout
const timeoutCalls: number[] = []
globalThis.fetch = mock(async (_url: string | URL | Request, _init?: RequestInit) => {
return new Response(JSON.stringify({
type: 'message',
model: 'model-main',
content: [],
}), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}) as typeof fetch
AbortSignal.timeout = ((ms: number) => {
timeoutCalls.push(ms)
return originalTimeout(ms)
}) as typeof AbortSignal.timeout
try {
const svc = new ProviderService()
await svc.testProviderConfig({
baseUrl: 'https://api.example.com/anthropic',
apiKey: 'sk-api',
modelId: 'model-main',
authStrategy: 'api_key',
apiFormat: 'anthropic',
})
expect(timeoutCalls).toEqual([timeoutMs])
} finally {
AbortSignal.timeout = originalTimeout
globalThis.fetch = originalFetch
}
})
})
})
// =============================================================================
// Providers REST API
// =============================================================================
describe('Providers API', () => {
beforeEach(setup)
afterEach(teardown)
// ─── GET /api/providers ──────────────────────────────────────────────────
test('GET /api/providers should return empty list initially', async () => {
const { req, url, segments } = makeRequest('GET', '/api/providers')
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(200)
const body = (await res.json()) as { providers: unknown[] }
expect(body.providers).toEqual([])
})
test('GET /api/providers should list added providers', async () => {
// Seed a provider via service
const svc = new ProviderService()
await svc.addProvider(sampleInput())
const { req, url, segments } = makeRequest('GET', '/api/providers')
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(200)
const body = (await res.json()) as { providers: { name: string; apiKey: string }[] }
expect(body.providers).toHaveLength(1)
expect(body.providers[0].name).toBe('Test Provider')
expect(body.providers[0].apiKey).toBe('sk-test-key-123')
})
// ─── POST /api/providers ─────────────────────────────────────────────────
test('POST /api/providers should create a provider', async () => {
const { req, url, segments } = makeRequest('POST', '/api/providers', {
presetId: 'custom',
name: 'New Provider',
baseUrl: 'https://api.example.com',
apiKey: 'sk-test',
apiFormat: 'anthropic',
autoCompactWindow: 64000,
disableExperimentalBetas: true,
models: {
main: 'gpt-4',
haiku: 'gpt-4-haiku',
sonnet: 'gpt-4-sonnet',
opus: 'gpt-4-opus',
},
})
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(201)
const body = (await res.json()) as { provider: { name: string; models: { main: string }; autoCompactWindow: number; disableExperimentalBetas?: boolean } }
expect(body.provider.name).toBe('New Provider')
expect(body.provider.models.main).toBe('gpt-4')
expect(body.provider.autoCompactWindow).toBe(64000)
expect(body.provider.disableExperimentalBetas).toBe(true)
})
test('POST /api/providers should return 400 for invalid input', async () => {
const { req, url, segments } = makeRequest('POST', '/api/providers', {
name: '', // invalid: empty name
})
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(400)
})
// ─── PUT /api/providers/reorder ──────────────────────────────────────────
test('PUT /api/providers/reorder should reorder providers', async () => {
const svc = new ProviderService()
const a = await svc.addProvider(sampleInput({ name: 'A' }))
const b = await svc.addProvider(sampleInput({ name: 'B' }))
const { req, url, segments } = makeRequest('PUT', '/api/providers/reorder', {
orderedIds: [b.id, a.id],
})
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(200)
const body = (await res.json()) as { providers: { name: string }[]; providerOrder: string[] }
expect(body.providers.map((p) => p.name)).toEqual(['B', 'A'])
expect(body.providerOrder).toEqual([
b.id,
a.id,
'claude-official',
'openai-official',
'grok-official',
])
})
test('PUT /api/providers/reorder should return 400 for a non-permutation', async () => {
const svc = new ProviderService()
const a = await svc.addProvider(sampleInput({ name: 'A' }))
await svc.addProvider(sampleInput({ name: 'B' }))
const { req, url, segments } = makeRequest('PUT', '/api/providers/reorder', {
orderedIds: [a.id], // missing B
})
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(400)
})
test('PUT /api/providers/reorder should return 400 for empty orderedIds', async () => {
const { req, url, segments } = makeRequest('PUT', '/api/providers/reorder', {
orderedIds: [],
})
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(400)
})
test('POST /api/providers/reorder should be method-not-allowed', async () => {
const { req, url, segments } = makeRequest('POST', '/api/providers/reorder', {
orderedIds: [],
})
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(405)
})
test('POST /api/providers should return 400 for invalid auto compact window', async () => {
const { req, url, segments } = makeRequest('POST', '/api/providers', {
presetId: 'custom',
name: 'New Provider',
baseUrl: 'https://api.example.com',
apiKey: 'sk-test',
apiFormat: 'anthropic',
autoCompactWindow: 8000,
models: {
main: 'gpt-4',
haiku: 'gpt-4-haiku',
sonnet: 'gpt-4-sonnet',
opus: 'gpt-4-opus',
},
})
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(400)
})
// ─── GET /api/providers/:id ──────────────────────────────────────────────
test('GET /api/providers/:id should return a provider', async () => {
const svc = new ProviderService()
const added = await svc.addProvider(sampleInput())
const { req, url, segments } = makeRequest('GET', `/api/providers/${added.id}`)
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(200)
const body = (await res.json()) as { provider: { id: string; name: string } }
expect(body.provider.id).toBe(added.id)
})
test('GET /api/providers/:id should return 404 for unknown id', async () => {
const { req, url, segments } = makeRequest('GET', '/api/providers/unknown-id')
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(404)
})
// ─── PUT /api/providers/:id ──────────────────────────────────────────────
test('PUT /api/providers/:id should update a provider', async () => {
const svc = new ProviderService()
const added = await svc.addProvider(sampleInput())
const { req, url, segments } = makeRequest('PUT', `/api/providers/${added.id}`, {
name: 'Renamed Provider',
disableExperimentalBetas: true,
})
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(200)
const body = (await res.json()) as { provider: { name: string; disableExperimentalBetas?: boolean } }
expect(body.provider.name).toBe('Renamed Provider')
expect(body.provider.disableExperimentalBetas).toBe(true)
})
// ─── DELETE /api/providers/:id ───────────────────────────────────────────
test('DELETE /api/providers/:id should delete an inactive provider', async () => {
const svc = new ProviderService()
await svc.addProvider(sampleInput({ name: 'First' }))
const second = await svc.addProvider(sampleInput({ name: 'Second' }))
const { req, url, segments } = makeRequest('DELETE', `/api/providers/${second.id}`)
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(200)
const body = (await res.json()) as { ok: boolean }
expect(body.ok).toBe(true)
})
test('DELETE /api/providers/:id should return 409 for active provider', async () => {
const svc = new ProviderService()
const active = await svc.addProvider(sampleInput())
await svc.activateProvider(active.id)
const { req, url, segments } = makeRequest('DELETE', `/api/providers/${active.id}`)
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(409)
})
// ─── POST /api/providers/:id/activate ────────────────────────────────────
test('POST /api/providers/:id/activate should activate a provider', async () => {
const svc = new ProviderService()
await svc.addProvider(sampleInput({ name: 'First' }))
const second = await svc.addProvider(
sampleInput({
name: 'Second',
baseUrl: 'https://second.example.com',
apiKey: 'sk-second',
}),
)
const { req, url, segments } = makeRequest(
'POST',
`/api/providers/${second.id}/activate`,
)
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(200)
const body = (await res.json()) as { ok: boolean }
expect(body.ok).toBe(true)
// Verify settings were synced
const settings = await readSettings()
const env = settings.env as Record<string, string>
expect(env.ANTHROPIC_BASE_URL).toBe('https://second.example.com')
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('sk-second')
expect(env.ANTHROPIC_API_KEY).toBe('')
expect(env.ANTHROPIC_MODEL).toBe('model-main')
})
test('POST /api/providers/:id/activate should not require modelId', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput())
const { req, url, segments } = makeRequest(
'POST',
`/api/providers/${provider.id}/activate`,
{},
)
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(200)
})
test('POST /api/providers/:id/activate should ignore modelId because session runtime selects the model', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput())
const { req, url, segments } = makeRequest(
'POST',
`/api/providers/${provider.id}/activate`,
{ modelId: 'non-existent-model' },
)
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(200)
})
test('GET and PUT /api/providers/:id/models configure a built-in OAuth provider', async () => {
const getRequest = makeRequest('GET', '/api/providers/openai-official/models')
const initial = await handleProvidersApi(
getRequest.req,
getRequest.url,
getRequest.segments,
)
expect(initial.status).toBe(200)
expect(await initial.json()).toMatchObject({
models: { main: 'gpt-6-sol', opus: 'gpt-6-astra' },
})
const configured = {
main: 'gpt-6-astra',
haiku: 'gpt-6-luna',
sonnet: 'gpt-6-astra',
opus: 'gpt-6-astra',
}
const putRequest = makeRequest('PUT', '/api/providers/openai-official/models', {
models: configured,
})
const updated = await handleProvidersApi(
putRequest.req,
putRequest.url,
putRequest.segments,
)
expect(updated.status).toBe(200)
expect(await updated.json()).toEqual({ models: configured })
})
test('PUT /api/providers/:id/models rejects saved providers', async () => {
const svc = new ProviderService()
const provider = await svc.addProvider(sampleInput())
const request = makeRequest('PUT', `/api/providers/${provider.id}/models`, {
models: provider.models,
})
const response = await handleProvidersApi(request.req, request.url, request.segments)
expect(response.status).toBe(404)
})
test('PUT /api/providers/:id/models rejects an empty main model', async () => {
const request = makeRequest('PUT', '/api/providers/openai-official/models', {
models: {
main: ' ',
haiku: '',
sonnet: '',
opus: '',
},
})
const response = await handleProvidersApi(request.req, request.url, request.segments)
expect(response.status).toBe(400)
})
// ─── Method not allowed ──────────────────────────────────────────────────
test('should return 405 for unsupported methods', async () => {
const { req, url, segments } = makeRequest('PATCH', '/api/providers')
const res = await handleProvidersApi(req, url, segments)
expect(res.status).toBe(405)
})
})
describe('ApiSmart preset request contract (offline fixtures)', () => {
beforeEach(setup)
afterEach(teardown)
const apiKey = 'sk-apismart-offline-fixture'
async function createApiSmart() {
const { PROVIDER_PRESETS } = await import('../config/providerPresets.js')
const preset = PROVIDER_PRESETS.find(item => item.id === 'apismart')!
expect(preset).toBeDefined()
// Exercise the same API boundary as the desktop's save action.
const { req, url, segments } = makeRequest('POST', '/api/providers', {
presetId: preset.id, name: preset.name, baseUrl: preset.baseUrl,
apiFormat: preset.apiFormat, apiKey, models: preset.defaultModels,
})
const result = await handleProvidersApi(req, url, segments)
expect(result.status).toBe(201)
const saved = await result.json()
return { preset, provider: saved.provider }
}
async function proxy(providerId: string, body: Record<string, unknown>) {
const { req, url } = makeRequest('POST', `/proxy/providers/${providerId}/v1/messages`, body)
return handleProxyRequest(req, url)
}
test('preset save, model fetch, connectivity, and actual chat all use exactly one /v1', async () => {
const { preset, provider } = await createApiSmart()
const originalFetch = globalThis.fetch
const calls: Array<{ url: string; method: string | undefined; body: any }> = []
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input)
expect(new Headers(init?.headers).get('authorization')).toBe(`Bearer ${apiKey}`)
const body = init?.body ? JSON.parse(String(init.body)) : undefined
calls.push({ url, method: init?.method, body })
if (url === 'https://gw.apismart.ai/v1/models' && init?.method === 'GET') {
return Response.json({ object: 'list', data: [
{ id: 'deepseek-v4-pro-0813', object: 'model', owned_by: 'deepseek' },
{ id: 'deepseek-v4-flash-0731-tem', object: 'model', owned_by: 'deepseek' },
] })
}
if (url !== 'https://gw.apismart.ai/v1/chat/completions' || init?.method !== 'POST') {
return Response.json({ error: 'Wrong request endpoint' }, { status: 404 })
}
return Response.json({
id: 'chatcmpl-offline', object: 'chat.completion', model: body.model,
choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 },
})
}) as typeof fetch
try {
const catalog = makeRequest('POST', '/api/providers/models', { baseUrl: preset.baseUrl, apiKey })
const models = await handleProvidersApi(catalog.req, catalog.url, catalog.segments)
expect(models.status).toBe(200)
expect(await models.json()).toMatchObject({ ok: true, models: [
{ id: 'deepseek-v4-flash-0731-tem' }, { id: 'deepseek-v4-pro-0813' },
] })
const check = await new ProviderService().testProviderConfig({
baseUrl: preset.baseUrl, apiKey, apiFormat: preset.apiFormat, modelId: preset.defaultModels.main,
})
expect(check.connectivity.success).toBe(true)
expect(check.proxy?.success).toBe(true)
for (const model of [preset.defaultModels.main, preset.defaultModels.haiku]) {
const response = await proxy(provider.id, {
model, max_tokens: 64, system: 'Be helpful.', messages: [{ role: 'user', content: 'hello' }],
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
model, content: [{ type: 'text', text: 'ok' }], stop_reason: 'end_turn',
})
expect(calls.at(-1)?.body).toMatchObject({ model, stream: false, messages: [
{ role: 'system', content: 'Be helpful.' }, { role: 'user', content: 'hello' },
] })
}
expect(calls.map(call => call.url)).toEqual([
'https://gw.apismart.ai/v1/models',
...Array(4).fill('https://gw.apismart.ai/v1/chat/completions'),
])
} finally {
globalThis.fetch = originalFetch
}
})
test('streamed tool calls survive the proxy and their result is sent back with DeepSeek reasoning', async () => {
const { preset, provider } = await createApiSmart()
const originalFetch = globalThis.fetch
const calls: any[] = []
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
expect(String(input)).toBe('https://gw.apismart.ai/v1/chat/completions')
expect(init?.method).toBe('POST')
expect(new Headers(init?.headers).get('authorization')).toBe(`Bearer ${apiKey}`)
calls.push(JSON.parse(String(init?.body)))
const chunks = calls.length === 1 ? [
{ delta: { role: 'assistant', reasoning_content: 'Need the weather.' } },
{ delta: { tool_calls: [{ index: 0, id: 'call_weather', type: 'function', function: { name: 'weather', arguments: '{"city":' } }] } },
{ delta: { tool_calls: [{ index: 0, function: { arguments: '"Paris"}' } }] } },
{ delta: {}, finish_reason: 'tool_calls' },
] : [
{ delta: { role: 'assistant', content: 'It is sunny.' } },
{ delta: {}, finish_reason: 'stop' },
]
const data = chunks.map(choice => `data: ${JSON.stringify({
id: 'chatcmpl-offline-tool', object: 'chat.completion.chunk', model: preset.defaultModels.main,
choices: [{ index: 0, ...choice }],
})}\n\n`).join('') + 'data: [DONE]\n\n'
return new Response(data, { headers: { 'Content-Type': 'text/event-stream' } })
}) as typeof fetch
try {
const tool = { name: 'weather', description: 'Get weather', input_schema: {
type: 'object', properties: { city: { type: 'string' } }, required: ['city'],
} }
const first = await proxy(provider.id, { model: preset.defaultModels.main, stream: true, max_tokens: 64,
messages: [{ role: 'user', content: 'Weather in Paris?' }], tools: [tool], tool_choice: { type: 'auto' },
})
expect(first.status).toBe(200)
const events = (await first.text()).split('\n\n').flatMap(block => {
const data = block.split('\n').find(line => line.startsWith('data: '))?.slice(6)
return data ? [JSON.parse(data)] : []
})
expect(calls[0]).toMatchObject({ stream: true, stream_options: { include_usage: true },
tools: [{ type: 'function', function: { name: 'weather', parameters: tool.input_schema } }], tool_choice: 'auto',
})
expect(events).toContainEqual(expect.objectContaining({ type: 'content_block_start', content_block: {
type: 'tool_use', id: 'call_weather', name: 'weather', input: {},
} }))
const toolInput = events.filter(event => event.delta?.type === 'input_json_delta')
.map(event => event.delta.partial_json).join('')
expect(JSON.parse(toolInput)).toEqual({ city: 'Paris' })
const thinking = events.filter(event => event.delta?.type === 'thinking_delta')
.map(event => event.delta.thinking).join('')
expect(thinking).toBe('Need the weather.')
expect(events.find(event => event.type === 'message_delta')?.delta.stop_reason).toBe('tool_use')
expect(events.at(-1)?.type).toBe('message_stop')
const second = await proxy(provider.id, { model: preset.defaultModels.main, stream: true, max_tokens: 64,
messages: [
{ role: 'user', content: 'Weather in Paris?' },
{ role: 'assistant', content: [
{ type: 'thinking', thinking },
{ type: 'tool_use', id: 'call_weather', name: 'weather', input: JSON.parse(toolInput) },
] },
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_weather', content: 'sunny' }] },
], tools: [tool],
})
expect(second.status).toBe(200)
expect(await second.text()).toContain('It is sunny.')
expect(calls[1].messages).toEqual([
{ role: 'user', content: 'Weather in Paris?' },
{ role: 'assistant', content: null, reasoning_content: 'Need the weather.', tool_calls: [
{ id: 'call_weather', type: 'function', function: { name: 'weather', arguments: '{"city":"Paris"}' } },
] },
{ role: 'tool', tool_call_id: 'call_weather', content: 'sunny' },
])
} finally {
globalThis.fetch = originalFetch
}
})
})
/**
* OpenCode Go binds the wire format to the URL path and does no cross-protocol
* translation, so one provider record has to reach three different endpoints
* depending on the model. These tests pin that contract: which URL each model
* family reaches, which credential header goes with it, and the client-identity
* headers the gateway refuses to serve without.
*/
describe('OpenCode Go preset request contract', () => {
beforeEach(setup)
afterEach(teardown)
const apiKey = 'sk-opencode-go-offline-fixture'
const SESSION_ID = 'session-opencode-go-1'
async function loadPreset() {
const { PROVIDER_PRESETS } = await import('../config/providerPresets.js')
const preset = PROVIDER_PRESETS.find(item => item.id === 'opencode-go')
expect(preset).toBeDefined()
return preset!
}
async function createProvider(overrides?: { presetId?: string; apiFormat?: string }) {
const preset = await loadPreset()
const { req, url, segments } = makeRequest('POST', '/api/providers', {
presetId: overrides?.presetId ?? preset.id,
name: preset.name,
baseUrl: preset.baseUrl,
apiFormat: overrides?.apiFormat ?? preset.apiFormat,
apiKey,
models: preset.defaultModels,
})
const result = await handleProvidersApi(req, url, segments)
expect(result.status).toBe(201)
const saved = await result.json()
return { preset, provider: saved.provider as { id: string } }
}
type UpstreamCall = { url: string; headers: Headers; body: any }
/**
* Records every upstream call and answers with a payload matching the endpoint
* that was reached, so a misrouted request surfaces as a transform failure
* rather than being masked by a permissive stub.
*/
function stubUpstream(calls: UpstreamCall[]) {
const originalFetch = globalThis.fetch
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input)
const body = init?.body ? JSON.parse(String(init.body)) : undefined
calls.push({ url, headers: new Headers(init?.headers), body })
if (url.endsWith('/chat/completions')) {
return Response.json({
id: 'chatcmpl-offline', object: 'chat.completion', model: body?.model,
choices: [{ index: 0, message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
usage: { prompt_tokens: 3, completion_tokens: 1, total_tokens: 4 },
})
}
if (url.endsWith('/responses')) {
return Response.json({
id: 'resp-offline', object: 'response', status: 'completed', model: body?.model,
output: [{ type: 'message', id: 'msg-1', role: 'assistant', status: 'completed',
content: [{ type: 'output_text', text: 'ok', annotations: [] }] }],
usage: { input_tokens: 3, output_tokens: 1, total_tokens: 4 },
})
}
if (url.endsWith('/messages')) {
return Response.json({
id: 'msg-offline', type: 'message', role: 'assistant', model: body?.model,
stop_reason: 'end_turn',
content: [{ type: 'text', text: 'ok' }],
usage: { input_tokens: 3, output_tokens: 1 },
})
}
return Response.json({ error: 'Wrong request endpoint' }, { status: 404 })
}) as typeof fetch
return () => { globalThis.fetch = originalFetch }
}
async function proxy(providerId: string, body: Record<string, unknown>, headers?: Record<string, string>) {
const req = new Request(`http://localhost:3456/proxy/providers/${providerId}/v1/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-claude-code-session-id': SESSION_ID, ...headers },
body: JSON.stringify(body),
})
return handleProxyRequest(req, new URL(req.url))
}
test('routes each model family to the endpoint the gateway serves it on, with that endpoint credential', async () => {
const { provider } = await createProvider()
const calls: UpstreamCall[] = []
const restore = stubUpstream(calls)
try {
const cases = [
{ model: 'glm-5.3', endpoint: '/zen/go/v1/chat/completions', credential: 'bearer' },
{ model: 'kimi-k3', endpoint: '/zen/go/v1/chat/completions', credential: 'bearer' },
{ model: 'minimax-m3', endpoint: '/zen/go/v1/messages', credential: 'x-api-key' },
{ model: 'qwen3.8-max', endpoint: '/zen/go/v1/messages', credential: 'x-api-key' },
{ model: 'union-alpha', endpoint: '/zen/go/v1/messages', credential: 'x-api-key' },
{ model: 'grok-4.6', endpoint: '/zen/go/v1/responses', credential: 'bearer' },
{ model: 'gpt-5.6-luna', endpoint: '/zen/go/v1/responses', credential: 'bearer' },
] as const
for (const { model, endpoint, credential } of cases) {
calls.length = 0
const response = await proxy(provider.id, {
model, max_tokens: 32, messages: [{ role: 'user', content: 'hello' }],
})
expect(response.status, `${model} status`).toBe(200)
// The response is transformed back into an Anthropic message for the CLI.
expect(await response.json(), `${model} body`).toMatchObject({
type: 'message', model, content: [{ type: 'text', text: 'ok' }],
})
expect(calls, `${model} calls`).toHaveLength(1)
expect(new URL(calls[0].url).pathname, `${model} endpoint`).toBe(endpoint)
expect(calls[0].body.model, `${model} upstream model`).toBe(model)
const headers = calls[0].headers
if (credential === 'x-api-key') {
expect(headers.get('x-api-key'), `${model} x-api-key`).toBe(apiKey)
// The Messages path parses only x-api-key; a Bearer there is ignored.
expect(headers.get('authorization'), `${model} must not send Bearer`).toBeNull()
} else {
expect(headers.get('authorization'), `${model} bearer`).toBe(`Bearer ${apiKey}`)
expect(headers.get('x-api-key'), `${model} must not send x-api-key`).toBeNull()
}
}
} finally {
restore()
}
})
test('sends the per-conversation session id and own client id on every path', async () => {
const { provider } = await createProvider()
const calls: UpstreamCall[] = []
const restore = stubUpstream(calls)
try {
for (const model of ['glm-5.3', 'minimax-m3', 'grok-4.6']) {
calls.length = 0
const response = await proxy(provider.id, {
model, max_tokens: 32, messages: [{ role: 'user', content: 'hello' }],
})
expect(response.status, `${model} status`).toBe(200)
// Without this the gateway answers 400 MissingSessionID on every path.
expect(calls[0].headers.get('x-opencode-session'), `${model} session`).toBe(SESSION_ID)
// Identify as this client, never as a generic HTTP library or the CLI we fork.
expect(calls[0].headers.get('user-agent'), `${model} ua`).toMatch(/^cc-haha\//)
expect(calls[0].headers.get('user-agent'), `${model} ua`).not.toContain('claude-cli/')
}
} finally {
restore()
}
})
test('omits the session header rather than sending an empty one without a conversation', async () => {
const { provider } = await createProvider()
const calls: UpstreamCall[] = []
const restore = stubUpstream(calls)
try {
const req = new Request(`http://localhost:3456/proxy/providers/${provider.id}/v1/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'glm-5.3', max_tokens: 32, messages: [{ role: 'user', content: 'hi' }] }),
})
expect((await handleProxyRequest(req, new URL(req.url))).status).toBe(200)
expect(calls[0].headers.get('x-opencode-session')).toBeNull()
} finally {
restore()
}
})
test('does not run the nested-media compatibility rewrite on a per-model Messages route', async () => {
const { provider } = await createProvider()
const calls: UpstreamCall[] = []
const restore = stubUpstream(calls)
try {
const response = await proxy(provider.id, {
model: 'minimax-m3',
max_tokens: 32,
messages: [{
role: 'user',
content: [{
type: 'tool_result',
tool_use_id: 'tool-1',
content: [
{ type: 'text', text: 'screenshot' },
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } },
],
}],
}],
})
expect(response.status).toBe(200)
// A native Messages endpoint accepts nested media, so lifting it out would
// be an unrequested rewrite of the caller's request.
expect(calls[0].body.messages[0].content[0]).toMatchObject({
type: 'tool_result',
content: [
{ type: 'text', text: 'screenshot' },
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } },
],
})
} finally {
restore()
}
})
test('keeps forwarding an anthropic-format provider without per-model rules as before', async () => {
const { provider } = await createProvider({ presetId: 'custom', apiFormat: 'anthropic' })
const calls: UpstreamCall[] = []
const restore = stubUpstream(calls)
try {
// No per-model override and nesting supported: the CLI talks to the
// upstream directly, so the proxy is not the right entry point.
const response = await proxy(provider.id, {
model: 'glm-5.3', max_tokens: 32, messages: [{ role: 'user', content: 'hi' }],
})
expect(response.status).toBe(400)
expect(calls).toHaveLength(0)
} finally {
restore()
}
})
test('does not leak the gateway client headers onto an unrelated provider', async () => {
const { provider } = await createProvider({ presetId: 'custom', apiFormat: 'openai_chat' })
const calls: UpstreamCall[] = []
const restore = stubUpstream(calls)
try {
const response = await proxy(provider.id, {
model: 'glm-5.3', max_tokens: 32, messages: [{ role: 'user', content: 'hi' }],
})
expect(response.status).toBe(200)
expect(calls[0].url).toBe('https://opencode.ai/zen/go/v1/chat/completions')
expect(calls[0].headers.get('x-opencode-session')).toBeNull()
expect(calls[0].headers.get('user-agent') ?? '').not.toMatch(/^cc-haha\//)
} finally {
restore()
}
})
test('the connectivity probe resolves the per-model protocol and sends the session header', async () => {
const calls: UpstreamCall[] = []
const restore = stubUpstream(calls)
try {
const service = new ProviderService()
for (const [model, endpoint] of [
['glm-5.3', '/chat/completions'],
['minimax-m3', '/messages'],
['grok-4.6', '/responses'],
] as const) {
calls.length = 0
const result = await service.testProviderConfig({
baseUrl: 'https://opencode.ai/zen/go/v1',
apiKey,
modelId: model,
apiFormat: 'openai_chat',
presetId: 'opencode-go',
})
expect(result.connectivity.success, `${model} connectivity`).toBe(true)
expect(result.proxy?.success, `${model} pipeline`).toBe(true)
expect(calls.every(call => call.url.endsWith(endpoint)), `${model} endpoints`).toBe(true)
expect(calls.every(call => call.headers.get('x-opencode-session')), `${model} session`).toBe(true)
}
} finally {
restore()
}
})
test('a record apiFormat that contradicts the preset cannot disable per-model routing', async () => {
// cc-switch imports default the record to anthropic, and the edit form lets it
// be changed by hand. Either way the preset owns the decision, because one
// recorded value cannot express a per-model split — and a record that won would
// send every model down one endpoint with no session header.
const { provider } = await createProvider({ presetId: 'opencode-go', apiFormat: 'anthropic' })
const calls: UpstreamCall[] = []
const restore = stubUpstream(calls)
try {
for (const [model, endpoint] of [
['glm-5.3', '/zen/go/v1/chat/completions'],
['minimax-m3', '/zen/go/v1/messages'],
] as const) {
calls.length = 0
const response = await proxy(provider.id, {
model, max_tokens: 32, messages: [{ role: 'user', content: 'hello' }],
})
expect(response.status, `${model} status`).toBe(200)
expect(new URL(calls[0].url).pathname, `${model} endpoint`).toBe(endpoint)
expect(calls[0].headers.get('x-opencode-session'), `${model} session`).toBe(SESSION_ID)
}
} finally {
restore()
}
})
test('preset client headers win over headers the caller passed in', async () => {
// The anthropic path forwards the caller's custom headers, so a client that
// sends its own user agent or session id would otherwise out-rank the values
// the gateway requires.
const { provider } = await createProvider()
const calls: UpstreamCall[] = []
const restore = stubUpstream(calls)
try {
const response = await proxy(
provider.id,
{ model: 'minimax-m3', max_tokens: 32, messages: [{ role: 'user', content: 'hello' }] },
{ 'user-agent': 'some-third-party-sdk/1.0', 'x-opencode-session': 'caller-supplied-bogus' },
)
expect(response.status).toBe(200)
expect(calls[0].headers.get('user-agent')).toMatch(/^cc-haha\//)
expect(calls[0].headers.get('x-opencode-session')).toBe(SESSION_ID)
} finally {
restore()
}
})
test('resolves the probe format from the preset even when the payload contradicts it', async () => {
const calls: UpstreamCall[] = []
const restore = stubUpstream(calls)
try {
const result = await new ProviderService().testProviderConfig({
baseUrl: 'https://opencode.ai/zen/go/v1',
apiKey,
modelId: 'glm-5.3',
apiFormat: 'anthropic',
presetId: 'opencode-go',
})
expect(result.connectivity.success).toBe(true)
expect(result.proxy?.success).toBe(true)
expect(calls.every(call => call.url.endsWith('/chat/completions'))).toBe(true)
expect(calls.every(call => call.headers.get('x-opencode-session'))).toBe(true)
} finally {
restore()
}
})
})