fix: resolve configured provider api keys from env and files
Supports {env:NAME} and {file:path} apiKey substitutions in provider config.
Keeps resolved credentials and file contents server-side.
Adds coverage for env and file-based credential resolution.
This commit is contained in:
@@ -51,6 +51,8 @@ other runtime API.
|
|||||||
provider's base URL, resolved from (1) `provider.<id>.options.baseURL`
|
provider's base URL, resolved from (1) `provider.<id>.options.baseURL`
|
||||||
in the OpenCode config, (2) the hardcoded `https://api.openai.com/v1`
|
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.
|
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
|
- `[small-model:diagnostic]` logs record provider/model, input character
|
||||||
counts, output budget, thinking toggle, HTTP/finish status, and
|
counts, output budget, thinking toggle, HTTP/finish status, and
|
||||||
content/reasoning lengths without logging prompts, response text, or
|
content/reasoning lengths without logging prompts, response text, or
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
import { readAuthFile, writeAuthFile } from '../opencode/auth.js';
|
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 { getCatalogProvider } from './catalog.js';
|
||||||
import { getAuthEntryForProvider } from './resolve.js';
|
import { getAuthEntryForProvider } from './resolve.js';
|
||||||
|
|
||||||
@@ -312,13 +315,48 @@ const callCodexResponses = async ({ accessToken, accountId, modelID, prompt, sys
|
|||||||
// Custom provider configuration support
|
// 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) => {
|
const readProviderConfig = (workingDirectory, providerID) => {
|
||||||
try {
|
try {
|
||||||
const config = readConfig(workingDirectory);
|
const config = readConfig(workingDirectory);
|
||||||
const providerCfg = config?.provider?.[providerID];
|
const providerCfg = config?.provider?.[providerID];
|
||||||
if (!providerCfg || typeof providerCfg !== 'object') return null;
|
if (!providerCfg || typeof providerCfg !== 'object') return null;
|
||||||
const baseURL = typeof providerCfg?.options?.baseURL === 'string' ? providerCfg.options.baseURL.trim() : 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 {
|
return {
|
||||||
baseURL,
|
baseURL,
|
||||||
// Shape the config-supplied key as a regular api-key auth entry so it
|
// Shape the config-supplied key as a regular api-key auth entry so it
|
||||||
|
|||||||
@@ -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';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
// readConfig reads merged opencode config layers from disk; mock it so each
|
// 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.
|
// untouched for this file.
|
||||||
vi.mock('../opencode/shared.js', () => ({
|
vi.mock('../opencode/shared.js', () => ({
|
||||||
readConfig: vi.fn(),
|
readConfig: vi.fn(),
|
||||||
|
readConfigLayers: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { callSmallModel } = await import('./call.js');
|
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.
|
// Minimal catalog fragment used by the catalog-based base URL resolution case.
|
||||||
const CATALOG = {
|
const CATALOG = {
|
||||||
@@ -50,13 +54,68 @@ describe('callSmallModel — custom provider config', () => {
|
|||||||
originalFetch = globalThis.fetch;
|
originalFetch = globalThis.fetch;
|
||||||
globalThis.fetch = fetchMock;
|
globalThis.fetch = fetchMock;
|
||||||
readConfig.mockReset();
|
readConfig.mockReset();
|
||||||
|
readConfigLayers.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
delete process.env.OPENCHAMBER_TEST_PROVIDER_KEY;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('config-supplied credentials (no auth.json entry)', () => {
|
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 () => {
|
it('uses apiKey and baseURL from provider config when no auth.json entry exists', async () => {
|
||||||
readConfig.mockReturnValue({
|
readConfig.mockReturnValue({
|
||||||
provider: {
|
provider: {
|
||||||
|
|||||||
Reference in New Issue
Block a user