* fix: add support for custom provider base URLs from config * feat: read custom provider apiKey from config and use it as primary credential when it exists * doc: update small-model documentation
This commit is contained in:
@@ -47,7 +47,9 @@ other runtime API.
|
||||
- **Anthropic** (`type: api`): `/v1/messages` with `x-api-key`.
|
||||
- **Google** (`type: api`): `generateContent` with `x-goog-api-key`.
|
||||
- Everything else: OpenAI-compatible `/chat/completions` against the
|
||||
provider's models.dev base URL with `Authorization: Bearer <key>`.
|
||||
provider's base URL, resolved from (1) `provider.<id>.options.baseURL`
|
||||
in the OpenCode config, (2) the hardcoded `https://api.openai.com/v1`
|
||||
endpoint, or (3) the provider's `api` field from the models.dev catalog.
|
||||
- `catalog.js` — models.dev catalog via the shared in-process cache
|
||||
(`../opencode/models-metadata.js`, also serving
|
||||
`/api/openchamber/models-metadata`).
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readAuthFile, writeAuthFile } from '../opencode/auth.js';
|
||||
import { readConfig } from '../opencode/shared.js';
|
||||
import { getCatalogProvider } from './catalog.js';
|
||||
import { getAuthEntryForProvider } from './resolve.js';
|
||||
|
||||
@@ -279,13 +280,41 @@ const callCodexResponses = async ({ accessToken, accountId, modelID, prompt, sys
|
||||
return result;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom provider configuration support
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const readProviderConfig = (workingDirectory, providerID) => {
|
||||
try {
|
||||
const config = readConfig(workingDirectory);
|
||||
const providerCfg = config?.provider?.[providerID];
|
||||
if (!providerCfg || typeof providerCfg !== 'object') return null;
|
||||
const baseURL = typeof providerCfg?.options?.baseURL === 'string' ? providerCfg.options.baseURL.trim() : null;
|
||||
const apiKey = typeof providerCfg?.options?.apiKey === 'string' ? providerCfg.options.apiKey.trim() : null;
|
||||
return {
|
||||
baseURL,
|
||||
// Shape the config-supplied key as a regular api-key auth entry so it
|
||||
// can win the precedence check below and flow through the dispatch's
|
||||
// `entry.type === 'api' ? entry.key : ...` branch unchanged.
|
||||
auth: apiKey ? { type: 'api', key: apiKey } : null,
|
||||
};
|
||||
} catch {
|
||||
// Provider config is non-essential — continue with catalog-only resolution.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function callSmallModel({ auth, catalog, providerID, modelID, prompt, system, maxOutputTokens }) {
|
||||
export async function callSmallModel({ auth, catalog, workingDirectory, providerID, modelID, prompt, system, maxOutputTokens }) {
|
||||
const tokens = Number(maxOutputTokens) > 0 ? Number(maxOutputTokens) : DEFAULT_MAX_OUTPUT_TOKENS;
|
||||
const entry = getAuthEntryForProvider(auth, providerID);
|
||||
const providerConfig = readProviderConfig(workingDirectory, providerID);
|
||||
// Match OpenCode's resolveSDK precedence:
|
||||
// config provider.<id>.options.apiKey (providerConfig.auth) wins; the
|
||||
// auth.json entry is only a fallback.
|
||||
const entry = providerConfig?.auth || getAuthEntryForProvider(auth, providerID);
|
||||
if (!entry) {
|
||||
throw new Error(`No OpenCode login found for provider "${providerID}"`);
|
||||
}
|
||||
@@ -343,13 +372,21 @@ export async function callSmallModel({ auth, catalog, providerID, modelID, promp
|
||||
}
|
||||
|
||||
// Everything else: OpenAI-compatible chat completions against the catalog's
|
||||
// base URL for that provider (openai itself included).
|
||||
// base URL for that provider (openai itself included). When a custom provider
|
||||
// is not in the catalog (e.g. a user-configured OpenAI-compatible proxy),
|
||||
// fall back to its baseURL from the OpenCode provider config. The openai
|
||||
// provider also respects provider.openai.options.baseURL — OpenCode itself
|
||||
// uses the same config for all providers including openai.
|
||||
const provider = getCatalogProvider(catalog, providerID);
|
||||
const baseURL = providerID === 'openai'
|
||||
? 'https://api.openai.com/v1'
|
||||
: typeof provider?.api === 'string' && provider.api
|
||||
? provider.api
|
||||
: null;
|
||||
const providerConfigUrl = providerConfig?.baseURL;
|
||||
const defaultOpenaiUrl = 'https://api.openai.com/v1';
|
||||
const baseURL = typeof providerConfigUrl === 'string' && providerConfigUrl
|
||||
? providerConfigUrl
|
||||
: providerID === 'openai'
|
||||
? defaultOpenaiUrl
|
||||
: typeof provider?.api === 'string' && provider.api
|
||||
? provider.api
|
||||
: null;
|
||||
if (!baseURL) {
|
||||
throw new Error(`Provider "${providerID}" has no known API base URL`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
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(),
|
||||
}));
|
||||
|
||||
const { callSmallModel } = await import('./call.js');
|
||||
const { readConfig } = await import('../opencode/shared.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();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe('config-supplied credentials (no auth.json entry)', () => {
|
||||
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' } } },
|
||||
});
|
||||
|
||||
await expect(callSmallModel({
|
||||
auth: {},
|
||||
catalog: {},
|
||||
workingDirectory: '/proj',
|
||||
providerID: 'custom',
|
||||
modelID: 'gpt-4o-mini',
|
||||
prompt: 'hi',
|
||||
})).rejects.toThrow('No OpenCode login found for provider "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('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');
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -111,6 +111,7 @@ export async function generateSmallModelText({ prompt, system, maxOutputTokens,
|
||||
const text = await callSmallModel({
|
||||
auth,
|
||||
catalog,
|
||||
workingDirectory: directory,
|
||||
providerID: resolved.providerID,
|
||||
modelID: resolved.modelID,
|
||||
prompt: clamped.prompt,
|
||||
|
||||
Reference in New Issue
Block a user