Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@ codebase project build --wait "build a launch waitlist page"

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`.

Signed-in sessions also get hosted web search with no extra key. BYOK users can
set `TAVILY_API_KEY`, `BRAVE_API_KEY`, or `SEARXNG_URL`; local search providers
take priority over the hosted service. Run `codebase doctor` to verify which
path is active.

## Use Codebase from an ACP client

Codebase speaks the [Agent Client Protocol](https://agentclientprotocol.com/) over stdio:
Expand Down
2 changes: 1 addition & 1 deletion docs/MIGRATION_v1_to_v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ and project memory all keep working.
| `~/.codebase/config.json` | User config (theme, MCP servers, hooks) | Compatible. New fields are added; existing ones are read as before. |
| `CLAUDE.md`, `AGENTS.md`, `CODEX.md`, `.cursorrules` | Project instructions | Identical pickup logic. |
| `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL` | Provider env vars | Identical. |
| `GLUE_*`, `TAVILY_API_KEY`, `BRAVE_API_KEY`, `SEARXNG_URL` | Sidecar + search keys | Identical. |
| `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. |
| `CODEBASE_NOBOOT`, `CODEBASE_NOSOUND` | Behavior toggles | Identical. |

If you sign in via codebase.foundation in v1, you stay signed in
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "codebase-cli",
"version": "2.0.0-pre.83",
"version": "2.0.0-pre.84",
"description": "Codebase CLI — a TypeScript coding agent on the pi-mono runtime. OAuth-aware, any LLM provider, single install.",
"keywords": [
"ai",
Expand Down
30 changes: 25 additions & 5 deletions src/agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,12 @@ export interface CreateAgentOptions {
* faux provider so tests can run without env vars or credentials.
* Production code never sets this.
*/
configOverride?: { model: ResolvedConfig["model"]; apiKey: string; source: ResolvedConfig["source"] };
configOverride?: {
model: ResolvedConfig["model"];
apiKey: string;
source: ResolvedConfig["source"];
proxyAuth?: ResolvedConfig["proxyAuth"];
};
/**
* Runtime model override for proxy/OAuth sessions. Lets the user swap
* models via /model without restarting. Format: `{ provider?, modelId }`.
Expand Down Expand Up @@ -184,15 +189,19 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
const effectiveOverride =
opts.modelOverride ??
(persistedModel?.modelId ? { provider: persistedModel.provider, modelId: persistedModel.modelId } : undefined);
const { model, apiKey, source } = opts.configOverride ?? resolveConfig({ modelOverride: effectiveOverride });
const credentials = new CredentialsStore();
const {
model,
apiKey,
source,
proxyAuth = "bearer",
} = opts.configOverride ?? resolveConfig({ credentials, modelOverride: effectiveOverride });

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

const config = persistedConfig;
Expand Down Expand Up @@ -324,6 +333,17 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {

const toolContext: ToolContext = {
cwd,
...(tokenManager && {
platform: {
baseUrl: (process.env.CODEBASE_AUTH_BASE_URL ?? "https://codebase.design").replace(/\/+$/, ""),
getAuthHeaders: async () => {
const token = await tokenManager.getAccessToken();
const headers: Record<string, string> =
proxyAuth === "api-key" ? { "X-API-Key": token } : { Authorization: `Bearer ${token}` };
return headers;
},
},
}),
fileStateCache: new FileStateCache(),
tasks: new TaskStore({ cwd, taskListId: opts.taskListId ?? sessions.id }),
userQueries,
Expand Down
14 changes: 14 additions & 0 deletions src/agent/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,20 @@ describe("resolveConfig", () => {
expect(config.model.provider).toBe("codebase");
});

it("uses X-API-Key transport for a manually entered Codebase API key", () => {
credentials.save({
accessToken: "cb_test_manual",
scopes: ["inference"],
source: "manual",
});

const config = resolveConfig({ env: {}, credentials });

expect(config.source).toBe("proxy");
expect(config.proxyAuth).toBe("api-key");
expect(config.model.headers).toMatchObject({ "X-API-Key": "cb_test_manual" });
});

it("does not use an expired manual token that cannot refresh", () => {
credentials.save({
accessToken: "expired-manual-token",
Expand Down
105 changes: 68 additions & 37 deletions src/agent/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export interface ResolvedConfig {
model: Model<string>;
apiKey: string;
source: "explicit" | "auto" | "proxy" | "byok";
/** Header contract for Codebase proxy credentials. OAuth uses Bearer; issued keys use X-API-Key. */
proxyAuth?: "bearer" | "api-key";
}

export class ConfigError extends Error {}
Expand Down Expand Up @@ -102,7 +104,12 @@ export function resolveConfig(envOrOpts: NodeJS.ProcessEnv | ResolveConfigOption
const byok = buildByokConfig(creds.provider as KnownProvider, creds.accessToken, override);
if (byok) return byok;
} else if (useProxy) {
const proxied = buildProxiedConfig(env, creds.accessToken, override);
const proxied = buildProxiedConfig(
env,
creds.accessToken,
creds.source === "manual" ? "api-key" : "bearer",
override,
);
if (proxied) return proxied;
}
}
Expand Down Expand Up @@ -202,6 +209,7 @@ export function resolveConfig(envOrOpts: NodeJS.ProcessEnv | ResolveConfigOption
function buildProxiedConfig(
env: NodeJS.ProcessEnv,
accessToken: string,
authMode: "bearer" | "api-key",
override?: { provider?: string; modelId: string },
): ResolvedConfig | null {
// Runtime override (set via /model) wins over env vars wins over the default.
Expand All @@ -220,35 +228,39 @@ function buildProxiedConfig(
const template = getModel("groq", "llama-3.3-70b-versatile") as Model<string> | undefined;
if (!template) return null;
const isDefault = !explicitModel;
const model: Model<string> = {
...template,
// "Codebase Auto" — backend renamed this slot from MiniMax-M2.7
// to d4f (DeepSeek V4 Flash) when the underlying SGLang server
// was repointed. Keep this in sync with web/backend/providers/
// registry.js DEFAULT_MODEL.
id: explicitModel ?? "d4f",
name: isDefault ? "Codebase Auto" : (explicitModel ?? "Codebase Auto"),
baseUrl: proxyBase,
// Override provider so the status bar and /model don't lie about
// where this is served from. pi-ai uses `provider` mainly for
// display + a few baseUrl heuristics; the request body sends
// `model.id` only, so this cast is safe.
provider: "codebase" as Model<string>["provider"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
// Keep this in sync with web/backend/providers/registry.js. The
// Groq llama template's context window is only a synthesis
// scaffold; compaction must budget against the actual d4f backend.
contextWindow: 131_072,
};
return { model, apiKey: accessToken, source: "proxy" };
const model = withProxyAuthHeaders(
{
...template,
// "Codebase Auto" — backend renamed this slot from MiniMax-M2.7
// to d4f (DeepSeek V4 Flash) when the underlying SGLang server
// was repointed. Keep this in sync with web/backend/providers/
// registry.js DEFAULT_MODEL.
id: explicitModel ?? "d4f",
name: isDefault ? "Codebase Auto" : (explicitModel ?? "Codebase Auto"),
baseUrl: proxyBase,
// Override provider so the status bar and /model don't lie about
// where this is served from. pi-ai uses `provider` mainly for
// display + a few baseUrl heuristics; the request body sends
// `model.id` only, so this cast is safe.
provider: "codebase" as Model<string>["provider"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
// Keep this in sync with web/backend/providers/registry.js. The
// Groq llama template's context window is only a synthesis
// scaffold; compaction must budget against the actual d4f backend.
contextWindow: 131_072,
},
accessToken,
authMode,
);
return { model, apiKey: accessToken, source: "proxy", proxyAuth: authMode };
}

const modelId = explicitModel ?? DEFAULT_MODELS[explicitProvider];
if (!modelId) return null;
const baseModel = getModel(explicitProvider, modelId as never) as Model<string> | undefined;
if (baseModel) {
const proxiedModel: Model<string> = { ...baseModel, baseUrl: proxyBase };
return { model: proxiedModel, apiKey: accessToken, source: "proxy" };
const proxiedModel = withProxyAuthHeaders({ ...baseModel, baseUrl: proxyBase }, accessToken, authMode);
return { model: proxiedModel, apiKey: accessToken, source: "proxy", proxyAuth: authMode };
}
// pi-ai's registry doesn't know this provider+modelId combo (common for
// backend-only models the proxy exposes, e.g. "codebase:MiniMax-M2.7"
Expand All @@ -260,20 +272,39 @@ function buildProxiedConfig(
// didn't have native cost / context-window data for.
const template = getModel("groq", "llama-3.3-70b-versatile") as Model<string> | undefined;
if (!template) return null;
const synthesized: Model<string> = {
...template,
id: modelId,
name: modelId,
baseUrl: proxyBase,
provider: explicitProvider as Model<string>["provider"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
// Same reasoning as Codebase Auto: synthesized proxy models point
// at large-context backends. Without overriding contextWindow the
// Groq llama template's 128k leaks through and the compaction
// engine triggers ~30% earlier than it should.
contextWindow: guessContextWindow(modelId, 200_000),
const synthesized = withProxyAuthHeaders(
{
...template,
id: modelId,
name: modelId,
baseUrl: proxyBase,
provider: explicitProvider as Model<string>["provider"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
// Same reasoning as Codebase Auto: synthesized proxy models point
// at large-context backends. Without overriding contextWindow the
// Groq llama template's 128k leaks through and the compaction
// engine triggers ~30% earlier than it should.
contextWindow: guessContextWindow(modelId, 200_000),
},
accessToken,
authMode,
);
return { model: synthesized, apiKey: accessToken, source: "proxy", proxyAuth: authMode };
}

function withProxyAuthHeaders(
model: Model<string>,
accessToken: string,
authMode: "bearer" | "api-key",
): Model<string> {
if (authMode === "bearer") return model;
return {
...model,
headers: {
...model.headers,
"X-API-Key": accessToken,
},
};
return { model: synthesized, apiKey: accessToken, source: "proxy" };
}

/**
Expand Down
17 changes: 17 additions & 0 deletions src/agent/model-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,23 @@ describe("fetchAvailableModels", () => {
expect(out).toEqual([{ id: "d4f", name: "Codebase Auto", provider: "codebase" }]);
});

it("preserves model X-API-Key headers for Codebase-issued credentials", async () => {
const f = mockFetch(200, { models: [] });
vi.stubGlobal("fetch", f);
const manualModel = {
...model("codebase", "https://codebase.design/api/inference"),
headers: { "X-API-Key": "cb_test_manual" },
};

await fetchAvailableModels(manualModel, "cb_test_manual");

const headers = (f as unknown as ReturnType<typeof vi.fn>).mock.calls[0][1]?.headers;
expect(headers).toMatchObject({
"X-API-Key": "cb_test_manual",
});
expect(headers.Authorization).toBeUndefined();
});

it("hits Anthropic's endpoint and maps display_name", async () => {
const f = mockFetch(200, { data: [{ id: "claude-opus-4-1", display_name: "Claude Opus 4.1" }] });
vi.stubGlobal("fetch", f);
Expand Down
11 changes: 7 additions & 4 deletions src/agent/model-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,23 @@ export async function fetchAvailableModels(
if (provider === "google") return fetchGoogle(apiKey, signal);
const baseUrl = (model.baseUrl ?? "").replace(/\/+$/, "");
if (!baseUrl) return [];
return fetchOpenAiCompat(baseUrl, apiKey, provider, signal);
return fetchOpenAiCompat(baseUrl, apiKey, provider, model.headers, signal);
}

/** OpenAI-compatible `GET {baseUrl}/models`. Also parses our proxy's `{models:[…]}` shape. */
async function fetchOpenAiCompat(
baseUrl: string,
apiKey: string | undefined,
provider: string,
modelHeaders: Record<string, string> | undefined,
signal?: AbortSignal,
): Promise<ModelOption[]> {
const headers: Record<string, string> = { Accept: "application/json" };
const headers: Record<string, string> = { ...modelHeaders, Accept: "application/json" };
// Local servers (Ollama/LM Studio) usually need no auth; only send a key
// when we have a real one.
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
// when we have a real one. A model-level X-API-Key means this is a
// manually entered Codebase key, so do not duplicate it as Bearer.
const hasApiKeyHeader = Object.keys(headers).some((name) => name.toLowerCase() === "x-api-key");
if (apiKey && !hasApiKeyHeader) headers.Authorization = `Bearer ${apiKey}`;
const res = await fetch(`${baseUrl}/models`, { headers, signal });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const json = (await res.json()) as { data?: unknown; models?: unknown };
Expand Down
77 changes: 77 additions & 0 deletions src/agent/proxy-auth-wire.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { mkdtempSync, rmSync } from "node:fs";
import { type AddressInfo, createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { completeSimple } from "@earendil-works/pi-ai";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { CredentialsStore } from "../auth/credentials.js";
import { resolveConfig } from "./config.js";

describe("manual proxy authentication on the wire", () => {
const requests: Array<Record<string, string | string[] | undefined>> = [];
let server: ReturnType<typeof createServer>;
let baseUrl: string;

beforeAll(async () => {
server = createServer((req, res) => {
requests.push(req.headers);
res.writeHead(200, { "Content-Type": "text/event-stream" });
res.write(
`data: ${JSON.stringify({
id: "chatcmpl-auth",
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: "d4f",
choices: [{ index: 0, delta: { role: "assistant", content: "authenticated" }, finish_reason: null }],
})}\n\n`,
);
res.write(
`data: ${JSON.stringify({
id: "chatcmpl-auth",
object: "chat.completion.chunk",
created: Math.floor(Date.now() / 1000),
model: "d4f",
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
})}\n\n`,
);
res.end("data: [DONE]\n\n");
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address() as AddressInfo;
baseUrl = `http://127.0.0.1:${address.port}/inference`;
});

afterAll(async () => {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
});

it("sends X-API-Key through the real OpenAI-compatible transport", async () => {
const dataRoot = mkdtempSync(join(tmpdir(), "codebase-proxy-wire-"));
try {
const credentials = new CredentialsStore({ dataRoot });
credentials.save({
accessToken: "cb_test_wire",
scopes: ["inference"],
source: "manual",
});
const config = resolveConfig({
env: { CODEBASE_PROXY_BASE_URL: baseUrl },
credentials,
});

const response = await completeSimple(
config.model,
{ messages: [{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: Date.now() }] },
{ apiKey: config.apiKey },
);

expect(response.content).toContainEqual({ type: "text", text: "authenticated" });
expect(requests).toHaveLength(1);
expect(requests[0]["x-api-key"]).toBe("cb_test_wire");
} finally {
rmSync(dataRoot, { recursive: true, force: true });
}
});
});
Loading
Loading