Skip to content

Commit 5054b9f

Browse files
committed
feat(model): switch between provider/model mid-session (/model, /models, picker)
Switching models used to require restarting with CODEBASE_PROVIDER / CODEBASE_MODEL env vars. Ship a real switching path now that the proxy exposes a live model list: - /model with no args opens an inline picker over the input row. ↑↓ to navigate, Enter to commit, r to reset to Codebase Auto, Esc to cancel. - /model <id> or /model <provider>:<id> switches directly (great for muscle memory once you know the id). - Aliases for common picks: sonnet, opus, haiku, gpt-5, gpt-4o, llama. - /model auto resets to the default. - /models lists everything your account can access, grouped by provider, with the active model marked. Mechanically: the agent bundle is now state (not a memo) inside ChatApp, so /model can abort the current run, snapshot state.messages, rebuild the bundle with the new model + existing transcript via a new modelOverride + initialMessages on createAgent, and swap it in via setBundle. The reducer gets a "model-switched" action that updates state.model and clears per-agent execution state (tools, streaming, error) without nuking the conversation. The choice persists to ~/.codebase/config.json (new `model` field on Config), so next launch defaults to your last pick. ConfigStore grows preferredModel() + setPreferredModel() to read/write that field while preserving every other field in the user config file. BYOK users get a graceful fallback message — there's no central model list to fetch for arbitrary upstreams, so /model and /models tell them to set env vars at launch instead of opening an empty overlay.
1 parent 86f4dd4 commit 5054b9f

13 files changed

Lines changed: 570 additions & 26 deletions

File tree

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.45",
3+
"version": "2.0.0-pre.46",
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: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,20 @@ export interface CreateAgentOptions {
6464
* Production code never sets this.
6565
*/
6666
configOverride?: { model: ResolvedConfig["model"]; apiKey: string; source: ResolvedConfig["source"] };
67+
/**
68+
* Runtime model override for proxy/OAuth sessions. Lets the user swap
69+
* models via /model without restarting. Format: `{ provider?, modelId }`.
70+
* Provider is optional — when omitted, the model id is sent verbatim
71+
* through the proxy and the backend's registry resolves it.
72+
*/
73+
modelOverride?: { provider?: string; modelId: string };
74+
/**
75+
* Seed the agent's transcript from an in-memory message list rather
76+
* than from `~/.codebase/sessions/`. Used by the runtime model switch:
77+
* we rebuild the agent with the existing conversation so the user
78+
* doesn't lose context, but we don't want to disk-roundtrip.
79+
*/
80+
initialMessages?: AgentMessage[];
6781
}
6882

6983
export interface AgentBundle {
@@ -98,10 +112,19 @@ export interface AgentBundle {
98112
}
99113

100114
export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
101-
const { model, apiKey, source } = opts.configOverride ?? resolveConfig();
102115
const cwd = opts.cwd ?? process.cwd();
103116
const systemPrompt = opts.systemPrompt ?? buildSystemPrompt(cwd);
104117

118+
// Persisted model preference from `~/.codebase/config.json` (set via
119+
// `/model`) seeds the override when the caller hasn't passed one
120+
// explicitly. Explicit runtime overrides still win.
121+
const persistedConfig = new ConfigStore({ cwd });
122+
const persistedModel = persistedConfig.preferredModel();
123+
const effectiveOverride =
124+
opts.modelOverride ??
125+
(persistedModel?.modelId ? { provider: persistedModel.provider, modelId: persistedModel.modelId } : undefined);
126+
const { model, apiKey, source } = opts.configOverride ?? resolveConfig({ modelOverride: effectiveOverride });
127+
105128
// OAuth-sourced credentials rotate ~hourly; build a refresh-aware getter
106129
// so the agent never sends a stale access token after the first refresh
107130
// window. BYOK / explicit / auto sources use the static key passed in.
@@ -111,7 +134,7 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
111134
: null;
112135
const getApiKey = tokenManager ? () => tokenManager.getAccessToken() : () => apiKey;
113136

114-
const config = new ConfigStore({ cwd });
137+
const config = persistedConfig;
115138
const permissions = new PermissionStore({
116139
allowPatterns: config.allowPatterns(),
117140
denyPatterns: config.denyPatterns(),
@@ -159,7 +182,7 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
159182
model,
160183
systemPrompt: fullSystemPrompt,
161184
tools: buildTools(toolContext),
162-
messages: resumed?.messages ?? [],
185+
messages: opts.initialMessages ?? resumed?.messages ?? [],
163186
},
164187
getApiKey: () => apiKey,
165188
transformContext: async (messages, signal) => {
@@ -321,6 +344,6 @@ export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
321344
diagnostics,
322345
subscribe,
323346
resumedFrom: resumed ? { updatedAt: resumed.updatedAt, messageCount: resumed.messages.length } : undefined,
324-
resumedMessages: resumed?.messages ?? [],
347+
resumedMessages: opts.initialMessages ?? resumed?.messages ?? [],
325348
};
326349
}

src/agent/config.ts

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,20 @@ export class ConfigError extends Error {}
5858
export interface ResolveConfigOptions {
5959
env?: NodeJS.ProcessEnv;
6060
credentials?: CredentialsStore;
61+
/**
62+
* Runtime model override. When set on an OAuth/proxy session, replaces
63+
* the resolved model id (and optionally provider) so a user can swap
64+
* mid-session via `/model <id>` without restarting. Ignored when no
65+
* proxy session is active.
66+
*/
67+
modelOverride?: { provider?: string; modelId: string };
6168
}
6269

6370
export function resolveConfig(envOrOpts: NodeJS.ProcessEnv | ResolveConfigOptions = process.env): ResolvedConfig {
6471
const opts = isProcessEnv(envOrOpts) ? { env: envOrOpts } : envOrOpts;
6572
const env = opts.env ?? process.env;
6673
const credentials = opts.credentials ?? new CredentialsStore();
74+
const override = opts.modelOverride;
6775

6876
// 1. Saved credentials. Routing depends on the source:
6977
// - codebase / manual → proxy through codebase.design
@@ -75,7 +83,7 @@ export function resolveConfig(envOrOpts: NodeJS.ProcessEnv | ResolveConfigOption
7583
const byok = buildByokConfig(creds.provider as KnownProvider, creds.accessToken);
7684
if (byok) return byok;
7785
} else if (useProxy) {
78-
const proxied = buildProxiedConfig(env, creds.accessToken);
86+
const proxied = buildProxiedConfig(env, creds.accessToken, override);
7987
if (proxied) return proxied;
8088
}
8189
}
@@ -165,21 +173,28 @@ export function resolveConfig(envOrOpts: NodeJS.ProcessEnv | ResolveConfigOption
165173
* the bearer scope on the token, so this works for any model the
166174
* user's account has access to.
167175
*/
168-
function buildProxiedConfig(env: NodeJS.ProcessEnv, accessToken: string): ResolvedConfig | null {
169-
const explicitProvider = env.CODEBASE_PROVIDER as KnownProvider | undefined;
170-
const explicitModel = env.CODEBASE_MODEL;
176+
function buildProxiedConfig(
177+
env: NodeJS.ProcessEnv,
178+
accessToken: string,
179+
override?: { provider?: string; modelId: string },
180+
): ResolvedConfig | null {
181+
// Runtime override (set via /model) wins over env vars wins over the default.
182+
const explicitProvider = (override?.provider ?? env.CODEBASE_PROVIDER) as KnownProvider | undefined;
183+
const explicitModel = override?.modelId ?? env.CODEBASE_MODEL;
171184
const proxyBase = (env.CODEBASE_PROXY_BASE_URL ?? DEFAULT_PROXY_BASE).replace(/\/+$/, "");
172185

173-
// Default: "Codebase Auto" — synthesized openai-compat model.
174-
// Can't use pi-ai's registry here because "codebase" isn't a
175-
// KnownProvider; clone a known chat-completions model and override.
186+
// No provider + no model → "Codebase Auto" (the routed default).
187+
// No provider + an explicit model id → synthesize an openai-compat model
188+
// against that id. Pi-ai dispatches by `model.id` in the request body;
189+
// the proxy's model registry is the gatekeeper for what actually exists.
176190
if (!explicitProvider) {
177191
const template = getModel("groq", "llama-3.3-70b-versatile") as Model<string> | undefined;
178192
if (!template) return null;
193+
const isDefault = !explicitModel;
179194
const model: Model<string> = {
180195
...template,
181-
id: "MiniMax-M2.7",
182-
name: "Codebase Auto",
196+
id: explicitModel ?? "MiniMax-M2.7",
197+
name: isDefault ? "Codebase Auto" : (explicitModel ?? "Codebase Auto"),
183198
baseUrl: proxyBase,
184199
// Override provider so the status bar and /model don't lie about
185200
// where this is served from. pi-ai uses `provider` mainly for

src/agent/events.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,3 +504,25 @@ describe("reducer · post-abort event suppression", () => {
504504
expect(s.status).toBe("aborted");
505505
});
506506
});
507+
508+
describe("reducer · model-switched", () => {
509+
it("replaces the model, clears tools/streaming/error, sets status idle, KEEPS messages", () => {
510+
const prior: ChatState = {
511+
...freshState(),
512+
messages: [{ role: "user", content: "preserved", timestamp: 1 } as AgentMessage],
513+
tools: new Map([["t1", { id: "t1", name: "shell", args: {}, status: "running", startedAt: 1 }]]),
514+
streaming: { role: "assistant" } as AgentMessage,
515+
status: "streaming",
516+
error: "old error",
517+
};
518+
const newModel = { provider: "anthropic", id: "claude-sonnet-4-5", name: "Sonnet 4.5" };
519+
const s = dispatch(prior, { type: "model-switched", model: newModel });
520+
expect(s.model).toEqual(newModel);
521+
expect(s.tools.size).toBe(0);
522+
expect(s.streaming).toBeUndefined();
523+
expect(s.status).toBe("idle");
524+
expect(s.error).toBeUndefined();
525+
// Transcript continues across model swap.
526+
expect(s.messages).toHaveLength(1);
527+
});
528+
});

src/agent/events.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ export type Action =
88
| { type: "chat-reply"; text: string }
99
| { type: "abort" }
1010
| { type: "error"; message: string }
11-
| { type: "reset" };
11+
| { type: "reset" }
12+
/** Mid-session model swap. Keeps the transcript, refreshes model + clears agent-specific bits. */
13+
| { type: "model-switched"; model: ChatState["model"] };
1214

1315
export function initialState(model: ChatState["model"], messages: AgentMessage[] = []): ChatState {
1416
return {
@@ -63,6 +65,19 @@ export function reducer(state: ChatState, action: Action): ChatState {
6365
case "reset":
6466
return initialState(state.model);
6567

68+
case "model-switched":
69+
return {
70+
...state,
71+
model: action.model,
72+
// New agent instance can't honor old tool execution state.
73+
tools: new Map(),
74+
streaming: undefined,
75+
// Status flips to idle so the input row enables immediately
76+
// even if the old agent's tail events haven't fully drained.
77+
status: "idle",
78+
error: undefined,
79+
};
80+
6681
case "agent-event":
6782
// When the user has aborted a turn, ignore the abandoned turn's
6883
// tail events that would flip status back to thinking/idle (and

src/commands/builtins.ts

Lines changed: 121 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -132,12 +132,128 @@ function padNum(n: number, width: number): string {
132132
return n.toLocaleString().padStart(width, " ");
133133
}
134134

135+
/**
136+
* Friendly short-name → "<provider>:<modelId>" map. Lets a user type
137+
* `/model sonnet` instead of `/model anthropic:claude-sonnet-4-5`. The
138+
* actual provider availability still depends on the user's account /
139+
* BYOK keys; aliases just shorten the path for the common cases.
140+
*/
141+
const MODEL_ALIASES: Record<string, string> = {
142+
auto: "auto", // sentinel; resolved to null override in the handler
143+
sonnet: "anthropic:claude-sonnet-4-5",
144+
opus: "anthropic:claude-opus-4-1",
145+
haiku: "anthropic:claude-haiku-4-5",
146+
"gpt-5": "openai:gpt-5",
147+
"gpt-4": "openai:gpt-4o",
148+
"gpt-4o": "openai:gpt-4o",
149+
llama: "groq:llama-3.3-70b-versatile",
150+
"llama-3.3": "groq:llama-3.3-70b-versatile",
151+
};
152+
153+
/** Parse `<provider>:<modelId>` or `<modelId>` or an alias into a model spec. */
154+
function parseModelSpec(raw: string): { provider?: string; modelId: string } | null {
155+
const trimmed = raw.trim();
156+
if (!trimmed) return null;
157+
const lower = trimmed.toLowerCase();
158+
if (lower === "auto" || lower === "default" || lower === "reset") return null; // signal: reset
159+
const aliased = MODEL_ALIASES[lower];
160+
const target = aliased && aliased !== "auto" ? aliased : trimmed;
161+
const colonIdx = target.indexOf(":");
162+
if (colonIdx === -1) return { modelId: target };
163+
return { provider: target.slice(0, colonIdx).trim(), modelId: target.slice(colonIdx + 1).trim() };
164+
}
165+
135166
const modelCmd: Command = {
136167
name: "model",
137-
description: "Show the current model.",
138-
handler: (_args, ctx) => {
139-
const m = ctx.state.model;
140-
ctx.emit(`${m.provider}/${m.id} (${m.name})`);
168+
description: "Show or switch the active model. `/model <id>` or `/model <provider>:<id>` to switch.",
169+
handler: async (args, ctx) => {
170+
const arg = args.trim();
171+
if (!arg) {
172+
// No-args opens the inline picker. For BYOK / non-proxy sessions
173+
// there's no live model list to fetch, so print the static info
174+
// instead of an empty overlay.
175+
if (ctx.bundle.source !== "proxy") {
176+
const m = ctx.state.model;
177+
ctx.emit(`Current model: ${m.name} (${m.provider}/${m.id})`);
178+
ctx.emit("BYOK session — switch via CODEBASE_PROVIDER + CODEBASE_MODEL env vars at launch.");
179+
return { handled: true };
180+
}
181+
ctx.openModelPicker();
182+
return { handled: true };
183+
}
184+
if (arg === "--help" || arg === "-h") {
185+
ctx.emit("Usage:");
186+
ctx.emit(" /model show the active model");
187+
ctx.emit(" /model <id> switch (e.g. /model claude-sonnet-4-5)");
188+
ctx.emit(" /model <prov>:<id> switch with explicit provider (e.g. /model anthropic:claude-sonnet-4-5)");
189+
ctx.emit(" /model auto reset to the default (Codebase Auto for proxy users)");
190+
ctx.emit(" /model sonnet|opus|haiku|gpt-5|llama aliases for common picks");
191+
return { handled: true };
192+
}
193+
const spec = parseModelSpec(arg);
194+
await ctx.switchModel(spec);
195+
return { handled: true };
196+
},
197+
};
198+
199+
const modelsCmd: Command = {
200+
name: "models",
201+
aliases: ["lm"],
202+
description: "List models available to your account (fetched live from the proxy).",
203+
handler: async (_args, ctx) => {
204+
// BYOK users don't go through our proxy — there's no central
205+
// "available models" endpoint for arbitrary upstreams. Tell them
206+
// how to switch and bail.
207+
if (ctx.bundle.source !== "proxy") {
208+
ctx.emit(`Current: ${ctx.state.model.name} (${ctx.state.model.provider}/${ctx.state.model.id})`);
209+
ctx.emit("BYOK session — switch by re-launching with CODEBASE_PROVIDER + CODEBASE_MODEL env vars.");
210+
return { handled: true };
211+
}
212+
const baseUrl = (ctx.bundle.model.baseUrl ?? "").replace(/\/+$/, "");
213+
if (!baseUrl) {
214+
ctx.emit("(model has no baseUrl — can't query the proxy)");
215+
return { handled: true };
216+
}
217+
try {
218+
const apiKey = await ctx.bundle.agent.getApiKey?.(ctx.bundle.model.provider);
219+
if (!apiKey) {
220+
ctx.emit("(not signed in — run `codebase auth login`)");
221+
return { handled: true };
222+
}
223+
const res = await fetch(`${baseUrl}/models`, {
224+
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
225+
});
226+
if (!res.ok) {
227+
ctx.emit(`(failed to fetch models: ${res.status} ${res.statusText})`);
228+
return { handled: true };
229+
}
230+
const json = (await res.json()) as { models?: Array<{ id: string; name: string; provider: string }> };
231+
const models = json.models ?? [];
232+
if (models.length === 0) {
233+
ctx.emit("(no models returned)");
234+
return { handled: true };
235+
}
236+
const current = `${ctx.state.model.provider}/${ctx.state.model.id}`;
237+
ctx.emit("Available models (* = active):");
238+
// Group by provider so the list reads as a tree.
239+
const byProvider = new Map<string, Array<{ id: string; name: string }>>();
240+
for (const m of models) {
241+
const arr = byProvider.get(m.provider) ?? [];
242+
arr.push({ id: m.id, name: m.name });
243+
byProvider.set(m.provider, arr);
244+
}
245+
const providers = [...byProvider.keys()].sort();
246+
for (const p of providers) {
247+
ctx.emit(` ${p}:`);
248+
for (const m of byProvider.get(p) ?? []) {
249+
const marker = `${p}/${m.id}` === current ? "*" : " ";
250+
ctx.emit(` ${marker} ${m.id} ${m.name === m.id ? "" : ${m.name}`}`);
251+
}
252+
}
253+
ctx.emit("Switch: /model <id> · /model <provider>:<id>");
254+
} catch (err) {
255+
ctx.emit(`(error fetching models: ${err instanceof Error ? err.message : String(err)})`);
256+
}
141257
return { handled: true };
142258
},
143259
};
@@ -595,6 +711,7 @@ export const BUILTIN_COMMANDS: readonly Command[] = [
595711
session,
596712
cost,
597713
modelCmd,
714+
modelsCmd,
598715
whoami,
599716
copy,
600717
diff,

src/commands/registry.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ function fakeCtx(overrides: Partial<CommandContext> = {}): CommandContext {
1010
clearDisplay: vi.fn(),
1111
exit: vi.fn(),
1212
registry: new CommandRegistry(),
13+
switchModel: vi.fn(async () => {}),
14+
openModelPicker: vi.fn(),
1315
...overrides,
1416
};
1517
}

src/commands/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,18 @@ export interface CommandContext {
1717
* needing to import every command's metadata separately.
1818
*/
1919
registry: CommandRegistry;
20+
/**
21+
* Mid-session model swap. `spec === null` resets to the default model
22+
* (Codebase Auto for proxy users). Aborts the current turn if active,
23+
* rebuilds the agent with the new model, preserves the transcript.
24+
*/
25+
switchModel: (spec: { provider?: string; modelId: string } | null) => Promise<void>;
26+
/**
27+
* Open the inline interactive model picker. Triggered by `/model` with
28+
* no args. The picker fetches the available-models list, renders an
29+
* arrow-navigable overlay, and calls back into switchModel on Enter.
30+
*/
31+
openModelPicker: () => void;
2032
}
2133

2234
export interface CommandResult {

0 commit comments

Comments
 (0)