Skip to content

Commit 449008e

Browse files
committed
fix(config): apply modelOverride across every session type
isProcessEnv whitelisted only env/credentials, so resolveConfig({modelOverride}) — exactly how createAgent passes a /model switch — was misclassified as a raw env and the override silently dropped, reverting even proxy switches to the default. Add modelOverride to the option keys, and thread it through the BYOK, local openai-compat, and explicit paths (id-cloning ids pi-ai doesn't know) so a switch actually sticks regardless of how the session authenticates.
1 parent a87940d commit 449008e

2 files changed

Lines changed: 74 additions & 12 deletions

File tree

src/agent/config.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,34 @@ describe("resolveConfig", () => {
7171
expect(config.model.contextWindow).toBe(32768);
7272
});
7373

74+
it("honors a /model override on an openai-compat (local) session", () => {
75+
credentials.save({
76+
accessToken: "none",
77+
scopes: [],
78+
source: "byok",
79+
provider: "openai-compat",
80+
baseUrl: "http://localhost:1234/v1",
81+
model: "qwen2.5-coder-32b",
82+
});
83+
84+
const config = resolveConfig({ env: {}, credentials, modelOverride: { modelId: "deepseek-r1:14b" } });
85+
86+
expect(config.source).toBe("byok");
87+
expect(config.model.id).toBe("deepseek-r1:14b"); // swapped via /model
88+
expect(config.model.baseUrl).toBe("http://localhost:1234/v1"); // same local endpoint
89+
});
90+
91+
it("honors a /model override on a keyed BYOK session, id-cloning unknown ids", () => {
92+
credentials.save({ accessToken: "sk-ant-test", scopes: [], source: "byok", provider: "anthropic" });
93+
94+
const config = resolveConfig({ env: {}, credentials, modelOverride: { modelId: "claude-future-9000" } });
95+
96+
expect(config.source).toBe("byok");
97+
expect(config.model.provider).toBe("anthropic");
98+
expect(config.model.id).toBe("claude-future-9000"); // not in pi-ai's registry, id-cloned
99+
expect(config.apiKey).toBe("sk-ant-test");
100+
});
101+
74102
it("byok credentials win over env-var auto-detect", () => {
75103
credentials.save({
76104
accessToken: "sk-or-byok",

src/agent/config.ts

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -82,16 +82,17 @@ export function resolveConfig(envOrOpts: NodeJS.ProcessEnv | ResolveConfigOption
8282
if (creds.source === "byok" && creds.provider === "openai-compat" && creds.baseUrl) {
8383
// Custom Chat Completions endpoint saved by the wizard (Ollama,
8484
// LM Studio, vLLM, …). Same synthesis as the OPENAI_BASE_URL env
85-
// path, but persisted so it survives restarts.
85+
// path, but persisted so it survives restarts. A /model override
86+
// swaps the id against the same local endpoint + key.
8687
const compat = buildOpenAiCompatConfig({
8788
baseUrl: creds.baseUrl,
88-
modelId: creds.model ?? "default",
89+
modelId: override?.modelId ?? creds.model ?? "default",
8990
apiKey: creds.accessToken,
9091
contextWindow: creds.contextWindow,
9192
});
9293
if (compat) return { ...compat, source: "byok" };
9394
} else if (creds.source === "byok" && creds.provider) {
94-
const byok = buildByokConfig(creds.provider as KnownProvider, creds.accessToken);
95+
const byok = buildByokConfig(creds.provider as KnownProvider, creds.accessToken, override);
9596
if (byok) return byok;
9697
} else if (useProxy) {
9798
const proxied = buildProxiedConfig(env, creds.accessToken, override);
@@ -109,7 +110,7 @@ export function resolveConfig(envOrOpts: NodeJS.ProcessEnv | ResolveConfigOption
109110
if (env.OPENAI_BASE_URL && env.OPENAI_API_KEY && env.OPENAI_MODEL) {
110111
const compat = buildOpenAiCompatConfig({
111112
baseUrl: env.OPENAI_BASE_URL,
112-
modelId: env.OPENAI_MODEL,
113+
modelId: override?.modelId ?? env.OPENAI_MODEL,
113114
apiKey: env.OPENAI_API_KEY,
114115
});
115116
if (compat) return compat;
@@ -119,7 +120,16 @@ export function resolveConfig(envOrOpts: NodeJS.ProcessEnv | ResolveConfigOption
119120
const explicitModel = env.CODEBASE_MODEL;
120121

121122
if (explicitProvider && explicitModel) {
122-
const model = getModel(explicitProvider, explicitModel as never);
123+
// A /model override swaps the id at runtime; the launch env value is
124+
// the default. An id pi-ai doesn't know is id-cloned from the launch
125+
// model so any model the provider lists is switchable (only the
126+
// initial launch id, with no override, must be registry-known).
127+
const wantId = override?.modelId ?? explicitModel;
128+
let model = getModel(explicitProvider, wantId as never) as Model<string> | undefined;
129+
if (!model && override?.modelId) {
130+
const base = getModel(explicitProvider, explicitModel as never) as Model<string> | undefined;
131+
if (base) model = { ...base, id: wantId, name: wantId };
132+
}
123133
if (!model) {
124134
throw new ConfigError(
125135
`CODEBASE_PROVIDER=${explicitProvider} CODEBASE_MODEL=${explicitModel} not in pi-ai's model registry. ` +
@@ -312,20 +322,44 @@ function buildOpenAiCompatConfig(opts: {
312322

313323
/**
314324
* BYOK mode: caller has saved a provider's own API key. Use the
315-
* provider's normal baseUrl from pi-ai's registry — no proxy.
325+
* provider's normal baseUrl from pi-ai's registry — no proxy. A /model
326+
* override swaps the id within the same provider; an id pi-ai doesn't
327+
* know natively is id-cloned from the provider's default so every model
328+
* the provider's /models endpoint lists is switchable.
316329
*/
317-
function buildByokConfig(provider: KnownProvider, apiKey: string): ResolvedConfig | null {
318-
const modelId = DEFAULT_MODELS[provider];
319-
if (!modelId) return null;
320-
const model = getModel(provider, modelId as never) as Model<string> | undefined;
330+
function buildByokConfig(
331+
provider: KnownProvider,
332+
apiKey: string,
333+
override?: { provider?: string; modelId: string },
334+
): ResolvedConfig | null {
335+
const defaultId = DEFAULT_MODELS[provider];
336+
const wantId = override?.modelId ?? defaultId;
337+
if (!wantId) return null;
338+
let model = getModel(provider, wantId as never) as Model<string> | undefined;
339+
if (!model && defaultId) {
340+
const template = getModel(provider, defaultId as never) as Model<string> | undefined;
341+
if (template) {
342+
model = {
343+
...template,
344+
id: wantId,
345+
name: wantId,
346+
contextWindow: guessContextWindow(wantId, template.contextWindow),
347+
};
348+
}
349+
}
321350
if (!model) return null;
322351
return { model, apiKey, source: "byok" };
323352
}
324353

354+
const OPTION_KEYS = new Set(["env", "credentials", "modelOverride"]);
355+
325356
function isProcessEnv(value: NodeJS.ProcessEnv | ResolveConfigOptions): value is NodeJS.ProcessEnv {
326357
if (!value) return true;
327-
// ResolveConfigOptions has at most env/credentials properties; ProcessEnv has many.
358+
// ResolveConfigOptions only ever holds env/credentials/modelOverride; a
359+
// real ProcessEnv has many other keys. (Missing modelOverride here was a
360+
// real bug: resolveConfig({modelOverride}) was treated as an env, so the
361+
// /model override was silently dropped and the model reverted to default.)
328362
const keys = Object.keys(value);
329363
if (keys.length === 0) return true;
330-
return !keys.every((k) => k === "env" || k === "credentials");
364+
return !keys.every((k) => OPTION_KEYS.has(k));
331365
}

0 commit comments

Comments
 (0)