Skip to content

Commit b717435

Browse files
committed
fix(auth): route manual keys with x-api-key
1 parent 4c75628 commit b717435

8 files changed

Lines changed: 160 additions & 53 deletions

File tree

src/agent/agent.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,12 @@ export interface CreateAgentOptions {
8282
* faux provider so tests can run without env vars or credentials.
8383
* Production code never sets this.
8484
*/
85-
configOverride?: { model: ResolvedConfig["model"]; apiKey: string; source: ResolvedConfig["source"] };
85+
configOverride?: {
86+
model: ResolvedConfig["model"];
87+
apiKey: string;
88+
source: ResolvedConfig["source"];
89+
proxyAuth?: ResolvedConfig["proxyAuth"];
90+
};
8691
/**
8792
* Runtime model override for proxy/OAuth sessions. Lets the user swap
8893
* models via /model without restarting. Format: `{ provider?, modelId }`.
@@ -184,15 +189,19 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
184189
const effectiveOverride =
185190
opts.modelOverride ??
186191
(persistedModel?.modelId ? { provider: persistedModel.provider, modelId: persistedModel.modelId } : undefined);
187-
const { model, apiKey, source } = opts.configOverride ?? resolveConfig({ modelOverride: effectiveOverride });
192+
const credentials = new CredentialsStore();
193+
const {
194+
model,
195+
apiKey,
196+
source,
197+
proxyAuth = "bearer",
198+
} = opts.configOverride ?? resolveConfig({ credentials, modelOverride: effectiveOverride });
188199

189200
// OAuth-sourced credentials rotate ~hourly; build a refresh-aware getter
190201
// so the agent never sends a stale access token after the first refresh
191202
// window. BYOK / explicit / auto sources use the static key passed in.
192203
const tokenManager =
193-
source === "proxy"
194-
? new TokenManager({ store: new CredentialsStore(), oauthConfig: defaultOAuthConfig() })
195-
: null;
204+
source === "proxy" ? new TokenManager({ store: credentials, oauthConfig: defaultOAuthConfig() }) : null;
196205
const getApiKey = tokenManager ? () => tokenManager.getAccessToken() : () => apiKey;
197206

198207
const config = persistedConfig;
@@ -327,7 +336,12 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
327336
...(tokenManager && {
328337
platform: {
329338
baseUrl: (process.env.CODEBASE_AUTH_BASE_URL ?? "https://codebase.design").replace(/\/+$/, ""),
330-
getAccessToken: () => tokenManager.getAccessToken(),
339+
getAuthHeaders: async () => {
340+
const token = await tokenManager.getAccessToken();
341+
const headers: Record<string, string> =
342+
proxyAuth === "api-key" ? { "X-API-Key": token } : { Authorization: `Bearer ${token}` };
343+
return headers;
344+
},
331345
},
332346
}),
333347
fileStateCache: new FileStateCache(),

src/agent/config.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,20 @@ describe("resolveConfig", () => {
8989
expect(config.model.provider).toBe("codebase");
9090
});
9191

92+
it("uses X-API-Key transport for a manually entered Codebase API key", () => {
93+
credentials.save({
94+
accessToken: "cb_test_manual",
95+
scopes: ["inference"],
96+
source: "manual",
97+
});
98+
99+
const config = resolveConfig({ env: {}, credentials });
100+
101+
expect(config.source).toBe("proxy");
102+
expect(config.proxyAuth).toBe("api-key");
103+
expect(config.model.headers).toMatchObject({ "X-API-Key": "cb_test_manual" });
104+
});
105+
92106
it("does not use an expired manual token that cannot refresh", () => {
93107
credentials.save({
94108
accessToken: "expired-manual-token",

src/agent/config.ts

Lines changed: 68 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ export interface ResolvedConfig {
5151
model: Model<string>;
5252
apiKey: string;
5353
source: "explicit" | "auto" | "proxy" | "byok";
54+
/** Header contract for Codebase proxy credentials. OAuth uses Bearer; issued keys use X-API-Key. */
55+
proxyAuth?: "bearer" | "api-key";
5456
}
5557

5658
export class ConfigError extends Error {}
@@ -102,7 +104,12 @@ export function resolveConfig(envOrOpts: NodeJS.ProcessEnv | ResolveConfigOption
102104
const byok = buildByokConfig(creds.provider as KnownProvider, creds.accessToken, override);
103105
if (byok) return byok;
104106
} else if (useProxy) {
105-
const proxied = buildProxiedConfig(env, creds.accessToken, override);
107+
const proxied = buildProxiedConfig(
108+
env,
109+
creds.accessToken,
110+
creds.source === "manual" ? "api-key" : "bearer",
111+
override,
112+
);
106113
if (proxied) return proxied;
107114
}
108115
}
@@ -202,6 +209,7 @@ export function resolveConfig(envOrOpts: NodeJS.ProcessEnv | ResolveConfigOption
202209
function buildProxiedConfig(
203210
env: NodeJS.ProcessEnv,
204211
accessToken: string,
212+
authMode: "bearer" | "api-key",
205213
override?: { provider?: string; modelId: string },
206214
): ResolvedConfig | null {
207215
// Runtime override (set via /model) wins over env vars wins over the default.
@@ -220,35 +228,39 @@ function buildProxiedConfig(
220228
const template = getModel("groq", "llama-3.3-70b-versatile") as Model<string> | undefined;
221229
if (!template) return null;
222230
const isDefault = !explicitModel;
223-
const model: Model<string> = {
224-
...template,
225-
// "Codebase Auto" — backend renamed this slot from MiniMax-M2.7
226-
// to d4f (DeepSeek V4 Flash) when the underlying SGLang server
227-
// was repointed. Keep this in sync with web/backend/providers/
228-
// registry.js DEFAULT_MODEL.
229-
id: explicitModel ?? "d4f",
230-
name: isDefault ? "Codebase Auto" : (explicitModel ?? "Codebase Auto"),
231-
baseUrl: proxyBase,
232-
// Override provider so the status bar and /model don't lie about
233-
// where this is served from. pi-ai uses `provider` mainly for
234-
// display + a few baseUrl heuristics; the request body sends
235-
// `model.id` only, so this cast is safe.
236-
provider: "codebase" as Model<string>["provider"],
237-
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
238-
// Keep this in sync with web/backend/providers/registry.js. The
239-
// Groq llama template's context window is only a synthesis
240-
// scaffold; compaction must budget against the actual d4f backend.
241-
contextWindow: 131_072,
242-
};
243-
return { model, apiKey: accessToken, source: "proxy" };
231+
const model = withProxyAuthHeaders(
232+
{
233+
...template,
234+
// "Codebase Auto" — backend renamed this slot from MiniMax-M2.7
235+
// to d4f (DeepSeek V4 Flash) when the underlying SGLang server
236+
// was repointed. Keep this in sync with web/backend/providers/
237+
// registry.js DEFAULT_MODEL.
238+
id: explicitModel ?? "d4f",
239+
name: isDefault ? "Codebase Auto" : (explicitModel ?? "Codebase Auto"),
240+
baseUrl: proxyBase,
241+
// Override provider so the status bar and /model don't lie about
242+
// where this is served from. pi-ai uses `provider` mainly for
243+
// display + a few baseUrl heuristics; the request body sends
244+
// `model.id` only, so this cast is safe.
245+
provider: "codebase" as Model<string>["provider"],
246+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
247+
// Keep this in sync with web/backend/providers/registry.js. The
248+
// Groq llama template's context window is only a synthesis
249+
// scaffold; compaction must budget against the actual d4f backend.
250+
contextWindow: 131_072,
251+
},
252+
accessToken,
253+
authMode,
254+
);
255+
return { model, apiKey: accessToken, source: "proxy", proxyAuth: authMode };
244256
}
245257

246258
const modelId = explicitModel ?? DEFAULT_MODELS[explicitProvider];
247259
if (!modelId) return null;
248260
const baseModel = getModel(explicitProvider, modelId as never) as Model<string> | undefined;
249261
if (baseModel) {
250-
const proxiedModel: Model<string> = { ...baseModel, baseUrl: proxyBase };
251-
return { model: proxiedModel, apiKey: accessToken, source: "proxy" };
262+
const proxiedModel = withProxyAuthHeaders({ ...baseModel, baseUrl: proxyBase }, accessToken, authMode);
263+
return { model: proxiedModel, apiKey: accessToken, source: "proxy", proxyAuth: authMode };
252264
}
253265
// pi-ai's registry doesn't know this provider+modelId combo (common for
254266
// backend-only models the proxy exposes, e.g. "codebase:MiniMax-M2.7"
@@ -260,20 +272,39 @@ function buildProxiedConfig(
260272
// didn't have native cost / context-window data for.
261273
const template = getModel("groq", "llama-3.3-70b-versatile") as Model<string> | undefined;
262274
if (!template) return null;
263-
const synthesized: Model<string> = {
264-
...template,
265-
id: modelId,
266-
name: modelId,
267-
baseUrl: proxyBase,
268-
provider: explicitProvider as Model<string>["provider"],
269-
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
270-
// Same reasoning as Codebase Auto: synthesized proxy models point
271-
// at large-context backends. Without overriding contextWindow the
272-
// Groq llama template's 128k leaks through and the compaction
273-
// engine triggers ~30% earlier than it should.
274-
contextWindow: guessContextWindow(modelId, 200_000),
275+
const synthesized = withProxyAuthHeaders(
276+
{
277+
...template,
278+
id: modelId,
279+
name: modelId,
280+
baseUrl: proxyBase,
281+
provider: explicitProvider as Model<string>["provider"],
282+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
283+
// Same reasoning as Codebase Auto: synthesized proxy models point
284+
// at large-context backends. Without overriding contextWindow the
285+
// Groq llama template's 128k leaks through and the compaction
286+
// engine triggers ~30% earlier than it should.
287+
contextWindow: guessContextWindow(modelId, 200_000),
288+
},
289+
accessToken,
290+
authMode,
291+
);
292+
return { model: synthesized, apiKey: accessToken, source: "proxy", proxyAuth: authMode };
293+
}
294+
295+
function withProxyAuthHeaders(
296+
model: Model<string>,
297+
accessToken: string,
298+
authMode: "bearer" | "api-key",
299+
): Model<string> {
300+
if (authMode === "bearer") return model;
301+
return {
302+
...model,
303+
headers: {
304+
...model.headers,
305+
"X-API-Key": accessToken,
306+
},
275307
};
276-
return { model: synthesized, apiKey: accessToken, source: "proxy" };
277308
}
278309

279310
/**

src/agent/model-list.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,23 @@ describe("fetchAvailableModels", () => {
3232
expect(out).toEqual([{ id: "d4f", name: "Codebase Auto", provider: "codebase" }]);
3333
});
3434

35+
it("preserves model X-API-Key headers for Codebase-issued credentials", async () => {
36+
const f = mockFetch(200, { models: [] });
37+
vi.stubGlobal("fetch", f);
38+
const manualModel = {
39+
...model("codebase", "https://codebase.design/api/inference"),
40+
headers: { "X-API-Key": "cb_test_manual" },
41+
};
42+
43+
await fetchAvailableModels(manualModel, "cb_test_manual");
44+
45+
const headers = (f as unknown as ReturnType<typeof vi.fn>).mock.calls[0][1]?.headers;
46+
expect(headers).toMatchObject({
47+
"X-API-Key": "cb_test_manual",
48+
});
49+
expect(headers.Authorization).toBeUndefined();
50+
});
51+
3552
it("hits Anthropic's endpoint and maps display_name", async () => {
3653
const f = mockFetch(200, { data: [{ id: "claude-opus-4-1", display_name: "Claude Opus 4.1" }] });
3754
vi.stubGlobal("fetch", f);

src/agent/model-list.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,23 @@ export async function fetchAvailableModels(
2424
if (provider === "google") return fetchGoogle(apiKey, signal);
2525
const baseUrl = (model.baseUrl ?? "").replace(/\/+$/, "");
2626
if (!baseUrl) return [];
27-
return fetchOpenAiCompat(baseUrl, apiKey, provider, signal);
27+
return fetchOpenAiCompat(baseUrl, apiKey, provider, model.headers, signal);
2828
}
2929

3030
/** OpenAI-compatible `GET {baseUrl}/models`. Also parses our proxy's `{models:[…]}` shape. */
3131
async function fetchOpenAiCompat(
3232
baseUrl: string,
3333
apiKey: string | undefined,
3434
provider: string,
35+
modelHeaders: Record<string, string> | undefined,
3536
signal?: AbortSignal,
3637
): Promise<ModelOption[]> {
37-
const headers: Record<string, string> = { Accept: "application/json" };
38+
const headers: Record<string, string> = { ...modelHeaders, Accept: "application/json" };
3839
// Local servers (Ollama/LM Studio) usually need no auth; only send a key
39-
// when we have a real one.
40-
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
40+
// when we have a real one. A model-level X-API-Key means this is a
41+
// manually entered Codebase key, so do not duplicate it as Bearer.
42+
const hasApiKeyHeader = Object.keys(headers).some((name) => name.toLowerCase() === "x-api-key");
43+
if (apiKey && !hasApiKeyHeader) headers.Authorization = `Bearer ${apiKey}`;
4144
const res = await fetch(`${baseUrl}/models`, { headers, signal });
4245
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
4346
const json = (await res.json()) as { data?: unknown; models?: unknown };

src/tools/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export interface ToolContext {
2424
*/
2525
platform?: {
2626
baseUrl: string;
27-
getAccessToken: () => Promise<string>;
27+
getAuthHeaders: () => Promise<Record<string, string>>;
2828
};
2929
fileStateCache: FileStateCache;
3030
tasks: TaskStore;

src/tools/web-search.test.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,10 @@ describe("web_search provider selection", () => {
6262
});
6363

6464
it("uses Codebase hosted search only when no local provider is configured", () => {
65-
const hosted = { baseUrl: "https://codebase.design", getAccessToken: async () => "token" };
65+
const hosted = {
66+
baseUrl: "https://codebase.design",
67+
getAuthHeaders: async () => ({ Authorization: "Bearer token" }),
68+
};
6669
expect(pickProvider({}, hosted).name).toBe("codebase");
6770
expect(pickProvider({ BRAVE_API_KEY: "x" }, hosted).name).toBe("brave");
6871
});
@@ -182,7 +185,7 @@ describe("web_search end-to-end via mock servers", () => {
182185
...makeCtx(),
183186
platform: {
184187
baseUrl,
185-
getAccessToken: async () => "oauth-token",
188+
getAuthHeaders: async () => ({ Authorization: "Bearer oauth-token" }),
186189
},
187190
}).execute("call", { query: "AIPG", max_results: 4 });
188191

@@ -196,6 +199,31 @@ describe("web_search end-to-end via mock servers", () => {
196199
}
197200
});
198201

202+
it("uses X-API-Key for hosted search with a manually entered Codebase key", async () => {
203+
routes["/api/v1/search"] = (req, res) => {
204+
expect(req.headers["x-api-key"]).toBe("cb_test_manual");
205+
expect(req.headers.authorization).toBeUndefined();
206+
res.setHeader("Content-Type", "application/json");
207+
res.end(JSON.stringify({ results: [] }));
208+
};
209+
210+
const original = { ...process.env };
211+
for (const key of ["TAVILY_API_KEY", "BRAVE_API_KEY", "SEARXNG_URL"]) delete process.env[key];
212+
try {
213+
const result = await createWebSearch({
214+
...makeCtx(),
215+
platform: {
216+
baseUrl,
217+
getAuthHeaders: async () => ({ "X-API-Key": "cb_test_manual" }),
218+
},
219+
}).execute("call", { query: "manual auth" });
220+
221+
expect(result.details.provider).toBe("codebase");
222+
} finally {
223+
Object.assign(process.env, original);
224+
}
225+
});
226+
199227
it("surfaces provider HTTP errors with the status code", async () => {
200228
routes["/search"] = (_req, res) => {
201229
res.statusCode = 401;

src/tools/web-search.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export interface ProviderEnv {
6969

7070
export interface HostedSearchConfig {
7171
baseUrl: string;
72-
getAccessToken: () => Promise<string>;
72+
getAuthHeaders: () => Promise<Record<string, string>>;
7373
}
7474

7575
export function createWebSearch(ctx: ToolContext): AgentTool<typeof Params, WebSearchDetails> {
@@ -138,11 +138,11 @@ function codebaseProvider(config: HostedSearchConfig): ProviderConfig {
138138
return {
139139
name: "codebase",
140140
search: async (query, max, signal) => {
141-
const token = await config.getAccessToken();
141+
const authHeaders = await config.getAuthHeaders();
142142
const res = await fetch(url, {
143143
method: "POST",
144144
headers: {
145-
Authorization: `Bearer ${token}`,
145+
...authHeaders,
146146
"Content-Type": "application/json",
147147
Accept: "application/json",
148148
},

0 commit comments

Comments
 (0)