diff --git a/packages/web/server/lib/small-model/DOCUMENTATION.md b/packages/web/server/lib/small-model/DOCUMENTATION.md index 209cb749..9e80347c 100644 --- a/packages/web/server/lib/small-model/DOCUMENTATION.md +++ b/packages/web/server/lib/small-model/DOCUMENTATION.md @@ -116,9 +116,11 @@ other runtime API. endpoint, (3) the endpoint OpenCode resolved at runtime, or (4) the provider's `api` field from the models.dev catalog. The credential follows the same shape: config `options.apiKey`, then the runtime credential, then - the auth.json entry. Configured API keys honor OpenCode's `{env:NAME}` and - `{file:path}` substitutions; file contents and resolved credentials remain - server-side. + the auth.json entry. `provider..options.headers` is sent with the + request and overrides the bearer default, so gateways that authenticate on + their own header work here exactly as they do in a chat turn. Configured API + keys and header values honor OpenCode's `{env:NAME}` and `{file:path}` + substitutions; file contents and resolved credentials remain server-side. - The runtime credential is refused for providers listed in `OWN_CREDENTIAL_HANDLING`. Their branches need the stored entry rather than a bearer token: the clearest case is the ChatGPT-plan `openai` login, whose diff --git a/packages/web/server/lib/small-model/call.js b/packages/web/server/lib/small-model/call.js index 09261516..1d2c2c5f 100644 --- a/packages/web/server/lib/small-model/call.js +++ b/packages/web/server/lib/small-model/call.js @@ -2,7 +2,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { readAuthFile, writeAuthFile } from '../opencode/auth.js'; -import { readConfig, readConfigLayers } from '../opencode/shared.js'; +import { readConfig, readConfigLayers, isPlainObject } from '../opencode/shared.js'; import { getCatalogProvider } from './catalog.js'; import { getAuthEntryForProvider } from './resolve.js'; import { getRuntimeProvider } from './runtime-providers.js'; @@ -544,6 +544,29 @@ const resolveConfigApiKey = (value, workingDirectory, providerID) => { } }; +/** + * `options.headers` from the provider config, with the same `{env:…}`/`{file:…}` + * substitutions the API key gets. + * + * OpenCode sends these on every request, so dropping them here would have the + * small model authenticating differently from the request path against the same + * URL. Gateways fronted by an API-management layer reject a bearer-only request + * outright, because the header is the credential rather than a supplement to it. + */ +const readConfiguredHeaders = (providerCfg, workingDirectory, providerID) => { + const configured = providerCfg?.options?.headers; + if (!isPlainObject(configured)) return null; + const headers = {}; + for (const [name, value] of Object.entries(configured)) { + // Config headers are strings; a malformed entry is skipped rather than + // stringified into a header the gateway would reject. + if (String(value) !== value) continue; + const resolved = resolveConfigApiKey(value.trim(), workingDirectory, providerID); + if (resolved) headers[name] = resolved; + } + return Object.keys(headers).length ? headers : null; +}; + const readProviderConfig = (workingDirectory, providerID) => { try { const config = readConfig(workingDirectory); @@ -554,6 +577,7 @@ const readProviderConfig = (workingDirectory, providerID) => { const apiKey = rawApiKey ? resolveConfigApiKey(rawApiKey, workingDirectory, providerID) : null; return { baseURL, + headers: readConfiguredHeaders(providerCfg, workingDirectory, providerID), // 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. @@ -753,7 +777,9 @@ export async function callSmallModel({ auth, catalog, workingDirectory, provider return callOpenaiCompatible({ baseURL, - headers: { Authorization: `Bearer ${apiKey}` }, + // Configured headers last: a gateway that authenticates on its own header + // must be able to override the bearer default rather than sit beside it. + headers: { Authorization: `Bearer ${apiKey}`, ...(providerConfig?.headers || {}) }, modelID, prompt, system, diff --git a/packages/web/server/lib/small-model/call.test.js b/packages/web/server/lib/small-model/call.test.js index 9b9533bc..7fd5fb1c 100644 --- a/packages/web/server/lib/small-model/call.test.js +++ b/packages/web/server/lib/small-model/call.test.js @@ -10,6 +10,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../opencode/shared.js', () => ({ readConfig: vi.fn(), readConfigLayers: vi.fn(), + // Pure predicate with no disk access — the real implementation, so header + // parsing is exercised rather than stubbed. + isPlainObject: (value) => value instanceof Object && !Array.isArray(value), })); vi.mock('./runtime-providers.js', () => ({ getRuntimeProvider: vi.fn(async () => null) })); @@ -122,6 +125,39 @@ describe('callSmallModel — custom provider config', () => { expect(lastCall(fetchMock).init.headers.Authorization).toBe('Bearer sk-env-key'); }); + it('sends configured provider headers alongside the bearer token', async () => { + process.env.OPENCHAMBER_TEST_GATEWAY_KEY = 'sub-key'; + readConfig.mockReturnValue({ + provider: { + custom: { + options: { + apiKey: 'sk-config', + baseURL: 'https://proxy.example.test/v1', + headers: { + 'Ocp-Apim-Subscription-Key': '{env:OPENCHAMBER_TEST_GATEWAY_KEY}', + 'x-tenant': 'team', + }, + }, + }, + }, + }); + fetchMock.mockResolvedValue(ok('hello')); + + await callSmallModel({ + auth: {}, + catalog: {}, + workingDirectory: '/proj', + providerID: 'custom', + modelID: 'model', + prompt: 'hi', + }); + + const { init } = lastCall(fetchMock); + expect(init.headers['Ocp-Apim-Subscription-Key']).toBe('sub-key'); + expect(init.headers['x-tenant']).toBe('team'); + expect(init.headers.Authorization).toBe('Bearer sk-config'); + }); + it('uses apiKey and baseURL from provider config when no auth.json entry exists', async () => { readConfig.mockReturnValue({ provider: {