From 3903cb53b18acf26f0c25fde0775b64a89b654f1 Mon Sep 17 00:00:00 2001 From: Dmitrii Date: Fri, 28 Aug 2026 15:46:05 -0500 Subject: [PATCH] fix(small-model): send configured provider headers (#3214) readProviderConfig read only options.baseURL and options.apiKey, and the OpenAI-compatible dispatch hardcoded a bearer token, so provider options.headers never reached the request. OpenCode sends those headers on every chat turn, which left the small model authenticating differently from the request path against the same URL. Providers behind a gateway that authenticates on its own header, such as the Ocp-Apim-Subscription-Key default of Azure API Management, answered 401 for walkthroughs, session goal audits, titles and commit summaries while the same model worked in chat. Read options.headers alongside the API key, resolve {env:...} and {file:...} in the values with the existing resolveConfigApiKey, and merge them into the request after the bearer default so a gateway whose header is the credential can override it. Closes #3213 --- .../server/lib/small-model/DOCUMENTATION.md | 8 +++-- packages/web/server/lib/small-model/call.js | 30 ++++++++++++++-- .../web/server/lib/small-model/call.test.js | 36 +++++++++++++++++++ 3 files changed, 69 insertions(+), 5 deletions(-) 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: {