fix(small-model): harden configured headers
This commit is contained in:
@@ -19,6 +19,18 @@ const DEFAULT_MAX_OUTPUT_TOKENS = 4_000;
|
|||||||
|
|
||||||
const USER_AGENT = 'opencode/1.0 openchamber';
|
const USER_AGENT = 'opencode/1.0 openchamber';
|
||||||
|
|
||||||
|
const mergeHeadersCaseInsensitive = (base, overrides) => {
|
||||||
|
const merged = { ...base };
|
||||||
|
for (const [name, value] of Object.entries(overrides || {})) {
|
||||||
|
const existingName = Object.keys(merged).find((key) => key.toLowerCase() === name.toLowerCase());
|
||||||
|
if (existingName) {
|
||||||
|
delete merged[existingName];
|
||||||
|
}
|
||||||
|
merged[name] = value;
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
};
|
||||||
|
|
||||||
const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token';
|
const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token';
|
||||||
const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
||||||
const CODEX_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses';
|
const CODEX_RESPONSES_URL = 'https://chatgpt.com/backend-api/codex/responses';
|
||||||
@@ -157,11 +169,10 @@ const callOpenaiCompatible = async ({ baseURL, headers, modelID, prompt, system,
|
|||||||
});
|
});
|
||||||
const response = await fetch(`${trimmedBase}/chat/completions`, {
|
const response = await fetch(`${trimmedBase}/chat/completions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: mergeHeadersCaseInsensitive({
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
...headers,
|
}, headers),
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: modelID,
|
model: modelID,
|
||||||
messages: [
|
messages: [
|
||||||
@@ -510,7 +521,7 @@ const callCodexResponses = async ({ accessToken, accountId, modelID, prompt, sys
|
|||||||
// Custom provider configuration support
|
// Custom provider configuration support
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const resolveConfigApiKey = (value, workingDirectory, providerID) => {
|
const resolveConfigValue = (value, workingDirectory, providerID, headerName = null) => {
|
||||||
const envMatch = value.match(/^\{env:([^}]+)\}$/i);
|
const envMatch = value.match(/^\{env:([^}]+)\}$/i);
|
||||||
if (envMatch) {
|
if (envMatch) {
|
||||||
return process.env[envMatch[1].trim()]?.trim() || null;
|
return process.env[envMatch[1].trim()]?.trim() || null;
|
||||||
@@ -531,7 +542,12 @@ const resolveConfigApiKey = (value, workingDirectory, providerID) => {
|
|||||||
{ config: layers.customConfig, filePath: layers.paths.customPath },
|
{ config: layers.customConfig, filePath: layers.paths.customPath },
|
||||||
{ config: layers.projectConfig, filePath: layers.paths.projectPath },
|
{ config: layers.projectConfig, filePath: layers.paths.projectPath },
|
||||||
{ config: layers.userConfig, filePath: layers.paths.userPath },
|
{ config: layers.userConfig, filePath: layers.paths.userPath },
|
||||||
].find(({ config }) => config?.provider?.[providerID]?.options?.apiKey === value);
|
].find(({ config }) => {
|
||||||
|
const options = config?.provider?.[providerID]?.options;
|
||||||
|
return headerName
|
||||||
|
? options?.headers?.[headerName] === value
|
||||||
|
: options?.apiKey === value;
|
||||||
|
});
|
||||||
resolvedPath = path.resolve(source?.filePath ? path.dirname(source.filePath) : workingDirectory || process.cwd(), configuredPath);
|
resolvedPath = path.resolve(source?.filePath ? path.dirname(source.filePath) : workingDirectory || process.cwd(), configuredPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,7 +556,7 @@ const resolveConfigApiKey = (value, workingDirectory, providerID) => {
|
|||||||
if (!key) throw new Error('empty file');
|
if (!key) throw new Error('empty file');
|
||||||
return key;
|
return key;
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`Failed to resolve configured apiKey file for provider "${providerID}"`);
|
throw new Error(`Failed to resolve configured ${headerName ? `header "${headerName}"` : 'apiKey'} file for provider "${providerID}"`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -561,7 +577,7 @@ const readConfiguredHeaders = (providerCfg, workingDirectory, providerID) => {
|
|||||||
// Config headers are strings; a malformed entry is skipped rather than
|
// Config headers are strings; a malformed entry is skipped rather than
|
||||||
// stringified into a header the gateway would reject.
|
// stringified into a header the gateway would reject.
|
||||||
if (String(value) !== value) continue;
|
if (String(value) !== value) continue;
|
||||||
const resolved = resolveConfigApiKey(value.trim(), workingDirectory, providerID);
|
const resolved = resolveConfigValue(value.trim(), workingDirectory, providerID, name);
|
||||||
if (resolved) headers[name] = resolved;
|
if (resolved) headers[name] = resolved;
|
||||||
}
|
}
|
||||||
return Object.keys(headers).length ? headers : null;
|
return Object.keys(headers).length ? headers : null;
|
||||||
@@ -574,7 +590,7 @@ const readProviderConfig = (workingDirectory, 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 rawApiKey = 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;
|
const apiKey = rawApiKey ? resolveConfigValue(rawApiKey, workingDirectory, providerID) : null;
|
||||||
return {
|
return {
|
||||||
baseURL,
|
baseURL,
|
||||||
headers: readConfiguredHeaders(providerCfg, workingDirectory, providerID),
|
headers: readConfiguredHeaders(providerCfg, workingDirectory, providerID),
|
||||||
@@ -779,7 +795,7 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider
|
|||||||
baseURL,
|
baseURL,
|
||||||
// Configured headers last: a gateway that authenticates on its own header
|
// Configured headers last: a gateway that authenticates on its own header
|
||||||
// must be able to override the bearer default rather than sit beside it.
|
// must be able to override the bearer default rather than sit beside it.
|
||||||
headers: { Authorization: `Bearer ${apiKey}`, ...(providerConfig?.headers || {}) },
|
headers: mergeHeadersCaseInsensitive({ Authorization: `Bearer ${apiKey}` }, providerConfig?.headers),
|
||||||
modelID,
|
modelID,
|
||||||
prompt,
|
prompt,
|
||||||
system,
|
system,
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ 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
|
||||||
// test controls the provider config without touching the filesystem. call.js
|
// 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
|
// imports the config readers and a plain-object predicate from shared.js, so
|
||||||
// untouched for this file.
|
// the rest of that module is left untouched for this file.
|
||||||
vi.mock('../opencode/shared.js', () => ({
|
vi.mock('../opencode/shared.js', () => ({
|
||||||
readConfig: vi.fn(),
|
readConfig: vi.fn(),
|
||||||
readConfigLayers: vi.fn(),
|
readConfigLayers: vi.fn(),
|
||||||
@@ -70,6 +70,7 @@ describe('callSmallModel — custom provider config', () => {
|
|||||||
globalThis.fetch = originalFetch;
|
globalThis.fetch = originalFetch;
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
delete process.env.OPENCHAMBER_TEST_PROVIDER_KEY;
|
delete process.env.OPENCHAMBER_TEST_PROVIDER_KEY;
|
||||||
|
delete process.env.OPENCHAMBER_TEST_GATEWAY_KEY;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('config-supplied credentials (no auth.json entry)', () => {
|
describe('config-supplied credentials (no auth.json entry)', () => {
|
||||||
@@ -158,6 +159,72 @@ describe('callSmallModel — custom provider config', () => {
|
|||||||
expect(init.headers.Authorization).toBe('Bearer sk-config');
|
expect(init.headers.Authorization).toBe('Bearer sk-config');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves a relative header file from the config layer that defines it', async () => {
|
||||||
|
const configPath = '/config/opencode.json';
|
||||||
|
const secretPath = '/config/gateway-key';
|
||||||
|
vi.spyOn(fs, 'readFileSync').mockImplementation((filePath) => {
|
||||||
|
if (filePath === secretPath) return 'sub-key\n';
|
||||||
|
throw new Error(`Unexpected file read: ${filePath}`);
|
||||||
|
});
|
||||||
|
const provider = {
|
||||||
|
custom: {
|
||||||
|
options: {
|
||||||
|
apiKey: 'sk-config',
|
||||||
|
baseURL: 'https://proxy.example.test/v1',
|
||||||
|
headers: { 'x-gateway-key': '{file:./gateway-key}' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
readConfig.mockReturnValue({ provider });
|
||||||
|
readConfigLayers.mockReturnValue({
|
||||||
|
customConfig: {},
|
||||||
|
projectConfig: {},
|
||||||
|
userConfig: { provider },
|
||||||
|
paths: { customPath: null, projectPath: '/project/opencode.json', userPath: configPath },
|
||||||
|
});
|
||||||
|
fetchMock.mockResolvedValue(ok('hello'));
|
||||||
|
|
||||||
|
await callSmallModel({
|
||||||
|
auth: {},
|
||||||
|
catalog: {},
|
||||||
|
workingDirectory: '/project',
|
||||||
|
providerID: 'custom',
|
||||||
|
modelID: 'model',
|
||||||
|
prompt: 'hi',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(lastCall(fetchMock).init.headers['x-gateway-key']).toBe('sub-key');
|
||||||
|
expect(fs.readFileSync).toHaveBeenCalledWith(secretPath, 'utf8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overrides Authorization without depending on header-name casing', async () => {
|
||||||
|
readConfig.mockReturnValue({
|
||||||
|
provider: {
|
||||||
|
custom: {
|
||||||
|
options: {
|
||||||
|
apiKey: 'sk-config',
|
||||||
|
baseURL: 'https://proxy.example.test/v1',
|
||||||
|
headers: { authorization: 'Basic gateway-token' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fetchMock.mockResolvedValue(ok('hello'));
|
||||||
|
|
||||||
|
await callSmallModel({
|
||||||
|
auth: {},
|
||||||
|
catalog: {},
|
||||||
|
workingDirectory: '/project',
|
||||||
|
providerID: 'custom',
|
||||||
|
modelID: 'model',
|
||||||
|
prompt: 'hi',
|
||||||
|
});
|
||||||
|
|
||||||
|
const headers = lastCall(fetchMock).init.headers;
|
||||||
|
expect(headers.authorization).toBe('Basic gateway-token');
|
||||||
|
expect(headers.Authorization).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
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