Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.

Expand Down
31 changes: 31 additions & 0 deletions apps/server/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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());
Expand Down
33 changes: 31 additions & 2 deletions apps/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, TourGenerationJob>();
Expand Down Expand Up @@ -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) => {
Expand Down
17 changes: 16 additions & 1 deletion apps/server/src/providers/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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/);
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/providers/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
Expand Down
15 changes: 14 additions & 1 deletion apps/server/src/sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down
46 changes: 45 additions & 1 deletion apps/server/src/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, unknown>): ReviewSession {
Expand Down
Loading
Loading