From 80730a6c282a4824b56c74a150e70b1da0dd7eca Mon Sep 17 00:00:00 2001 From: Sawyer Date: Sat, 3 Oct 2026 08:48:26 -0700 Subject: [PATCH] feat(provider): let custom providers set sampling and token knobs --- src/config.test.ts | 45 +++++++++++ src/config/index.ts | 28 +++++-- src/config/inference-sources.test.ts | 61 ++++++++++++++ src/config/inference-sources.ts | 6 ++ src/config/providers.test.ts | 28 +++++++ src/config/providers.ts | 10 +++ src/config/settings.ts | 19 +++++ .../openai-compatible-adapter.test.ts | 8 ++ src/tui/onboarding.test.ts | 12 +++ src/tui/provider-setup-submit.test.ts | 80 +++++++++++++++++++ src/tui/provider-setup.test.ts | 56 ++++++++++++- src/tui/provider/form.ts | 44 +++++++++- src/tui/provider/setup.ts | 4 + src/tui/provider/steps.ts | 27 ++++++- src/tui/provider/submit.ts | 20 +++++ src/tui/provider/types.ts | 9 +++ 16 files changed, 448 insertions(+), 9 deletions(-) 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..659b10fb2 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 } : {}), }; } @@ -1405,6 +1413,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 +1495,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/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.ts b/src/config/settings.ts index 9fcea09cf..f1429b5fd 100644 --- a/src/config/settings.ts +++ b/src/config/settings.ts @@ -44,6 +44,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 +251,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 } : {}), @@ -616,6 +632,9 @@ const ProviderSettingsSchema = type({ "keyless?": "boolean", "free?": "boolean", "contextWindow?": "number", + "maxTokens?": "number", + "temperature?": "number", + "topP?": "number", "bifrostVirtualKey?": "boolean", "anthropic?": "boolean", "opencodeGo?": "boolean", 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/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..0789de518 100644 --- a/src/tui/provider-setup-submit.test.ts +++ b/src/tui/provider-setup-submit.test.ts @@ -90,6 +90,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "gpt-5", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }; const preset = { id: "openai", @@ -121,6 +125,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "llama3", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }; // skipValidation avoids the live connection probe in this unit test. @@ -146,6 +154,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "sk-stale-secret", model: "qwen3", oauthProfile: "default", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }; const preset = { id: "ollama", @@ -184,6 +196,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "qwen3", oauthProfile: "default", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }; const preset = { id: "ollama", @@ -236,6 +252,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "llama3", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, noopSetPhase, { skipValidation: true }, @@ -264,6 +284,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "sk-test-fake", model: "gpt-5", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }; const preset = { id: "openai", @@ -288,6 +312,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "sk-test-fake", model: "gpt-5", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, options: { skipValidation: true, @@ -308,6 +336,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "llama3", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, options: { skipValidation: true }, provider: "ollama", @@ -320,6 +352,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "gpt-5", oauthProfile: "work", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, options: { skipValidation: true, @@ -379,6 +415,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "sk-test-fake", model: "gpt-5", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, options: { skipValidation: true, @@ -400,6 +440,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "llama3", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, options: { skipValidation: true }, provider: "ollama", @@ -413,6 +457,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "gpt-5", oauthProfile: "work", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, options: { skipValidation: true, oauth: stagedCodexOAuth() }, provider: "codex/work", @@ -461,6 +509,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "gpt-5", oauthProfile: "work", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, noopSetPhase, { @@ -502,6 +554,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "gpt-5", oauthProfile: "work", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, noopSetPhase, { @@ -546,6 +602,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "gpt-5", oauthProfile: "work", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, noopSetPhase, { @@ -586,6 +646,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "gpt-5", oauthProfile: "work", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, noopSetPhase, { @@ -625,6 +689,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "gpt-5", oauthProfile: "work", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, noopSetPhase, { @@ -688,6 +756,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "", model: "gpt-5", oauthProfile: "work", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }, noopSetPhase, { skipValidation: true, oauth: stagedCodexOAuth() }, @@ -715,6 +787,10 @@ describe("buildProviderSubmitHandler", () => { apiKey: "sk-test-fake", model: "gpt-5", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }; await submit(values, noopSetPhase, { @@ -752,6 +828,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..dde76e550 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,28 @@ 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("secrets render as capped bullets", () => { const masked = maskSecret("sk-abc"); expect(masked).toHaveLength(6); @@ -235,6 +261,10 @@ describe("provider setup pure helpers", () => { "baseURL", "apiKey", "model", + "contextWindow", + "maxTokens", + "temperature", + "topP", ]); }); @@ -498,6 +528,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 +1208,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 +1225,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 +1234,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,6 +1248,10 @@ describe("runProviderSetup", () => { apiKey: "sk-key", model: "fp-small", oauthProfile: "", + contextWindow: "", + maxTokens: "", + temperature: "", + topP: "", }); expect(opts[0]?.preset).toBeUndefined(); }); @@ -1248,7 +1300,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/form.ts b/src/tui/provider/form.ts index ef75a8646..961d5940c 100644 --- a/src/tui/provider/form.ts +++ b/src/tui/provider/form.ts @@ -17,6 +17,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`. */ @@ -44,8 +48,34 @@ export function maskEcho(value: string): string { } /** apiKey is optional — blank means a keyless local provider (e.g. Ollama). */ +const NUMERIC_STEPS: readonly SetupStep[] = [ + "contextWindow", + "maxTokens", + "temperature", + "topP", +]; + +/** Range bounds for the numeric sampling/token steps; blank is always allowed. */ +const NUMERIC_BOUNDS: Partial> = + { + temperature: { min: 0, max: 2 }, + topP: { min: 0, max: 1 }, + }; + +/** + * 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 (!NUMERIC_STEPS.includes(step)) return value.trim().length > 0; + const trimmed = value.trim(); + if (trimmed.length === 0) return true; + const parsed = Number(trimmed); + if (!Number.isFinite(parsed)) return false; + const bounds = NUMERIC_BOUNDS[step]; + if (bounds !== undefined) return parsed >= bounds.min && parsed <= bounds.max; + return true; } /** @@ -117,6 +147,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..2e66f6a66 100644 --- a/src/tui/provider/submit.ts +++ b/src/tui/provider/submit.ts @@ -70,6 +70,22 @@ export function buildProviderSubmitHandler( const trimmedBaseURL = baseURL.trim(); const trimmedKey = apiKey.trim(); const selectedModel = model.trim(); + + // Optional token/sampling knobs: blank means leave unset. temperature and + // topP are mutually exclusive at the OpenAI API level — never send both. + const parseOptionalNumber = (raw: string): number | undefined => { + const trimmed = raw.trim(); + return trimmed.length === 0 ? undefined : Number(trimmed); + }; + const contextWindow = parseOptionalNumber(values.contextWindow); + const maxTokens = parseOptionalNumber(values.maxTokens); + const temperature = parseOptionalNumber(values.temperature); + const topP = parseOptionalNumber(values.topP); + if (temperature !== undefined && topP !== undefined) { + throw new Error( + "temperature and top p are mutually exclusive — set only one.", + ); + } const isOllama = preset !== undefined && isOllamaProviderId(preset.id); const effectiveApiKey = isOllama || trimmedKey.length === 0 ? undefined : trimmedKey; @@ -160,6 +176,10 @@ export function buildProviderSubmitHandler( : { keyless: true }), ...(preset?.anthropic === true ? { anthropic: true } : {}), ...(preset?.opencodeGo === true ? { opencodeGo: true } : {}), + ...(contextWindow !== undefined ? { contextWindow } : {}), + ...(maxTokens !== undefined ? { maxTokens } : {}), + ...(temperature !== undefined ? { temperature } : {}), + ...(topP !== undefined ? { topP } : {}), // "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;