From e88fe9d3f225fe2f8e3839b41a1ea16878038c01 Mon Sep 17 00:00:00 2001 From: Cody Hart <157865967+HartBrook@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:32:11 -0500 Subject: [PATCH] Use configured model by default --- README.md | 4 +- apps/server/src/app.test.ts | 31 +++++++ apps/server/src/app.ts | 33 ++++++- apps/server/src/providers/catalog.test.ts | 17 +++- apps/server/src/providers/catalog.ts | 7 ++ apps/server/src/sessions.test.ts | 15 +++- apps/server/src/sessions.ts | 46 +++++++++- apps/web/src/App.tsx | 105 ++++++++++++++++------ apps/web/src/ai.test.ts | 41 +-------- apps/web/src/ai.ts | 27 ++---- apps/web/src/session.ts | 18 ++++ apps/web/src/styles.css | 5 +- e2e-live/reviewer-live.spec.ts | 4 +- e2e/reviewer-flow.spec.ts | 37 +++++++- 14 files changed, 291 insertions(+), 99 deletions(-) diff --git a/README.md b/README.md index 964f3d7..fa193da 100644 --- a/README.md +++ b/README.md @@ -155,7 +155,7 @@ form. Wingdiff does not run the terminal renderer or consume ANSI output; Git's structured diff remains the authoritative source for line numbers, fingerprints, and review anchors. -From the real-PR Summary, choose a configured model and select **Generate guided review**. The model organizes the validated diff into semantic stops, ranks concrete findings by severity, and references opaque evidence anchors. Wingdiff rejects the result unless every changed file is covered and every claim or finding resolves to an exact known anchor. Generated tours are pinned to the acquired head SHA and can be resumed locally. +From the real-PR Summary, select **Generate guided review**. Wingdiff uses the configured default model automatically; choose **Change** beside the model summary to apply an override for only that review. The model organizes the validated diff into semantic stops, ranks concrete findings by severity, and references opaque evidence anchors. Wingdiff rejects the result unless every changed file is covered and every claim or finding resolves to an exact known anchor. Generated tours are pinned to the acquired head SHA and can be resumed locally. Before generation, Wingdiff shows the exact model context, provider transport, changed files, repository instructions, exclusions, size, and content fingerprint. The immutable manifest, fingerprint, and evidence-anchor snapshot are stored with the tour; a changed manifest invalidates the resumable tour. Model-facing evidence references use compact request-local aliases constrained to the supplied anchor set, then map back to immutable evidence fingerprints before validation and storage. Potentially sensitive, generated, and vendor files are marked; common key and environment-file patterns are excluded by default. Exclusions are editable local globs. `AGENTS.md`, `CONTRIBUTING.md`, `.github/CONTRIBUTING.md`, and `.gitlab/CONTRIBUTING.md` are included when present, capped at 20,000 characters each. Context over 750,000 characters is blocked until reduced. @@ -200,7 +200,7 @@ Codex CLI uses the default model selected for the signed-in ChatGPT account. Win - `gpt-6-luna` — fast, cost-efficient investigations - `gpt-5.3-codex` — deprecated Codex-tuned model available only through the direct OpenAI API provider -Claude Sonnet 4.6 and Opus 4.6 remain available through the same provider interface. Select Codex CLI, OpenAI API, or Anthropic API plus the model and supported reasoning effort from the model control in the top bar. +Claude Sonnet 4.6 and Opus 4.6 remain available through the same provider interface. Wingdiff chooses the first configured provider as the default. The Summary shows the effective provider, model, and reasoning effort, with a review-scoped override available through **Change**. API keys are read only by the loopback Node server and are never returned to browser JavaScript. OpenAI API requests use the Responses API with `store: false`; tour generation uses strict structured output and investigations stream. Wingdiff never reads or copies Codex CLI credentials. diff --git a/apps/server/src/app.test.ts b/apps/server/src/app.test.ts index e25a4b4..001bf03 100644 --- a/apps/server/src/app.test.ts +++ b/apps/server/src/app.test.ts @@ -57,6 +57,37 @@ describe("generated tour API", () => { })).status).toBe(403); }); + it("exposes the configured default and persists a session model override", async () => { + store = new SessionStore(":memory:"); + const session = store.upsertReadySession(target, metadata, evidence, process.cwd()); + const provider: TextProvider = { + id: "codex", + async generateTour() { return {}; }, + async *streamInvestigation() { yield ""; }, + }; + const app = createApp({}, { sessionStore: store, providers: new Map([["codex", provider]]) }); + server = app.listen(0, "127.0.0.1"); + await new Promise((resolve) => server!.once("listening", resolve)); + const port = (server.address() as AddressInfo).port; + const baseUrl = `http://127.0.0.1:${port}/api`; + + expect(await (await fetch(`${baseUrl}/providers`)).json()).toMatchObject({ + defaultSelection: { provider: "codex", model: "codex-default", reasoningEffort: "medium" }, + }); + const settingsUrl = `${baseUrl}/sessions/${session.id}/model-selection`; + expect(await (await fetch(settingsUrl)).json()).toEqual({ override: null }); + const selection = { provider: "codex", model: "codex-default", reasoningEffort: "high" }; + const saved = await fetch(settingsUrl, { + method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ override: selection }), + }); + expect(saved.status).toBe(200); + expect(await saved.json()).toEqual({ override: selection }); + expect(await (await fetch(settingsUrl)).json()).toEqual({ override: selection }); + expect(await (await fetch(settingsUrl, { + method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ override: null }), + })).json()).toEqual({ override: null }); + }); + it("generates and resumes a revision-pinned tour", async () => { store = new SessionStore(":memory:"); const session = store.upsertReadySession(target, metadata, evidence, process.cwd()); diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index a1ef47c..c1e94b4 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -4,7 +4,7 @@ import { acquireReviewSession, refreshReviewSession, type AcquisitionDependencie import { validateDraftComment } from "./comments.js"; import { buildSessionGenerationContext } from "./context.js"; import { buildSessionInvestigationContext } from "./investigation.js"; -import { createProviders, publicProviders, validateSelection } from "./providers/index.js"; +import { createProviders, defaultSelection, publicProviders, validateSelection } from "./providers/index.js"; import type { ModelSelection, ProviderId, TextProvider } from "./providers/types.js"; import { SessionStore } from "./sessions.js"; import type { InvestigationEntry, ReviewDraft, ReviewProgressStatus, TourScope } from "./sessions.js"; @@ -42,6 +42,8 @@ interface TourGenerationJob { export function createApp(environment: NodeJS.ProcessEnv = process.env, options: AppOptions = {}) { const app = express(); const providers = options.providers ?? createProviders(environment); + const providerCatalog = publicProviders(environment, new Set(providers.keys())); + const configuredDefaultSelection = defaultSelection(providerCatalog); const cwd = options.cwd ?? process.cwd(); const sessionStore = options.sessionStore ?? new SessionStore(); const tourGenerations = new Map(); @@ -540,7 +542,34 @@ export function createApp(environment: NodeJS.ProcessEnv = process.env, options: }); app.get("/api/providers", (_request, response) => { - response.json({ providers: publicProviders(environment, new Set(providers.keys())) }); + response.json({ providers: providerCatalog, defaultSelection: configuredDefaultSelection }); + }); + + app.get("/api/sessions/:id/model-selection", (request, response) => { + const session = sessionStore.getSession(request.params.id); + if (!session) { + response.status(404).json({ error: "Review session not found." }); + return; + } + response.json({ override: sessionStore.getModelSelectionOverride(session.id) }); + }); + + app.put("/api/sessions/:id/model-selection", (request, response) => { + try { + const session = sessionStore.getSession(request.params.id); + if (!session) { + response.status(404).json({ error: "Review session not found." }); + return; + } + const override = request.body?.override == null ? null : validateSelection(request.body.override); + if (override && !providers.has(override.provider)) { + response.status(400).json({ error: `${override.provider} is not configured.` }); + return; + } + response.json({ override: sessionStore.saveModelSelectionOverride(session.id, override) }); + } catch (error) { + response.status(400).json({ error: error instanceof Error ? error.message : "The model override is invalid." }); + } }); app.post("/api/sessions/:id/investigate", async (request, response) => { diff --git a/apps/server/src/providers/catalog.test.ts b/apps/server/src/providers/catalog.test.ts index ce84a91..c34eb52 100644 --- a/apps/server/src/providers/catalog.test.ts +++ b/apps/server/src/providers/catalog.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { MODEL_CATALOG, publicProviders, validateSelection } from "./catalog.js"; +import { defaultSelection, MODEL_CATALOG, publicProviders, validateSelection } from "./catalog.js"; describe("model catalog", () => { it("offers OpenAI, Codex, and Anthropic choices", () => { @@ -31,6 +31,21 @@ describe("model catalog", () => { expect(JSON.stringify(providers)).not.toContain("secret-openai"); }); + it("selects the first configured provider as the application default", () => { + expect(defaultSelection(publicProviders({}, new Set(["codex"])))).toEqual({ + provider: "codex", model: "codex-default", reasoningEffort: "medium", + }); + expect(defaultSelection(publicProviders({ ANTHROPIC_API_KEY: "secret" }, new Set()))).toEqual({ + provider: "anthropic", model: "claude-sonnet-4-6", reasoningEffort: "medium", + }); + }); + + it("falls back to Codex setup when no provider is configured", () => { + expect(defaultSelection(publicProviders({}, new Set()))).toEqual({ + provider: "codex", model: "codex-default", reasoningEffort: "medium", + }); + }); + it("rejects cross-provider and unsupported reasoning selections", () => { expect(() => validateSelection({ provider: "anthropic", model: "gpt-6-sol", reasoningEffort: "medium" })).toThrow(/Unknown model/); expect(() => validateSelection({ provider: "openai", model: "gpt-6-astra", reasoningEffort: "none" })).toThrow(/does not support/); diff --git a/apps/server/src/providers/catalog.ts b/apps/server/src/providers/catalog.ts index 9125e55..b665f98 100644 --- a/apps/server/src/providers/catalog.ts +++ b/apps/server/src/providers/catalog.ts @@ -122,6 +122,13 @@ export function publicProviders( })); } +export function defaultSelection(providers: readonly PublicProvider[]): ModelSelection { + const provider = providers.find((candidate) => candidate.configured) ?? providers[0]; + const model = provider?.models[0]; + if (!provider || !model) throw new Error("No review model is available."); + return { provider: provider.id, model: model.id, reasoningEffort: model.defaultEffort }; +} + export function validateSelection(input: unknown): ModelSelection { if (!input || typeof input !== "object") throw new Error("A model selection is required."); const value = input as Record; diff --git a/apps/server/src/sessions.test.ts b/apps/server/src/sessions.test.ts index 9a923ca..65f699c 100644 --- a/apps/server/src/sessions.test.ts +++ b/apps/server/src/sessions.test.ts @@ -125,7 +125,20 @@ describe("local review sessions", () => { selection: { provider: "codex", model: "gpt-6-sol", reasoningEffort: "medium" }, tour: { summary: "The counter change is small and focused." }, }); - expect(Number(store.database.prepare("PRAGMA user_version").get()?.user_version)).toBe(15); + expect(Number(store.database.prepare("PRAGMA user_version").get()?.user_version)).toBe(16); + store.close(); + }); + + it("persists and clears a review-scoped model override", () => { + const store = new SessionStore(":memory:"); + const session = store.upsertReadySession(target, metadata(), evidence()); + const selection = { provider: "openai" as const, model: "gpt-6-astra", reasoningEffort: "high" as const }; + + expect(store.getModelSelectionOverride(session.id)).toBeNull(); + expect(store.saveModelSelectionOverride(session.id, selection)).toEqual(selection); + expect(store.getModelSelectionOverride(session.id)).toEqual(selection); + expect(store.saveModelSelectionOverride(session.id, null)).toBeNull(); + expect(store.getModelSelectionOverride(session.id)).toBeNull(); store.close(); }); diff --git a/apps/server/src/sessions.ts b/apps/server/src/sessions.ts index db63fe0..a22f7fe 100644 --- a/apps/server/src/sessions.ts +++ b/apps/server/src/sessions.ts @@ -232,6 +232,36 @@ export class SessionStore { })); } + getModelSelectionOverride(sessionId: string): ModelSelection | null { + this.requireSession(sessionId); + const row = this.database.prepare(` + SELECT provider, model, reasoning_effort FROM model_settings WHERE session_id = ? + `).get(sessionId); + return row ? { + provider: String(row.provider) as ModelSelection["provider"], + model: String(row.model), + reasoningEffort: String(row.reasoning_effort) as ModelSelection["reasoningEffort"], + } : null; + } + + saveModelSelectionOverride(sessionId: string, selection: ModelSelection | null): ModelSelection | null { + this.requireSession(sessionId); + if (!selection) { + this.database.prepare("DELETE FROM model_settings WHERE session_id = ?").run(sessionId); + return null; + } + this.database.prepare(` + INSERT INTO model_settings (session_id, provider, model, reasoning_effort, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(session_id) DO UPDATE SET + provider = excluded.provider, + model = excluded.model, + reasoning_effort = excluded.reasoning_effort, + updated_at = excluded.updated_at + `).run(sessionId, selection.provider, selection.model, selection.reasoningEffort, this.now().toISOString()); + return selection; + } + getReviewProgress(sessionId: string): ReviewProgressSnapshot { this.requireSession(sessionId); const rows = this.database.prepare(` @@ -717,7 +747,7 @@ export function defaultDatabasePath(environment: NodeJS.ProcessEnv = process.env function migrate(database: DatabaseSync) { let version = Number(database.prepare("PRAGMA user_version").get()?.user_version ?? 0); - if (version > 15) throw new Error(`Wingdiff session database version ${version} is newer than this application supports.`); + if (version > 16) throw new Error(`Wingdiff session database version ${version} is newer than this application supports.`); if (version === 0) database.exec(` BEGIN; @@ -1001,6 +1031,20 @@ function migrate(database: DatabaseSync) { PRAGMA user_version = 15; COMMIT; `); + + version = Number(database.prepare("PRAGMA user_version").get()?.user_version ?? 0); + if (version === 15) database.exec(` + BEGIN; + CREATE TABLE model_settings ( + session_id TEXT PRIMARY KEY REFERENCES review_sessions(id) ON DELETE CASCADE, + provider TEXT NOT NULL CHECK (provider IN ('codex', 'openai', 'anthropic')), + model TEXT NOT NULL, + reasoning_effort TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + PRAGMA user_version = 16; + COMMIT; + `); } function rowToSession(row: Record): ReviewSession { diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 7729d2a..0d79349 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,8 +4,6 @@ import { DEFAULT_SELECTION, FALLBACK_PROVIDERS, fetchProviders, - normalizeSelection, - preferredAvailableSelection, selectedModel, streamInvestigation, streamSessionInvestigation, @@ -40,6 +38,7 @@ import { fetchReviewCheckpoint, fetchReviewProgress, fetchReviewSession, + fetchSessionModelSelectionOverride, fetchDraftComments, fetchInvestigationEntries, fetchReviewDraft, @@ -56,6 +55,7 @@ import { revisionStopIndex, saveReviewDraft, saveReviewProgress, + saveSessionModelSelectionOverride, saveSessionContext, startSessionTourGeneration, updateInvestigationEntry, @@ -251,8 +251,10 @@ function AcquiredReviewApp({ onHome, onSession, session }: { onHome: () => void; const [notice, setNotice] = useState(null); const [error, setErrorMessage] = useState(null); const [providers, setProviders] = useState(FALLBACK_PROVIDERS); - const [modelSelection, setModelSelection] = usePersistentState("wingdiff:model", DEFAULT_SELECTION, restoreModelSelection); + const [defaultModelSelection, setDefaultModelSelection] = useState(DEFAULT_SELECTION); + const [modelSelectionOverride, setModelSelectionOverride] = useState(null); const [modelPickerOpen, setModelPickerOpen] = useState(false); + const [modelSettingsSaving, setModelSettingsSaving] = useState(false); const [contextManifest, setContextManifest] = useState(null); const [contextPreviewOpen, setContextPreviewOpen] = useState(false); const [contextLoading, setContextLoading] = useState(false); @@ -297,6 +299,7 @@ function AcquiredReviewApp({ onHome, onSession, session }: { onHome: () => void; const activeStop = stops[activeIndex]; const activeEvidence = activeStop?.evidence.find((item) => item.id === activeEvidenceId) ?? activeStop?.evidence[0]; const stopNotebook = activeStop ? notebook.filter((entry) => entry.stopId === activeStop.id) : []; + const modelSelection = modelSelectionOverride ?? defaultModelSelection; const activeProvider = providers.find((provider) => provider.id === modelSelection.provider); const activeModel = selectedModel(providers, modelSelection); const activeModelLabel = activeProvider ? `${activeProvider.name} · ${activeModel.name}` : activeModel.name; @@ -338,11 +341,12 @@ function AcquiredReviewApp({ onHome, onSession, session }: { onHome: () => void; fetchReviewSubmission(session.id, controller.signal), fetchReviewPublication(session.id, controller.signal), fetchTourGenerationStatus(session.id, controller.signal), - fetchProviders(controller.signal).then((availableProviders) => { - setProviders(availableProviders); - setModelSelection((current) => preferredAvailableSelection(availableProviders, current)); + fetchSessionModelSelectionOverride(session.id, controller.signal), + fetchProviders(controller.signal).then((catalog) => { + setProviders(catalog.providers); + setDefaultModelSelection(catalog.defaultSelection); }), - ]).then(([fullTour, updateTour, updateContext, fullCheckpoint, updateCheckpoint, storedProgress, storedComments, storedNotebook, storedReviewDraft, storedSubmission, storedPublication, storedGeneration]) => { + ]).then(([fullTour, updateTour, updateContext, fullCheckpoint, updateCheckpoint, storedProgress, storedComments, storedNotebook, storedReviewDraft, storedSubmission, storedPublication, storedGeneration, storedModelOverride]) => { setTours({ full: fullTour, update: updateTour }); setUpdate(updateContext?.update ?? null); setBaselineCheckpoint(updateContext?.baselineCheckpoint ?? null); @@ -354,6 +358,7 @@ function AcquiredReviewApp({ onHome, onSession, session }: { onHome: () => void; setDisposition(storedReviewDraft?.event ?? "COMMENT"); setSubmission(storedSubmission); setPublication(storedPublication); + setModelSelectionOverride(storedModelOverride); setPublicationError(storedPublication?.error ?? null); setGenerationStatus(storedGeneration.state === "idle" ? null : storedGeneration); if (storedGeneration.state === "running" || storedGeneration.state === "succeeded") { @@ -475,6 +480,21 @@ function AcquiredReviewApp({ onHome, onSession, session }: { onHome: () => void; } } + async function saveModelSettings(selection: ModelSelection | null) { + if (modelSettingsSaving) return; + setModelSettingsSaving(true); + setError(null); + try { + const override = selection && sameModelSelection(selection, defaultModelSelection) ? null : selection; + setModelSelectionOverride(await saveSessionModelSelectionOverride(session.id, override)); + setModelPickerOpen(false); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Wingdiff could not save review model settings."); + } finally { + setModelSettingsSaving(false); + } + } + async function confirmGenerationContext() { if (!contextManifest || contextSaving) return; setContextSaving(true); @@ -819,7 +839,6 @@ function AcquiredReviewApp({ onHome, onSession, session }: { onHome: () => void;
{metadata.repository}{session.target.platform === "gitlab" ? "!" : "#"}{metadata.number}{metadata.title}
- {hostName} @@ -828,16 +847,16 @@ function AcquiredReviewApp({ onHome, onSession, session }: { onHome: () => void; openView("browse")} onReview={() => openView("review")} onSelect={selectStop} onSummary={() => openView("summary")} statuses={statuses} stops={stops} />
comment.stopId === activeStop.id).length} headSha={metadata.head.sha} onAsk={(prompt) => { setDrawerOpen(true); if (prompt) void askQuestion(prompt); }} onComment={() => openComment(false)} onEvidence={setActiveEvidenceId} onFindingComment={() => openComment(true)} onFlag={toggleFlag} onNavigate={navigateStop} onSelectLine={selectLine} onUnderstood={markUnderstood} selection={selection} status={statuses[activeStop.id] ?? "unseen"} stop={activeStop} totalStops={stops.length} />
:
- {view === "summary" ? selectStop(activeIndex)} onBrowse={() => openView("browse")} onCheckUpdates={() => void checkForUpdates()} onComplete={() => void completeReview()} onGenerate={() => void previewGenerationContext()} onModel={() => setModelPickerOpen(true)} onScope={selectScope} onSelectStop={selectStop} refreshing={refreshing} reviewScope={reviewScope} scopedEvidence={scopedEvidence} session={session} statuses={statuses} stops={stops} tourLoading={tourLoading} update={update} /> : view === "browse" ? tourLoading ? null : replaceReviewLocation(scoped("browse", { file }))} key={reviewScope} onSummary={() => openView("summary")} scope={reviewScope} session={session} /> : void allowPublicationRetry()} onBack={() => back(scoped(generated ? "tour" : "summary", { stop: activeStop?.id }))} onDisposition={(event) => { setDisposition(event); void persistReviewDraft(reviewSummary, event); }} onPublish={(acknowledged) => void publishReviewToCodeHost(acknowledged)} onRemoveComment={(id) => void removeComment(id)} onSave={() => void persistReviewDraft()} onSummary={setReviewSummary} publication={publication} publishError={publicationError} publishing={publishingReview} saving={savingReview} statuses={statuses} stops={stops} submission={submission} summary={reviewSummary} />} + {view === "summary" ? selectStop(activeIndex)} onBrowse={() => openView("browse")} onCheckUpdates={() => void checkForUpdates()} onComplete={() => void completeReview()} onGenerate={() => void previewGenerationContext()} onModel={() => setModelPickerOpen(true)} onScope={selectScope} onSelectStop={selectStop} refreshing={refreshing} reviewScope={reviewScope} scopedEvidence={scopedEvidence} session={session} statuses={statuses} stops={stops} tourLoading={tourLoading} update={update} /> : view === "browse" ? tourLoading ? null : replaceReviewLocation(scoped("browse", { file }))} key={reviewScope} onSummary={() => openView("summary")} scope={reviewScope} session={session} /> : void allowPublicationRetry()} onBack={() => back(scoped(generated ? "tour" : "summary", { stop: activeStop?.id }))} onDisposition={(event) => { setDisposition(event); void persistReviewDraft(reviewSummary, event); }} onPublish={(acknowledged) => void publishReviewToCodeHost(acknowledged)} onRemoveComment={(id) => void removeComment(id)} onSave={() => void persistReviewDraft()} onSummary={setReviewSummary} publication={publication} publishError={publicationError} publishing={publishingReview} saving={savingReview} statuses={statuses} stops={stops} submission={submission} summary={reviewSummary} />}
} - {modelPickerOpen && setModelPickerOpen(false)} onSelection={setModelSelection} providers={providers} selection={modelSelection} />} + {modelPickerOpen && void saveModelSettings(selection)} onClose={() => setModelPickerOpen(false)} overrideSelection={modelSelectionOverride} providers={providers} saving={modelSettingsSaving} />} {contextPreviewOpen && contextManifest && activeProvider && setContextPreviewOpen(false)} onConfirm={() => void confirmGenerationContext()} onExclusions={setExclusionText} provider={activeProvider} saving={contextSaving} />} {drawerOpen && activeStop && activeEvidence && } {composer && setComposer(null)} onChange={(body) => setComposer((current) => current ? { ...current, body } : null)} onSeverity={(severity) => setComposer((current) => current ? { ...current, severity } : null)} onStage={stageComment} />} ; } -function AcquiredSummary({ activeModel, baselineCheckpoint, checkpoint, completing, error, generated, generating, generationFailed, generationStatus, modelReady, notice, onBegin, onBrowse, onCheckUpdates, onComplete, onGenerate, onModel, onScope, onSelectStop, refreshing, reviewScope, scopedEvidence, session, statuses, stops, tourLoading, update }: { +function AcquiredSummary({ activeModel, baselineCheckpoint, checkpoint, completing, error, generated, generating, generationFailed, generationStatus, hasModelOverride, modelReady, notice, onBegin, onBrowse, onCheckUpdates, onComplete, onGenerate, onModel, onScope, onSelectStop, refreshing, reviewScope, scopedEvidence, session, statuses, stops, tourLoading, update }: { activeModel: string; baselineCheckpoint: ReviewCheckpoint | null; checkpoint: ReviewCheckpoint | null; @@ -847,6 +866,7 @@ function AcquiredSummary({ activeModel, baselineCheckpoint, checkpoint, completi generating: boolean; generationFailed: boolean; generationStatus: TourGenerationStatus | null; + hasModelOverride: boolean; modelReady: boolean; notice: string | null; onBegin: () => void; @@ -897,10 +917,10 @@ function AcquiredSummary({ activeModel, baselineCheckpoint, checkpoint, completi
{stops.length} review stop{stops.length === 1 ? "" : "s"}{stops.reduce((total, stop) => total + stop.minutes, 0)} min · pinned to {generated.headSha.slice(0, 7)}{generated.contextFingerprint ? ` · context ${generated.contextFingerprint.slice(0, 10)}` : ""}
{checkpoint ? `Reviewed at ${checkpoint.reviewedHeadSha.slice(0, 7)}` : allReviewed ? "Ready to track future updates" : `${stops.length - reviewedStops} stop${stops.length - reviewedStops === 1 ? "" : "s"} remaining`}{checkpoint ? "Check whether the author has pushed anything new." : allReviewed ? "Save this head as the baseline for “Since your review.”" : "Review every stop before saving an update checkpoint."}
{checkpoint ? : }
: <> -
Evidence ready

The change is pinned and ready for a guided review

{tourLoading ? "Checking" : "Not analyzed"}
{session.evidence.files.length} changed file{session.evidence.files.length === 1 ? "" : "s"} passed anchor validation.Generate a semantic route with {activeModel}, or inspect the diff directly.
+
Evidence ready

The change is pinned and ready for a guided review

{tourLoading ? "Checking" : "Not analyzed"}
{session.evidence.files.length} changed file{session.evidence.files.length === 1 ? "" : "s"} passed anchor validation.Generate a semantic route, or inspect the diff directly.
{metadata.body &&
Author description
} {error &&
{error}{generationFailed && }
} -
{generating ? `Building ${generationStatus?.scope === "update" ? "update" : "guided"} tour` : modelReady ? activeModel : "Choose a configured model"}{generating && generationStatus?.state === "running" ? `${formatElapsedTime(generationStatus.elapsedMs)} elapsed${generationStatus.timeoutMs ? ` · ${formatElapsedTime(generationStatus.timeoutMs)} limit` : ""} · status checked every second` : "Analysis stays inside the local Wingdiff process"}
+
{generating ? `Building ${generationStatus?.scope === "update" ? "update" : "guided"} tour` : modelReady ? "Ready to build your guided review" : "Model setup required"}{generating && generationStatus?.state === "running" ? `${formatElapsedTime(generationStatus.elapsedMs)} elapsed${generationStatus.timeoutMs ? ` · ${formatElapsedTime(generationStatus.timeoutMs)} limit` : ""} · status checked every second` : <>{hasModelOverride ? "Override" : "Default"} · {activeModel} }
} {notice &&
{notice}
} {generated && error &&
{error}
} @@ -1070,7 +1090,8 @@ function ReviewApp({ onHome }: { onHome: () => void }) { const [question, setQuestion] = useState(""); const [answering, setAnswering] = useState(false); const [providers, setProviders] = useState(FALLBACK_PROVIDERS); - const [modelSelection, setModelSelection] = usePersistentState("wingdiff:model", DEFAULT_SELECTION, restoreModelSelection); + const [defaultModelSelection, setDefaultModelSelection] = useState(DEFAULT_SELECTION); + const [modelSelectionOverride, setModelSelectionOverride] = useState(null); const [modelPickerOpen, setModelPickerOpen] = useState(false); const [theme, setTheme] = usePersistentState<"dark" | "light">("wingdiff:theme", "dark"); const [reviewSummary, setReviewSummary] = useState( @@ -1089,6 +1110,7 @@ function ReviewApp({ onHome }: { onHome: () => void }) { const understoodCount = tourStops.filter((stop) => statuses[stop.id] === "understood").length; const activeEvidence = activeStop.evidence.find((item) => item.id === activeEvidenceId) ?? activeStop.evidence[0]!; const stopNotebook = notebook.filter((entry) => entry.stopId === activeStop.id); + const modelSelection = modelSelectionOverride ?? defaultModelSelection; const activeModel = selectedModel(providers, modelSelection); const activeProvider = providers.find((provider) => provider.id === modelSelection.provider); const activeModelLabel = activeProvider ? `${activeProvider.name} · ${activeModel.name}` : activeModel.name; @@ -1099,9 +1121,9 @@ function ReviewApp({ onHome }: { onHome: () => void }) { useEffect(() => { const controller = new AbortController(); - fetchProviders(controller.signal).then((availableProviders) => { - setProviders(availableProviders); - setModelSelection((current) => preferredAvailableSelection(availableProviders, current)); + fetchProviders(controller.signal).then((catalog) => { + setProviders(catalog.providers); + setDefaultModelSelection(catalog.defaultSelection); }).catch(() => undefined); return () => controller.abort(); }, []); @@ -1391,10 +1413,13 @@ function ReviewApp({ onHome }: { onHome: () => void }) { {modelPickerOpen && ( { setModelSelectionOverride(selection && !sameModelSelection(selection, defaultModelSelection) ? selection : null); setModelPickerOpen(false); }} onClose={() => setModelPickerOpen(false)} - onSelection={setModelSelection} + overrideSelection={modelSelectionOverride} providers={providers} - selection={modelSelection} + saving={false} /> )} @@ -1558,22 +1583,34 @@ function ContextPreview({ error, exclusions, manifest, modelName, onCancel, onCo ; } -function ModelPicker({ onClose, onSelection, providers, selection }: { onClose: () => void; onSelection: (selection: ModelSelection) => void; providers: ProviderDefinition[]; selection: ModelSelection }) { +function ModelPicker({ defaultSelection, error, onApply, onClose, overrideSelection, providers, saving }: { + defaultSelection: ModelSelection; + error: string | null; + onApply: (selection: ModelSelection | null) => void; + onClose: () => void; + overrideSelection: ModelSelection | null; + providers: ProviderDefinition[]; + saving: boolean; +}) { + const [selection, setSelection] = useState(overrideSelection ?? defaultSelection); const provider = providers.find((candidate) => candidate.id === selection.provider) ?? providers[0]!; const active = provider.models.find((model) => model.id === selection.model) ?? provider.models[0]!; + const defaultLabel = modelSelectionLabel(providers, defaultSelection); function chooseProvider(next: ProviderDefinition) { const first = next.models[0]!; - onSelection({ provider: next.id, model: first.id, reasoningEffort: first.defaultEffort }); + setSelection({ provider: next.id, model: first.id, reasoningEffort: first.defaultEffort }); } - return
-
AI provider

Choose your review copilot.

Codex CLI uses your existing local sign-in. Direct API providers remain optional, and credentials never enter browser JavaScript.

+ return
+
Review settings

Override the model for this review.

New reviews use the configured default automatically. This override stays with only the current review.

+
Configured default{defaultLabel}
{!overrideSelection && In use}
+ {error &&
{error}
}
{providers.map((candidate) => )}
-
{provider.models.map((model) => )}
-
Reasoning effortHigher effort can improve difficult reviews with more latency and token usage.
{active.reasoningEfforts.map((effort) => )}
+
{provider.models.map((model) => )}
+
Reasoning effortHigher effort can improve difficult reviews with more latency and token usage.
{active.reasoningEfforts.map((effort) => )}
{!provider.configured &&
{provider.name} is not ready

{provider.setupDescription} Run {provider.setupCommand}, then restart Wingdiff.

} -
Selection saved locally
+
; } @@ -1623,14 +1660,26 @@ function AuthorMarkdown({ fallback, source }: { fallback?: string; source: strin return Formatting description…

}>
; } -function restoreModelSelection(stored: ModelSelection) { return normalizeSelection(FALLBACK_PROVIDERS, stored); } - function usePersistentState(key: string, fallback: T, restore: (stored: T) => T = (stored) => stored) { const [value, setValue] = useState(() => { try { const stored = window.localStorage.getItem(key); return stored ? restore(JSON.parse(stored) as T) : fallback; } catch { return fallback; } }); useEffect(() => { try { window.localStorage.setItem(key, JSON.stringify(value)); } catch { /* storage can be unavailable */ } }, [key, value]); return [value, setValue] as const; } +function sameModelSelection(left: ModelSelection, right: ModelSelection) { + return left.provider === right.provider && left.model === right.model && left.reasoningEffort === right.reasoningEffort; +} + +function modelSelectionLabel(providers: ProviderDefinition[], selection: ModelSelection) { + const provider = providers.find((candidate) => candidate.id === selection.provider); + const model = selectedModel(providers, selection); + return `${provider?.name ?? model.family} · ${model.name} · ${reasoningEffortLabel(selection.reasoningEffort)}`; +} + +function reasoningEffortLabel(effort: ModelSelection["reasoningEffort"]) { + return effort === "xhigh" ? "Extra high" : effort.charAt(0).toUpperCase() + effort.slice(1); +} + function shortTitle(title: string) { return title.replace(/^The /, "").replace(/^Every /, "").replace(/^Delay /, ""); } function fileName(path: string) { return path.split("/").at(-1) ?? path; } function directoryName(path: string) { const pieces = path.split("/"); pieces.pop(); return pieces.length ? `${pieces.join("/")}/` : ""; } diff --git a/apps/web/src/ai.test.ts b/apps/web/src/ai.test.ts index e72d762..5db3a35 100644 --- a/apps/web/src/ai.test.ts +++ b/apps/web/src/ai.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_SELECTION, FALLBACK_PROVIDERS, normalizeSelection, preferredAvailableSelection, selectedModel } from "./ai"; -import type { ModelSelection } from "./types"; +import { DEFAULT_SELECTION, FALLBACK_PROVIDERS, selectedModel } from "./ai"; describe("AI model selection", () => { it("lets the local Codex CLI choose the account's supported default", () => { @@ -19,42 +18,4 @@ describe("AI model selection", () => { expect(selectedModel(FALLBACK_PROVIDERS, { provider: "openai", model: "gpt-6-sol", reasoningEffort: "medium" }).provider).toBe("openai"); }); - it("migrates a saved named Codex selection to the account default", () => { - const providers = FALLBACK_PROVIDERS.map((provider) => ({ ...provider, configured: provider.id === "codex" })); - expect(preferredAvailableSelection(providers, { - provider: "codex", - model: "gpt-6-sol", - reasoningEffort: "medium", - })).toEqual(DEFAULT_SELECTION); - }); - - it("migrates a saved named Codex selection even when no provider is configured", () => { - const saved: ModelSelection = { provider: "codex", model: "gpt-5.3-codex", reasoningEffort: "xhigh" }; - expect(preferredAvailableSelection(FALLBACK_PROVIDERS, saved)).toEqual(DEFAULT_SELECTION); - expect(normalizeSelection(FALLBACK_PROVIDERS, saved)).toEqual(DEFAULT_SELECTION); - }); - - it("stays on the current provider when its saved model is no longer offered", () => { - const providers = FALLBACK_PROVIDERS.map((provider) => ({ ...provider, configured: true })); - expect(preferredAvailableSelection(providers, { provider: "openai", model: "gpt-4-retired", reasoningEffort: "medium" })) - .toEqual({ provider: "openai", model: "gpt-6-sol", reasoningEffort: "medium" }); - }); - - it("resets a reasoning effort the saved model does not support", () => { - const providers = FALLBACK_PROVIDERS.map((provider) => ({ ...provider, configured: true })); - expect(preferredAvailableSelection(providers, { provider: "openai", model: "gpt-6-astra", reasoningEffort: "none" })) - .toEqual({ provider: "openai", model: "gpt-6-astra", reasoningEffort: "high" }); - }); - - it("keeps a valid selection untouched and replaces an unreadable one", () => { - const saved: ModelSelection = { provider: "openai", model: "gpt-6-luna", reasoningEffort: "low" }; - expect(normalizeSelection(FALLBACK_PROVIDERS, saved)).toBe(saved); - expect(normalizeSelection(FALLBACK_PROVIDERS, null as unknown as ModelSelection)).toEqual(DEFAULT_SELECTION); - }); - - it("moves to a configured provider when the saved one is not set up", () => { - const providers = FALLBACK_PROVIDERS.map((provider) => ({ ...provider, configured: provider.id === "anthropic" })); - expect(preferredAvailableSelection(providers, DEFAULT_SELECTION)) - .toEqual({ provider: "anthropic", model: "claude-sonnet-4-6", reasoningEffort: "medium" }); - }); }); diff --git a/apps/web/src/ai.ts b/apps/web/src/ai.ts index eb88dd4..7bcf2d6 100644 --- a/apps/web/src/ai.ts +++ b/apps/web/src/ai.ts @@ -54,11 +54,15 @@ export const FALLBACK_PROVIDERS: ProviderDefinition[] = [ }, ]; -export async function fetchProviders(signal?: AbortSignal): Promise { +export interface ProviderCatalog { + providers: ProviderDefinition[]; + defaultSelection: ModelSelection; +} + +export async function fetchProviders(signal?: AbortSignal): Promise { const response = await fetch("/api/providers", { signal }); if (!response.ok) throw new Error("Could not load AI providers."); - const body = await response.json() as { providers: ProviderDefinition[] }; - return body.providers; + return response.json() as Promise; } export async function streamInvestigation({ @@ -160,23 +164,6 @@ export function selectedModel(providers: ProviderDefinition[], selection: ModelS ?? FALLBACK_PROVIDERS[0]!.models[0]!; } -export function normalizeSelection(providers: ProviderDefinition[], current: ModelSelection): ModelSelection { - const provider = providers.find((candidate) => candidate.id === current?.provider); - if (!provider) return DEFAULT_SELECTION; - const model = provider.models.find((candidate) => candidate.id === current.model) ?? provider.models[0]; - if (!model) return current; - if (model.id === current.model && model.reasoningEfforts.includes(current.reasoningEffort)) return current; - return { provider: provider.id, model: model.id, reasoningEffort: model.defaultEffort }; -} - -export function preferredAvailableSelection(providers: ProviderDefinition[], current: ModelSelection): ModelSelection { - const normalized = normalizeSelection(providers, current); - if (providers.some((provider) => provider.id === normalized.provider && provider.configured)) return normalized; - const provider = providers.find((candidate) => candidate.configured); - const model = provider?.models[0]; - return provider && model ? { provider: provider.id, model: model.id, reasoningEffort: model.defaultEffort } : normalized; -} - function model( id: string, name: string, diff --git a/apps/web/src/session.ts b/apps/web/src/session.ts index 48ecaa0..ca469c5 100644 --- a/apps/web/src/session.ts +++ b/apps/web/src/session.ts @@ -258,6 +258,24 @@ export async function fetchReviewSession(id: string, signal?: AbortSignal): Prom return sessionRequest(`/api/sessions/${encodeURIComponent(id)}`, { signal }); } +export async function fetchSessionModelSelectionOverride(id: string, signal?: AbortSignal): Promise { + const response = await fetch(`/api/sessions/${encodeURIComponent(id)}/model-selection`, { signal }); + const body = await response.json() as { override?: ModelSelection | null; error?: string }; + if (!response.ok) throw new Error(body.error ?? "Wingdiff could not load review model settings."); + return body.override ?? null; +} + +export async function saveSessionModelSelectionOverride(id: string, override: ModelSelection | null): Promise { + const response = await fetch(`/api/sessions/${encodeURIComponent(id)}/model-selection`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ override }), + }); + const body = await response.json() as { override?: ModelSelection | null; error?: string }; + if (!response.ok) throw new Error(body.error ?? "Wingdiff could not save review model settings."); + return body.override ?? null; +} + export async function fetchSessionTour(id: string, scope: "full" | "update" = "full", signal?: AbortSignal): Promise { const response = await fetch(`/api/sessions/${encodeURIComponent(id)}/tour?scope=${scope}`, { signal }); if (response.status === 404 || response.status === 409) return null; diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 393cba8..20fb8a6 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -320,6 +320,7 @@ code, kbd { font-family: var(--mono); } .risk-level--low { color: var(--blue); background: var(--blue-soft); } .begin-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; margin-top: 14px; padding: 16px 18px; gap: 20px; border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border)); border-radius: 12px; background: linear-gradient(105deg, var(--accent-soft), transparent 52%), var(--surface); } .begin-card > div:first-child strong, .begin-card > div:first-child span { display: block; }.begin-card > div:first-child strong { font-size: 11px; }.begin-card > div:first-child span { margin-top: 3px; color: var(--text-4); font-size: 9px; } +.model-change { margin-left: 5px; padding: 0; color: var(--accent); border: 0; background: transparent; cursor: pointer; font: inherit; font-weight: 700; }.model-change:hover { color: var(--accent-strong); text-decoration: underline; }.model-change:disabled { cursor: default; opacity: .6; text-decoration: none; } .begin-card__route { display: flex; align-items: center; color: var(--text-3); font-family: var(--mono); font-size: 9px; } .begin-card__route span { display: grid; width: 31px; height: 31px; place-items: center; border: 1px solid var(--border-strong); border-radius: 50%; background: var(--surface-2); } .begin-card__route i { width: 39px; height: 1px; background: linear-gradient(90deg, var(--accent), var(--border)); } @@ -573,6 +574,8 @@ code, kbd { font-family: var(--mono); } .model-modal > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; } .model-modal > header h2 { margin: 8px 0 5px; font-size: 24px; font-weight: 500; letter-spacing: -.035em; } .model-modal > header p { max-width: 520px; margin: 0; color: var(--text-3); font-size: 10px; line-height: 1.5; } +.model-default { display: flex; margin-top: 18px; align-items: center; padding: 11px 12px; gap: 9px; border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--border)); border-radius: 10px; background: var(--accent-soft); }.model-default > span { display: grid; width: 27px; height: 27px; flex: 0 0 auto; place-items: center; color: var(--accent); border-radius: 7px; background: var(--surface); }.model-default small, .model-default strong { display: block; }.model-default small { margin-bottom: 2px; color: var(--text-4); font-size: 7px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; }.model-default strong { color: var(--text-2); font-size: 9px; }.model-default em { margin-left: auto; padding: 3px 7px; color: var(--green); border-radius: 6px; background: var(--green-soft); font-size: 7px; font-style: normal; font-weight: 700; text-transform: uppercase; } +.model-settings-error { margin-top: 12px; } .provider-tabs { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); margin: 24px 0 14px; padding: 4px; gap: 4px; border: 1px solid var(--border); border-radius: 11px; background: var(--canvas-soft); } .provider-tabs button { display: flex; min-height: 42px; align-items: center; justify-content: space-between; padding: 8px 11px; color: var(--text-3); border: 1px solid transparent; border-radius: 8px; background: transparent; cursor: pointer; } .provider-tabs button.is-active { color: var(--text); border-color: var(--border); background: var(--surface-2); box-shadow: 0 6px 16px rgba(0,0,0,.08); } @@ -595,7 +598,7 @@ code, kbd { font-family: var(--mono); } .reasoning-setting button.is-active { color: #17141f; background: var(--accent); } .provider-setup { display: flex; margin-top: 12px; padding: 11px 12px; align-items: flex-start; gap: 9px; color: var(--amber); border: 1px solid color-mix(in srgb, var(--amber) 28%, var(--border)); border-radius: 10px; background: var(--amber-soft); } .provider-setup strong { display: block; color: var(--text-2); font-size: 9px; }.provider-setup p { margin: 3px 0 0; color: var(--text-3); font-size: 8px; line-height: 1.45; }.provider-setup code { color: var(--amber); } -.model-modal > footer { display: flex; margin-top: 18px; align-items: center; justify-content: space-between; }.model-modal > footer > span { display: flex; align-items: center; gap: 5px; color: var(--text-4); font-size: 8px; } +.model-modal > footer { display: flex; margin-top: 18px; align-items: center; justify-content: space-between; gap: 10px; }.model-modal > footer > div { display: flex; gap: 8px; } .toast { position: fixed; z-index: 150; bottom: 24px; left: 50%; display: flex; align-items: center; padding: 11px 15px; gap: 8px; color: #eafaf1; border: 1px solid rgba(120,214,168,.35); border-radius: 10px; background: #183a29; box-shadow: var(--shadow); font-size: 10px; transform: translateX(-50%); animation: toast-in 220ms ease-out; } @keyframes toast-in { from { opacity: 0; transform: translate(-50%, 10px); } } diff --git a/e2e-live/reviewer-live.spec.ts b/e2e-live/reviewer-live.spec.ts index 806d465..7b03ca0 100644 --- a/e2e-live/reviewer-live.spec.ts +++ b/e2e-live/reviewer-live.spec.ts @@ -48,8 +48,8 @@ test.describe.serial("local live review", () => { test.setTimeout(3 * 60_000); await openReview(page); - await expect(page.locator(".model-button")).toContainText("Codex CLI"); - await expect(page.locator(".model-button")).toContainText("Account default"); + await expect(page.locator(".begin-card")).toContainText("Default · Codex CLI · Account default"); + await expect(page.locator(".topbar .model-button")).toHaveCount(0); await expect(page.getByRole("button", { name: "Generate guided review" })).toBeVisible(); await expect(page.getByText(/changed files? passed anchor validation/i)).toBeVisible(); }); diff --git a/e2e/reviewer-flow.spec.ts b/e2e/reviewer-flow.spec.ts index 1240a2d..4daea13 100644 --- a/e2e/reviewer-flow.spec.ts +++ b/e2e/reviewer-flow.spec.ts @@ -51,6 +51,32 @@ test("reviews a real-session fixture from privacy preview through an anchored dr await expect(page.getByRole("button", { name: "Check setup" })).toBeVisible(); }); +test("uses the configured default and applies model changes only after confirmation", async ({ page }) => { + await mockReviewApi(page); + await page.goto("/?session=pilot-session"); + + const runCard = page.locator(".begin-card"); + await expect(runCard).toContainText("Default · Codex CLI · Account default"); + await expect(page.locator(".topbar .model-button")).toHaveCount(0); + + await runCard.getByRole("button", { name: "Change" }).click(); + const settings = page.getByRole("dialog", { name: "Review settings" }); + await settings.getByRole("button", { name: /OpenAI API/ }).click(); + await settings.getByRole("button", { name: "Cancel" }).click(); + await expect(runCard).toContainText("Default · Codex CLI · Account default"); + + await runCard.getByRole("button", { name: "Change" }).click(); + await settings.getByRole("button", { name: /OpenAI API/ }).click(); + await settings.getByRole("button", { name: "Apply override" }).click(); + await expect(runCard).toContainText("Override · OpenAI API · GPT-6 Sol"); + + await page.reload(); + await expect(runCard).toContainText("Override · OpenAI API · GPT-6 Sol"); + await runCard.getByRole("button", { name: "Change" }).click(); + await settings.getByRole("button", { name: "Reset to default" }).click(); + await expect(runCard).toContainText("Default · Codex CLI · Account default"); +}); + test("returns to the top when marking a stop understood", async ({ page }) => { await page.goto("/?demo=1"); await page.getByRole("button", { name: "Review updates" }).click(); @@ -198,14 +224,19 @@ async function mockReviewApi(page: Page, options: { failFirstPublish?: boolean; let generationPolls = 0; let investigationId = "investigation-1"; let publishAttempts = 0; + let modelOverride: typeof defaultSelection | null = null; await page.route("**/api/**", async (route) => { const request = route.request(); const url = new URL(request.url()); const path = url.pathname; const method = request.method(); - if (path === "/api/providers") return json(route, { providers }); + if (path === "/api/providers") return json(route, { providers, defaultSelection }); if (path === "/api/sessions/pilot-session") return json(route, { session: reviewSession }); + if (path === "/api/sessions/pilot-session/model-selection") { + if (method === "PUT") modelOverride = request.postDataJSON().override ?? null; + return json(route, { override: modelOverride }); + } if (path === "/api/sessions/pilot-session/update") return json(route, { update: null, baselineCheckpoint: null }); if (path === "/api/sessions/pilot-session/progress") { return json(route, { progress: { activeScope: null, activeStopIds: { full: null, update: null }, scopes: { full: {}, update: {} } } }); @@ -321,4 +352,8 @@ const manifest = { const providers = [{ id: "codex", name: "Codex CLI", configured: true, transport: "cli", setupCommand: "codex login", setupDescription: "Sign in.", models: [{ id: "codex-default", provider: "codex", name: "Account default", family: "Codex", description: "Uses the signed-in account default.", badge: "Recommended", reasoningEfforts: ["medium"], defaultEffort: "medium" }], +}, { + id: "openai", name: "OpenAI API", configured: true, transport: "api", setupCommand: "OPENAI_API_KEY", setupDescription: "Set a key.", + models: [{ id: "gpt-6-sol", provider: "openai", name: "GPT-6 Sol", family: "OpenAI", description: "Balanced review model.", badge: "API", reasoningEfforts: ["medium", "high"], defaultEffort: "medium" }], }]; +const defaultSelection = { provider: "codex", model: "codex-default", reasoningEffort: "medium" } as const;