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
This commit is contained in:
Dmitrii
2026-08-28 23:46:05 +03:00
committed by GitHub
parent 8bd4995b8b
commit 3903cb53b1
3 changed files with 69 additions and 5 deletions
@@ -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.<id>.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
+28 -2
View File
@@ -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,
@@ -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: {