diff --git a/packages/web/server/lib/small-model/DOCUMENTATION.md b/packages/web/server/lib/small-model/DOCUMENTATION.md index 56769495..dfe20b6f 100644 --- a/packages/web/server/lib/small-model/DOCUMENTATION.md +++ b/packages/web/server/lib/small-model/DOCUMENTATION.md @@ -51,6 +51,8 @@ other runtime API. provider's base URL, resolved from (1) `provider..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. + Configured API keys honor OpenCode's `{env:NAME}` and `{file:path}` + substitutions; file contents and resolved credentials remain server-side. - `[small-model:diagnostic]` logs record provider/model, input character counts, output budget, thinking toggle, HTTP/finish status, and content/reasoning lengths without logging prompts, response text, or diff --git a/packages/web/server/lib/small-model/call.js b/packages/web/server/lib/small-model/call.js index b67e3343..df3bdd1a 100644 --- a/packages/web/server/lib/small-model/call.js +++ b/packages/web/server/lib/small-model/call.js @@ -1,5 +1,8 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; import { readAuthFile, writeAuthFile } from '../opencode/auth.js'; -import { readConfig } from '../opencode/shared.js'; +import { readConfig, readConfigLayers } from '../opencode/shared.js'; import { getCatalogProvider } from './catalog.js'; import { getAuthEntryForProvider } from './resolve.js'; @@ -312,13 +315,48 @@ const callCodexResponses = async ({ accessToken, accountId, modelID, prompt, sys // Custom provider configuration support // --------------------------------------------------------------------------- +const resolveConfigApiKey = (value, workingDirectory, providerID) => { + const envMatch = value.match(/^\{env:([^}]+)\}$/i); + if (envMatch) { + return process.env[envMatch[1].trim()]?.trim() || null; + } + + const fileMatch = value.match(/^\{file:(.+)\}$/i); + if (!fileMatch) return value; + + const configuredPath = fileMatch[1].trim(); + let resolvedPath; + if (configuredPath === '~' || configuredPath.startsWith('~/') || configuredPath.startsWith('~\\')) { + resolvedPath = path.join(os.homedir(), configuredPath.slice(2)); + } else if (path.isAbsolute(configuredPath)) { + resolvedPath = configuredPath; + } else { + const layers = readConfigLayers(workingDirectory); + const source = [ + { config: layers.customConfig, filePath: layers.paths.customPath }, + { config: layers.projectConfig, filePath: layers.paths.projectPath }, + { config: layers.userConfig, filePath: layers.paths.userPath }, + ].find(({ config }) => config?.provider?.[providerID]?.options?.apiKey === value); + resolvedPath = path.resolve(source?.filePath ? path.dirname(source.filePath) : workingDirectory || process.cwd(), configuredPath); + } + + try { + const key = fs.readFileSync(resolvedPath, 'utf8').trim(); + if (!key) throw new Error('empty file'); + return key; + } catch { + throw new Error(`Failed to resolve configured apiKey file for provider "${providerID}"`); + } +}; + 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; + const rawApiKey = typeof providerCfg?.options?.apiKey === 'string' ? providerCfg.options.apiKey.trim() : null; + const apiKey = rawApiKey ? resolveConfigApiKey(rawApiKey, workingDirectory, providerID) : null; return { baseURL, // Shape the config-supplied key as a regular api-key auth entry so it diff --git a/packages/web/server/lib/small-model/call.test.js b/packages/web/server/lib/small-model/call.test.js index 34d73b16..86602217 100644 --- a/packages/web/server/lib/small-model/call.test.js +++ b/packages/web/server/lib/small-model/call.test.js @@ -1,3 +1,6 @@ +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 @@ -6,10 +9,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // untouched for this file. vi.mock('../opencode/shared.js', () => ({ readConfig: vi.fn(), + readConfigLayers: vi.fn(), })); const { callSmallModel } = await import('./call.js'); -const { readConfig } = await import('../opencode/shared.js'); +const { readConfig, readConfigLayers } = await import('../opencode/shared.js'); // Minimal catalog fragment used by the catalog-based base URL resolution case. const CATALOG = { @@ -50,13 +54,68 @@ describe('callSmallModel — custom provider config', () => { originalFetch = globalThis.fetch; globalThis.fetch = fetchMock; readConfig.mockReset(); + readConfigLayers.mockReset(); }); 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: {