diff --git a/README.md b/README.md
index c43baa22..c85fecf6 100644
--- a/README.md
+++ b/README.md
@@ -48,7 +48,7 @@ Codra listens to GitHub pull request events, runs AI-powered review jobs, posts
- Queue-backed processing through Cloudflare Queues
- GitHub OAuth dashboard authentication
- External PostgreSQL storage through Cloudflare Hyperdrive
-- Dashboard-managed LLM providers for OpenAI, OpenRouter, Anthropic, Google, and Cloudflare models
+- Dashboard-managed LLM providers for OpenAI, OpenRouter, Anthropic, Google, NVIDIA, and Cloudflare models
- Repository settings for labels, skipped globs, custom rules, and model routing
## How It Works
@@ -66,7 +66,7 @@ Codra listens to GitHub pull request events, runs AI-powered review jobs, posts
- **Dashboard**: React, Vite, Tailwind CSS, Radix UI, Recharts
- **Data**: PostgreSQL, Cloudflare Hyperdrive, Cloudflare KV
- **Queues**: Cloudflare Queues and Workflows
-- **Models**: OpenAI, OpenRouter, Anthropic, Google, and Cloudflare providers
+- **Models**: OpenAI, OpenRouter, Anthropic, Google, NVIDIA, and Cloudflare providers
- **GitHub**: GitHub App webhooks, checks, reviews, and OAuth
- **Quality**: TypeScript, Zod, Vitest, Playwright browser tests
@@ -77,6 +77,7 @@ The full setup and operations guides live at [codra.run/docs](https://codra.run/
- [Installation guide](https://codra.run/docs/installation)
- [Configuration guide](https://codra.run/docs/configuration)
- [Deploy with Neon](https://codra.run/docs/neon)
+- [Deploy with Railway](https://codra.run/docs/railway)
- [Contributing](CONTRIBUTING.md)
- [Security policy](SECURITY.md)
diff --git a/db/migrations/003_grounding.sql b/db/migrations/003_grounding.sql
index eefba816..766097c8 100644
--- a/db/migrations/003_grounding.sql
+++ b/db/migrations/003_grounding.sql
@@ -185,3 +185,12 @@ ALTER TABLE file_reviews ADD COLUMN IF NOT EXISTS batch_size INTEGER;
CREATE INDEX IF NOT EXISTS file_reviews_batch_size_idx
ON file_reviews (batch_size)
WHERE batch_size > 1;
+
+-- Top level, not inside the $backfill$ block above: that block returns early on databases that
+-- predate consolidation, and this seed has to reach every fresh database.
+INSERT INTO llm_providers (name, api_format, base_url, enabled)
+VALUES ('NVIDIA', 'openai', 'https://integrate.api.nvidia.com/v1', FALSE)
+ON CONFLICT (name) DO UPDATE SET
+ api_format = EXCLUDED.api_format,
+ base_url = EXCLUDED.base_url,
+ updated_at = now();
diff --git a/eslint.config.js b/eslint.config.js
index c18b53fb..bb56b9df 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -50,11 +50,11 @@ export default tseslint.config(
'import-x/no-cycle': 'error',
// 400 lines, counting neither blanks nor comments, so adding an explanation never pushes a file
- // over. Two files carry an explicit override below, each for a stated reason -- a third should
- // be a split, not a third override.
+ // over. One file carries an explicit override below, for a stated reason -- a second should
+ // be a split, not a second override.
'max-lines': ['error', { max: 400, skipBlankLines: true, skipComments: true }],
- // An error, not a warning: the four places whose dependency array is deliberately narrower
+ // An error, not a warning: the three places whose dependency array is deliberately narrower
// than their closure now carry a line-level disable stating why. A new violation should fail.
'react-hooks/exhaustive-deps': 'error',
@@ -107,19 +107,13 @@ export default tseslint.config(
},
},
{
- // The two files still over the limit, each for a stated reason. Both are known work, not
- // permanent carve-outs -- delete the entry rather than raising `max` when either is split.
- //
- // settings.tsx (552): SettingsPage is one component whose JSX reads nearly all of its 19 pieces
- // of state, so its remaining sections do not align with extractable units -- lifting the
- // providers section out means threading ~23 props. The support module, provider row, About
- // section, review section and review-settings hook are already out (1096 -> 612 lines); what
- // is left needs the provider load/CRUD split into a hook first.
+ // The one file still over the limit, for a stated reason. Known work, not a permanent
+ // carve-out -- delete the entry rather than raising `max` when it is split.
//
// test/api/auth.spec.ts (422): the review-settings suites here read-modify-write the same
// singleton `global_settings` row set and race across files once `fileParallelism` is on. See
// the DO-NOT-SPLIT header on the file itself.
- files: ['src/client/pages/settings.tsx', 'test/api/auth.spec.ts'],
+ files: ['test/api/auth.spec.ts'],
rules: {
'max-lines': 'off',
},
diff --git a/scripts/migrate.mjs b/scripts/migrate.mjs
index cc903577..10da3eb2 100644
--- a/scripts/migrate.mjs
+++ b/scripts/migrate.mjs
@@ -107,6 +107,7 @@ async function ensureModelCatalog() {
('Anthropic', 'anthropic', 'https://api.anthropic.com/v1', FALSE),
('OpenRouter', 'openai', 'https://openrouter.ai/api/v1', FALSE),
('xAI', 'openai', 'https://api.x.ai/v1', FALSE),
+ ('NVIDIA', 'openai', 'https://integrate.api.nvidia.com/v1', FALSE),
('Vertex AI', 'vertex', NULL, FALSE)
ON CONFLICT (name) DO UPDATE SET
api_format = EXCLUDED.api_format,
diff --git a/src/client/components/features/account/details-section.tsx b/src/client/components/features/account/details-section.tsx
new file mode 100644
index 00000000..fec9b946
--- /dev/null
+++ b/src/client/components/features/account/details-section.tsx
@@ -0,0 +1,127 @@
+import { useMemo } from 'react';
+import { Mail } from 'lucide-react';
+import { Text } from '@client/components/ui/text';
+import { Select } from '@client/components/ui/select';
+import { Skeleton } from '@client/components/shared/skeleton';
+import { SectionCard } from '@client/components/shared/section-card';
+import {
+ COMMON_TIME_ZONES,
+ DEFAULT_TIME_ZONE,
+ browserTimeZone,
+ formatDateTime,
+ resolvedTimeZone,
+ timeZoneOffsetLabel,
+} from '@client/lib/timezone';
+import type { AccountSettings, AuthSessionUser } from '@shared/api';
+
+import { DetailGroup, RevealOnClick, DetailRow } from './detail-rows';
+
+// No "Automatic" option: defaults to UTC so timestamps read the same for everyone; the browser's own zone is folded into the list.
+function zoneOptions() {
+ const zones = Array.from(new Set([DEFAULT_TIME_ZONE, ...COMMON_TIME_ZONES, browserTimeZone()]))
+ .sort((a, b) => a.localeCompare(b));
+ return zones.map((zone) => {
+ const offset = timeZoneOffsetLabel(zone);
+ return { value: zone, label: offset ? `${zone} · ${offset}` : zone };
+ });
+}
+
+function formatDate(value: string) {
+ return formatDateTime(value, {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ timeZoneName: 'short',
+ });
+}
+
+export function AccountDetailsSection({
+ user,
+ account,
+ pending,
+ displayName,
+ zonePref,
+ onZoneChange,
+}: {
+ user: AuthSessionUser | null;
+ account: AccountSettings | null;
+ /** Render chrome with skeletons in place of content, so the card doesn't reflow when data lands. */
+ pending: boolean;
+ displayName: string;
+ zonePref: string | null;
+ onZoneChange: (zone: string) => void;
+}) {
+ // Built once - a fresh array each render gave Select a new `options` identity, re-firing its highlight/measure effects and jittering the open panel.
+ const zoneOpts = useMemo(() => zoneOptions(), []);
+
+ return (
+
+
+
+
+ {displayName}
+
+
+ {user?.email ? (
+
+
+ {user.email}
+
+ ) : (
+ Not provided
+ )}
+
+
+
+
+
+ @{user?.login}
+
+
+ {user?.githubUserId}
+
+
+
+
+ {(pending || account) && (
+
+ {account?.id}
+
+ )}
+
+ {user ? formatDate(user.signedInAt) : null}
+
+
+ {/* Timestamps are stored absolute (UTC); this only controls how they're rendered. */}
+
+
+
+ Date & time zone
+
+
+ Stored in UTC, shown in {resolvedTimeZone()}
+
+
+ {pending ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/client/components/features/account/profile-card.tsx b/src/client/components/features/account/profile-card.tsx
new file mode 100644
index 00000000..28a098a7
--- /dev/null
+++ b/src/client/components/features/account/profile-card.tsx
@@ -0,0 +1,171 @@
+import { useState } from 'react';
+import { toast } from 'sonner';
+import { api } from '@client/lib/api';
+import { Button, LinkButton } from '@client/components/ui/button';
+import { Input } from '@client/components/ui/input';
+import { Badge } from '@client/components/ui/badge';
+import { Skeleton } from '@client/components/shared/skeleton';
+import { GithubMark } from '@client/components/shared/github-mark';
+import { ExternalLink, Pencil, Check, X } from 'lucide-react';
+import type { AccountSettings, AuthSessionUser } from '@shared/api';
+
+export function ProfileCard({
+ user,
+ pending,
+ displayName,
+ initial,
+ profileUrl,
+ onAccountChange,
+}: {
+ user: AuthSessionUser | null;
+ /** Render chrome with skeletons in place of content, so the card doesn't reflow when data lands. */
+ pending: boolean;
+ displayName: string;
+ initial: string;
+ profileUrl: string;
+ onAccountChange: (account: AccountSettings) => void;
+}) {
+ const [editingName, setEditingName] = useState(false);
+ const [nameDraft, setNameDraft] = useState('');
+ const [savingName, setSavingName] = useState(false);
+
+ const startEditName = () => {
+ setNameDraft(displayName);
+ setEditingName(true);
+ };
+
+ const saveName = async () => {
+ const trimmed = nameDraft.trim();
+ if (!trimmed) {
+ toast.error('Name cannot be empty.');
+ return;
+ }
+ setSavingName(true);
+ try {
+ const res = await api.updateAccountName(trimmed);
+ onAccountChange(res.account);
+ setEditingName(false);
+ toast.success('Account name updated');
+ } catch (e) {
+ toast.error('Could not update name', {
+ description: e instanceof Error ? e.message : undefined,
+ });
+ } finally {
+ setSavingName(false);
+ }
+ };
+
+ return (
+
+
+ {pending ? (
+
+ ) : user!.avatarUrl ? (
+
+ ) : (
+
+ {initial}
+
+ )}
+
+
+ {pending ? (
+
+
+
+
+ ) : editingName ? (
+
+
+
setNameDraft(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') void saveName();
+ if (e.key === 'Escape') setEditingName(false);
+ }}
+ autoFocus
+ maxLength={120}
+ aria-label="Account name"
+ className="h-9 w-full max-w-xs"
+ />
+
+ }
+ >
+ Save
+
+ setEditingName(false)}
+ disabled={savingName}
+ icon={ }
+ className="text-ui-subtle hover:text-ui-default"
+ >
+ Cancel
+
+
+
+
+ Enter to save · Esc to cancel - this name is used across Codra.
+
+
+ ) : (
+ <>
+
+
+ {displayName}
+
+
+
+
+
+
+
+ @{user!.login}
+
+
+
+ GitHub
+
+
+ >
+ )}
+
+
+ {pending ? (
+
+ ) : (
+
}
+ className="shrink-0 self-start sm:self-auto"
+ >
+ View on GitHub
+
+
+ )}
+
+
+ );
+}
diff --git a/src/client/components/features/job-detail/comment-card.tsx b/src/client/components/features/job-detail/comment-card.tsx
index 42c9c17b..fa8faccd 100644
--- a/src/client/components/features/job-detail/comment-card.tsx
+++ b/src/client/components/features/job-detail/comment-card.tsx
@@ -8,6 +8,7 @@ import { CopyButton } from '@client/components/shared/copy-button';
import { preventToggleOnTextSelection } from '@client/lib/selection';
import type { ParsedReviewComment } from '@shared/schema';
import { severityConfig } from './constants';
+import { ContextSnippet } from './context-snippet';
import { safeRehypePlugins } from '@client/lib/markdown-plugins';
/** Plain-English reason a finding never reached the pull request. */
@@ -173,12 +174,7 @@ export function CommentCard({ comment, filePath, jobId }: CommentCardProps) {
-
- {comment.contextSnippet}
-
+
)}
diff --git a/src/client/components/features/job-detail/context-snippet.tsx b/src/client/components/features/job-detail/context-snippet.tsx
new file mode 100644
index 00000000..d3c5c051
--- /dev/null
+++ b/src/client/components/features/job-detail/context-snippet.tsx
@@ -0,0 +1,65 @@
+import { useMemo } from 'react';
+import { highlightLine, langForPath } from '@client/lib/highlight';
+import { cn } from '@client/lib/utils';
+import { ROW_TONES } from './diff-file-panel-utils';
+
+/**
+ * The stored diff context of a finding, rendered like the diff viewer: gutter, +/- marker and
+ * syntax-highlighted code. Lines arrive from renderDiffSnippet as `%4d `.
+ */
+
+const SNIPPET_LINE = /^(\s*\d*) ([+\- ])(.*)$/;
+
+type SnippetRow = { gutter: string; kind: 'add' | 'del' | 'ctx'; text: string };
+
+function parseSnippet(snippet: string): SnippetRow[] {
+ return snippet.split('\n').map((line) => {
+ const match = SNIPPET_LINE.exec(line);
+ // Anything that doesn't fit the server's shape renders verbatim as a context line.
+ if (!match) return { gutter: '', kind: 'ctx' as const, text: line };
+ const [, gutter, prefix, text] = match;
+ return {
+ gutter: gutter.trim(),
+ kind: prefix === '+' ? ('add' as const) : prefix === '-' ? ('del' as const) : ('ctx' as const),
+ text,
+ };
+ });
+}
+
+export function ContextSnippet({ snippet, filePath }: { snippet: string; filePath: string }) {
+ const lang = useMemo(() => langForPath(filePath), [filePath]);
+ const rows = useMemo(() => parseSnippet(snippet), [snippet]);
+
+ return (
+
+
+ {rows.map((row, i) => {
+ const tone = ROW_TONES[row.kind];
+ return (
+
+
+ {row.gutter}
+
+
+ {row.kind === 'add' ? '+' : row.kind === 'del' ? '-' : ' '}
+
+
+ {highlightLine(row.text, lang)}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/client/components/features/job-detail/diff-file-panel-utils.ts b/src/client/components/features/job-detail/diff-file-panel-utils.ts
new file mode 100644
index 00000000..ee924c62
--- /dev/null
+++ b/src/client/components/features/job-detail/diff-file-panel-utils.ts
@@ -0,0 +1,47 @@
+import type { CSSProperties } from 'react';
+import type { DiffRow } from '@client/lib/prompt-diff';
+import type { ParsedReviewComment } from '@shared/schema';
+
+export const LARGE_DIFF_ROWS = 300;
+
+// Longer diffs render only the first PREVIEW_ROWS lines behind a "Show full diff" control, so a huge
+// PR never dumps tens of thousands of rows into the DOM. Files with comments are never truncated.
+export const PREVIEW_ROWS = 150;
+
+// Estimated row height, used to size a panel before it first paints.
+export const DIFF_ROW_PX = 20;
+
+// Offscreen panels skip layout/paint; this placeholder height keeps the scrollbar and page height
+// stable instead of the page "growing" as panels come into view.
+export function panelCvStyle(open: boolean, lineEstimate: number): CSSProperties {
+ const body = open ? Math.min(lineEstimate, PREVIEW_ROWS) * DIFF_ROW_PX + 140 : 0;
+ return {
+ contentVisibility: 'auto',
+ containIntrinsicSize: `auto ${46 + body}px`,
+ };
+}
+
+export function fileAnchorId(id: string) {
+ return `diff-file-${id}`;
+}
+
+export const ROW_TONES: Record = {
+ add: { row: 'diff-add', gutter: 'diff-add-fg', marker: 'diff-add-fg' },
+ del: { row: 'diff-del', gutter: 'diff-del-fg', marker: 'diff-del-fg' },
+ ctx: { row: '', gutter: 'text-ui-subtle/60', marker: 'text-transparent' },
+ hunk: { row: 'ui-well', gutter: '', marker: '' },
+};
+
+export const NO_ROWS: DiffRow[] = [];
+
+/** Row identity: the embedded line numbers, plus the text so hunk headers (which have none) differ. */
+export function rowKey(row: DiffRow) {
+ return `${row.kind}:${row.oldNo ?? ''}:${row.newNo ?? ''}:${row.text}`;
+}
+
+// The (path, line, title) triple `fingerprint` hashes, plus the position within the rendered list.
+// The triple alone is not unique: `parsedComments` is the ungated set, so a model that reports the
+// same finding twice on one line keeps both rows, and duplicate React keys drop siblings.
+export function commentKey(comment: ParsedReviewComment, index: number) {
+ return `${comment.path}:${comment.line ?? ''}:${comment.title}#${index}`;
+}
diff --git a/src/client/components/features/job-detail/diff-file-panel.tsx b/src/client/components/features/job-detail/diff-file-panel.tsx
index 9ff39061..6363187b 100644
--- a/src/client/components/features/job-detail/diff-file-panel.tsx
+++ b/src/client/components/features/job-detail/diff-file-panel.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState, type CSSProperties } from 'react';
+import { useMemo, useState } from 'react';
import { Check, ChevronDown } from 'lucide-react';
import { Badge, StatusBadge } from '@client/components/ui/badge';
import { parsePromptDiff, diffStats, type DiffRow } from '@client/lib/prompt-diff';
@@ -6,38 +6,16 @@ import { highlightLine, langForPath } from '@client/lib/highlight';
import { cn } from '@client/lib/utils';
import type { FileReviewRecord, ParsedReviewComment } from '@shared/schema';
import { CommentCard } from './comment-card';
+import {
+ LARGE_DIFF_ROWS,
+ NO_ROWS,
+ PREVIEW_ROWS,
+ ROW_TONES,
+ commentKey,
+ rowKey,
+} from './diff-file-panel-utils';
-export const LARGE_DIFF_ROWS = 300;
-
-// Longer diffs render only the first PREVIEW_ROWS lines behind a "Show full diff" control, so a huge
-// PR never dumps tens of thousands of rows into the DOM. Files with comments are never truncated.
-export const PREVIEW_ROWS = 150;
-
-// Estimated row height, used to size a panel before it first paints.
-export const DIFF_ROW_PX = 20;
-
-// Offscreen panels skip layout/paint; this placeholder height keeps the scrollbar and page height
-// stable instead of the page "growing" as panels come into view.
-export function panelCvStyle(open: boolean, lineEstimate: number): CSSProperties {
- const body = open ? Math.min(lineEstimate, PREVIEW_ROWS) * DIFF_ROW_PX + 140 : 0;
- return {
- contentVisibility: 'auto',
- containIntrinsicSize: `auto ${46 + body}px`,
- };
-}
-
-export function fileAnchorId(id: string) {
- return `diff-file-${id}`;
-}
-
-export const ROW_TONES: Record = {
- add: { row: 'diff-add', gutter: 'diff-add-fg', marker: 'diff-add-fg' },
- del: { row: 'diff-del', gutter: 'diff-del-fg', marker: 'diff-del-fg' },
- ctx: { row: '', gutter: 'text-ui-subtle/60', marker: 'text-transparent' },
- hunk: { row: 'ui-well', gutter: '', marker: '' },
-};
-
-export function DiffLine({ row, lang }: { row: DiffRow; lang: ReturnType }) {
+function DiffLine({ row, lang }: { row: DiffRow; lang: ReturnType }) {
const tone = ROW_TONES[row.kind];
if (row.kind === 'hunk') {
@@ -79,8 +57,6 @@ export interface FileDiffProps {
onToggleViewed: (viewed: boolean) => void;
}
-export const NO_ROWS: DiffRow[] = [];
-
export function FileDiff({ file, open, viewed, diffsLoading = false, onOpenChange, onToggleViewed }: FileDiffProps) {
const lang = useMemo(() => langForPath(file.filePath), [file.filePath]);
// Header stats come from a cheap line scan; the full row parse only happens once the panel opens.
@@ -114,21 +90,23 @@ export function FileDiff({ file, open, viewed, diffsLoading = false, onOpenChang
}
}
+ // Each segment is keyed off its first row / anchor line, so keys survive a re-slice when the
+ // panel expands from preview to full.
const segs: Array<
- | { type: 'rows'; rows: DiffRow[] }
- | { type: 'comments'; comments: ParsedReviewComment[] }
+ | { type: 'rows'; key: string; rows: DiffRow[] }
+ | { type: 'comments'; key: string; comments: ParsedReviewComment[] }
> = [];
let run: DiffRow[] = [];
for (const row of visibleRows) {
run.push(row);
const comments = row.newNo !== null ? byLine.get(row.newNo) : undefined;
if (comments) {
- segs.push({ type: 'rows', rows: run });
- segs.push({ type: 'comments', comments });
+ segs.push({ type: 'rows', key: `rows:${rowKey(run[0])}`, rows: run });
+ segs.push({ type: 'comments', key: `comments:${row.newNo}`, comments });
run = [];
}
}
- if (run.length > 0) segs.push({ type: 'rows', rows: run });
+ if (run.length > 0) segs.push({ type: 'rows', key: `rows:${rowKey(run[0])}`, rows: run });
return { segments: segs, unanchored: rest };
}, [visibleRows, file.parsedComments]);
@@ -204,20 +182,20 @@ export function FileDiff({ file, open, viewed, diffsLoading = false, onOpenChang
) : (
// Each segment scrolls independently, so comment cards stay at panel width instead of
// stretching to the widest code line in a shared scroller.
- segments.map((segment, i) =>
+ segments.map((segment) =>
segment.type === 'rows' ? (
-
+
- {segment.rows.map((row, j) => (
-
+ {segment.rows.map((row) => (
+
))}
) : (
-
+
- {segment.comments.map((comment, j) => (
-
+ {segment.comments.map((comment, i) => (
+
))}
@@ -266,8 +244,8 @@ export function FileDiff({ file, open, viewed, diffsLoading = false, onOpenChang
File-level comments
- {unanchored.map((comment, j) => (
-
+ {unanchored.map((comment, i) => (
+
))}
diff --git a/src/client/components/features/job-detail/file-finding.tsx b/src/client/components/features/job-detail/file-finding.tsx
index 4f6e1ebe..298c43c8 100644
--- a/src/client/components/features/job-detail/file-finding.tsx
+++ b/src/client/components/features/job-detail/file-finding.tsx
@@ -4,7 +4,8 @@ import { ChevronRight } from 'lucide-react';
import type { FileReviewRecord, ParsedReviewComment } from '@shared/schema';
import { CommentCard } from './comment-card';
import { preventToggleOnTextSelection } from '@client/lib/selection';
-import { MonoPath, StatusDot, VerdictPill, statusLabel } from './job-chips';
+import { MonoPath, StatusDot, VerdictPill } from './job-chips';
+import { statusLabel } from './job-chip-utils';
import { safeRehypePlugins } from '@client/lib/markdown-plugins';
interface FileFindingProps {
diff --git a/src/client/components/features/job-detail/job-chip-utils.ts b/src/client/components/features/job-detail/job-chip-utils.ts
new file mode 100644
index 00000000..514db1d2
--- /dev/null
+++ b/src/client/components/features/job-detail/job-chip-utils.ts
@@ -0,0 +1,31 @@
+/**
+ * The non-component half of the row vocabulary in `job-chips.tsx` - formatters and shared class
+ * strings, kept out of the component module so Fast Refresh can preserve chip state.
+ */
+import { formatDateTime } from '@client/lib/timezone';
+
+// Re-exported so the sibling job-detail components get the whole row vocabulary from one place.
+export { formatRelativeDate, statusLabel } from '@client/lib/job-format';
+
+/** Full stamp for `title` tooltips, in the account's display time zone (falling back to UTC). */
+export function formatAbsoluteDate(value: string | Date | null | undefined) {
+ if (!value) return undefined;
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return undefined;
+ // Component options, not dateStyle/timeStyle: Intl throws if a style shorthand is combined
+ // with a component option like `timeZoneName`.
+ return formatDateTime(date, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ timeZoneName: 'short',
+ });
+}
+
+/** One label → value row. Fixed height and hairline dividers echo the table's 48px rhythm. */
+export const DETAIL_ROW =
+ 'flex h-11 items-center justify-between gap-4 border-t border-ui-line first:border-transparent';
+
+export const DETAIL_LABEL = 'shrink-0 text-xs leading-none text-ui-default dark:text-ui-subtle';
diff --git a/src/client/components/features/job-detail/job-chips.tsx b/src/client/components/features/job-detail/job-chips.tsx
index 90f75063..8c854dc7 100644
--- a/src/client/components/features/job-detail/job-chips.tsx
+++ b/src/client/components/features/job-detail/job-chips.tsx
@@ -5,32 +5,11 @@
import { useState, type ReactNode } from 'react';
import { CheckCircle2, MessageSquare, type LucideIcon } from 'lucide-react';
import { cn } from '@client/lib/utils';
-import { formatDateTime } from '@client/lib/timezone';
import { STATUS_DOT, jobDuration, statusLabel } from '@client/lib/job-format';
-// Re-exported so the sibling job-detail components keep importing the row vocabulary from one place.
-export { formatRelativeDate, formatRunDuration, jobDuration, statusLabel } from '@client/lib/job-format';
-
import type { JobDetail, JobSummary } from '@shared/schema';
-/** Full stamp for `title` tooltips, in the account's display time zone (falling back to UTC). */
-export function formatAbsoluteDate(value: string | Date | null | undefined) {
- if (!value) return undefined;
- const date = new Date(value);
- if (Number.isNaN(date.getTime())) return undefined;
- // Component options, not dateStyle/timeStyle: Intl throws if a style shorthand is combined
- // with a component option like `timeZoneName`.
- return formatDateTime(date, {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- timeZoneName: 'short',
- });
-}
-
/** Status dot alone, for rows that render their own label. */
export function StatusDot({ status, className }: { status: string; className?: string }) {
return (
@@ -183,12 +162,6 @@ export function AuthorChip({ login }: { login: string | null }) {
);
}
-/** One label → value row. Fixed height and hairline dividers echo the table's 48px rhythm. */
-export const DETAIL_ROW =
- 'flex h-11 items-center justify-between gap-4 border-t border-ui-line first:border-transparent';
-
-export const DETAIL_LABEL = 'shrink-0 text-xs leading-none text-ui-default dark:text-ui-subtle';
-
export function EmptyValue() {
return
- ;
}
diff --git a/src/client/components/features/job-detail/job-diffs.tsx b/src/client/components/features/job-detail/job-diffs.tsx
index b5a66e6b..4d7403bd 100644
--- a/src/client/components/features/job-detail/job-diffs.tsx
+++ b/src/client/components/features/job-detail/job-diffs.tsx
@@ -14,7 +14,8 @@ import { diffStats } from '@client/lib/prompt-diff';
import { readDiffsCache, writeDiffsCache } from '@client/lib/diffs-cache';
import type { FileReviewRecord, JobDetail } from '@shared/schema';
-import { panelCvStyle, fileAnchorId, FileDiff } from './diff-file-panel';
+import { FileDiff } from './diff-file-panel';
+import { panelCvStyle, fileAnchorId } from './diff-file-panel-utils';
import { FileTree } from './diff-file-tree';
/** A file present in the PR diff with no review row yet (job still running or file skipped) - shown as pending, GitHub-style. */
function syntheticFileReview(jobId: string, filePath: string, diffInput: string): FileReviewRecord {
diff --git a/src/client/components/features/job-detail/job-findings-list.tsx b/src/client/components/features/job-detail/job-findings-list.tsx
index c139c065..6e50bc4a 100644
--- a/src/client/components/features/job-detail/job-findings-list.tsx
+++ b/src/client/components/features/job-detail/job-findings-list.tsx
@@ -6,6 +6,7 @@ import { Tabs, TabsList, TabsTrigger } from '@client/components/motion/tabs';
import { FileFinding } from './file-finding';
import { CommentCard } from './comment-card';
import { severityConfig } from './constants';
+import { commentKey } from './diff-file-panel-utils';
interface JobFindingsListProps {
job: JobDetail;
@@ -123,9 +124,9 @@ export function JobFindingsList({ job }: JobFindingsListProps) {
{reviewSeverities.map((groupName) => {
const comments = job.files.flatMap((f) =>
- f.parsedComments
- .filter((c) => c.severity === groupName)
- .map((c) => ({ ...c, filePath: f.filePath })),
+ f.parsedComments.flatMap((c) =>
+ c.severity === groupName ? [{ ...c, filePath: f.filePath }] : [],
+ ),
);
if (comments.length === 0) return null;
@@ -147,9 +148,9 @@ export function JobFindingsList({ job }: JobFindingsListProps) {
{groupName}
- {comments.map((comment, index) => (
+ {comments.map((comment, i) => (
;
+ label: string;
+ /** In-flight: swaps the icon for a spinner. Also disables unless `disabled` says otherwise. */
+ busy: boolean;
+ disabled?: boolean;
+ variant?: ButtonProps['variant'];
+ className?: string;
+ onClick: () => void;
+}
+
+// Every header action is the same icon-only button whose only state is "in flight", so the busy flag
+// lives here rather than branching the header itself.
+function JobActionButton({
+ icon: Icon,
+ label,
+ busy,
+ disabled,
+ variant = 'secondary',
+ className = 'rounded-[7px]',
+ onClick,
+}: JobActionButtonProps) {
+ return (
+
+ {busy ? : }
+
+ );
+}
+
interface JobHeaderProps {
job: JobDetail;
isRerunning: boolean;
@@ -148,45 +182,30 @@ export function JobHeader({
- setStopOpen(true)}
- title="Stop review"
- aria-label="Stop review"
- >
- {isStopping ? : }
-
+ />
{/* Always restarts the review from the beginning (every file), regardless of the job's current status. */}
-
- {isRerunning ? : }
-
+ />
- setDeleteOpen(true)}
- title="Delete job"
- aria-label="Delete job"
- >
- {isDeleting ? : }
-
+ />
diff --git a/src/client/components/features/job-detail/job-meta-cards.tsx b/src/client/components/features/job-detail/job-meta-cards.tsx
index 028aea1c..794a6d8a 100644
--- a/src/client/components/features/job-detail/job-meta-cards.tsx
+++ b/src/client/components/features/job-detail/job-meta-cards.tsx
@@ -4,16 +4,13 @@ import { Link } from 'react-router-dom';
import { cn, formatPreciseDuration } from '@client/lib/utils';
import type { JobDetail, JobStep } from '@shared/schema';
import {
- DETAIL_LABEL,
- DETAIL_ROW,
EmptyValue,
JobStatusLine,
MetaChip,
StatusDot,
VerdictPill,
- formatAbsoluteDate,
- formatRelativeDate,
} from './job-chips';
+import { DETAIL_LABEL, DETAIL_ROW, formatAbsoluteDate, formatRelativeDate } from './job-chip-utils';
interface JobMetaCardsProps {
job: JobDetail;
@@ -192,7 +189,7 @@ export function JobMetaCards({ job }: JobMetaCardsProps) {
{steps.length === 0 ? (
No steps recorded yet.
) : (
- steps.map((step, idx) =>
)
+ steps.map((step) =>
)
)}
diff --git a/src/client/components/features/job-detail/job-skeleton.tsx b/src/client/components/features/job-detail/job-skeleton.tsx
index d8bc59c3..6721443a 100644
--- a/src/client/components/features/job-detail/job-skeleton.tsx
+++ b/src/client/components/features/job-detail/job-skeleton.tsx
@@ -2,7 +2,7 @@ import { Link } from 'react-router-dom';
import { ChevronRight, ClipboardList, FileDiff, Info, ListChecks } from 'lucide-react';
import { Skeleton } from '@client/components/shared/skeleton';
import { LoadError } from '@client/components/shared/load-error';
-import { DETAIL_LABEL, DETAIL_ROW } from './job-chips';
+import { DETAIL_LABEL, DETAIL_ROW } from './job-chip-utils';
interface JobDetailSkeletonProps {
error: string | null;
diff --git a/src/client/components/features/models/model-chain.tsx b/src/client/components/features/models/model-chain.tsx
index 3e066bcd..33432f96 100644
--- a/src/client/components/features/models/model-chain.tsx
+++ b/src/client/components/features/models/model-chain.tsx
@@ -1,10 +1,9 @@
-import { useState, useMemo, useEffect } from 'react';
+import { useId, useMemo, useState } from 'react';
import { cn } from '@client/lib/utils';
import { Select } from '@client/components/ui/select';
import { Button } from '@client/components/ui/button';
import { Trash2, ListPlus } from 'lucide-react';
-
import type {
ModelDensity,
ModelOption,
@@ -12,25 +11,7 @@ import type {
ModelRouteTier,
ProviderOption,
} from './model-route';
-import {
- EMPTY_MODEL_ROUTE,
- describeModelRoute,
- normalizeModelRoute,
- routesEqual,
-} from './model-route';
-// Re-exported so repos.tsx and settings.tsx keep importing these names from here, where they've always lived.
-export {
- EMPTY_MODEL_ROUTE,
- describeModelRoute,
- normalizeModelRoute,
- routesEqual,
- type ModelDensity,
- type ModelOption,
- type ModelRouteConfig,
- type ModelRouteTier,
- type ProviderOption,
-};
interface ModelSelectorProps {
value: string | null;
onValueChange: (value: string) => void;
@@ -41,7 +22,7 @@ interface ModelSelectorProps {
className?: string;
}
-export function ModelSelector({
+function ModelSelector({
value,
onValueChange,
models,
@@ -50,18 +31,14 @@ export function ModelSelector({
density = 'comfortable',
className,
}: ModelSelectorProps) {
+ // The shown provider follows the selected model, so it stays derived rather than synced. The picked
+ // provider only decides the filter while nothing is selected yet.
+ const [pickedProvider, setPickedProvider] = useState(null);
const currentModel = models.find(m => m.value === value);
- const [provider, setProvider] = useState(currentModel?.providerId ?? providers[0]?.value ?? '');
-
- useEffect(() => {
- const model = models.find(m => m.value === value);
- if (model && model.providerId !== provider) {
- setProvider(model.providerId);
- }
- }, [models, provider, value]);
+ const provider = currentModel?.providerId ?? pickedProvider ?? providers[0]?.value ?? '';
const filteredModels = useMemo(
- () => models.filter(m => m.providerId === provider).map(m => ({ value: m.value, label: m.label })),
+ () => models.flatMap(m => (m.providerId === provider ? [{ value: m.value, label: m.label }] : [])),
[models, provider],
);
@@ -87,7 +64,7 @@ export function ModelSelector({
label={hideLabels ? undefined : 'Provider'}
value={provider}
onValueChange={(nextProvider) => {
- setProvider(nextProvider);
+ setPickedProvider(nextProvider);
const first = models.find(m => m.providerId === nextProvider);
if (first) onValueChange(first.value);
}}
@@ -115,7 +92,7 @@ interface ModelChainProps {
density?: ModelDensity;
}
-export function ModelChain({
+function ModelChain({
primary,
fallbacks,
onChange,
@@ -219,6 +196,7 @@ export function ModelRouteEditor({
density = 'comfortable',
className,
}: ModelRouteEditorProps) {
+ const fieldId = useId();
const tiers = value.size_overrides ?? [];
const updateTier = (index: number, updates: Partial) => {
@@ -305,11 +283,15 @@ export function ModelRouteEditor({
-
+
Max lines
void;
}
-export function RepoModelModal({
+type RepoModelFormProps = Omit
;
+
+// Lives inside `Dialog.Portal`, which unmounts on close, so the draft starts from the repo's stored
+// route on every open instead of being synced back from props.
+function RepoModelForm({
repo,
globalConfig,
modelOptions,
providerOptions,
- open,
- onOpenChange,
onModelApplied,
onModelReset,
-}: RepoModelModalProps) {
- const selectedRepoId = repo ? repoId(repo) : null;
- const globalRouteKey = useMemo(
- () => JSON.stringify(getGlobalRoute(globalConfig)),
- [globalConfig],
+}: RepoModelFormProps) {
+ const [route, setRoute] = useState(
+ () => (repo ? getRepoRoute(repo, globalConfig) : EMPTY_MODEL_ROUTE),
);
- const [route, setRoute] = useState(EMPTY_MODEL_ROUTE);
- const [initialRoute, setInitialRoute] = useState(EMPTY_MODEL_ROUTE);
+ const [initialRoute, setInitialRoute] = useState(route);
const [saving, setSaving] = useState<'apply' | 'reset' | null>(null);
const [error, setError] = useState(null);
- useEffect(() => {
- if (!repo) return;
- const nextRoute = getRepoRoute(repo, globalConfig);
- setRoute(nextRoute);
- setInitialRoute(nextRoute);
- setSaving(null);
- setError(null);
- // Keyed on value identity (id + JSON of the route), not object identity, so a poll returning a
- // structurally identical object doesn't reset the user's unsaved edits.
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [selectedRepoId, globalRouteKey]);
-
const dirty = useMemo(() => !routesEqual(route, initialRoute), [initialRoute, route]);
const hasStoredStrategy = repo ? hasStoredModelStrategy(repo) : false;
@@ -114,6 +101,69 @@ export function RepoModelModal({
}
};
+ return (
+ <>
+
+
+
+
}
+ className="text-ui-subtle hover:text-ui-default"
+ >
+ Use global
+
+
+ }>
+ Cancel
+
+ }
+ >
+ Apply
+
+
+
+ >
+ );
+}
+
+export function RepoModelModal({
+ repo,
+ globalConfig,
+ modelOptions,
+ providerOptions,
+ open,
+ onOpenChange,
+ onModelApplied,
+ onModelReset,
+}: RepoModelModalProps) {
+ // Keyed on the route's value identity, not object identity, so a poll returning a structurally
+ // identical global config doesn't discard the user's unsaved edits. The repo is part of the key
+ // because Base UI keeps the portal mounted for the 150 ms close animation: reopening on a
+ // different repo inside that window would otherwise reuse the previous repo's draft and save it
+ // to the new one.
+ const formKey = useMemo(
+ () => `${repo ? repoId(repo) : 'none'}:${JSON.stringify(getGlobalRoute(globalConfig))}`,
+ [repo, globalConfig],
+ );
+
return (
@@ -135,43 +185,15 @@ export function RepoModelModal({
-
-
-
-
}
- className="text-ui-subtle hover:text-ui-default"
- >
- Use global
-
-
- }>
- Cancel
-
- }
- >
- Apply
-
-
-
+
diff --git a/src/client/components/features/repos/repo-route.ts b/src/client/components/features/repos/repo-route.ts
index e0a1549c..d20fc5b1 100644
--- a/src/client/components/features/repos/repo-route.ts
+++ b/src/client/components/features/repos/repo-route.ts
@@ -1,6 +1,6 @@
import { formatDateTime } from '@client/lib/timezone';
import type { RepoConfig, RepoConfigRecord } from '@shared/schema';
-import { EMPTY_MODEL_ROUTE, normalizeModelRoute, routesEqual, type ModelRouteConfig } from '@client/components/features/models/model-chain';
+import { EMPTY_MODEL_ROUTE, normalizeModelRoute, routesEqual, type ModelRouteConfig } from '@client/components/features/models/model-route';
// Shared by the repos page, its rows and the strategy dialog, so it can't live in any single one.
export type GlobalModelConfig = RepoConfig['model'];
diff --git a/src/client/components/features/repos/repo-row.tsx b/src/client/components/features/repos/repo-row.tsx
index 89611a81..9a1f5541 100644
--- a/src/client/components/features/repos/repo-row.tsx
+++ b/src/client/components/features/repos/repo-row.tsx
@@ -3,7 +3,7 @@ import { Badge } from '@client/components/ui/badge';
import { Switch } from '@client/components/ui/switch';
import { Settings2 } from 'lucide-react';
import type { RepoConfigRecord } from '@shared/schema';
-import { describeModelRoute, type ModelOption, type ModelRouteConfig } from '@client/components/features/models/model-chain';
+import { describeModelRoute, type ModelOption, type ModelRouteConfig } from '@client/components/features/models/model-route';
import { getRepoRoute, hasMeaningfulCustomStrategy, formatLastActivity, type GlobalModelConfig } from './repo-route';
export interface RepoRowProps {
diff --git a/src/client/components/features/reviews/live-review-stepper.tsx b/src/client/components/features/reviews/live-review-stepper.tsx
index 27843b02..faa662b6 100644
--- a/src/client/components/features/reviews/live-review-stepper.tsx
+++ b/src/client/components/features/reviews/live-review-stepper.tsx
@@ -4,6 +4,14 @@ interface LiveReviewStepperProps {
job: JobSummary;
}
+const styles: Record = {
+ running: 'bg-info/10 text-info border-info/20',
+ queued: 'bg-secondary text-muted-foreground border-border/60',
+ done: 'bg-success/10 text-success border-success/20',
+ failed: 'bg-danger/10 text-danger border-danger/20',
+ superseded: 'bg-secondary text-muted-foreground border-border/40',
+};
+
export function LiveReviewStepper({ job }: LiveReviewStepperProps) {
const { status, steps = [] } = job;
@@ -39,14 +47,6 @@ export function LiveReviewStepper({ job }: LiveReviewStepperProps) {
activeLabel = 'Superseded';
}
- const styles: Record = {
- running: 'bg-info/10 text-info border-info/20',
- queued: 'bg-secondary text-muted-foreground border-border/60',
- done: 'bg-success/10 text-success border-success/20',
- failed: 'bg-danger/10 text-danger border-danger/20',
- superseded: 'bg-secondary text-muted-foreground border-border/40',
- };
-
const cls = styles[status] ?? styles.queued;
return (
diff --git a/src/client/components/features/settings/default-models-section.tsx b/src/client/components/features/settings/default-models-section.tsx
new file mode 100644
index 00000000..457061bf
--- /dev/null
+++ b/src/client/components/features/settings/default-models-section.tsx
@@ -0,0 +1,63 @@
+import { useMemo } from 'react';
+import type { ModelConfig } from '@shared/schema';
+import { Skeleton } from '@client/components/shared/skeleton';
+import { ModelRouteEditor } from '@client/components/features/models/model-chain';
+import type {
+ ModelOption,
+ ModelRouteConfig,
+ ProviderOption,
+} from '@client/components/features/models/model-route';
+import type { ProviderDraft } from './settings-support';
+
+export function DefaultModelsSection({
+ loading,
+ providers,
+ configs,
+ globalConfig,
+ setGlobalConfig,
+}: {
+ loading: boolean;
+ providers: ProviderDraft[];
+ configs: ModelConfig[];
+ globalConfig: ModelRouteConfig | null;
+ setGlobalConfig: (value: ModelRouteConfig) => void;
+}) {
+ const providerOptions: ProviderOption[] = useMemo(
+ () => providers.map(provider => ({ value: provider.id, label: provider.name })),
+ [providers],
+ );
+
+ const modelOptions: ModelOption[] = useMemo(
+ () => configs.map(config => ({
+ value: config.modelId,
+ label: `${config.providerName} / ${config.modelName}`,
+ providerId: config.providerId,
+ })),
+ [configs],
+ );
+
+ return (
+
+
+
Default models
+
Used by repos that don't set their own model
+
+
+ {!loading && globalConfig ? (
+
+ ) : (
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/client/components/features/settings/field-label.tsx b/src/client/components/features/settings/field-label.tsx
new file mode 100644
index 00000000..56d167fe
--- /dev/null
+++ b/src/client/components/features/settings/field-label.tsx
@@ -0,0 +1,8 @@
+// Split from settings-support so that module can stay a pure .ts helper for Fast Refresh.
+export function FieldLabel({ htmlFor, id, children }: { htmlFor: string; id?: string; children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/client/components/features/settings/new-provider-form.tsx b/src/client/components/features/settings/new-provider-form.tsx
new file mode 100644
index 00000000..92d2cab4
--- /dev/null
+++ b/src/client/components/features/settings/new-provider-form.tsx
@@ -0,0 +1,114 @@
+import type { Dispatch, SetStateAction } from 'react';
+import { Plus } from 'lucide-react';
+import { Button } from '@client/components/ui/button';
+import { Input } from '@client/components/ui/input';
+import { Select } from '@client/components/ui/select';
+import { FieldLabel } from './field-label';
+import {
+ PROVIDER_PRESETS,
+ apiKeyFieldLabel,
+ providerKeyPlaceholder,
+ type NewProviderDraft,
+} from './settings-support';
+
+// The draft lives in `useProviderSettings`, not here, so toggling this panel closed doesn't throw
+// away a half-typed provider.
+export function NewProviderForm({
+ newProvider,
+ setNewProvider,
+ selectedProviderNameExists,
+ newProviderReady,
+ saving,
+ onCreate,
+ onCancel,
+}: {
+ newProvider: NewProviderDraft;
+ setNewProvider: Dispatch>;
+ selectedProviderNameExists: boolean;
+ newProviderReady: boolean;
+ saving: string | null;
+ onCreate: () => void | Promise;
+ onCancel: () => void;
+}) {
+ const selectedPreset = PROVIDER_PRESETS.find(preset => preset.value === newProvider.preset) ?? PROVIDER_PRESETS[0];
+
+ return (
+
+
+ New provider
+
+
+
+ Protocol
+ {
+ const preset = PROVIDER_PRESETS.find(item => item.value === value) ?? PROVIDER_PRESETS[0];
+ setNewProvider(current => ({
+ ...current,
+ preset: preset.value,
+ name: preset.name,
+ apiFormat: preset.apiFormat,
+ baseUrl: preset.baseUrl,
+ }));
+ }}
+ options={PROVIDER_PRESETS.map(preset => ({ value: preset.value, label: preset.label }))}
+ />
+
+
+
Display name
+
setNewProvider(current => ({ ...current, name: e.target.value }))}
+ />
+ {selectedProviderNameExists && (
+
{newProvider.name.trim()} already exists
+ )}
+
+
+ Base URL
+ setNewProvider(current => ({ ...current, baseUrl: e.target.value }))}
+ />
+
+
+ {apiKeyFieldLabel(newProvider.apiFormat)}
+ setNewProvider(current => ({ ...current, apiKey: e.target.value }))}
+ />
+
+
+
+
+ Cancel
+
+ }
+ >
+ Create
+
+
+
+ );
+}
diff --git a/src/client/components/features/settings/provider-list.tsx b/src/client/components/features/settings/provider-list.tsx
new file mode 100644
index 00000000..db1ceeb9
--- /dev/null
+++ b/src/client/components/features/settings/provider-list.tsx
@@ -0,0 +1,81 @@
+import { toast } from 'sonner';
+import type { LlmProvider } from '@shared/schema';
+import { Skeleton } from '@client/components/shared/skeleton';
+import { ProviderRow } from './provider-row';
+import type { ProviderDraft } from './settings-support';
+
+const SKELETON_ROWS = ['first', 'second', 'third'];
+
+export function ProviderList({
+ loading,
+ addingProvider,
+ providers,
+ savedProviders,
+ providerModelCounts,
+ expandedProviderId,
+ setExpandedProviderId,
+ updateProviderDraft,
+ saveProvider,
+ removeProvider,
+ clearProviderKey,
+ saving,
+}: {
+ loading: boolean;
+ addingProvider: boolean;
+ providers: ProviderDraft[];
+ savedProviders: LlmProvider[];
+ providerModelCounts: Map;
+ expandedProviderId: string | null;
+ setExpandedProviderId: (id: string | null) => void;
+ updateProviderDraft: (id: string, updates: Partial) => void;
+ saveProvider: (provider: ProviderDraft) => void | Promise;
+ removeProvider: (id: string) => void | Promise;
+ clearProviderKey: (provider: ProviderDraft) => void | Promise;
+ saving: string | null;
+}) {
+ if (loading) {
+ return (
+
+ {SKELETON_ROWS.map(row => (
+
+ ))}
+
+ );
+ }
+
+ if (providers.length === 0 && !addingProvider) {
+ return (
+
+
No providers yet
+
Add one to start routing models.
+
+ );
+ }
+
+ return (
+
+ {providers.map(provider => (
+
+ ))}
+
+ );
+}
diff --git a/src/client/components/features/settings/provider-row.tsx b/src/client/components/features/settings/provider-row.tsx
index f8481e8d..7fb18743 100644
--- a/src/client/components/features/settings/provider-row.tsx
+++ b/src/client/components/features/settings/provider-row.tsx
@@ -6,9 +6,9 @@ import { Switch } from '@client/components/ui/switch';
import { Badge } from '@client/components/ui/badge';
import { cn } from '@client/lib/utils';
import type { LlmApiFormat, LlmProvider } from '@shared/schema';
+import { FieldLabel } from './field-label';
import {
API_FORMAT_OPTIONS,
- FieldLabel,
apiKeyFieldLabel,
domId,
isCustomProvider,
diff --git a/src/client/components/features/settings/review-section.tsx b/src/client/components/features/settings/review-section.tsx
index e7bf2a5a..84e677d5 100644
--- a/src/client/components/features/settings/review-section.tsx
+++ b/src/client/components/features/settings/review-section.tsx
@@ -5,12 +5,12 @@ import { SteppedSlider } from '@client/components/motion/stepped-slider';
import { ConfirmDialog } from '@client/components/ui/confirm-dialog';
import type { ReviewSettings } from '@shared/schema';
import { REVIEW_CONCURRENCY_LIMITS, reviewMaxFilesRange } from '@shared/review-limits';
+import { FieldLabel } from './field-label';
import {
CONCURRENCY_LEVEL_LABEL,
CONCURRENCY_MAX_VALUE,
CONCURRENCY_STEPS,
CONCURRENCY_VALUE_TO_LEVEL,
- FieldLabel,
MAX_COMMENTS_CEILING,
MAX_COMMENTS_STEPS,
} from './settings-support';
diff --git a/src/client/components/features/settings/settings-support.tsx b/src/client/components/features/settings/settings-support.ts
similarity index 90%
rename from src/client/components/features/settings/settings-support.tsx
rename to src/client/components/features/settings/settings-support.ts
index bdeb9dd8..f1b31ebd 100644
--- a/src/client/components/features/settings/settings-support.tsx
+++ b/src/client/components/features/settings/settings-support.ts
@@ -1,7 +1,7 @@
import type { LlmApiFormat, LlmProvider } from '@shared/schema';
import { REVIEW_CONCURRENCY_LIMITS, reviewMaxCommentsOptions, type ReviewConcurrencyLevel } from '@shared/review-limits';
-// Pure and render-free apart from FieldLabel, so the settings page and its sections can all depend on it without depending on each other.
+// Pure and render-free, so the settings page and its sections can all depend on it without depending on each other.
export const API_FORMAT_OPTIONS: Array<{ value: LlmApiFormat; label: string }> = [
{ value: 'openai', label: 'OpenAI' },
@@ -18,11 +18,12 @@ export const PROVIDER_PRESETS = [
{ value: 'custom-vertex', label: 'Google Vertex AI', apiFormat: 'vertex' as const, baseUrl: '', name: 'Vertex AI', exampleUrl: 'https://us-central1-aiplatform.googleapis.com/v1/projects/YOUR_PROJECT_ID/locations/us-central1' },
];
-export const FIXED_PROVIDER_NAMES = new Set(['OpenAI', 'OpenRouter', 'Anthropic', 'Google', 'Cloudflare', 'xAI']);
+export const FIXED_PROVIDER_NAMES = new Set(['OpenAI', 'OpenRouter', 'Anthropic', 'Google', 'Cloudflare', 'xAI', 'NVIDIA']);
export function providerKeyPlaceholder(providerName: string, apiFormat: LlmApiFormat) {
if (apiFormat === 'vertex') return '{ "type": "service_account", … }';
if (providerName === 'xAI') return 'xai-…';
+ if (providerName === 'NVIDIA') return 'nvapi-…';
return 'sk-…';
}
@@ -95,11 +96,3 @@ export function providerDraftDirty(provider: ProviderDraft, saved?: LlmProvider)
provider.apiKey.trim().length > 0
);
}
-
-export function FieldLabel({ htmlFor, id, children }: { htmlFor: string; id?: string; children: React.ReactNode }) {
- return (
-
- {children}
-
- );
-}
diff --git a/src/client/components/features/stats/chart-primitives.tsx b/src/client/components/features/stats/chart-primitives.tsx
index 56836cfb..0e57b819 100644
--- a/src/client/components/features/stats/chart-primitives.tsx
+++ b/src/client/components/features/stats/chart-primitives.tsx
@@ -1,56 +1,20 @@
-import { type ReactNode } from 'react';
+import { Children, type ReactNode } from 'react';
import { Activity, Boxes, Coins, FolderGit2, ShieldCheck } from 'lucide-react';
import { LayerCard } from '@client/components/ui/layer-card';
import { Skeleton } from '@client/components/shared/skeleton';
-import { cn, fmtNumber } from '@client/lib/utils';
-import { formatDayLabel } from '@client/lib/timezone';
-
-export const CHART = {
- primary: '#65a30d',
- primaryDark: '#e0fe56',
- blue: '#3b82f6',
- blueDark: '#3b82f6',
- amber: '#d97706',
- amberDark: '#f59e0b',
- danger: '#dc2626',
- dangerDark: '#f87171',
- info: '#0ea5e9',
- infoDark: '#38bdf8',
- quiet: '#94a3b8',
- quietDark: '#64748b',
-};
-
-// Per-row accents for the segmented tick meters (white / orange / cyan / blue / purple rhythm).
-export const TICK_COLORS_DARK = ['#e4e4e7', '#fb923c', '#22d3ee', '#3b82f6', '#a78bfa'];
-
-export const TICK_COLORS_LIGHT = ['#3f3f46', '#ea580c', '#0891b2', '#2563eb', '#7c3aed'];
-
-export const MONO_STACK = "'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
-
-// Rendered verbatim, not re-parsed: parsing these server-bucketed `YYYY-MM-DD` strings in the
-// viewer's local zone used to shift the date by a day for negative UTC offsets.
-export function formatDay(value: string) {
- return formatDayLabel(value);
-}
-
-export function formatCompact(value: number) {
- return value >= 1000 ? fmtNumber(value) : value.toLocaleString();
-}
-
-export function modelName(model: string) {
- return model.split('/').pop()?.replace(/-/g, ' ') ?? model;
-}
+import { cn } from '@client/lib/utils';
+import { formatCompact, formatDayRange } from './chart-support';
export function ChartTooltip({ active, payload, label }: any) {
if (!active || !payload?.length) return null;
+ const endDay: string | undefined = payload[0]?.payload?.endDay;
+ const heading =
+ typeof label === 'string' && label.includes('-') ? formatDayRange(label, endDay) : label;
+
return (
- {label && (
-
- {typeof label === 'string' && label.includes('-') ? formatDay(label) : label}
-
- )}
+ {label &&
{heading}
}
{payload.map((item: any) => (
@@ -69,7 +33,7 @@ export function ChartTooltip({ active, payload, label }: any) {
);
}
-export function CardDots() {
+function CardDots() {
return (
visible;
+
+ return (
+
+ );
+}
+
/** Segmented tick meter (reference "cost allocation" bars). */
export function TickMeter({
label,
@@ -192,7 +181,7 @@ export function TickMeter({
const filled = value > 0 ? Math.max(1, Math.round((value / Math.max(max, 1)) * SEGMENTS)) : 0;
return (
-
+
{label}
@@ -212,7 +201,7 @@ export function TickMeter({
);
}
-export function GraphCardSkeleton({ title, icon, className = '' }: { title: string; icon?: ReactNode; className?: string }) {
+function GraphCardSkeleton({ title, icon, className = '' }: { title: string; icon?: ReactNode; className?: string }) {
return (
@@ -222,11 +211,11 @@ export function GraphCardSkeleton({ title, icon, className = '' }: { title: stri
);
}
-export function GraphBarCardSkeleton({ title, icon, className = '' }: { title: string; icon?: ReactNode; className?: string }) {
+function GraphBarCardSkeleton({ title, icon, rows = 5, className = '' }: { title: string; icon?: ReactNode; rows?: number; className?: string }) {
return (
- {Array.from({ length: 8 }).map((_, i) => (
+ {Array.from({ length: rows }).map((_, i) => (
@@ -247,8 +236,8 @@ export function MetricsGridSkeleton() {
} />
- } />
- } />
+ } rows={4} />
+ } rows={5} />
);
diff --git a/src/client/components/features/stats/chart-support.ts b/src/client/components/features/stats/chart-support.ts
new file mode 100644
index 00000000..06565e7e
--- /dev/null
+++ b/src/client/components/features/stats/chart-support.ts
@@ -0,0 +1,47 @@
+import { fmtNumber } from '@client/lib/utils';
+import { formatDayLabel } from '@client/lib/timezone';
+
+// Pure and render-free, so the chart components and the grid can share it without Fast Refresh
+// losing state on the components next door.
+
+export const CHART = {
+ primary: '#65a30d',
+ primaryDark: '#e0fe56',
+ blue: '#3b82f6',
+ blueDark: '#3b82f6',
+ amber: '#d97706',
+ amberDark: '#f59e0b',
+ danger: '#dc2626',
+ dangerDark: '#f87171',
+ info: '#0ea5e9',
+ infoDark: '#38bdf8',
+ quiet: '#94a3b8',
+ quietDark: '#64748b',
+};
+
+// Per-row accents for the segmented tick meters (white / orange / cyan / blue / purple rhythm).
+export const TICK_COLORS_DARK = ['#e4e4e7', '#fb923c', '#22d3ee', '#3b82f6', '#a78bfa'];
+
+export const TICK_COLORS_LIGHT = ['#3f3f46', '#ea580c', '#0891b2', '#2563eb', '#7c3aed'];
+
+export const MONO_STACK = "'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace";
+
+// Rendered verbatim, not re-parsed: parsing these server-bucketed `YYYY-MM-DD` strings in the
+// viewer's local zone used to shift the date by a day for negative UTC offsets.
+export function formatDay(value: string) {
+ return formatDayLabel(value);
+}
+
+/** Bucketed trend points cover a span; label them `Jul 1 – Jul 7` rather than just the start day. */
+export function formatDayRange(day: string, endDay?: string) {
+ if (!endDay || endDay === day) return formatDay(day);
+ return `${formatDay(day)} – ${formatDay(endDay)}`;
+}
+
+export function formatCompact(value: number) {
+ return value >= 1000 ? fmtNumber(value) : value.toLocaleString();
+}
+
+export function modelName(model: string) {
+ return model.split('/').pop()?.replace(/-/g, ' ') ?? model;
+}
diff --git a/src/client/components/features/stats/metrics-grid-charts.tsx b/src/client/components/features/stats/metrics-grid-charts.tsx
new file mode 100644
index 00000000..8a1174eb
--- /dev/null
+++ b/src/client/components/features/stats/metrics-grid-charts.tsx
@@ -0,0 +1,245 @@
+import {
+ Area,
+ AreaChart,
+ Bar,
+ BarChart,
+ CartesianGrid,
+ Cell,
+ Pie,
+ PieChart,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from 'recharts';
+import { Activity, Boxes, Coins, FolderGit2, ShieldCheck } from 'lucide-react';
+import type { StatsPayload } from '@shared/schema';
+import {
+ ChartDefs,
+ ChartTooltip,
+ GraphShell,
+ LegendChip,
+ MeterList,
+ TickMeter,
+} from './chart-primitives';
+import {
+ CHART,
+ MONO_STACK,
+ TICK_COLORS_DARK,
+ TICK_COLORS_LIGHT,
+ formatCompact,
+ formatDay,
+ modelName,
+} from './chart-support';
+
+// `equidistantPreserveStart` drops labels on a fixed stride (every 2nd, every 3rd, ...) sized to the
+// available width, so the dates stay evenly spaced instead of jumping by uneven gaps.
+const X_AXIS_PROPS = {
+ dataKey: 'day',
+ tickFormatter: formatDay,
+ interval: 'equidistantPreserveStart' as const,
+ minTickGap: 12,
+};
+
+export function MetricsGridCharts({
+ stats,
+ isDark,
+}: {
+ stats: StatsPayload;
+ isDark: boolean;
+}) {
+ const lime = isDark ? CHART.primaryDark : CHART.primary;
+ const amber = isDark ? CHART.amberDark : CHART.amber;
+ const dangerColor = isDark ? CHART.dangerDark : CHART.danger;
+ const infoColor = isDark ? CHART.infoDark : CHART.info;
+ const quietColor = isDark ? CHART.quietDark : CHART.quiet;
+ const dashColor = isDark ? 'rgba(228,228,231,0.75)' : 'rgba(63,63,70,0.65)';
+ const tickColors = isDark ? TICK_COLORS_DARK : TICK_COLORS_LIGHT;
+ // Long ranges arrive pre-combined into multi-day buckets; say so, since each point is a sum, not a day.
+ const bucketDays = stats.trendBucketDays ?? 1;
+ const bucketNote = bucketDays > 1 ? {bucketDays}-day totals : null;
+ const repoMax = Math.max(...stats.topRepos.map((repo) => repo.jobs), 1);
+ const modelMax = Math.max(...stats.models.map((model) => model.calls), 1);
+
+ // CSS variables don't reliably resolve inside Recharts SVG text, so colors are keyed off the active theme explicitly.
+ const axisColor = isDark ? 'rgba(228,228,231,0.55)' : 'rgba(63,63,70,0.7)';
+ const gridColor = isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)';
+ const cursorColor = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)';
+ const axisProps = {
+ fontSize: 10,
+ tickLine: false,
+ tickMargin: 8,
+ axisLine: false,
+ tick: { fontFamily: MONO_STACK, fill: axisColor },
+ } as const;
+
+ const STATUS_COLOR: Record = {
+ done: lime,
+ running: infoColor,
+ queued: quietColor,
+ failed: dangerColor,
+ superseded: quietColor,
+ cancelled: quietColor,
+ };
+ const statusTotal = Math.max(stats.statuses.reduce((sum, s) => sum + s.count, 0), 1);
+
+ return (
+
+
+
}
+ legend={
+ <>
+
+
+ {bucketNote}
+ >
+ }
+ >
+
+
+
+
+
+
+
+ } cursor={{ stroke: amber, strokeDasharray: '4 4' }} />
+
+
+
+
+
+
+
+
}
+ legend={
+ <>
+
+
+ {bucketNote}
+ >
+ }
+ >
+
+
+
+
+
+
+
+ } cursor={{ fill: cursorColor }} />
+ {/* Capped so a short range (or a heavily bucketed one) doesn't render a handful of slab-wide bars. */}
+
+
+
+
+
+
+
+
+
+
}>
+
+
+
+
+
+ {stats.statuses.map((s) => (
+ |
+ ))}
+
+
+
+
+
+ {formatCompact(statusTotal)}
+
+ Jobs
+
+
+
+
+ {stats.statuses.map((s) => (
+
+
+
+ {s.status}
+
+
+ {s.count}
+ ({Math.round((s.count / statusTotal) * 100)}%)
+
+
+ ))}
+
+
+
+
+
}>
+
+ {stats.topRepos.map((repo, i) => (
+
+ ))}
+
+
+
+
}>
+
+ {stats.models.map((model, i) => (
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/client/components/features/stats/metrics-grid-prefetch.ts b/src/client/components/features/stats/metrics-grid-prefetch.ts
new file mode 100644
index 00000000..b0281a3f
--- /dev/null
+++ b/src/client/components/features/stats/metrics-grid-prefetch.ts
@@ -0,0 +1,7 @@
+// The chart chunk is only *rendered* once stats have loaded, so React.lazy alone would delay its
+// download until after the fetch resolved -- a waterfall the eager import it replaced never had.
+// Calling this on mount puts the ~68 kB gzip request alongside the stats fetch instead of behind it.
+// Separate from metrics-grid.tsx so that file keeps exporting components only (Fast Refresh).
+export function prefetchMetricsCharts() {
+ void import('./metrics-grid-charts');
+}
diff --git a/src/client/components/features/stats/metrics-grid.tsx b/src/client/components/features/stats/metrics-grid.tsx
index a865857d..1f8f6dc5 100644
--- a/src/client/components/features/stats/metrics-grid.tsx
+++ b/src/client/components/features/stats/metrics-grid.tsx
@@ -1,33 +1,12 @@
-import {
- Area,
- AreaChart,
- Bar,
- BarChart,
- CartesianGrid,
- Cell,
- Pie,
- PieChart,
- ResponsiveContainer,
- Tooltip,
- XAxis,
- YAxis,
-} from 'recharts';
-import { Activity, Boxes, Coins, FolderGit2, ShieldCheck } from 'lucide-react';
+import React, { Suspense } from 'react';
import type { StatsPayload } from '@shared/schema';
-import {
- CHART,
- ChartDefs,
- ChartTooltip,
- GraphShell,
- LegendChip,
- MONO_STACK,
- TICK_COLORS_DARK,
- TICK_COLORS_LIGHT,
- TickMeter,
- formatCompact,
- formatDay,
- modelName,
-} from './chart-primitives';
+import { MetricsGridSkeleton } from './chart-primitives';
+
+// Recharts is only needed once stats have loaded, so it stays out of the initial bundle and the
+// same skeleton covers both the fetch and the chunk download.
+// Kept warm by `prefetchMetricsCharts` in ./metrics-grid-prefetch: the charts only render once
+// `stats` arrives, so `lazy` on its own would not start the download until after the fetch resolved.
+const MetricsGridCharts = React.lazy(() => import('./metrics-grid-charts').then(m => ({ default: m.MetricsGridCharts })));
export function MetricsGrid({
stats,
@@ -36,199 +15,9 @@ export function MetricsGrid({
stats: StatsPayload;
isDark: boolean;
}) {
- const lime = isDark ? CHART.primaryDark : CHART.primary;
- const amber = isDark ? CHART.amberDark : CHART.amber;
- const dangerColor = isDark ? CHART.dangerDark : CHART.danger;
- const infoColor = isDark ? CHART.infoDark : CHART.info;
- const quietColor = isDark ? CHART.quietDark : CHART.quiet;
- const dashColor = isDark ? 'rgba(228,228,231,0.75)' : 'rgba(63,63,70,0.65)';
- const tickColors = isDark ? TICK_COLORS_DARK : TICK_COLORS_LIGHT;
- const repoMax = Math.max(...stats.topRepos.map((repo) => repo.jobs), 1);
- const modelMax = Math.max(...stats.models.map((model) => model.calls), 1);
-
- // CSS variables don't reliably resolve inside Recharts SVG text, so colors are keyed off the active theme explicitly.
- const axisColor = isDark ? 'rgba(228,228,231,0.55)' : 'rgba(63,63,70,0.7)';
- const gridColor = isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.07)';
- const cursorColor = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)';
- const axisProps = {
- fontSize: 10,
- tickLine: false,
- tickMargin: 8,
- axisLine: false,
- tick: { fontFamily: MONO_STACK, fill: axisColor },
- } as const;
- // minTickGap thins labels by available space, not a fixed stride, since the trend array can have far fewer points than `days`.
- const xAxisProps = {
- dataKey: 'day',
- tickFormatter: formatDay,
- interval: 'preserveStartEnd' as const,
- minTickGap: 24,
- };
-
- const STATUS_COLOR: Record = {
- done: lime,
- running: infoColor,
- queued: quietColor,
- failed: dangerColor,
- superseded: quietColor,
- cancelled: quietColor,
- };
- const statusTotal = Math.max(stats.statuses.reduce((sum, s) => sum + s.count, 0), 1);
-
return (
-
-
-
}
- legend={
- <>
-
-
- >
- }
- >
-
-
-
-
-
-
-
- } cursor={{ stroke: amber, strokeDasharray: '4 4' }} />
-
-
-
-
-
-
-
-
}
- legend={
- <>
-
-
- >
- }
- >
-
-
-
-
-
-
-
- } cursor={{ fill: cursorColor }} />
-
-
-
-
-
-
-
-
-
-
}>
-
-
-
-
-
- {stats.statuses.map((s) => (
- |
- ))}
-
-
-
-
-
- {formatCompact(statusTotal)}
-
- Jobs
-
-
-
-
- {stats.statuses.map((s) => (
-
-
-
- {s.status}
-
-
- {s.count}
- ({Math.round((s.count / statusTotal) * 100)}%)
-
-
- ))}
-
-
-
-
-
}>
-
- {stats.topRepos.slice(0, 8).map((repo, i) => (
-
- ))}
-
-
-
-
}>
-
- {stats.models.slice(0, 8).map((model, i) => (
-
- ))}
-
-
-
-
+ }>
+
+
);
}
diff --git a/src/client/components/features/stats/stats-grid.tsx b/src/client/components/features/stats/stats-grid.tsx
index 102a580e..e86e8cae 100644
--- a/src/client/components/features/stats/stats-grid.tsx
+++ b/src/client/components/features/stats/stats-grid.tsx
@@ -42,8 +42,8 @@ export function StatsGrid({ items, className, ...props }: StatsGridProps) {
)}
{...props}
>
- {items.map((item, i) => (
-
+ {items.map((item) => (
+
))}
);
diff --git a/src/client/components/features/stats/time-range-select.tsx b/src/client/components/features/stats/time-range-select.tsx
index 6cb59054..b6311ad4 100644
--- a/src/client/components/features/stats/time-range-select.tsx
+++ b/src/client/components/features/stats/time-range-select.tsx
@@ -1,6 +1,7 @@
import type { CSSProperties } from 'react';
import { Clock } from 'lucide-react';
import { Select } from '@client/components/ui/select';
+import { DEFAULT_STATS_DAYS } from '@client/hooks/use-stats-range';
import { cn } from '@client/lib/utils';
interface TimeRangeSelectProps {
@@ -18,7 +19,10 @@ const timeRanges = [
];
export function TimeRangeSelect({ value, onValueChange, className, triggerStyle }: TimeRangeSelectProps) {
- const selectedRange = timeRanges.find((r) => r.value === value) || timeRanges[1];
+ const selectedRange =
+ timeRanges.find((r) => r.value === value) ??
+ timeRanges.find((r) => r.value === DEFAULT_STATS_DAYS) ??
+ timeRanges[0];
return (
{mobileMenuOpen && (
- setMobileMenuOpen(false)}
/>
)}
diff --git a/src/client/components/motion/shared-layout-bg.tsx b/src/client/components/motion/shared-layout-bg.tsx
index a05d1202..b164274c 100644
--- a/src/client/components/motion/shared-layout-bg.tsx
+++ b/src/client/components/motion/shared-layout-bg.tsx
@@ -1,7 +1,9 @@
// beui.dev/components/motion/shared-layout-bg
import {
AnimatePresence,
- motion,
+ domMax,
+ LazyMotion,
+ m,
useReducedMotion,
type Variants,
} from "motion/react";
@@ -54,62 +56,69 @@ export function SharedLayoutBg({
const uid = useId();
const reduce = useReducedMotion();
- return (
-
setActiveId(null)}
- className={cn("flex w-full flex-col", className)}
- >
- {Children.toArray(children)
- .filter(isValidElement)
- .map((child, index) => {
- const el = child as ReactElement<{
- className?: string;
- onMouseEnter?: (e?: any) => void;
- children?: ReactNode;
- }>;
- const childKey = el.key ? String(el.key) : `item-${index}`;
- return cloneElement(
- el,
- {
- key: childKey,
- className: cn("relative z-10", el.props.className),
- onMouseEnter: (e: any) => {
- el.props.onMouseEnter?.(e);
- setActiveId(childKey);
- },
- },
- <>
-
-
- {activeId !== null ? (
-
- {activeId === childKey ? (
-
- ) : null}
-
+ const rows: ReactNode[] = [];
+ for (const child of Children.toArray(children)) {
+ if (!isValidElement(child)) continue;
+ const el = child as ReactElement<{
+ className?: string;
+ onMouseEnter?: (e?: any) => void;
+ children?: ReactNode;
+ }>;
+ // rows.length is the index among *valid* children, so keyless rows keep stable fallback keys.
+ const childKey = el.key ? String(el.key) : `item-${rows.length}`;
+ rows.push(
+ cloneElement(
+ el,
+ {
+ key: childKey,
+ className: cn("relative z-10", el.props.className),
+ onMouseEnter: (e: any) => {
+ el.props.onMouseEnter?.(e);
+ setActiveId(childKey);
+ },
+ },
+ <>
+
+
+ {activeId !== null ? (
+
+ {activeId === childKey ? (
+
) : null}
-
-
- {el}
- >
- );
- })}
-
+
+ ) : null}
+
+
+ {el}
+ >
+ ),
+ );
+ }
+
+ return (
+
+ setActiveId(null)}
+ className={cn("flex w-full flex-col", className)}
+ >
+ {rows}
+
+
);
}
diff --git a/src/client/components/motion/stepped-slider.tsx b/src/client/components/motion/stepped-slider.tsx
index 958fbd6c..1b23e858 100644
--- a/src/client/components/motion/stepped-slider.tsx
+++ b/src/client/components/motion/stepped-slider.tsx
@@ -1,7 +1,9 @@
// Adapted from a min/max/step range slider so labeled steps (e.g. Low/Medium/High/Max) line up on
// evenly spaced stops; the value readout sits statically above the track (no floating/portal tooltip).
import {
- motion,
+ domAnimation,
+ LazyMotion,
+ m,
useMotionTemplate,
useMotionValue,
useReducedMotion,
@@ -82,8 +84,10 @@ export function SteppedSlider({
const [internal, setInternal] = useState(defaultValue);
const [active, setActive] = useState(false);
// Decoupled from the committed value so onValueChange fires once per gesture (on release),
- // not on every pointer-move tick.
+ // not on every pointer-move tick. Mirrored in a ref so release can read the pending value
+ // without doing the commit inside a state updater.
const [dragValue, setDragValue] = useState(null);
+ const dragValueRef = useRef(null);
const controlled = value !== undefined;
const committedValue = clamp(controlled ? (value as number) : internal, min, max);
const current = active && dragValue !== null ? clamp(dragValue, min, max) : committedValue;
@@ -132,34 +136,38 @@ export function SteppedSlider({
[committedValue, min, max],
);
+ const trackDragValue = useCallback((next: number | null) => {
+ dragValueRef.current = next;
+ setDragValue(next);
+ }, []);
+
const onPointerDown = useCallback(
(event: PointerEvent) => {
if (disabled) return;
event.currentTarget.setPointerCapture(event.pointerId);
setActive(true);
- setDragValue(snapValue(valueFromX(event.clientX)));
+ trackDragValue(snapValue(valueFromX(event.clientX)));
},
- [disabled, valueFromX, snapValue],
+ [disabled, valueFromX, snapValue, trackDragValue],
);
const onPointerMove = useCallback(
(event: PointerEvent) => {
if (!active || disabled) return;
- setDragValue(snapValue(valueFromX(event.clientX)));
+ trackDragValue(snapValue(valueFromX(event.clientX)));
},
- [active, disabled, valueFromX, snapValue],
+ [active, disabled, valueFromX, snapValue, trackDragValue],
);
const endDrag = useCallback(
(event: PointerEvent) => {
event.currentTarget.releasePointerCapture?.(event.pointerId);
setActive(false);
- setDragValue((pending) => {
- if (pending !== null) commit(pending);
- return null;
- });
+ const pending = dragValueRef.current;
+ trackDragValue(null);
+ if (pending !== null) commit(pending);
},
- [commit],
+ [commit, trackDragValue],
);
const onKeyDown = useCallback(
@@ -184,120 +192,122 @@ export function SteppedSlider({
const valueLabel = formatValue ? formatValue(current) : String(current);
return (
-
-
- {valueLabel}
-
-
-
- {/* Pattern repeats every 10px, shifting by exactly one period, so the loop point is never visible. */}
-
- {isMaxed ? (
-
-
-
- ) : (
-
- )}
-
-
- {/* Inset so end dots don't clip; sized up at max so they don't blend into the dot texture. */}
-
- {steps.map((tick) => {
- const tp = ((tick.value - min) / (max - min)) * 100;
- return (
-
- );
- })}
+
+
+
+ {valueLabel}
- {/* Own layer so only opacity animates - Motion's box-shadow interpolator can't parse CSS custom properties in the color stops. */}
- {isMaxed && !reduce && (
-
- )}
-
-
-
+ >
+ {/* Pattern repeats every 10px, shifting by exactly one period, so the loop point is never visible. */}
+
+ {isMaxed ? (
+
+
+
+ ) : (
+
+ )}
+
- {steps.length > 0 && (
-
-
- {steps.map((tick, index) => {
- const isFirst = index === 0;
- const isLast = index === steps.length - 1;
+ {/* Inset so end dots don't clip; sized up at max so they don't blend into the dot texture. */}
+
+ {steps.map((tick) => {
const tp = ((tick.value - min) / (max - min)) * 100;
return (
- {tick.label}
-
+ style={{ left: `${tp}%` }}
+ />
);
})}
+
+ {/* Own layer so only opacity animates - Motion's box-shadow interpolator can't parse CSS custom properties in the color stops. */}
+ {isMaxed && !reduce && (
+
+ )}
+
+
- )}
-
+
+ {steps.length > 0 && (
+
+
+ {steps.map((tick, index) => {
+ const isFirst = index === 0;
+ const isLast = index === steps.length - 1;
+ const tp = ((tick.value - min) / (max - min)) * 100;
+ return (
+
+ {tick.label}
+
+ );
+ })}
+
+
+ )}
+
+
);
}
diff --git a/src/client/components/motion/tabs.tsx b/src/client/components/motion/tabs.tsx
index 221b3413..583e2556 100644
--- a/src/client/components/motion/tabs.tsx
+++ b/src/client/components/motion/tabs.tsx
@@ -1,6 +1,16 @@
// beui.dev/components/motion/tabs
-import { motion, MotionConfig, useReducedMotion, type Transition } from 'motion/react';
-import { createContext, useContext, useId, useState, type ReactNode } from 'react';
+// domMax rather than domAnimation: the active-tab indicator animates via layoutId/layoutRoot, and
+// layout projection only ships in the max bundle.
+import { LazyMotion, m, domMax, MotionConfig, useReducedMotion, type Transition } from 'motion/react';
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useId,
+ useMemo,
+ useState,
+ type ReactNode,
+} from 'react';
import { cn } from '@client/lib/utils';
type Variant = 'pill' | 'underline' | 'segment';
@@ -48,18 +58,27 @@ export function Tabs({
const reduce = useReducedMotion();
const controlled = value !== undefined;
const current = controlled ? value : internal;
- const setValue = (v: string) => {
- if (!controlled) setInternal(v);
- onValueChange?.(v);
- };
+ const setValue = useCallback(
+ (v: string) => {
+ if (!controlled) setInternal(v);
+ onValueChange?.(v);
+ },
+ [controlled, onValueChange],
+ );
+ const ctx = useMemo(
+ () => ({ value: current, setValue, layoutId, variant }),
+ [current, setValue, layoutId, variant],
+ );
return (
-
- {/* layoutRoot: the indicator's layoutId measures in page coordinates, so without this
- it would replay scroll offsets as movement inside fixed/scrolled containers. */}
-
- {children}
-
+
+
+ {/* layoutRoot: the indicator's layoutId measures in page coordinates, so without this
+ it would replay scroll offsets as movement inside fixed/scrolled containers. */}
+
+ {children}
+
+
);
@@ -109,7 +128,7 @@ export function TabsTrigger({
>
{children}
{active ? (
-
@@ -130,7 +149,7 @@ export function TabsTrigger({
return (
{active ? (
-
0 && (
- {hints.map((hint, i) => (
-
+ {hints.map((hint) => (
+
{hint}
diff --git a/src/client/components/shared/route-error-boundary.tsx b/src/client/components/shared/route-error-boundary.tsx
new file mode 100644
index 00000000..b5876e30
--- /dev/null
+++ b/src/client/components/shared/route-error-boundary.tsx
@@ -0,0 +1,114 @@
+import { isRouteErrorResponse, Link, useRouteError } from 'react-router-dom';
+import { AlertTriangle, Compass, LayoutDashboard, RefreshCw } from 'lucide-react';
+import { Button } from '@client/components/ui/button';
+
+interface Presentation {
+ code: string;
+ title: string;
+ hint: string;
+ detail?: string;
+ icon: typeof AlertTriangle;
+ /** A reload can't conjure a route that doesn't exist. */
+ reloadable: boolean;
+}
+
+/** Route responses (loader `throw new Response`, 404s) read very differently from a crashed render. */
+function present(error: unknown): Presentation {
+ if (isRouteErrorResponse(error)) {
+ const notFound = error.status === 404;
+ return {
+ code: `ERR_ROUTE_${error.status}`,
+ title: notFound ? 'Resource not found' : error.statusText || 'Request failed',
+ hint: notFound
+ ? "That address isn't part of Codra. It may have been moved, or the record it pointed at was deleted."
+ : 'The server refused this request. Try again, or head back and pick a different route.',
+ detail: typeof error.data === 'string' ? error.data : undefined,
+ icon: notFound ? Compass : AlertTriangle,
+ reloadable: !notFound,
+ };
+ }
+
+ return {
+ code: 'ERR_RENDER_FAILED',
+ title: 'Something broke on this screen',
+ hint: 'The page failed while rendering. Reloading usually clears it; if it keeps happening the details below are worth reporting.',
+ detail: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
+ icon: AlertTriangle,
+ reloadable: true,
+ };
+}
+
+/**
+ * Application fallback for the router's route branches - without it, rendering, loader and action
+ * failures land on React Router's unstyled default screen.
+ *
+ * `inline` is for the routes nested in `AppShell`: those render inside the shell's ``, so the
+ * fallback has to be a plain section rather than a second `` filling the viewport. That keeps
+ * the sidebar and header alive when a single page crashes, which is what the hand-rolled error
+ * boundary this replaced used to do.
+ */
+export function RouteErrorBoundary({ inline = false }: { inline?: boolean }) {
+ const error = useRouteError();
+ const { code, title, hint, detail, icon: Icon, reloadable } = present(error);
+ const stack = import.meta.env.DEV && error instanceof Error ? error.stack : undefined;
+ const Container = inline ? 'section' : 'main';
+
+ return (
+
+
+
+
+
+
+
{title}
+
{hint}
+
+ {detail && (
+
+ {detail}
+
+ )}
+
+ {stack && (
+
+
+ Stack trace
+
+
+ {stack}
+
+
+ )}
+
+
+ {reloadable && (
+ }
+ onClick={() => window.location.reload()}
+ >
+ Reload page
+
+ )}
+
+
+
+ Back to dashboard
+
+
+
+
+
+ Error code: {code}
+
+
+
+ );
+}
diff --git a/src/client/components/ui/badge-variants.ts b/src/client/components/ui/badge-variants.ts
new file mode 100644
index 00000000..606deaef
--- /dev/null
+++ b/src/client/components/ui/badge-variants.ts
@@ -0,0 +1,21 @@
+import { cva } from 'class-variance-authority';
+
+// Borderless tinted pills (Cloudflare-dashboard style): translucent fill + saturated text of the same hue.
+export const badgeVariants = cva(
+ 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors',
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary/15 text-primary',
+ secondary: 'bg-ui-fill/45 text-ui-default',
+ neutral: 'bg-ui-fill/45 text-ui-default',
+ info: 'bg-info/15 text-info',
+ success: 'bg-success/15 text-success',
+ warning: 'bg-warning/15 text-warning',
+ danger: 'bg-danger/15 text-danger',
+ outline: 'text-ui-default ring-1 ring-inset ring-ui-line bg-transparent',
+ },
+ },
+ defaultVariants: { variant: 'default' },
+ },
+);
diff --git a/src/client/components/ui/badge.tsx b/src/client/components/ui/badge.tsx
index e11287e6..65b6b048 100644
--- a/src/client/components/ui/badge.tsx
+++ b/src/client/components/ui/badge.tsx
@@ -1,28 +1,9 @@
import * as React from 'react';
-import { cva, type VariantProps } from 'class-variance-authority';
+import { type VariantProps } from 'class-variance-authority';
import { cn } from '@client/lib/utils';
import type { JobSummary } from '@shared/schema';
import { LiveReviewStepper } from '@client/components/features/reviews/live-review-stepper';
-
-// Borderless tinted pills (Cloudflare-dashboard style): translucent fill + saturated text of the same hue.
-const badgeVariants = cva(
- 'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors',
- {
- variants: {
- variant: {
- default: 'bg-primary/15 text-primary',
- secondary: 'bg-ui-fill/45 text-ui-default',
- neutral: 'bg-ui-fill/45 text-ui-default',
- info: 'bg-info/15 text-info',
- success: 'bg-success/15 text-success',
- warning: 'bg-warning/15 text-warning',
- danger: 'bg-danger/15 text-danger',
- outline: 'text-ui-default ring-1 ring-inset ring-ui-line bg-transparent',
- },
- },
- defaultVariants: { variant: 'default' },
- },
-);
+import { badgeVariants } from '@client/components/ui/badge-variants';
export interface BadgeProps
extends React.HTMLAttributes,
@@ -75,4 +56,4 @@ function StatusBadge({ label, job }: { label: string; job?: JobSummary }) {
);
}
-export { Badge, StatusBadge, badgeVariants };
+export { Badge, StatusBadge };
diff --git a/src/client/components/ui/button-variants.ts b/src/client/components/ui/button-variants.ts
new file mode 100644
index 00000000..3b5292a8
--- /dev/null
+++ b/src/client/components/ui/button-variants.ts
@@ -0,0 +1,35 @@
+import { cva } from 'class-variance-authority';
+
+// `primary`/`secondary` use the bordered-panel look; remaining variants are legacy, kept for screens still on the older palette. Also drives .
+export const buttonVariants = cva(
+ 'relative inline-flex select-none items-center justify-center gap-2 whitespace-nowrap font-semibold transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
+ {
+ variants: {
+ variant: {
+ primary:
+ 'border border-[var(--btn-primary-border)] bg-[var(--btn-primary-surface)] text-[var(--btn-primary-fg)] hover:bg-[var(--btn-primary-hover)]',
+ secondary: 'border border-ui-line bg-ui-base text-ui-default hover:bg-ui-fill/60',
+ default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 active:scale-[.98]',
+ destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
+ 'destructive-outline':
+ 'border border-danger-border bg-danger-bg/40 text-danger shadow-sm hover:bg-destructive hover:text-destructive-foreground hover:border-destructive',
+ 'warning-outline':
+ 'border border-warning-border bg-warning-bg/40 text-warning shadow-sm hover:bg-warning-bg hover:border-warning',
+ outline:
+ 'border border-zinc-200 bg-white shadow-sm hover:bg-zinc-50 hover:text-zinc-900 dark:border-white/10 dark:bg-white/[0.06] dark:hover:bg-white/[0.1] dark:hover:text-foreground',
+ ghost: 'hover:bg-secondary hover:text-secondary-foreground',
+ link: 'text-primary underline-offset-4 hover:underline',
+ accent: 'bg-accent text-accent-foreground shadow-sm hover:bg-accent/90 active:scale-[.98]',
+ },
+ size: {
+ default: 'h-9 rounded-md px-4 py-2 text-sm',
+ sm: 'h-8 rounded-md px-3 text-xs',
+ base: 'h-9 rounded-md px-3 text-sm',
+ lg: 'h-11 rounded-md px-6 text-sm',
+ xs: 'h-6 rounded-md px-1.5 text-xs',
+ icon: 'h-9 w-9 rounded-md',
+ },
+ },
+ defaultVariants: { variant: 'default', size: 'default' },
+ },
+);
diff --git a/src/client/components/ui/button.tsx b/src/client/components/ui/button.tsx
index 2f488bfc..b8053488 100644
--- a/src/client/components/ui/button.tsx
+++ b/src/client/components/ui/button.tsx
@@ -1,42 +1,9 @@
import * as React from 'react';
import { useRender } from '@base-ui/react/use-render';
-import { cva, type VariantProps } from 'class-variance-authority';
+import { type VariantProps } from 'class-variance-authority';
import { Loader2 } from 'lucide-react';
import { cn } from '@client/lib/utils';
-
-// `primary`/`secondary` use the bordered-panel look; remaining variants are legacy, kept for screens still on the older palette. Also drives below.
-const buttonVariants = cva(
- 'relative inline-flex select-none items-center justify-center gap-2 whitespace-nowrap font-semibold transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0',
- {
- variants: {
- variant: {
- primary:
- 'border border-[var(--btn-primary-border)] bg-[var(--btn-primary-surface)] text-[var(--btn-primary-fg)] hover:bg-[var(--btn-primary-hover)]',
- secondary: 'border border-ui-line bg-ui-base text-ui-default hover:bg-ui-fill/60',
- default: 'bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 active:scale-[.98]',
- destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
- 'destructive-outline':
- 'border border-danger-border bg-danger-bg/40 text-danger shadow-sm hover:bg-destructive hover:text-destructive-foreground hover:border-destructive',
- 'warning-outline':
- 'border border-warning-border bg-warning-bg/40 text-warning shadow-sm hover:bg-warning-bg hover:border-warning',
- outline:
- 'border border-zinc-200 bg-white shadow-sm hover:bg-zinc-50 hover:text-zinc-900 dark:border-white/10 dark:bg-white/[0.06] dark:hover:bg-white/[0.1] dark:hover:text-foreground',
- ghost: 'hover:bg-secondary hover:text-secondary-foreground',
- link: 'text-primary underline-offset-4 hover:underline',
- accent: 'bg-accent text-accent-foreground shadow-sm hover:bg-accent/90 active:scale-[.98]',
- },
- size: {
- default: 'h-9 rounded-md px-4 py-2 text-sm',
- sm: 'h-8 rounded-md px-3 text-xs',
- base: 'h-9 rounded-md px-3 text-sm',
- lg: 'h-11 rounded-md px-6 text-sm',
- xs: 'h-6 rounded-md px-1.5 text-xs',
- icon: 'h-9 w-9 rounded-md',
- },
- },
- defaultVariants: { variant: 'default', size: 'default' },
- },
-);
+import { buttonVariants } from '@client/components/ui/button-variants';
type Shape = 'base' | 'square' | 'circle';
@@ -119,4 +86,4 @@ const LinkButton = React.forwardRef(
);
LinkButton.displayName = 'LinkButton';
-export { Button, LinkButton, buttonVariants };
+export { Button, LinkButton };
diff --git a/src/client/components/ui/select-panel.tsx b/src/client/components/ui/select-panel.tsx
new file mode 100644
index 00000000..f021d16e
--- /dev/null
+++ b/src/client/components/ui/select-panel.tsx
@@ -0,0 +1,156 @@
+import { Check } from 'lucide-react';
+import { m, type Transition } from 'motion/react';
+import type { RefObject } from 'react';
+import { cn } from '@client/lib/utils';
+import { EASE_OUT } from '@client/lib/ease';
+import {
+ INSTANT_TRANSITION,
+ ITEM_VARIANTS,
+ LIST_VARIANTS,
+ type SelectOption,
+ type TriggerRect,
+} from './select-shared';
+
+const REDUCED_TRANSITION: Transition = { duration: 0.12 };
+const GAP_TRANSITION_OPEN: Transition = { type: 'spring', duration: 0.44, bounce: 0.45, delay: 0.09 };
+const GAP_TRANSITION_CLOSED: Transition = { type: 'spring', duration: 0.26, bounce: 0.1 };
+const RADIUS_TRANSITION_OPEN: Transition = { duration: 0.26, ease: EASE_OUT, delay: 0.1 };
+const RADIUS_TRANSITION_CLOSED: Transition = { duration: 0.15, ease: EASE_OUT };
+const OPACITY_TRANSITION_OPEN: Transition = { duration: 0.18 };
+const OPACITY_TRANSITION_CLOSED: Transition = { duration: 0.16, delay: 0.1 };
+const HEIGHT_TRANSITION_OPEN: Transition = { type: 'spring', duration: 0.4, bounce: 0.14 };
+const HEIGHT_TRANSITION_CLOSED: Transition = { duration: 0.24, ease: EASE_OUT, delay: 0.1 };
+
+interface SelectPanelProps {
+ panelRef: RefObject;
+ innerRef: RefObject;
+ optionRefs: RefObject>;
+ listId: string;
+ triggerId: string;
+ options: SelectOption[];
+ value: string;
+ highlightedIndex: number;
+ open: boolean;
+ isTop: boolean;
+ reduce: boolean;
+ height: number;
+ rect: TriggerRect | null;
+ onSelect: (value: string) => void;
+ onHighlight: (index: number) => void;
+}
+
+export function SelectPanel({
+ panelRef,
+ innerRef,
+ optionRefs,
+ listId,
+ triggerId,
+ options,
+ value,
+ highlightedIndex,
+ open,
+ isTop,
+ reduce,
+ height,
+ rect,
+ onSelect,
+ onHighlight,
+}: SelectPanelProps) {
+ const nearGap = open ? 8 : 0;
+ const nearRadius = open ? 7 : 0;
+ const gapT = open ? GAP_TRANSITION_OPEN : GAP_TRANSITION_CLOSED;
+ const radiusT = open ? RADIUS_TRANSITION_OPEN : RADIUS_TRANSITION_CLOSED;
+
+ return (
+ round; far corners stay rounded
+ borderTopLeftRadius: isTop ? 7 : nearRadius,
+ borderTopRightRadius: isTop ? 7 : nearRadius,
+ borderBottomLeftRadius: isTop ? nearRadius : 7,
+ borderBottomRightRadius: isTop ? nearRadius : 7,
+ }
+ }
+ transition={
+ reduce
+ ? REDUCED_TRANSITION
+ : {
+ opacity: open ? OPACITY_TRANSITION_OPEN : OPACITY_TRANSITION_CLOSED,
+ height: open ? HEIGHT_TRANSITION_OPEN : HEIGHT_TRANSITION_CLOSED,
+ marginTop: isTop ? INSTANT_TRANSITION : gapT,
+ marginBottom: isTop ? gapT : INSTANT_TRANSITION,
+ borderTopLeftRadius: isTop ? INSTANT_TRANSITION : radiusT,
+ borderTopRightRadius: isTop ? INSTANT_TRANSITION : radiusT,
+ borderBottomLeftRadius: isTop ? radiusT : INSTANT_TRANSITION,
+ borderBottomRightRadius: isTop ? radiusT : INSTANT_TRANSITION,
+ }
+ }
+ style={{
+ position: 'fixed',
+ left: rect?.left ?? 0,
+ width: rect?.width ?? 0,
+ top: isTop ? undefined : (rect?.bottom ?? 0),
+ bottom: isTop ? window.innerHeight - (rect?.top ?? 0) : undefined,
+ transformOrigin: isTop ? 'bottom' : 'top',
+ overflow: 'hidden',
+ pointerEvents: open ? 'auto' : 'none',
+ }}
+ // Flush against the trigger, then separates into its own rounded pill.
+ className="z-50 border border-ui-line bg-ui-base shadow-lg shadow-black/[0.04] dark:shadow-black/40"
+ >
+
+ {options.map((option, index) => {
+ const selected = option.value === value;
+ const highlighted = index === highlightedIndex;
+ return (
+
+ {
+ optionRefs.current[index] = node;
+ }}
+ id={`${listId}-option-${index}`}
+ type="button"
+ role="option"
+ aria-selected={selected}
+ tabIndex={-1}
+ onMouseEnter={() => onHighlight(index)}
+ onClick={() => onSelect(option.value)}
+ className={cn(
+ // `whitespace-nowrap`: on the 72px "rows per page" select, the check icon ate the width and "10" wrapped to stacked digits.
+ 'flex w-full items-center justify-between gap-2 whitespace-nowrap rounded-md px-2.5 py-1.5 text-left text-sm outline-none transition-colors',
+ selected
+ ? 'bg-ui-brand/10 font-medium text-ui-brand'
+ : 'text-ui-default hover:bg-ui-fill hover:text-ui-strong focus-visible:bg-ui-fill',
+ highlighted && !selected && 'bg-ui-fill text-ui-strong',
+ )}
+ >
+ {option.label}
+ {selected ? : null}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/client/components/ui/select-shared.ts b/src/client/components/ui/select-shared.ts
new file mode 100644
index 00000000..daae2415
--- /dev/null
+++ b/src/client/components/ui/select-shared.ts
@@ -0,0 +1,32 @@
+import type { Transition, Variants } from 'motion/react';
+
+export type Placement = 'bottom' | 'top';
+
+export interface SelectOption {
+ value: string;
+ label: string;
+}
+
+/** Trigger box in viewport coordinates; the portaled panel is positioned from it. */
+export interface TriggerRect {
+ left: number;
+ width: number;
+ top: number;
+ bottom: number;
+}
+
+// Spring with bounce powers the unfold; per-property timings on the panel choreograph it.
+export const CHEVRON_TRANSITION: Transition = { type: 'spring', duration: 0.4, bounce: 0.3 };
+
+// Compounds per option; 0.035 delayed the last item ~0.9s on long lists, so 0.02 is the compromise.
+export const LIST_VARIANTS: Variants = {
+ hidden: {},
+ show: { transition: { staggerChildren: 0.02, delayChildren: 0.03 } },
+};
+export const ITEM_VARIANTS: Variants = {
+ hidden: { opacity: 0, y: -5, filter: 'blur(2px)' },
+ show: { opacity: 1, y: 0, filter: 'blur(0px)' },
+};
+
+/** Snaps a property to its target: the side not being choreographed, and every reduced-motion path. */
+export const INSTANT_TRANSITION: Transition = { duration: 0 };
diff --git a/src/client/components/ui/select-trigger.tsx b/src/client/components/ui/select-trigger.tsx
new file mode 100644
index 00000000..48ae3923
--- /dev/null
+++ b/src/client/components/ui/select-trigger.tsx
@@ -0,0 +1,109 @@
+import { ChevronDown } from 'lucide-react';
+import { m, type Transition } from 'motion/react';
+import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, ReactNode, RefObject } from 'react';
+import { cn } from '@client/lib/utils';
+import { EASE_OUT } from '@client/lib/ease';
+import { CHEVRON_TRANSITION, INSTANT_TRANSITION, type SelectOption } from './select-shared';
+
+// Gooey: the edge facing the panel snaps flat while attached, then rounds as the two pinch apart.
+const RADIUS_OPEN = [0, 0, 7];
+const RADIUS_CLOSED = [7, 0, 7];
+const RADIUS_TRANSITION_OPEN: Transition = { duration: 0.46, times: [0, 0.4, 1], ease: EASE_OUT };
+const RADIUS_TRANSITION_CLOSED: Transition = { duration: 0.34, times: [0, 0.5, 1], ease: EASE_OUT };
+
+interface SelectTriggerProps {
+ triggerRef: RefObject;
+ triggerId: string;
+ listId: string;
+ labelId: string | undefined;
+ activeDescendantId: string | undefined;
+ open: boolean;
+ isTop: boolean;
+ reduce: boolean;
+ selectedOption: SelectOption | undefined;
+ placeholder: string;
+ leadingIcon: ReactNode;
+ variant: 'page' | 'card';
+ className: string | undefined;
+ style: CSSProperties | undefined;
+ onToggle: () => void;
+ onKeyDown: (e: ReactKeyboardEvent) => void;
+}
+
+export function SelectTrigger({
+ triggerRef,
+ triggerId,
+ listId,
+ labelId,
+ activeDescendantId,
+ open,
+ isTop,
+ reduce,
+ selectedOption,
+ placeholder,
+ leadingIcon,
+ variant,
+ className,
+ style,
+ onToggle,
+ onKeyDown,
+}: SelectTriggerProps) {
+ const kf = open ? RADIUS_OPEN : RADIUS_CLOSED;
+ const kfT = reduce
+ ? INSTANT_TRANSITION
+ : open
+ ? RADIUS_TRANSITION_OPEN
+ : RADIUS_TRANSITION_CLOSED;
+
+ return (
+ also points here.
+ aria-labelledby={labelId ? `${labelId} ${triggerId}` : undefined}
+ aria-activedescendant={activeDescendantId}
+ onClick={onToggle}
+ onKeyDown={onKeyDown}
+ initial={false}
+ animate={{
+ borderTopLeftRadius: isTop ? kf : 7,
+ borderTopRightRadius: isTop ? kf : 7,
+ borderBottomLeftRadius: isTop ? 7 : kf,
+ borderBottomRightRadius: isTop ? 7 : kf,
+ }}
+ transition={{
+ borderTopLeftRadius: isTop ? kfT : INSTANT_TRANSITION,
+ borderTopRightRadius: isTop ? kfT : INSTANT_TRANSITION,
+ borderBottomLeftRadius: isTop ? INSTANT_TRANSITION : kfT,
+ borderBottomRightRadius: isTop ? INSTANT_TRANSITION : kfT,
+ }}
+ style={style}
+ className={cn(
+ 'relative z-10 flex h-9 w-full items-center justify-between gap-2 border border-ui-line px-3 py-2 text-sm font-normal text-ui-default outline-none transition-colors',
+ variant === 'page' ? 'bg-ui-base' : 'bg-ui-fill/50',
+ 'hover:bg-ui-fill/70 focus-visible:ring-2 focus-visible:ring-ui-brand/40 focus-visible:ring-offset-1 focus-visible:ring-offset-background',
+ !selectedOption && 'text-ui-subtle',
+ className,
+ )}
+ >
+
+ {leadingIcon && {leadingIcon} }
+
+ {selectedOption ? selectedOption.label : placeholder}
+
+
+
+
+
+
+ );
+}
diff --git a/src/client/components/ui/select.tsx b/src/client/components/ui/select.tsx
index 240199dd..89170159 100644
--- a/src/client/components/ui/select.tsx
+++ b/src/client/components/ui/select.tsx
@@ -1,5 +1,4 @@
-import { Check, ChevronDown } from 'lucide-react';
-import { motion, type Transition, useReducedMotion, type Variants } from 'motion/react';
+import { domAnimation, LazyMotion, useReducedMotion } from 'motion/react';
import {
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
@@ -12,27 +11,9 @@ import {
} from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@client/lib/utils';
-import { EASE_OUT } from '@client/lib/ease';
-
-// Spring with bounce powers the unfold; per-property timings on the panel choreograph it.
-const CHEVRON_TRANSITION: Transition = { type: 'spring', duration: 0.4, bounce: 0.3 };
-
-// Compounds per option; 0.035 delayed the last item ~0.9s on long lists, so 0.02 is the compromise.
-const LIST_VARIANTS: Variants = {
- hidden: {},
- show: { transition: { staggerChildren: 0.02, delayChildren: 0.03 } },
-};
-const ITEM_VARIANTS: Variants = {
- hidden: { opacity: 0, y: -5, filter: 'blur(2px)' },
- show: { opacity: 1, y: 0, filter: 'blur(0px)' },
-};
-
-type Placement = 'bottom' | 'top';
-
-interface SelectOption {
- value: string;
- label: string;
-}
+import { SelectPanel } from './select-panel';
+import type { Placement, SelectOption, TriggerRect } from './select-shared';
+import { SelectTrigger } from './select-trigger';
interface SelectProps {
value: string;
@@ -64,13 +45,15 @@ export function Select({
const baseId = useId();
const triggerId = `${baseId}-trigger`;
const listId = `${baseId}-list`;
+ const labelId = `${baseId}-label`;
const triggerRef = useRef(null);
+ const labelRef = useRef(null);
const panelRef = useRef(null);
const innerRef = useRef(null);
const [open, setOpen] = useState(false);
const [placement, setPlacement] = useState('bottom');
const [height, setHeight] = useState(0);
- const [rect, setRect] = useState<{ left: number; width: number; top: number; bottom: number } | null>(null);
+ const [rect, setRect] = useState(null);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const optionRefs = useRef>([]);
/** What last moved the highlight - only keyboard moves should auto-scroll. */
@@ -100,6 +83,9 @@ export function Select({
const target = e.target as Node;
if (triggerRef.current?.contains(target)) return;
if (panelRef.current?.contains(target)) return;
+ // The label forwards its click to the trigger, so treating it as "outside" would close the
+ // panel here and let that forwarded click reopen it -- one click, no visible change.
+ if (labelRef.current?.contains(target)) return;
setOpen(false);
};
window.addEventListener('keydown', onKey);
@@ -196,187 +182,76 @@ export function Select({
}
};
- const isTop = placement === 'top';
+ const selectOption = (next: string) => {
+ onValueChange(next);
+ setOpen(false);
+ };
- // Gooey: the edge facing the panel snaps flat while attached, then rounds as the two pinch apart.
- const kf = open ? [0, 0, 7] : [7, 0, 7];
- const kfT: Transition = reduce
- ? { duration: 0 }
- : open
- ? { duration: 0.46, times: [0, 0.4, 1], ease: EASE_OUT }
- : { duration: 0.34, times: [0, 0.5, 1], ease: EASE_OUT };
- const flatT: Transition = { duration: 0 };
+ const highlightOption = (index: number) => {
+ highlightSource.current = 'pointer';
+ setHighlightedIndex(index);
+ };
- const nearGap = open ? 8 : 0;
- const nearRadius = open ? 7 : 0;
- const gapT: Transition = open
- ? { type: 'spring', duration: 0.44, bounce: 0.45, delay: 0.09 }
- : { type: 'spring', duration: 0.26, bounce: 0.1 };
- const radiusT: Transition = open
- ? { duration: 0.26, ease: EASE_OUT, delay: 0.1 }
- : { duration: 0.15, ease: EASE_OUT };
- const instant: Transition = { duration: 0 };
+ const isTop = placement === 'top';
return (
-
- {label && (
-
- {label}
-
- )}
-
-
setOpen((v) => !v)}
- onKeyDown={onTriggerKeyDown}
- initial={false}
- animate={{
- borderTopLeftRadius: isTop ? kf : 7,
- borderTopRightRadius: isTop ? kf : 7,
- borderBottomLeftRadius: isTop ? 7 : kf,
- borderBottomRightRadius: isTop ? 7 : kf,
- }}
- transition={{
- borderTopLeftRadius: isTop ? kfT : flatT,
- borderTopRightRadius: isTop ? kfT : flatT,
- borderBottomLeftRadius: isTop ? flatT : kfT,
- borderBottomRightRadius: isTop ? flatT : kfT,
- }}
- style={triggerStyle}
- className={cn(
- 'relative z-10 flex h-9 w-full items-center justify-between gap-2 border border-ui-line px-3 py-2 text-sm font-normal text-ui-default outline-none transition-colors',
- variant === 'page' ? 'bg-ui-base' : 'bg-ui-fill/50',
- 'hover:bg-ui-fill/70 focus-visible:ring-2 focus-visible:ring-ui-brand/40 focus-visible:ring-offset-1 focus-visible:ring-offset-background',
- !selectedOption && 'text-ui-subtle',
- triggerClassName,
- )}
- >
-
- {leadingIcon && {leadingIcon} }
-
- {selectedOption ? selectedOption.label : placeholder}
-
-
-
+
+ {label && (
+
-
-
-
+ {label}
+
+ )}
+
+ setOpen((v) => !v)}
+ onKeyDown={onTriggerKeyDown}
+ />
+
+ {/* Portaled to so it can't be clipped by an ancestor's stacking context. */}
+ {createPortal(
+
,
+ document.body,
+ )}
-
- {/* Portaled to so it can't be clipped by an ancestor's stacking context. */}
- {createPortal(
- round; far corners stay rounded
- borderTopLeftRadius: isTop ? 7 : nearRadius,
- borderTopRightRadius: isTop ? 7 : nearRadius,
- borderBottomLeftRadius: isTop ? nearRadius : 7,
- borderBottomRightRadius: isTop ? nearRadius : 7,
- }
- }
- transition={
- reduce
- ? { duration: 0.12 }
- : {
- opacity: open ? { duration: 0.18 } : { duration: 0.16, delay: 0.1 },
- height: open
- ? { type: 'spring', duration: 0.4, bounce: 0.14 }
- : { duration: 0.24, ease: EASE_OUT, delay: 0.1 },
- marginTop: isTop ? instant : gapT,
- marginBottom: isTop ? gapT : instant,
- borderTopLeftRadius: isTop ? instant : radiusT,
- borderTopRightRadius: isTop ? instant : radiusT,
- borderBottomLeftRadius: isTop ? radiusT : instant,
- borderBottomRightRadius: isTop ? radiusT : instant,
- }
- }
- style={{
- position: 'fixed',
- left: rect?.left ?? 0,
- width: rect?.width ?? 0,
- top: isTop ? undefined : (rect?.bottom ?? 0),
- bottom: isTop ? window.innerHeight - (rect?.top ?? 0) : undefined,
- transformOrigin: isTop ? 'bottom' : 'top',
- overflow: 'hidden',
- pointerEvents: open ? 'auto' : 'none',
- }}
- // Flush against the trigger, then separates into its own rounded pill.
- className="z-50 border border-ui-line bg-ui-base shadow-lg shadow-black/[0.04] dark:shadow-black/40"
- >
-
- {options.map((option, index) => {
- const selected = option.value === value;
- const highlighted = index === highlightedIndex;
- return (
-
- {
- optionRefs.current[index] = node;
- }}
- id={`${listId}-option-${index}`}
- type="button"
- role="option"
- aria-selected={selected}
- tabIndex={-1}
- onMouseEnter={() => {
- highlightSource.current = 'pointer';
- setHighlightedIndex(index);
- }}
- onClick={() => {
- onValueChange(option.value);
- setOpen(false);
- }}
- className={cn(
- // `whitespace-nowrap`: on the 72px "rows per page" select, the check icon ate the width and "10" wrapped to stacked digits.
- 'flex w-full items-center justify-between gap-2 whitespace-nowrap rounded-md px-2.5 py-1.5 text-left text-sm outline-none transition-colors',
- selected
- ? 'bg-ui-brand/10 font-medium text-ui-brand'
- : 'text-ui-default hover:bg-ui-fill hover:text-ui-strong focus-visible:bg-ui-fill',
- highlighted && !selected && 'bg-ui-fill text-ui-strong',
- )}
- >
- {option.label}
- {selected ? : null}
-
-
- );
- })}
-
- ,
- document.body,
- )}
-
+
);
}
diff --git a/src/client/hooks/use-job-detail.ts b/src/client/hooks/use-job-detail.ts
index cae42a36..308472d4 100644
--- a/src/client/hooks/use-job-detail.ts
+++ b/src/client/hooks/use-job-detail.ts
@@ -179,6 +179,7 @@ export function useJobDetail(id: string) {
const msg = e instanceof Error ? e.message : 'Failed to delete job.';
toast.error('Could not delete the job.', { id: t, description: msg });
setError(msg);
+ } finally {
setIsDeleting(false);
}
};
diff --git a/src/client/hooks/use-provider-settings.ts b/src/client/hooks/use-provider-settings.ts
new file mode 100644
index 00000000..d36e2058
--- /dev/null
+++ b/src/client/hooks/use-provider-settings.ts
@@ -0,0 +1,340 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { toast } from 'sonner';
+import { api, type ProviderPayload } from '@client/lib/api';
+import type { LlmProvider, ModelConfig, ReviewSettings } from '@shared/schema';
+import type { ModelConfigsResponse } from '@shared/api';
+import {
+ normalizeModelRoute,
+ routesEqual,
+ type ModelRouteConfig,
+} from '@client/components/features/models/model-route';
+import {
+ providerDraftDirty,
+ providerHasCredential,
+ providerIsReady,
+ providerToDraft,
+ type NewProviderDraft,
+ type ProviderDraft,
+ type SyncError,
+} from '@client/components/features/settings/settings-support';
+
+/** Alias for the global route normalizer; `test/api/settings.spec.ts` imports it from this module. */
+export const normalizeGlobalConfig = normalizeModelRoute;
+
+const BLANK_NEW_PROVIDER: NewProviderDraft = {
+ preset: 'custom-openai',
+ name: 'Custom OpenAI',
+ apiFormat: 'openai',
+ baseUrl: '',
+ apiKey: '',
+ enabled: true,
+};
+
+// Every provider, model-catalog and default-route value SettingsPage renders, plus the one batched
+// load that hydrates them. `setLoading` / `setSaving` / `setError` and the review-settings hydrator
+// are passed in because the page shares those across both halves of the page; for the same reason
+// `loadConfigs` is returned rather than run from an effect here, so the state it writes lands in the
+// page's own render pass instead of costing a second one.
+export function useProviderSettings({
+ setLoading,
+ setSaving,
+ setError,
+ hydrateReviewSettings,
+}: {
+ setLoading: (value: boolean) => void;
+ setSaving: (value: string | null) => void;
+ setError: (value: string | null) => void;
+ hydrateReviewSettings: (settings: ReviewSettings) => void;
+}) {
+ const [providers, setProviders] = useState
([]);
+ const [savedProviders, setSavedProviders] = useState([]);
+ const [configs, setConfigs] = useState([]);
+ const [globalConfig, setGlobalConfig] = useState(null);
+ const [savedGlobalConfig, setSavedGlobalConfig] = useState(null);
+
+ const [newProvider, setNewProvider] = useState(BLANK_NEW_PROVIDER);
+ const [syncErrors, setSyncErrors] = useState([]);
+ const [catalogRefreshing, setCatalogRefreshing] = useState(false);
+ const [catalogRefreshedOnce, setCatalogRefreshedOnce] = useState(false);
+ const [addingProvider, setAddingProvider] = useState(false);
+ const [expandedProviderId, setExpandedProviderId] = useState(null);
+
+ const existingProviderNames = useMemo(
+ () => new Set(providers.map(provider => provider.name.toLowerCase())),
+ [providers],
+ );
+
+ const selectedProviderNameExists = existingProviderNames.has(newProvider.name.trim().toLowerCase());
+
+ const providerModelCounts = useMemo(
+ () => configs.reduce((counts, config) => {
+ counts.set(config.providerId, (counts.get(config.providerId) ?? 0) + 1);
+ return counts;
+ }, new Map()),
+ [configs],
+ );
+
+ const globalDirty = useMemo(
+ () => !routesEqual(globalConfig, savedGlobalConfig),
+ [globalConfig, savedGlobalConfig],
+ );
+
+ const applyModelConfigResponse = (modelsRes: ModelConfigsResponse) => {
+ // A save on one provider triggers a catalog refresh that can land here mid-edit on other rows; only overwrite rows without an unsaved draft.
+ setProviders(current => modelsRes.providers.map(fresh => {
+ const draft = current.find(item => item.id === fresh.id);
+ const lastKnownSaved = savedProviders.find(item => item.id === fresh.id);
+ if (draft && providerDraftDirty(draft, lastKnownSaved)) {
+ return draft;
+ }
+ return providerToDraft(fresh);
+ }));
+ setSavedProviders(modelsRes.providers);
+ setConfigs(modelsRes.configs);
+ setSyncErrors(modelsRes.syncErrors ?? []);
+ };
+
+ const refreshModelCatalog = async ({ quiet = false }: { quiet?: boolean } = {}) => {
+ if (catalogRefreshing) return;
+ setCatalogRefreshing(true);
+ setSyncErrors([]);
+ const tid = quiet ? null : toast.loading('Syncing providers and models...');
+ try {
+ let savedProviderCount = 0;
+ let failedProviderCount = 0;
+ if (!quiet) {
+ const dirtyProviders = providers.filter(
+ provider => providerDraftDirty(provider, savedProviders.find(saved => saved.id === provider.id)),
+ );
+ if (dirtyProviders.length > 0) {
+ const results = await Promise.all(dirtyProviders.map(provider => persistProvider(provider, { quiet: true })));
+ savedProviderCount = results.filter(Boolean).length;
+ failedProviderCount = results.length - savedProviderCount;
+ }
+ }
+
+ const modelsRes = await api.refreshModelCatalog();
+ applyModelConfigResponse(modelsRes);
+ setCatalogRefreshedOnce(true);
+
+ if (!quiet) {
+ const failedCatalogs = modelsRes.syncErrors?.length ?? 0;
+ const parts: string[] = [];
+ if (savedProviderCount > 0) parts.push(`${savedProviderCount} provider${savedProviderCount === 1 ? '' : 's'} saved`);
+ if (failedProviderCount > 0) parts.push(`${failedProviderCount} provider${failedProviderCount === 1 ? '' : 's'} failed to save`);
+ if (failedCatalogs > 0) parts.push(`${failedCatalogs} provider${failedCatalogs === 1 ? '' : 's'} reported a catalog error`);
+
+ const description = parts.length > 0 ? parts.join(' · ') : 'Providers and model lists are up to date.';
+ if (failedProviderCount > 0 || failedCatalogs > 0) {
+ toast.error('Sync finished with issues', { id: tid ?? undefined, description });
+ } else {
+ toast.success('Sync complete', { id: tid ?? undefined, description });
+ }
+ }
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Catalog refresh failed';
+ setSyncErrors([{ providerId: 'catalog-refresh', providerName: 'Model catalog', error: msg }]);
+ if (!quiet) toast.error('Could not refresh catalog', { id: tid ?? undefined, description: msg });
+ } finally {
+ setCatalogRefreshing(false);
+ }
+ };
+
+ const loadConfigs = async () => {
+ try {
+ const [modelsRes, globalRes, reviewSettingsRes] = await Promise.all([
+ api.getModelConfigs(),
+ api.getGlobalConfig(),
+ api.getReviewSettings(),
+ ]);
+ const nextGlobalConfig = normalizeGlobalConfig(globalRes.config);
+ applyModelConfigResponse(modelsRes);
+ setGlobalConfig(nextGlobalConfig);
+ setSavedGlobalConfig(nextGlobalConfig);
+ hydrateReviewSettings(reviewSettingsRes.settings);
+ return true;
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Failed to load settings';
+ setError(msg);
+ toast.error('Could not load settings', { description: 'Something went wrong fetching your configuration.' });
+ return false;
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // Memoized because the debounced autosave below lists it as a dependency, so it has to be stable
+ // or the 600 ms timer restarts on every render. `setSaving`/`setError` are the page's raw
+ // `useState` setters, which React guarantees are stable -- don't pass inline wrappers instead.
+ const persistGlobalConfig = useCallback(async (next: ModelRouteConfig) => {
+ setSaving('global');
+ setError(null);
+ const tid = toast.loading('Saving model strategy...');
+ try {
+ await api.updateGlobalConfig(next);
+ setSavedGlobalConfig(next);
+ toast.success('Global strategy saved', {
+ id: tid,
+ description: 'Repositories without a custom strategy will use these settings.',
+ });
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Update failed';
+ setError(msg);
+ toast.error('Could not save strategy', { id: tid, description: 'Your changes were not applied.' });
+ } finally {
+ setSaving(null);
+ }
+ }, [setSaving, setError]);
+
+ useEffect(() => {
+ if (!globalConfig || !globalDirty) return;
+ const handle = setTimeout(() => void persistGlobalConfig(globalConfig), 600);
+ return () => clearTimeout(handle);
+ }, [globalConfig, globalDirty, persistGlobalConfig]);
+
+ const updateProviderDraft = (id: string, updates: Partial) => {
+ setProviders(current => current.map(provider => provider.id === id ? { ...provider, ...updates } : provider));
+ };
+
+ const persistProvider = async (
+ provider: ProviderDraft,
+ { quiet = false, clearApiKey = false, successMessage }: { quiet?: boolean; clearApiKey?: boolean; successMessage?: string } = {},
+ ) => {
+ if (provider.enabled && !clearApiKey && !providerHasCredential(provider)) {
+ if (!quiet) {
+ setExpandedProviderId(provider.id);
+ toast.error('Add an API key before enabling this provider.');
+ }
+ return null;
+ }
+
+ setSaving(`provider:${provider.id}`);
+ setError(null);
+ const tid = quiet ? null : toast.loading('Saving provider...');
+ try {
+ const payload: ProviderPayload = {
+ name: provider.name,
+ apiFormat: provider.apiFormat,
+ baseUrl: provider.baseUrl || null,
+ enabled: provider.enabled,
+ };
+ if (clearApiKey) {
+ payload.clearApiKey = true;
+ } else if (provider.apiKey.trim()) {
+ payload.apiKey = provider.apiKey.trim();
+ }
+ const { provider: saved } = await api.updateProvider(provider.id, payload);
+ setProviders(current => current.map(item => item.id === saved.id ? providerToDraft(saved) : item));
+ setSavedProviders(current => current.map(item => item.id === saved.id ? saved : item));
+ if (!quiet) toast.success(successMessage ?? 'Provider saved', { id: tid ?? undefined });
+ return saved;
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Provider update failed';
+ setError(msg);
+ toast.error('Could not save provider', { id: tid ?? undefined, description: msg });
+ return null;
+ } finally {
+ setSaving(null);
+ }
+ };
+
+ const saveProvider = async (provider: ProviderDraft) => {
+ const saved = await persistProvider(provider);
+ if (saved && saved.enabled && (saved.hasApiKey || saved.apiFormat === 'cloudflare-workers-ai')) {
+ void refreshModelCatalog({ quiet: true });
+ }
+ };
+
+ const clearProviderKey = async (provider: ProviderDraft) => {
+ // Build from the last saved state, not the draft, so clearing the key doesn't persist unrelated unsaved edits; a provider without a key can't stay enabled.
+ const saved = savedProviders.find(item => item.id === provider.id);
+ const base = saved ? providerToDraft(saved) : provider;
+ await persistProvider(
+ { ...base, apiKey: '', enabled: false },
+ { clearApiKey: true, successMessage: 'API key removed' },
+ );
+ };
+
+ const createProvider = async () => {
+ if (!newProvider.name.trim() || selectedProviderNameExists) return;
+ setSaving('provider:new');
+ setError(null);
+ const tid = toast.loading('Creating provider...');
+ try {
+ const { provider } = await api.createProvider({
+ name: newProvider.name.trim(),
+ apiFormat: newProvider.apiFormat,
+ baseUrl: newProvider.baseUrl.trim() || null,
+ apiKey: newProvider.apiKey.trim() || undefined,
+ enabled: newProvider.enabled,
+ });
+ setProviders(current => [...current, providerToDraft(provider)]);
+ setSavedProviders(current => [...current, provider]);
+ setNewProvider(BLANK_NEW_PROVIDER);
+ setAddingProvider(false);
+ toast.success('Provider created', { id: tid });
+ if (provider.enabled && (provider.hasApiKey || provider.apiFormat === 'cloudflare-workers-ai')) {
+ void refreshModelCatalog({ quiet: true });
+ }
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Provider creation failed';
+ setError(msg);
+ toast.error('Could not create provider', { id: tid, description: msg });
+ } finally {
+ setSaving(null);
+ }
+ };
+
+ const removeProvider = async (id: string) => {
+ setSaving(`provider:${id}`);
+ setError(null);
+ const tid = toast.loading('Deleting provider...');
+ try {
+ await api.deleteProvider(id);
+ setProviders(current => current.filter(provider => provider.id !== id));
+ setSavedProviders(current => current.filter(provider => provider.id !== id));
+ toast.success('Provider deleted', { id: tid });
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : 'Provider delete failed';
+ setError(msg);
+ toast.error('Could not delete provider', { id: tid, description: msg });
+ } finally {
+ setSaving(null);
+ }
+ };
+
+ const newProviderReady = newProvider.name.trim().length > 0 &&
+ newProvider.baseUrl.trim().length > 0 &&
+ newProvider.apiKey.trim().length > 0 &&
+ !selectedProviderNameExists;
+
+ const configuredProviderCount = providers.filter(providerIsReady).length;
+
+ return {
+ providers,
+ savedProviders,
+ configs,
+ globalConfig,
+ setGlobalConfig,
+ newProvider,
+ setNewProvider,
+ syncErrors,
+ catalogRefreshing,
+ catalogRefreshedOnce,
+ addingProvider,
+ setAddingProvider,
+ expandedProviderId,
+ setExpandedProviderId,
+ providerModelCounts,
+ selectedProviderNameExists,
+ newProviderReady,
+ configuredProviderCount,
+ loadConfigs,
+ refreshModelCatalog,
+ updateProviderDraft,
+ saveProvider,
+ removeProvider,
+ clearProviderKey,
+ createProvider,
+ };
+}
diff --git a/src/client/hooks/use-stats-range.ts b/src/client/hooks/use-stats-range.ts
new file mode 100644
index 00000000..b33f8cae
--- /dev/null
+++ b/src/client/hooks/use-stats-range.ts
@@ -0,0 +1,36 @@
+import { useCallback, useSyncExternalStore } from 'react';
+
+export const DEFAULT_STATS_DAYS = 14;
+
+/**
+ * The stats time range, shared by the dashboard and the stats page so navigating between them
+ * doesn't silently swap the range out from under the reader.
+ *
+ * Module state, deliberately not persisted: it lives as long as the SPA session and a fresh page
+ * load starts back at {@link DEFAULT_STATS_DAYS}.
+ */
+let days = DEFAULT_STATS_DAYS;
+const listeners = new Set<() => void>();
+
+function subscribe(listener: () => void) {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
+
+function getSnapshot() {
+ return days;
+}
+
+export function useStatsRange(): [number, (next: number) => void] {
+ const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
+
+ const setDays = useCallback((next: number) => {
+ if (next === days) return;
+ days = next;
+ for (const listener of listeners) listener();
+ }, []);
+
+ return [value, setDays];
+}
diff --git a/src/client/lib/batch-groups.ts b/src/client/lib/batch-groups.ts
new file mode 100644
index 00000000..aeaf649d
--- /dev/null
+++ b/src/client/lib/batch-groups.ts
@@ -0,0 +1,26 @@
+import type { FileReviewRecord } from '@shared/schema';
+
+// Bin membership is never persisted (pack.ts derives it rather than storing it). But every file in
+// a bin is written with the SAME shared response, so grouping on `rawAiOutput` reconstructs the bins
+// exactly. Two different bins producing byte-identical JSON is not a real possibility: the payload
+// names each file it covers.
+export type BatchGroup = { index: number; paths: string[] };
+
+export function groupBatches(files: FileReviewRecord[]): Map {
+ const byResponse = new Map();
+
+ for (const file of files) {
+ // 1 means reviewed alone, null predates batching, and a failed row has no response to group on.
+ if ((file.batchSize ?? 1) <= 1 || !file.rawAiOutput) continue;
+ const existing = byResponse.get(file.rawAiOutput);
+ if (existing) existing.paths.push(file.filePath);
+ else byResponse.set(file.rawAiOutput, { index: byResponse.size + 1, paths: [file.filePath] });
+ }
+
+ // Re-keyed by path, because a row only knows its own identity.
+ const byPath = new Map();
+ for (const group of byResponse.values()) {
+ for (const path of group.paths) byPath.set(path, group);
+ }
+ return byPath;
+}
diff --git a/src/client/lib/file-tree.ts b/src/client/lib/file-tree.ts
index 0352fd07..63461c74 100644
--- a/src/client/lib/file-tree.ts
+++ b/src/client/lib/file-tree.ts
@@ -43,7 +43,7 @@ export function buildTree(files: FileReviewRecord[]): TreeNode[] {
// Folders before files, each alphabetical - matches GitHub's ordering.
function sortNodes(nodes: TreeNode[]): TreeNode[] {
- const sorted = [...nodes].sort((a, b) => {
+ const sorted = nodes.toSorted((a, b) => {
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
return a.name.localeCompare(b.name);
});
diff --git a/src/client/lib/theme.tsx b/src/client/lib/theme.tsx
index ba2cfcb5..4275a3ed 100644
--- a/src/client/lib/theme.tsx
+++ b/src/client/lib/theme.tsx
@@ -1,4 +1,4 @@
-import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
+import React, { createContext, useContext, useState, useEffect, useCallback, useMemo } from 'react';
export type Theme = 'light' | 'dark';
@@ -69,12 +69,8 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
}, []);
const toggleTheme = useCallback(() => {
- setThemeState((prev) => {
- const next = prev === 'light' ? 'dark' : 'light';
- applyTheme(next, { pauseTransitions: true });
- return next;
- });
- }, []);
+ setTheme(theme === 'light' ? 'dark' : 'light');
+ }, [theme, setTheme]);
useEffect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)');
@@ -87,11 +83,9 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
return () => media.removeEventListener('change', handler);
}, []);
- return (
-
- {children}
-
- );
+ const value = useMemo(() => ({ theme, toggleTheme, setTheme }), [theme, toggleTheme, setTheme]);
+
+ return {children} ;
}
export function useTheme() {
diff --git a/src/client/main.tsx b/src/client/main.tsx
index 9c6f6c3d..f808fd4d 100644
--- a/src/client/main.tsx
+++ b/src/client/main.tsx
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { Toaster } from 'sonner';
import { AppShell } from './components/layout/app-shell';
+import { RouteErrorBoundary } from './components/shared/route-error-boundary';
const LandingPage = React.lazy(() => import('./pages/landing').then(m => ({ default: m.LandingPage })));
const DashboardPage = React.lazy(() => import('./pages/dashboard').then(m => ({ default: m.DashboardPage })));
@@ -52,62 +53,45 @@ function ToasterWrapper() {
);
}
-class ErrorBoundary extends React.Component<{ fallback?: React.ReactNode, children: React.ReactNode }, { error: Error | null }> {
- constructor(props: { fallback?: React.ReactNode, children: React.ReactNode }) {
- super(props);
- this.state = { error: null };
- }
- static getDerivedStateFromError(error: Error) { return { error }; }
- componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
- console.error("ErrorBoundary caught an error:", error, errorInfo);
- }
- render() {
- if (this.state.error) {
- if (this.props.fallback) return this.props.fallback;
- return (
-
-
An error occurred rendering this component:
-
{this.state.error.toString()}
-
- );
- }
- return this.props.children;
- }
-}
-
+// Render failures (including a failed lazy chunk) bubble to the branch's
+// `errorElement` so there is one styled fallback instead of two.
const withSuspense = (Component: React.ComponentType, isFullPage = false) => (
-
- }>
-
-
-
+ }>
+
+
);
const router = createBrowserRouter([
{
path: '/',
element: withSuspense(LandingPage, true),
+ errorElement: ,
},
{
path: '/login',
element: withSuspense(LoginPage, true),
+ errorElement: ,
},
{
element: ,
+ errorElement: ,
+ // Per child too, not just on the layout: React Router replaces the whole matched branch, so a
+ // boundary only on the branch would take the sidebar and header down with a single page.
children: [
- { path: 'dashboard', element: withSuspense(DashboardPage) },
- { path: 'jobs', element: withSuspense(JobsPage) },
- { path: 'jobs/:id', element: withSuspense(JobDetailPage) },
- { path: 'jobs/:id/logs', element: withSuspense(JobLogsPage) },
- { path: 'repos', element: withSuspense(ReposPage) },
- { path: 'stats', element: withSuspense(StatsPage) },
- { path: 'settings', element: withSuspense(SettingsPage) },
- { path: 'account', element: withSuspense(AccountPage) },
+ { path: 'dashboard', element: withSuspense(DashboardPage), errorElement: },
+ { path: 'jobs', element: withSuspense(JobsPage), errorElement: },
+ { path: 'jobs/:id', element: withSuspense(JobDetailPage), errorElement: },
+ { path: 'jobs/:id/logs', element: withSuspense(JobLogsPage), errorElement: },
+ { path: 'repos', element: withSuspense(ReposPage), errorElement: },
+ { path: 'stats', element: withSuspense(StatsPage), errorElement: },
+ { path: 'settings', element: withSuspense(SettingsPage), errorElement: },
+ { path: 'account', element: withSuspense(AccountPage), errorElement: },
],
},
{
path: '*',
element: withSuspense(NotFoundPage, true),
+ errorElement: ,
},
]);
diff --git a/src/client/pages/account.tsx b/src/client/pages/account.tsx
index ea4f6295..0d4e555e 100644
--- a/src/client/pages/account.tsx
+++ b/src/client/pages/account.tsx
@@ -1,51 +1,17 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import { api } from '@client/lib/api';
import { PageHeader } from '@client/components/layout/page-header';
-import { Button, LinkButton } from '@client/components/ui/button';
-import { Input } from '@client/components/ui/input';
-import { Badge } from '@client/components/ui/badge';
-import { Text } from '@client/components/ui/text';
-import { Skeleton } from '@client/components/shared/skeleton';
import { LoadError } from '@client/components/shared/load-error';
-import { SectionCard } from '@client/components/shared/section-card';
-import { Select } from '@client/components/ui/select';
-import { ExternalLink, Mail, Pencil, Check, X } from 'lucide-react';
-import { GithubMark } from '@client/components/shared/github-mark';
import {
- COMMON_TIME_ZONES,
- DEFAULT_TIME_ZONE,
- browserTimeZone,
- formatDateTime,
getStoredTimeZone,
resolvedTimeZone,
setStoredTimeZone,
- timeZoneOffsetLabel,
} from '@client/lib/timezone';
import type { AccountSettings, AuthSessionUser } from '@shared/api';
-import { DetailGroup, RevealOnClick, DetailRow } from '@client/components/features/account/detail-rows';
-// No "Automatic" option: defaults to UTC so timestamps read the same for everyone; the browser's own zone is folded into the list.
-function zoneOptions() {
- const zones = Array.from(new Set([DEFAULT_TIME_ZONE, ...COMMON_TIME_ZONES, browserTimeZone()]))
- .sort((a, b) => a.localeCompare(b));
- return zones.map((zone) => {
- const offset = timeZoneOffsetLabel(zone);
- return { value: zone, label: offset ? `${zone} · ${offset}` : zone };
- });
-}
-
-
-function formatDate(value: string) {
- return formatDateTime(value, {
- year: 'numeric',
- month: 'long',
- day: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- timeZoneName: 'short',
- });
-}
+import { ProfileCard } from '@client/components/features/account/profile-card';
+import { AccountDetailsSection } from '@client/components/features/account/details-section';
export function AccountPage() {
const [user, setUser] = useState(null);
@@ -53,10 +19,8 @@ export function AccountPage() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
- const [editingName, setEditingName] = useState(false);
- const [nameDraft, setNameDraft] = useState('');
- const [savingName, setSavingName] = useState(false);
- const [savingZone, setSavingZone] = useState(false);
+ // A ref, not state: it only guards concurrent saves and is never rendered.
+ const savingZone = useRef(false);
// State-driven, not a render-time localStorage read - that wasn't reactive and never reflected a save.
const [zonePref, setZonePref] = useState(() => getStoredTimeZone());
@@ -82,11 +46,12 @@ export function AccountPage() {
};
const saveTimezone = async (zone: string) => {
+ if (savingZone.current) return;
const previous = zonePref;
// Optimistic: reflects the choice immediately and reverts if the server rejects it.
setZonePref(zone);
setStoredTimeZone(zone);
- setSavingZone(true);
+ savingZone.current = true;
try {
const res = await api.updateAccountTimezone(zone);
setAccount(res.account);
@@ -102,7 +67,7 @@ export function AccountPage() {
description: e instanceof Error ? e.message : undefined,
});
} finally {
- setSavingZone(false);
+ savingZone.current = false;
}
};
@@ -110,41 +75,12 @@ export function AccountPage() {
void load();
}, []);
- // Built once - a fresh array each render gave Select a new `options` identity, re-firing its highlight/measure effects and jittering the open panel.
- const zoneOpts = useMemo(() => zoneOptions(), []);
-
// Falls back to GitHub profile name (then login) until the user sets their own.
const displayName =
account?.accountName?.trim() || user?.name?.trim() || user?.login || 'GitHub user';
const initial = displayName.charAt(0).toUpperCase();
const profileUrl = user ? `https://github.com/${user.login}` : 'https://github.com';
- const startEditName = () => {
- setNameDraft(displayName);
- setEditingName(true);
- };
-
- const saveName = async () => {
- const trimmed = nameDraft.trim();
- if (!trimmed) {
- toast.error('Name cannot be empty.');
- return;
- }
- setSavingName(true);
- try {
- const res = await api.updateAccountName(trimmed);
- setAccount(res.account);
- setEditingName(false);
- toast.success('Account name updated');
- } catch (e) {
- toast.error('Could not update name', {
- description: e instanceof Error ? e.message : undefined,
- });
- } finally {
- setSavingName(false);
- }
- };
-
// Skeletons replace content only; chrome and labels stay rendered so the page doesn't reflow when data lands.
const pending = loading || !user;
@@ -166,184 +102,23 @@ export function AccountPage() {
{(loading || user) && (
<>
-
-
- {pending ? (
-
- ) : user!.avatarUrl ? (
-
- ) : (
-
- {initial}
-
- )}
-
-
- {pending ? (
-
-
-
-
- ) : editingName ? (
-
-
-
setNameDraft(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter') void saveName();
- if (e.key === 'Escape') setEditingName(false);
- }}
- autoFocus
- maxLength={120}
- aria-label="Account name"
- className="h-9 w-full max-w-xs"
- />
-
- }
- >
- Save
-
- setEditingName(false)}
- disabled={savingName}
- icon={ }
- className="text-ui-subtle hover:text-ui-default"
- >
- Cancel
-
-
-
-
- Enter to save · Esc to cancel - this name is used across Codra.
-
-
- ) : (
- <>
-
-
- {displayName}
-
-
-
-
-
-
-
- @{user!.login}
-
-
-
- GitHub
-
-
- >
- )}
-
-
- {pending ? (
-
- ) : (
-
}
- className="shrink-0 self-start sm:self-auto"
- >
- View on GitHub
-
-
- )}
-
-
-
-
-
-
-
- {displayName}
-
-
- {user?.email ? (
-
-
- {user.email}
-
- ) : (
- Not provided
- )}
-
-
-
-
-
- @{user?.login}
-
-
- {user?.githubUserId}
-
-
-
-
- {(pending || account) && (
-
- {account?.id}
-
- )}
-
- {user ? formatDate(user.signedInAt) : null}
-
-
- {/* Timestamps are stored absolute (UTC); this only controls how they're rendered. */}
-
-
-
- Date & time zone
-
-
- Stored in UTC, shown in {resolvedTimeZone()}
-
-
- {pending ? (
-
- ) : (
-
- { if (!savingZone) void saveTimezone(v); }}
- options={zoneOpts}
- variant="card"
- triggerClassName="h-8 px-2.5 text-[13px]"
- />
-
- )}
-
-
-
-
+
+
+ void saveTimezone(zone)}
+ />
>
)}
diff --git a/src/client/pages/dashboard.tsx b/src/client/pages/dashboard.tsx
index 56c3d486..80a82051 100644
--- a/src/client/pages/dashboard.tsx
+++ b/src/client/pages/dashboard.tsx
@@ -11,6 +11,7 @@ import { Button } from '@client/components/ui/button';
import { PageHeader } from '@client/components/layout/page-header';
import { OverviewStats } from '@client/components/features/stats/overview-stats';
import { usePolling } from '@client/hooks/use-polling';
+import { useStatsRange } from '@client/hooks/use-stats-range';
import { LoadError } from '@client/components/shared/load-error';
export function DashboardPage() {
@@ -20,7 +21,7 @@ export function DashboardPage() {
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState(null);
- const [days, setDays] = useState(14);
+ const [days, setDays] = useStatsRange();
// Clears stats to show skeletons while the new range loads; recent-jobs is range-independent and keeps its data.
const changeDays = (next: number) => {
diff --git a/src/client/pages/job-detail.tsx b/src/client/pages/job-detail.tsx
index aa2b8599..cde2fb47 100644
--- a/src/client/pages/job-detail.tsx
+++ b/src/client/pages/job-detail.tsx
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
-import { motion } from 'motion/react';
+import { LazyMotion, m, domMax } from 'motion/react';
import { ClipboardList, FileDiff } from 'lucide-react';
import { LoadError } from '@client/components/shared/load-error';
import { useJobDetail } from '@client/hooks/use-job-detail';
@@ -60,34 +60,37 @@ export function JobDetailPage() {
-
- {TABS.map(({ id: tabId, label, icon: Icon }) => {
- const active = tab === tabId;
- return (
- setTab(tabId)}
- className={cn(
- 'relative -mb-px flex items-center gap-2 px-3 py-2.5 text-[13px] transition-colors',
- active ? 'font-medium text-ui-strong' : 'text-ui-subtle hover:text-ui-default',
- )}
- >
-
- {label}
- {active && (
-
- )}
-
- );
- })}
-
+ {/* domMax, not domAnimation: the underline uses `layoutId`, which needs the layout feature. */}
+
+
+ {TABS.map(({ id: tabId, label, icon: Icon }) => {
+ const active = tab === tabId;
+ return (
+ setTab(tabId)}
+ className={cn(
+ 'relative -mb-px flex items-center gap-2 px-3 py-2.5 text-[13px] transition-colors',
+ active ? 'font-medium text-ui-strong' : 'text-ui-subtle hover:text-ui-default',
+ )}
+ >
+
+ {label}
+ {active && (
+
+ )}
+
+ );
+ })}
+
+
{tab === 'overview' ? (
diff --git a/src/client/pages/job-logs.tsx b/src/client/pages/job-logs.tsx
index c61fd6d7..0f450608 100644
--- a/src/client/pages/job-logs.tsx
+++ b/src/client/pages/job-logs.tsx
@@ -4,6 +4,8 @@ import { LoadError } from '@client/components/shared/load-error';
import { CopyButton } from '@client/components/shared/copy-button';
import { preventToggleOnTextSelection } from '@client/lib/selection';
import { readDiffsCache, writeDiffsCache } from '@client/lib/diffs-cache';
+import { groupBatches } from '@client/lib/batch-groups';
+import type { BatchGroup } from '@client/lib/batch-groups';
import {
ChevronLeft, FileCode2, Clock, Cpu, Hash, Layers, MessageSquare,
AlertCircle, CheckCircle2, SkipForward, Hourglass,
@@ -36,31 +38,6 @@ const STATUS_META: Record
{
- const byResponse = new Map();
-
- for (const file of files) {
- // 1 means reviewed alone, null predates batching, and a failed row has no response to group on.
- if ((file.batchSize ?? 1) <= 1 || !file.rawAiOutput) continue;
- const existing = byResponse.get(file.rawAiOutput);
- if (existing) existing.paths.push(file.filePath);
- else byResponse.set(file.rawAiOutput, { index: byResponse.size + 1, paths: [file.filePath] });
- }
-
- // Re-keyed by path, because a row only knows its own identity.
- const byPath = new Map();
- for (const group of byResponse.values()) {
- for (const path of group.paths) byPath.set(path, group);
- }
- return byPath;
-}
-
function withheldTotal(file: FileReviewRecord): number {
const counts = file.withheldCounts;
if (!counts) return 0;
@@ -125,7 +102,7 @@ function FileRow({ file, diffsLoading, batch }: { file: FileReviewRecord; diffsL
{inTok ?? '-'}↑ {outTok ?? '-'}↓
)}
- {batchSize && (
+ {batchSize !== null && (
{modelShort} }
{duration && {duration} }
{inTok && {inTok}↑ {outTok ?? '-'}↓ }
- {batchSize && batch {batch?.index ?? '?'} · ×{batchSize} }
+ {batchSize !== null && batch {batch?.index ?? '?'} · ×{batchSize} }
{file.fileStatus === 'done' && (
{kept} kept{withheld > 0 ? `, ${withheld} withheld` : ''}
)}
diff --git a/src/client/pages/landing.tsx b/src/client/pages/landing.tsx
index 53d6473a..ff96e057 100644
--- a/src/client/pages/landing.tsx
+++ b/src/client/pages/landing.tsx
@@ -155,7 +155,7 @@ export function LandingPage() {
key={item.title}
className="group relative overflow-hidden rounded-xl border border-ui-line bg-ui-base p-4 transition-colors duration-200 hover:border-ui-brand/40"
>
-
+
diff --git a/src/client/pages/login.tsx b/src/client/pages/login.tsx
index dce174f9..63cc1133 100644
--- a/src/client/pages/login.tsx
+++ b/src/client/pages/login.tsx
@@ -82,7 +82,7 @@ export function LoginPage() {
Sign in with GitHub
diff --git a/src/client/pages/repos.tsx b/src/client/pages/repos.tsx
index 2a6fbbbc..2ba51124 100644
--- a/src/client/pages/repos.tsx
+++ b/src/client/pages/repos.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo, useReducer, useState } from 'react';
import { toast } from 'sonner';
import { api } from '@client/lib/api';
import { Skeleton } from '@client/components/shared/skeleton';
@@ -17,21 +17,79 @@ import {
type ModelOption,
type ModelRouteConfig,
type ProviderOption,
-} from '@client/components/features/models/model-chain';
+} from '@client/components/features/models/model-route';
import { RepoRow } from '@client/components/features/repos/repo-row';
import { RepoModelModal } from '@client/components/features/repos/repo-model-modal';
import { repoId, hasMeaningfulCustomStrategy } from '@client/components/features/repos/repo-route';
+
+type ReposState = {
+ repos: RepoConfigRecord[];
+ globalConfig: ModelRouteConfig;
+ modelOptions: ModelOption[];
+ providerOptions: ProviderOption[];
+ error: string | null;
+ loading: boolean;
+};
+
+type ReposAction =
+ | { type: 'load-started' }
+ | {
+ type: 'load-succeeded';
+ repos: RepoConfigRecord[];
+ globalConfig: ModelRouteConfig;
+ modelOptions: ModelOption[];
+ providerOptions: ProviderOption[];
+ }
+ | { type: 'load-failed'; message: string }
+ | { type: 'sync-started' }
+ | { type: 'sync-failed'; message: string }
+ | { type: 'repo-merged'; targetId: string; updates: Partial };
+
+const INITIAL_REPOS_STATE: ReposState = {
+ repos: [],
+ globalConfig: EMPTY_MODEL_ROUTE,
+ modelOptions: [],
+ providerOptions: [],
+ error: null,
+ loading: true,
+};
+
+function reposReducer(state: ReposState, action: ReposAction): ReposState {
+ switch (action.type) {
+ case 'load-started':
+ return { ...state, loading: true };
+ case 'load-succeeded':
+ return {
+ ...state,
+ repos: action.repos,
+ globalConfig: action.globalConfig,
+ modelOptions: action.modelOptions,
+ providerOptions: action.providerOptions,
+ loading: false,
+ };
+ case 'load-failed':
+ return { ...state, error: action.message, loading: false };
+ case 'sync-started':
+ return { ...state, error: null };
+ case 'sync-failed':
+ return { ...state, error: action.message };
+ case 'repo-merged':
+ return {
+ ...state,
+ repos: state.repos.map(repo =>
+ repoId(repo) === action.targetId ? { ...repo, ...action.updates } : repo,
+ ),
+ };
+ }
+}
+
export function ReposPage() {
- const [repos, setRepos] = useState([]);
- const [globalConfig, setGlobalConfig] = useState(EMPTY_MODEL_ROUTE);
- const [modelOptions, setModelOptions] = useState([]);
- const [providerOptions, setProviderOptions] = useState([]);
- const [error, setError] = useState(null);
+ const [{ repos, globalConfig, modelOptions, providerOptions, error, loading }, dispatch] =
+ useReducer(reposReducer, INITIAL_REPOS_STATE);
const [syncing, setSyncing] = useState(false);
- const [loading, setLoading] = useState(true);
const [editingRepoId, setEditingRepoId] = useState(null);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('');
@@ -57,7 +115,7 @@ export function ReposPage() {
const enabledCount = repos.filter(repo => repo.enabled).length;
const loadRepos = () => {
- setLoading(true);
+ dispatch({ type: 'load-started' });
Promise.all([
api.getRepos(),
api.getGlobalConfig(),
@@ -68,30 +126,30 @@ export function ReposPage() {
const providers = Array.isArray(modelsRes?.providers) ? modelsRes.providers : [];
const configs = Array.isArray(modelsRes?.configs) ? modelsRes.configs : [];
- setRepos(nextRepos);
- setGlobalConfig(normalizeModelRoute(globalRes?.config));
- setProviderOptions(providers.map(provider => ({ value: provider.id, label: provider.name })));
- setModelOptions(configs.map(config => ({
- value: config.modelId,
- label: `${config.providerName} / ${config.modelName}`,
- providerId: config.providerId,
- })));
- setLoading(false);
+ dispatch({
+ type: 'load-succeeded',
+ repos: nextRepos,
+ globalConfig: normalizeModelRoute(globalRes?.config),
+ providerOptions: providers.map(provider => ({ value: provider.id, label: provider.name })),
+ modelOptions: configs.map(config => ({
+ value: config.modelId,
+ label: `${config.providerName} / ${config.modelName}`,
+ providerId: config.providerId,
+ })),
+ });
})
.catch(e => {
- setError(e instanceof Error ? e.message : 'Failed to load repositories.');
- setLoading(false);
+ dispatch({
+ type: 'load-failed',
+ message: e instanceof Error ? e.message : 'Failed to load repositories.',
+ });
});
};
useEffect(() => { loadRepos(); }, []);
const mergeRepo = (targetId: string, updates: Partial) => {
- setRepos(current =>
- current.map(repo =>
- repoId(repo) === targetId ? { ...repo, ...updates } : repo,
- ),
- );
+ dispatch({ type: 'repo-merged', targetId, updates });
};
const handleToggleEnabled = async (repo: RepoConfigRecord, nextEnabled: boolean) => {
@@ -138,7 +196,7 @@ export function ReposPage() {
const handleSync = async () => {
if (syncing) return;
setSyncing(true);
- setError(null);
+ dispatch({ type: 'sync-started' });
const tid = toast.loading('Syncing with GitHub…');
try {
const result = await api.syncRepos();
@@ -152,7 +210,7 @@ export function ReposPage() {
loadRepos();
} catch (e) {
const msg = e instanceof Error ? e.message : 'Sync failed.';
- setError(msg);
+ dispatch({ type: 'sync-failed', message: msg });
toast.error('Sync failed', { id: tid, description: 'Could not reach GitHub. Check your connection and try again.' });
} finally {
setSyncing(false);
diff --git a/src/client/pages/settings.tsx b/src/client/pages/settings.tsx
index 3f447980..f927a1f7 100644
--- a/src/client/pages/settings.tsx
+++ b/src/client/pages/settings.tsx
@@ -1,67 +1,24 @@
-import { useEffect, useMemo, useState } from 'react';
-import { toast } from 'sonner';
-import { api, type ProviderPayload } from '@client/lib/api';
+import { useEffect, useState } from 'react';
import { PageHeader } from '@client/components/layout/page-header';
import { Button } from '@client/components/ui/button';
import { Alert } from '@client/components/ui/alert';
-import { Skeleton } from '@client/components/shared/skeleton';
import { LoadError } from '@client/components/shared/load-error';
-import { Input } from '@client/components/ui/input';
-import { Select } from '@client/components/ui/select';
import { RefreshCw, Plus, X } from 'lucide-react';
-import type { LlmProvider, ModelConfig } from '@shared/schema';
-import type { ModelConfigsResponse } from '@shared/api';
-import {
- ModelRouteEditor,
- normalizeModelRoute,
- routesEqual,
- type ModelOption,
- type ModelRouteConfig,
- type ProviderOption,
-} from '@client/components/features/models/model-chain';
import { cn } from '@client/lib/utils';
-import {
- FieldLabel,
- PROVIDER_PRESETS,
- apiKeyFieldLabel,
- providerDraftDirty,
- providerHasCredential,
- providerIsReady,
- providerKeyPlaceholder,
- providerToDraft,
- type NewProviderDraft,
- type ProviderDraft,
- type SyncError,
-} from '@client/components/features/settings/settings-support';
import { AboutSection } from '@client/components/features/settings/about-section';
-import { ProviderRow } from '@client/components/features/settings/provider-row';
+import { DefaultModelsSection } from '@client/components/features/settings/default-models-section';
+import { NewProviderForm } from '@client/components/features/settings/new-provider-form';
+import { ProviderList } from '@client/components/features/settings/provider-list';
import { ReviewSection } from '@client/components/features/settings/review-section';
import { useReviewSettings } from '@client/hooks/use-review-settings';
-
-/** Named export kept here because `test/settings.spec.ts` imports it from this module. */
-export const normalizeGlobalConfig = normalizeModelRoute;
-
+import { useProviderSettings } from '@client/hooks/use-provider-settings';
export function SettingsPage() {
- const [providers, setProviders] = useState([]);
- const [savedProviders, setSavedProviders] = useState([]);
- const [configs, setConfigs] = useState([]);
- const [globalConfig, setGlobalConfig] = useState(null);
- const [savedGlobalConfig, setSavedGlobalConfig] = useState(null);
-
- const [newProvider, setNewProvider] = useState({
- preset: 'custom-openai',
- name: 'Custom OpenAI',
- apiFormat: 'openai',
- baseUrl: '',
- apiKey: '',
- enabled: true,
- });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(null);
const [error, setError] = useState(null);
- // Review-settings half of the page. Hydrated below from the same combined load as the providers.
+ // Review-settings half of the page. Hydrated by the provider hook from the same combined load.
const {
reviewSettings,
maxFilesDraft,
@@ -74,131 +31,37 @@ export function SettingsPage() {
commitMaxFiles,
applyPendingConfirm,
} = useReviewSettings({ setSaving, setError });
- const [syncErrors, setSyncErrors] = useState([]);
- const [catalogRefreshing, setCatalogRefreshing] = useState(false);
- const [catalogRefreshedOnce, setCatalogRefreshedOnce] = useState(false);
- const [addingProvider, setAddingProvider] = useState(false);
- const [expandedProviderId, setExpandedProviderId] = useState(null);
-
- const providerOptions: ProviderOption[] = useMemo(
- () => providers.map(provider => ({ value: provider.id, label: provider.name })),
- [providers],
- );
-
- const modelOptions: ModelOption[] = useMemo(
- () => configs.map(config => ({
- value: config.modelId,
- label: `${config.providerName} / ${config.modelName}`,
- providerId: config.providerId,
- })),
- [configs],
- );
-
- const existingProviderNames = useMemo(
- () => new Set(providers.map(provider => provider.name.toLowerCase())),
- [providers],
- );
-
- const selectedPreset = PROVIDER_PRESETS.find(preset => preset.value === newProvider.preset) ?? PROVIDER_PRESETS[0];
- const selectedProviderNameExists = existingProviderNames.has(newProvider.name.trim().toLowerCase());
-
- const providerModelCounts = useMemo(
- () => configs.reduce((counts, config) => {
- counts.set(config.providerId, (counts.get(config.providerId) ?? 0) + 1);
- return counts;
- }, new Map()),
- [configs],
- );
-
- const globalDirty = useMemo(
- () => !routesEqual(globalConfig, savedGlobalConfig),
- [globalConfig, savedGlobalConfig],
- );
-
- const applyModelConfigResponse = (modelsRes: ModelConfigsResponse) => {
- // A save on one provider triggers a catalog refresh that can land here mid-edit on other rows; only overwrite rows without an unsaved draft.
- setProviders(current => modelsRes.providers.map(fresh => {
- const draft = current.find(item => item.id === fresh.id);
- const lastKnownSaved = savedProviders.find(item => item.id === fresh.id);
- if (draft && providerDraftDirty(draft, lastKnownSaved)) {
- return draft;
- }
- return providerToDraft(fresh);
- }));
- setSavedProviders(modelsRes.providers);
- setConfigs(modelsRes.configs);
- setSyncErrors(modelsRes.syncErrors ?? []);
- };
-
- const refreshModelCatalog = async ({ quiet = false }: { quiet?: boolean } = {}) => {
- if (catalogRefreshing) return;
- setCatalogRefreshing(true);
- setSyncErrors([]);
- const tid = quiet ? null : toast.loading('Syncing providers and models...');
- try {
- let savedProviderCount = 0;
- let failedProviderCount = 0;
- if (!quiet) {
- const dirtyProviders = providers.filter(
- provider => providerDraftDirty(provider, savedProviders.find(saved => saved.id === provider.id)),
- );
- if (dirtyProviders.length > 0) {
- const results = await Promise.all(dirtyProviders.map(provider => persistProvider(provider, { quiet: true })));
- savedProviderCount = results.filter(Boolean).length;
- failedProviderCount = results.length - savedProviderCount;
- }
- }
-
- const modelsRes = await api.refreshModelCatalog();
- applyModelConfigResponse(modelsRes);
- setCatalogRefreshedOnce(true);
-
- if (!quiet) {
- const failedCatalogs = modelsRes.syncErrors?.length ?? 0;
- const parts: string[] = [];
- if (savedProviderCount > 0) parts.push(`${savedProviderCount} provider${savedProviderCount === 1 ? '' : 's'} saved`);
- if (failedProviderCount > 0) parts.push(`${failedProviderCount} provider${failedProviderCount === 1 ? '' : 's'} failed to save`);
- if (failedCatalogs > 0) parts.push(`${failedCatalogs} provider${failedCatalogs === 1 ? '' : 's'} reported a catalog error`);
-
- const description = parts.length > 0 ? parts.join(' · ') : 'Providers and model lists are up to date.';
- if (failedProviderCount > 0 || failedCatalogs > 0) {
- toast.error('Sync finished with issues', { id: tid ?? undefined, description });
- } else {
- toast.success('Sync complete', { id: tid ?? undefined, description });
- }
- }
- } catch (e) {
- const msg = e instanceof Error ? e.message : 'Catalog refresh failed';
- setSyncErrors([{ providerId: 'catalog-refresh', providerName: 'Model catalog', error: msg }]);
- if (!quiet) toast.error('Could not refresh catalog', { id: tid ?? undefined, description: msg });
- } finally {
- setCatalogRefreshing(false);
- }
- };
-
- const loadConfigs = async () => {
- try {
- const [modelsRes, globalRes, reviewSettingsRes] = await Promise.all([
- api.getModelConfigs(),
- api.getGlobalConfig(),
- api.getReviewSettings(),
- ]);
- const nextGlobalConfig = normalizeGlobalConfig(globalRes.config);
- applyModelConfigResponse(modelsRes);
- setGlobalConfig(nextGlobalConfig);
- setSavedGlobalConfig(nextGlobalConfig);
- hydrateReviewSettings(reviewSettingsRes.settings);
- return true;
- } catch (e) {
- const msg = e instanceof Error ? e.message : 'Failed to load settings';
- setError(msg);
- toast.error('Could not load settings', { description: 'Something went wrong fetching your configuration.' });
- return false;
- } finally {
- setLoading(false);
- }
- };
+ const {
+ providers,
+ savedProviders,
+ configs,
+ globalConfig,
+ setGlobalConfig,
+ newProvider,
+ setNewProvider,
+ syncErrors,
+ catalogRefreshing,
+ catalogRefreshedOnce,
+ addingProvider,
+ setAddingProvider,
+ expandedProviderId,
+ setExpandedProviderId,
+ providerModelCounts,
+ selectedProviderNameExists,
+ newProviderReady,
+ configuredProviderCount,
+ loadConfigs,
+ refreshModelCatalog,
+ updateProviderDraft,
+ saveProvider,
+ removeProvider,
+ clearProviderKey,
+ createProvider,
+ } = useProviderSettings({ setLoading, setSaving, setError, hydrateReviewSettings });
+
+ // The load lives here, not inside the hook: it writes `loading`/`error` and the review settings,
+ // all of which this page owns, and a hook effect pushing them up would cost an extra render.
useEffect(() => {
let mounted = true;
loadConfigs().then((loaded) => {
@@ -209,158 +72,6 @@ export function SettingsPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- const persistGlobalConfig = async (next: ModelRouteConfig) => {
- setSaving('global');
- setError(null);
- const tid = toast.loading('Saving model strategy...');
- try {
- await api.updateGlobalConfig(next);
- setSavedGlobalConfig(next);
- toast.success('Global strategy saved', {
- id: tid,
- description: 'Repositories without a custom strategy will use these settings.',
- });
- } catch (e) {
- const msg = e instanceof Error ? e.message : 'Update failed';
- setError(msg);
- toast.error('Could not save strategy', { id: tid, description: 'Your changes were not applied.' });
- } finally {
- setSaving(null);
- }
- };
-
- useEffect(() => {
- if (!globalConfig || !globalDirty) return;
- const handle = setTimeout(() => void persistGlobalConfig(globalConfig), 600);
- return () => clearTimeout(handle);
- }, [globalConfig, globalDirty]);
-
-
- const updateProviderDraft = (id: string, updates: Partial) => {
- setProviders(current => current.map(provider => provider.id === id ? { ...provider, ...updates } : provider));
- };
-
- const persistProvider = async (
- provider: ProviderDraft,
- { quiet = false, clearApiKey = false, successMessage }: { quiet?: boolean; clearApiKey?: boolean; successMessage?: string } = {},
- ) => {
- if (provider.enabled && !clearApiKey && !providerHasCredential(provider)) {
- if (!quiet) {
- setExpandedProviderId(provider.id);
- toast.error('Add an API key before enabling this provider.');
- }
- return null;
- }
-
- setSaving(`provider:${provider.id}`);
- setError(null);
- const tid = quiet ? null : toast.loading('Saving provider...');
- try {
- const payload: ProviderPayload = {
- name: provider.name,
- apiFormat: provider.apiFormat,
- baseUrl: provider.baseUrl || null,
- enabled: provider.enabled,
- };
- if (clearApiKey) {
- payload.clearApiKey = true;
- } else if (provider.apiKey.trim()) {
- payload.apiKey = provider.apiKey.trim();
- }
- const { provider: saved } = await api.updateProvider(provider.id, payload);
- setProviders(current => current.map(item => item.id === saved.id ? providerToDraft(saved) : item));
- setSavedProviders(current => current.map(item => item.id === saved.id ? saved : item));
- if (!quiet) toast.success(successMessage ?? 'Provider saved', { id: tid ?? undefined });
- return saved;
- } catch (e) {
- const msg = e instanceof Error ? e.message : 'Provider update failed';
- setError(msg);
- toast.error('Could not save provider', { id: tid ?? undefined, description: msg });
- return null;
- } finally {
- setSaving(null);
- }
- };
-
- const saveProvider = async (provider: ProviderDraft) => {
- const saved = await persistProvider(provider);
- if (saved && saved.enabled && (saved.hasApiKey || saved.apiFormat === 'cloudflare-workers-ai')) {
- void refreshModelCatalog({ quiet: true });
- }
- };
-
- const clearProviderKey = async (provider: ProviderDraft) => {
- // Build from the last saved state, not the draft, so clearing the key doesn't persist unrelated unsaved edits; a provider without a key can't stay enabled.
- const saved = savedProviders.find(item => item.id === provider.id);
- const base = saved ? providerToDraft(saved) : provider;
- await persistProvider(
- { ...base, apiKey: '', enabled: false },
- { clearApiKey: true, successMessage: 'API key removed' },
- );
- };
-
- const createProvider = async () => {
- if (!newProvider.name.trim() || selectedProviderNameExists) return;
- setSaving('provider:new');
- setError(null);
- const tid = toast.loading('Creating provider...');
- try {
- const { provider } = await api.createProvider({
- name: newProvider.name.trim(),
- apiFormat: newProvider.apiFormat,
- baseUrl: newProvider.baseUrl.trim() || null,
- apiKey: newProvider.apiKey.trim() || undefined,
- enabled: newProvider.enabled,
- });
- setProviders(current => [...current, providerToDraft(provider)]);
- setSavedProviders(current => [...current, provider]);
- setNewProvider({
- preset: 'custom-openai',
- name: 'Custom OpenAI',
- apiFormat: 'openai',
- baseUrl: '',
- apiKey: '',
- enabled: true,
- });
- setAddingProvider(false);
- toast.success('Provider created', { id: tid });
- if (provider.enabled && (provider.hasApiKey || provider.apiFormat === 'cloudflare-workers-ai')) {
- void refreshModelCatalog({ quiet: true });
- }
- } catch (e) {
- const msg = e instanceof Error ? e.message : 'Provider creation failed';
- setError(msg);
- toast.error('Could not create provider', { id: tid, description: msg });
- } finally {
- setSaving(null);
- }
- };
-
- const removeProvider = async (id: string) => {
- setSaving(`provider:${id}`);
- setError(null);
- const tid = toast.loading('Deleting provider...');
- try {
- await api.deleteProvider(id);
- setProviders(current => current.filter(provider => provider.id !== id));
- setSavedProviders(current => current.filter(provider => provider.id !== id));
- toast.success('Provider deleted', { id: tid });
- } catch (e) {
- const msg = e instanceof Error ? e.message : 'Provider delete failed';
- setError(msg);
- toast.error('Could not delete provider', { id: tid, description: msg });
- } finally {
- setSaving(null);
- }
- };
-
- const newProviderReady = newProvider.name.trim().length > 0 &&
- newProvider.baseUrl.trim().length > 0 &&
- newProvider.apiKey.trim().length > 0 &&
- !selectedProviderNameExists;
-
- const configuredProviderCount = providers.filter(providerIsReady).length;
-
return (
-
@@ -432,146 +142,40 @@ export function SettingsPage() {
- {addingProvider && (
-
-
- New provider
-
-
-
- Protocol
- {
- const preset = PROVIDER_PRESETS.find(item => item.value === value) ?? PROVIDER_PRESETS[0];
- setNewProvider(current => ({
- ...current,
- preset: preset.value,
- name: preset.name,
- apiFormat: preset.apiFormat,
- baseUrl: preset.baseUrl,
- }));
- }}
- options={PROVIDER_PRESETS.map(preset => ({ value: preset.value, label: preset.label }))}
- />
-
-
-
Display name
-
setNewProvider(current => ({ ...current, name: e.target.value }))}
- />
- {selectedProviderNameExists && (
-
{newProvider.name.trim()} already exists
- )}
-
-
- Base URL
- setNewProvider(current => ({ ...current, baseUrl: e.target.value }))}
- />
-
-
- {apiKeyFieldLabel(newProvider.apiFormat)}
- setNewProvider(current => ({ ...current, apiKey: e.target.value }))}
- />
-
-
-
- setAddingProvider(false)}
- className="text-ui-subtle hover:text-ui-default"
- >
- Cancel
-
- }
- >
- Create
-
-
-
+ {addingProvider && (
+ setAddingProvider(false)}
+ />
)}
- {loading ? (
-
- {[148, 148, 148].map((h, i) => (
-
- ))}
-
- ) : providers.length === 0 && !addingProvider ? (
-
-
No providers yet
-
Add one to start routing models.
-
- ) : (
-
- {providers.map(provider => (
-
- ))}
-
- )}
+
-
-
-
Default models
-
Used by repos that don't set their own model
-
-
- {!loading && globalConfig ? (
-
- ) : (
-
-
-
-
- )}
-
-
+
{!loading && (
diff --git a/src/client/pages/stats.tsx b/src/client/pages/stats.tsx
index 4af99f84..6ef3bb24 100644
--- a/src/client/pages/stats.tsx
+++ b/src/client/pages/stats.tsx
@@ -1,15 +1,17 @@
-import { useState } from 'react';
+import { useEffect, useState } from 'react';
import { PageHeaderActions } from '@client/components/shared/page-header-actions';
import { PageHeader } from '@client/components/layout/page-header';
import { LoadError } from '@client/components/shared/load-error';
import { useIsDarkMode } from '@client/hooks/use-is-dark-mode';
import { usePolling } from '@client/hooks/use-polling';
+import { useStatsRange } from '@client/hooks/use-stats-range';
import { api } from '@client/lib/api';
import type { StatsPayload } from '@shared/schema';
import { MetricsGridSkeleton } from '@client/components/features/stats/chart-primitives';
import { MetricsGrid } from '@client/components/features/stats/metrics-grid';
+import { prefetchMetricsCharts } from '@client/components/features/stats/metrics-grid-prefetch';
// Skeletons reuse GraphShell so the card chrome (border, title, icon) stays put; only the chart body is skeletoned.
@@ -17,9 +19,12 @@ export function StatsPage() {
const [stats, setStats] = useState
(null);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState(null);
- const [days, setDays] = useState(14);
+ const [days, setDays] = useStatsRange();
const isDark = useIsDarkMode();
+ // Downloads the lazy chart chunk in parallel with the first stats fetch rather than after it.
+ useEffect(prefetchMetricsCharts, []);
+
// Switching the range reloads every metric; clear current data first so skeletons show while it loads.
const changeDays = (next: number) => {
setStats(null);
diff --git a/src/server/core/claim-checks.ts b/src/server/core/claim-checks.ts
index 1ca1824b..c3c623f5 100644
--- a/src/server/core/claim-checks.ts
+++ b/src/server/core/claim-checks.ts
@@ -35,8 +35,72 @@ const VERSION_CLAIM_PATTERNS: readonly RegExp[] = [
/\blatest (?:major )?version\b/i,
/\bno such (?:version|tag|release)\b/i,
/\bnot a valid (?:configuration )?(?:option|key|property)\b/i,
+ // A claim about what an installed library's API offers is the same kind of claim as one about a
+ // version: it is settled by node_modules, not by the diff. Added after a P0 on codra's own PR #86
+ // asserted that `z.uuid()` "does not expose" a top-level validator and would throw at runtime --
+ // Zod 4 has had it since the 4.0 release, and the suggested fix reverted to the deprecated form.
+ // "does not exist" was already covered; the miss was purely the verb.
+ /\b(?:does not|doesn't|do not|don't)\s+(?:expose|provide|have|support|include|offer)\b/i,
+ /\bno such (?:function|method|export|property|api|field)\b/i,
+ /\bis not (?:exposed|exported|available) (?:by|from|in)\b/i,
];
+// ---- Undecidable-claim refutations ---------------------------------------------------------------
+// CLAIM_TYPE_DECIDABILITY answers "can this be settled from a diff hunk?" per claim TYPE, which leaves
+// `other` -- the deliberate escape hatch, marked diff_local -- carrying whatever a model wants to
+// assert. These answer the same question per CLAIM, for the two families that recur:
+//
+// cross-file the claim's consequence lands in a file that is not in the diff
+// environment the claim is conditional on a runtime, framework or engine version not shown
+//
+// Both are already forbidden by the review prompt in prose; on codra's own PR #86 the models ignored
+// that instruction four times in one review, and the verification pass confirmed every one of them
+// (generator and verifier share a knowledge gap, so verification cannot close it).
+//
+// Same soundness rule as the absence checker above: a refutation asserts only that the claim cannot be
+// settled HERE, never that the code is fine. Losing one is free; a wrong one silences a real defect.
+
+// The claim reaches for consumers it cannot see: "other modules", "downstream callers".
+const CROSS_FILE_SUBJECT = /\b(?:other|another|external|downstream|consuming|importing|dependent|calling)\s+(?:module|file|component|caller|package|consumer|import)s?\b/i;
+const CROSS_FILE_CONSEQUENCE = /\b(?:break|breaks|breaking|broken|fail|fails|failing|error|errors|cannot import|can't import|unable to|compilation|compile|prevent|prevents|preventing|block|blocks|blocking)\b/i;
+
+// Hedged, and hedged specifically about where the code runs rather than about what it does.
+const ENVIRONMENT_HEDGE = /\b(?:depending on|might not|may not|could be undefined|if (?:this|the|it)\b[^.]{0,60}\b(?:is )?(?:rendered|run|executed|used)\b)/i;
+const ENVIRONMENT_SUBJECT = /\b(?:older|legacy|earlier|some)\s+(?:node(?:\.js)?|browsers?|runtimes?|environments?|engines?|versions?)\b|\bserver[- ]side\b|\bSSR\b|\bhydration\b|\bpolyfill\b|\bis not defined on the server\b/i;
+
+// "if `loadCooldowns()` fails, the rejection is unhandled" -- a claim about how a function HANDLES ITS
+// OWN ERRORS, where that function's body is not in the diff. Posted as a P1 on codra's own PR: the
+// callee already wrapped its only failure path in try/catch, in another file, with a comment saying so.
+// Requires a call-shaped subject (`name(` or `name()`), a failure condition, and an unhandled-outcome
+// word, so an ordinary claim about visible code -- "this catch swallows the error" -- does not match.
+// `(?!\.\s)` skips a sentence break but keeps dotted member expressions, so the condition still matches
+// "if the `this.persistence.loadCooldowns()` call fails" without spanning two sentences.
+const CALLEE_FAILURE_CONDITION = /\b(?:if|when|should|were)\b(?:(?!\.\s)[^;!?]){0,100}\b(?:fails?|failing|rejects?|rejecting|throws?|throwing|errors? out)\b/i;
+const CALLEE_CALL_SHAPE = /[\w.$]+\s*\(\s*\)|`[\w.$]+\(/;
+const CALLEE_UNHANDLED_OUTCOME = /\bunhandled\b|\bunhandled promise\b|\bnot (?:caught|handled)\b|\bno (?:\.)?catch\b|\bwithout (?:a )?(?:try|catch)\b|\bcrash\b/i;
+
+export type UndecidableClaimReason = 'cross-file' | 'environment' | 'callee-errors';
+
+/**
+ * Refutes a claim whose truth lives outside the diff, returning the family it belongs to or null.
+ *
+ * Deliberately requires TWO independent signals per family -- a subject and a consequence -- because
+ * either alone is ordinary review language. "This breaks the build" is a normal thing to say about
+ * code in the diff; "other modules import this" is a normal aside. Only together do they describe a
+ * consequence in a file nobody showed the model.
+ */
+export function refuteUndecidableClaim(input: { title: string; body: string }): UndecidableClaimReason | null {
+ const text = `${input.title}\n${input.body}`;
+
+ if (CROSS_FILE_SUBJECT.test(text) && CROSS_FILE_CONSEQUENCE.test(text)) return 'cross-file';
+ if (ENVIRONMENT_HEDGE.test(text) && ENVIRONMENT_SUBJECT.test(text)) return 'environment';
+ if (CALLEE_FAILURE_CONDITION.test(text) && CALLEE_CALL_SHAPE.test(text) && CALLEE_UNHANDLED_OUTCOME.test(text)) {
+ return 'callee-errors';
+ }
+
+ return null;
+}
+
// A full git object id: `uses: owner/action@<40 hex>` pins, and any version beside it is a comment.
const FULL_SHA_PATTERN = /\b[0-9a-f]{40}\b/;
diff --git a/src/server/core/diff/index.ts b/src/server/core/diff/index.ts
index 29b7540c..ff6d459e 100644
--- a/src/server/core/diff/index.ts
+++ b/src/server/core/diff/index.ts
@@ -279,11 +279,14 @@ export function filterReviewableFiles(
): { files: FileDiff[]; skipped: number } {
const customMatchers = config.skip_files.map((pattern) => picomatch(pattern, { dot: true }));
- const reviewable = files
- .filter((file) => !file.isDeleted && !file.isBinary)
- .filter((file) => !defaultSkipMatchers.some((matcher) => matcher(file.path)))
- .filter((file) => !customMatchers.some((matcher) => matcher(file.path)))
- .sort((left, right) => Number(left.isNew) - Number(right.isNew) || left.path.localeCompare(right.path));
+ const reviewable: FileDiff[] = [];
+ for (const file of files) {
+ if (file.isDeleted || file.isBinary) continue;
+ if (defaultSkipMatchers.some((matcher) => matcher(file.path))) continue;
+ if (customMatchers.some((matcher) => matcher(file.path))) continue;
+ reviewable.push(file);
+ }
+ reviewable.sort((left, right) => Number(left.isNew) - Number(right.isNew) || left.path.localeCompare(right.path));
return {
files: reviewable.slice(0, maxFiles),
diff --git a/src/server/core/diff/position.ts b/src/server/core/diff/position.ts
index 208b5c2d..98ee7ba6 100644
--- a/src/server/core/diff/position.ts
+++ b/src/server/core/diff/position.ts
@@ -26,23 +26,29 @@ export type FileDiff = {
};
export function getValidNewLines(file: FileDiff) {
- return new Set(
- file.hunks.flatMap((hunk) =>
- hunk.lines
- .filter((line) => line.kind !== 'del' && line.newLineNumber !== undefined)
- .map((line) => line.newLineNumber as number),
- ),
- );
+ const newLines = new Set();
+ for (const hunk of file.hunks) {
+ for (const line of hunk.lines) {
+ if (line.kind !== 'del' && line.newLineNumber !== undefined) {
+ newLines.add(line.newLineNumber);
+ }
+ }
+ }
+
+ return newLines;
}
export function getValidPositions(file: FileDiff) {
- return new Set(
- file.hunks.flatMap((hunk) =>
- hunk.lines
- .filter((line) => line.kind !== 'del')
- .map((line) => line.position),
- ),
- );
+ const positions = new Set();
+ for (const hunk of file.hunks) {
+ for (const line of hunk.lines) {
+ if (line.kind !== 'del') {
+ positions.add(line.position);
+ }
+ }
+ }
+
+ return positions;
}
export function findPositionForLine(file: FileDiff, lineNumber: number) {
diff --git a/src/server/core/finding-gates.ts b/src/server/core/finding-gates.ts
index a8d21158..155501d7 100644
--- a/src/server/core/finding-gates.ts
+++ b/src/server/core/finding-gates.ts
@@ -99,12 +99,16 @@ export async function verifyFindings(params: {
const conflicting = new Set();
for (const result of results) {
if (!Number.isInteger(result.index) || result.index < 0 || result.index >= candidates.length) continue;
+ // `decidable: false` is a drop whatever the verdict says: the verifier has just stated that the
+ // window it was given cannot settle the claim, and a claim nobody can check must not be posted as
+ // if it were checked. Only an explicit `false` counts -- an omitted field means "did not say".
+ const verdict = result.decidable === false ? 'drop' as const : result.verdict;
const prior = byIndex.get(result.index);
- if (prior && prior.verdict !== result.verdict) {
+ if (prior && prior.verdict !== verdict) {
conflicting.add(result.index);
continue;
}
- if (!prior) byIndex.set(result.index, { verdict: result.verdict, reason: result.reason });
+ if (!prior) byIndex.set(result.index, { verdict, reason: result.reason });
}
for (const index of conflicting) byIndex.delete(index);
diff --git a/src/server/core/github/index.ts b/src/server/core/github/index.ts
index 79fe9909..68c76906 100644
--- a/src/server/core/github/index.ts
+++ b/src/server/core/github/index.ts
@@ -329,6 +329,8 @@ export class GitHubClient {
const currentByLowerName = new Map(currentLabels.map(label => [label.toLowerCase(), label]));
const uniqueLabels = Array.from(new Set(labels.map(label => label.toLowerCase())));
+ // Deletes stay sequential: concurrent mutations of one issue's labels trip GitHub's secondary
+ // rate limit, and the fan-out would also compete for the invocation's subrequest budget.
for (const label of uniqueLabels) {
const currentLabel = currentByLowerName.get(label);
if (currentLabel) {
diff --git a/src/server/core/job-recovery.ts b/src/server/core/job-recovery.ts
index a7e60e1b..f395e0cb 100644
--- a/src/server/core/job-recovery.ts
+++ b/src/server/core/job-recovery.ts
@@ -8,6 +8,9 @@ const MAX_RECOVERY_COUNT = 3;
export async function recoverJobs(env: AppBindings) {
try {
const recovered = await recoverExpiredJobLeases(env, MAX_RECOVERY_COUNT);
+ // Sent one at a time on purpose: the recovery query returns up to 25 ids and each send is a
+ // subrequest, so fanning out would spend the invocation's budget the maintenance tick shares
+ // with completeTerminalCheckRuns below.
for (const jobId of recovered.requeuedJobIds) {
await env.REVIEW_QUEUE.send({
jobId,
diff --git a/src/server/core/model-output/batch.ts b/src/server/core/model-output/batch.ts
index dc14ce94..9b6fc54b 100644
--- a/src/server/core/model-output/batch.ts
+++ b/src/server/core/model-output/batch.ts
@@ -76,7 +76,7 @@ function trimOverCap(reviews: Map, cap: number, stat
for (const [path, review] of reviews) {
if (review.comments.length <= cap) continue;
- const ranked = [...review.comments].sort((a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity));
+ const ranked = review.comments.toSorted((a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity));
const dropped = ranked.slice(cap);
stats.overCap += dropped.length;
@@ -116,7 +116,7 @@ export function parseBatchReviewResponse(
stats.flatFallback = 1;
stats.entriesReturned = 1;
- const byFile = new Map();
+ const byFile = new Map();
for (const finding of payload.data.findings) {
const reported = finding.code_location.absolute_file_path?.trim();
// `claimed` stays empty here: findings share files, so claiming would starve the rest.
@@ -125,11 +125,13 @@ export function parseBatchReviewResponse(
stats.unroutableEntries += 1;
continue;
}
- byFile.set(target.path, [...(byFile.get(target.path) ?? []), finding]);
+ const bucket = byFile.get(target.path);
+ if (bucket) bucket.findings.push(finding);
+ else byFile.set(target.path, { file: target, findings: [finding] });
}
- for (const [path, findings] of byFile) {
- ground(files.find((f) => f.path === path)!, {
+ for (const { file, findings } of byFile.values()) {
+ ground(file, {
findings,
overall_correctness: payload.data.overall_correctness,
// Batch-level summary is all there is here.
@@ -154,5 +156,5 @@ export function parseBatchReviewResponse(
// Defence: the grammar caps per file, but only binds on providers that enforce it.
if (options?.maxCommentsPerFile) trimOverCap(reviews, generatorFindingCap(options.maxCommentsPerFile), stats);
- return { reviews, missing: files.filter((f) => !reviews.has(f.path)).map((f) => f.path), stats };
+ return { reviews, missing: files.flatMap((f) => (reviews.has(f.path) ? [] : [f.path])), stats };
}
diff --git a/src/server/core/model-output/evidence.ts b/src/server/core/model-output/evidence.ts
index 117e971e..28e3f54c 100644
--- a/src/server/core/model-output/evidence.ts
+++ b/src/server/core/model-output/evidence.ts
@@ -105,11 +105,11 @@ export function resolveEvidence(
if (exact && exact.length > 0) return { status: 'matched', line: nearest(exact) };
// A quote may be a fragment or carry trailing context, so accept containment either way -- but BOTH sides must be discriminating, or a fabricated quote trivially contains a real but meaningless line.
- const contained = index.lines
- .filter(({ normalized }) =>
- normalized.length >= MIN_DISCRIMINATING_EVIDENCE_CHARS
- && (normalized.includes(firstLine) || firstLine.includes(normalized)))
- .map(({ line }) => line);
+ const contained = index.lines.flatMap(({ normalized, line }) =>
+ normalized.length >= MIN_DISCRIMINATING_EVIDENCE_CHARS
+ && (normalized.includes(firstLine) || firstLine.includes(normalized))
+ ? [line]
+ : []);
if (contained.length > 0) return { status: 'matched', line: nearest(contained) };
return { status: 'unmatched' };
diff --git a/src/server/core/model-output/index.ts b/src/server/core/model-output/index.ts
index a8a246fb..7d8d5c79 100644
--- a/src/server/core/model-output/index.ts
+++ b/src/server/core/model-output/index.ts
@@ -21,6 +21,7 @@ import {
checkAbsenceClaim,
isVersionClaimRefutedByPin,
looksLikeExternalVersionClaim,
+ refuteUndecidableClaim,
} from '../claim-checks';
import { parseRawPayload } from './json';
import {
@@ -213,6 +214,16 @@ function applyClaimGate(
return { withheld: { title, body, tag: 'refuted:pinned-sha' } };
}
+ // Claims whose consequence lives in a file, framework or engine version the model was never shown.
+ // Counted under its own key and tagged distinctly, so every suppression stays auditable in the
+ // off-diff list rather than vanishing -- a wrong refutation must be findable.
+ const undecidable = refuteUndecidableClaim({ title, body });
+ if (undecidable) {
+ const key = `undecidable_${undecidable.replace('-', '_')}`;
+ deniedClaimCounts[key] = (deniedClaimCounts[key] ?? 0) + 1;
+ return { withheld: { title, body, tag: `refuted:${undecidable}` } };
+ }
+
return { claimType };
}
@@ -249,6 +260,15 @@ function buildParsedComment(params: {
? finding.confidence_score
: 0;
+ // An empty or whitespace-only suggestion means "no suggestion", not "discard this finding" -- but
+ // `codeSuggestion` is `z.string().min(1)`, so passing `""` straight through threw a ZodError and the
+ // catch below binned the whole comment as `unverified:unassemblable`. Measured across an 800-review
+ // sweep: 256 findings destroyed this way, including real ones (a hardcoded-secret P1 among them).
+ // `evidence` on the next line has always had this guard; this field simply never got it.
+ const codeSuggestion = typeof finding.code_suggestion === 'string' && finding.code_suggestion.trim()
+ ? finding.code_suggestion
+ : undefined;
+
return parsedReviewCommentSchema.parse({
path: file.path,
line,
@@ -260,8 +280,8 @@ function buildParsedComment(params: {
// Unrecoverable later: 003 nulls diff_input and the KV diff cache expires after 6h.
contextSnippet: renderDiffSnippet(file, line) || undefined,
title,
- body: withSuggestion(body, finding.code_suggestion),
- codeSuggestion: finding.code_suggestion,
+ body: withSuggestion(body, codeSuggestion),
+ codeSuggestion,
confidenceScore,
evidence: typeof finding.evidence === 'string' && finding.evidence.trim() ? finding.evidence.trim() : undefined,
fingerprint: buildFindingFingerprint(file.path, title),
@@ -407,5 +427,10 @@ export function parseFileReviewResponse(
export { dedupeFindings } from './dedupe';
+export {
+ isNonAnswerReview,
+ NON_ANSWER_MAX_RESPONSE_CHARS,
+ NON_ANSWER_MIN_DIFF_LINES,
+} from './non-answer';
export { parseRawBatchPayload, type RawBatchPayload } from './json-batch';
export { parseBatchReviewResponse, type BatchParseStats, type BatchReviewResult } from './batch';
diff --git a/src/server/core/model-output/json-batch.ts b/src/server/core/model-output/json-batch.ts
index 098827fc..72674793 100644
--- a/src/server/core/model-output/json-batch.ts
+++ b/src/server/core/model-output/json-batch.ts
@@ -9,6 +9,7 @@ import {
normalizeFinding,
parseRawPayload,
preprocessJson,
+ stripNulls,
truncateJsonForLog,
} from './json';
@@ -40,7 +41,10 @@ function normalizeBatchFileEntry(entry: unknown, fallbackPath?: string): unknown
return {
absolute_file_path: path,
- findings: e.findings.map(normalizeFinding).filter(Boolean),
+ findings: e.findings.flatMap((finding) => {
+ const normalized = normalizeFinding(finding);
+ return normalized ? [normalized] : [];
+ }),
overall_correctness: typeof e.overall_correctness === 'string' && e.overall_correctness ? e.overall_correctness : undefined,
overall_explanation: typeof e.overall_explanation === 'string' && e.overall_explanation ? e.overall_explanation : undefined,
overall_confidence_score: normalizeConfidence(e.overall_confidence_score),
@@ -53,12 +57,16 @@ function collectBatchEntries(parsedJson: unknown): unknown[] | null {
const files = root?.files ?? (Array.isArray(parsedJson) ? parsedJson : undefined);
if (Array.isArray(files)) {
- return files.map((entry) => normalizeBatchFileEntry(entry)).filter(Boolean);
+ return files.flatMap((entry) => {
+ const normalized = normalizeBatchFileEntry(entry);
+ return normalized ? [normalized] : [];
+ });
}
if (files && typeof files === 'object') {
- return Object.entries(files as Record)
- .map(([path, entry]) => normalizeBatchFileEntry(entry, path))
- .filter(Boolean);
+ return Object.entries(files as Record).flatMap(([path, entry]) => {
+ const normalized = normalizeBatchFileEntry(entry, path);
+ return normalized ? [normalized] : [];
+ });
}
return null;
}
@@ -102,7 +110,8 @@ export function parseRawBatchPayload(raw: string): RawBatchPayload {
let parsedJson: unknown;
try {
- parsedJson = JSON.parse(repaired);
+ // See stripNulls: one `"code_suggestion": null` used to discard the whole bin's response.
+ parsedJson = stripNulls(JSON.parse(repaired));
} catch (e) {
logger.error('Critical JSON parse error after extraction and repair', { repaired: truncateJsonForLog(repaired), error: e });
throw new Error(`Invalid JSON format: ${e instanceof Error ? e.message : 'Unknown error'}`, { cause: e });
diff --git a/src/server/core/model-output/json.ts b/src/server/core/model-output/json.ts
index 2fcc537a..9e2b763b 100644
--- a/src/server/core/model-output/json.ts
+++ b/src/server/core/model-output/json.ts
@@ -221,6 +221,33 @@ export function preprocessJson(json: string): string {
return result;
}
+/**
+ * Deletes every `null`-valued key, recursively, before Zod sees the payload.
+ *
+ * The model output schemas mark optional fields `.optional()`, which accepts an ABSENT key and rejects
+ * an explicit `null` -- and these models routinely emit `"code_suggestion": null` for a finding that
+ * carries no suggestion. On the batched path that single null failed
+ * `batchReviewModelOutputSchema.parse`, so `parseBatchReviewResponse` threw and the response for EVERY
+ * file in the bin was discarded, then reported as an unreadable answer and failed over to the next
+ * model. Measured on this repository's own review: 37 of 88 rejected payloads were otherwise complete
+ * and readable.
+ *
+ * Stripping rather than widening each field is deliberate: absent and null mean the same thing to every
+ * one of these schemas, one pass covers the fields nobody has thought of yet, and no downstream type
+ * has to learn about `null`.
+ */
+export function stripNulls(value: T): T {
+ if (Array.isArray(value)) return value.map(stripNulls) as unknown as T;
+ if (value === null || typeof value !== 'object') return value;
+
+ const out: Record = {};
+ for (const [key, entry] of Object.entries(value as Record)) {
+ if (entry === null) continue;
+ out[key] = stripNulls(entry);
+ }
+ return out as T;
+}
+
function isPlaceholderString(value: unknown) {
return typeof value === 'string' && /^<[^>]+>$/.test(value.trim());
}
@@ -306,7 +333,8 @@ export function parseRawPayload(raw: string): z.infer {
+ const normalized = normalizeFinding(finding);
+ return normalized ? [normalized] : [];
+ });
}
data = obj;
}
diff --git a/src/server/core/model-output/non-answer.ts b/src/server/core/model-output/non-answer.ts
new file mode 100644
index 00000000..0a57223e
--- /dev/null
+++ b/src/server/core/model-output/non-answer.ts
@@ -0,0 +1,38 @@
+// A model can decline to review without failing. It returns valid JSON, an empty `findings` array,
+// `overall_correctness: "patch is correct"`, and a one-sentence explanation -- and the pipeline records
+// that as "this file is clean", which is indistinguishable from a real clean verdict.
+//
+// Measured on a 221-file job reviewed by a `-flash-lite` primary: 165 files came back under 100 output
+// tokens, and `src/server/core/review/index.ts` answered a 751-line diff (15,022 input tokens) with 71
+// output tokens. Exactly one file in the job produced a response over 250 tokens, and it was the only
+// file that produced a finding. The pipeline was working; the model was not reviewing.
+//
+// This detects that shape so the chain can escalate, rather than posting a clean review nobody earned.
+
+import type { FileDiff } from '../diff';
+
+// Below this a zero-finding response has not said enough to be a considered judgement about a large
+// diff. The real observations clustered at 305-476 chars for eight substantive files; 600 leaves room
+// for a genuinely thorough "clean" explanation without admitting a one-liner.
+export const NON_ANSWER_MAX_RESPONSE_CHARS = 600;
+
+// Only diffs at least this big. A short diff CAN be honestly dismissed in a sentence -- 162 files in that
+// same job were comment-only cleanups whose empty findings arrays were correct -- so applying this to
+// small files would manufacture failures out of accurate verdicts.
+export const NON_ANSWER_MIN_DIFF_LINES = 200;
+
+/**
+ * True when a review response is a non-answer: a substantive diff dismissed in a sentence with no
+ * findings. Deliberately conservative -- it must never fire on a small diff, and never when the model
+ * actually engaged, because the cost of a false positive is an escalation that spends real quota.
+ */
+export function isNonAnswerReview(input: {
+ rawText: string;
+ file: Pick;
+ findingCount: number;
+ minDiffLines?: number;
+}): boolean {
+ if (input.findingCount > 0) return false;
+ if (input.file.lineCount < (input.minDiffLines ?? NON_ANSWER_MIN_DIFF_LINES)) return false;
+ return input.rawText.trim().length < NON_ANSWER_MAX_RESPONSE_CHARS;
+}
diff --git a/src/server/core/review/finalize.ts b/src/server/core/review/finalize.ts
index 45bd25fc..fd4eafb7 100644
--- a/src/server/core/review/finalize.ts
+++ b/src/server/core/review/finalize.ts
@@ -33,8 +33,12 @@ export async function runFinalizePhase(
const config = (job.configSnapshot ?? defaultRepoConfig) as RepoConfig;
// One lookup supplies both the file ceiling and the gating comment cap.
const reviewSettings = await getReviewSettings(env);
- const { files, skipped: filesOverCap } = await getDiffFiles(env, job, github, config, reviewSettings.maxFiles);
- let reviews = await getFileReviewsForJobs(env, [job.id]);
+ // The diff (KV/GitHub) and the file reviews (Postgres) share no state; two in flight cannot breach the subrequest cap.
+ const [{ files, skipped: filesOverCap }, initialReviews] = await Promise.all([
+ getDiffFiles(env, job, github, config, reviewSettings.maxFiles),
+ getFileReviewsForJobs(env, [job.id]),
+ ]);
+ let reviews = initialReviews;
{
// Set difference, not counts: the re-fetched diff can differ, so equal counts can still hide unreviewed files.
@@ -165,22 +169,10 @@ export async function runFinalizePhase(
formattedSummary += `\n\n> [!WARNING]\n> **${filesOverCap} file${filesOverCap === 1 ? ' was' : 's were'} not reviewed.** This pull request has ${files.length + filesOverCap} reviewable files and the limit is ${reviewSettings.maxFiles}. Raise it in Settings to cover the whole diff.`;
}
- // One note: headline count plus dominant reason. withheldByParser is counted upstream, folded here.
- const totalHeld = omittedCount + withheldByParser;
- if (totalHeld > 0) {
- const causes = ([
- { n: droppedByVerification, why: 'unconfirmed against the diff' },
- { n: withheldByParser, why: 'not grounded in a quoted line' },
- { n: droppedBySuppression, why: 'already reported or dismissed' },
- { n: droppedByFilters, why: 'below the severity or confidence threshold' },
- { n: droppedByCap, why: `over the \`max_comments\` cap (${effectiveMaxComments})` },
- ]).filter((cause) => cause.n > 0).sort((a, b) => b.n - a.n);
- const top = causes[0];
- const rest = causes.length > 1 ? `, ${causes.length - 1} other reason${causes.length > 2 ? 's' : ''}` : '';
-
- formattedSummary += `\n\n> [!NOTE]\n> **${totalHeld} finding${totalHeld === 1 ? '' : 's'} not posted** - mostly ${top.why} (${top.n})${rest}. Per-file detail below.`;
- }
-
+ // Deliberately NOT surfaced in the GitHub comment. The withheld tally is diagnostic -- it says how
+ // the pipeline behaved, not anything about the pull request -- and a reader of the review cannot act
+ // on "5 not grounded in a quoted line". It stays in the structured log above, in `withheld_counts` on
+ // each file_reviews row, and in the per-file off-diff list, which is where it is actually useful.
// A finalize that died after createReview but before completeJob left a review on GitHub; reuse it.
const finalizeRetriedPastPost = job.steps.some(
(step) => step.name === 'Completing' && (step.status === 'running' || step.status === 'done'),
diff --git a/src/server/core/review/pack.ts b/src/server/core/review/pack.ts
index eaeaa43e..7b6455c9 100644
--- a/src/server/core/review/pack.ts
+++ b/src/server/core/review/pack.ts
@@ -10,7 +10,31 @@ export const PACKABLE_MAX_DIFF_LINES = 150;
// larger bin "saved" get spent back, and every file in that bin waits out the long call first.
export const BIN_TARGET_DIFF_LINES = 300;
// Blast radius and attention: per-file recall degrades before the token budget runs out.
-export const BIN_MAX_FILES = 6;
+//
+// 6 -> 3 -> 2, each step on measurement rather than intuition, and the last step on the only design
+// that survives contact with these models: PAIRED. The same unchanged prompt scored 36.1% and 44.9% in
+// two sessions hours apart, so cross-time comparisons at this effect size are worthless -- every arm
+// below ran in the same block as its own baseline, back to back, and is differenced within that block.
+//
+// 3 files/call -> 2 gemini-3.5-flash-lite +11.1 pts recall (SE 2.7, t=4.15, 8 blocks)
+// gemini-2.5-flash +12.1 pts recall (SE 1.4, t=8.88, 4 blocks)
+//
+// Precision stayed at 100% on both models at bin 2, so the gain is defects that were previously never
+// mentioned, not extra noise. Going further to ONE file per call did not extend the gain (+2.9 pts
+// against bin 3, i.e. worse than bin 2) while tripling the call count, so 2 is the knee of the curve.
+// Two things that look like they should help do NOT, once paired: raising max_comments (-2.1 pts here,
+// and it costs precision on the stronger model) and adding custom repo rules (+1.4, t=0.56 -- an
+// earlier unpaired sweep put this at +7.0, which was drift).
+//
+// The cost is paid in subrequests, not tokens per finding: each call re-sends the ~2,800-token preamble,
+// and on the Workers Free 50-subrequests-per-invocation ceiling a smaller bin means more continuations
+// per job, which phase-control already handles but which shows up as wall clock.
+//
+// The value is nonetheless set ABOVE the measured knee, at 4, trading the recall the table above
+// quantifies for a quarter of the model calls that bin 2 would spend on the same diff. Bin 2 is the
+// recall-optimal setting and the table stands; if the subrequest pressure that motivated 4 goes away,
+// this should go back down rather than being re-derived from scratch.
+export const BIN_MAX_FILES = 4;
export const BIN_DIFF_CHAR_BUDGET = 24_000;
export type ReviewUnit =
diff --git a/src/server/core/review/phase-control.ts b/src/server/core/review/phase-control.ts
index 1c781a42..d1b0981c 100644
--- a/src/server/core/review/phase-control.ts
+++ b/src/server/core/review/phase-control.ts
@@ -24,7 +24,12 @@ export const RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS = [30, 2 * 60, 5 * 60]
export const FRESH_INVOCATION_YIELD_SECONDS = 8;
// Poll cadence for an in-flight Workers AI async batch, bounded by MAX_JOB_CONTINUATIONS so a stuck batch cannot loop forever.
export const ASYNC_BATCH_POLL_DELAY_SECONDS = 20;
-export const MAX_RETRYABLE_FILE_REVIEW_FAILURES = 3;
+// A big bin now spends a whole invocation on ONE model (MODEL_FALLBACK_CHAIN_BUDGET_MS is only a little
+// above the per-call ceiling), so this is also the ceiling on how DEEP into its fallback chain a file
+// can ever get: the resume memo advances one entry per deferral. At 3 a chain longer than three models
+// lost its tail no matter how healthy those entries were. Costs worst-case latency, not attempts --
+// RETRYABLE_MODEL_FAILURE_RETRY_DELAYS_SECONDS tops out at 5 minutes per deferral.
+export const MAX_RETRYABLE_FILE_REVIEW_FAILURES = 6;
// Ceiling on same-phase reschedules with no file completed; any progress resets it.
export const MAX_JOB_CONTINUATIONS = 20;
// Lower than review's: finalize either fits a fresh invocation's budget or it doesn't; the check-run reconciler recovers past that.
diff --git a/src/server/core/review/phase.ts b/src/server/core/review/phase.ts
index 22474fe1..feb5b290 100644
--- a/src/server/core/review/phase.ts
+++ b/src/server/core/review/phase.ts
@@ -48,9 +48,11 @@ export async function runReviewPhase(
await updateJobStep(env, job.id, 'Reviewing Files', { status: 'running' });
- const rejectedExemplars = await loadRejectedExemplars(env, job);
-
- const pr = await github.getPullRequest(job.owner, job.repo, job.prNumber);
+ // One DB read and one GitHub read with nothing between them; two in flight cannot breach the subrequest cap.
+ const [rejectedExemplars, pr] = await Promise.all([
+ loadRejectedExemplars(env, job),
+ github.getPullRequest(job.owner, job.repo, job.prNumber),
+ ]);
const config = (job.configSnapshot ?? defaultRepoConfig) as RepoConfig;
const failureModelId = config.model?.main ?? 'unconfigured';
let failureModelProviderPromise: Promise | null = null;
@@ -78,8 +80,13 @@ export async function runReviewPhase(
const jobIdsToQuery = [job.id];
if (job.retryOfJobId) jobIdsToQuery.push(job.retryOfJobId);
const allExistingReviews = await getFileReviewsForJobs(env, jobIdsToQuery);
- const currentReviews = new Map(allExistingReviews.filter((review) => review.job_id === job.id).map((review) => [review.file_path, review]));
- const parentReviews = new Map(allExistingReviews.filter((review) => review.job_id !== job.id && review.file_status === 'done').map((review) => [review.file_path, review]));
+ type ExistingReview = (typeof allExistingReviews)[number];
+ const currentReviews = new Map();
+ const parentReviews = new Map();
+ for (const review of allExistingReviews) {
+ if (review.job_id === job.id) currentReviews.set(review.file_path, review);
+ else if (review.file_status === 'done') parentReviews.set(review.file_path, review);
+ }
const reviewTasks: Array> = [];
// Single-threaded, so ++ is safe.
@@ -88,13 +95,11 @@ export async function runReviewPhase(
// Bulk-copy parent reviews in one DB pass, so a fully-inheritable retry finishes in one invocation.
if (job.retryOfJobId && parentReviews.size > 0) {
- const inheritablePaths = files
- .filter((file) => {
- if (currentReviews.has(file.path)) return false;
- const parent = parentReviews.get(file.path);
- return Boolean(parent && canInheritParentFileReview(config, parent));
- })
- .map((file) => file.path);
+ const inheritablePaths = files.flatMap((file) => {
+ if (currentReviews.has(file.path)) return [];
+ const parent = parentReviews.get(file.path);
+ return parent && canInheritParentFileReview(config, parent) ? [file.path] : [];
+ });
if (inheritablePaths.length > 0) {
const inheritedPaths = await bulkInheritFileReviews(env, {
@@ -286,6 +291,16 @@ export async function runReviewPhase(
await resetJobContinuationCount(env, job.id);
}
+ // Before the throw paths on purpose: a chunk that defers is exactly when waste is highest.
+ // `wasted` is estimated, `usage` is billed -- see TokenTracker. Skips rising while attempts fall
+ // is the shape that says the cooldown gates are doing their job.
+ logger.info('Review chunk model usage', {
+ jobId: job.id,
+ subrequests: tracker.getSubrequestCount(),
+ usage: tracker.getTotalUsage(),
+ wasted: tracker.getWasted(),
+ });
+
const rejected = results.filter((result): result is PromiseRejectedResult => result.status === 'rejected');
if (rejected.length > 0) {
rejected.forEach((result, index) => {
@@ -306,7 +321,9 @@ export async function runReviewPhase(
const latestReviews = await getFileReviewsForJobs(env, [job.id]);
// Exclude files awaiting async results so the job doesn't finalize with pending reviews.
const reviewedPaths = new Set(
- latestReviews.filter((review) => countsAsHandledFileReview(review) && !isAwaitingAsyncReview(review)).map((review) => review.file_path),
+ latestReviews.flatMap((review) => (
+ countsAsHandledFileReview(review) && !isAwaitingAsyncReview(review) ? [review.file_path] : []
+ )),
);
const completedCount = files.filter((file) => reviewedPaths.has(file.path)).length;
diff --git a/src/server/core/review/telemetry.ts b/src/server/core/review/telemetry.ts
index fcf5e648..eb37bb79 100644
--- a/src/server/core/review/telemetry.ts
+++ b/src/server/core/review/telemetry.ts
@@ -20,10 +20,10 @@ export async function sendReviewTelemetry(
const cleanModels = Array.from(
new Set(
- doneReviews
- .map((r) => bareModelId(r.model_used))
- .filter(Boolean)
- .filter((m) => !m.toLowerCase().includes('test')),
+ doneReviews.flatMap((r) => {
+ const model = bareModelId(r.model_used);
+ return model && !model.toLowerCase().includes('test') ? [model] : [];
+ }),
),
);
@@ -39,7 +39,10 @@ export async function sendReviewTelemetry(
inputTokens: doneReviews.reduce((sum, r) => sum + (r.input_tokens ?? 0), 0),
outputTokens: doneReviews.reduce((sum, r) => sum + (r.output_tokens ?? 0), 0),
modelsUsed: cleanModels,
- fileExtensions: Array.from(new Set(files.map((f) => extractExtension(f.path)).filter(Boolean))),
+ fileExtensions: Array.from(new Set(files.flatMap((f) => {
+ const extension = extractExtension(f.path);
+ return extension ? [extension] : [];
+ }))),
triggerType: job.trigger,
reviewDurationMs: Math.max(0, Date.now() - new Date(job.createdAt).getTime()),
filesReviewed: files.length,
diff --git a/src/server/core/rpc.ts b/src/server/core/rpc.ts
new file mode 100644
index 00000000..41727e0e
--- /dev/null
+++ b/src/server/core/rpc.ts
@@ -0,0 +1,15 @@
+// Workflow.create()/.get() resolve to an RPC stub, not a plain object. An undisposed stub holds its
+// end of the connection until the GC happens to collect it, which the runtime warns about as "An RPC
+// result was not disposed properly" -- and because that warning is raised whenever the finalizer
+// notices, it is attributed to whatever invocation is running at the time rather than to the leak.
+//
+// `WorkflowInstance` does not declare `Symbol.dispose` in @cloudflare/workers-types even though the
+// stub carries it, so this reaches for it defensively: a plain object simply no-ops.
+export function disposeRpc(stub: unknown): void {
+ const disposable = stub as { [Symbol.dispose]?: () => void } | null;
+ try {
+ disposable?.[Symbol.dispose]?.();
+ } catch {
+ // Disposal is bookkeeping; never fail the caller's work over it.
+ }
+}
diff --git a/src/server/core/rules/detect.ts b/src/server/core/rules/detect.ts
index 5bbd71ec..9bbaf1c7 100644
--- a/src/server/core/rules/detect.ts
+++ b/src/server/core/rules/detect.ts
@@ -79,9 +79,10 @@ export function scanFileForRuleHits(file: FileDiff, options: RuleScanOptions = {
for (const hunk of file.hunks) {
// Same discipline as buildPresenceIndex: collected per hunk so reformat-move suppression can compare within the same window.
- const removed = new Set(
- hunk.lines.filter((l) => l.kind === 'del').map((l) => normalizeDiffText(l.content)),
- );
+ const removed = new Set();
+ for (const l of hunk.lines) {
+ if (l.kind === 'del') removed.add(normalizeDiffText(l.content));
+ }
for (const line of hunk.lines) {
if (line.kind !== 'add') continue;
@@ -130,25 +131,29 @@ export function scanFileForRuleHits(file: FileDiff, options: RuleScanOptions = {
// Turns rule hits into the same `ParsedReviewComment` shape the LLM channel produces, so downstream stages treat them uniformly.
// The fingerprint deliberately includes the anchor hash: a rule's title is a CONSTANT, so two hits of one rule in one file would otherwise collide on a single fingerprint identity.
export function ruleHitsToComments(file: FileDiff, result: RuleScanResult): ParsedReviewComment[] {
- return result.hits
- .filter((hit) => !hit.shadow)
- .map((hit) => {
- const anchorHash = buildAnchorHash(hit.line.content);
- return {
- path: file.path,
- line: hit.line.newLineNumber ?? null,
- position: hit.line.position ?? null,
- severity: hit.rule.severity,
- category: CLAIM_TYPE_CATEGORY[hit.rule.claimType] ?? 'quality',
- title: hit.rule.title,
- body: hit.rule.body,
- evidence: hit.line.content,
- anchorHash,
- claimType: hit.rule.claimType,
- fingerprint: buildFindingFingerprint(file.path, `${hit.rule.title} @${anchorHash}`),
- fingerprintV2: buildFindingFingerprintV2(file.path, hit.rule.claimType, anchorHash),
- source: 'rule' as const,
- ruleId: hit.rule.id,
- } satisfies ParsedReviewComment;
- });
+ const comments: ParsedReviewComment[] = [];
+ for (const hit of result.hits) {
+ if (hit.shadow) continue;
+
+ const { rule, line } = hit;
+ const anchorHash = buildAnchorHash(line.content);
+ comments.push({
+ path: file.path,
+ line: line.newLineNumber ?? null,
+ position: line.position ?? null,
+ severity: rule.severity,
+ category: CLAIM_TYPE_CATEGORY[rule.claimType] ?? 'quality',
+ title: rule.title,
+ body: rule.body,
+ evidence: line.content,
+ anchorHash,
+ claimType: rule.claimType,
+ fingerprint: buildFindingFingerprint(file.path, `${rule.title} @${anchorHash}`),
+ fingerprintV2: buildFindingFingerprintV2(file.path, rule.claimType, anchorHash),
+ source: 'rule' as const,
+ ruleId: rule.id,
+ } satisfies ParsedReviewComment);
+ }
+
+ return comments;
}
diff --git a/src/server/core/telemetry.ts b/src/server/core/telemetry.ts
index 3c4e01e3..55197cb3 100644
--- a/src/server/core/telemetry.ts
+++ b/src/server/core/telemetry.ts
@@ -74,9 +74,11 @@ export async function sendTelemetryEvent(
}
// Filter out stub/test models (e.g. 'test-model') used in vitest mocks.
- const cleanModelsUsed = data.modelsUsed
- .map((m) => m.replace(/^(google|cloudflare|openai|anthropic):/i, '').trim())
- .filter((m) => Boolean(m) && !m.toLowerCase().includes('test'));
+ const cleanModelsUsed: string[] = [];
+ for (const model of data.modelsUsed) {
+ const cleaned = model.replace(/^(google|cloudflare|openai|anthropic):/i, '').trim();
+ if (cleaned && !cleaned.toLowerCase().includes('test')) cleanModelsUsed.push(cleaned);
+ }
if (data.modelsUsed.length > 0 && cleanModelsUsed.length === 0) {
logger.debug('Skipping telemetry: only test/stub models detected', { modelsUsed: data.modelsUsed });
diff --git a/src/server/core/token-tracker.ts b/src/server/core/token-tracker.ts
index f7f24798..3b61295e 100644
--- a/src/server/core/token-tracker.ts
+++ b/src/server/core/token-tracker.ts
@@ -10,8 +10,26 @@ export interface ModelUsage extends TokenUsage {
calls: number;
}
+export type WastedAttemptReason = 'rate-limited' | 'error';
+
+// Prompts we paid to transmit but got nothing back for. Estimated, never billed: a failed call
+// returns no usageMetadata, so this is `estimatePromptTokens` output and must not be compared to a
+// provider's own promptTokenCount as an equal.
+//
+// `estimatedInput` is a token count but must NOT be named `...Tokens`: logger.ts redacts any key
+// whose name contains "token", so the field would log as [REDACTED] and the metric would be useless.
+export interface WastedUsage {
+ attempts: number;
+ estimatedInput: number;
+ skips: number;
+ byReason: Record;
+}
+
export class TokenTracker {
private usage: Map = new Map();
+ // Kept out of `usage` so estimates can never leak into billed accounting or telemetry.
+ private wasted = { attempts: 0, estimatedInput: 0, skips: 0 };
+ private wastedByReason: Map = new Map();
private subrequests = 0;
private readonly MAX_SUBREQUESTS = 50;
// Covers untracked Hyperdrive queries per chunk (lease heartbeats, review reads/writes, etc.) that the tracker never sees.
@@ -56,6 +74,27 @@ export class TokenTracker {
});
}
+ // A full prompt went over the wire and produced no reviewable response.
+ recordFailedAttempt(model: string, estimatedInputTokens: number, reason: WastedAttemptReason) {
+ this.wasted.attempts += 1;
+ this.wasted.estimatedInput += estimatedInputTokens;
+ this.wastedByReason.set(reason, (this.wastedByReason.get(reason) ?? 0) + 1);
+
+ logger.debug(`Wasted model attempt on ${model}`, { estimatedInput: estimatedInputTokens, reason });
+ }
+
+ // A prompt we did NOT send because a gate already knew it would fail -- the positive signal that
+ // cooldown learning is working, and the counterpart to recordFailedAttempt.
+ recordSkippedCall(model: string, reason: string) {
+ this.wasted.skips += 1;
+
+ logger.debug(`Skipped model call on ${model}`, { reason });
+ }
+
+ getWasted(): WastedUsage {
+ return { ...this.wasted, byReason: Object.fromEntries(this.wastedByReason) };
+ }
+
getTotalUsage(): TokenUsage {
let input = 0;
let output = 0;
@@ -74,9 +113,19 @@ export class TokenTracker {
for (const usage of other.getBreakdown()) {
this.record(usage.model, usage.input, usage.output);
}
+
+ const otherWasted = other.getWasted();
+ this.wasted.attempts += otherWasted.attempts;
+ this.wasted.estimatedInput += otherWasted.estimatedInput;
+ this.wasted.skips += otherWasted.skips;
+ for (const [reason, count] of Object.entries(otherWasted.byReason)) {
+ this.wastedByReason.set(reason, (this.wastedByReason.get(reason) ?? 0) + count);
+ }
}
reset() {
this.usage.clear();
+ this.wasted = { attempts: 0, estimatedInput: 0, skips: 0 };
+ this.wastedByReason.clear();
}
}
diff --git a/src/server/db/client.ts b/src/server/db/client.ts
index 7bcbe8be..41547b6c 100644
--- a/src/server/db/client.ts
+++ b/src/server/db/client.ts
@@ -64,13 +64,28 @@ export function runWithDb(env: DbEnv, fn: () => T): T {
}
// Keyed by connection string so a caller outside a runWithDb() scope shares one bounded pool instead of leaking a fresh pool per query.
+//
+// This Map lives at MODULE SCOPE, which in Workers outlives the request that filled it -- so a cached
+// client holds a socket opened in an earlier request context, and the next request to reuse it dies with
+// "Cannot perform I/O on behalf of a different request". It was described as test-only ("production
+// always wraps work in runWithDb()"), but production hit it repeatedly: binding a workflow ID to a job,
+// recovering expired job leases, and at least one outright failed file review. AsyncLocalStorage context
+// can also be lost crossing into a Workflow/jsrpc entrypoint, which drops callers onto this path without
+// them changing anything. So the cache has to be self-healing rather than merely convenient.
const fallbackClients = new Map();
+// Both faces of a socket belonging to a dead request context: the runtime's refusal, and a pooled
+// connection that was already torn down with it.
+function isStaleConnectionError(error: unknown): boolean {
+ const message = error instanceof Error ? error.message : String(error);
+ return message.includes('Cannot perform I/O on behalf of a different request')
+ || message.includes('CONNECTION_CLOSED');
+}
+
export function getDb(env: DbEnv) {
const store = dbStorage.getStore();
if (store) return store;
- // Test-only in practice (production always wraps work in runWithDb()); reuses one client per connection string to avoid leaking connections across the test process.
const connectionString = env.HYPERDRIVE.connectionString;
let client = fallbackClients.get(connectionString);
if (!client) {
@@ -80,12 +95,32 @@ export function getDb(env: DbEnv) {
return client;
}
+// Runs `op` against the ambient client, and if a request-scoped socket from the module cache has gone
+// stale, discards it and retries ONCE on a fresh one. Only the cached path is retried: inside
+// runWithDb() the client belongs to this request already, so the same error there is a real bug and must
+// surface rather than be papered over.
+async function withStaleConnectionRecovery(env: DbEnv, op: (db: DbClient) => Promise): Promise {
+ const inScope = dbStorage.getStore() !== undefined;
+ try {
+ return await op(getDb(env));
+ } catch (error) {
+ if (inScope || !isStaleConnectionError(error)) throw error;
+
+ const connectionString = env.HYPERDRIVE.connectionString;
+ fallbackClients.delete(connectionString);
+ const fresh = createDbClient(env);
+ fallbackClients.set(connectionString, fresh);
+ return op(fresh);
+ }
+}
+
export async function queryRows(env: DbEnv, sqlText: string, params: unknown[] = []) {
- return getDb(env).query(sqlText, params);
+ return withStaleConnectionRecovery(env, (db) => db.query(sqlText, params));
}
export async function queryTransaction(env: DbEnv, fn: (tx: DbClient) => Promise) {
- return getDb(env).transaction(fn);
+ // Safe to re-run: the failed attempt never reached the server, so no partial transaction was committed.
+ return withStaleConnectionRecovery(env, (db) => db.transaction(fn));
}
export function parseJsonColumn(value: T | string | null | undefined, fallback: T): T {
diff --git a/src/server/db/file-reviews.ts b/src/server/db/file-reviews.ts
index b218e3be..ae1708a3 100644
--- a/src/server/db/file-reviews.ts
+++ b/src/server/db/file-reviews.ts
@@ -249,6 +249,7 @@ export async function getModelUsageStats(env: Pick, d
WHERE created_at >= now() - ($1::int * interval '1 day')
GROUP BY model_used
ORDER BY calls DESC, model_used ASC
+ LIMIT 20
`,
[days],
);
diff --git a/src/server/db/jobs.ts b/src/server/db/jobs.ts
index cf49d4d2..197ee679 100644
--- a/src/server/db/jobs.ts
+++ b/src/server/db/jobs.ts
@@ -172,29 +172,30 @@ export async function listJobs(
params.push(query.offset);
const offsetIdx = params.length;
- const rows = await queryRows(
- env,
- `
- SELECT j.*, r.owner, r.repo, r.installation_id
- FROM jobs j
- JOIN repositories r ON j.repository_id = r.id
- ${whereClause}
- ORDER BY j.created_at DESC
- LIMIT $${limitIdx} OFFSET $${offsetIdx}
- `,
- params,
- );
-
- const [totalResult] = await queryRows<{ count: string }>(
- env,
- `
- SELECT COUNT(*) as count
- FROM jobs j
- JOIN repositories r ON j.repository_id = r.id
- ${whereClause}
- `,
- params.slice(0, -2),
- );
+ const [rows, [totalResult]] = await Promise.all([
+ queryRows(
+ env,
+ `
+ SELECT j.*, r.owner, r.repo, r.installation_id
+ FROM jobs j
+ JOIN repositories r ON j.repository_id = r.id
+ ${whereClause}
+ ORDER BY j.created_at DESC
+ LIMIT $${limitIdx} OFFSET $${offsetIdx}
+ `,
+ params,
+ ),
+ queryRows<{ count: string }>(
+ env,
+ `
+ SELECT COUNT(*) as count
+ FROM jobs j
+ JOIN repositories r ON j.repository_id = r.id
+ ${whereClause}
+ `,
+ params.slice(0, -2),
+ ),
+ ]);
return {
jobs: rows.map(mapJob),
diff --git a/src/server/db/model-configs.ts b/src/server/db/model-configs.ts
index ad17e577..4a6a0a62 100644
--- a/src/server/db/model-configs.ts
+++ b/src/server/db/model-configs.ts
@@ -296,7 +296,10 @@ export async function upsertDiscoveredModelConfigs(
modelNames: string[];
},
) {
- const uniqueModelNames = Array.from(new Set(input.modelNames.map(name => name.trim()).filter(Boolean)));
+ const uniqueModelNames = Array.from(new Set(input.modelNames.flatMap(name => {
+ const trimmed = name.trim();
+ return trimmed ? [trimmed] : [];
+ })));
if (uniqueModelNames.length === 0) return [];
const providerSlug = slugify(input.providerName);
diff --git a/src/server/db/stats.ts b/src/server/db/stats.ts
index 56307523..ccc5a84e 100644
--- a/src/server/db/stats.ts
+++ b/src/server/db/stats.ts
@@ -10,12 +10,25 @@ const reviewTriggerSet = new Set(reviewTriggers);
const reviewSeveritySet = new Set(reviewSeverities);
const reviewCategorySet = new Set(reviewCategories);
+/**
+ * Days rolled up into a single trend point. A 90-day range plotted daily is unreadable (and the
+ * x-axis drops most labels anyway), so wider ranges are combined into multi-day buckets, keeping
+ * every range at roughly a dozen-and-a-half points.
+ */
+export function trendBucketDays(days: number) {
+ if (days <= 14) return 1;
+ if (days <= 45) return 3;
+ if (days <= 120) return 7;
+ return 14;
+}
+
// `created_at` is `timestamptz` (absolute); `AT TIME ZONE ` converts it to wall-clock time before truncating, so a job at 03:00 IST lands on the IST day, not the UTC one.
export async function getStats(env: Pick, days = 30, timeZone = 'UTC') {
const parsedDays = Number(days);
const safeDays = Number.isFinite(parsedDays) ? Math.trunc(parsedDays) : 30;
const clampedDays = Math.min(Math.max(safeDays, 1), 365);
const zone = isSupportedTimeZone(timeZone) ? timeZone : 'UTC';
+ const bucketDays = trendBucketDays(clampedDays);
const [[totals], dailyRows, verdictRows, topRepos, modelRows, statusRows, triggerRows, severityRows, categoryRows, [performanceRow]] = await Promise.all([
queryRows<{
jobs: number;
@@ -35,21 +48,38 @@ export async function getStats(env: Pick, days = 30,
`,
[clampedDays],
),
- queryRows<{ day: string; jobs: number; input_tokens: number; output_tokens: number; comments: number }>(
+ // Buckets are generated first and LEFT JOINed, so quiet stretches come back as explicit zeros
+ // instead of gaps -- the chart then shows a continuous, evenly spaced series for every range.
+ queryRows<{ day: string; end_day: string; jobs: number; input_tokens: number; output_tokens: number; comments: number }>(
env,
`
+ WITH bounds AS (
+ SELECT
+ ((now() - ($1::int * interval '1 day')) AT TIME ZONE $2)::date AS start_day,
+ (now() AT TIME ZONE $2)::date AS end_day
+ ),
+ buckets AS (
+ SELECT
+ g::date AS bucket_start,
+ LEAST((g + (($3::int - 1) * interval '1 day'))::date, b.end_day) AS bucket_end
+ FROM bounds b,
+ generate_series(b.start_day::timestamp, b.end_day::timestamp, ($3::int * interval '1 day')) AS g
+ )
SELECT
- TO_CHAR(DATE_TRUNC('day', created_at AT TIME ZONE $2), 'YYYY-MM-DD') AS day,
- COUNT(*)::int AS jobs,
- COALESCE(SUM(total_input_tokens), 0)::int AS input_tokens,
- COALESCE(SUM(total_output_tokens), 0)::int AS output_tokens,
- COALESCE(SUM(comment_count), 0)::int AS comments
- FROM jobs
- WHERE created_at >= now() - ($1::int * interval '1 day')
- GROUP BY DATE_TRUNC('day', created_at AT TIME ZONE $2)
- ORDER BY day ASC
+ TO_CHAR(bk.bucket_start, 'YYYY-MM-DD') AS day,
+ TO_CHAR(bk.bucket_end, 'YYYY-MM-DD') AS end_day,
+ COUNT(j.id)::int AS jobs,
+ COALESCE(SUM(j.total_input_tokens), 0)::int AS input_tokens,
+ COALESCE(SUM(j.total_output_tokens), 0)::int AS output_tokens,
+ COALESCE(SUM(j.comment_count), 0)::int AS comments
+ FROM buckets bk
+ LEFT JOIN jobs j
+ ON j.created_at >= now() - ($1::int * interval '1 day')
+ AND (j.created_at AT TIME ZONE $2)::date BETWEEN bk.bucket_start AND bk.bucket_end
+ GROUP BY bk.bucket_start, bk.bucket_end
+ ORDER BY bk.bucket_start ASC
`,
- [clampedDays, zone],
+ [clampedDays, zone, bucketDays],
),
queryRows<{ verdict: 'approve' | 'comment' | null; count: number }>(
env,
@@ -141,13 +171,15 @@ export async function getStats(env: Pick, days = 30,
outputTokens: totals?.output_tokens ?? 0,
comments: totals?.comments ?? 0,
},
- trend: dailyRows.map((row) => ({
- day: row.day,
+ trend: dailyRows.map((row) => ({
+ day: row.day,
+ endDay: row.end_day,
jobs: row.jobs,
inputTokens: row.input_tokens,
outputTokens: row.output_tokens,
comments: row.comments
})),
+ trendBucketDays: bucketDays,
verdicts: verdictRows.map((row) => ({ verdict: row.verdict, count: row.count })),
models: modelRows.map((row) => ({
modelUsed: row.model_used,
@@ -158,10 +190,10 @@ export async function getStats(env: Pick, days = 30,
})),
topRepos: topRepos.map((row) => ({ owner: row.owner, repo: row.repo, jobs: row.jobs })),
// Drop rows whose enum-typed column holds an unexpected value (e.g. legacy rows with no DB CHECK constraint) -- keeping them would fail statsSchema.parse and 500 the endpoint.
- statuses: statusRows.filter((row) => jobStatusSet.has(row.status)).map((row) => ({ status: row.status as (typeof jobStatuses)[number], count: row.count })),
- triggers: triggerRows.filter((row) => reviewTriggerSet.has(row.trigger)).map((row) => ({ trigger: row.trigger as (typeof reviewTriggers)[number], count: row.count })),
- severities: severityRows.filter((row) => reviewSeveritySet.has(row.severity)).map((row) => ({ severity: row.severity as (typeof reviewSeverities)[number], count: row.count })),
- categories: categoryRows.filter((row) => reviewCategorySet.has(row.category)).map((row) => ({ category: row.category as (typeof reviewCategories)[number], count: row.count })),
+ statuses: statusRows.flatMap((row) => (jobStatusSet.has(row.status) ? [{ status: row.status as (typeof jobStatuses)[number], count: row.count }] : [])),
+ triggers: triggerRows.flatMap((row) => (reviewTriggerSet.has(row.trigger) ? [{ trigger: row.trigger as (typeof reviewTriggers)[number], count: row.count }] : [])),
+ severities: severityRows.flatMap((row) => (reviewSeveritySet.has(row.severity) ? [{ severity: row.severity as (typeof reviewSeverities)[number], count: row.count }] : [])),
+ categories: categoryRows.flatMap((row) => (reviewCategorySet.has(row.category) ? [{ category: row.category as (typeof reviewCategories)[number], count: row.count }] : [])),
performance: {
avgDurationMs: performanceRow?.avg_duration_ms != null ? Math.round(performanceRow.avg_duration_ms) : null,
p95DurationMs: performanceRow?.p95_duration_ms != null ? Math.round(performanceRow.p95_duration_ms) : null,
diff --git a/src/server/index.ts b/src/server/index.ts
index 21f78dcc..3e2fde4e 100644
--- a/src/server/index.ts
+++ b/src/server/index.ts
@@ -3,6 +3,7 @@ import { ReviewWorkflow } from './workflows/review';
import type { AppBindings } from './env';
import { reviewJobMessageSchema } from '@shared/schema';
import { logger } from '@server/core/logger';
+import { disposeRpc } from '@server/core/rpc';
import { runWithDb } from '@server/db/client';
import { failJob, hasPendingMaintenanceWork, clearSystemActive } from '@server/db/jobs';
import { runBestEffortJobMaintenance } from '@server/core/job-recovery';
@@ -52,6 +53,9 @@ export default {
logger.error('Pre-batch maintenance task failed', error instanceof Error ? error : new Error(String(error)));
}
+ // Sequential by design: each iteration creates a Workflow instance (a subrequest), and a
+ // batch can carry enough messages that fanning out would breach the Workers simultaneous-
+ // subrequest cap on the Free plan.
for (const message of batch.messages) {
const parseResult = reviewJobMessageSchema.safeParse(message.body);
@@ -75,29 +79,30 @@ export default {
continue;
}
+ const { jobId, deliveryId, forceFreshInstance } = parseResult.data;
+
try {
// Recovery re-enqueues a stuck job under its original jobId; keying the instance on jobId
// would collide with the dead instance (instance.already_exists), so recovery sets
// forceFreshInstance to key the new instance on the (fresh) deliveryId -- a UUID,
// matching workflow_instance_id's column type.
- const id = parseResult.data.forceFreshInstance
- ? parseResult.data.deliveryId
- : (parseResult.data.jobId ?? parseResult.data.deliveryId);
+ const id = forceFreshInstance ? deliveryId : (jobId ?? deliveryId);
if (!id) {
logger.error('Message missing identifiers; dropping', { body: message.body });
message.ack();
continue;
}
- await env.REVIEW_WORKFLOW.create({
+ // The returned handle is an RPC stub and this path never uses it; see core/rpc.ts.
+ disposeRpc(await env.REVIEW_WORKFLOW.create({
id,
params: parseResult.data,
- });
+ }));
message.ack();
} catch (error) {
if (error instanceof Error && error.message.includes('instance.already_exists')) {
logger.info('Workflow instance already exists; dropping duplicate queue message.', {
- jobId: parseResult.data.jobId,
- deliveryId: parseResult.data.deliveryId,
+ jobId,
+ deliveryId,
});
message.ack();
continue;
@@ -105,7 +110,7 @@ export default {
logger.error('Failed to create workflow', error instanceof Error ? error : new Error(String(error)));
if (message.attempts >= 3) {
- const id = parseResult.data.jobId ?? parseResult.data.deliveryId;
+ const id = jobId ?? deliveryId;
if (id) {
try {
await failJob(env, id, 'Failed to start Cloudflare Workflow after multiple attempts. The Cloudflare infrastructure might be experiencing an outage.');
diff --git a/src/server/models/anthropic.ts b/src/server/models/anthropic.ts
index 790f8bec..6d879397 100644
--- a/src/server/models/anthropic.ts
+++ b/src/server/models/anthropic.ts
@@ -2,9 +2,13 @@ import { logger } from '@server/core/logger';
import { withTimeout } from '@server/core/timeout';
import { ProviderRequestError, providerErrorMessage, jsonOnlyPrompts, type ModelResponse } from './types';
import { assertPublicBaseUrl } from './url-guard';
+import { MODEL_TIMEOUT_MAX_MS, resolveOutputTokenCeiling } from './limits';
-const ANTHROPIC_TIMEOUT_MS = 80_000;
-const ANTHROPIC_MAX_OUTPUT_TOKENS = 4096;
+// Fallback when the caller supplies no diff-size-aware budget. Shares the review ceiling so an
+// omitting caller can never outlast the chain budget that governs everything else.
+const ANTHROPIC_TIMEOUT_MS = MODEL_TIMEOUT_MAX_MS;
+const ANTHROPIC_DEFAULT_OUTPUT_TOKENS = 4096;
+const ANTHROPIC_MAX_OUTPUT_TOKENS = 16_384;
const DEFAULT_ANTHROPIC_BASE_URL = 'https://api.anthropic.com/v1';
export interface AnthropicResponse {
@@ -18,7 +22,7 @@ export interface AnthropicResponse {
export async function reviewWithAnthropic(
config: { apiKey: string; baseUrl?: string | null; providerName: string; timeoutMs?: number },
model: string,
- input: { systemPrompt: string; userPrompt: string },
+ input: { systemPrompt: string; userPrompt: string; outputBudgetTokens?: number },
tracker?: { incrementSubrequests(count?: number): void },
): Promise {
logger.info(`Calling Anthropic model: ${model}`);
@@ -44,7 +48,11 @@ export async function reviewWithAnthropic(
{ role: 'user', content: prompts.user },
{ role: 'assistant', content: '{' }
],
- max_tokens: ANTHROPIC_MAX_OUTPUT_TOKENS,
+ max_tokens: resolveOutputTokenCeiling(
+ input.outputBudgetTokens,
+ ANTHROPIC_MAX_OUTPUT_TOKENS,
+ ANTHROPIC_DEFAULT_OUTPUT_TOKENS,
+ ),
// 0.6 of a 0-1 scale.
temperature: 0.6,
}),
diff --git a/src/server/models/catalog.ts b/src/server/models/catalog.ts
index 8f12df3a..75731dc4 100644
--- a/src/server/models/catalog.ts
+++ b/src/server/models/catalog.ts
@@ -109,6 +109,36 @@ function extractOpenAiModels(data: OpenAIModelsResponse) {
: [];
}
+// NVIDIA Build serves its whole NIM catalog from one OpenAI-compatible `/models` endpoint, so the
+// chat models arrive mixed in with embedding, reranking, and speech/OCR models that can never
+// answer a review. This is a display-quality filter, not a correctness gate: an operator can still
+// target any model id explicitly through repo config.
+const NVIDIA_NON_CHAT_MODEL_PATTERNS = [
+ /embed/i,
+ /rerank/i,
+ /retriever/i,
+ /ocr/i,
+ /parakeet/i,
+ /riva/i,
+ /\basr\b/i,
+ /\btts\b/i,
+ /speech/i,
+];
+
+const NVIDIA_BUILD_HOST = 'integrate.api.nvidia.com';
+
+function isNvidiaBuildBaseUrl(baseUrl: string) {
+ try {
+ return new URL(baseUrl).hostname.toLowerCase() === NVIDIA_BUILD_HOST;
+ } catch {
+ return false;
+ }
+}
+
+function isNonChatNvidiaModel(modelId: string) {
+ return NVIDIA_NON_CHAT_MODEL_PATTERNS.some((pattern) => pattern.test(modelId));
+}
+
function extractAnthropicModels(data: AnthropicModelsResponse) {
return Array.isArray(data?.data)
? data.data.map((item) => item?.id).filter((id: unknown): id is string => typeof id === 'string' && id.length > 0)
@@ -117,12 +147,17 @@ function extractAnthropicModels(data: AnthropicModelsResponse) {
function extractGeminiModels(data: GeminiModelsResponse) {
if (!Array.isArray(data?.models)) return [];
- return data.models
- .filter((model) => Array.isArray(model?.supportedGenerationMethods)
- ? model.supportedGenerationMethods.includes('generateContent')
- : true)
- .map((model) => typeof model?.name === 'string' ? cleanGeminiModelName(model.name) : null)
- .filter((id: unknown): id is string => typeof id === 'string' && id.length > 0);
+
+ const ids: string[] = [];
+ for (const model of data.models) {
+ const methods = model?.supportedGenerationMethods;
+ if (Array.isArray(methods) && !methods.includes('generateContent')) continue;
+ if (typeof model?.name !== 'string') continue;
+ const id = cleanGeminiModelName(model.name);
+ if (id.length > 0) ids.push(id);
+ }
+
+ return ids;
}
export async function listProviderModels(input: {
@@ -149,7 +184,9 @@ export async function listProviderModels(input: {
}),
);
if (!response.ok) throw new Error(`OpenAI model list failed with ${response.status}: ${await limitedErrorBody(response)}`);
- return extractOpenAiModels(await response.json() as OpenAIModelsResponse);
+ const models = extractOpenAiModels(await response.json() as OpenAIModelsResponse);
+ // Host-gated so a custom OpenAI-format provider that happens to serve an id like "embed-1" is left alone.
+ return isNvidiaBuildBaseUrl(baseUrl) ? models.filter((id) => !isNonChatNvidiaModel(id)) : models;
}
if (input.apiFormat === 'anthropic') {
diff --git a/src/server/models/cloudflare.ts b/src/server/models/cloudflare.ts
index f2f612ad..e96c4b2e 100644
--- a/src/server/models/cloudflare.ts
+++ b/src/server/models/cloudflare.ts
@@ -2,10 +2,14 @@ import { logger } from '@server/core/logger';
import type { AppBindings } from '@server/env';
import { TimeoutError } from '@server/core/timeout';
import { ProviderRequestError, UnparseableModelResponseError, jsonOnlyPrompts, type ModelInput, type ModelResponse } from './types';
+import { MODEL_TIMEOUT_MAX_MS, OUTPUT_TOKENS_FLOOR, resolveOutputTokenCeiling } from './limits';
// Reasoning models under strict-JSON can burn the token budget thinking and never emit; fail fast and defer.
-const CLOUDFLARE_TIMEOUT_MS = 45_000;
-const CLOUDFLARE_MAX_OUTPUT_TOKENS = 8192;
+const CLOUDFLARE_TIMEOUT_MS = MODEL_TIMEOUT_MAX_MS;
+const CLOUDFLARE_DEFAULT_OUTPUT_TOKENS = OUTPUT_TOKENS_FLOOR;
+// Workers AI context windows vary widely by model, so this stays modest next to Gemini's: an over-large
+// `max_completion_tokens` is refused by the smaller models rather than clamped.
+const CLOUDFLARE_MAX_OUTPUT_TOKENS = 16_384;
type UnknownRecord = Record;
@@ -117,7 +121,11 @@ function buildCloudflareInferenceRequest(input: ModelInput) {
{ role: 'system', content: prompts.system },
{ role: 'user', content: prompts.user },
],
- max_completion_tokens: CLOUDFLARE_MAX_OUTPUT_TOKENS,
+ max_completion_tokens: resolveOutputTokenCeiling(
+ input.outputBudgetTokens,
+ CLOUDFLARE_MAX_OUTPUT_TOKENS,
+ CLOUDFLARE_DEFAULT_OUTPUT_TOKENS,
+ ),
...(input.responseSchema
? {
response_format: {
diff --git a/src/server/models/google.ts b/src/server/models/google.ts
index 8d9ae631..1b3c4bf8 100644
--- a/src/server/models/google.ts
+++ b/src/server/models/google.ts
@@ -3,18 +3,29 @@ import { withTimeout } from '@server/core/timeout';
import { ProviderRequestError, UnparseableModelResponseError, providerErrorMessage, jsonOnlyPrompts, type ModelInput, type ModelResponse } from './types';
import { toGeminiResponseJsonSchema } from './gemini-schema';
import { assertPublicBaseUrl } from './url-guard';
+import {
+ MODEL_TIMEOUT_MAX_MS,
+ OUTPUT_TOKENS_FLOOR,
+ geminiThinkingBudgetTokens,
+ resolveOutputTokenCeiling,
+} from './limits';
/** Fallback when the caller supplies no diff-size-aware budget. */
-const GEMINI_TIMEOUT_MS = 45_000;
+const GEMINI_TIMEOUT_MS = MODEL_TIMEOUT_MAX_MS;
const GEMINI_MAX_RETRIES = 2;
-// Headroom so reasoning models can think and still emit the JSON answer without truncating.
-const GEMINI_MAX_OUTPUT_TOKENS = 8192;
+// Floor, used when the caller states no budget (verify and summary, which answer in well under this).
+const GEMINI_DEFAULT_OUTPUT_TOKENS = OUTPUT_TOKENS_FLOOR;
+// What a review call may claim when it asks for room. The old single 8192 for every call was the
+// binding constraint on findings: thinking tokens bill against it, so a six-file bin asked for ~120
+// findings inside a window that held ~35 and answered with near-empty arrays.
+const GEMINI_MAX_OUTPUT_TOKENS = 65_536;
// Cap on any in-call retry sleep; a longer cool-off is better served by deferring the file than by pinning a gate slot here.
const GEMINI_MAX_RETRY_DELAY_MS = 5_000;
const DEFAULT_GEMINI_BASE_URL = 'https://generativelanguage.googleapis.com/v1beta';
+// 429 is handled separately: it is retryable only when the provider names a cool-off we can wait out.
function isRetryableGeminiStatus(status: number) {
- return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504 || status === 524;
+ return status === 408 || status === 500 || status === 502 || status === 503 || status === 504 || status === 524;
}
function defaultRetryDelayMs(attempt: number) {
@@ -56,10 +67,25 @@ function isSchemaRejection(status: number, message: string) {
lower.includes('response_schema') ||
lower.includes('invalid json payload') ||
lower.includes('unknown name') ||
- lower.includes('schema')
+ lower.includes('schema') ||
+ // The bare, detail-less 400. Google sometimes rejects a request with nothing but "Request
+ // contains an invalid argument." and no `error.details`, so none of the specific markers above
+ // can fire and the file used to fail permanently on its FIRST 400 -- no schema probe, no
+ // fallback, because a 400 is not transient. Only consulted when a grammar was actually sent, so
+ // the worst case is one extra schema-less attempt that 400s again and rethrows the real message.
+ lower.includes('invalid argument')
);
}
+// Narrow, and probed BEFORE isSchemaRejection: that one matches "unknown name" and "invalid argument",
+// so an endpoint or model that does not know `thinkingConfig` would otherwise be read as a grammar
+// rejection, dropping the schema while still sending the field that was actually refused.
+function isThinkingRejection(status: number, message: string) {
+ if (status !== 400) return false;
+ const lower = message.toLowerCase();
+ return lower.includes('thinking') || lower.includes('thought');
+}
+
function isRetryableTransportError(error: unknown) {
if (!(error instanceof Error)) return false;
// Never retry timeouts: the caller already grants up to 2 minutes, so let the fallback chain take over instead.
@@ -84,6 +110,26 @@ export async function reviewWithGoogle(
: null;
// Latched: once the endpoint rejects the grammar, every later attempt goes without it.
let schemaRejected = false;
+ // Same latch for `thinkingConfig`: the 2.0-era models and some proxies do not accept the field.
+ let thinkingRejected = false;
+
+ // Room for the JSON alone, then thinking ON TOP of it. Summing rather than sharing is the fix: with
+ // one flat ceiling for both, a thinking model spent it deliberating and returned a truncated prefix.
+ const answerBudget = resolveOutputTokenCeiling(
+ input.outputBudgetTokens,
+ GEMINI_MAX_OUTPUT_TOKENS,
+ GEMINI_DEFAULT_OUTPUT_TOKENS,
+ );
+ const thinkingBudget = geminiThinkingBudgetTokens(answerBudget);
+ const outputCeiling = Math.min(GEMINI_MAX_OUTPUT_TOKENS, answerBudget + thinkingBudget);
+ // The caller latches per (provider, model, grammar) off this flag. Marking the error too means a
+ // schema-dropped attempt that then fails still teaches the caller, instead of re-probing next call.
+ const fail = (error: unknown): never => {
+ if (schemaRejected && typeof error === 'object' && error !== null) {
+ Object.defineProperty(error, 'schemaDropped', { value: true, configurable: true });
+ }
+ throw error;
+ };
const startTime = Date.now();
const baseUrl = (config.baseUrl || DEFAULT_GEMINI_BASE_URL).replace(/\/+$/, '');
@@ -122,7 +168,10 @@ export async function reviewWithGoogle(
responseMimeType: 'application/json',
// See gemini-schema.ts for why not `responseSchema`.
...(responseJsonSchema && !schemaRejected ? { responseJsonSchema } : {}),
- maxOutputTokens: GEMINI_MAX_OUTPUT_TOKENS,
+ maxOutputTokens: outputCeiling,
+ // Bounded on purpose: thinking bills against maxOutputTokens, so leaving it dynamic lets
+ // it eat the ceiling and return a prefix of the JSON.
+ ...(thinkingRejected ? {} : { thinkingConfig: { thinkingBudget } }),
// See the note in models/types.ts on sampling. 0.9 on Gemini's 0-2 scale.
temperature: 0.9,
},
@@ -135,13 +184,26 @@ export async function reviewWithGoogle(
delayBeforeAttemptMs = defaultRetryDelayMs(attempt);
continue;
}
- throw error;
+ return fail(error);
}
if (!response.ok) {
const errorText = await response.text();
const message = providerErrorMessage(errorText);
+ // Before the schema probe: isSchemaRejection is deliberately broad and would swallow this.
+ if (!thinkingRejected && isThinkingRejection(response.status, message)) {
+ thinkingRejected = true;
+ logger.warn('Gemini rejected thinkingConfig; retrying without an explicit thinking budget', {
+ model,
+ error: message,
+ });
+ lastError = new ProviderRequestError(config.providerName ?? 'Google', response.status, message);
+ // Attempt refunded, no sleep: the latch bounds this to one extra probe, as with the grammar.
+ attempt--;
+ continue;
+ }
+
if (responseJsonSchema && !schemaRejected && isSchemaRejection(response.status, message)) {
schemaRejected = true;
// Inferred, not established: another cause 400s again and throws the real message below.
@@ -158,9 +220,11 @@ export async function reviewWithGoogle(
const requestedDelayMs = response.status === 429
? retryAfterDelayMs(response.headers.get('retry-after')) ?? requestedRetryDelayFromBody(message)
: null;
- // A cool-off longer than our cap can't be retried usefully; defer the file instead.
- const canHonorCoolOff = requestedDelayMs === null || requestedDelayMs <= GEMINI_MAX_RETRY_DELAY_MS;
- const isRetryable = isRetryableGeminiStatus(response.status) && canHonorCoolOff;
+ // An unstated 429 cool-off is ~60s by construction on a per-minute bucket, so backing off ~1s
+ // buys a second 429 and a second full prompt re-send. Only a stated, short cool-off is retryable.
+ const isRetryable = response.status === 429
+ ? requestedDelayMs !== null && requestedDelayMs <= GEMINI_MAX_RETRY_DELAY_MS
+ : isRetryableGeminiStatus(response.status);
const retryDelayMs = Math.min(
GEMINI_MAX_RETRY_DELAY_MS,
requestedDelayMs ?? defaultRetryDelayMs(attempt),
@@ -172,6 +236,12 @@ export async function reviewWithGoogle(
willRetry: isRetryable && attempt < maxRetries,
requestedDelayMs: requestedDelayMs ?? undefined,
retryDelayMs: isRetryable && attempt < maxRetries ? retryDelayMs : undefined,
+ // A bare "Request contains an invalid argument." with no `error.details` is unactionable, and
+ // without the body there is no way to learn what Google objected to. Bounded, and only on a
+ // 4xx we are about to give up on -- one body per genuinely failed call, never on a retry rung.
+ rawBody: response.status >= 400 && response.status < 500 && !(isRetryable && attempt < maxRetries)
+ ? errorText.slice(0, 2_000)
+ : undefined,
};
if (isRetryable && attempt < maxRetries) {
logger.warn(`Gemini request failed with ${response.status}; retrying`, logData);
@@ -181,7 +251,7 @@ export async function reviewWithGoogle(
}
logger.error(`Gemini request failed with ${response.status}`, logData);
- throw new ProviderRequestError(config.providerName ?? 'Google', response.status, message);
+ return fail(new ProviderRequestError(config.providerName ?? 'Google', response.status, message));
}
const durationMs = Date.now() - startTime;
@@ -203,17 +273,22 @@ export async function reviewWithGoogle(
const finishReason = candidate?.finishReason;
// A thinking model burning budget before emitting text, or a safety block, is deterministic and should fail permanently; an empty STOP is transient.
if (finishReason && finishReason !== 'STOP') {
- throw new UnparseableModelResponseError(model, `finishReason=${finishReason}`);
+ return fail(new UnparseableModelResponseError(model, `finishReason=${finishReason}`));
}
- throw new Error('Gemini returned an empty response.');
+ return fail(new Error('Gemini returned an empty response.'));
}
// A non-empty non-STOP response is only a prefix: json.ts repairs the braces and the tail findings vanish silently.
if (candidate?.finishReason && candidate.finishReason !== 'STOP') {
logger.warn(`Gemini response for ${model} ended with finishReason=${candidate.finishReason}; output is likely incomplete`, {
- // Thinking tokens bill against the same ceiling, so compare the sum.
- outputTokens: (data.usageMetadata?.candidatesTokenCount ?? 0) + (data.usageMetadata?.thoughtsTokenCount ?? 0),
- maxOutputTokens: GEMINI_MAX_OUTPUT_TOKENS,
+ // NONE of these may be named `...Tokens`: logger.ts redacts any key containing "token", so the
+ // previous `outputTokens`/`maxOutputTokens` pair logged as [REDACTED] and this warning could
+ // never show how close to the ceiling a truncated response actually got.
+ // Thinking bills against the same ceiling, so compare the sum.
+ outputSpend: (data.usageMetadata?.candidatesTokenCount ?? 0) + (data.usageMetadata?.thoughtsTokenCount ?? 0),
+ thoughtSpend: data.usageMetadata?.thoughtsTokenCount ?? 0,
+ outputCeiling,
+ thinkingBudget: thinkingRejected ? undefined : thinkingBudget,
schemaDropped: schemaRejected,
});
}
@@ -228,5 +303,5 @@ export async function reviewWithGoogle(
};
}
- throw lastError;
+ return fail(lastError);
}
diff --git a/src/server/models/limits.ts b/src/server/models/limits.ts
index e0452ca7..8bc9e230 100644
--- a/src/server/models/limits.ts
+++ b/src/server/models/limits.ts
@@ -4,22 +4,119 @@
export const MODEL_TIMEOUT_BASE_MS = 20_000;
const MODEL_TIMEOUT_PER_LINE_MS = 100;
const MODEL_TIMEOUT_FREE_LINES = 100;
-// Hard ceiling for one call, well under the ~120s invocation wall clock: at the old 120s ceiling a hung call took the whole invocation down as `exceededCpu` instead of failing over.
-export const MODEL_TIMEOUT_MAX_MS = 40_000;
+// Hard ceiling for one call, still well under the ~120s invocation wall clock: at the old 120s ceiling a hung call took the whole invocation down as `exceededCpu` instead of failing over.
+export const MODEL_TIMEOUT_MAX_MS = 50_000;
-// Budget for one file's entire fallback chain; past this, defer the file to resume from the primary model in a fresh invocation.
+// Budget for one file's entire fallback chain; past this, defer the file to RESUME AT THE NEXT MODEL in
+// a fresh invocation (ModelChainProgressStore holds the position, so nothing is replayed). Deliberately
+// only a little above MODEL_TIMEOUT_MAX_MS: a big bin therefore spends an invocation on ONE model and
+// gets the full ceiling to itself, rather than splitting the budget and giving every model too little.
+// The chain still gets walked, one model per continuation, bounded by MAX_RETRYABLE_FILE_REVIEW_FAILURES.
export const MODEL_FALLBACK_CHAIN_BUDGET_MS = 55_000;
-// Per-call timeout, scaled by the size of the (already truncated) diff being reviewed.
-export function adaptiveModelTimeoutMs(diffLineCount: number | null | undefined): number {
+// What an answer costs in wall clock, per 1,000 output tokens the caller has asked room for. Latency
+// here tracks how much the model WRITES (and thinks), which the diff size only loosely predicts: a
+// two-file bin is 60 diff lines and therefore got the 20s base, while its median answer took 18s on
+// gemini-2.5-flash and 31% of those calls overran the ceiling their diff size had earned them. Sizing
+// the timeout off the same `reviewOutputBudgetTokens` figure the prompt is built from removes that
+// mismatch, and the MODEL_TIMEOUT_MAX_MS cap still bounds the worst case.
+const MODEL_TIMEOUT_PER_1K_OUTPUT_MS = 1_200;
+
+// Per-call timeout, scaled by the (already truncated) diff being reviewed AND by the size of the answer
+// being requested. `outputBudgetTokens` is optional: a caller that omits it keeps the old arithmetic
+// exactly, so the verify and summary paths are unaffected.
+export function adaptiveModelTimeoutMs(
+ diffLineCount: number | null | undefined,
+ outputBudgetTokens?: number | null,
+): number {
const lines = typeof diffLineCount === 'number' && Number.isFinite(diffLineCount) ? Math.max(0, diffLineCount) : 0;
const scaled = MODEL_TIMEOUT_BASE_MS + Math.max(0, lines - MODEL_TIMEOUT_FREE_LINES) * MODEL_TIMEOUT_PER_LINE_MS;
- return Math.min(MODEL_TIMEOUT_MAX_MS, scaled);
+
+ const budget = typeof outputBudgetTokens === 'number' && Number.isFinite(outputBudgetTokens)
+ ? Math.max(0, outputBudgetTokens)
+ : 0;
+ // Only the room ABOVE the floor earns extra time: every caller asks for at least the floor.
+ const answerAllowance = Math.max(0, budget - OUTPUT_TOKENS_FLOOR) / 1_000 * MODEL_TIMEOUT_PER_1K_OUTPUT_MS;
+
+ return Math.min(MODEL_TIMEOUT_MAX_MS, scaled + answerAllowance);
+}
+
+// One call must always fit the chain budget, or the HEAD of every chain would be deferred before it
+// ever ran -- a job that never calls a model at all. Enforced here rather than at the call sites so
+// raising MODEL_TIMEOUT_MAX_MS past the chain budget cannot quietly produce that.
+export function clampTimeoutToChainBudget(timeoutMs: number): number {
+ return Math.min(timeoutMs, MODEL_FALLBACK_CHAIN_BUDGET_MS);
}
// Kept below the runtime's 6-connection cap so KV/Hyperdrive/GitHub requests from concurrent file reviews still find a free slot.
export const MAX_CONCURRENT_MODEL_CALLS = 3;
+// What one finding costs on the wire: a body capped at 160 words (~210 tokens), an `evidence` quote, a
+// title, an optional `code_suggestion`, and the JSON scaffolding around them. Deliberately generous --
+// under-budgeting truncates the response, and a truncated response is repaired into valid JSON with its
+// tail findings silently gone (see the finishReason note in google.ts), which reads as "the file is clean".
+const OUTPUT_TOKENS_PER_FINDING = 340;
+// Per file entry in a batched response: the path, `overall_explanation`, `overall_correctness`.
+const OUTPUT_TOKENS_PER_FILE_ENTRY = 160;
+// Enough for the verify/summary paths and any caller that states no budget.
+export const OUTPUT_TOKENS_FLOOR = 8_192;
+
+// Room the ANSWER needs -- reasoning tokens are NOT included, and adapters that bill thinking against
+// the same ceiling must add their thinking budget on top of this rather than carve it out of it.
+//
+// Sized from the ASK, not from the diff: a bin told it may return N findings per file across F files
+// must be able to emit F*N of them, or the instruction and the ceiling contradict each other and the
+// model resolves that by returning almost nothing. Callers pass this as `ModelInput.outputBudgetTokens`;
+// each adapter clamps it to its own provider maximum.
+export function reviewOutputBudgetTokens(input: { findingCap: number; fileCount: number }): number {
+ const files = Math.max(1, input.fileCount);
+ const findings = Math.max(1, input.findingCap) * files;
+ return Math.max(
+ OUTPUT_TOKENS_FLOOR,
+ findings * OUTPUT_TOKENS_PER_FINDING + files * OUTPUT_TOKENS_PER_FILE_ENTRY,
+ );
+}
+
+// Clamps a caller's requested ceiling into what one provider actually accepts. Centralised so a raised
+// `providerMax` cannot silently apply to a caller that never asked for the room.
+export function resolveOutputTokenCeiling(
+ requested: number | undefined,
+ providerMax: number,
+ providerDefault: number,
+): number {
+ if (typeof requested !== 'number' || !Number.isFinite(requested) || requested <= 0) {
+ return Math.min(providerDefault, providerMax);
+ }
+ return Math.min(providerMax, Math.max(providerDefault, Math.ceil(requested)));
+}
+
+// Gemini 2.5 bills `thoughtsTokenCount` against the SAME `maxOutputTokens` the JSON has to fit in, and
+// with no explicit budget it thinks dynamically -- it can consume the whole ceiling and emit only a
+// prefix of the answer. Bounding it is what makes the answer budget mean something; the caller then adds
+// this ON TOP of the answer budget, so thinking can never eat into it.
+// Floored at 1024 and ceilinged at 8192: 0 is refused outright by the Pro models, and every 2.5 model
+// accepts a budget in this band.
+export function geminiThinkingBudgetTokens(answerBudgetTokens: number): number {
+ return Math.min(8_192, Math.max(1_024, Math.floor(answerBudgetTokens / 4)));
+}
+
+// What one attempt can actually cost, as opposed to the ~1 that review/budget.ts budgets it at: the
+// Gemini adapter retries transport errors and may re-probe without its grammar, and a deferral writes
+// chain progress to KV.
+const SUBREQUESTS_PER_MODEL_ATTEMPT = 3;
+
+// Headroom a unit must see before it commits a prompt to the wire. Times the concurrency cap because
+// the check cannot reserve: every in-flight unit may pass it in the same tick and only then start
+// spending, so the floor has to hold for all of them at once.
+//
+// Why a hard floor at all, when budgetAwareFileLimit already sized the chunk: that limit is computed
+// ONCE from the budget at dispatch time and deliberately under-counts (see the note on
+// estimatedSubrequestsPerFile), so a chain would transmit full prompts and learn the invocation was out
+// of subrequests only from the runtime's refusal -- observed paying for three files' prompts before
+// aborting. Declining to start costs nothing, and the unit defers to a fresh budget with its place in
+// the chain remembered.
+export const SUBREQUEST_HEADROOM_FOR_MODEL_CALL = SUBREQUESTS_PER_MODEL_ATTEMPT * MAX_CONCURRENT_MODEL_CALLS;
+
// Tiny FIFO semaphore; callers wait *before* their provider timeout starts, so queueing never eats into a call's own time budget.
export class ModelCallGate {
private active = 0;
diff --git a/src/server/models/openai.ts b/src/server/models/openai.ts
index 74c06b9d..eaf1a4b8 100644
--- a/src/server/models/openai.ts
+++ b/src/server/models/openai.ts
@@ -2,9 +2,13 @@ import { logger } from '@server/core/logger';
import { withTimeout } from '@server/core/timeout';
import { ProviderRequestError, providerErrorMessage, jsonOnlyPrompts, type ModelResponse } from './types';
import { assertPublicBaseUrl } from './url-guard';
+import { MODEL_TIMEOUT_MAX_MS, resolveOutputTokenCeiling } from './limits';
-const OPENAI_TIMEOUT_MS = 80_000;
-const OPENAI_MAX_OUTPUT_TOKENS = 4096;
+// Fallback when the caller supplies no diff-size-aware budget. Shares the review ceiling so an
+// omitting caller can never outlast the chain budget that governs everything else.
+const OPENAI_TIMEOUT_MS = MODEL_TIMEOUT_MAX_MS;
+const OPENAI_DEFAULT_OUTPUT_TOKENS = 4096;
+const OPENAI_MAX_OUTPUT_TOKENS = 16_384;
export interface OpenAIResponse {
choices?: Array<{
@@ -35,11 +39,16 @@ function extractOpenAiText(data: OpenAIResponse) {
export async function reviewWithOpenAI(
config: { apiKey: string | null; baseUrl: string; providerName: string; timeoutMs?: number },
model: string,
- input: { systemPrompt: string; userPrompt: string },
+ input: { systemPrompt: string; userPrompt: string; outputBudgetTokens?: number },
tracker?: { incrementSubrequests(count?: number): void },
): Promise {
logger.info(`Calling OpenAI-format model: ${model}`);
const timeoutMs = config.timeoutMs ?? OPENAI_TIMEOUT_MS;
+ const outputCeiling = resolveOutputTokenCeiling(
+ input.outputBudgetTokens,
+ OPENAI_MAX_OUTPUT_TOKENS,
+ OPENAI_DEFAULT_OUTPUT_TOKENS,
+ );
assertPublicBaseUrl(config.baseUrl, config.providerName);
const prompts = jsonOnlyPrompts(input);
@@ -63,7 +72,7 @@ export async function reviewWithOpenAI(
],
// 0.9 of a 0-2 scale.
temperature: 0.9,
- max_tokens: OPENAI_MAX_OUTPUT_TOKENS,
+ max_tokens: outputCeiling,
response_format: { type: 'json_object' },
}),
}),
diff --git a/src/server/models/types.ts b/src/server/models/types.ts
index 68fd48f6..c3815c82 100644
--- a/src/server/models/types.ts
+++ b/src/server/models/types.ts
@@ -19,6 +19,10 @@ export type ModelInput = {
systemPrompt: string;
userPrompt: string;
responseSchema?: ModelResponseSchema;
+ // Output tokens this call needs to answer in full, from `reviewOutputBudgetTokens`. Advisory: each
+ // adapter clamps it to its own provider maximum and never goes BELOW its own default, so a caller
+ // that omits it is unaffected. Omitting it on a large batched review is what truncates the response.
+ outputBudgetTokens?: number;
};
export class ProviderRequestError extends Error {
diff --git a/src/server/models/vertex.ts b/src/server/models/vertex.ts
index 0607387e..0516ccff 100644
--- a/src/server/models/vertex.ts
+++ b/src/server/models/vertex.ts
@@ -2,10 +2,20 @@ import { logger } from '@server/core/logger';
import { withTimeout } from '@server/core/timeout';
import { ProviderRequestError, UnparseableModelResponseError, providerErrorMessage, jsonOnlyPrompts, type ModelResponse } from './types';
import { assertPublicBaseUrl } from './url-guard';
+import { MODEL_TIMEOUT_MAX_MS, OUTPUT_TOKENS_FLOOR, resolveOutputTokenCeiling } from './limits';
// Vertex's REST API rejects plain API keys and requires an OAuth2 token via RFC 7523 JWT-bearer grant, so `apiKey` here holds the full service-account JSON key, not a short API key string.
-const VERTEX_TIMEOUT_MS = 45_000;
-const VERTEX_MAX_OUTPUT_TOKENS = 8192;
+const VERTEX_TIMEOUT_MS = MODEL_TIMEOUT_MAX_MS;
+const VERTEX_DEFAULT_OUTPUT_TOKENS = OUTPUT_TOKENS_FLOOR;
+// Same Gemini models as the Google adapter, so the same ceiling. No `thinkingConfig` here though: this
+// adapter makes ONE attempt and has no latch, so a model that refused the field would fail the file.
+const VERTEX_MAX_OUTPUT_TOKENS = 65_536;
+// Retries for a 429 only, and only while the caller's own timeout still has room. See the loop below
+// for why resending an unchanged request is the correct response to this particular refusal.
+const VERTEX_QUOTA_RETRIES = 2;
+const VERTEX_QUOTA_BACKOFF_MS = 4_000;
+// Room a resend needs to be worth starting at all; a Vertex 429 itself comes back in ~7s.
+const VERTEX_MIN_ATTEMPT_MS = 8_000;
const OAUTH_TOKEN_URL = 'https://oauth2.googleapis.com/token';
const OAUTH_SCOPE = 'https://www.googleapis.com/auth/cloud-platform';
const ACCESS_TOKEN_LIFETIME_S = 3600;
@@ -117,11 +127,16 @@ async function getAccessToken(
export async function reviewWithVertex(
config: { apiKey: string; baseUrl?: string | null; providerName?: string; timeoutMs?: number },
model: string,
- input: { systemPrompt: string; userPrompt: string },
+ input: { systemPrompt: string; userPrompt: string; outputBudgetTokens?: number },
tracker?: { incrementSubrequests(count?: number): void },
): Promise {
const providerName = config.providerName ?? 'Google Vertex AI';
const timeoutMs = config.timeoutMs ?? VERTEX_TIMEOUT_MS;
+ const outputCeiling = resolveOutputTokenCeiling(
+ input.outputBudgetTokens,
+ VERTEX_MAX_OUTPUT_TOKENS,
+ VERTEX_DEFAULT_OUTPUT_TOKENS,
+ );
logger.info(`Calling Vertex AI model: ${model}`);
assertPublicBaseUrl(config.baseUrl, providerName);
@@ -141,33 +156,57 @@ export async function reviewWithVertex(
const baseUrl = config.baseUrl.replace(/\/+$/, '');
const url = `${baseUrl}/publishers/google/models/${encodeURIComponent(model)}:generateContent`;
- if (tracker) tracker.incrementSubrequests(1);
- const response = await withTimeout('Vertex AI', timeoutMs, (signal) =>
- fetch(url, {
- method: 'POST',
- signal,
- headers: {
- 'content-type': 'application/json',
- authorization: `Bearer ${accessToken}`,
- },
- body: JSON.stringify({
- systemInstruction: {
- role: 'system',
- parts: [{ text: prompts.system }],
- },
- contents: [
- { role: 'user', parts: [{ text: prompts.user }] },
- ],
- generationConfig: {
- responseMimeType: 'application/json',
- // No `responseJsonSchema`: this adapter makes one attempt and cannot drop the schema, so a rejection would fail the file outright.
- maxOutputTokens: VERTEX_MAX_OUTPUT_TOKENS,
- // Same models as the Google adapter, so the same value keeps the two paths comparable.
- temperature: 0.9,
+ const body = JSON.stringify({
+ systemInstruction: {
+ role: 'system',
+ parts: [{ text: prompts.system }],
+ },
+ contents: [
+ { role: 'user', parts: [{ text: prompts.user }] },
+ ],
+ generationConfig: {
+ responseMimeType: 'application/json',
+ // No `responseJsonSchema`: this adapter cannot drop a schema mid-flight, so a rejection would fail the file outright.
+ maxOutputTokens: outputCeiling,
+ // Same models as the Google adapter, so the same value keeps the two paths comparable.
+ temperature: 0.9,
+ },
+ });
+
+ const attempt = () =>
+ withTimeout('Vertex AI', timeoutMs, (signal) =>
+ fetch(url, {
+ method: 'POST',
+ signal,
+ headers: {
+ 'content-type': 'application/json',
+ authorization: `Bearer ${accessToken}`,
},
+ body,
}),
- }),
- );
+ );
+
+ if (tracker) tracker.incrementSubrequests(1);
+ let response = await attempt();
+
+ // A Vertex 429 here is queueing, not a bucket the caller can pace around. Measured over ~900 calls on
+ // one project: roughly three in four refused, and the refusal was uncorrelated with the requested
+ // output ceiling, with the endpoint, and with whether the previous call succeeded -- resending the
+ // IDENTICAL request works. The adapter used to make one attempt and turn every one of those into a
+ // failed file, which is the one case where the single-attempt rule above does not apply: there is no
+ // schema to re-probe and nothing about the request to change.
+ //
+ // Bounded by the caller's own timeout, not by a retry count alone: `timeoutMs` is already clamped to
+ // the fallback-chain budget, so a slow rung must not spend the whole invocation sitting in backoff.
+ for (let retry = 0; retry < VERTEX_QUOTA_RETRIES && response.status === 429; retry++) {
+ const waitMs = VERTEX_QUOTA_BACKOFF_MS * (retry + 1);
+ if (Date.now() - startTime + waitMs + VERTEX_MIN_ATTEMPT_MS > timeoutMs) break;
+
+ logger.warn(`Vertex AI refused with 429; resending unchanged in ${waitMs}ms`, { model, retry: retry + 1 });
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
+ if (tracker) tracker.incrementSubrequests(1);
+ response = await attempt();
+ }
if (!response.ok) {
const message = providerErrorMessage(await response.text());
diff --git a/src/server/prompts/file-review.ts b/src/server/prompts/file-review.ts
index d32748b6..af3c3151 100644
--- a/src/server/prompts/file-review.ts
+++ b/src/server/prompts/file-review.ts
@@ -4,6 +4,12 @@ import type { ModelResponseSchema } from '@server/models/types';
import { getLanguageForFile } from './languages';
// Generator cap, NOT the posted cap: per CHUNK, upstream of four remove-only filters, where `max_comments` is once per job.
+//
+// Deliberately NOT divided by the size of a batched bin. That was tried, on the theory that a six-file
+// bin asking 20 findings per file requested more than one response could hold: measured on a 221-file
+// job, all 71 bin responses ended cleanly at 967-1,845 chars and the whole job spent 17,158 output
+// tokens -- about 3% of the ceiling that was supposedly binding. The cap has never been what limits
+// findings, so lowering it only removes room a genuinely defective file might need.
export function generatorFindingCap(maxComments: number): number {
return Math.max(1, maxComments * 2);
}
@@ -171,8 +177,13 @@ export function buildFileReviewSystemPromptBase(opts?: { multiFile?: boolean }):
? '4. Return at most {{MAX_COMMENTS}} findings PER FILE, most severe first. Keep each body under 160 words.'
: '4. Return at most {{MAX_COMMENTS}} findings, most severe first. Keep each body under 160 words.';
+ // The multi-file wording must demand one entry per file (the parser reports a missing file as
+ // unreviewed and re-queues it) WITHOUT handing out an empty array as the easy way to satisfy that.
+ // The previous phrasing -- "even for files with no defect, give those an empty findings array" --
+ // presupposed clean files in every bin and reintroduced exactly the restraint language the note above
+ // says measured 0.039 findings/file. Review each diff on its own merits is the whole instruction.
const emptyRule = multi
- ? `5. Return exactly one entry per file listed below, in the same order, even for files with no defect - give those an empty findings array and a short explanation. Do not pad, do not withhold, and do not omit a file.`
+ ? `5. Return exactly one entry per file listed below, in the same order, and never omit a file. Review each file's diff with the same care you would give it if it were the only file in front of you. An empty findings array is a positive claim that this diff introduces no defect, so return one only when that is true. Do not pad, and do not withhold.`
: '5. If the diff genuinely introduces no defect, return an empty findings array and a short explanation. Do not pad, and do not withhold.';
return `You are a world-class software engineer performing a precise, high-signal code review.
@@ -180,13 +191,14 @@ Your goal is to find REAL defects (bugs, security vulnerabilities, and performan
### CONTEXT EXTENDS (read carefully, this prevents false positives):
${contextScope}
-- Do NOT report that a symbol is undefined, unimported, unused, missing, or never-called merely because its declaration or usage is not visible in the diff. Imports, types, and definitions frequently live in unchanged parts of the file. Flag such an issue ONLY if the diff itself clearly introduces it.
-- Do NOT assume how code elsewhere behaves. If confirming an issue requires code you cannot see, do not report it.
+- You cannot see which files import this one. Never predict that a change breaks callers, importers, "other modules" or "external files" -- a removed \`export\`, a renamed symbol or a changed signature may have no consumers at all, and you have no way to check. The same applies in reverse to a function whose body is not shown: do not assume what it does with its errors or its return value.
+- Assume every third-party package is at the version this project pins, and that its API is whatever that version provides. Never claim a library "does not expose", "does not provide" or "does not support" something; your training data predates the installed version.
+- Assume the language, runtime and build target are whatever the project already uses successfully. A syntax or standard-library method appearing in the diff is available in this project by construction -- the code around it already compiles and ships. Do not raise compatibility, polyfill, transpilation, engine-version or server-side-rendering concerns unless the diff itself shows the incompatibility.
+- Two async facts that are frequently misread. \`return somePromise()\` inside an \`async\` function IS awaited by whoever awaits that function; it is equivalent to \`return await\` except inside \`try\`/\`finally\`, so it is not a missing await and not a floating promise. And \`void someAsyncCall()\` is deliberate fire-and-forget: if the called function handles its own errors, there is no unhandled rejection to report.
### WHAT TO REPORT:
- Report anything a senior engineer reviewing this diff would want to investigate: a bug, a security hole, a performance problem, a resource leak, an unhandled failure, a broken invariant.
- You do not need to be certain. A finding you can ground in a quoted line is worth raising; every finding is independently checked against the diff afterwards, and a wrong one is discarded at no cost to you. A defect you decline to mention is simply lost.
-- Do NOT report subjective preferences (naming, formatting, "cleaner" alternatives, "consider using X") unless they cause a concrete bug, security hole, or measurable performance problem. These are discarded and crowd out real defects.
### EVIDENCE (mandatory, a finding without it cannot be posted):
- Every finding MUST include "evidence": ${evidenceSource}
@@ -205,6 +217,7 @@ ${claimTypes.join(', ')}
1. Output MUST be valid JSON, EXACTLY ONE object matching the schema below.
2. DO NOT output any conversational text, source code, or diff hunks before or after the JSON.
3. Prioritize by severity: 0 = P0 critical, 1 = P1 high, 2 = P2 medium, 3 = P3 low, 4 = nit (cosmetic/trivial). Set priority honestly; do not inflate. Use 4 for anything a reviewer would prefix with "nit:".
+ A finding that rests on a condition you cannot check from the diff -- "if this runs on an older engine", "if another module imports this", "depending on the caller" -- is at most priority 3, never 0 or 1, however serious the consequence would be if the condition held. Certainty about the consequence is not certainty about the premise.
${capRule}
${emptyRule}
diff --git a/src/server/prompts/verify.ts b/src/server/prompts/verify.ts
index 4bf0cc63..8ecaf8c1 100644
--- a/src/server/prompts/verify.ts
+++ b/src/server/prompts/verify.ts
@@ -19,6 +19,9 @@ const verifyResultSchema = z.object({
index: z.number().int(),
// `.optional()` and NOT `.default()`: a default would materialize the key on every parsed result, changing the shape callers compare against.
reason: z.string().optional(),
+ // Optional so a model that ignores the field is treated as "did not say", never as "not
+ // decidable" -- only an explicit `false` costs a finding. See the note on the prompt below.
+ decidable: z.boolean().optional(),
verdict: z.enum(['keep', 'drop']),
confidence: z.number().min(0).max(1).optional(),
}),
@@ -28,7 +31,9 @@ const verifyResultSchema = z.object({
export type VerifyResult = z.infer['results'][number];
-// Field order matters for providers that decode against the schema: `reason` precedes `verdict` so the model commits to a justification BEFORE the decision token.
+// Field order matters for providers that decode against the schema: `reason` precedes `verdict` so the
+// model commits to a justification BEFORE the decision token, and `decidable` precedes it for the same
+// reason -- it must answer "could I check this at all?" before it is allowed to answer "is it true?".
export const VERIFY_RESPONSE_SCHEMA = {
name: 'codra_verify_findings',
schema: {
@@ -41,10 +46,13 @@ export const VERIFY_RESPONSE_SCHEMA = {
items: {
type: 'object',
additionalProperties: false,
- required: ['index', 'reason', 'verdict'],
+ required: ['index', 'reason', 'decidable', 'verdict'],
properties: {
index: { type: 'integer', minimum: 0 },
- reason: { type: 'string', maxLength: 200 },
+ // Longer than the 15 words the verdict gets: naming the artifact you would need to check
+ // a claim is the whole point of the `decidable` field, and it does not fit in 15 words.
+ reason: { type: 'string', maxLength: 300 },
+ decidable: { type: 'boolean' },
verdict: { type: 'string', enum: ['keep', 'drop'] },
confidence: { type: 'number', minimum: 0, maximum: 1 },
},
@@ -56,19 +64,41 @@ export const VERIFY_RESPONSE_SCHEMA = {
export const VERIFY_SYSTEM_PROMPT = `You are a meticulous senior engineer checking whether each candidate code-review finding is actually supported by the code it points at.
-For EACH finding you are given the claim and the diff context it was anchored to. Decide:
-- "keep": the quoted/anchored code genuinely exhibits the problem the claim describes.
-- "drop": the claim is not supported by the code shown - it describes something that isn't there, it is speculative, it is a subjective style preference, or confirming it would require code that is not visible.
+For EACH finding you are given the claim and a SHORT WINDOW of diff context around the line it was anchored to. That window is all you have: you cannot see the rest of the file, any other file, the project's dependencies and their versions, its build target, or its runtime.
+
+Answer two questions per finding, in this order.
+
+1. "decidable": can this claim be settled from the window you were given?
+ - true - the window contains everything needed to say whether the claim holds.
+ - false - settling it would need something outside the window: which files import this one, what a function defined elsewhere does, which version of a dependency is installed, what engine or renderer the code runs on, or how a caller uses the result.
+ Watch for claims that assert a CONSEQUENCE somewhere you cannot see: "this breaks importers", "this throws on older runtimes", "this fails during server rendering", "the caller will not await this". The anchored line can be exactly as quoted and the consequence still be unverifiable - confirming that the quote is real is NOT confirming the claim.
+ When "decidable" is false, say in "reason" what you would have to look at, e.g. "would need the importers of this module".
+
+ Two rules, because both have been got wrong on real reviews:
+
+ a) A claim of the form "if X() fails / rejects / throws, this is unhandled" is NOT decidable unless the
+ BODY of X is inside your window. A function whose body you cannot see may well handle its own
+ errors, in which case there is nothing to report. Seeing the CALL is not seeing the body. Mark it
+ not decidable and say you would need that function's implementation.
+
+ b) Read the diff markers before you agree that something was removed or changed. A line prefixed "-"
+ is the OLD code and a line prefixed "+" is the NEW code. A claim that says "X was replaced by Y" is
+ false if the diff shows Y being replaced by X, and a claim that a safeguard was "removed" is false
+ if the "+" line still carries an equivalent one under a different name. State the direction in your
+ reason: "the + line adds strict validation, so the claim is backwards".
+
+2. "verdict":
+ - "keep": the code in the window genuinely exhibits the problem the claim describes.
+ - "drop": the claim is not supported by the code shown - it describes something that isn't there, it is speculative, it is a subjective style preference, or it is not decidable from this window.
+ A claim you marked not decidable is always a "drop".
Judge the CLAIM against the CODE. Do not defer to the claim's confidence or phrasing; a well-written claim about code that doesn't do what it says is still a drop.
Be strict: when in doubt, "drop". It is better to drop a borderline finding than to keep a wrong one.
-Give a "reason" of at most 15 words BEFORE the verdict, then the verdict.
-
Output MUST be valid JSON, exactly one object, no prose before or after:
{
"results": [
- { "index": , "reason": "", "verdict": "keep" | "drop", "confidence": }
+ { "index": , "reason": "", "decidable": true | false, "verdict": "keep" | "drop", "confidence": }
]
}
Include exactly one result object for every finding index provided, and use the same index numbers you were given.`;
diff --git a/src/server/routes/api/auth.ts b/src/server/routes/api/auth.ts
index aeb274ea..04da08b0 100644
--- a/src/server/routes/api/auth.ts
+++ b/src/server/routes/api/auth.ts
@@ -6,17 +6,17 @@ import { getUpdatesEmailPreference, syncUpdatesEmail } from '@server/core/update
import { getAccountSettings, updateAccountSettings, upsertAccountSettings } from '@server/db/accounts';
import type { AppEnv } from '@server/env';
-const emailSchema = z.object({
+const emailSchema = z.strictObject({
email: z.string().trim().email().max(254),
-}).strict();
+});
// Fields are independently optional (at least one required); timezone null means "follow the browser", else must be an Intl-known zone.
-const accountUpdateSchema = z.object({
+const accountUpdateSchema = z.strictObject({
name: z.string().trim().min(1).max(120).optional(),
timezone: z.string().trim().min(1).max(64).refine(isSupportedTimeZone, {
message: 'Unknown time zone.',
}).nullable().optional(),
-}).strict().refine(
+}).refine(
(body) => body.name !== undefined || body.timezone !== undefined,
{ message: 'Nothing to update.' },
);
diff --git a/src/server/routes/api/jobs.ts b/src/server/routes/api/jobs.ts
index 2da48cfa..59f6b45f 100644
--- a/src/server/routes/api/jobs.ts
+++ b/src/server/routes/api/jobs.ts
@@ -9,6 +9,7 @@ import { jsonError } from '@server/core/http';
import { scheduleBestEffortJobMaintenance } from '@server/core/job-recovery';
import { loadRepoConfig } from '@server/core/config';
import { logger } from '@server/core/logger';
+import { disposeRpc } from '@server/core/rpc';
import { getOrFetchRawDiffForCompletedJob } from '@server/core/review';
import { parseUnifiedDiff } from '@server/core/diff';
import { buildFileReviewPrompts } from '@server/prompts/file-review';
@@ -17,13 +18,18 @@ import { GitHubService } from '@server/services/github';
// Best-effort terminate; .get() throws if the instance is gone and .terminate() if already terminal, both non-fatal.
async function terminateJobWorkflow(env: AppBindings, job: { id: string; workflowInstanceId?: string | null }) {
const instanceId = job.workflowInstanceId ?? job.id;
+ let instance: Awaited> | undefined;
try {
- const instance = await env.REVIEW_WORKFLOW.get(instanceId);
+ instance = await env.REVIEW_WORKFLOW.get(instanceId);
await instance.terminate();
} catch (error) {
logger.info(`Could not terminate workflow for job ${job.id} (already finished or never started)`, {
error: error instanceof Error ? error.message : String(error),
});
+ } finally {
+ // In `finally` on purpose: .terminate() throws on an already-terminal instance, and the handle
+ // still needs releasing on that path. See core/rpc.ts.
+ disposeRpc(instance);
}
}
diff --git a/src/server/routes/api/models.ts b/src/server/routes/api/models.ts
index 6be6d382..c17a8db5 100644
--- a/src/server/routes/api/models.ts
+++ b/src/server/routes/api/models.ts
@@ -35,39 +35,40 @@ const apiFormatSchema = z.enum(llmApiFormats);
const positiveIntegerSchema = z.number().int().positive().finite();
const modelIdSchema = z.string().trim().min(1);
const optionalUrlSchema = z.string().trim().url().nullable().optional();
-const providerIdSchema = z.string().uuid();
+const providerIdSchema = z.uuid();
-const providerCreateSchema = z.object({
+const providerCreateSchema = z.strictObject({
name: z.string().trim().min(1),
apiFormat: apiFormatSchema,
baseUrl: optionalUrlSchema,
apiKey: z.string().optional(),
enabled: z.boolean().default(true),
-}).strict();
+});
+// `.extend()` carries the parent's strictness through, so no second `.strict()` is needed here.
const providerUpdateSchema = providerCreateSchema.extend({
clearApiKey: z.boolean().optional(),
-}).strict();
+});
-const modelConfigUpdateSchema = z.object({
+const modelConfigUpdateSchema = z.strictObject({
providerId: providerIdSchema,
modelName: z.string().trim().min(1),
-}).strict();
+});
-const globalModelConfigSchema = z.object({
+const globalModelConfigSchema = z.strictObject({
main: modelIdSchema.nullable().default(null),
fallbacks: z.array(modelIdSchema).nullable().default([]),
size_overrides: z
.array(
- z.object({
+ z.strictObject({
max_lines: positiveIntegerSchema,
model: modelIdSchema,
fallbacks: z.array(modelIdSchema).optional(),
- }).strict(),
+ }),
)
.nullable()
.optional(),
-}).strict();
+});
function normalizedBaseUrl(apiFormat: z.infer, baseUrl?: string | null) {
if (apiFormat === 'cloudflare-workers-ai') return null;
diff --git a/src/server/routes/api/repos.ts b/src/server/routes/api/repos.ts
index cf730a8d..1617929d 100644
--- a/src/server/routes/api/repos.ts
+++ b/src/server/routes/api/repos.ts
@@ -8,12 +8,11 @@ import { invalidateRepoConfigCache } from '@server/core/config';
import { repoConfigSchema } from '@shared/schema';
const repoConfigPatchSchema = z
- .object({
+ .strictObject({
enabled: z.boolean().optional(),
review: repoConfigSchema.shape.review.optional(),
model: repoConfigSchema.shape.model.optional(),
})
- .strict()
.refine(
(patch) => patch.enabled !== undefined || patch.review !== undefined || patch.model !== undefined,
'Repository config patch cannot be empty.',
@@ -69,15 +68,18 @@ export function createReposRouter() {
repos,
5,
async (repo: GitHubRepository) => {
+ const owner = repo.owner.login;
+ const name = repo.name;
+ const fullName = `${owner}/${name}`;
try {
await syncRepoConfig(c.env, {
installationId: String(inst.id),
- owner: repo.owner.login,
- repo: repo.name,
+ owner,
+ repo: name,
});
- return `${repo.owner.login}/${repo.name}`;
+ return fullName;
} catch (repoError) {
- console.error('Failed to sync repo:', `${repo.owner.login}/${repo.name}`, repoError);
+ console.error('Failed to sync repo:', fullName, repoError);
return null;
}
},
diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts
index 905d8c18..136107a0 100644
--- a/src/server/routes/api/settings.ts
+++ b/src/server/routes/api/settings.ts
@@ -5,14 +5,14 @@ import { getReviewSettings, updateReviewSettings } from '@server/db/app-settings
import { jsonError } from '@server/core/http';
import { reviewConcurrencyLevels, reviewMaxCommentsOptions, reviewMaxFilesRange, reviewSettingsSchema } from '@shared/schema';
-const reviewSettingsPatchSchema = z.object({
+const reviewSettingsPatchSchema = z.strictObject({
concurrencyLevel: z.enum(reviewConcurrencyLevels).optional(),
maxComments: z.number().int().refine(
(value) => (reviewMaxCommentsOptions as readonly number[]).includes(value),
'Invalid max comments value.',
).optional(),
maxFiles: z.number().int().min(reviewMaxFilesRange.min).max(reviewMaxFilesRange.max).optional(),
-}).strict().refine(
+}).refine(
(settings) => Object.values(settings).some((value) => value !== undefined),
'At least one setting must be provided.',
);
diff --git a/src/server/services/model-chain-progress.ts b/src/server/services/model-chain-progress.ts
index a7130c66..c5240b5c 100644
--- a/src/server/services/model-chain-progress.ts
+++ b/src/server/services/model-chain-progress.ts
@@ -1,6 +1,7 @@
import { logger } from '../core/logger';
import type { AppBindings } from '../env';
import type { TokenTracker } from '../core/token-tracker';
+import { isPlausibleTokenBucket } from './model-support';
// Where each label (file path, or a bin's label) got to in its model chain, so a deferred review
// resumes at the next model instead of replaying the models that already failed for it.
@@ -21,15 +22,70 @@ const CHAIN_PROGRESS_TTL_SECONDS = 24 * 60 * 60;
// whole round rather than on a single slow call, and is dropped from the next invocation onward.
const MODEL_TIMEOUT_STRIKES = 3;
-type StoredShape = { files?: Record; timeouts?: Record };
+// Strikes before the LAST candidate in a chain is dropped too. Higher than MODEL_TIMEOUT_STRIKES
+// because dropping the last one defers the unit having attempted no model at all, which is the worse
+// outcome for a merely-slow model. But it must be FINITE: exempting the tail entirely is what let one
+// model burn 15 minutes of a job's wall clock at 20+ consecutive timeouts, every unit paying a full
+// per-call budget to learn what the tally already knew. Strikes reset on success (see noteSuccess),
+// so a count this high means the model has not once answered on this job.
+const LAST_CANDIDATE_TIMEOUT_STRIKES = 6;
+
+// Ceiling on a persisted cool-off. A mis-parsed "retry in 3600s" would otherwise disable a model for
+// the rest of the job; per-minute buckets never legitimately need more than this.
+const MAX_PERSISTED_COOLDOWN_MS = 5 * 60 * 1000;
+
+export interface ModelCooldown {
+ cooldownUntil: number;
+ limitTokens?: number;
+}
+
+type StoredCooldown = { until?: unknown; limitTokens?: unknown };
+type StoredShape = {
+ files?: Record;
+ timeouts?: Record;
+ cooldowns?: Record;
+};
function positiveInts(source: Record | undefined): Map {
- if (!source || typeof source !== 'object') return new Map();
- return new Map(
- Object.entries(source)
- .filter(([, value]) => typeof value === 'number' && Number.isInteger(value) && value > 0)
- .map(([key, value]) => [key, value as number]),
- );
+ const kept = new Map();
+ if (!source || typeof source !== 'object') return kept;
+ for (const [key, value] of Object.entries(source)) {
+ if (typeof value === 'number' && Number.isInteger(value) && value > 0) kept.set(key, value);
+ }
+ return kept;
+}
+
+// `until` is an absolute epoch-ms deadline, never a duration: a stale KV read then yields an
+// already-expired entry, which degrades to today's behaviour (one wasted probe) and can never
+// over-suppress. Expired entries are kept, not dropped -- `limitTokens` outlives the cool-off and
+// still answers "can this prompt ever fit in that bucket?".
+function parseCooldowns(source: Record | undefined): Map {
+ const kept = new Map();
+ if (!source || typeof source !== 'object') return kept;
+
+ const ceiling = Date.now() + MAX_PERSISTED_COOLDOWN_MS;
+ for (const [model, value] of Object.entries(source)) {
+ if (!value || typeof value !== 'object') continue;
+ const until = typeof value.until === 'number' && Number.isFinite(value.until) ? value.until : 0;
+ // Implausible buckets are dropped rather than trusted: a job that persisted a misparsed request
+ // count would otherwise keep skipping every prompt for that model until the memo's TTL expired.
+ const limitTokens =
+ typeof value.limitTokens === 'number' && isPlausibleTokenBucket(value.limitTokens)
+ ? value.limitTokens
+ : undefined;
+ if (until <= 0 && limitTokens === undefined) continue;
+ kept.set(model, { cooldownUntil: Math.min(until, ceiling), limitTokens });
+ }
+ return kept;
+}
+
+function mergeCooldown(a: ModelCooldown | undefined, b: ModelCooldown): ModelCooldown {
+ return {
+ // Indexes and deadlines both only move forward, which makes max() the idempotent merge here too.
+ cooldownUntil: Math.max(a?.cooldownUntil ?? 0, b.cooldownUntil),
+ // Sticky: a later 429 that omits the bucket size must not erase a known one.
+ limitTokens: a?.limitTokens ?? b.limitTokens,
+ };
}
export class ModelChainProgressStore {
@@ -39,6 +95,16 @@ export class ModelChainProgressStore {
// cost a second subrequest read per invocation, out of the 50 this whole mechanism exists to save.
private timeouts = new Map();
+ // Models whose strikes a success cleared in THIS invocation. Needed because writeOnce merges the
+ // stored tally with max(): without it the merge would read the pre-success count back out of KV and
+ // undo the reset, making the clear invisible the moment it was persisted.
+ private clearedTimeouts = new Set();
+
+ // Per-model rate-limit state, in the same KV value again. Without persistence ModelRateLimitBook
+ // is invocation-scoped, so every continuation re-paid a full-prompt 429 to re-learn the cool-off
+ // this job already knew -- which is what the comment in model-review-chain.ts assumed was covered.
+ private cooldowns = new Map();
+
// Single-flight writer. Two bins deferring at once used to issue two overlapping puts, and KV has
// no ordering guarantee: if the put carrying LESS state happened to land second, the other bin's
// entry was gone and those files replayed a model already ruled out. Only one put is ever in
@@ -71,8 +137,13 @@ export class ModelChainProgressStore {
// Values written before `timeouts` existed are a bare label->index map. Reading them as the
// files map keeps in-flight jobs resuming correctly across the deploy.
const stored = raw as StoredShape;
- const isNewShape = stored.files !== undefined || stored.timeouts !== undefined;
+ const isNewShape =
+ stored.files !== undefined || stored.timeouts !== undefined || stored.cooldowns !== undefined;
this.timeouts = positiveInts(isNewShape ? stored.timeouts : undefined);
+ // Merged, not assigned: noteRateLimit is sync and may land before this read resolves.
+ for (const [model, value] of parseCooldowns(isNewShape ? stored.cooldowns : undefined)) {
+ this.cooldowns.set(model, mergeCooldown(this.cooldowns.get(model), value));
+ }
return positiveInts(isNewShape ? stored.files : (raw as Record));
} catch (error) {
// A missing memo costs a repeated model attempt, never correctness -- never fail the review for it.
@@ -137,19 +208,34 @@ export class ModelChainProgressStore {
const raw = await this.env.APP_KV.get(key, 'json');
if (raw && typeof raw === 'object') {
const stored = raw as StoredShape;
- const isNewShape = stored.files !== undefined || stored.timeouts !== undefined;
+ const isNewShape =
+ stored.files !== undefined || stored.timeouts !== undefined || stored.cooldowns !== undefined;
for (const [label, value] of positiveInts(isNewShape ? stored.files : (raw as Record))) {
if (value > (progress.get(label) ?? 0)) progress.set(label, value);
}
for (const [model, value] of positiveInts(isNewShape ? stored.timeouts : undefined)) {
+ // A success in this invocation outranks any stored tally; see `clearedTimeouts`.
+ if (this.clearedTimeouts.has(model)) continue;
if (value > (this.timeouts.get(model) ?? 0)) this.timeouts.set(model, value);
}
+ for (const [model, value] of parseCooldowns(isNewShape ? stored.cooldowns : undefined)) {
+ this.cooldowns.set(model, mergeCooldown(this.cooldowns.get(model), value));
+ }
}
this.tracker?.incrementSubrequests(1);
await this.env.APP_KV.put(
key,
- JSON.stringify({ files: Object.fromEntries(progress), timeouts: Object.fromEntries(this.timeouts) }),
+ JSON.stringify({
+ files: Object.fromEntries(progress),
+ timeouts: Object.fromEntries(this.timeouts),
+ cooldowns: Object.fromEntries(
+ Array.from(this.cooldowns, ([model, entry]) => [
+ model,
+ { until: entry.cooldownUntil, limitTokens: entry.limitTokens },
+ ]),
+ ),
+ }),
{ expirationTtl: CHAIN_PROGRESS_TTL_SECONDS },
);
} catch (error) {
@@ -177,8 +263,50 @@ export class ModelChainProgressStore {
return this.flush();
}
+ // A success proves the model works here, so its tally restarts. Without this the count was
+ // cumulative over a job's whole 24h memo, so three slow calls early on condemned a healthy model for
+ // the rest of it -- and LAST_CANDIDATE_TIMEOUT_STRIKES could not mean "never answered".
+ async noteSuccess(modelId: string): Promise {
+ if (!this.key) return;
+ await this.load();
+ // No-ops for a model with a clean record, which is the overwhelmingly common case: the healthy
+ // path must not pay a KV get+put per reviewed file out of a budget of 50.
+ if (!this.timeouts.has(modelId)) return;
+ this.timeouts.delete(modelId);
+ this.clearedTimeouts.add(modelId);
+ this.dirty = true;
+ return this.flush();
+ }
+
async isTimingOut(modelId: string): Promise {
await this.load();
return (this.timeouts.get(modelId) ?? 0) >= MODEL_TIMEOUT_STRIKES;
}
+
+ // For the tail of a chain, which has no fallback to fall through to.
+ async isTimingOutTerminally(modelId: string): Promise {
+ await this.load();
+ return (this.timeouts.get(modelId) ?? 0) >= LAST_CANDIDATE_TIMEOUT_STRIKES;
+ }
+
+ // Shares load()'s single promise, so hydrating the rate-limit book costs no extra KV read.
+ async loadCooldowns(): Promise> {
+ await this.load();
+ return new Map(this.cooldowns);
+ }
+
+ // Deliberately sync and non-flushing. A 429 does not advance chain progress (see the caller), so
+ // flushing here would add a get+put pair on a path that has none today; the deferral that follows
+ // calls flushPending() instead, and the single-flight writer coalesces a whole wave into one put.
+ noteRateLimit(modelId: string, entry: ModelCooldown): void {
+ if (!this.key) return;
+ this.cooldowns.set(modelId, mergeCooldown(this.cooldowns.get(modelId), entry));
+ this.dirty = true;
+ }
+
+ // For paths that mutated state without advancing progress -- notably a quota deferral.
+ flushPending(): Promise {
+ if (!this.key || !this.dirty) return Promise.resolve();
+ return this.flush();
+ }
}
diff --git a/src/server/services/model-chain-runner.ts b/src/server/services/model-chain-runner.ts
index 8bf30cc6..e16d69a5 100644
--- a/src/server/services/model-chain-runner.ts
+++ b/src/server/services/model-chain-runner.ts
@@ -1,6 +1,6 @@
import { buildSummaryPrompt, SUMMARY_SYSTEM_PROMPT } from '../prompts/summary';
import { buildVerifyPrompt, VERIFY_RESPONSE_SCHEMA, VERIFY_SYSTEM_PROMPT, type VerifyCandidate } from '../prompts/verify';
-import { adaptiveModelTimeoutMs, MODEL_FALLBACK_CHAIN_BUDGET_MS } from '../models/limits';
+import { adaptiveModelTimeoutMs, clampTimeoutToChainBudget, MODEL_FALLBACK_CHAIN_BUDGET_MS } from '../models/limits';
import { isCloudflareAllocationError, isTransientModelFailure, RetryableModelError } from './model-support';
import { logger } from '../core/logger';
import type { RepoConfig } from '@shared/schema';
@@ -102,7 +102,7 @@ export async function verifyFindings(ctx: ModelChainContext, params: { candidate
responseSchema: VERIFY_RESPONSE_SCHEMA as unknown as ModelInput['responseSchema'],
};
// Scale the timeout with the number of findings under review (capped inside adaptiveModelTimeoutMs).
- const timeoutMs = adaptiveModelTimeoutMs(params.candidates.length * 8);
+ const timeoutMs = clampTimeoutToChainBudget(adaptiveModelTimeoutMs(params.candidates.length * 8));
let lastError: unknown;
const chainStartedAt = Date.now();
@@ -117,10 +117,12 @@ export async function verifyFindings(ctx: ModelChainContext, params: { candidate
});
break;
}
- if (modelIndex > 0 && Date.now() - chainStartedAt - gateWaitMs > MODEL_FALLBACK_CHAIN_BUDGET_MS) {
- logger.warn('Stopping the verification chain; it exceeded its per-invocation time budget', {
+ // Prospective: see the matching check in runModelChain.
+ if (modelIndex > 0 && Date.now() - chainStartedAt - gateWaitMs + timeoutMs > MODEL_FALLBACK_CHAIN_BUDGET_MS) {
+ logger.warn('Stopping the verification chain; no room in the per-invocation time budget for another model', {
elapsedMs: Date.now() - chainStartedAt,
gateWaitMs,
+ timeoutMs,
skippedModels: modelsToTry.slice(modelIndex),
});
break;
diff --git a/src/server/services/model-rate-limits.ts b/src/server/services/model-rate-limits.ts
index e6a00008..8d14d889 100644
--- a/src/server/services/model-rate-limits.ts
+++ b/src/server/services/model-rate-limits.ts
@@ -3,6 +3,13 @@ import { ModelCallGate } from '../models/limits';
import type { ResolvedModelConfig } from '@server/db/model-configs';
import { MAX_METERED_QUEUE_DEPTH, PROMPT_FIT_SAFETY_FACTOR, parseRateLimitFromError } from './model-support';
+// Narrow port onto whatever survives an invocation (today: the job's chain-progress KV value), so
+// this class stays unit-testable without an env and the barrel surface is unchanged.
+export interface RateLimitPersistence {
+ loadCooldowns(): Promise>;
+ noteRateLimit(modelId: string, entry: { cooldownUntil: number; limitTokens?: number }): void;
+}
+
// Import from the services/model barrel, not here (four specs vi.mock it).
export class ModelRateLimitBook {
// Workers allows 6 simultaneous connections per invocation; gating starts the client timeout once a slot is held, instead of while queued.
@@ -14,17 +21,46 @@ export class ModelRateLimitBook {
// Keyed by model, not provider: keying by provider serialized every call in an all-Google chain, dropping concurrency and throughput.
private readonly tokenMeteredModels = new Map();
+ // Memoized so the KV read is shared, not repeated per model per file.
+ private hydrated: Promise | null = null;
+
+ constructor(private readonly persistence?: RateLimitPersistence) {}
+
+ // Without this the book is invocation-scoped: every job continuation re-paid a full-prompt 429 to
+ // re-learn a cool-off the previous invocation had already been told about.
+ private hydrate(): Promise {
+ this.hydrated ??= (async () => {
+ if (!this.persistence) return;
+ for (const [modelName, entry] of await this.persistence.loadCooldowns()) {
+ const existing = this.modelRateLimits.get(modelName);
+ this.modelRateLimits.set(modelName, {
+ limitTokens: existing?.limitTokens ?? entry.limitTokens,
+ cooldownUntil: Math.max(existing?.cooldownUntil ?? 0, entry.cooldownUntil),
+ });
+
+ // A model with a known bucket is token-metered, so serialize it from the FIRST call rather
+ // than after this invocation re-earns its own 429.
+ if (!this.tokenMeteredModels.has(modelName)) {
+ this.tokenMeteredModels.set(modelName, new ModelCallGate(1));
+ }
+ }
+ })();
+ return this.hydrated;
+ }
+
// Deliberately does NOT wait out the cool-off: the file falls through to the next model immediately, trading share of files for wall-clock.
note(resolved: ResolvedModelConfig, error: unknown) {
const { limitTokens, retryAfterMs } = parseRateLimitFromError(error);
const existing = this.modelRateLimits.get(resolved.modelName);
- this.modelRateLimits.set(resolved.modelName, {
+ const entry = {
// Sticky: a later 429 that omits the number must not erase it.
limitTokens: limitTokens ?? existing?.limitTokens,
// Default to a minute when the provider didn't say -- these buckets are per-minute.
cooldownUntil: Date.now() + (retryAfterMs ?? 60_000),
- });
+ };
+ this.modelRateLimits.set(resolved.modelName, entry);
+ this.persistence?.noteRateLimit(resolved.modelName, entry);
if (!this.tokenMeteredModels.has(resolved.modelName)) {
this.tokenMeteredModels.set(resolved.modelName, new ModelCallGate(1));
@@ -36,7 +72,10 @@ export class ModelRateLimitBook {
}
// Avoids re-probing a model that already said "retry in Ns" once per file, which is what produced "Too many subrequests".
- skipReason(modelName: string, estimatedPromptTokens: number): string | null {
+ async skipReason(modelName: string, estimatedPromptTokens: number): Promise {
+ // Cool-offs learned by an earlier invocation of this job count too.
+ await this.hydrate();
+
// Never queue deeply behind a serialized model; a shallow queue sends overflow to a free model instead.
const gate = this.tokenMeteredModels.get(modelName);
if (gate && gate.queueDepth >= MAX_METERED_QUEUE_DEPTH) {
diff --git a/src/server/services/model-review-chain.ts b/src/server/services/model-review-chain.ts
index 0de6aefa..b0305d07 100644
--- a/src/server/services/model-review-chain.ts
+++ b/src/server/services/model-review-chain.ts
@@ -3,7 +3,7 @@ import { isSubrequestBudgetMessage, isTimeoutMessage } from '@shared/transient-e
import type { RepoConfig } from '@shared/schema';
import type { AppBindings } from '../env';
import type { ModelResponseSchema } from '../models/types';
-import { MODEL_FALLBACK_CHAIN_BUDGET_MS } from '../models/limits';
+import { clampTimeoutToChainBudget, MODEL_FALLBACK_CHAIN_BUDGET_MS, SUBREQUEST_HEADROOM_FOR_MODEL_CALL } from '../models/limits';
import {
estimatePromptTokens,
isCloudflareAllocationError,
@@ -43,13 +43,18 @@ export async function runModelChain(ctx: ModelReviewContext, params: {
label: string;
totalLineCount: number;
config: RepoConfig;
- parse: (rawText: string) => T;
+ // Output-token headroom this prompt needs to answer in full; see reviewOutputBudgetTokens. Adapters
+ // clamp it, so omitting it leaves a caller on its provider's default.
+ outputBudgetTokens?: number;
+ // `isLastModel` lets a parser reject a technically-valid non-answer while a stronger entry is still
+ // untried, and accept it once nothing better remains -- so escalation can never fail a file outright.
+ parse: (rawText: string, ctx: { isLastModel: boolean }) => T;
// Stable keys for the resume memo. A bin passes its member paths: its own `label` embeds the file
// count, so it changes the moment a member completes or the bin de-escalates to singles, and the
// progress would be lost exactly when it matters most.
progressLabels?: readonly string[];
}) {
- const { systemPrompt, userPrompt, responseSchema, timeoutMs, label } = params;
+ const { systemPrompt, userPrompt, responseSchema, label, outputBudgetTokens } = params;
const progressLabels = params.progressLabels?.length ? params.progressLabels : [label];
const { primary, fallbacks } = ctx.selectModel({
@@ -73,6 +78,9 @@ export async function runModelChain(ctx: ModelReviewContext, params: {
});
}
+ // Guards the head of the chain only; see clampTimeoutToChainBudget.
+ const timeoutMs = clampTimeoutToChainBudget(params.timeoutMs);
+
const estimatedPromptTokens = estimatePromptTokens(systemPrompt, userPrompt);
let lastError: unknown;
@@ -81,6 +89,9 @@ export async function runModelChain(ctx: ModelReviewContext, params: {
let quotaFailures = 0;
// The `continue` paths can otherwise leave `lastError` undefined, failing the file permanently.
let attemptedAnyModel = false;
+ // Separates "every model is on a rate-limit cooldown" from "every model is timing out" in the
+ // no-model-attempted message; the two need opposite responses from whoever reads the job log.
+ let skippedForTimeouts = false;
// Absolute index just past the last model that ran and failed on its own merits. Only these
// advance the memo: a model skipped by a budget breaker never ran, and a 429 means "same model,
// later" (ModelRateLimitBook already holds that cool-off), so neither has been ruled out.
@@ -105,10 +116,15 @@ export async function runModelChain(ctx: ModelReviewContext, params: {
}
// Back-to-back slow calls pass Cloudflare's ~120s limit and die as `exceededCpu`.
- if (modelIndex > 0 && Date.now() - chainStartedAt - gateWaitMs > MODEL_FALLBACK_CHAIN_BUDGET_MS) {
- logger.warn(`Deferring ${label}: fallback chain exceeded its per-invocation time budget`, {
+ // Prospective, not reactive: asking whether the budget is ALREADY blown let a call start with less
+ // time left than it needs, burn what remained, and defer anyway -- paying for a doomed attempt and
+ // reporting it as that model's failure. Asking whether THIS call still fits spends nothing instead,
+ // and the resume memo means the model it declines to start is the one the next invocation begins at.
+ if (modelIndex > 0 && Date.now() - chainStartedAt - gateWaitMs + timeoutMs > MODEL_FALLBACK_CHAIN_BUDGET_MS) {
+ logger.warn(`Deferring ${label}: no room in the per-invocation time budget for another model`, {
elapsedMs: Date.now() - chainStartedAt,
gateWaitMs,
+ timeoutMs,
skippedModels: modelsToTry.slice(modelIndex),
});
// Deferrable, so the file retries on a fresh budget instead of failing permanently.
@@ -133,17 +149,45 @@ export async function runModelChain(ctx: ModelReviewContext, params: {
continue;
}
- // Proven too slow for this job's budget. Never for the last candidate: a chain that skips every
- // model reports "no model was attempted", which is a worse outcome than one more slow try.
- if (modelIndex < modelsToTry.length - 1 && await ctx.chainProgress.isTimingOut(currentModel)) {
- logger.info(`Skipping ${currentModel} for ${label}: it has repeatedly timed out on this job`);
+ // Proven too slow for this job's budget. The last candidate is held to a higher bar rather than
+ // exempted: skipping every model reports "no model was attempted", which is worse than one more
+ // slow try -- but it is far better than paying a full per-call budget per unit, forever, for a
+ // model that has never once answered on this job.
+ const isLastCandidate = modelIndex === modelsToTry.length - 1;
+ const timingOut = isLastCandidate
+ ? await ctx.chainProgress.isTimingOutTerminally(currentModel)
+ : await ctx.chainProgress.isTimingOut(currentModel);
+ if (timingOut) {
+ skippedForTimeouts = true;
+ logger.info(`Skipping ${currentModel} for ${label}: it has repeatedly timed out on this job`, {
+ isLastCandidate,
+ });
continue;
}
+ // Hard floor, and unlike the isNearLimit() breaker above it applies to the PRIMARY too: that
+ // breaker exists to leave room for other in-flight files and so exempts index 0, which left the
+ // head of every chain free to transmit a full prompt into an invocation that had nothing left.
+ // The runtime then refuses it and the whole unit is lost having paid for the prompt.
+ if (ctx.tracker && !ctx.tracker.hasRemainingSubrequests(SUBREQUEST_HEADROOM_FOR_MODEL_CALL)) {
+ logger.warn(`Deferring ${label}: not enough subrequest budget left to commit a prompt`, {
+ subrequests: ctx.tracker.getSubrequestCount(),
+ needed: SUBREQUEST_HEADROOM_FOR_MODEL_CALL,
+ skippedModels: modelsToTry.slice(modelIndex),
+ });
+ sawTransientFailure = true;
+ // Must not say "subrequest": isSubrequestBudgetError substring-matches it and would treat this
+ // as the runtime's own refusal, which skips persisting chain progress.
+ lastTransientError = lastTransientError ?? lastError
+ ?? new Error(`Per-invocation request budget was too low to attempt a model for ${label}; deferring for retry.`);
+ break;
+ }
+
// Skip a call known to fail rather than pay a subrequest to be told.
- const skipReason = ctx.rateLimits.skipReason(resolved.modelName, estimatedPromptTokens);
+ const skipReason = await ctx.rateLimits.skipReason(resolved.modelName, estimatedPromptTokens);
if (skipReason) {
logger.info(`Skipping ${currentModel} for ${label}: ${skipReason}`);
+ ctx.tracker?.recordSkippedCall(resolved.modelName, skipReason);
continue;
}
@@ -152,7 +196,7 @@ export async function runModelChain(ctx: ModelReviewContext, params: {
attemptedAnyModel = true;
const response = await ctx.callResolvedModel(
resolved,
- { systemPrompt, userPrompt, responseSchema },
+ { systemPrompt, userPrompt, responseSchema, outputBudgetTokens },
timeoutMs,
recordGateWait,
);
@@ -161,13 +205,31 @@ export async function runModelChain(ctx: ModelReviewContext, params: {
ctx.tracker.record(response.modelUsed, response.inputTokens, response.outputTokens);
}
- // Inside the try on purpose -- see the header.
- const parsed = params.parse(response.rawText);
+ // Inside the try on purpose -- see the header. `isLastModel` is computed against the WHOLE chain,
+ // not `modelsToTry`: a resumed job starts mid-chain, and measuring from the slice would call the
+ // resume point "last" and skip the escalation the memo was holding a place for.
+ const parsed = params.parse(response.rawText, {
+ isLastModel: startIndex + modelIndex >= wholeChain.length - 1,
+ });
+ // Keyed on the chain entry, matching noteTimeout. No-ops unless this model has strikes, so the
+ // healthy path stays free of the KV write.
+ await ctx.chainProgress.noteSuccess(currentModel);
// Terminal for these labels; drop the memo so a job retry starts from the primary again.
await Promise.all(progressLabels.map((key) => ctx.chainProgress.clear(key)));
+ // The common shape is "primary 429s, fallback answers": the file succeeds, so nothing below
+ // runs, yet a cool-off was just paid for in full and the next invocation would re-pay it.
+ // No-ops unless a 429 actually landed, so the healthy path stays free.
+ await ctx.chainProgress.flushPending();
return { ...response, userPrompt, parsed };
} catch (error) {
lastError = error;
+ // The prompt was transmitted in full and bought nothing; the only site that sees every failed
+ // attempt across both the single-file and batched paths.
+ ctx.tracker?.recordFailedAttempt(
+ resolved.modelName,
+ estimatedPromptTokens,
+ isGoogleRateLimitError(error) ? 'rate-limited' : 'error',
+ );
if (isTransientModelFailure(error)) {
sawTransientFailure = true;
lastTransientError = error;
@@ -211,6 +273,8 @@ export async function runModelChain(ctx: ModelReviewContext, params: {
error: error instanceof Error ? error.message : String(error),
rateLimited,
quotaFailures,
+ // Not `...Tokens`: logger.ts redacts any key containing "token".
+ estimatedWastedInput: estimatedPromptTokens,
willTryFallback: !outOfQuotaBudget && modelIndex < modelsToTry.length - 1,
});
@@ -234,8 +298,15 @@ export async function runModelChain(ctx: ModelReviewContext, params: {
// Only when there is somewhere left to go: at the end of the chain the memo would pin every
// future attempt to the last entry, and the file should get a clean walk instead.
if (attemptedFailedThrough > 0 && attemptedFailedThrough < wholeChain.length) {
- for (const key of progressLabels) await ctx.chainProgress.advance(key, attemptedFailedThrough);
+ // Together, not one at a time: the store is single-flight, so a bin's N labels coalesce into
+ // one merged put (plus the drain loop's redundant second put) instead of paying a KV get+put
+ // per member out of the 50-subrequest budget.
+ await Promise.all(progressLabels.map((key) => ctx.chainProgress.advance(key, attemptedFailedThrough)));
Object.defineProperty(error, 'nextChainIndex', { value: attemptedFailedThrough, configurable: true });
+ } else {
+ // A quota deferral advances no chain progress (a 429 means "same model, later"), so nothing
+ // above would have flushed the cool-off this file just paid a full prompt to learn.
+ await ctx.chainProgress.flushPending();
}
throw error;
}
@@ -250,9 +321,11 @@ export async function runModelChain(ctx: ModelReviewContext, params: {
throw lastError;
}
- // Genuinely skipped: a cooldown from another file's 429, or an unavailable provider.
+ // Genuinely skipped: a cooldown from another file's 429, repeated timeouts, or an unavailable provider.
throw new RetryableModelError(
- `No configured review model was attempted for ${label} (all skipped: rate-limit cooldown or provider unavailable); retrying later.`,
+ `No configured review model was attempted for ${label} (all skipped: ${
+ skippedForTimeouts ? 'repeated timeouts on this job' : 'rate-limit cooldown or provider unavailable'
+ }); retrying later.`,
);
}
diff --git a/src/server/services/model-review-file.ts b/src/server/services/model-review-file.ts
index 01430c9a..9726029a 100644
--- a/src/server/services/model-review-file.ts
+++ b/src/server/services/model-review-file.ts
@@ -5,9 +5,11 @@ import {
buildReviewResponseSchema,
type RejectedExemplar,
} from '../prompts/file-review';
-import { parseBatchReviewResponse, parseFileReviewResponse, type BatchReviewResult } from '../core/model-output';
+import { isNonAnswerReview, parseBatchReviewResponse, parseFileReviewResponse, type BatchReviewResult } from '../core/model-output';
+import { UnparseableModelResponseError } from '../models/types';
import { chunkFileDiff, type FileDiff } from '../core/diff';
-import { adaptiveModelTimeoutMs } from '../models/limits';
+import { adaptiveModelTimeoutMs, reviewOutputBudgetTokens } from '../models/limits';
+import { generatorFindingCap } from '../prompts/file-review';
import { mergeCounts } from './model-support';
import { type ModelReviewContext, runModelChain } from './model-review-chain';
import { logger } from '../core/logger';
@@ -51,11 +53,12 @@ export async function reviewFile(ctx: ModelReviewContext, params: {
}
const results: Array, reviewedLineCount: number, wasPromptTruncated: boolean, userPrompt: string }> = [];
-
+ const { path: filePath } = params.file;
+
for (const [chunkIndex, chunk] of chunks.entries()) {
// No new chunk when close to the 50-subrequest limit.
if (results.length > 0 && ctx.tracker?.isNearLimit()) {
- logger.warn(`Stopping chunk processing for ${params.file.path} early due to subrequest budget limits.`);
+ logger.warn(`Stopping chunk processing for ${filePath} early due to subrequest budget limits.`);
break;
}
@@ -63,7 +66,7 @@ export async function reviewFile(ctx: ModelReviewContext, params: {
if (chunkIndex >= BASE_CHUNKS) {
const remaining = ctx.tracker?.remainingSafeBudget() ?? Number.POSITIVE_INFINITY;
if (remaining < EXTRA_CHUNK_BUDGET_RESERVE) {
- logger.info(`Skipping the opportunistic chunk tail for ${params.file.path}; budget is committed elsewhere.`, {
+ logger.info(`Skipping the opportunistic chunk tail for ${filePath}; budget is committed elsewhere.`, {
chunkIndex,
totalChunks: chunks.length,
remainingSafeBudget: remaining,
@@ -80,7 +83,7 @@ export async function reviewFile(ctx: ModelReviewContext, params: {
if (results.length === 0) {
throw error; // First chunk failed, let it defer/fail properly
}
- logger.warn(`Chunk review failed for ${params.file.path}, returning partial results to avoid stalling the job.`, { error: error instanceof Error ? error.message : String(error) });
+ logger.warn(`Chunk review failed for ${filePath}, returning partial results to avoid stalling the job.`, { error: error instanceof Error ? error.message : String(error) });
break;
}
}
@@ -135,18 +138,49 @@ async function reviewFileChunk(ctx: ModelReviewContext, params: {
rejectedExemplars: params.rejectedExemplars,
});
+ // One figure drives three things: the room the answer gets, and now the time it gets to write it.
+ const outputBudgetTokens = reviewOutputBudgetTokens({
+ findingCap: generatorFindingCap(params.config.review.max_comments),
+ fileCount: 1,
+ });
+
const response = await runModelChain(ctx, {
systemPrompt,
userPrompt,
responseSchema: buildReviewResponseSchema(params.config.review.max_comments),
- // Scales with the diff the model sees: small files fail over fast.
- timeoutMs: adaptiveModelTimeoutMs(params.file.lineCount),
+ // Scales with the diff the model sees AND the answer it was asked for: small files fail over fast.
+ timeoutMs: adaptiveModelTimeoutMs(params.file.lineCount, outputBudgetTokens),
+ outputBudgetTokens,
label: params.file.path,
totalLineCount: params.totalLineCount,
config: params.config,
- parse: (rawText) => parseFileReviewResponse(rawText, params.file, {
- deniedClaimTypes: params.config.review.deny_claim_types,
- }),
+ parse: (rawText, { isLastModel }) => {
+ const parsed = parseFileReviewResponse(rawText, params.file, {
+ deniedClaimTypes: params.config.review.deny_claim_types,
+ });
+
+ // A substantive diff waved through in one sentence is not a clean verdict, it is a model declining
+ // to review. Thrown as UnparseableModelResponseError so the chain treats it exactly like any other
+ // useless response and tries the next entry -- and never on the last one, where the alternative to
+ // an unearned "clean" is failing the file, which is worse.
+ if (!isLastModel && isNonAnswerReview({
+ rawText,
+ file: params.file,
+ findingCount: parsed.comments.length,
+ })) {
+ logger.warn('Model returned a non-answer for a substantive diff; escalating to the next model', {
+ path: params.file.path,
+ diffLineCount: params.file.lineCount,
+ responseChars: rawText.trim().length,
+ });
+ throw new UnparseableModelResponseError(
+ params.config.model?.main ?? 'unconfigured',
+ `no findings and only ${rawText.trim().length} characters of response for a ${params.file.lineCount}-line diff`,
+ );
+ }
+
+ return parsed;
+ },
});
return {
@@ -181,11 +215,21 @@ export async function reviewFiles(ctx: ModelReviewContext, params: {
// The bin's total: a 400-line bin on a small-file timeout dies mid-call and takes all of it down.
const binLineCount = params.files.reduce((sum, file) => sum + file.lineCount, 0);
+ // The bin's whole response, not one file's: every entry shares one `maxOutputTokens`, and a bin that
+ // overruns it comes back as a repaired prefix with its tail files looking clean. A packed bin is also
+ // the slowest call the system makes, and its diff line count badly under-predicts that, so the same
+ // figure sizes the timeout.
+ const outputBudgetTokens = reviewOutputBudgetTokens({
+ findingCap: generatorFindingCap(params.config.review.max_comments),
+ fileCount: params.files.length,
+ });
+
const response = await runModelChain(ctx, {
systemPrompt,
userPrompt,
responseSchema: buildBatchReviewResponseSchema(params.config.review.max_comments, params.files.length),
- timeoutMs: adaptiveModelTimeoutMs(binLineCount),
+ timeoutMs: adaptiveModelTimeoutMs(binLineCount, outputBudgetTokens),
+ outputBudgetTokens,
label: `${params.files.length} files (${params.files[0]?.path ?? 'unknown'} …)`,
// Per file, so progress survives the bin narrowing or exploding into singles.
progressLabels: params.files.map((file) => file.path),
diff --git a/src/server/services/model-support.ts b/src/server/services/model-support.ts
index 4c64fcc1..59b2f476 100644
--- a/src/server/services/model-support.ts
+++ b/src/server/services/model-support.ts
@@ -25,6 +25,16 @@ export function estimatePromptTokens(systemPrompt: string, userPrompt: string):
// Only commit a prompt to a token-metered model if the estimate leaves this much headroom.
export const PROMPT_FIT_SAFETY_FACTOR = 0.8;
+// A learned bucket below this is not a token quota, whatever the body said. Belt to the metric-name
+// braces in parseRateLimitFromError: bodies already misparsed are persisted in KV for a job's 24h
+// life and are sticky by design, so the read path has to reject them too or those jobs stay broken.
+// No review prompt is ever this small, so a genuine bucket under it would skip every prompt anyway.
+export const MIN_PLAUSIBLE_TOKEN_BUCKET = 1_000;
+
+export function isPlausibleTokenBucket(limitTokens: number | undefined): boolean {
+ return typeof limitTokens === 'number' && limitTokens >= MIN_PLAUSIBLE_TOKEN_BUCKET;
+}
+
// Set by runModelChain on the deferral it throws when the chain still has untried models, so the
// caller can tell "we made progress, resume lower down" from "the same models failed again".
// A property rather than a constructor field, matching how retry-policy.ts attaches
@@ -36,21 +46,47 @@ export function nextChainIndexOf(error: unknown): number | null {
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null;
}
+// Set by the Gemini adapter on any error it throws after dropping the response grammar, so the
+// caller latches the (provider, model, grammar) triple even when that schema-less attempt also
+// failed. Lives here rather than on the barrel for the same reason as `nextChainIndexOf` above.
+export function isSchemaDroppedError(error: unknown): boolean {
+ return (error as { schemaDropped?: unknown } | null)?.schemaDropped === true;
+}
+
// Calls that may queue on a serialized model before further files route elsewhere; deeper queues have cost files their per-file chain budget while waiting.
export const MAX_METERED_QUEUE_DEPTH = 2;
+// Every `metric: , limit: ` pair Google states in a 429 body. A body may carry several, one
+// per violated quota.
+const QUOTA_VIOLATION_PATTERN = /metric:\s*(\S+?),\s*limit:\s*(\d[\d_,]*)/gi;
+
+// Which of those metrics measures TOKENS. The rest count requests, and reading a request count as a
+// bucket size is what took a model out for a whole job: Google's free tier reports
+// `generate_content_free_tier_requests, limit: 15` -- 15 requests per minute -- and storing 15 as
+// `limitTokens` made skipReason refuse every prompt over 12 tokens from then on, permanently, for a
+// model that was merely busy. An unrecognised metric therefore teaches nothing about prompt size.
+const TOKEN_QUOTA_METRIC = /(?:input_token|output_token|token_count|_tokens)/i;
+
// Google states both numbers in the 429 body ("...limit: 16000, model: Please retry in 26.9s."); anything absent is simply omitted.
export function parseRateLimitFromError(error: unknown): { limitTokens?: number; retryAfterMs?: number } {
const message = error instanceof Error ? error.message : String(error ?? '');
- const limitMatch = /limit:\s*(\d[\d_,]*)/i.exec(message);
- const retryMatch = /retry in ([\d.]+)\s*s/i.exec(message);
+ // Deliberately NOT a bare /limit:\s*(\d+)/: the first stated limit in a multi-quota body is as
+ // likely to be the request count as the token bucket.
+ let limitTokens: number | undefined;
+ for (const [, metric, limit] of message.matchAll(QUOTA_VIOLATION_PATTERN)) {
+ if (!TOKEN_QUOTA_METRIC.test(metric)) continue;
+ const parsed = Number(limit.replace(/[_,]/g, ''));
+ if (!Number.isFinite(parsed) || !isPlausibleTokenBucket(parsed)) continue;
+ // Smallest stated token bucket wins: it is the one that will reject the prompt first.
+ if (limitTokens === undefined || parsed < limitTokens) limitTokens = parsed;
+ }
- const limitTokens = limitMatch ? Number(limitMatch[1].replace(/[_,]/g, '')) : undefined;
+ const retryMatch = /retry in ([\d.]+)\s*s/i.exec(message);
const retryAfterMs = retryMatch ? Number(retryMatch[1]) * 1000 : undefined;
return {
- limitTokens: Number.isFinite(limitTokens) && limitTokens! > 0 ? limitTokens : undefined,
+ limitTokens,
retryAfterMs: Number.isFinite(retryAfterMs) && retryAfterMs! > 0 ? retryAfterMs : undefined,
};
}
diff --git a/src/server/services/model.ts b/src/server/services/model.ts
index 8b109f9b..dde1f434 100644
--- a/src/server/services/model.ts
+++ b/src/server/services/model.ts
@@ -12,6 +12,7 @@ import { logger } from '../core/logger';
import { getResolvedModelConfig, type ResolvedModelConfig } from '@server/db/model-configs';
import { decryptLlmApiKey } from '@server/core/llm-crypto';
import {
+ isSchemaDroppedError,
normalizeModel,
uniqueModels,
} from './model-support';
@@ -30,6 +31,8 @@ export { RetryableModelError, isRetryableModelError, nextChainIndexOf } from './
export { PROMPT_FIT_SAFETY_FACTOR, estimatePromptTokens } from './model-support';
// Re-exported so its unit spec can reach it without a sibling import (no-restricted-imports).
export { ModelChainProgressStore } from './model-chain-progress';
+// Same reason: the 429-parsing spec asserts against the real implementation, not a copy.
+export { isPlausibleTokenBucket, parseRateLimitFromError } from './model-support';
const PROVIDER_UNAVAILABLE_TTL_SECONDS = 24 * 60 * 60;
export class ModelService {
@@ -37,7 +40,9 @@ export class ModelService {
private readonly resolvedModelCache = new Map>();
// Rate-limit learning plus the connection/token gates, keyed by MODEL, not provider.
- private readonly rateLimits = new ModelRateLimitBook();
+ // Backed by chainProgress so learned cool-offs outlive the invocation; assigned in the constructor
+ // because it depends on it.
+ private readonly rateLimits: ModelRateLimitBook;
// Provider-unavailable markers live in KV and can't flip set-to-unset within one invocation, so cache them per instance.
private readonly providerUnavailableCache = new Map>();
@@ -57,6 +62,7 @@ export class ModelService {
private options: { jobId?: string } = {},
) {
this.chainProgress = new ModelChainProgressStore(env, options.jobId, tracker);
+ this.rateLimits = new ModelRateLimitBook(this.chainProgress);
}
private providerUnavailableKey(providerId: string) {
@@ -120,7 +126,7 @@ export class ModelService {
let fallbackModels = (modelCfg?.fallbacks || []).map(normalizeModel);
if (modelCfg?.size_overrides && modelCfg.size_overrides.length > 0) {
- const sortedOverrides = [...modelCfg.size_overrides].sort((a, b) => a.max_lines - b.max_lines);
+ const sortedOverrides = modelCfg.size_overrides.toSorted((a, b) => a.max_lines - b.max_lines);
const matched = sortedOverrides.find(o => thresholdBase <= o.max_lines);
if (matched) {
selectedModel = normalizeModel(matched.model);
@@ -185,18 +191,26 @@ export class ModelService {
if (config.apiFormat === 'gemini') {
const apiKey = await this.decryptApiKey(config);
const schemaKey = `${config.providerId}|${config.modelName}|${input.responseSchema?.name ?? 'none'}`;
- const response = await this.rateLimits.runGated(config, onGateWait, () => {
- // Read inside the gate: hoisted, the opening wave would all see "not yet known" and probe.
- const gatedInput = this.schemaUnsupportedModels.has(schemaKey)
- ? { ...input, responseSchema: undefined }
- : input;
- return reviewWithGoogle(
- { apiKey, baseUrl: config.baseUrl, providerName: config.providerName, timeoutMs },
- config.modelName,
- gatedInput,
- this.tracker,
- );
- });
+ let response: ModelResponse;
+ try {
+ response = await this.rateLimits.runGated(config, onGateWait, () => {
+ // Read inside the gate: hoisted, the opening wave would all see "not yet known" and probe.
+ const gatedInput = this.schemaUnsupportedModels.has(schemaKey)
+ ? { ...input, responseSchema: undefined }
+ : input;
+ return reviewWithGoogle(
+ { apiKey, baseUrl: config.baseUrl, providerName: config.providerName, timeoutMs },
+ config.modelName,
+ gatedInput,
+ this.tracker,
+ );
+ });
+ } catch (error) {
+ // Latch on failure too: the probe already proved the grammar is refused, and without this a
+ // schema-dropped attempt that then 429s re-pays the 400 plus a full prompt on the next call.
+ if (isSchemaDroppedError(error)) this.schemaUnsupportedModels.add(schemaKey);
+ throw error;
+ }
if (response.degraded === 'schema-dropped') {
this.schemaUnsupportedModels.add(schemaKey);
}
diff --git a/src/shared/schema.ts b/src/shared/schema.ts
index ac70e1ea..31e8302a 100644
--- a/src/shared/schema.ts
+++ b/src/shared/schema.ts
@@ -181,7 +181,7 @@ export const batchReviewModelOutputSchema = z.object({
});
export const reviewJobMessageSchema = z.object({
- jobId: z.string().uuid().optional(),
+ jobId: z.uuid().optional(),
deliveryId: z.string().min(1),
phase: z.enum(['prepare', 'review', 'finalize']).optional(),
eventName: z.string().min(1).optional(),
@@ -212,7 +212,7 @@ export const reviewJobMessageSchema = z.object({
});
export const jobSummarySchema = z.object({
- id: z.string().uuid(),
+ id: z.uuid(),
workflowInstanceId: z.string().nullable().optional(),
owner: z.string(),
repo: z.string(),
@@ -239,7 +239,7 @@ export const jobSummarySchema = z.object({
steps: z.array(jobStepSchema).default([]),
checkRunId: coerceNumberSchema.nullable().optional(),
configSnapshot: repoConfigSchema.nullable().optional(),
- retryOfJobId: z.string().uuid().nullable().optional(),
+ retryOfJobId: z.uuid().nullable().optional(),
});
export const jobsQuerySchema = z.object({
@@ -259,8 +259,8 @@ export const jobsQuerySchema = z.object({
export type JobStep = z.infer;
const fileReviewRecordSchema = z.object({
- id: z.string().uuid(),
- jobId: z.string().uuid(),
+ id: z.uuid(),
+ jobId: z.uuid(),
filePath: z.string(),
fileStatus: z.enum(fileStatuses),
modelUsed: z.string(),
@@ -301,7 +301,7 @@ export const jobDetailSchema = jobSummarySchema.extend({
summaryMarkdown: z.string().nullable(),
configSnapshot: repoConfigSchema.nullable(),
reviewId: coerceNumberSchema.nullable(),
- retryOfJobId: z.string().uuid().nullable(),
+ retryOfJobId: z.uuid().nullable(),
summaryModel: z.string().nullable(),
files: z.array(fileReviewRecordSchema),
});
@@ -327,15 +327,20 @@ export const statsSchema = z.object({
outputTokens: z.number().int(),
comments: z.number().int(),
}),
+ // One point per bucket, not per day: long ranges are collapsed server-side so the chart stays legible.
trend: z.array(
z.object({
day: z.string(),
+ /** Last day covered by the bucket (equal to `day` when bucketing is daily). */
+ endDay: z.string(),
jobs: z.number().int(),
inputTokens: z.number().int(),
outputTokens: z.number().int(),
comments: z.number().int(),
}),
),
+ /** Days rolled up into each `trend` point. 1 = daily. */
+ trendBucketDays: z.number().int().positive(),
verdicts: z.array(
z.object({
verdict: z.enum(reviewVerdicts).nullable(),
@@ -397,10 +402,10 @@ export type JobDetail = z.infer;
export type RepoConfigRecord = z.infer;
export const llmProviderSchema = z.object({
- id: z.string().uuid(),
+ id: z.uuid(),
name: z.string(),
apiFormat: z.enum(llmApiFormats),
- baseUrl: z.string().url().nullable(),
+ baseUrl: z.url().nullable(),
enabled: z.boolean(),
hasApiKey: z.boolean(),
createdAt: dateStringSchema,
@@ -409,7 +414,7 @@ export const llmProviderSchema = z.object({
export const modelConfigSchema = z.object({
modelId: z.string(),
- providerId: z.string().uuid(),
+ providerId: z.uuid(),
providerName: z.string(),
apiFormat: z.enum(llmApiFormats),
modelName: z.string(),
diff --git a/test/api/settings.spec.ts b/test/api/settings.spec.ts
index b762c332..42d88fb0 100644
--- a/test/api/settings.spec.ts
+++ b/test/api/settings.spec.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { normalizeGlobalConfig } from '@client/pages/settings';
+import { normalizeGlobalConfig } from '@client/hooks/use-provider-settings';
describe('settings model strategy', () => {
it('does not invent a global strategy when none has been saved', () => {
diff --git a/test/db/stats-trend.spec.ts b/test/db/stats-trend.spec.ts
new file mode 100644
index 00000000..4ebf7edf
--- /dev/null
+++ b/test/db/stats-trend.spec.ts
@@ -0,0 +1,27 @@
+import { expect, it } from 'vitest';
+import { getStats, trendBucketDays } from '@server/db/stats';
+import { createTestEnv, dbDescribe } from '../helpers';
+
+const env = createTestEnv();
+
+dbDescribe('stats trend bucketing', () => {
+ it('collapses long ranges into evenly spaced buckets with no gaps', async () => {
+ for (const days of [7, 14, 30, 90]) {
+ const width = trendBucketDays(days);
+ const stats = await getStats(env, days, 'Asia/Kolkata');
+
+ expect(stats.trendBucketDays).toBe(width);
+ // Range spans `days + 1` calendar days (the rolling window starts mid-day), split by width.
+ expect(stats.trend).toHaveLength(Math.ceil((days + 1) / width));
+ expect(stats.trend.length).toBeLessThanOrEqual(15);
+
+ for (const point of stats.trend) {
+ expect(point.endDay >= point.day).toBe(true);
+ }
+ // Buckets are contiguous and ascending.
+ for (let i = 1; i < stats.trend.length; i += 1) {
+ expect(stats.trend[i].day > stats.trend[i - 1].endDay).toBe(true);
+ }
+ }
+ }, 60_000);
+});
diff --git a/test/e2e/batch-grouping.spec.ts b/test/e2e/batch-grouping.spec.ts
index dad80bd7..e130f31b 100644
--- a/test/e2e/batch-grouping.spec.ts
+++ b/test/e2e/batch-grouping.spec.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { groupBatches } from '@client/pages/job-logs';
+import { groupBatches } from '@client/lib/batch-groups';
import type { FileReviewRecord } from '@shared/schema';
// Which files shared a model call is NOT stored -- pack.ts derives bins and never persists them.
diff --git a/test/e2e/dashboard.spec.tsx b/test/e2e/dashboard.spec.tsx
index 8b618cf4..5133ff62 100644
--- a/test/e2e/dashboard.spec.tsx
+++ b/test/e2e/dashboard.spec.tsx
@@ -48,6 +48,7 @@ describe('Frontend UI Flows (JSDOM)', () => {
stats: {
totals: { jobs: 10, inputTokens: 500, outputTokens: 250, comments: 5 },
trend: [],
+ trendBucketDays: 1,
verdicts: [],
models: [],
topRepos: [],
diff --git a/test/findings/non-answer.spec.ts b/test/findings/non-answer.spec.ts
new file mode 100644
index 00000000..0232f84c
--- /dev/null
+++ b/test/findings/non-answer.spec.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it } from 'vitest';
+import {
+ isNonAnswerReview,
+ NON_ANSWER_MIN_DIFF_LINES,
+} from '@server/core/model-output';
+
+// The literal response gemini-3.5-flash-lite returned for a 253-line diff: valid JSON, zero findings,
+// one sentence, full confidence. 77 output tokens. Recorded verbatim so a future prompt or model change
+// can be measured against the exact shape this guard exists to catch.
+const OBSERVED_NON_ANSWER = JSON.stringify({
+ findings: [],
+ overall_correctness: 'patch is correct',
+ overall_explanation:
+ 'The patch correctly implements batched file reviews with structured Gemini output, robust error handling, proper async/await usage, and correct state tracking for retries and terminal states.',
+ overall_confidence_score: 1.0,
+});
+
+describe('isNonAnswerReview', () => {
+ it('flags a substantive diff dismissed in one sentence', () => {
+ expect(isNonAnswerReview({
+ rawText: OBSERVED_NON_ANSWER,
+ file: { lineCount: 253 },
+ findingCount: 0,
+ })).toBe(true);
+ });
+
+ // 162 files in the measured job were comment-only cleanups whose empty findings arrays were CORRECT.
+ // Firing on those would manufacture escalations out of accurate verdicts.
+ it('never flags a small diff, however terse the response', () => {
+ expect(isNonAnswerReview({
+ rawText: OBSERVED_NON_ANSWER,
+ file: { lineCount: NON_ANSWER_MIN_DIFF_LINES - 1 },
+ findingCount: 0,
+ })).toBe(false);
+ expect(isNonAnswerReview({
+ rawText: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"Comments only."}',
+ file: { lineCount: 12 },
+ findingCount: 0,
+ })).toBe(false);
+ });
+
+ it('never flags a response that produced a finding', () => {
+ expect(isNonAnswerReview({
+ rawText: OBSERVED_NON_ANSWER,
+ file: { lineCount: 751 },
+ findingCount: 1,
+ })).toBe(false);
+ });
+
+ it('accepts a long, engaged clean verdict on a big diff', () => {
+ // A model that actually walked the diff and concluded it is clean says considerably more than a
+ // sentence. Only the terse dismissal is the signal.
+ const engaged = JSON.stringify({
+ findings: [],
+ overall_correctness: 'patch is correct',
+ overall_explanation: `${'The parameter array is checked against every placeholder, the transaction wraps both statements, and the conflict target matches the unique index. '.repeat(6)}`,
+ overall_confidence_score: 0.8,
+ });
+ expect(engaged.length).toBeGreaterThan(600);
+ expect(isNonAnswerReview({ rawText: engaged, file: { lineCount: 751 }, findingCount: 0 })).toBe(false);
+ });
+
+ it('honours a caller-supplied line threshold', () => {
+ expect(isNonAnswerReview({
+ rawText: OBSERVED_NON_ANSWER,
+ file: { lineCount: 50 },
+ findingCount: 0,
+ minDiffLines: 40,
+ })).toBe(true);
+ });
+});
diff --git a/test/findings/prompts-file-review.spec.ts b/test/findings/prompts-file-review.spec.ts
index 0b9fcf1c..526356a4 100644
--- a/test/findings/prompts-file-review.spec.ts
+++ b/test/findings/prompts-file-review.spec.ts
@@ -120,10 +120,11 @@ describe('output contract', () => {
// Restraints no downstream gate can check, so the generator is the only place to enforce them.
it('keeps the restraints the gates cannot replace', () => {
- // Context limits: the model sees a diff, not a repository.
+ // Context limits: the model sees a diff, not a repository, a lockfile, or a build target.
expect(systemBase).toMatch(/ONLY the diff/);
- expect(systemBase).toMatch(/undefined, unimported, unused, missing, or never-called/);
- expect(systemBase).toMatch(/If confirming an issue requires code you cannot see, do not report it/);
+ expect(systemBase).toMatch(/Never predict that a change breaks callers, importers/);
+ expect(systemBase).toMatch(/your training data predates the installed version/);
+ expect(systemBase).toMatch(/Do not raise compatibility, polyfill, transpilation, engine-version or server-side-rendering concerns/);
// The evidence mandate. Without it the parser withholds everything and recall goes to zero.
expect(systemBase).toMatch(/copied VERBATIM from the diff/);
@@ -134,7 +135,11 @@ describe('output contract', () => {
expect(systemBase).toMatch(/resolves by that SHA/);
// Survives in both profiles: the model still emits "technically true, nobody cared" comments.
- expect(systemBase).toMatch(/Do NOT report subjective preferences/);
+ // The base prompt now leaves this to the per-file instruction rather than repeating it.
+ expect(userPrompt).toMatch(/avoid subjective style feedback/);
+
+ // A claim resting on something outside the window may still be raised, but never as P0/P1.
+ expect(systemBase).toMatch(/at most priority 3, never 0 or 1/);
});
});
diff --git a/test/findings/review-verify.spec.ts b/test/findings/review-verify.spec.ts
index c0bc3379..b8452e16 100644
--- a/test/findings/review-verify.spec.ts
+++ b/test/findings/review-verify.spec.ts
@@ -59,6 +59,36 @@ describe('verifyFindings orchestrator', () => {
expect(result.reasons.size).toBeGreaterThanOrEqual(0);
});
+ // The failure this exists for: on codra's own PR #86 the verifier confirmed five findings whose
+ // consequences lay outside the window it was shown ("this breaks importers", "this throws under SSR")
+ // because it could only check that the quoted line was real. `decidable` is the field that lets it
+ // say so, and an explicit `false` has to cost the finding or the field is decoration.
+ it('drops a finding the verifier says it cannot settle, whatever verdict it gave', async () => {
+ const comments = [comment({ title: 'Checkable' }), comment({ title: 'Needs the importers' })];
+ const model = fakeModel('{"results":['
+ + '{"index":0,"reason":"line does exhibit it","decidable":true,"verdict":"keep"},'
+ + '{"index":1,"reason":"would need the importers of this module","decidable":false,"verdict":"keep"}'
+ + ']}');
+
+ const result = await verifyFindings({ ...base, comments, model });
+
+ expect(result.comments.map(c => c.title)).toEqual(['Checkable']);
+ expect(result.dropped).toHaveLength(1);
+ expect(result.dropped[0].comment.title).toBe('Needs the importers');
+ expect(result.dropped[0].reason).toBe('would need the importers of this module');
+ });
+
+ // A model that ignores the new field must not have every finding read as undecidable.
+ it('keeps findings when the verifier omits decidable entirely', async () => {
+ const comments = [comment({ title: 'Kept' }), comment({ title: 'Also kept' })];
+ const model = fakeModel('{"results":[{"index":0,"verdict":"keep"},{"index":1,"verdict":"keep"}]}');
+
+ const result = await verifyFindings({ ...base, comments, model });
+
+ expect(result.comments).toHaveLength(2);
+ expect(result.dropped).toHaveLength(0);
+ });
+
it('falls back to the input findings when verification throws', async () => {
const comments = [comment({ title: 'A' }), comment({ title: 'B' })];
const result = await verifyFindings({ ...base, comments, model: throwingModel() });
diff --git a/test/findings/undecidable-claims.spec.ts b/test/findings/undecidable-claims.spec.ts
new file mode 100644
index 00000000..7d94b345
--- /dev/null
+++ b/test/findings/undecidable-claims.spec.ts
@@ -0,0 +1,211 @@
+import { describe, expect, it } from 'vitest';
+import { looksLikeExternalVersionClaim, refuteUndecidableClaim } from '@server/core/claim-checks';
+import { parseFileReviewResponse } from '@server/core/model-output';
+import type { FileDiff } from '@server/core/diff';
+
+// The fixtures below are the VERBATIM titles and bodies of findings codra posted on its own PR #86.
+// Every one was checked against the repository and every one was false, and each was false for the
+// same reason: it asserted something settled outside the diff -- an installed package's API surface,
+// whether a symbol has consumers, or which runtime the code lands on. All five survived evidence
+// grounding (their quoted lines were real) and the verification pass agreed with all of them, because
+// the verifier shares the generator's knowledge gap. Deterministic refutation is the only gate left.
+const PR86 = {
+ zodApi: {
+ title: 'Invalid Zod schema method call',
+ body: 'Zod does not expose top-level `z.uuid()` or `z.url()` validator functions; string validations like UUID and URL must be chained off `z.string()`, such as `z.string().uuid()`. Using `z.uuid()` directly results in a runtime TypeError when the schema is evaluated.',
+ },
+ removedExportSelector: {
+ title: 'Removed export keyword from ModelSelector',
+ body: 'The export keyword was removed from ModelSelector, which will cause compilation and import errors in other modules that rely on importing ModelSelector from this file.',
+ },
+ removedExportChain: {
+ title: 'Removed export keyword from ModelChain',
+ body: 'The export keyword was removed from ModelChain, preventing external files from importing this component.',
+ },
+ toSorted: {
+ title: 'Use of toSorted method which might not exist in all Node/JS environments',
+ body: 'The array method `toSorted` is a relatively new addition to ECMAScript (Node.js 20+). Depending on the runtime target, using `toSorted()` directly on an array without a polyfill or spreading via `[...modelCfg.size_overrides].sort(...)` can cause a TypeError in older Node versions.',
+ },
+ windowInRender: {
+ title: 'Accessing window object during render',
+ body: 'Accessing `window.innerHeight` directly in the component body can lead to hydration mismatches in Next.js or SSR environments, as `window` is not defined on the server. If this component is rendered server-side, it will throw a ReferenceError.',
+ },
+} as const;
+
+describe('library-API claims route into the external-version denial', () => {
+ // The pattern list already covered "does not exist"; this claim said "does not expose", so it
+ // sailed through as `other` -- which CLAIM_TYPE_DECIDABILITY marks diff_local -- and posted as a P0.
+ it('recognises the z.uuid() claim from PR #86', () => {
+ expect(looksLikeExternalVersionClaim(PR86.zodApi.title, PR86.zodApi.body)).toBe(true);
+ });
+
+ it('recognises the other ways a model says an API is absent', () => {
+ for (const body of [
+ 'The library does not provide a top-level helper for this.',
+ 'There is no such method on the client.',
+ 'This helper is not exported from the package root.',
+ ]) {
+ expect(looksLikeExternalVersionClaim('API misuse', body)).toBe(true);
+ }
+ });
+
+ // The relabel must not swallow ordinary findings that happen to discuss what code does not do.
+ it('leaves claims about the code in the diff alone', () => {
+ for (const body of [
+ 'The catch block does not rethrow, so the caller sees a success it never got.',
+ 'This query does not use a parameter placeholder, so the term is interpolated into SQL.',
+ 'The effect does not clean up its subscription on unmount.',
+ ]) {
+ expect(looksLikeExternalVersionClaim('Defect', body)).toBe(false);
+ }
+ });
+});
+
+describe('cross-file breakage claims are undecidable from a diff', () => {
+ it('refutes both removed-export claims from PR #86', () => {
+ expect(refuteUndecidableClaim(PR86.removedExportSelector)).toBe('cross-file');
+ expect(refuteUndecidableClaim(PR86.removedExportChain)).toBe('cross-file');
+ });
+
+ // Two signals are required, so neither half alone suppresses anything.
+ it('does not refute on a cross-file mention with no predicted breakage', () => {
+ expect(refuteUndecidableClaim({
+ title: 'Duplicated helper',
+ body: 'Other modules define a similar helper; consider consolidating them later.',
+ })).toBeNull();
+ });
+
+ it('does not refute on breakage predicted about the code actually shown', () => {
+ expect(refuteUndecidableClaim({
+ title: 'Unbalanced braces',
+ body: 'The added block never closes, so this file fails to compile.',
+ })).toBeNull();
+ });
+});
+
+describe('environment-conditional claims are undecidable from a diff', () => {
+ it('refutes the toSorted and window claims from PR #86', () => {
+ expect(refuteUndecidableClaim(PR86.toSorted)).toBe('environment');
+ expect(refuteUndecidableClaim(PR86.windowInRender)).toBe('environment');
+ });
+
+ it('does not refute a hedge that is not about the environment', () => {
+ expect(refuteUndecidableClaim({
+ title: 'Possible null dereference',
+ body: 'Depending on the caller, `user` may not be set before this line runs.',
+ })).toBeNull();
+ });
+
+ it('does not refute a definite statement about a real environment constraint', () => {
+ // No hedge: the claim is that the code as written cannot work here, which the diff can settle.
+ expect(refuteUndecidableClaim({
+ title: 'Node API used in a Worker',
+ body: 'This calls `fs.readFileSync`, which the Workers runtime does not implement at all.',
+ })).toBeNull();
+ });
+});
+
+describe('the guards leave genuine findings untouched', () => {
+ // The five defects codra posts most reliably on the benchmark corpus. If any of these start being
+ // refuted, a guard has gone too far.
+ it('passes through the high-confidence defect families', () => {
+ for (const fixture of [
+ { title: 'SQL injection in findUserByEmail', body: 'The email is interpolated straight into the statement, so a crafted address changes the query.' },
+ { title: 'Authentication bypass in catch block', body: 'The catch returns true, so any error during verification authenticates the request.' },
+ { title: 'Missing await on chargeCard', body: 'The charge promise is never awaited, so the order is marked paid before the card is charged.' },
+ { title: 'Hardcoded live secret API key', body: 'A live `sk_live_` key is committed as a fallback and will be used whenever the environment variable is unset.' },
+ { title: 'Mass update without filtering', body: 'The UPDATE has no WHERE clause, so every order in the table is archived.' },
+ ]) {
+ expect(refuteUndecidableClaim(fixture)).toBeNull();
+ expect(looksLikeExternalVersionClaim(fixture.title, fixture.body)).toBe(false);
+ }
+ });
+});
+
+describe('an empty code_suggestion must not destroy the finding', () => {
+ // Observed 256 times across an 800-review sweep. `codeSuggestion` is z.string().min(1), so a model
+ // that emits `"code_suggestion": ""` used to throw a ZodError inside buildParsedComment and lose the
+ // whole comment as `unverified:unassemblable` -- including real defects.
+ const file: FileDiff = {
+ path: 'src/auth/session.ts',
+ previousPath: null,
+ isNew: false,
+ isDeleted: false,
+ isBinary: false,
+ lineCount: 2,
+ hunks: [{
+ header: '@@ -1,2 +1,2 @@',
+ lines: [
+ { kind: 'add', content: " return process.env.SESSION_SECRET ?? 'hardcoded-dev-secret';", newLineNumber: 1, position: 1 },
+ { kind: 'add', content: '}', newLineNumber: 2, position: 2 },
+ ],
+ }],
+ };
+
+ const payload = (codeSuggestion: unknown) => JSON.stringify({
+ findings: [{
+ evidence: " return process.env.SESSION_SECRET ?? 'hardcoded-dev-secret';",
+ code_location: { absolute_file_path: 'src/auth/session.ts', line: 1 },
+ claim_type: 'hardcoded_secret',
+ title: 'Fallback to a hardcoded session secret',
+ body: 'The signing secret falls back to a literal, so tokens can be forged wherever the env var is unset.',
+ priority: 1,
+ code_suggestion: codeSuggestion,
+ }],
+ overall_correctness: 'patch is incorrect',
+ overall_explanation: 'one finding',
+ });
+
+ for (const [label, value] of [['an empty string', ''], ['whitespace only', ' \n ']] as const) {
+ it(`keeps the finding when the suggestion is ${label}`, () => {
+ const parsed = parseFileReviewResponse(payload(value), file);
+ expect(parsed.comments).toHaveLength(1);
+ expect(parsed.comments[0].title).toBe('Fallback to a hardcoded session secret');
+ expect(parsed.comments[0].codeSuggestion).toBeUndefined();
+ // And the empty fence must not reach the posted body either.
+ expect(parsed.comments[0].body).not.toContain('```suggestion');
+ });
+ }
+
+ it('still carries a real suggestion through', () => {
+ const parsed = parseFileReviewResponse(payload(" return requireEnv('SESSION_SECRET');"), file);
+ expect(parsed.comments).toHaveLength(1);
+ expect(parsed.comments[0].codeSuggestion).toBe(" return requireEnv('SESSION_SECRET');");
+ expect(parsed.comments[0].body).toContain('```suggestion');
+ });
+});
+
+describe('claims about a callee handling its own errors are undecidable', () => {
+ // Posted as a P1 on codra's own PR #86 after the first round of guards shipped. `loadCooldowns`
+ // already wraps its only failure path in try/catch -- in another file, with a comment saying "never
+ // fail the review for it" -- so the rejection the claim depends on cannot occur. The verifier
+ // nonetheless marked it `decidable: true`, which is why this needs a deterministic gate too.
+ const PR86_SECOND_ROUND = {
+ title: 'Unhandled promise rejection in async initializer',
+ body: 'The `hydrate` method uses an async IIFE to initialize state. If the `this.persistence.loadCooldowns()` call fails (a network call or database query), the resulting promise rejection will be unhandled as it is assigned to `this.hydrated` without a `.catch()` block or internal try/catch. This can lead to unhandled promise rejections and potential process crashes in some environments.',
+ };
+
+ it('refutes the unhandled-rejection claim about an unseen callee', () => {
+ expect(refuteUndecidableClaim(PR86_SECOND_ROUND)).toBe('callee-errors');
+ });
+
+ it('refutes the same shape however it is worded', () => {
+ for (const body of [
+ 'If `fetchUser()` rejects, nothing catches it and the worker crashes.',
+ 'When loadConfig() throws, the rejection is not handled anywhere.',
+ ]) {
+ expect(refuteUndecidableClaim({ title: 'Unhandled rejection', body })).toBe('callee-errors');
+ }
+ });
+
+ // The visible-code equivalents must still get through: these are about what the diff itself does.
+ it('leaves claims about error handling in the shown code alone', () => {
+ for (const body of [
+ 'The catch block returns true, so any error during verification authenticates the request.',
+ 'This empty catch swallows the write failure with no logging.',
+ 'The added line assigns the promise to a field and never awaits it, so the order is marked paid first.',
+ ]) {
+ expect(refuteUndecidableClaim({ title: 'Defect', body })).toBeNull();
+ }
+ });
+});
diff --git a/test/model/catalog-nvidia.spec.ts b/test/model/catalog-nvidia.spec.ts
new file mode 100644
index 00000000..5e564d15
--- /dev/null
+++ b/test/model/catalog-nvidia.spec.ts
@@ -0,0 +1,91 @@
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { listProviderModels } from '@server/models/catalog';
+
+// NVIDIA Build serves chat NIMs and non-chat NIMs (embedding, reranking, speech, OCR) from the same
+// OpenAI-compatible /models endpoint. Without a filter, provider sync writes the non-chat ones into
+// model_configs and they show up in every model picker as if they could review a diff.
+
+const MIXED_MODEL_LIST = {
+ data: [
+ { id: 'meta/llama-3.3-70b-instruct' },
+ { id: 'deepseek-ai/deepseek-r1' },
+ { id: 'qwen/qwen2.5-coder-32b-instruct' },
+ { id: 'nvidia/llama-3.2-nv-embedqa-1b-v2' },
+ { id: 'nvidia/nv-rerankqa-mistral-4b-v3' },
+ { id: 'nvidia/nv-embed-v1' },
+ { id: 'nvidia/nemoretriever-parse' },
+ { id: 'baidu/paddleocr' },
+ { id: 'nvidia/parakeet-ctc-0.6b-asr' },
+ { id: 'nvidia/magpie-tts-multilingual' },
+ ],
+};
+
+const CHAT_IDS = [
+ 'meta/llama-3.3-70b-instruct',
+ 'deepseek-ai/deepseek-r1',
+ 'qwen/qwen2.5-coder-32b-instruct',
+];
+
+function stubModelList(payload: unknown) {
+ const fetchMock = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify(payload), { status: 200, headers: { 'content-type': 'application/json' } }),
+ );
+ vi.stubGlobal('fetch', fetchMock);
+ return fetchMock;
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('listProviderModels NVIDIA Build filtering', () => {
+ it('drops embedding, reranking, retrieval, OCR, and speech NIMs from the NVIDIA catalog', async () => {
+ stubModelList(MIXED_MODEL_LIST);
+
+ const models = await listProviderModels({
+ apiFormat: 'openai',
+ baseUrl: 'https://integrate.api.nvidia.com/v1',
+ apiKey: 'nvapi-test',
+ });
+
+ expect(models).toEqual(CHAT_IDS);
+ });
+
+ it('requests the standard OpenAI-compatible /models endpoint with a bearer key', async () => {
+ const fetchMock = stubModelList(MIXED_MODEL_LIST);
+
+ await listProviderModels({
+ apiFormat: 'openai',
+ baseUrl: 'https://integrate.api.nvidia.com/v1/',
+ apiKey: 'nvapi-test',
+ });
+
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe('https://integrate.api.nvidia.com/v1/models');
+ expect((init as RequestInit).headers).toMatchObject({ authorization: 'Bearer nvapi-test' });
+ });
+
+ it('leaves an identical list untouched for other OpenAI-format providers', async () => {
+ stubModelList(MIXED_MODEL_LIST);
+
+ const models = await listProviderModels({
+ apiFormat: 'openai',
+ baseUrl: 'https://openrouter.ai/api/v1',
+ apiKey: 'sk-test',
+ });
+
+ expect(models).toEqual(MIXED_MODEL_LIST.data.map((entry) => entry.id));
+ });
+
+ it('does not filter a self-hosted provider whose host merely resembles NVIDIA Build', async () => {
+ stubModelList({ data: [{ id: 'nv-embed-v1' }, { id: 'meta/llama-3.3-70b-instruct' }] });
+
+ const models = await listProviderModels({
+ apiFormat: 'openai',
+ baseUrl: 'https://api.nvidia.example.com/v1',
+ apiKey: 'sk-test',
+ });
+
+ expect(models).toEqual(['nv-embed-v1', 'meta/llama-3.3-70b-instruct']);
+ });
+});
diff --git a/test/model/chain-progress-store.spec.ts b/test/model/chain-progress-store.spec.ts
index 90b28bf2..cdf324e9 100644
--- a/test/model/chain-progress-store.spec.ts
+++ b/test/model/chain-progress-store.spec.ts
@@ -31,7 +31,11 @@ function makeKV() {
return maxInFlight;
},
get stored() {
- return value === null ? null : JSON.parse(value) as { files?: Record; timeouts?: Record };
+ return value === null ? null : JSON.parse(value) as {
+ files?: Record;
+ timeouts?: Record;
+ cooldowns?: Record;
+ };
},
writes,
};
@@ -108,6 +112,66 @@ describe('ModelChainProgressStore', () => {
expect(await next.isTimingOut('vertex-ai:gemini-2.5-flash')).toBe(false);
});
+ // The tail of a chain has no fallback to fall through to, so it is held to a higher bar rather than
+ // exempted. Exempting it entirely let one model burn 15 minutes of a job's wall clock at 20+
+ // consecutive timeouts, every unit paying a full per-call budget to learn what the tally knew.
+ it('holds the last candidate to a higher strike count before dropping it too', async () => {
+ const kv = makeKV();
+ const store = new ModelChainProgressStore({ APP_KV: kv.kv } as never, 'job-tail');
+
+ for (let i = 0; i < 3; i += 1) await store.noteTimeout('cf:glm-4.7-flash');
+ // Enough to drop it mid-chain, deliberately not enough to drop the tail.
+ expect(await store.isTimingOut('cf:glm-4.7-flash')).toBe(true);
+ expect(await store.isTimingOutTerminally('cf:glm-4.7-flash')).toBe(false);
+
+ for (let i = 0; i < 3; i += 1) await store.noteTimeout('cf:glm-4.7-flash');
+ expect(await store.isTimingOutTerminally('cf:glm-4.7-flash')).toBe(true);
+
+ // Durable, or the next invocation re-pays the whole wave to re-learn it.
+ const next = new ModelChainProgressStore({ APP_KV: kv.kv } as never, 'job-tail');
+ expect(await next.isTimingOutTerminally('cf:glm-4.7-flash')).toBe(true);
+ });
+
+ describe('noteSuccess', () => {
+ // Without the reset the tally was cumulative over the memo's 24h life, so three slow calls early
+ // in a long job condemned a healthy model for the rest of it.
+ it('restarts the tally, so a slow patch cannot condemn a working model', async () => {
+ const kv = makeKV();
+ const store = new ModelChainProgressStore({ APP_KV: kv.kv } as never, 'job-recovered');
+
+ for (let i = 0; i < 3; i += 1) await store.noteTimeout('vertex-ai:gemini-2.5-pro');
+ expect(await store.isTimingOut('vertex-ai:gemini-2.5-pro')).toBe(true);
+
+ await store.noteSuccess('vertex-ai:gemini-2.5-pro');
+ expect(await store.isTimingOut('vertex-ai:gemini-2.5-pro')).toBe(false);
+ });
+
+ // writeOnce merges the stored tally with max(), which would otherwise read the pre-success count
+ // straight back out of KV and undo the reset the moment it was persisted.
+ it('survives the merge against what another invocation stored', async () => {
+ const kv = makeKV();
+ await kv.kv.put('k', JSON.stringify({ timeouts: { 'vertex-ai:gemini-2.5-pro': 5 } }));
+
+ const store = new ModelChainProgressStore({ APP_KV: kv.kv } as never, 'job-merge-success');
+ await store.noteSuccess('vertex-ai:gemini-2.5-pro');
+
+ expect(kv.stored?.timeouts?.['vertex-ai:gemini-2.5-pro']).toBeUndefined();
+ const next = new ModelChainProgressStore({ APP_KV: kv.kv } as never, 'job-merge-success');
+ expect(await next.isTimingOut('vertex-ai:gemini-2.5-pro')).toBe(false);
+ });
+
+ it('writes nothing for a model with a clean record', async () => {
+ const kv = makeKV();
+ const store = new ModelChainProgressStore({ APP_KV: kv.kv } as never, 'job-clean');
+
+ await store.noteSuccess('vertex-ai:gemini-2.5-pro');
+
+ // The healthy path is every successful file: a KV get+put here would spend two subrequests
+ // per file out of the 50 this memo exists to protect.
+ expect(kv.writes).toHaveLength(0);
+ });
+ });
+
it('keeps chain progress and timeouts in one value without either clobbering the other', async () => {
const kv = makeKV();
const store = new ModelChainProgressStore({ APP_KV: kv.kv } as never, 'job-both');
@@ -132,6 +196,109 @@ describe('ModelChainProgressStore', () => {
expect(await store.isTimingOut('anything')).toBe(false);
});
+ // Without persistence ModelRateLimitBook is invocation-scoped, so every job continuation re-paid a
+ // full-prompt 429 to re-learn a cool-off the previous invocation had already been told about.
+ describe('rate-limit cool-offs', () => {
+ it('carries a learned cool-off and bucket size to the next invocation', async () => {
+ const kv = makeKV();
+ const store = new ModelChainProgressStore({ APP_KV: kv.kv } as never, 'job-cooldown');
+ const until = Date.now() + 30_000;
+
+ store.noteRateLimit('google:gemini-2.5-flash', { cooldownUntil: until, limitTokens: 16000 });
+ await store.flushPending();
+
+ const next = new ModelChainProgressStore({ APP_KV: kv.kv } as never, 'job-cooldown');
+ const loaded = await next.loadCooldowns();
+ expect(loaded.get('google:gemini-2.5-flash')).toEqual({ cooldownUntil: until, limitTokens: 16000 });
+ // Scoped to the model that actually 429'd: each Gemini model has its own per-minute bucket.
+ expect(loaded.has('google:gemini-2.5-flash-lite')).toBe(false);
+ });
+
+ it('does not write on note alone, so a 429 adds no subrequests on a path that had none', async () => {
+ const kv = makeKV();
+ const store = new ModelChainProgressStore({ APP_KV: kv.kv } as never, 'job-lazy');
+
+ store.noteRateLimit('google:gemini-2.5-flash', { cooldownUntil: Date.now() + 30_000 });
+ expect(kv.writes).toHaveLength(0);
+
+ // The deferral that follows is what makes it durable.
+ await store.flushPending();
+ expect(kv.writes.length).toBeGreaterThan(0);
+ });
+
+ it('takes the later deadline when two invocations both learned one', async () => {
+ const kv = makeKV();
+ const earlier = Date.now() + 10_000;
+ const later = Date.now() + 90_000;
+ await kv.kv.put('k', JSON.stringify({ cooldowns: { 'google:m': { until: later, limitTokens: 16000 } } }));
+
+ const store = new ModelChainProgressStore({ APP_KV: kv.kv } as never, 'job-merge-cooldown');
+ // Omits limitTokens on purpose: a later 429 that doesn't restate the bucket must not erase it.
+ store.noteRateLimit('google:m', { cooldownUntil: earlier });
+ await store.flushPending();
+
+ expect(kv.stored?.cooldowns?.['google:m']).toEqual({ until: later, limitTokens: 16000 });
+ });
+
+ // A job that persisted a misparsed request count as a bucket would keep skipping every prompt for
+ // that model until the memo's 24h TTL expired, because the bucket size is sticky by design.
+ it('discards a stored bucket too small to be a token quota', async () => {
+ const kv = makeKV();
+ const until = Date.now() + 30_000;
+ await kv.kv.put('k', JSON.stringify({ cooldowns: { 'google:m': { until, limitTokens: 15 } } }));
+
+ const store = new ModelChainProgressStore({ APP_KV: kv.kv } as never, 'job-poisoned-bucket');
+
+ const entry = (await store.loadCooldowns()).get('google:m');
+ // The cool-off survives -- the model really was rate-limited; only the bucket size is nonsense.
+ expect(entry?.cooldownUntil).toBe(until);
+ expect(entry?.limitTokens).toBeUndefined();
+ });
+
+ it('clamps an implausible cool-off rather than disabling a model for the whole job', async () => {
+ const kv = makeKV();
+ // A mis-parsed "retry in 3600s" would otherwise pin this model out for the job's 24h lifetime.
+ await kv.kv.put('k', JSON.stringify({ cooldowns: { 'google:m': { until: Date.now() + 3_600_000 } } }));
+
+ const store = new ModelChainProgressStore({ APP_KV: kv.kv } as never, 'job-clamp');
+
+ const entry = (await store.loadCooldowns()).get('google:m');
+ expect(entry!.cooldownUntil).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000);
+ });
+
+ // The bucket size outlives the cool-off: it still answers "can this prompt ever fit?".
+ it('keeps an expired entry so its bucket size survives', async () => {
+ const kv = makeKV();
+ await kv.kv.put('k', JSON.stringify({ cooldowns: { 'google:m': { until: Date.now() - 60_000, limitTokens: 16000 } } }));
+
+ const store = new ModelChainProgressStore({ APP_KV: kv.kv } as never, 'job-expired');
+
+ expect((await store.loadCooldowns()).get('google:m')?.limitTokens).toBe(16000);
+ });
+
+ it('reads a blob written before cooldowns existed without losing resume progress', async () => {
+ const kv = makeKV();
+ await kv.kv.put('k', JSON.stringify({ files: { 'src/a.ts': 2 }, timeouts: { 'google:m': 1 } }));
+
+ const store = new ModelChainProgressStore({ APP_KV: kv.kv } as never, 'job-old-shape');
+
+ expect(await store.startIndexFor('src/a.ts')).toBe(2);
+ expect((await store.loadCooldowns()).size).toBe(0);
+ });
+
+ it('keeps a cool-off noted before the KV read resolved', async () => {
+ const kv = makeKV();
+ await kv.kv.put('k', JSON.stringify({ files: { 'src/a.ts': 2 } }));
+
+ const store = new ModelChainProgressStore({ APP_KV: kv.kv } as never, 'job-early-note');
+ // noteRateLimit is sync and can land first; load() must merge into it, not replace it.
+ store.noteRateLimit('google:m', { cooldownUntil: Date.now() + 30_000, limitTokens: 16000 });
+
+ expect((await store.loadCooldowns()).get('google:m')?.limitTokens).toBe(16000);
+ expect(await store.startIndexFor('src/a.ts')).toBe(2);
+ });
+ });
+
it('does nothing at all without a jobId', async () => {
const kv = makeKV();
const store = new ModelChainProgressStore({ APP_KV: kv.kv } as never, undefined);
diff --git a/test/model/gemini-schema.spec.ts b/test/model/gemini-schema.spec.ts
index ba4678aa..ac889716 100644
--- a/test/model/gemini-schema.spec.ts
+++ b/test/model/gemini-schema.spec.ts
@@ -22,8 +22,9 @@ describe('toGeminiResponseJsonSchema', () => {
expect(Object.keys(location.properties)).toEqual(['absolute_file_path', 'line', 'line_range']);
const verify = toGeminiResponseJsonSchema(VERIFY_RESPONSE_SCHEMA.schema as Record) as any;
- // `reason` before `verdict`, so the verifier justifies before deciding.
- expect(verify.properties.results.items.propertyOrdering).toEqual(['index', 'reason', 'verdict', 'confidence']);
+ // `reason` then `decidable`, both before `verdict`: the verifier justifies, and states whether the
+ // window it was given can settle the claim at all, before it is allowed to emit a decision token.
+ expect(verify.properties.results.items.propertyOrdering).toEqual(['index', 'reason', 'decidable', 'verdict', 'confidence']);
// The batch grammar nests one level deeper; the same transforms must reach it.
const batch = toGeminiResponseJsonSchema(buildBatchReviewResponseSchema(10, 4).schema) as any;
diff --git a/test/model/limits.spec.ts b/test/model/limits.spec.ts
index 65f3ccaf..5572c203 100644
--- a/test/model/limits.spec.ts
+++ b/test/model/limits.spec.ts
@@ -2,9 +2,78 @@ import { describe, expect, it } from 'vitest';
import {
ModelCallGate,
adaptiveModelTimeoutMs,
+ clampTimeoutToChainBudget,
+ geminiThinkingBudgetTokens,
+ MODEL_FALLBACK_CHAIN_BUDGET_MS,
MODEL_TIMEOUT_BASE_MS,
MODEL_TIMEOUT_MAX_MS,
+ OUTPUT_TOKENS_FLOOR,
+ resolveOutputTokenCeiling,
+ reviewOutputBudgetTokens,
} from '../../src/server/models/limits';
+import { generatorFindingCap } from '../../src/server/prompts/file-review';
+
+// The whole point of these: a bin that overruns `maxOutputTokens` comes back as a repaired JSON prefix
+// with its tail files silently empty, which is indistinguishable from "those files are clean".
+describe('reviewOutputBudgetTokens', () => {
+ it('never asks for less than the floor', () => {
+ expect(reviewOutputBudgetTokens({ findingCap: 1, fileCount: 1 })).toBe(OUTPUT_TOKENS_FLOOR);
+ });
+
+ it('grows with the number of findings the prompt asked for', () => {
+ const one = reviewOutputBudgetTokens({ findingCap: generatorFindingCap(10), fileCount: 1 });
+ const bin = reviewOutputBudgetTokens({ findingCap: generatorFindingCap(10), fileCount: 6 });
+ // Six files at the same per-file cap need more room than one.
+ expect(bin).toBeGreaterThan(one);
+ expect(bin).toBeGreaterThan(OUTPUT_TOKENS_FLOOR);
+ });
+
+ it('covers the bin ask that the old flat ceiling could not', () => {
+ // The regression: 6 files x 20 findings each, requested inside a flat 8192.
+ expect(reviewOutputBudgetTokens({ findingCap: 20, fileCount: 6 })).toBeGreaterThan(8_192);
+ });
+});
+
+describe('resolveOutputTokenCeiling', () => {
+ it('falls back to the provider default when no budget is stated', () => {
+ expect(resolveOutputTokenCeiling(undefined, 65_536, 8_192)).toBe(8_192);
+ // A caller that omits it must be unaffected by a raised provider max.
+ expect(resolveOutputTokenCeiling(0, 65_536, 8_192)).toBe(8_192);
+ expect(resolveOutputTokenCeiling(Number.NaN, 65_536, 8_192)).toBe(8_192);
+ });
+
+ it('never drops below the provider default, and never exceeds its max', () => {
+ expect(resolveOutputTokenCeiling(1_000, 65_536, 8_192)).toBe(8_192);
+ expect(resolveOutputTokenCeiling(20_000, 65_536, 8_192)).toBe(20_000);
+ expect(resolveOutputTokenCeiling(999_999, 65_536, 8_192)).toBe(65_536);
+ // A provider whose max is below the shared default still gets a request it accepts.
+ expect(resolveOutputTokenCeiling(20_000, 4_096, 8_192)).toBe(4_096);
+ });
+});
+
+describe('geminiThinkingBudgetTokens', () => {
+ // Thinking bills against the SAME maxOutputTokens the JSON must fit in, so raising the ceiling has to
+ // buy answer rather than more thinking.
+ it('stays a minority of the ceiling', () => {
+ expect(geminiThinkingBudgetTokens(32_768)).toBeLessThan(32_768 / 3);
+ expect(geminiThinkingBudgetTokens(8_192)).toBeLessThan(8_192 / 3);
+ });
+
+ it('stays inside the band every Gemini 2.5 model accepts', () => {
+ // Never 0 (the Pro models refuse it outright) and never above 8192 (Flash's own ceiling is lower).
+ expect(geminiThinkingBudgetTokens(1_024)).toBeGreaterThanOrEqual(1_024);
+ expect(geminiThinkingBudgetTokens(65_536)).toBeLessThanOrEqual(8_192);
+ });
+});
+
+describe('generatorFindingCap', () => {
+ // Bin size deliberately does NOT divide this; see the note on generatorFindingCap. Measured output was
+ // ~3% of the ceiling, so the cap has never been the limit and lowering it only removes headroom.
+ it('is 2x max_comments regardless of how many files share the call', () => {
+ expect(generatorFindingCap(10)).toBe(20);
+ expect(generatorFindingCap(1)).toBe(2);
+ });
+});
describe('adaptiveModelTimeoutMs', () => {
it('uses the base budget for small diffs', () => {
@@ -25,6 +94,21 @@ describe('adaptiveModelTimeoutMs', () => {
});
});
+describe('clampTimeoutToChainBudget', () => {
+ it('leaves every budget the adaptive ceiling can produce untouched', () => {
+ // A big bin is meant to spend a whole invocation on one model and get the full ceiling.
+ expect(clampTimeoutToChainBudget(MODEL_TIMEOUT_MAX_MS)).toBe(MODEL_TIMEOUT_MAX_MS);
+ expect(clampTimeoutToChainBudget(MODEL_TIMEOUT_BASE_MS)).toBe(MODEL_TIMEOUT_BASE_MS);
+ });
+
+ // The invariant it exists to hold: the head of a chain is exempt from the budget check, so a per-call
+ // budget above the chain budget would let a call start that can never finish inside it.
+ it('holds the ceiling under the chain budget', () => {
+ expect(MODEL_TIMEOUT_MAX_MS).toBeLessThanOrEqual(MODEL_FALLBACK_CHAIN_BUDGET_MS);
+ expect(clampTimeoutToChainBudget(MODEL_FALLBACK_CHAIN_BUDGET_MS + 10_000)).toBe(MODEL_FALLBACK_CHAIN_BUDGET_MS);
+ });
+});
+
describe('ModelCallGate', () => {
it('never runs more than the limit concurrently and eventually runs everything', async () => {
const gate = new ModelCallGate(2);
diff --git a/test/model/rate-limit-parse.spec.ts b/test/model/rate-limit-parse.spec.ts
new file mode 100644
index 00000000..c434ccd7
--- /dev/null
+++ b/test/model/rate-limit-parse.spec.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from 'vitest';
+import { isPlausibleTokenBucket, parseRateLimitFromError } from '@server/services/model';
+
+// Verbatim from production: a free-tier 429 whose only stated quota counts REQUESTS, not tokens.
+const REQUESTS_QUOTA_429 = [
+ 'You exceeded your current quota, please check your plan and billing details.',
+ 'For more information on this error, head to: https://ai.google.dev/gemini-api/docs/rate-limits.',
+ 'To monitor your current usage, head to: https://ai.dev/rate-limit.',
+ '* Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_requests, limit: 15, model: gemini-3.5-flash-lite',
+ 'Please retry in 21.35281435s.',
+].join('\n');
+
+const TOKENS_QUOTA_429 =
+ '* Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_input_token_count, limit: 16000, model: gemini-2.5-flash Please retry in 26.9s.';
+
+describe('parseRateLimitFromError', () => {
+ // The regression: `limit: 15` is 15 requests per minute. Reading it as a 15-token bucket made
+ // skipReason refuse every prompt over 12 tokens for the rest of the job -- a model that was merely
+ // busy for a minute was taken out for 24 hours, and the whole fallback chain with it.
+ it('does not read a request-count quota as a token bucket', () => {
+ const parsed = parseRateLimitFromError(new Error(REQUESTS_QUOTA_429));
+
+ expect(parsed.limitTokens).toBeUndefined();
+ // The cool-off is still learned: the model IS rate-limited, just not by prompt size.
+ expect(parsed.retryAfterMs).toBeCloseTo(21352.81435, 3);
+ });
+
+ it('reads a genuine token quota', () => {
+ const parsed = parseRateLimitFromError(new Error(TOKENS_QUOTA_429));
+
+ expect(parsed.limitTokens).toBe(16000);
+ expect(parsed.retryAfterMs).toBe(26900);
+ });
+
+ // A body may state several violated quotas, and the request count often comes first -- which a bare
+ // /limit:\s*(\d+)/ would happily return as the bucket size.
+ it('picks the token quota out of a multi-quota body, not the first limit stated', () => {
+ const parsed = parseRateLimitFromError(new Error([
+ '* Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_requests, limit: 15, model: m',
+ '* Quota exceeded for metric: generativelanguage.googleapis.com/generate_content_free_tier_input_token_count, limit: 16000, model: m',
+ ].join('\n')));
+
+ expect(parsed.limitTokens).toBe(16000);
+ });
+
+ it('takes the smallest stated token bucket, which rejects a prompt first', () => {
+ const parsed = parseRateLimitFromError(new Error([
+ '* Quota exceeded for metric: x/input_token_count, limit: 32000, model: m',
+ '* Quota exceeded for metric: x/output_token_count, limit: 8000, model: m',
+ ].join('\n')));
+
+ expect(parsed.limitTokens).toBe(8000);
+ });
+
+ it('rejects an implausibly small token bucket even from a token metric', () => {
+ const parsed = parseRateLimitFromError(
+ new Error('* Quota exceeded for metric: x/input_token_count, limit: 15, model: m'),
+ );
+
+ expect(parsed.limitTokens).toBeUndefined();
+ });
+
+ it('returns nothing for an error that states no quota at all', () => {
+ const parsed = parseRateLimitFromError(new Error('Resource has been exhausted.'));
+
+ expect(parsed.limitTokens).toBeUndefined();
+ expect(parsed.retryAfterMs).toBeUndefined();
+ });
+});
+
+describe('isPlausibleTokenBucket', () => {
+ it('rejects request counts and accepts real buckets', () => {
+ expect(isPlausibleTokenBucket(15)).toBe(false);
+ expect(isPlausibleTokenBucket(undefined)).toBe(false);
+ expect(isPlausibleTokenBucket(16000)).toBe(true);
+ });
+});
diff --git a/test/model/service-chunking.spec.ts b/test/model/service-chunking.spec.ts
index db463450..686dd564 100644
--- a/test/model/service-chunking.spec.ts
+++ b/test/model/service-chunking.spec.ts
@@ -7,6 +7,8 @@ import { ModelService } from '@server/services/model';
import { createTestEnv, saveTestProviderApiKey } from '../helpers';
import { defaultRepoConfig } from '@shared/schema';
import { TokenTracker } from '@server/core/token-tracker';
+import { geminiThinkingBudgetTokens, reviewOutputBudgetTokens } from '@server/models/limits';
+import { generatorFindingCap } from '@server/prompts/file-review';
describe('ModelService: diff chunking', () => {
afterEach(() => {
@@ -65,8 +67,17 @@ describe('ModelService: diff chunking', () => {
// 900 lines at the 800-line cap: two chunks, each its own model call.
expect(fetchMock).toHaveBeenCalledTimes(2);
+ const answerBudget = reviewOutputBudgetTokens({
+ findingCap: generatorFindingCap(defaultRepoConfig.review.max_comments),
+ fileCount: 1,
+ });
for (const body of requestBodies) {
- expect(body.generationConfig.maxOutputTokens).toBe(8192);
+ // Room for the findings the prompt asked for, PLUS a bounded thinking budget on top -- thinking
+ // bills against the same ceiling, so sharing one flat 8192 truncated the JSON.
+ expect(body.generationConfig.thinkingConfig.thinkingBudget)
+ .toBe(geminiThinkingBudgetTokens(answerBudget));
+ expect(body.generationConfig.maxOutputTokens)
+ .toBe(answerBudget + geminiThinkingBudgetTokens(answerBudget));
// Proves the review grammar survives reviewFile -> callResolvedModel -> adapter.
expect(body.generationConfig.responseJsonSchema).toBeDefined();
}
diff --git a/test/model/service-fallbacks.spec.ts b/test/model/service-fallbacks.spec.ts
index ed9c64e5..6f6d2436 100644
--- a/test/model/service-fallbacks.spec.ts
+++ b/test/model/service-fallbacks.spec.ts
@@ -143,6 +143,78 @@ describe('ModelService: chain fallback, budget breakers and provider availabilit
expect(fetchMock).not.toHaveBeenCalled();
});
+ // Regression: the tail of the chain used to be exempt from the timeout breaker entirely, so a model
+ // that had never once answered on a job still cost every unit a full per-call budget -- 20 batches
+ // and 15 minutes of wall clock in production, all of it spent to re-learn the tally's verdict.
+ it('drops even the last candidate once it has never answered on this job', async () => {
+ const fetchMock = vi.spyOn(globalThis, 'fetch');
+ const env = createTestEnv();
+ await saveTestProviderApiKey(env);
+
+ // Six strikes: past the tail's higher bar, which a merely-slow model does not reach.
+ await env.APP_KV.put(
+ 'jobs:job-tail-drop:chain-progress',
+ JSON.stringify({ timeouts: { 'gemini-3.1-pro-preview': 6, 'gemini-2.5-pro': 6 } }),
+ );
+ const service = new ModelService(env, undefined, { jobId: 'job-tail-drop' });
+
+ const promise = service.reviewFile({
+ file: { path: 'src/app.ts', lineCount: 1, hunks: [], isDeleted: false, isBinary: false, isNew: false, previousPath: null },
+ prTitle: 'Test',
+ prDescription: null,
+ config: {
+ ...defaultRepoConfig,
+ model: { main: 'gemini-3.1-pro-preview', fallbacks: ['gemini-2.5-pro'], size_overrides: [] },
+ },
+ totalLineCount: 1,
+ });
+
+ // Deferred, and the message says which of the two skip reasons applied.
+ await expect(promise).rejects.toThrow(/No configured review model was attempted.*repeated timeouts/);
+ await promise.catch((error) => expect(isRetryableModelError(error)).toBe(true));
+ // The whole point: not one call was paid for.
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ // The other side of the same rule: a merely-slow tail still gets its shot, because deferring with no
+ // model attempted is the worse outcome when the model does sometimes answer.
+ it('still tries the last candidate when it is only mid-chain slow', async () => {
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }],
+ usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 },
+ }),
+ { status: 200, headers: { 'content-type': 'application/json' } },
+ ),
+ );
+ const env = createTestEnv();
+ await saveTestProviderApiKey(env);
+
+ // Three strikes drops a model mid-chain but not at the tail.
+ await env.APP_KV.put(
+ 'jobs:job-tail-slow:chain-progress',
+ JSON.stringify({ timeouts: { 'gemini-3.1-pro-preview': 3, 'gemini-2.5-pro': 3 } }),
+ );
+ const service = new ModelService(env, undefined, { jobId: 'job-tail-slow' });
+
+ const response = await service.reviewFile({
+ file: { path: 'src/app.ts', lineCount: 1, hunks: [], isDeleted: false, isBinary: false, isNew: false, previousPath: null },
+ prTitle: 'Test',
+ prDescription: null,
+ config: {
+ ...defaultRepoConfig,
+ model: { main: 'gemini-3.1-pro-preview', fallbacks: ['gemini-2.5-pro'], size_overrides: [] },
+ },
+ totalLineCount: 1,
+ });
+
+ // The struck primary is skipped, the tail is attempted anyway, and it answers.
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(String(fetchMock.mock.calls[0][0])).toContain('/models/gemini-2.5-pro:generateContent');
+ expect(response.modelUsed).toBe('gemini-2.5-pro');
+ });
+
// An unresolvable model is a permanent operator error; a transient deferral would hide the fix.
it('surfaces a permanent config error rather than deferring', async () => {
const env = createTestEnv();
@@ -207,6 +279,36 @@ describe('ModelService: chain fallback, budget breakers and provider availabilit
expect(response.modelUsed).toBe('gemini-3.1-pro-preview');
});
+ // The counterpart to the test above: the primary gets its shot at a merely-tight budget, but not at
+ // one that cannot cover the call. Previously it transmitted the prompt regardless and the runtime
+ // refused it, losing the unit AND the prompt -- three files' worth in one observed invocation.
+ it('will not commit a prompt when the budget cannot cover the call', async () => {
+ const fetchMock = vi.spyOn(globalThis, 'fetch');
+ const env = createTestEnv();
+ await saveTestProviderApiKey(env);
+ const tracker = new TokenTracker();
+ // Leaves 5 of the 50-subrequest cap, under the headroom one call may need.
+ tracker.incrementSubrequests(45);
+ const service = new ModelService(env, tracker);
+
+ const promise = service.reviewFile({
+ file: { path: 'src/app.ts', lineCount: 1, hunks: [], isDeleted: false, isBinary: false, isNew: false, previousPath: null },
+ prTitle: 'Test',
+ prDescription: null,
+ config: {
+ ...defaultRepoConfig,
+ model: { main: 'gemini-3.1-pro-preview', fallbacks: ['gemini-2.5-pro'], size_overrides: [] },
+ },
+ totalLineCount: 1,
+ });
+
+ // Deferred, not failed: a fresh invocation has a fresh budget.
+ await expect(promise).rejects.toThrow(/retrying later/);
+ await promise.catch((error) => expect(isRetryableModelError(error)).toBe(true));
+ // The whole point -- nothing went over the wire.
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
it('skips remaining fallback models (instead of spending more of the shared budget) once near the subrequest limit', async () => {
// The primary retries internally, so return a fresh Response per call (a body reads once).
// 503, not 500: only a genuinely transient failure produces a retryable deferral.
diff --git a/test/model/service-grammar-rejection.spec.ts b/test/model/service-grammar-rejection.spec.ts
new file mode 100644
index 00000000..eb2e6b81
--- /dev/null
+++ b/test/model/service-grammar-rejection.spec.ts
@@ -0,0 +1,199 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { ModelService } from '@server/services/model';
+import { reviewWithGoogle } from '@server/models/google';
+import { buildReviewResponseSchema } from '@server/prompts/file-review';
+import { createTestEnv, saveTestProviderApiKey } from '../helpers';
+import { defaultRepoConfig } from '@shared/schema';
+
+// Split out of service-retries.spec.ts: a 400 matches no transient pattern, so grammar rejection is
+// its own ladder rung -- drop responseJsonSchema, retry once, latch it off -- not part of the
+// transient-failure ladder those specs cover.
+describe('ModelService: response-grammar rejection', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ function geminiOk() {
+ return new Response(
+ JSON.stringify({
+ candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] }, finishReason: 'STOP' }],
+ usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 },
+ }),
+ { status: 200, headers: { 'content-type': 'application/json' } },
+ );
+ }
+
+ function gemini400(message: string) {
+ return new Response(
+ JSON.stringify({ error: { code: 400, status: 'INVALID_ARGUMENT', message } }),
+ { status: 400, headers: { 'content-type': 'application/json' } },
+ );
+ }
+
+ const withGrammar = { systemPrompt: 'system', userPrompt: 'user', responseSchema: buildReviewResponseSchema(10) };
+
+ // Google sometimes 400s with nothing but "Request contains an invalid argument." and no
+ // `error.details`, so none of the specific schema markers can fire. That used to fail the file on its
+ // first 400 -- permanently, since a 400 is not transient -- with no grammar probe and no fallback.
+ it('drops the response grammar and retries on a 400 that explains nothing', async () => {
+ const bareInvalidArgument = () =>
+ new Response(
+ JSON.stringify({ error: { code: 400, message: 'Request contains an invalid argument.' } }),
+ { status: 400, headers: { 'content-type': 'application/json' } },
+ );
+ const fetchMock = vi.spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(bareInvalidArgument())
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }],
+ usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 },
+ }),
+ { status: 200, headers: { 'content-type': 'application/json' } },
+ ),
+ );
+
+ const response = await reviewWithGoogle(
+ { apiKey: 'test-key' },
+ 'gemini-3.1-flash-lite',
+ {
+ systemPrompt: 'system',
+ userPrompt: 'user',
+ responseSchema: buildReviewResponseSchema(5),
+ },
+ );
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ // The first attempt carried the grammar and the retry did not.
+ const firstBody = JSON.parse(String((fetchMock.mock.calls[0][1] as RequestInit).body));
+ const retryBody = JSON.parse(String((fetchMock.mock.calls[1][1] as RequestInit).body));
+ expect(firstBody.generationConfig.responseJsonSchema).toBeDefined();
+ expect(retryBody.generationConfig.responseJsonSchema).toBeUndefined();
+ // Still asks for JSON, or the schema-less attempt returns prose.
+ expect(retryBody.generationConfig.responseMimeType).toBe('application/json');
+ expect(response.rawText).toContain('"findings"');
+ });
+
+ it('drops the grammar and retries once when Gemini rejects responseJsonSchema', async () => {
+ const fetchMock = vi.spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(gemini400('Unknown name "responseJsonSchema" at \'generation_config\': Cannot find field.'))
+ .mockResolvedValueOnce(geminiOk());
+
+ const response = await reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', withGrammar);
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)).generationConfig.responseJsonSchema).toBeDefined();
+ expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)).generationConfig.responseJsonSchema).toBeUndefined();
+ expect(response.rawText).toContain('"findings"');
+ // Surfaced so the "Test connection" preflight cannot call a grammar-incapable endpoint working.
+ expect(response.degraded).toBe('schema-dropped');
+
+ // The probe is not spent on an unrelated 400, nor when there was no grammar to drop.
+ for (const [message, input] of [
+ ['API key not valid. Please pass a valid API key.', withGrammar],
+ ['Invalid value at generation_config.schema.', { systemPrompt: 'system', userPrompt: 'user' }],
+ ] as Array<[string, any]>) {
+ vi.restoreAllMocks();
+ const guarded = vi.spyOn(globalThis, 'fetch').mockResolvedValue(gemini400(message));
+ await expect(
+ reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', input),
+ ).rejects.toThrow(/400/);
+ expect(guarded).toHaveBeenCalledTimes(1);
+ }
+ });
+
+ // The latch used to be set only when the schema-less retry SUCCEEDED. If that retry then 429'd,
+ // the next call re-probed with the grammar -- a wasted 400 plus a second full prompt, every call.
+ it('latches the grammar off even when the schema-less retry itself fails', async () => {
+ const fetchMock = vi.spyOn(globalThis, 'fetch')
+ // Grammar rejected, then the schema-less probe fails for an unrelated reason. Deliberately
+ // not a 429: that would cool the model off and the second review would skip it entirely,
+ // masking whether the latch held.
+ .mockResolvedValueOnce(gemini400('Unknown name "responseJsonSchema" at \'generation_config\'.'))
+ .mockResolvedValueOnce(gemini400('API key not valid. Please pass a valid API key.'))
+ .mockResolvedValue(geminiOk());
+
+ const env = createTestEnv();
+ await saveTestProviderApiKey(env);
+ const service = new ModelService(env);
+ const params = {
+ file: { path: 'src/app.ts', lineCount: 1, hunks: [], isDeleted: false, isBinary: false, isNew: false, previousPath: null },
+ prTitle: 'Test',
+ prDescription: null,
+ config: {
+ ...defaultRepoConfig,
+ model: { main: 'gemini-3.1-pro-preview', fallbacks: [], size_overrides: [] },
+ },
+ totalLineCount: 1,
+ };
+
+ await expect(service.reviewFile(params)).rejects.toThrow();
+ const callsAfterFirstReview = fetchMock.mock.calls.length;
+
+ await service.reviewFile(params);
+
+ // The second review goes straight out without the grammar: no re-probe, no wasted 400.
+ const firstCallOfSecondReview = fetchMock.mock.calls[callsAfterFirstReview];
+ expect(JSON.parse(String(firstCallOfSecondReview?.[1]?.body)).generationConfig.responseJsonSchema).toBeUndefined();
+ expect(fetchMock.mock.calls.length).toBe(callsAfterFirstReview + 1);
+ });
+
+ // Observed in production: Gemini 3.x sends a generic top-level message and puts the real reason
+ // in `details`. Without reading it the grammar rejection looked like an unrelated 400 and the model
+ // was dropped from the chain entirely.
+ it('reads the rejection reason out of error.details, not just the message', async () => {
+ const fetchMock = vi.spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(new Response(
+ JSON.stringify({
+ error: {
+ code: 400,
+ status: 'INVALID_ARGUMENT',
+ message: 'Request contains an invalid argument.',
+ details: [{
+ '@type': 'type.googleapis.com/google.rpc.BadRequest',
+ fieldViolations: [{
+ description: 'The specified schema produces a constraint that has too many states for serving.',
+ }],
+ }],
+ },
+ }),
+ { status: 400, headers: { 'content-type': 'application/json' } },
+ ))
+ .mockResolvedValueOnce(geminiOk());
+
+ const response = await reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', withGrammar);
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)).generationConfig.responseJsonSchema).toBeUndefined();
+ expect(response.degraded).toBe('schema-dropped');
+ });
+
+ it('gives the attempt back for the probe, but only once', async () => {
+ // The probe isn't a transient rung: without the give-back, a ladder spent on 5xx could never
+ // drop the schema. The latch stops it looping.
+ const gemini500 = () => new Response(JSON.stringify({ error: { code: 500, message: 'Internal error encountered.' } }), { status: 500, headers: { 'content-type': 'application/json' } });
+ const ladderSpent = vi.spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(gemini500())
+ .mockResolvedValueOnce(gemini500())
+ .mockResolvedValueOnce(gemini400('Unknown name "responseJsonSchema" at \'generation_config\'.'))
+ .mockResolvedValueOnce(geminiOk());
+
+ const response = await reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', withGrammar);
+
+ expect(ladderSpent).toHaveBeenCalledTimes(4);
+ expect(JSON.parse(String(ladderSpent.mock.calls[3]?.[1]?.body)).generationConfig.responseJsonSchema).toBeUndefined();
+ expect(response.degraded).toBe('schema-dropped');
+
+ // mockImplementation, not mockResolvedValue: a retried call cannot re-read one Response body.
+ vi.restoreAllMocks();
+ const persistent = vi.spyOn(globalThis, 'fetch')
+ .mockImplementation(async () => gemini400('Invalid JSON payload received. Unknown name "responseJsonSchema".'));
+
+ await expect(
+ reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', withGrammar),
+ ).rejects.toThrow(/400/);
+
+ // Two, not three and not unbounded: one with the grammar, one without, then throw.
+ expect(persistent).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/test/model/service-requests.spec.ts b/test/model/service-requests.spec.ts
index 567d0645..f56e6884 100644
--- a/test/model/service-requests.spec.ts
+++ b/test/model/service-requests.spec.ts
@@ -240,7 +240,10 @@ describe('ModelService: request shape and response handling', () => {
expect(schemaKeys(review)).toEqual(['responseJsonSchema']);
expect(review.generationConfig.responseJsonSchema.properties.findings.maxItems).toBe(20);
expect(review.generationConfig.responseMimeType).toBe('application/json');
- expect(review.generationConfig.maxOutputTokens).toBe(8192);
+ // No `outputBudgetTokens` on this input, so the adapter's own default answer budget applies -- and
+ // the bounded thinking budget is added ON TOP of it, never carved out of it.
+ expect(review.generationConfig.thinkingConfig.thinkingBudget).toBe(2048);
+ expect(review.generationConfig.maxOutputTokens).toBe(8192 + 2048);
// Per-call, not hardcoded: forcing the review grammar onto the verify pass made it unsatisfiable.
const verify = await captureGeminiBody({ systemPrompt: 'system', userPrompt: 'user', responseSchema: VERIFY_RESPONSE_SCHEMA as any });
diff --git a/test/model/service-retries.spec.ts b/test/model/service-retries.spec.ts
index 419bc5e3..bff75b77 100644
--- a/test/model/service-retries.spec.ts
+++ b/test/model/service-retries.spec.ts
@@ -2,9 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { isRetryableModelError, ModelService } from '@server/services/model';
import { reviewWithCloudflare } from '@server/models/cloudflare';
import { reviewWithGoogle } from '@server/models/google';
-
-
-import { buildReviewResponseSchema } from '@server/prompts/file-review';
+import { MODEL_TIMEOUT_MAX_MS } from '@server/models/limits';
import { createTestEnv, saveTestProviderApiKey } from '../helpers';
import { defaultRepoConfig } from '@shared/schema';
@@ -102,6 +100,51 @@ describe('ModelService: transient failures and the retry ladder', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
+ // The free-tier buckets are per-minute, so an unstated cool-off is ~60s by construction. Backing
+ // off ~0.8s then ~1.6s bought two more 429s and two more full prompt transmissions for nothing.
+ it('gives up immediately on a 429 that states no cool-off at all', async () => {
+ const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
+ new Response(
+ JSON.stringify({ error: { code: 429, message: 'Resource has been exhausted.' } }),
+ { status: 429, headers: { 'content-type': 'application/json' } },
+ ),
+ );
+
+ await expect(
+ reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', { systemPrompt: 'system', userPrompt: 'user' }),
+ ).rejects.toThrow(/429/);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('still retries a 5xx with no Retry-After, which is a genuinely transient blip', async () => {
+ const fetchMock = vi.spyOn(globalThis, 'fetch')
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({ error: { code: 503, message: 'The model is overloaded.' } }),
+ { status: 503, headers: { 'content-type': 'application/json' } },
+ ),
+ )
+ .mockResolvedValueOnce(
+ new Response(
+ JSON.stringify({
+ candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] } }],
+ usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 },
+ }),
+ { status: 200, headers: { 'content-type': 'application/json' } },
+ ),
+ );
+
+ const response = await reviewWithGoogle(
+ { apiKey: 'test-key' },
+ 'gemini-3.1-pro-preview',
+ { systemPrompt: 'system', userPrompt: 'user' },
+ );
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ expect(response.rawText).toContain('"findings"');
+ });
+
it('does not retry TypeErrors thrown after a successful Google response', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
ok: true,
@@ -121,116 +164,6 @@ describe('ModelService: transient failures and the retry ladder', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
- // A 400 matches no transient pattern, so a grammar-rejecting endpoint fails permanently.
- describe('response-grammar rejection', () => {
- function geminiOk() {
- return new Response(
- JSON.stringify({
- candidates: [{ content: { parts: [{ text: '{"findings":[],"overall_correctness":"patch is correct","overall_explanation":"ok","overall_confidence_score":0.9}' }] }, finishReason: 'STOP' }],
- usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 },
- }),
- { status: 200, headers: { 'content-type': 'application/json' } },
- );
- }
-
- function gemini400(message: string) {
- return new Response(
- JSON.stringify({ error: { code: 400, status: 'INVALID_ARGUMENT', message } }),
- { status: 400, headers: { 'content-type': 'application/json' } },
- );
- }
-
- const withGrammar = { systemPrompt: 'system', userPrompt: 'user', responseSchema: buildReviewResponseSchema(10) };
-
- it('drops the grammar and retries once when Gemini rejects responseJsonSchema', async () => {
- const fetchMock = vi.spyOn(globalThis, 'fetch')
- .mockResolvedValueOnce(gemini400('Unknown name "responseJsonSchema" at \'generation_config\': Cannot find field.'))
- .mockResolvedValueOnce(geminiOk());
-
- const response = await reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', withGrammar);
-
- expect(fetchMock).toHaveBeenCalledTimes(2);
- expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)).generationConfig.responseJsonSchema).toBeDefined();
- expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)).generationConfig.responseJsonSchema).toBeUndefined();
- expect(response.rawText).toContain('"findings"');
- // Surfaced so the "Test connection" preflight cannot call a grammar-incapable endpoint working.
- expect(response.degraded).toBe('schema-dropped');
-
- // The probe is not spent on an unrelated 400, nor when there was no grammar to drop.
- for (const [message, input] of [
- ['API key not valid. Please pass a valid API key.', withGrammar],
- ['Invalid value at generation_config.schema.', { systemPrompt: 'system', userPrompt: 'user' }],
- ] as Array<[string, any]>) {
- vi.restoreAllMocks();
- const guarded = vi.spyOn(globalThis, 'fetch').mockResolvedValue(gemini400(message));
- await expect(
- reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', input),
- ).rejects.toThrow(/400/);
- expect(guarded).toHaveBeenCalledTimes(1);
- }
- });
-
- // Observed in production: Gemini 3.x sends a generic top-level message and puts the real reason
-// in `details`. Without reading it the grammar rejection looked like an unrelated 400 and the model
- // was dropped from the chain entirely.
- it('reads the rejection reason out of error.details, not just the message', async () => {
- const fetchMock = vi.spyOn(globalThis, 'fetch')
- .mockResolvedValueOnce(new Response(
- JSON.stringify({
- error: {
- code: 400,
- status: 'INVALID_ARGUMENT',
- message: 'Request contains an invalid argument.',
- details: [{
- '@type': 'type.googleapis.com/google.rpc.BadRequest',
- fieldViolations: [{
- description: 'The specified schema produces a constraint that has too many states for serving.',
- }],
- }],
- },
- }),
- { status: 400, headers: { 'content-type': 'application/json' } },
- ))
- .mockResolvedValueOnce(geminiOk());
-
- const response = await reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', withGrammar);
-
- expect(fetchMock).toHaveBeenCalledTimes(2);
- expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)).generationConfig.responseJsonSchema).toBeUndefined();
- expect(response.degraded).toBe('schema-dropped');
- });
-
- it('gives the attempt back for the probe, but only once', async () => {
- // The probe isn't a transient rung: without the give-back, a ladder spent on 5xx could never
- // drop the schema. The latch stops it looping.
- const gemini500 = () => new Response(JSON.stringify({ error: { code: 500, message: 'Internal error encountered.' } }), { status: 500, headers: { 'content-type': 'application/json' } });
- const ladderSpent = vi.spyOn(globalThis, 'fetch')
- .mockResolvedValueOnce(gemini500())
- .mockResolvedValueOnce(gemini500())
- .mockResolvedValueOnce(gemini400('Unknown name "responseJsonSchema" at \'generation_config\'.'))
- .mockResolvedValueOnce(geminiOk());
-
- const response = await reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', withGrammar);
-
- expect(ladderSpent).toHaveBeenCalledTimes(4);
- expect(JSON.parse(String(ladderSpent.mock.calls[3]?.[1]?.body)).generationConfig.responseJsonSchema).toBeUndefined();
- expect(response.degraded).toBe('schema-dropped');
-
- // mockImplementation, not mockResolvedValue: a retried call cannot re-read one Response body.
- vi.restoreAllMocks();
- const persistent = vi.spyOn(globalThis, 'fetch')
- .mockImplementation(async () => gemini400('Invalid JSON payload received. Unknown name "responseJsonSchema".'));
-
- await expect(
- reviewWithGoogle({ apiKey: 'test-key' }, 'gemini-3.1-pro-preview', withGrammar),
- ).rejects.toThrow(/400/);
-
- // Two, not three and not unbounded: one with the grammar, one without, then throw.
- expect(persistent).toHaveBeenCalledTimes(2);
- });
-
- });
-
it('does not spend an extra queue slice retrying the same Cloudflare model inline', async () => {
let attempts = 0;
const env = createTestEnv({
@@ -272,9 +205,11 @@ describe('ModelService: transient failures and the retry ladder', () => {
// Prevent an unhandled-rejection warning while the timer is still pending.
promise.catch(() => {});
- await vi.advanceTimersByTimeAsync(45_000);
+ // Derived, not hardcoded: pinning the number here meant raising the ceiling made this test
+ // advance past nothing, so the promise never settled and the run hung on fake timers.
+ await vi.advanceTimersByTimeAsync(MODEL_TIMEOUT_MAX_MS);
- await expect(promise).rejects.toThrow('timed out after 45000ms');
+ await expect(promise).rejects.toThrow(`timed out after ${MODEL_TIMEOUT_MAX_MS}ms`);
// The underlying Workers-AI request was actually cancelled, not just abandoned.
expect(capturedSignal?.aborted).toBe(true);
} finally {
diff --git a/test/review/batch-flow.spec.ts b/test/review/batch-flow.spec.ts
index 62d8f038..5155ce42 100644
--- a/test/review/batch-flow.spec.ts
+++ b/test/review/batch-flow.spec.ts
@@ -1,4 +1,4 @@
-import { runReviewJob } from '@server/core/review';
+import { BIN_MAX_FILES, runReviewJob } from '@server/core/review';
import { createTestEnv, dbDescribe, generateMockDiff, sha, uniqueRepo } from '../helpers';
import { afterEach, expect, it, vi } from 'vitest';
import { insertJob, updateJobFileCount, updateJobStep } from '@server/db/jobs';
@@ -33,11 +33,10 @@ const batchingConfig = {
review: { ...defaultRepoConfig.review, batch_small_files: true },
};
-const smallFiles = [
- { path: 'src/a.ts', content: 'console.log(1);' },
- { path: 'src/b.ts', content: 'console.log(2);' },
- { path: 'src/c.ts', content: 'console.log(3);' },
-];
+const smallFiles = Array.from({ length: BIN_MAX_FILES }, (_unused, index) => ({
+ path: `src/${String.fromCharCode(97 + index)}.ts`,
+ content: `console.log(${index + 1});`,
+}));
async function seedJob(env: ReturnType, repo: string, config = batchingConfig) {
const job = await insertJob(env, {
@@ -83,17 +82,17 @@ dbDescribe('Review flow: batched small files', () => {
await runReviewJob(env, { jobId: job.id, deliveryId: 'delivery-batch', phase: 'review' });
});
- // The whole point: three files, ONE model call.
+ // The whole point: a bin's worth of files, ONE model call.
expect(reviewFilesSpy).toHaveBeenCalledTimes(1);
expect((reviewFilesSpy.mock.calls[0][0] as { files: Array<{ path: string }> }).files.map((f) => f.path))
- .toEqual(['src/a.ts', 'src/b.ts', 'src/c.ts']);
+ .toEqual(smallFiles.map((f) => f.path));
expect(reviewFileSpy).not.toHaveBeenCalled();
const reviews = await getFileReviewsForJobs(env, [job.id]);
- expect(reviews).toHaveLength(3);
+ expect(reviews).toHaveLength(smallFiles.length);
for (const review of reviews) {
expect(review.file_status).toBe('done');
- expect(review.batch_size).toBe(3);
+ expect(review.batch_size).toBe(smallFiles.length);
// Per-file summary, not one shared string: the reason for the nested response shape.
expect(review.file_summary).toBe(`Looks ok: ${review.file_path}`);
expect(review.parsed_comments).toHaveLength(1);
diff --git a/test/review/pack.spec.ts b/test/review/pack.spec.ts
index bd18cb49..a8e592d9 100644
--- a/test/review/pack.spec.ts
+++ b/test/review/pack.spec.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
+ BIN_MAX_FILES,
PACKABLE_MAX_DIFF_LINES,
type LedgerEntry,
narrowUnit,
@@ -38,14 +39,15 @@ const ledger = (entries: Record>) =>
describe('planReviewUnits', () => {
// Independent ceilings: a file under the line limit can still blow the char budget alone.
it('packs small files, respects both ceilings, and no-ops when disabled', () => {
- const files = [file('a.ts', 10), file('b.ts', 10), file('c.ts', 10)];
+ const paths = Array.from({ length: BIN_MAX_FILES }, (_unused, index) => `f${index}.ts`);
+ const files = paths.map(path => file(path, 10));
const packed = planReviewUnits(files, { enabled: true });
expect(packed).toHaveLength(1);
- expect(unitFiles(packed[0]).map(f => f.path)).toEqual(['a.ts', 'b.ts', 'c.ts']);
+ expect(unitFiles(packed[0]).map(f => f.path)).toEqual(paths);
const unpacked = planReviewUnits(files, { enabled: false });
- expect(unpacked).toHaveLength(3);
+ expect(unpacked).toHaveLength(BIN_MAX_FILES);
expect(unpacked.every(u => u.kind === 'single')).toBe(true);
const units = planReviewUnits([
@@ -64,29 +66,36 @@ describe('planReviewUnits', () => {
describe('narrowUnit', () => {
it('drops handled files and collapses to a single when one is left', () => {
- const [unit] = planReviewUnits([file('a.ts', 10), file('b.ts', 10), file('c.ts', 10)], { enabled: true });
+ // Needs a bin big enough to still hold two files after one is handled, whatever the cap is.
+ const paths = Array.from({ length: Math.max(3, BIN_MAX_FILES) }, (_unused, index) => `f${index}.ts`);
+ const [unit] = planReviewUnits(paths.slice(0, BIN_MAX_FILES).map(path => file(path, 10)), { enabled: true });
+ const inBin = unitFiles(unit).map(f => f.path);
- const partial = narrowUnit(unit, ledger({ 'a.ts': { handled: true } }));
- expect(partial).toHaveLength(1);
- expect(unitFiles(partial[0]).map(f => f.path)).toEqual(['b.ts', 'c.ts']);
+ // Handling all but the last leaves exactly one file, which must de-escalate to a single.
+ const allButLast = Object.fromEntries(inBin.slice(0, -1).map(path => [path, { handled: true }]));
+ const one = narrowUnit(unit, ledger(allButLast));
+ expect(one).toEqual([{ kind: 'single', file: expect.objectContaining({ path: inBin[inBin.length - 1] }) }]);
- const one = narrowUnit(unit, ledger({ 'a.ts': { handled: true }, 'b.ts': { handled: true } }));
- expect(one).toEqual([{ kind: 'single', file: expect.objectContaining({ path: 'c.ts' }) }]);
-
- expect(narrowUnit(unit, ledger({ 'a.ts': { handled: true }, 'b.ts': { handled: true }, 'c.ts': { handled: true } }))).toEqual([]);
+ // Handling every member leaves nothing to review.
+ const all = Object.fromEntries(inBin.map(path => [path, { handled: true }]));
+ expect(narrowUnit(unit, ledger(all))).toEqual([]);
});
// Otherwise a deterministic plan re-forms the same failing bin; de-escalating must not strand
// the other files.
it('explodes a bin into singles once any member has failed transiently', () => {
- const [unit] = planReviewUnits(
- [file('a.ts', 10), file('b.ts', 10), file('c.ts', 10), file('d.ts', 10)],
- { enabled: true },
- );
+ // Sized from BIN_MAX_FILES so lowering the cap cannot turn this into a test about packing.
+ const paths = Array.from({ length: BIN_MAX_FILES }, (_unused, index) => `f${index}.ts`);
+ const [unit] = planReviewUnits(paths.map(path => file(path, 10)), { enabled: true });
+ expect(unitFiles(unit)).toHaveLength(BIN_MAX_FILES);
- const narrowed = narrowUnit(unit, ledger({ 'a.ts': { handled: true }, 'c.ts': { transientErrorCount: 2 } }));
+ // First file already done, last one failed transiently: the rest must not be stranded with it.
+ const narrowed = narrowUnit(unit, ledger({
+ [paths[0]]: { handled: true },
+ [paths[paths.length - 1]]: { transientErrorCount: 2 },
+ }));
expect(narrowed.every(u => u.kind === 'single')).toBe(true);
- expect(narrowed.flatMap(unitFiles).map(f => f.path)).toEqual(['b.ts', 'c.ts', 'd.ts']);
+ expect(narrowed.flatMap(unitFiles).map(f => f.path)).toEqual(paths.slice(1));
});
});
diff --git a/test/review/quota-deferral.spec.ts b/test/review/quota-deferral.spec.ts
index 95e7aada..6031a7f3 100644
--- a/test/review/quota-deferral.spec.ts
+++ b/test/review/quota-deferral.spec.ts
@@ -196,6 +196,46 @@ describe('learning a provider rate limit from its own 429', () => {
expect(fetchMock.mock.calls).toHaveLength(1);
});
+ // The book used to be in-memory on ModelService, so it died with the invocation. A job runs up to
+ // 20 continuations, and each fresh invocation re-paid a full-prompt 429 to re-learn a cool-off the
+ // previous one had already been told about -- the single largest source of wasted input tokens.
+ it('carries a cool-off to the next invocation of the same job', async () => {
+ const fetchMock = googleMock(() => quotaResponse(56));
+ // MemoryKV persists across ModelService instances, standing in for a continuation handoff.
+ const env = createTestEnv();
+ await saveTestProviderApiKey(env);
+ const params = { prTitle: 'Test', prDescription: null, totalLineCount: 1, config: chain };
+
+ const first = new ModelService(env, undefined, { jobId: 'job-continuation' });
+ await first.reviewFile({ ...params, file });
+ expect(fetchMock.mock.calls.some((c) => String(c[0]).includes('pro-preview'))).toBe(true);
+
+ // A brand-new service, as a fresh invocation would build.
+ fetchMock.mockClear();
+ const next = new ModelService(env, undefined, { jobId: 'job-continuation' });
+ await next.reviewFile({ ...params, file: { ...file, path: 'src/second.ts' } });
+
+ // The metered model is never probed again: no 429, no wasted prompt.
+ expect(fetchMock.mock.calls.map((c) => String(c[0])).some((url) => url.includes('pro-preview'))).toBe(false);
+ expect(fetchMock.mock.calls).toHaveLength(1);
+ });
+
+ it('keeps a cool-off scoped to its own job and model', async () => {
+ const fetchMock = googleMock(() => quotaResponse(56));
+ const env = createTestEnv();
+ await saveTestProviderApiKey(env);
+ const params = { prTitle: 'Test', prDescription: null, totalLineCount: 1, config: chain };
+
+ await new ModelService(env, undefined, { jobId: 'job-a' }).reviewFile({ ...params, file });
+
+ // An unrelated job must not inherit it: each Gemini model meters per project, but a stale
+ // cool-off leaking across jobs would silently narrow coverage with no re-probe path.
+ fetchMock.mockClear();
+ await new ModelService(env, undefined, { jobId: 'job-b' }).reviewFile({ ...params, file });
+
+ expect(fetchMock.mock.calls.map((c) => String(c[0])).some((url) => url.includes('pro-preview'))).toBe(true);
+ });
+
// Small files must still reach the stronger model once its cool-off lapses.
it('returns to the primary model once its cool-off has expired', async () => {
let meteredCalls = 0;
diff --git a/test/timezone-format.spec.ts b/test/timezone-format.spec.ts
index 59474ddf..9ab97e1c 100644
--- a/test/timezone-format.spec.ts
+++ b/test/timezone-format.spec.ts
@@ -62,7 +62,7 @@ describe('timezone formatting', () => {
// Exercises the real call site rather than a copy of its options, so the guard
// can't drift away from the component it protects.
it('formats the job-detail absolute stamp with its requested options', async () => {
- const { formatAbsoluteDate } = await import('@client/components/features/job-detail/job-chips');
+ const { formatAbsoluteDate } = await import('@client/components/features/job-detail/job-chip-utils');
const stamp = formatAbsoluteDate(INSTANT);
expect(stamp).toBeTruthy();
diff --git a/test/token-tracker.spec.ts b/test/token-tracker.spec.ts
index 852e25b6..ccb38b73 100644
--- a/test/token-tracker.spec.ts
+++ b/test/token-tracker.spec.ts
@@ -45,3 +45,73 @@ describe('TokenTracker.remainingSafeBudget', () => {
expect(tracker.remainingSafeBudget()).toBe(0);
});
});
+
+// A failed model call still put a full prompt on the wire, but record() only ever ran after a
+// success -- so every 429'd and retried send was invisible to token accounting and telemetry, and
+// the reported input total understated what the review actually cost.
+describe('TokenTracker wasted-attempt accounting', () => {
+ it('counts failed attempts by reason without touching billed usage', () => {
+ const tracker = new TokenTracker();
+ tracker.record('google:m', 1000, 200);
+ tracker.recordFailedAttempt('google:m', 3000, 'rate-limited');
+ tracker.recordFailedAttempt('google:m', 3000, 'error');
+ tracker.recordFailedAttempt('google:m', 3000, 'rate-limited');
+
+ // Estimates must never leak into the billed figures.
+ expect(tracker.getTotalUsage()).toEqual({ input: 1000, output: 200 });
+ expect(tracker.getBreakdown()).toHaveLength(1);
+
+ expect(tracker.getWasted()).toEqual({
+ attempts: 3,
+ estimatedInput: 9000,
+ skips: 0,
+ byReason: { 'rate-limited': 2, error: 1 },
+ });
+ });
+
+ it('counts skipped calls separately -- the signal that the cool-off gates are working', () => {
+ const tracker = new TokenTracker();
+ tracker.recordSkippedCall('google:m', 'cooling off for another 42s');
+ tracker.recordSkippedCall('google:m', 'cooling off for another 41s');
+
+ const wasted = tracker.getWasted();
+ expect(wasted.skips).toBe(2);
+ // A skip sent no prompt, so it costs no estimated tokens.
+ expect(wasted.attempts).toBe(0);
+ expect(wasted.estimatedInput).toBe(0);
+ });
+
+ it('carries wasted counters through merge, so a per-chunk tracker rolls up', () => {
+ const parent = new TokenTracker();
+ parent.recordFailedAttempt('google:m', 1000, 'error');
+ parent.recordSkippedCall('google:m', 'cooling off');
+
+ const child = new TokenTracker();
+ child.record('google:m', 500, 100);
+ child.recordFailedAttempt('google:m', 2000, 'rate-limited');
+ child.recordFailedAttempt('google:m', 2000, 'error');
+ child.recordSkippedCall('google:m', 'cooling off');
+
+ parent.merge(child);
+
+ expect(parent.getTotalUsage()).toEqual({ input: 500, output: 100 });
+ expect(parent.getWasted()).toEqual({
+ attempts: 3,
+ estimatedInput: 5000,
+ skips: 2,
+ byReason: { error: 2, 'rate-limited': 1 },
+ });
+ });
+
+ it('clears wasted counters on reset alongside usage', () => {
+ const tracker = new TokenTracker();
+ tracker.record('google:m', 100, 10);
+ tracker.recordFailedAttempt('google:m', 1000, 'error');
+ tracker.recordSkippedCall('google:m', 'cooling off');
+
+ tracker.reset();
+
+ expect(tracker.getTotalUsage()).toEqual({ input: 0, output: 0 });
+ expect(tracker.getWasted()).toEqual({ attempts: 0, estimatedInput: 0, skips: 0, byReason: {} });
+ });
+});