diff --git a/src/config.test.ts b/src/config.test.ts index d74080c81..05c6fd0ba 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -1344,6 +1344,51 @@ describe("buildOpenAISource", () => { }); }); + test("overrides maxTokens and adds temperature to providerOptions", () => { + const source = buildOpenAISource({ + id: "fp", + baseURL: "https://fp/v1", + apiKey: "k", + model: "m", + maxTokens: 8192, + temperature: 0.7, + }); + expect(source.defaults).toEqual({ + maxTokens: 8192, + providerOptions: { temperature: 0.7 }, + }); + }); + + test("adds top_p to providerOptions when topP is present", () => { + const source = buildOpenAISource({ + id: "fp", + baseURL: "https://fp/v1", + apiKey: "k", + model: "m", + topP: 0.9, + }); + expect(source.defaults).toEqual({ + maxTokens: SOURCE_MAX_TOKENS, + providerOptions: { top_p: 0.9 }, + }); + }); + + test("coexists temperature with reasoning_effort in one providerOptions", () => { + const source = buildOpenAISource({ + id: "fp", + baseURL: "https://fp/v1", + apiKey: "k", + model: "gpt-5.1", + reasoningEffort: "medium", + temperature: 1.0, + maxTokens: 4096, + }); + expect(source.defaults).toEqual({ + maxTokens: 4096, + providerOptions: { reasoning_effort: "medium", temperature: 1.0 }, + }); + }); + test("projects an Ollama root URL to the OpenAI-compatible /v1 endpoint", () => { const source = buildOpenAISource({ id: "ollama/default", diff --git a/src/config/index.ts b/src/config/index.ts index 738d11ccc..6eb1ade76 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -272,12 +272,17 @@ export function buildOpenAISource(fields: { apiKey?: string; model: string; reasoningEffort?: ReasoningEffort; + maxTokens?: number; + temperature?: number; + topP?: number; quirks?: Record; }): InferenceSource { - const overrides = - fields.reasoningEffort !== undefined - ? { providerOptions: { reasoning_effort: fields.reasoningEffort } } - : {}; + const providerOptions: Record = {}; + if (fields.reasoningEffort !== undefined) + providerOptions["reasoning_effort"] = fields.reasoningEffort; + if (fields.temperature !== undefined) + providerOptions["temperature"] = fields.temperature; + if (fields.topP !== undefined) providerOptions["top_p"] = fields.topP; registerSourceSecret(fields.id, fields.apiKey); return { id: fields.id, @@ -287,7 +292,10 @@ export function buildOpenAISource(fields: { : normalizeOpenAICompatibleBaseURL(fields.baseURL), credentialId: fields.id, model: fields.model, - defaults: { maxTokens: SOURCE_MAX_TOKENS, ...overrides }, + defaults: { + maxTokens: fields.maxTokens ?? SOURCE_MAX_TOKENS, + ...(Object.keys(providerOptions).length > 0 ? { providerOptions } : {}), + }, ...(fields.quirks !== undefined ? { quirks: fields.quirks } : {}), }; } @@ -1374,12 +1382,55 @@ export function mergeOAuthCatalog( export async function refreshLiveProviderCatalog( settings: Settings | null, resolved: ResolvedProvider, + liveSelection?: () => Pick, ): Promise { const [codexProfiles, xaiProfiles] = await Promise.all([ listCodexProfiles(), listXaiProfiles(), ]); - return mergeOAuthCatalog(settings, resolved, codexProfiles, xaiProfiles); + const catalog = mergeOAuthCatalog( + settings, + resolved, + codexProfiles, + xaiProfiles, + ); + // Discovery can finish after a model switch; never restore its old bare-model slot. + const active = liveSelection?.() ?? resolved; + refreshProviderContextWindows( + settings ?? undefined, + catalog, + active.providerName, + active.model, + ); + return catalog; +} + +export function refreshProviderContextWindows( + settings: Settings | undefined, + catalog: readonly ProviderCatalogEntry[], + activeProvider: string, + activeModel: string, +): void { + // Catalog models can expand during discovery. OAuth projections deliberately + // omit settings-only window overrides, just as startup resolution does. + const providers = Object.fromEntries( + catalog.map((entry) => { + const window = + entry.codexProfile === undefined && entry.xaiProfile === undefined + ? settings?.providers[entry.name]?.contextWindow + : undefined; + return [ + entry.name, + { + models: entry.models, + ...(window !== undefined ? { contextWindow: window } : {}), + }, + ]; + }), + ); + setProviderContextWindowOverrides( + buildProviderContextWindowOverrides(providers, activeProvider, activeModel), + ); } export function catalogEntryAsProviderSettings( @@ -1405,6 +1456,11 @@ export function catalogEntryAsProviderSettings( ? { defaultModel: entry.defaultModel } : {}), ...(entry.free !== undefined ? { free: entry.free } : {}), + ...(entry.maxTokens !== undefined ? { maxTokens: entry.maxTokens } : {}), + ...(entry.temperature !== undefined + ? { temperature: entry.temperature } + : {}), + ...(entry.topP !== undefined ? { topP: entry.topP } : {}), ...(entry.bifrostVirtualKey === true ? { bifrostVirtualKey: true } : {}), ...(entry.anthropic === true ? { anthropic: true } : {}), ...(go ? { opencodeGo: true } : {}), @@ -1482,6 +1538,11 @@ export function buildProviderCatalog( ? { defaultModel: p.defaultModel } : {}), ...(p.free !== undefined ? { free: p.free } : {}), + ...(p.maxTokens !== undefined ? { maxTokens: p.maxTokens } : {}), + ...(p.temperature !== undefined + ? { temperature: p.temperature } + : {}), + ...(p.topP !== undefined ? { topP: p.topP } : {}), ...(p.bifrostVirtualKey === true ? { bifrostVirtualKey: true } : {}), ...(p.anthropic === true ? { anthropic: true } : {}), ...(go ? { opencodeGo: true } : {}), diff --git a/src/config/inference-sources.test.ts b/src/config/inference-sources.test.ts index 56c221001..2e6144688 100644 --- a/src/config/inference-sources.test.ts +++ b/src/config/inference-sources.test.ts @@ -205,6 +205,67 @@ describe("contextWindow / maxTokens split (CL-7784)", () => { }); }); +describe("custom provider token/sampling knobs flow into the source", () => { + test("maxTokens/temperature/topP from provider settings reach defaults", () => { + const entryCatalog: ProviderCatalogEntry[] = [ + { + name: "custom", + baseURL: "https://api.example.com/v1", + apiKey: "sk-key", + models: ["m"], + }, + ]; + const settings: Settings = { + providers: { + custom: { + baseURL: "https://api.example.com/v1", + apiKey: "sk-key", + models: ["m"], + maxTokens: 4096, + temperature: 0.7, + }, + }, + }; + const source = buildInferenceSourceForRef( + { provider: "custom", model: "m" }, + { sessionId: "sess-1", catalog: entryCatalog }, + settings, + ); + expect(source).not.toBeNull(); + expect(source?.defaults?.maxTokens).toBe(4096); + expect(source?.defaults?.providerOptions).toEqual({ temperature: 0.7 }); + }); + + test("topP flows as top_p and overrides nothing else", () => { + const entryCatalog: ProviderCatalogEntry[] = [ + { + name: "custom", + baseURL: "https://api.example.com/v1", + apiKey: "sk-key", + models: ["m"], + topP: 0.9, + }, + ]; + const source = buildInferenceSourceForRef( + { provider: "custom", model: "m" }, + { sessionId: "sess-1", catalog: entryCatalog }, + undefined, + ); + expect(source?.defaults?.maxTokens).toBe(SOURCE_MAX_TOKENS); + expect(source?.defaults?.providerOptions).toEqual({ top_p: 0.9 }); + }); + + test("unset knobs leave the default maxTokens and no providerOptions", () => { + const source = buildInferenceSourceForRef( + { provider: "fp", model: "fp-large" }, + ctx(), + undefined, + ); + expect(source?.defaults?.maxTokens).toBe(SOURCE_MAX_TOKENS); + expect(source?.defaults?.providerOptions).toBeUndefined(); + }); +}); + describe("OpenAI reasoning max_completion_tokens quirk (CL-7785)", () => { const openaiApi = firstClassProviderById("openai")?.paths?.find( (p) => p.id === "api", diff --git a/src/config/inference-sources.ts b/src/config/inference-sources.ts index e6a5fb476..ce1da6bb6 100644 --- a/src/config/inference-sources.ts +++ b/src/config/inference-sources.ts @@ -179,6 +179,9 @@ export function buildInferenceSourceForRef( } const quirks = openAISourceQuirks(baseURL, ref.model); + const maxTokens = entry?.maxTokens ?? providerSettings?.maxTokens; + const temperature = entry?.temperature ?? providerSettings?.temperature; + const topP = entry?.topP ?? providerSettings?.topP; return buildOpenAISource({ id: ref.provider, baseURL, @@ -189,6 +192,9 @@ export function buildInferenceSourceForRef( : {}), model: ref.model, ...(effort !== undefined ? { reasoningEffort: effort } : {}), + ...(maxTokens !== undefined ? { maxTokens } : {}), + ...(temperature !== undefined ? { temperature } : {}), + ...(topP !== undefined ? { topP } : {}), ...(quirks !== undefined ? { quirks } : {}), }); } diff --git a/src/config/live-provider-context-window.test.ts b/src/config/live-provider-context-window.test.ts new file mode 100644 index 000000000..ce764ef2c --- /dev/null +++ b/src/config/live-provider-context-window.test.ts @@ -0,0 +1,264 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { withMockedModuleDuring } from "../../testkit/mock-module.js"; +import { + buildProviderCatalog, + refreshLiveProviderCatalog, + refreshProviderContextWindows, + type ProviderCatalogEntry, +} from "./index.js"; +import { + buildMainSessionSources, + buildSubagentSources, +} from "./inference-sources.js"; +import type { ResolvedProvider, Settings } from "./settings.js"; +import { clearSourceCredentials } from "./source-credentials.js"; +import { + compactionThresholdFor, + contextWindowFor, + hasContextWindowFor, + setModelContextWindows, + setProviderContextWindowOverrides, +} from "../provider/context-window.js"; +import { resolveLiveSessionSources } from "../session/assemble-runtime.js"; +import { applyLiveModelSwitch } from "../session/live-model-switch.js"; +import type { MainSessionSourceConfig } from "../session/runtime-assembly.js"; +import { buildProviderSubmitHandler } from "../tui/provider/submit.js"; + +const active: ResolvedProvider = { + providerName: "first", + baseURL: "https://first.example/v1", + apiKey: "", + keyless: true, + model: "shared-model", +}; + +function settings(): Settings { + return { + providers: { + first: { + baseURL: active.baseURL, + keyless: true, + models: ["shared-model"], + contextWindow: 32_000, + }, + second: { + baseURL: "https://second.example/v1", + keyless: true, + models: ["shared-model"], + contextWindow: 64_000, + }, + }, + }; +} + +function refreshCatalog(current: Settings): Promise { + return withMockedModuleDuring( + import.meta.resolve("./oauth-stores.js"), + (real: typeof import("./oauth-stores.js")) => ({ + ...real, + listCodexProfiles: async () => [], + listXaiProfiles: async () => [], + }), + () => refreshLiveProviderCatalog(current, active), + ); +} + +afterEach(() => { + setModelContextWindows(undefined); + setProviderContextWindowOverrides(undefined); + clearSourceCredentials(); +}); + +describe("live provider context windows", () => { + test("an asynchronous catalog refresh respects a model switch while discovery is pending", async () => { + const current = settings(); + const catalog = buildProviderCatalog(current, active); + let selection = { + providerName: active.providerName, + model: active.model, + }; + const discovery = Promise.withResolvers<[]>(); + await withMockedModuleDuring( + import.meta.resolve("./oauth-stores.js"), + (real: typeof import("./oauth-stores.js")) => ({ + ...real, + listCodexProfiles: () => discovery.promise, + listXaiProfiles: async () => [], + }), + async () => { + const refresh = refreshLiveProviderCatalog( + current, + active, + () => selection, + ); + selection = { providerName: "second", model: "shared-model" }; + refreshProviderContextWindows( + current, + catalog, + selection.providerName, + selection.model, + ); + discovery.resolve([]); + await refresh; + expect(contextWindowFor("shared-model")).toBe(64_000); + expect(compactionThresholdFor("shared-model")).toBe(38_400); + }, + ); + }); + + test("a connected custom provider updates occupancy and compaction before restart", async () => { + let current = settings(); + const submit = buildProviderSubmitHandler( + "/tmp/unused-provider-context-settings.json", + null, + null, + async (apply) => { + current = apply(current); + return current; + }, + ); + await submit( + { + name: "connected", + baseURL: "https://connected.example/v1", + apiKey: "", + model: "connected-model", + oauthProfile: "", + contextWindow: "16000", + maxTokens: "", + temperature: "", + topP: "", + }, + () => undefined, + { skipValidation: true }, + ); + + await refreshCatalog(current); + expect(contextWindowFor("connected:connected-model")).toBe(16_000); + expect(compactionThresholdFor("connected:connected-model")).toBe(9_600); + }); + + test("catalog refresh replaces edited overrides and removes deleted providers", async () => { + setModelContextWindows({ "shared-model": 256_000 }); + const current = settings(); + await refreshCatalog(current); + expect(contextWindowFor("shared-model")).toBe(32_000); + expect(contextWindowFor("second:shared-model")).toBe(64_000); + + const first = current.providers["first"]; + if (first === undefined) throw new Error("missing first provider"); + await refreshCatalog({ + providers: { first: { ...first, contextWindow: 48_000 } }, + }); + expect(contextWindowFor("shared-model")).toBe(48_000); + expect(contextWindowFor("first:shared-model")).toBe(48_000); + expect(contextWindowFor("second:shared-model")).toBe(48_000); + await refreshCatalog({ + providers: { + first: { + baseURL: first.baseURL, + keyless: true, + models: first.models, + }, + }, + }); + expect(contextWindowFor("shared-model")).toBe(256_000); + expect(hasContextWindowFor("first:shared-model")).toBe(true); + expect(contextWindowFor("second:shared-model")).toBe(256_000); + }); + + test("live model switching moves bare model precedence without worker interference", () => { + const current = settings(); + let config: MainSessionSourceConfig = { + settings: current, + providers: buildProviderCatalog(current, active), + providerName: active.providerName, + model: active.model, + }; + resolveLiveSessionSources(config, "primary-session"); + expect(contextWindowFor("shared-model")).toBe(32_000); + + applyLiveModelSwitch( + { providerName: "second", model: "shared-model" }, + { + applyIdentity: (next) => { + config = { ...config, ...next }; + }, + setPermissionIdentity: () => undefined, + rebuildInference: () => { + resolveLiveSessionSources(config, "primary-session"); + }, + refreshAdvertisedSchemas: () => undefined, + }, + ); + expect(contextWindowFor("shared-model")).toBe(64_000); + expect(contextWindowFor("first:shared-model")).toBe(32_000); + expect(compactionThresholdFor("shared-model")).toBe(38_400); + buildSubagentSources({ + settings: current, + catalog: config.providers, + head: { provider: "first", model: "shared-model" }, + sessionId: "worker-session", + }); + expect(contextWindowFor("shared-model")).toBe(64_000); + buildMainSessionSources({ + settings: current, + catalog: config.providers, + activeProvider: "first", + activeModel: "shared-model", + sessionId: "recovery-candidate", + }); + expect(contextWindowFor("shared-model")).toBe(64_000); + + resolveLiveSessionSources( + { ...config, model: "typed-model" }, + "primary-session", + ); + expect(contextWindowFor("second:typed-model")).toBe(64_000); + expect(contextWindowFor("typed-model")).toBe(64_000); + }); + + test("catalog model discovery updates qualified overrides and excludes OAuth projections", () => { + const current = settings(); + current.providers["codex/work"] = { + baseURL: "https://chatgpt.com/backend-api/codex", + models: ["oauth-model"], + contextWindow: 16_000, + }; + const catalog = buildProviderCatalog(current, active).map((entry) => + entry.name === "first" + ? { ...entry, models: [...entry.models, "discovered-model"] } + : entry.name === "codex/work" + ? { ...entry, codexProfile: "work" } + : entry, + ); + refreshProviderContextWindows(current, catalog, "first", "shared-model"); + expect(contextWindowFor("first:discovered-model")).toBe(32_000); + expect(hasContextWindowFor("codex/work:oauth-model")).toBe(false); + }); + + test("switching to a provider without an override clears the prior bare model slot", () => { + setModelContextWindows({ "shared-model": 256_000 }); + const current = settings(); + current.providers["unconfigured"] = { + baseURL: "https://unconfigured.example/v1", + models: ["shared-model"], + keyless: true, + }; + const config = { + settings: current, + providers: buildProviderCatalog(current, active), + providerName: "first", + model: "shared-model", + }; + resolveLiveSessionSources(config, "primary-session"); + expect(contextWindowFor("shared-model")).toBe(32_000); + resolveLiveSessionSources( + { ...config, providerName: "unconfigured" }, + "primary-session", + ); + expect(contextWindowFor("shared-model")).toBe(256_000); + expect(contextWindowFor("first:shared-model")).toBe(32_000); + }); +}); diff --git a/src/config/provider-inference-options.ts b/src/config/provider-inference-options.ts new file mode 100644 index 000000000..fa14a256e --- /dev/null +++ b/src/config/provider-inference-options.ts @@ -0,0 +1,20 @@ +import { type } from "arktype"; + +export const ProviderInferenceOptionSchemas = { + contextWindow: type("number.integer > 0"), + maxTokens: type("number.integer > 0"), + temperature: type("0 <= number <= 2"), + topP: type("0 <= number <= 1"), +}; + +export const ProviderInferenceOptionsSchema = type({ + "contextWindow?": ProviderInferenceOptionSchemas.contextWindow, + "maxTokens?": ProviderInferenceOptionSchemas.maxTokens, + "temperature?": ProviderInferenceOptionSchemas.temperature, + "topP?": ProviderInferenceOptionSchemas.topP, +}).narrow( + (options, ctx) => + options.temperature === undefined || + options.topP === undefined || + ctx.mustBe("sampling options with only one of temperature and top p"), +); diff --git a/src/config/providers.test.ts b/src/config/providers.test.ts index d9ffab79f..37609d954 100644 --- a/src/config/providers.test.ts +++ b/src/config/providers.test.ts @@ -298,3 +298,31 @@ describe("buildProviderEntry protocol flag preservation", () => { expect(result.entry.opencodeGo).toBe(true); }); }); + +describe("buildProviderEntry token/sampling knobs", () => { + test("forwards maxTokens, temperature, and topP when present", () => { + const entry = expectBuiltEntry({ + name: "custom", + baseURL: "https://api.example.com/v1", + apiKey: "sk-key", + models: ["m"], + maxTokens: 4096, + temperature: 0.7, + }); + expect(entry.maxTokens).toBe(4096); + expect(entry.temperature).toBe(0.7); + expect(entry.topP).toBeUndefined(); + }); + + test("omits all three when submission leaves them unset", () => { + const entry = expectBuiltEntry({ + name: "custom", + baseURL: "https://api.example.com/v1", + apiKey: "sk-key", + models: ["m"], + }); + expect(entry.maxTokens).toBeUndefined(); + expect(entry.temperature).toBeUndefined(); + expect(entry.topP).toBeUndefined(); + }); +}); diff --git a/src/config/providers.ts b/src/config/providers.ts index d805ae2fb..a072bed00 100644 --- a/src/config/providers.ts +++ b/src/config/providers.ts @@ -17,6 +17,9 @@ export interface ProviderSubmission { bifrostVirtualKey?: boolean; anthropic?: boolean; opencodeGo?: boolean; + maxTokens?: number; + temperature?: number; + topP?: number; } // Precedence for the active/backup model of a provider: defaultModel wins @@ -100,6 +103,13 @@ export function buildProviderEntry( : {}), ...(anthropic ? { anthropic: true } : {}), ...(opencodeGo ? { opencodeGo: true } : {}), + ...(submission.maxTokens !== undefined + ? { maxTokens: submission.maxTokens } + : {}), + ...(submission.temperature !== undefined + ? { temperature: submission.temperature } + : {}), + ...(submission.topP !== undefined ? { topP: submission.topP } : {}), }; const catalog = currentCatalog .filter( diff --git a/src/config/settings.test.ts b/src/config/settings.test.ts index f5157617e..5a23cf5ab 100644 --- a/src/config/settings.test.ts +++ b/src/config/settings.test.ts @@ -13,6 +13,78 @@ import { loadConfig } from "./index.js"; import { resetPricingMetadataRefreshForTests } from "../cost/pricing-metadata.js"; import { setProviderContextWindowOverrides } from "../provider/context-window.js"; import { withMockedHomedir } from "../../testkit/mock-module.js"; +import { isSettings, loadSettings, saveGlobalSettings } from "./settings.js"; + +test.each([ + ["contextWindow", 0], + ["contextWindow", -1], + ["contextWindow", 12.5], + ["maxTokens", 0], + ["maxTokens", -1], + ["maxTokens", 12.5], + ["maxTokens", Infinity], + ["temperature", -0.1], + ["temperature", 2.1], + ["topP", -0.1], + ["topP", 1.1], +] as const)("provider settings reject invalid %s=%p", (field, value) => { + expect( + isSettings({ + providers: { + custom: { + baseURL: "https://custom.example/v1", + keyless: true, + models: ["test-model"], + [field]: value, + }, + }, + }), + ).toBe(false); +}); + +test("invalid token settings cannot be loaded or overwrite a saved provider", async () => { + const cwd = await mkdtemp(join(tmpdir(), "invalid-provider-tokens-")); + const path = join(cwd, "settings.json"); + const valid = { + providers: { + custom: { + baseURL: "https://custom.example/v1", + keyless: true, + models: ["test-model"], + maxTokens: 8192, + }, + }, + }; + const invalid = { + providers: { + custom: { ...valid.providers.custom, maxTokens: 12.5 }, + }, + }; + try { + await saveGlobalSettings(path, valid); + await expect(saveGlobalSettings(path, invalid)).rejects.toThrow(); + expect(await loadSettings(path)).toEqual(valid); + await writeFile(path, JSON.stringify(invalid)); + await expect(loadSettings(path)).rejects.toThrow(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("provider settings reject simultaneous sampling fields but preserve zero", () => { + const provider = { + baseURL: "https://custom.example/v1", + keyless: true, + models: ["test-model"], + contextWindow: 1, + maxTokens: 8192, + temperature: 0, + }; + expect(isSettings({ providers: { custom: provider } })).toBe(true); + expect(isSettings({ providers: { custom: { ...provider, topP: 0 } } })).toBe( + false, + ); +}); afterEach(() => { setProviderContextWindowOverrides(undefined); diff --git a/src/config/settings.ts b/src/config/settings.ts index 9fcea09cf..a81f2bc55 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -16,6 +16,7 @@ import { import type { OtelSettings } from "../perf/otel-config.js"; import type { SessionMode } from "./session-mode.js"; import { resolveDefaultModel } from "./providers.js"; +import { ProviderInferenceOptionsSchema } from "./provider-inference-options.js"; import { OPENCODE_GO_BASE_URL, isOpenCodeGoProvider, @@ -44,6 +45,15 @@ export interface ProviderSettings { // config load into contextWindowFor. OAuth-projected Codex/xAI entries drop // this field, so a hand-edited value on those providers is ignored. contextWindow?: number; + // Per-call output cap for this provider, overriding the default + // SOURCE_MAX_TOKENS when set. + maxTokens?: number; + // Sampling temperature (0..2, OpenAI-compatible). Mutually exclusive with + // topP — never send both on the wire. + temperature?: number; + // Sampling top-p (0..1, OpenAI-compatible). Mutually exclusive with + // temperature — never send both on the wire. + topP?: number; // When true, this provider uses a Bifrost virtual key (sk-bf-...). // The marker causes the inference source to route through the Bifrost // adapter (which injects the x-bf-vk header) and enables model @@ -242,6 +252,13 @@ function providerSelectionMetadata( ...(provider.contextWindow !== undefined ? { contextWindow: provider.contextWindow } : {}), + ...(provider.maxTokens !== undefined + ? { maxTokens: provider.maxTokens } + : {}), + ...(provider.temperature !== undefined + ? { temperature: provider.temperature } + : {}), + ...(provider.topP !== undefined ? { topP: provider.topP } : {}), ...(provider.bifrostVirtualKey === true ? { bifrostVirtualKey: true } : {}), ...(provider.anthropic === true ? { anthropic: true } : {}), ...(provider.opencodeGo === true ? { opencodeGo: true } : {}), @@ -615,12 +632,11 @@ const ProviderSettingsSchema = type({ "defaultModel?": "string", "keyless?": "boolean", "free?": "boolean", - "contextWindow?": "number", "bifrostVirtualKey?": "boolean", "anthropic?": "boolean", "opencodeGo?": "boolean", "verified?": "boolean", -}); +}).and(ProviderInferenceOptionsSchema); const ModelRefSchema = type({ provider: "string", diff --git a/src/provider/openai-compatible-adapter.test.ts b/src/provider/openai-compatible-adapter.test.ts index f07e222cd..518ff29b5 100644 --- a/src/provider/openai-compatible-adapter.test.ts +++ b/src/provider/openai-compatible-adapter.test.ts @@ -66,6 +66,14 @@ describe("openai-compatible adapter providerOptions passthrough", () => { expect(body["reasoning_effort"]).toBe("high"); }); + test("merges temperature and top_p together from providerOptions", () => { + const body = bodyFor({ + providerOptions: { temperature: 0.7, top_p: 0.9 }, + } as InferenceOptions); + expect(body["temperature"]).toBe(0.7); + expect(body["top_p"]).toBe(0.9); + }); + test("leaves the body untouched when no providerOptions are present", () => { const body = bodyFor({} as InferenceOptions); expect("reasoning_effort" in body).toBe(false); diff --git a/src/session/assemble-runtime.ts b/src/session/assemble-runtime.ts index 135a4babd..2a4be3a1d 100644 --- a/src/session/assemble-runtime.ts +++ b/src/session/assemble-runtime.ts @@ -40,6 +40,7 @@ import { resolveLocalSettingsPath, type LocalSettings, } from "../config/settings.js"; +import { refreshProviderContextWindows } from "../config/index.js"; import type { SessionMode } from "../config/session-mode.js"; import { readSourceCredentialMaterial } from "../config/source-credentials.js"; import { @@ -358,6 +359,14 @@ export function resolveLiveSessionSources( if (selected === undefined) { throw new Error("Selected inference source was not assembled"); } + // Candidate sources and workers must not steal the primary's bare-model + // overrides; only the actual live-session rebuild updates that precedence. + refreshProviderContextWindows( + config.settings, + config.providers, + config.providerName, + config.model, + ); return { sources, defaultSource, selected }; } diff --git a/src/tui/onboarding.test.ts b/src/tui/onboarding.test.ts index 39da663f7..d8a1d5cbe 100644 --- a/src/tui/onboarding.test.ts +++ b/src/tui/onboarding.test.ts @@ -85,6 +85,10 @@ const CUSTOM_PROVIDER: ProviderFormValues = { apiKey: "test-key", model: "test-model", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }; const ISOLATED_PROVIDER: ProviderFormValues = { @@ -93,6 +97,10 @@ const ISOLATED_PROVIDER: ProviderFormValues = { apiKey: "isolated-key", model: "isolated-model", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }; /** Stub the setup flow to submit one provider form, validation skipped. */ @@ -237,6 +245,10 @@ describe("runOnboarding settings source", () => { apiKey: "", model: "grok-4", oauthProfile: "work", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, { skipValidation: true, diff --git a/src/tui/provider-setup-submit.test.ts b/src/tui/provider-setup-submit.test.ts index 37c53ee20..1c436d614 100644 --- a/src/tui/provider-setup-submit.test.ts +++ b/src/tui/provider-setup-submit.test.ts @@ -79,6 +79,182 @@ async function withTempDir(run: (dir: string) => Promise): Promise { } describe("buildProviderSubmitHandler", () => { + test.each(["preset", "oauth"] as const)( + "%s ignores stale hidden custom numeric fields", + async (pathKind) => { + await withTempDir(async (dir) => { + const path = join(dir, "settings.json"); + let committed = false; + const submit = buildProviderSubmitHandler(path, null, null); + await submit( + { + name: "openai/work", + baseURL: "https://api.example/v1", + apiKey: "test-key", + model: "test-model", + oauthProfile: "work", + contextWindow: "not-a-number", + maxTokens: "-1", + temperature: "0.7", + topP: "0.9", + }, + noopSetPhase, + pathKind === "preset" + ? { + skipValidation: true, + preset: { + id: "openai", + models: ["test-model"], + anthropic: false, + opencodeGo: false, + }, + } + : { + skipValidation: true, + oauth: stagedCodexOAuth(async () => { + committed = true; + }), + }, + ); + const provider = (await loadSettings(path))?.providers[ + pathKind === "preset" ? "openai/work" : "codex/work" + ]; + expect(provider).toBeDefined(); + for (const field of [ + "contextWindow", + "maxTokens", + "temperature", + "topP", + ] as const) { + expect(provider?.[field]).toBeUndefined(); + } + expect(committed).toBe(pathKind === "oauth"); + }); + }, + ); + + test.each([ + ["contextWindow", "0"], + ["contextWindow", "-1"], + ["contextWindow", "12.5"], + ["maxTokens", "0"], + ["maxTokens", "-1"], + ["maxTokens", "12.5"], + ["maxTokens", "Infinity"], + ["maxTokens", "not-a-number"], + ["temperature", "-0.1"], + ["temperature", "2.1"], + ["topP", "-0.1"], + ["topP", "1.1"], + ] as const)( + "custom submit rejects invalid %s=%s before persistence", + async (field, value) => { + let writes = 0; + const probes = connectionChecks.length; + const submit = buildProviderSubmitHandler( + "/tmp/unused-custom-provider-settings.json", + null, + null, + async (apply) => { + writes++; + return apply({ providers: {} }); + }, + ); + await expect( + submit( + { + name: "custom", + baseURL: "https://custom.example/v1", + apiKey: "", + model: "test-model", + oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", + [field]: value, + }, + noopSetPhase, + { skipValidation: false }, + ), + ).rejects.toThrow(); + expect(writes).toBe(0); + expect(connectionChecks.length).toBe(probes); + }, + ); + + test("custom submit rejects simultaneous sampling options before persistence", async () => { + let writes = 0; + const submit = buildProviderSubmitHandler( + "/tmp/unused-custom-provider-settings.json", + null, + null, + async (apply) => { + writes++; + return apply({ providers: {} }); + }, + ); + await expect( + submit( + { + name: "custom", + baseURL: "https://custom.example/v1", + apiKey: "", + model: "test-model", + oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "0", + topP: "0", + }, + noopSetPhase, + { skipValidation: true }, + ), + ).rejects.toThrow(); + expect(writes).toBe(0); + }); + + test("custom numeric fields round-trip, preserve zero sampling, and clear on blank edit", async () => { + await withTempDir(async (dir) => { + const path = join(dir, "settings.json"); + const values: ProviderFormValues = { + name: "custom", + baseURL: "https://custom.example/v1", + apiKey: "", + model: "test-model", + oauthProfile: "", + contextWindow: "32000", + maxTokens: "8192", + temperature: "0", + topP: "", + }; + await buildProviderSubmitHandler(path, null, null)(values, noopSetPhase, { + skipValidation: true, + }); + const saved = await loadSettings(path); + expect(saved?.providers["custom"]).toMatchObject({ + contextWindow: 32_000, + maxTokens: 8192, + temperature: 0, + }); + await buildProviderSubmitHandler(path, saved, null)( + { + ...values, + contextWindow: " ", + maxTokens: "", + temperature: "", + }, + noopSetPhase, + { skipValidation: true }, + ); + const edited = (await loadSettings(path))?.providers["custom"]; + expect(edited?.contextWindow).toBeUndefined(); + expect(edited?.maxTokens).toBeUndefined(); + expect(edited?.temperature).toBeUndefined(); + expect(edited?.topP).toBeUndefined(); + }); + }); + test("rejects an empty key on a key-required preset without persisting", async () => { await withTempDir(async (dir) => { const path = join(dir, "settings.json"); @@ -90,6 +266,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "gpt-5", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }; const preset = { id: "openai", @@ -121,6 +301,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "llama3", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }; // skipValidation avoids the live connection probe in this unit test. @@ -146,6 +330,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "sk-stale-secret", model: "qwen3", oauthProfile: "default", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }; const preset = { id: "ollama", @@ -184,6 +372,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "qwen3", oauthProfile: "default", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }; const preset = { id: "ollama", @@ -236,6 +428,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "llama3", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, noopSetPhase, { skipValidation: true }, @@ -264,6 +460,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "sk-test-fake", model: "gpt-5", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }; const preset = { id: "openai", @@ -288,6 +488,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "sk-test-fake", model: "gpt-5", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, options: { skipValidation: true, @@ -308,6 +512,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "llama3", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, options: { skipValidation: true }, provider: "ollama", @@ -320,6 +528,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "gpt-5", oauthProfile: "work", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, options: { skipValidation: true, @@ -379,6 +591,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "sk-test-fake", model: "gpt-5", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, options: { skipValidation: true, @@ -400,6 +616,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "llama3", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, options: { skipValidation: true }, provider: "ollama", @@ -413,6 +633,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "gpt-5", oauthProfile: "work", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, options: { skipValidation: true, oauth: stagedCodexOAuth() }, provider: "codex/work", @@ -461,6 +685,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "gpt-5", oauthProfile: "work", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, noopSetPhase, { @@ -502,6 +730,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "gpt-5", oauthProfile: "work", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, noopSetPhase, { @@ -546,6 +778,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "gpt-5", oauthProfile: "work", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, noopSetPhase, { @@ -586,6 +822,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "gpt-5", oauthProfile: "work", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, noopSetPhase, { @@ -625,6 +865,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "gpt-5", oauthProfile: "work", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, noopSetPhase, { @@ -688,6 +932,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "gpt-5", oauthProfile: "work", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, noopSetPhase, { skipValidation: true, oauth: stagedCodexOAuth() }, @@ -715,6 +963,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "sk-test-fake", model: "gpt-5", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }; await submit(values, noopSetPhase, { @@ -752,6 +1004,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "gpt-5", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, noopSetPhase, { diff --git a/src/tui/provider-setup.test.ts b/src/tui/provider-setup.test.ts index 306f6bdd5..e88b268d9 100644 --- a/src/tui/provider-setup.test.ts +++ b/src/tui/provider-setup.test.ts @@ -60,6 +60,10 @@ const EMPTY: ProviderFormValues = { apiKey: "", model: "", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }; type LoginCompletion = Awaited; @@ -178,6 +182,40 @@ describe("provider setup pure helpers", () => { expect(stepReady("apiKey", "")).toBe(true); }); + test("numeric token/sampling steps accept blank or a valid in-range number", () => { + // Blank is always allowed (leave unset). + expect(stepReady("contextWindow", "")).toBe(true); + expect(stepReady("contextWindow", " ")).toBe(true); + expect(stepReady("maxTokens", "")).toBe(true); + expect(stepReady("temperature", "")).toBe(true); + expect(stepReady("topP", "")).toBe(true); + // Valid finite numbers pass. + expect(stepReady("contextWindow", "128000")).toBe(true); + expect(stepReady("maxTokens", "8192")).toBe(true); + // Range checks: temperature 0..2, topP 0..1. + expect(stepReady("temperature", "0")).toBe(true); + expect(stepReady("temperature", "2")).toBe(true); + expect(stepReady("temperature", "2.5")).toBe(false); + expect(stepReady("topP", "0")).toBe(true); + expect(stepReady("topP", "1")).toBe(true); + expect(stepReady("topP", "1.5")).toBe(false); + // Non-numeric rejects. + expect(stepReady("maxTokens", "abc")).toBe(false); + expect(stepReady("temperature", "nan")).toBe(false); + }); + + test.each(["contextWindow", "maxTokens"] as const)( + "%s rejects nonpositive and fractional token counts", + (field) => { + for (const value of ["0", "-1", "12.5", "Infinity", "NaN"]) { + expect(stepReady(field, value)).toBe(false); + } + expect(stepReady(field, "1")).toBe(true); + expect(stepReady(field, " 8192 ")).toBe(true); + expect(stepReady(field, " ")).toBe(true); + }, + ); + test("secrets render as capped bullets", () => { const masked = maskSecret("sk-abc"); expect(masked).toHaveLength(6); @@ -235,6 +273,10 @@ describe("provider setup pure helpers", () => { "baseURL", "apiKey", "model", + "contextWindow", + "maxTokens", + "temperature", + "topP", ]); }); @@ -498,6 +540,15 @@ async function connectCustom(harness: Harness): Promise { type(harness, "fp-small"); harness.pressKey("Enter"); await harness.renderOnce(); + // The four optional token/sampling steps: Enter accepts blank (leave unset). + harness.pressKey("Enter"); + await harness.renderOnce(); + harness.pressKey("Enter"); + await harness.renderOnce(); + harness.pressKey("Enter"); + await harness.renderOnce(); + harness.pressKey("Enter"); + await harness.renderOnce(); } const AUTHORIZE_URL = "https://auth.example.com/authorize?code_challenge=abc"; @@ -1169,6 +1220,10 @@ describe("runProviderSetup", () => { apiKey: "sk-key", model: openai?.defaultModel ?? "", oauthProfile: "default", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }); expect(opts[0]?.preset?.id).toBe("openai"); expect(opts[0]?.preset?.models.length).toBeGreaterThan(1); @@ -1182,7 +1237,7 @@ describe("runProviderSetup", () => { opts.push(o); }); await pickRow(harness, PROVIDER_IDS, CUSTOM_CHOICE_ID); - expect(harness.captureCharFrame()).toContain("step 2 of 5"); + expect(harness.captureCharFrame()).toContain("step 2 of 9"); type(harness, "firepass"); harness.pressKey("Enter"); type(harness, "https://api.example.com"); @@ -1191,6 +1246,11 @@ describe("runProviderSetup", () => { harness.pressKey("Enter"); type(harness, "fp-small"); harness.pressKey("Enter"); + // Token/sampling knobs are optional — leave all four blank. + harness.pressKey("Enter"); + harness.pressKey("Enter"); + harness.pressKey("Enter"); + harness.pressKey("Enter"); await harness.renderOnce(); expect(await done).toBe(true); @@ -1200,10 +1260,82 @@ describe("runProviderSetup", () => { apiKey: "sk-key", model: "fp-small", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }); expect(opts[0]?.preset).toBeUndefined(); }); + test.each(["openai", CUSTOM_CHOICE_ID])( + "changing provider choices to %s clears custom numeric fields", + async (target) => { + const seen: ProviderFormValues[] = []; + const { done, harness } = await mountSetup(async (values) => { + seen.push({ ...values }); + }); + try { + await pickRow(harness, PROVIDER_IDS, CUSTOM_CHOICE_ID); + for (const value of [ + "custom", + "https://custom.example/v1", + "test-key", + "test-model", + "32000", + "8192", + "0.7", + ]) { + type(harness, value); + harness.pressKey("Enter"); + await harness.renderOnce(); + } + type(harness, "0.9"); + for (let index = 0; index < 8; index++) await pressEscape(harness); + const distance = + PROVIDER_IDS.indexOf(target) - PROVIDER_IDS.indexOf(CUSTOM_CHOICE_ID); + for (let index = 0; index < Math.abs(distance); index++) { + harness.pressKey(distance < 0 ? "ARROW_UP" : "ARROW_DOWN"); + } + harness.pressKey("Enter"); + await flush(harness); + if (target === "openai") { + harness.pressKey("Enter"); + await harness.renderOnce(); + type(harness, "new-key"); + harness.pressKey("Enter"); + await harness.renderOnce(); + harness.pressKey("Enter"); + } else { + for (const value of [ + "new-custom", + "https://new.example/v1", + "new-key", + "new-model", + ]) { + type(harness, value); + harness.pressKey("Enter"); + await harness.renderOnce(); + } + for (let index = 0; index < 4; index++) { + harness.pressKey("Enter"); + await harness.renderOnce(); + } + } + await done; + expect(seen[0]).toMatchObject({ + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", + }); + } finally { + harness.pressKey("Ctrl+C"); + await done; + } + }, + ); + test("the model pick-list can escape to a typed model id", async () => { const seen: ProviderFormValues[] = []; const { done, harness } = await mountSetup(async (values) => { @@ -1248,7 +1380,7 @@ describe("runProviderSetup", () => { await pickRow(harness, PROVIDER_IDS, CUSTOM_CHOICE_ID); harness.pressKey("Enter"); await harness.renderOnce(); - expect(harness.captureCharFrame()).toContain("step 2 of 5"); + expect(harness.captureCharFrame()).toContain("step 2 of 9"); harness.pressKey("Ctrl+C"); await done; }); diff --git a/src/tui/provider/choices.ts b/src/tui/provider/choices.ts index 24d83eff9..35f5e2847 100644 --- a/src/tui/provider/choices.ts +++ b/src/tui/provider/choices.ts @@ -351,6 +351,10 @@ export function chooseProviderRow( state.ollamaDiscovery = "idle"; state.choice = picked; state.values.apiKey = ""; + state.values.contextWindow = ""; + state.values.maxTokens = ""; + state.values.temperature = ""; + state.values.topP = ""; state.typedModel = false; state.oauthProfileError = null; state.oauthProfileConfirmPending = false; diff --git a/src/tui/provider/form.ts b/src/tui/provider/form.ts index ef75a8646..0e7a2974b 100644 --- a/src/tui/provider/form.ts +++ b/src/tui/provider/form.ts @@ -4,6 +4,7 @@ */ import { UI } from "../theme.js"; +import { ProviderInferenceOptionSchemas } from "../../config/provider-inference-options.js"; import { type ProviderField, stepLabel, type SetupStep } from "./steps.js"; import type { ProviderChoice, @@ -17,6 +18,10 @@ export const PROVIDER_FIELD_HINTS: Record = { baseURL: "https://api.openai.com/v1", apiKey: "sk-… (blank for keyless/local)", model: "gpt-4o", + contextWindow: "128000 (optional)", + maxTokens: "8192 (optional)", + temperature: "0.7 (optional)", + topP: "0.9 (optional)", }; /** Placeholder for the OAuth account-name step, which edits `oauthProfile`. */ @@ -43,9 +48,20 @@ export function maskEcho(value: string): string { return MASK_CHAR.repeat([...value].length); } -/** apiKey is optional — blank means a keyless local provider (e.g. Ollama). */ +/** + * The four optional token/sampling steps accept a blank (leave unset) or a + * finite number in range. Everything else is ready once it is non-blank. + */ export function stepReady(step: SetupStep, value: string): boolean { - return step === "apiKey" || value.trim().length > 0; + if (step === "apiKey") return true; + if (!(step in ProviderInferenceOptionSchemas)) return value.trim().length > 0; + const trimmed = value.trim(); + if (trimmed.length === 0) return true; + const schema = + ProviderInferenceOptionSchemas[ + step as keyof typeof ProviderInferenceOptionSchemas + ]; + return schema.allows(Number(trimmed)); } /** @@ -117,6 +133,18 @@ function settledValue( : values.name; } if (step === "baseURL") return values.baseURL; + if (step === "contextWindow") { + return values.contextWindow.trim().length > 0 ? values.contextWindow : "—"; + } + if (step === "maxTokens") { + return values.maxTokens.trim().length > 0 ? values.maxTokens : "—"; + } + if (step === "temperature") { + return values.temperature.trim().length > 0 ? values.temperature : "—"; + } + if (step === "topP") { + return values.topP.trim().length > 0 ? values.topP : "—"; + } return values.model; } diff --git a/src/tui/provider/setup.ts b/src/tui/provider/setup.ts index dbeabf2de..b3aed4301 100644 --- a/src/tui/provider/setup.ts +++ b/src/tui/provider/setup.ts @@ -110,6 +110,10 @@ export async function runProviderSetup( apiKey: "", model: "", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, margin: resolveSideMargin(renderer.width || 80), choice: null, diff --git a/src/tui/provider/steps.ts b/src/tui/provider/steps.ts index 4f9a84747..d9b66aad2 100644 --- a/src/tui/provider/steps.ts +++ b/src/tui/provider/steps.ts @@ -6,7 +6,15 @@ import { isOllamaProviderId } from "../../provider/ollama.js"; import type { ProviderChoice } from "./types.js"; -export type ProviderField = "name" | "baseURL" | "apiKey" | "model"; +export type ProviderField = + | "name" + | "baseURL" + | "apiKey" + | "model" + | "contextWindow" + | "maxTokens" + | "temperature" + | "topP"; /** One screen of the flow. `provider` and `model` can be pick-lists. */ export type SetupStep = @@ -15,6 +23,10 @@ export type SetupStep = | "baseURL" | "apiKey" | "model" + | "contextWindow" + | "maxTokens" + | "temperature" + | "topP" | "login"; /** Known-provider path: pick, name the instance, paste key, pick model. */ @@ -52,6 +64,10 @@ export const CUSTOM_STEPS: readonly SetupStep[] = [ "baseURL", "apiKey", "model", + "contextWindow", + "maxTokens", + "temperature", + "topP", ]; export const STEP_LABELS: Record = { @@ -60,6 +76,10 @@ export const STEP_LABELS: Record = { baseURL: "base url", apiKey: "api key", model: "model", + contextWindow: "context window", + maxTokens: "max output tokens", + temperature: "temperature", + topP: "top p", login: "sign in", }; @@ -70,6 +90,11 @@ export const STEP_PROMPTS: Record = { "paste the provider url — Ollama uses the server root; others may include /v1", apiKey: "paste the api key — leave blank for a keyless local endpoint", model: "pick the model to start with", + contextWindow: + "context window (tokens) — optional, leave blank to use the default", + maxTokens: "max output tokens — optional, leave blank to use the default", + temperature: "temperature (0..2) — optional; mutually exclusive with top p", + topP: "top p (0..1) — optional; mutually exclusive with temperature", login: "authorize in the browser — this window waits for you", }; diff --git a/src/tui/provider/submit.ts b/src/tui/provider/submit.ts index c4354bce2..645ac85c8 100644 --- a/src/tui/provider/submit.ts +++ b/src/tui/provider/submit.ts @@ -1,3 +1,5 @@ +import { type } from "arktype"; + import { isOllamaProviderId, normalizeOllamaRootURL, @@ -20,6 +22,33 @@ import type { ProviderSetupSubmit } from "./types.js"; import { NOOP_TELEMETRY, type Telemetry } from "../../telemetry/index.js"; import { classifyAuthProvider } from "../../telemetry/classify.js"; import { captureAuthSuccess } from "../../telemetry/product-events.js"; +import { ProviderInferenceOptionsSchema } from "../../config/provider-inference-options.js"; + +const CustomInferenceInputsSchema = type({ + contextWindow: "string", + maxTokens: "string", + temperature: "string", + topP: "string", +}); + +function parseCustomInferenceOptions( + values: unknown, +): typeof ProviderInferenceOptionsSchema.infer { + const inputs = CustomInferenceInputsSchema(values); + if (inputs instanceof type.errors) { + throw new Error(`Invalid custom provider options: ${inputs.summary}`); + } + const numbers: Record = {}; + for (const [field, raw] of Object.entries(inputs)) { + const trimmed = raw.trim(); + if (trimmed.length > 0) numbers[field] = Number(trimmed); + } + const options = ProviderInferenceOptionsSchema(numbers); + if (options instanceof type.errors) { + throw new Error(`Invalid custom provider options: ${options.summary}`); + } + return options; +} /** * Persist the project-local provider/model selection after a successful @@ -70,6 +99,18 @@ export function buildProviderSubmitHandler( const trimmedBaseURL = baseURL.trim(); const trimmedKey = apiKey.trim(); const selectedModel = model.trim(); + + // Preset/OAuth paths never expose these fields, so stale drafts must not + // affect their credentials or persist hidden custom configuration. + const inferenceOptions = + preset === undefined && oauth === undefined + ? parseCustomInferenceOptions({ + contextWindow: values.contextWindow, + maxTokens: values.maxTokens, + temperature: values.temperature, + topP: values.topP, + }) + : {}; const isOllama = preset !== undefined && isOllamaProviderId(preset.id); const effectiveApiKey = isOllama || trimmedKey.length === 0 ? undefined : trimmedKey; @@ -160,6 +201,7 @@ export function buildProviderSubmitHandler( : { keyless: true }), ...(preset?.anthropic === true ? { anthropic: true } : {}), ...(preset?.opencodeGo === true ? { opencodeGo: true } : {}), + ...inferenceOptions, // "Save anyway" (Ctrl+S) persists a credential the connection test // never passed. Mark it so the running session can warn on first use // instead of surfacing a bare adapter error. diff --git a/src/tui/provider/types.ts b/src/tui/provider/types.ts index 3b28008a1..1d701058a 100644 --- a/src/tui/provider/types.ts +++ b/src/tui/provider/types.ts @@ -65,6 +65,15 @@ export interface ProviderFormValues { * edits `name` free-form. */ oauthProfile: string; + /** + * Optional sampling/token knobs for the custom path. Kept as blank-by-default + * strings so an empty input means "leave unset" rather than a forced value; + * submit parses them to numbers and omits blanks. + */ + contextWindow: string; + maxTokens: string; + temperature: string; + topP: string; } // "testing" covers the connection-check call against the entered credentials; diff --git a/src/tui/runner/settings.ts b/src/tui/runner/settings.ts index ee15dbb20..c2c7e802d 100644 --- a/src/tui/runner/settings.ts +++ b/src/tui/runner/settings.ts @@ -362,6 +362,7 @@ export async function wireSettings( const providers = await refreshLiveProviderCatalog( onDisk, resolvedForCatalog, + () => state.config, ); state.config = { ...state.config, @@ -393,6 +394,7 @@ export async function wireSettings( const nextProviders = await refreshLiveProviderCatalog( nextDisk, resolvedForCatalog, + () => state.config, ); state.config = { ...state.config, @@ -419,6 +421,7 @@ export async function wireSettings( const nextProviders = await refreshLiveProviderCatalog( nextDisk, resolvedForCatalog, + () => state.config, ); state.config = { ...state.config, @@ -738,6 +741,7 @@ export async function wireSettings( const providers = await refreshLiveProviderCatalog( onDisk, resolvedForCatalog, + () => state.config, ); state.config = { ...state.config,