956 lines
31 KiB
JavaScript
956 lines
31 KiB
JavaScript
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
// readConfig reads merged opencode config layers from disk; mock it so each
|
|
// test controls the provider config without touching the filesystem. call.js
|
|
// imports only readConfig from shared.js, so the rest of that module is left
|
|
// untouched for this file.
|
|
vi.mock('../opencode/shared.js', () => ({
|
|
readConfig: vi.fn(),
|
|
readConfigLayers: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('./runtime-providers.js', () => ({ getRuntimeProvider: vi.fn(async () => null) }));
|
|
|
|
const { callSmallModel } = await import('./call.js');
|
|
const { readConfig, readConfigLayers } = await import('../opencode/shared.js');
|
|
const { getRuntimeProvider } = await import('./runtime-providers.js');
|
|
|
|
// Minimal catalog fragment used by the catalog-based base URL resolution case.
|
|
const CATALOG = {
|
|
mistral: {
|
|
id: 'mistral',
|
|
name: 'Mistral',
|
|
api: 'https://api.mistral.ai/v1',
|
|
models: {
|
|
'mistral-small-latest': { id: 'mistral-small-latest' },
|
|
},
|
|
},
|
|
};
|
|
|
|
const ok = (content) => ({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
choices: [{ message: { content }, finish_reason: 'stop' }],
|
|
}),
|
|
text: async () => JSON.stringify({
|
|
choices: [{ message: { content }, finish_reason: 'stop' }],
|
|
}),
|
|
});
|
|
|
|
const lastCall = (mock) => {
|
|
const [url, init] = mock.mock.calls.at(-1);
|
|
return { url: String(url), init };
|
|
};
|
|
|
|
// Regression coverage for the small-model dispatch to custom OpenAI-compatible
|
|
// providers — credential and endpoint resolution, precedence, and non-leakage.
|
|
describe('callSmallModel — custom provider config', () => {
|
|
let fetchMock;
|
|
let originalFetch;
|
|
|
|
beforeEach(() => {
|
|
fetchMock = vi.fn();
|
|
originalFetch = globalThis.fetch;
|
|
globalThis.fetch = fetchMock;
|
|
readConfig.mockReset();
|
|
readConfigLayers.mockReset();
|
|
// Default: OpenCode knows nothing, so resolution stays file-based.
|
|
getRuntimeProvider.mockReset();
|
|
getRuntimeProvider.mockResolvedValue(null);
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
delete process.env.OPENCHAMBER_TEST_PROVIDER_KEY;
|
|
});
|
|
|
|
describe('config-supplied credentials (no auth.json entry)', () => {
|
|
it('resolves an OpenCode file variable before sending the API key', async () => {
|
|
const secretPath = path.join(os.homedir(), '.secret');
|
|
const originalReadFileSync = fs.readFileSync;
|
|
vi.spyOn(fs, 'readFileSync').mockImplementation((filePath, ...args) => {
|
|
if (filePath === secretPath) return 'sk-file-key\n';
|
|
return originalReadFileSync(filePath, ...args);
|
|
});
|
|
readConfig.mockReturnValue({
|
|
provider: {
|
|
custom: {
|
|
options: { apiKey: '{file:~/.secret}', baseURL: 'https://proxy.example.test/v1' },
|
|
},
|
|
},
|
|
});
|
|
fetchMock.mockResolvedValue(ok('hello'));
|
|
|
|
await callSmallModel({
|
|
auth: {},
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'custom',
|
|
modelID: 'model',
|
|
prompt: 'hi',
|
|
});
|
|
|
|
expect(lastCall(fetchMock).init.headers.Authorization).toBe('Bearer sk-file-key');
|
|
expect(JSON.stringify(fetchMock.mock.calls[0][1])).not.toContain('{file:');
|
|
});
|
|
|
|
it('resolves an OpenCode environment variable before sending the API key', async () => {
|
|
process.env.OPENCHAMBER_TEST_PROVIDER_KEY = 'sk-env-key';
|
|
readConfig.mockReturnValue({
|
|
provider: {
|
|
custom: {
|
|
options: { apiKey: '{env:OPENCHAMBER_TEST_PROVIDER_KEY}', baseURL: 'https://proxy.example.test/v1' },
|
|
},
|
|
},
|
|
});
|
|
fetchMock.mockResolvedValue(ok('hello'));
|
|
|
|
await callSmallModel({
|
|
auth: {},
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'custom',
|
|
modelID: 'model',
|
|
prompt: 'hi',
|
|
});
|
|
|
|
expect(lastCall(fetchMock).init.headers.Authorization).toBe('Bearer sk-env-key');
|
|
});
|
|
|
|
it('uses apiKey and baseURL from provider config when no auth.json entry exists', async () => {
|
|
readConfig.mockReturnValue({
|
|
provider: {
|
|
custom: {
|
|
options: { apiKey: 'test-key', baseURL: 'https://proxy.example.test/v1' },
|
|
},
|
|
},
|
|
});
|
|
fetchMock.mockResolvedValue(ok('hello'));
|
|
|
|
const text = await callSmallModel({
|
|
auth: {},
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'custom',
|
|
modelID: 'gpt-4o-mini',
|
|
prompt: 'hi',
|
|
});
|
|
|
|
expect(text).toBe('hello');
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
|
|
const { url, init } = lastCall(fetchMock);
|
|
// Config baseURL is used, never the hardcoded OpenAI endpoint.
|
|
expect(url).toBe('https://proxy.example.test/v1/chat/completions');
|
|
expect(url).not.toContain('api.openai.com');
|
|
// Config apiKey becomes the bearer credential.
|
|
expect(init.headers.Authorization).toBe('Bearer test-key');
|
|
});
|
|
|
|
it('trims a trailing slash from the configured baseURL', async () => {
|
|
readConfig.mockReturnValue({
|
|
provider: {
|
|
custom: { options: { apiKey: 'k', baseURL: 'https://proxy.example.test/v1/' } },
|
|
},
|
|
});
|
|
fetchMock.mockResolvedValue(ok('ok'));
|
|
|
|
await callSmallModel({
|
|
auth: {},
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'custom',
|
|
modelID: 'gpt-4o-mini',
|
|
prompt: 'hi',
|
|
});
|
|
|
|
expect(lastCall(fetchMock).url).toBe('https://proxy.example.test/v1/chat/completions');
|
|
});
|
|
|
|
it('throws "No OpenCode login found for provider" when neither auth.json nor config apiKey exists', async () => {
|
|
readConfig.mockReturnValue({
|
|
provider: { custom: { options: { baseURL: 'https://proxy.example.test/v1' } } },
|
|
});
|
|
|
|
const error = await callSmallModel({
|
|
auth: {},
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'custom',
|
|
modelID: 'gpt-4o-mini',
|
|
prompt: 'hi',
|
|
}).then(() => null, (e) => e);
|
|
|
|
expect(error).toMatchObject({
|
|
message: 'No OpenCode login found for provider "custom"',
|
|
code: 'no-provider-login',
|
|
statusCode: 401,
|
|
providerID: 'custom',
|
|
});
|
|
|
|
// The credential gate fires before any network call.
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('treats a blank/whitespace apiKey in config as absent', async () => {
|
|
readConfig.mockReturnValue({
|
|
provider: {
|
|
custom: { options: { apiKey: ' ', baseURL: 'https://proxy.example.test/v1' } },
|
|
},
|
|
});
|
|
|
|
await expect(callSmallModel({
|
|
auth: {},
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'custom',
|
|
modelID: 'gpt-4o-mini',
|
|
prompt: 'hi',
|
|
})).rejects.toThrow('No OpenCode login found for provider "custom"');
|
|
});
|
|
});
|
|
|
|
describe('resolution order when auth.json is also present', () => {
|
|
it('uses the auth.json credential with the config baseURL', async () => {
|
|
readConfig.mockReturnValue({
|
|
provider: { custom: { options: { baseURL: 'https://proxy.example.test/v1' } } },
|
|
});
|
|
fetchMock.mockResolvedValue(ok('done'));
|
|
|
|
const text = await callSmallModel({
|
|
auth: { custom: { type: 'api', key: 'authjson-key' } },
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'custom',
|
|
modelID: 'gpt-4o-mini',
|
|
prompt: 'hi',
|
|
});
|
|
|
|
expect(text).toBe('done');
|
|
const { url, init } = lastCall(fetchMock);
|
|
expect(url).toBe('https://proxy.example.test/v1/chat/completions');
|
|
expect(init.headers.Authorization).toBe('Bearer authjson-key');
|
|
});
|
|
|
|
it('prefers the config apiKey over an auth.json credential when both are present (matches OpenCode)', async () => {
|
|
readConfig.mockReturnValue({
|
|
provider: {
|
|
custom: { options: { apiKey: 'config-key', baseURL: 'https://proxy.example.test/v1' } },
|
|
},
|
|
});
|
|
fetchMock.mockResolvedValue(ok('done'));
|
|
|
|
await callSmallModel({
|
|
auth: { custom: { type: 'api', key: 'authjson-key' } },
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'custom',
|
|
modelID: 'gpt-4o-mini',
|
|
prompt: 'hi',
|
|
});
|
|
|
|
// OpenCode's resolveSDK reads options.apiKey first and only falls back to
|
|
// auth.json's key when config has none — so the config key wins and the
|
|
// auth.json credential must never be sent.
|
|
expect(lastCall(fetchMock).init.headers.Authorization).toBe('Bearer config-key');
|
|
expect(JSON.stringify(fetchMock.mock.calls[0][1])).not.toContain('authjson-key');
|
|
});
|
|
});
|
|
|
|
describe('openai provider custom baseURL override', () => {
|
|
it('respects provider.openai.options.baseURL over the hardcoded OpenAI endpoint', async () => {
|
|
readConfig.mockReturnValue({
|
|
provider: { openai: { options: { baseURL: 'https://gateway.example.test/v1' } } },
|
|
});
|
|
fetchMock.mockResolvedValue(ok('ok'));
|
|
|
|
await callSmallModel({
|
|
auth: { openai: { type: 'api', key: 'sk-openai' } },
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'openai',
|
|
modelID: 'gpt-4o-mini',
|
|
prompt: 'hi',
|
|
});
|
|
|
|
const { url, init } = lastCall(fetchMock);
|
|
expect(url).toBe('https://gateway.example.test/v1/chat/completions');
|
|
expect(url).not.toContain('api.openai.com');
|
|
expect(init.headers.Authorization).toBe('Bearer sk-openai');
|
|
});
|
|
|
|
it('falls back to https://api.openai.com/v1 when no openai baseURL override is configured', async () => {
|
|
readConfig.mockReturnValue({});
|
|
fetchMock.mockResolvedValue(ok('ok'));
|
|
|
|
await callSmallModel({
|
|
auth: { openai: { type: 'api', key: 'sk-openai' } },
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'openai',
|
|
modelID: 'gpt-4o-mini',
|
|
prompt: 'hi',
|
|
});
|
|
|
|
expect(lastCall(fetchMock).url).toBe('https://api.openai.com/v1/chat/completions');
|
|
});
|
|
|
|
it('still requires a credential: a baseURL alone does not authenticate openai', async () => {
|
|
readConfig.mockReturnValue({
|
|
provider: { openai: { options: { baseURL: 'https://gateway.example.test/v1' } } },
|
|
});
|
|
|
|
await expect(callSmallModel({
|
|
auth: {},
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'openai',
|
|
modelID: 'gpt-4o-mini',
|
|
prompt: 'hi',
|
|
})).rejects.toThrow('No OpenCode login found for provider "openai"');
|
|
});
|
|
});
|
|
|
|
describe('anthropic provider custom baseURL override', () => {
|
|
const anthropicOk = (text) => ({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ content: [{ type: 'text', text }] }),
|
|
});
|
|
|
|
it('respects provider.anthropic.options.baseURL over the hardcoded Anthropic endpoint', async () => {
|
|
readConfig.mockReturnValue({
|
|
provider: { anthropic: { options: { baseURL: 'http://127.0.0.1:3456/v1' } } },
|
|
});
|
|
fetchMock.mockResolvedValue(anthropicOk('ok'));
|
|
|
|
await callSmallModel({
|
|
auth: { anthropic: { type: 'api', key: 'dummy' } },
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'anthropic',
|
|
modelID: 'claude-haiku-4-5',
|
|
prompt: 'hi',
|
|
});
|
|
|
|
const { url, init } = lastCall(fetchMock);
|
|
expect(url).toBe('http://127.0.0.1:3456/v1/messages');
|
|
expect(url).not.toContain('api.anthropic.com');
|
|
expect(init.headers['x-api-key']).toBe('dummy');
|
|
});
|
|
|
|
it('uses a bare-host baseURL as-is without inserting /v1, matching @ai-sdk/anthropic', async () => {
|
|
readConfig.mockReturnValue({
|
|
provider: { anthropic: { options: { baseURL: 'http://127.0.0.1:3456' } } },
|
|
});
|
|
fetchMock.mockResolvedValue(anthropicOk('ok'));
|
|
|
|
await callSmallModel({
|
|
auth: { anthropic: { type: 'api', key: 'dummy' } },
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'anthropic',
|
|
modelID: 'claude-haiku-4-5',
|
|
prompt: 'hi',
|
|
});
|
|
|
|
expect(lastCall(fetchMock).url).toBe('http://127.0.0.1:3456/messages');
|
|
});
|
|
|
|
it('falls back to https://api.anthropic.com when no anthropic baseURL override is configured', async () => {
|
|
readConfig.mockReturnValue({});
|
|
fetchMock.mockResolvedValue(anthropicOk('ok'));
|
|
|
|
await callSmallModel({
|
|
auth: { anthropic: { type: 'api', key: 'sk-ant' } },
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'anthropic',
|
|
modelID: 'claude-haiku-4-5',
|
|
prompt: 'hi',
|
|
});
|
|
|
|
expect(lastCall(fetchMock).url).toBe('https://api.anthropic.com/v1/messages');
|
|
});
|
|
});
|
|
|
|
describe('catalog-based base URL (no config override)', () => {
|
|
it('uses the catalog api field when no config baseURL is set', async () => {
|
|
readConfig.mockReturnValue({});
|
|
fetchMock.mockResolvedValue(ok('ok'));
|
|
|
|
await callSmallModel({
|
|
auth: { mistral: { type: 'api', key: 'mistral-key' } },
|
|
catalog: CATALOG,
|
|
workingDirectory: '/proj',
|
|
providerID: 'mistral',
|
|
modelID: 'mistral-small-latest',
|
|
prompt: 'hi',
|
|
});
|
|
|
|
const { url, init } = lastCall(fetchMock);
|
|
expect(url).toBe('https://api.mistral.ai/v1/chat/completions');
|
|
expect(init.headers.Authorization).toBe('Bearer mistral-key');
|
|
});
|
|
|
|
it('throws when a non-openai provider has no catalog api and no config baseURL', async () => {
|
|
readConfig.mockReturnValue({});
|
|
|
|
await expect(callSmallModel({
|
|
auth: { custom: { type: 'api', key: 'k' } },
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'custom',
|
|
modelID: 'gpt-4o-mini',
|
|
prompt: 'hi',
|
|
})).rejects.toThrow('Provider "custom" has no known API base URL');
|
|
});
|
|
|
|
// A plugin registers its provider inside the running OpenCode process, so
|
|
// neither the config nor auth.json knows anything about it. This is the
|
|
// case that used to fail with "has no known API base URL" (#2666).
|
|
it('uses the endpoint and credential OpenCode resolved for a plugin provider', async () => {
|
|
readConfig.mockReturnValue({});
|
|
getRuntimeProvider.mockResolvedValue({
|
|
id: 'llmapi',
|
|
apiKey: 'plugin-key',
|
|
baseURL: 'https://api.llmapi.ai/v1',
|
|
anonymousZen: false,
|
|
});
|
|
const fetchMock = vi.fn(async () => ok('done'));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await callSmallModel({
|
|
auth: {},
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'llmapi',
|
|
modelID: 'claude-opus-4-8',
|
|
prompt: 'hi',
|
|
});
|
|
|
|
const { url, init } = lastCall(fetchMock);
|
|
expect(url).toBe('https://api.llmapi.ai/v1/chat/completions');
|
|
expect(init.headers.Authorization).toBe('Bearer plugin-key');
|
|
});
|
|
|
|
it('keeps the ChatGPT-plan login on its own transport instead of the runtime key', async () => {
|
|
readConfig.mockReturnValue({});
|
|
// OpenCode reports an OAuth access token as `options.apiKey` for openai;
|
|
// api.openai.com answers it with 401, so it must not stand in for the
|
|
// codex path.
|
|
getRuntimeProvider.mockResolvedValue({
|
|
id: 'openai',
|
|
apiKey: 'oauth-access-token',
|
|
baseURL: null,
|
|
anonymousZen: false,
|
|
});
|
|
|
|
await expect(callSmallModel({
|
|
auth: {},
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'openai',
|
|
modelID: 'gpt-5.4-mini',
|
|
prompt: 'hi',
|
|
})).rejects.toMatchObject({ code: 'no-provider-login' });
|
|
});
|
|
|
|
it('prefers an explicit config baseURL over the runtime endpoint', async () => {
|
|
readConfig.mockReturnValue({ provider: { custom: { options: { baseURL: 'https://configured.example/v1' } } } });
|
|
getRuntimeProvider.mockResolvedValue({
|
|
id: 'custom',
|
|
apiKey: 'runtime-key',
|
|
baseURL: 'https://runtime.example/v1',
|
|
anonymousZen: false,
|
|
});
|
|
const fetchMock = vi.fn(async () => ok('done'));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
await callSmallModel({
|
|
auth: { custom: { type: 'api', key: 'auth-key' } },
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'custom',
|
|
modelID: 'm',
|
|
prompt: 'hi',
|
|
});
|
|
|
|
expect(lastCall(fetchMock).url).toBe('https://configured.example/v1/chat/completions');
|
|
});
|
|
});
|
|
|
|
describe('config-supplied key does not leak', () => {
|
|
// The config-supplied key must stay in-memory: never copied into catalog
|
|
// metadata, the response, or the request body.
|
|
it('does not mutate the catalog or echo the key in the request/response', async () => {
|
|
const catalog = { custom: { id: 'custom', models: {} } };
|
|
const catalogBefore = JSON.parse(JSON.stringify(catalog));
|
|
readConfig.mockReturnValue({
|
|
provider: {
|
|
custom: { options: { apiKey: 'test-key', baseURL: 'https://proxy.example.test/v1' } },
|
|
},
|
|
});
|
|
fetchMock.mockResolvedValue(ok('the answer'));
|
|
|
|
const text = await callSmallModel({
|
|
auth: {},
|
|
catalog,
|
|
workingDirectory: '/proj',
|
|
providerID: 'custom',
|
|
modelID: 'gpt-4o-mini',
|
|
prompt: 'hi',
|
|
});
|
|
|
|
// Response text is exactly the model output — no credential echoed back.
|
|
expect(text).toBe('the answer');
|
|
// Catalog object left untouched (key stays in-memory only).
|
|
expect(catalog).toEqual(catalogBefore);
|
|
|
|
const { url, init } = lastCall(fetchMock);
|
|
// The key rides only in the Authorization header.
|
|
expect(url).not.toContain('test-key');
|
|
const body = JSON.parse(init.body);
|
|
expect(JSON.stringify(body)).not.toContain('test-key');
|
|
});
|
|
});
|
|
|
|
describe('merged config layers', () => {
|
|
it('reads the provider config for the supplied working directory', async () => {
|
|
readConfig.mockReturnValue({
|
|
provider: {
|
|
custom: { options: { apiKey: 'test-key', baseURL: 'https://proxy.example.test/v1' } },
|
|
},
|
|
});
|
|
fetchMock.mockResolvedValue(ok('ok'));
|
|
|
|
await callSmallModel({
|
|
auth: {},
|
|
catalog: {},
|
|
workingDirectory: '/path/to/project',
|
|
providerID: 'custom',
|
|
modelID: 'gpt-4o-mini',
|
|
prompt: 'hi',
|
|
});
|
|
|
|
// readConfig merges global + project-scoped layers for this directory;
|
|
// confirm callSmallModel passes the working directory straight through.
|
|
expect(readConfig).toHaveBeenCalledWith('/path/to/project');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('callSmallModel — Google thinking configuration', () => {
|
|
let fetchMock;
|
|
let originalFetch;
|
|
|
|
beforeEach(() => {
|
|
fetchMock = vi.fn();
|
|
originalFetch = globalThis.fetch;
|
|
globalThis.fetch = fetchMock;
|
|
readConfig.mockReset();
|
|
readConfig.mockReturnValue({});
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
const googleResponse = (text) => ({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ candidates: [{ content: { parts: [{ text }] } }] }),
|
|
});
|
|
|
|
it('uses thinkingLevel for Gemini 3 Flash models', async () => {
|
|
fetchMock.mockResolvedValue(googleResponse('generated commit'));
|
|
|
|
const text = await callSmallModel({
|
|
auth: { google: { type: 'api', key: 'google-key' } },
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'google',
|
|
modelID: 'gemini-3.1-flash-lite-preview',
|
|
prompt: 'generate',
|
|
});
|
|
|
|
expect(text).toBe('generated commit');
|
|
const body = JSON.parse(lastCall(fetchMock).init.body);
|
|
expect(body.generationConfig.thinkingConfig).toEqual({ thinkingLevel: 'minimal' });
|
|
});
|
|
|
|
it('keeps thinkingBudget disabled for Gemini 2.5 Flash models', async () => {
|
|
fetchMock.mockResolvedValue(googleResponse('generated commit'));
|
|
|
|
await callSmallModel({
|
|
auth: { google: { type: 'api', key: 'google-key' } },
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'google',
|
|
modelID: 'gemini-2.5-flash-lite',
|
|
prompt: 'generate',
|
|
});
|
|
|
|
const body = JSON.parse(lastCall(fetchMock).init.body);
|
|
expect(body.generationConfig.thinkingConfig).toEqual({ thinkingBudget: 0 });
|
|
});
|
|
|
|
it('omits thinkingConfig for other Google/Gemini models', async () => {
|
|
fetchMock.mockResolvedValue(googleResponse('generated commit'));
|
|
|
|
await callSmallModel({
|
|
auth: { google: { type: 'api', key: 'google-key' } },
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'google',
|
|
modelID: 'gemini-1.5-flash',
|
|
prompt: 'generate',
|
|
});
|
|
|
|
const body = JSON.parse(lastCall(fetchMock).init.body);
|
|
expect(body.generationConfig.thinkingConfig).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe('callSmallModel — GitHub Copilot endpoint routing', () => {
|
|
let fetchMock;
|
|
let originalFetch;
|
|
|
|
const copilotAuth = (overrides = {}) => ({
|
|
'github-copilot': {
|
|
type: 'oauth',
|
|
access: 'test-token',
|
|
refresh: 'test-token',
|
|
expires: 0,
|
|
...overrides,
|
|
},
|
|
});
|
|
|
|
const jsonResponse = (payload, status = 200) => new Response(
|
|
JSON.stringify(payload),
|
|
{
|
|
status,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
},
|
|
);
|
|
|
|
const callCopilot = (modelID, options = {}) => callSmallModel({
|
|
auth: copilotAuth(options.auth),
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'github-copilot',
|
|
modelID,
|
|
prompt: 'summarize this diff',
|
|
system: 'Write a commit message',
|
|
maxOutputTokens: 100,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
fetchMock = vi.fn();
|
|
originalFetch = globalThis.fetch;
|
|
globalThis.fetch = fetchMock;
|
|
readConfig.mockReset();
|
|
readConfig.mockReturnValue({});
|
|
readConfigLayers.mockReset();
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('routes a model advertising /responses through the Responses API', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(jsonResponse({
|
|
data: [{
|
|
id: 'mai-code-1-flash-picker',
|
|
supported_endpoints: ['/responses'],
|
|
}],
|
|
}))
|
|
.mockResolvedValueOnce(jsonResponse({
|
|
output: [{
|
|
type: 'message',
|
|
content: [{ type: 'output_text', text: 'feat: add summary' }],
|
|
}],
|
|
}));
|
|
|
|
await expect(callCopilot('mai-code-1-flash-picker')).resolves.toBe('feat: add summary');
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(String(fetchMock.mock.calls[0][0])).toBe('https://api.githubcopilot.com/models');
|
|
expect(String(fetchMock.mock.calls[1][0])).toBe('https://api.githubcopilot.com/responses');
|
|
const body = JSON.parse(fetchMock.mock.calls[1][1].body);
|
|
expect(body).toMatchObject({
|
|
model: 'mai-code-1-flash-picker',
|
|
instructions: 'Write a commit message',
|
|
max_output_tokens: 100,
|
|
stream: false,
|
|
store: false,
|
|
input: [{
|
|
role: 'user',
|
|
content: [{ type: 'input_text', text: 'summarize this diff' }],
|
|
}],
|
|
});
|
|
expect(JSON.stringify(body)).not.toContain('test-token');
|
|
});
|
|
|
|
it('prefers /v1/messages when a model advertises multiple endpoints', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(jsonResponse({
|
|
data: [{
|
|
id: 'claude-opus-4.7',
|
|
supported_endpoints: ['/chat/completions', '/responses', '/v1/messages'],
|
|
}],
|
|
}))
|
|
.mockResolvedValueOnce(jsonResponse({
|
|
content: [{ type: 'text', text: 'fix: route Claude correctly' }],
|
|
}));
|
|
|
|
await expect(callCopilot('claude-opus-4.7')).resolves.toBe('fix: route Claude correctly');
|
|
|
|
expect(String(fetchMock.mock.calls[1][0])).toBe('https://api.githubcopilot.com/v1/messages');
|
|
const body = JSON.parse(fetchMock.mock.calls[1][1].body);
|
|
expect(body).toMatchObject({
|
|
model: 'claude-opus-4.7',
|
|
system: 'Write a commit message',
|
|
max_tokens: 100,
|
|
messages: [{ role: 'user', content: 'summarize this diff' }],
|
|
});
|
|
});
|
|
|
|
it('uses /chat/completions when the model advertises the chat endpoint', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(jsonResponse({
|
|
data: [{
|
|
id: 'gpt-5.4-nano',
|
|
supported_endpoints: ['/chat/completions'],
|
|
}],
|
|
}))
|
|
.mockResolvedValueOnce(ok('chore: update summary'));
|
|
|
|
await expect(callCopilot('gpt-5.4-nano')).resolves.toBe('chore: update summary');
|
|
|
|
expect(String(fetchMock.mock.calls[1][0])).toBe('https://api.githubcopilot.com/chat/completions');
|
|
});
|
|
|
|
it('keeps chat completions as the legacy default when endpoint metadata is absent', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(jsonResponse({
|
|
data: [{ id: 'gpt-4o-mini' }],
|
|
}))
|
|
.mockResolvedValueOnce(ok('docs: clarify behavior'));
|
|
|
|
await expect(callCopilot('gpt-4o-mini')).resolves.toBe('docs: clarify behavior');
|
|
|
|
expect(String(fetchMock.mock.calls[1][0])).toBe('https://api.githubcopilot.com/chat/completions');
|
|
});
|
|
|
|
it('uses the enterprise Copilot host for metadata and generation', async () => {
|
|
fetchMock
|
|
.mockResolvedValueOnce(jsonResponse({
|
|
data: [{
|
|
id: 'mai-code-1-flash-picker',
|
|
supported_endpoints: ['/responses'],
|
|
}],
|
|
}))
|
|
.mockResolvedValueOnce(jsonResponse({
|
|
output_text: 'fix: support enterprise routing',
|
|
}));
|
|
|
|
await expect(callCopilot('mai-code-1-flash-picker', {
|
|
auth: { enterpriseUrl: 'https://ghe.example.com/' },
|
|
})).resolves.toBe('fix: support enterprise routing');
|
|
|
|
expect(String(fetchMock.mock.calls[0][0])).toBe('https://copilot-api.ghe.example.com/models');
|
|
expect(String(fetchMock.mock.calls[1][0])).toBe('https://copilot-api.ghe.example.com/responses');
|
|
});
|
|
|
|
it('surfaces Copilot model metadata request failures without generating', async () => {
|
|
fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'unavailable' }, 503));
|
|
|
|
await expect(callCopilot('mai-code-1-flash-picker'))
|
|
.rejects.toThrow('GitHub Copilot models request failed with 503');
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('rejects missing models and unsupported advertised endpoints', async () => {
|
|
fetchMock.mockResolvedValueOnce(jsonResponse({
|
|
data: [{ id: 'different-model', supported_endpoints: ['/responses'] }],
|
|
}));
|
|
|
|
await expect(callCopilot('mai-code-1-flash-picker'))
|
|
.rejects.toThrow('GitHub Copilot model "mai-code-1-flash-picker" was not returned by /models');
|
|
|
|
fetchMock.mockReset();
|
|
fetchMock.mockResolvedValueOnce(jsonResponse({
|
|
data: [{
|
|
id: 'mai-code-1-flash-picker',
|
|
supported_endpoints: ['/future'],
|
|
}],
|
|
}));
|
|
|
|
await expect(callCopilot('mai-code-1-flash-picker'))
|
|
.rejects.toThrow('GitHub Copilot model "mai-code-1-flash-picker" has no supported text endpoint');
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
// Structured output has no single wire format: each provider family needs its
|
|
// own request shape and its own extraction, and one family cannot do it at all.
|
|
// These lock the per-format translation so a provider is never silently sent a
|
|
// schema it will ignore.
|
|
describe('callSmallModel — structured output', () => {
|
|
let fetchMock;
|
|
let originalFetch;
|
|
|
|
const SCHEMA = {
|
|
type: 'object',
|
|
properties: { title: { type: 'string' } },
|
|
required: ['title'],
|
|
additionalProperties: false,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
fetchMock = vi.fn();
|
|
originalFetch = globalThis.fetch;
|
|
globalThis.fetch = fetchMock;
|
|
readConfig.mockReset();
|
|
readConfig.mockReturnValue({});
|
|
readConfigLayers.mockReset();
|
|
readConfigLayers.mockReturnValue({ mergedConfig: {} });
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
it('sends a json_schema response_format on OpenAI-compatible chat', async () => {
|
|
fetchMock.mockResolvedValue(ok('{"title":"ok"}'));
|
|
|
|
const text = await callSmallModel({
|
|
auth: { openai: { type: 'api', key: 'sk-test' } },
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'openai',
|
|
modelID: 'gpt-5.4-mini',
|
|
prompt: 'summarize',
|
|
responseSchema: SCHEMA,
|
|
});
|
|
|
|
expect(text).toBe('{"title":"ok"}');
|
|
const body = JSON.parse(lastCall(fetchMock).init.body);
|
|
expect(body.response_format).toEqual({
|
|
type: 'json_schema',
|
|
json_schema: { name: 'response', strict: true, schema: SCHEMA },
|
|
});
|
|
});
|
|
|
|
it('omits response_format entirely when no schema is requested', async () => {
|
|
fetchMock.mockResolvedValue(ok('plain text'));
|
|
|
|
await callSmallModel({
|
|
auth: { openai: { type: 'api', key: 'sk-test' } },
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'openai',
|
|
modelID: 'gpt-5.4-mini',
|
|
prompt: 'summarize',
|
|
});
|
|
|
|
const body = JSON.parse(lastCall(fetchMock).init.body);
|
|
expect(body.response_format).toBeUndefined();
|
|
});
|
|
|
|
it('forces a single tool call on the Anthropic messages API and returns its input', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
content: [
|
|
{ type: 'text', text: 'thinking out loud' },
|
|
{ type: 'tool_use', name: 'response', input: { title: 'ok' } },
|
|
],
|
|
}),
|
|
});
|
|
|
|
const text = await callSmallModel({
|
|
auth: { anthropic: { type: 'api', key: 'sk-ant' } },
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'anthropic',
|
|
modelID: 'claude-haiku-4-5',
|
|
prompt: 'summarize',
|
|
responseSchema: SCHEMA,
|
|
});
|
|
|
|
expect(JSON.parse(text)).toEqual({ title: 'ok' });
|
|
const body = JSON.parse(lastCall(fetchMock).init.body);
|
|
expect(body.tool_choice).toEqual({ type: 'tool', name: 'response' });
|
|
expect(body.tools[0].input_schema).toEqual(SCHEMA);
|
|
});
|
|
|
|
it('fails loudly when Anthropic answers with prose instead of the tool call', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ content: [{ type: 'text', text: 'here you go' }] }),
|
|
});
|
|
|
|
await expect(callSmallModel({
|
|
auth: { anthropic: { type: 'api', key: 'sk-ant' } },
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'anthropic',
|
|
modelID: 'claude-haiku-4-5',
|
|
prompt: 'summarize',
|
|
responseSchema: SCHEMA,
|
|
})).rejects.toThrow('returned no structured output');
|
|
});
|
|
|
|
it('strips JSON Schema keywords Google rejects', async () => {
|
|
fetchMock.mockResolvedValue({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ candidates: [{ content: { parts: [{ text: '{"title":"ok"}' }] } }] }),
|
|
});
|
|
|
|
await callSmallModel({
|
|
auth: { google: { type: 'api', key: 'google-key' } },
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'google',
|
|
modelID: 'gemini-2.5-flash',
|
|
prompt: 'summarize',
|
|
responseSchema: { ...SCHEMA, $schema: 'https://json-schema.org/draft/2020-12/schema' },
|
|
});
|
|
|
|
const body = JSON.parse(lastCall(fetchMock).init.body);
|
|
expect(body.generationConfig.responseMimeType).toBe('application/json');
|
|
expect(body.generationConfig.responseSchema).toEqual({
|
|
type: 'object',
|
|
properties: { title: { type: 'string' } },
|
|
required: ['title'],
|
|
});
|
|
});
|
|
|
|
it('refuses a schema on the ChatGPT-plan backend instead of returning prose', async () => {
|
|
await expect(callSmallModel({
|
|
auth: { openai: { type: 'oauth', access: 'token', refresh: 'refresh' } },
|
|
catalog: {},
|
|
workingDirectory: '/proj',
|
|
providerID: 'openai',
|
|
modelID: 'gpt-5.4-mini',
|
|
prompt: 'summarize',
|
|
responseSchema: SCHEMA,
|
|
})).rejects.toThrow('does not support structured output');
|
|
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|