Skip to content

Commit b9c1e07

Browse files
authored
Merge pull request #14 from codebase/codex/oauth-web-search
feat(search): use hosted search for signed-in sessions
2 parents 5ce32a9 + 5b8c0c9 commit b9c1e07

17 files changed

Lines changed: 384 additions & 64 deletions

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,11 @@ codebase project build --wait "build a launch waitlist page"
8686

8787
OAuth uses Codebase Auto by default (`codebase/d4f`, in-house DeepSeek V4 Flash). Swap models live with `/model`. Set reasoning depth with `/effort`. `project build` hands a prompt to the web builder and prints the session, status, event stream, and preview URL when you pass `--wait`.
8888

89+
Signed-in sessions also get hosted web search with no extra key. BYOK users can
90+
set `TAVILY_API_KEY`, `BRAVE_API_KEY`, or `SEARXNG_URL`; local search providers
91+
take priority over the hosted service. Run `codebase doctor` to verify which
92+
path is active.
93+
8994
## Use Codebase from an ACP client
9095

9196
Codebase speaks the [Agent Client Protocol](https://agentclientprotocol.com/) over stdio:

docs/MIGRATION_v1_to_v2.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ and project memory all keep working.
2929
| `~/.codebase/config.json` | User config (theme, MCP servers, hooks) | Compatible. New fields are added; existing ones are read as before. |
3030
| `CLAUDE.md`, `AGENTS.md`, `CODEX.md`, `.cursorrules` | Project instructions | Identical pickup logic. |
3131
| `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL` | Provider env vars | Identical. |
32-
| `GLUE_*`, `TAVILY_API_KEY`, `BRAVE_API_KEY`, `SEARXNG_URL` | Sidecar + search keys | Identical. |
32+
| `GLUE_*`, `TAVILY_API_KEY`, `BRAVE_API_KEY`, `SEARXNG_URL` | Sidecar + search keys | Still supported. Signed-in Codebase sessions can use hosted search without a separate search key. |
3333
| `CODEBASE_NOBOOT`, `CODEBASE_NOSOUND` | Behavior toggles | Identical. |
3434

3535
If you sign in via codebase.foundation in v1, you stay signed in

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codebase-cli",
3-
"version": "2.0.0-pre.83",
3+
"version": "2.0.0-pre.84",
44
"description": "Codebase CLI — a TypeScript coding agent on the pi-mono runtime. OAuth-aware, any LLM provider, single install.",
55
"keywords": [
66
"ai",

src/agent/agent.ts

Lines changed: 25 additions & 5 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;
@@ -324,6 +333,17 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
324333

325334
const toolContext: ToolContext = {
326335
cwd,
336+
...(tokenManager && {
337+
platform: {
338+
baseUrl: (process.env.CODEBASE_AUTH_BASE_URL ?? "https://codebase.design").replace(/\/+$/, ""),
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+
},
345+
},
346+
}),
327347
fileStateCache: new FileStateCache(),
328348
tasks: new TaskStore({ cwd, taskListId: opts.taskListId ?? sessions.id }),
329349
userQueries,

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/agent/proxy-auth-wire.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { mkdtempSync, rmSync } from "node:fs";
2+
import { type AddressInfo, createServer } from "node:http";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { completeSimple } from "@earendil-works/pi-ai";
6+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
7+
import { CredentialsStore } from "../auth/credentials.js";
8+
import { resolveConfig } from "./config.js";
9+
10+
describe("manual proxy authentication on the wire", () => {
11+
const requests: Array<Record<string, string | string[] | undefined>> = [];
12+
let server: ReturnType<typeof createServer>;
13+
let baseUrl: string;
14+
15+
beforeAll(async () => {
16+
server = createServer((req, res) => {
17+
requests.push(req.headers);
18+
res.writeHead(200, { "Content-Type": "text/event-stream" });
19+
res.write(
20+
`data: ${JSON.stringify({
21+
id: "chatcmpl-auth",
22+
object: "chat.completion.chunk",
23+
created: Math.floor(Date.now() / 1000),
24+
model: "d4f",
25+
choices: [{ index: 0, delta: { role: "assistant", content: "authenticated" }, finish_reason: null }],
26+
})}\n\n`,
27+
);
28+
res.write(
29+
`data: ${JSON.stringify({
30+
id: "chatcmpl-auth",
31+
object: "chat.completion.chunk",
32+
created: Math.floor(Date.now() / 1000),
33+
model: "d4f",
34+
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
35+
})}\n\n`,
36+
);
37+
res.end("data: [DONE]\n\n");
38+
});
39+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
40+
const address = server.address() as AddressInfo;
41+
baseUrl = `http://127.0.0.1:${address.port}/inference`;
42+
});
43+
44+
afterAll(async () => {
45+
await new Promise<void>((resolve, reject) => {
46+
server.close((error) => (error ? reject(error) : resolve()));
47+
});
48+
});
49+
50+
it("sends X-API-Key through the real OpenAI-compatible transport", async () => {
51+
const dataRoot = mkdtempSync(join(tmpdir(), "codebase-proxy-wire-"));
52+
try {
53+
const credentials = new CredentialsStore({ dataRoot });
54+
credentials.save({
55+
accessToken: "cb_test_wire",
56+
scopes: ["inference"],
57+
source: "manual",
58+
});
59+
const config = resolveConfig({
60+
env: { CODEBASE_PROXY_BASE_URL: baseUrl },
61+
credentials,
62+
});
63+
64+
const response = await completeSimple(
65+
config.model,
66+
{ messages: [{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() }] },
67+
{ apiKey: config.apiKey },
68+
);
69+
70+
expect(response.content).toContainEqual({ type: "text", text: "authenticated" });
71+
expect(requests).toHaveLength(1);
72+
expect(requests[0]["x-api-key"]).toBe("cb_test_wire");
73+
} finally {
74+
rmSync(dataRoot, { recursive: true, force: true });
75+
}
76+
});
77+
});

0 commit comments

Comments
 (0)