open-swe/ui/src/routes/admin.tsx
seahaven-openswe[bot] c2bc7720cd
Some checks are pending
CI / Lint (push) Waiting to run
CI / Format check (push) Waiting to run
CI / Unit tests (push) Waiting to run
CI / Playwright E2E (push) Waiting to run
CI / Docker build smoke (push) Waiting to run
CI / Triage ledger up to date (push) Waiting to run
CI / ui bun.lock in sync (push) Waiting to run
feat: port LangSmith LLM Gateway routing from upstream (#1671, #1673, #1674, #1678) (#155)
* feat: port LangSmith LLM Gateway routing from upstream (#1671, #1673, #1674, #1678)

Ports four upstream commits that add opt-in LLM call routing through the
LangSmith Gateway, preserving fork conventions (Bedrock/Fireworks model IDs,
no-agent-attribution, bun toolchain).

- #1671 (e9dc6e01): opt-in gateway routing — new gateway.py, team-settings
  toggle, admin UI section, wired into make_model for all graph entrypoints
- #1673 (702ef908): dedicated LANGSMITH_GATEWAY_API_KEY precedence over
  platform LANGSMITH_API_KEY
- #1674 (5f7c2f46): fix Fireworks gateway base URL to /fireworks (bare host,
  SDK appends /v1/chat/completions) + SanitizeFireworksMessagesMiddleware
- #1678 (73b7d1c0): fix OpenAI Responses reasoning replay —
  SanitizeOpenAIResponsesMiddleware, store/include config for encrypted
  reasoning content, reasoning_effort coercion for Chat Completions fallback

Refs #134

* fix: downgrade gateway not-routed log to debug, add Bedrock UI note, add sanitizer parity

- Downgrade logger.warning to logger.debug in gateway_overrides for
  not-routed providers and missing API key (Bedrock is the default
  provider in this fork, so these are expected steady states)
- Add Bedrock to the LLMGatewaySection route-toggle description so
  admins know it is not routed through the gateway
- Add SanitizeOpenAIResponsesMiddleware to chat.py for parity with
  server.py and reviewer.py
- Restore the Bedrock region comment in model.py that explains the
  AWS_REGION / AWS_DEFAULT_REGION precedence

Refs #138

---------

Co-authored-by: amoussa1229 <166072409+amoussa1229@users.noreply.github.com>
2026-07-09 14:44:15 -04:00

981 lines
31 KiB
TypeScript

import { Link, Navigate, createFileRoute } from "@tanstack/react-router"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { CaretRightIcon } from "@phosphor-icons/react"
import { useEffect, useMemo, useState } from "react"
import type {
DatadogConnectBody,
LangSmithConnectBody,
ModelOption,
PRTraceResolutionResult,
TeamSettings,
UserMapping,
} from "@/lib/api"
import { AppShell, SettingsRow, SettingsSection } from "@/components/AppShell"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { api } from "@/lib/api"
import { RequireLogin } from "@/lib/auth-redirect"
import { useSession } from "@/lib/session"
export const Route = createFileRoute("/admin")({ component: AdminPage })
function AdminPage() {
const session = useSession()
const options = useQuery({
queryKey: ["options"],
queryFn: api.options,
enabled: !!session.data?.is_admin,
})
if (session.isLoading) {
return (
<main className="p-6">
<Skeleton className="h-64 w-full" />
</main>
)
}
if (!session.data) return <RequireLogin />
if (!session.data.is_admin) return <Navigate to="/my-settings" />
return (
<AppShell
user={session.data}
title="Admin"
description="Workspace-wide defaults and user mappings."
>
<GlobalDefaultsSection models={options.data?.models ?? []} />
<LLMGatewaySection />
<TriggerReviewSection />
<SettingsSection title="Evals">
<Link
to="/admin/evals"
className="flex items-center justify-between gap-6 px-4 py-3 hover:bg-muted/40"
>
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-foreground">
Reviewer eval
</span>
<span className="text-xs text-muted-foreground">
Run the offline reviewer benchmark and watch its output stream
live.
</span>
</div>
<CaretRightIcon className="size-3.5 shrink-0 text-muted-foreground" />
</Link>
</SettingsSection>
<ObservabilityCredentialsSection />
<PRTraceResolutionSection />
<UserMappingsSection enabled={!!session.data.is_admin} />
</AppShell>
)
}
const PR_URL_RE = /^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+)\/pull\/(\d+)/
function TriggerReviewSection() {
const [url, setUrl] = useState("")
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const [trace, setTrace] = useState<PRTraceResolutionResult | null>(null)
const parsed = useMemo(() => {
const match = PR_URL_RE.exec(url.trim())
if (!match) return null
const [, owner, repo, number] = match
if (!owner || !repo || !number) return null
return { owner, repo, number: Number(number) }
}, [url])
const trigger = useMutation({
mutationFn: () => {
if (!parsed) throw new Error("invalid PR URL")
return api.reReview(parsed.owner, parsed.repo, parsed.number)
},
onSuccess: (result) => {
setError(null)
setMessage(
result.queued
? "Review queued — a run is already in progress on this PR."
: "Review started."
)
},
onError: (e: Error) => {
setMessage(null)
setError(e.message)
},
})
const resolveTrace = useMutation({
mutationFn: () => {
if (!parsed) throw new Error("invalid PR URL")
return api.resolveTrace(parsed.owner, parsed.repo, parsed.number)
},
onSuccess: (result) => {
setError(null)
setMessage(null)
setTrace(result)
},
onError: (e: Error) => {
setTrace(null)
setError(e.message)
},
})
return (
<SettingsSection
title="Trigger a review"
description="Manually start an Open SWE Review run on a pull request, or dry-run author trace resolution for it. The repository must be enabled for review."
>
<div className="flex flex-col gap-2 p-4">
<div className="flex items-center gap-2">
<Input
className="flex-1"
placeholder="https://github.com/owner/repo/pull/123"
value={url}
onChange={(e) => {
setUrl(e.target.value)
setMessage(null)
setError(null)
setTrace(null)
}}
/>
<Button
size="sm"
variant="outline"
onClick={() => resolveTrace.mutate()}
disabled={!parsed || resolveTrace.isPending}
>
{resolveTrace.isPending ? "Resolving…" : "Resolve trace"}
</Button>
<Button
size="sm"
onClick={() => trigger.mutate()}
disabled={!parsed || trigger.isPending}
>
{trigger.isPending ? "Starting…" : "Start review"}
</Button>
</div>
{url.trim() && !parsed && (
<p className="text-xs text-muted-foreground">
Enter a full PR URL like https://github.com/owner/repo/pull/123
</p>
)}
{message && parsed && (
<p className="text-xs text-muted-foreground">
{message}{" "}
<Link
to="/agents/reviews/$owner/$repo/$number"
params={{
owner: parsed.owner,
repo: parsed.repo,
number: String(parsed.number),
}}
className="underline hover:text-foreground"
>
View review
</Link>
</p>
)}
{trace &&
(trace.resolved ? (
<p className="text-xs text-muted-foreground">
Resolved thread{" "}
<code className="font-mono">{trace.thread_id}</code> · confidence{" "}
{trace.confidence?.toFixed(2)} · {trace.evidence.join(", ")} ·{" "}
{trace.run_count} run{trace.run_count === 1 ? "" : "s"}
{trace.trace_url && (
<>
{" · "}
<a
href={trace.trace_url}
target="_blank"
rel="noreferrer"
className="underline hover:text-foreground"
>
open trace
</a>
</>
)}
</p>
) : (
<p className="text-xs text-muted-foreground">
No trace resolved — {trace.detail}
{trace.project ? ` (project: ${trace.project})` : ""}
</p>
))}
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
</SettingsSection>
)
}
const PAGE_SIZE = 20
function UserMappingsSection({ enabled }: { enabled: boolean }) {
const [error, setError] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [newLogin, setNewLogin] = useState("")
const [newEmail, setNewEmail] = useState("")
const mappings = useQuery({
queryKey: ["adminUserMappings", page],
queryFn: () => api.adminListUserMappings(page, PAGE_SIZE),
enabled,
})
const total = mappings.data?.total ?? 0
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE))
useEffect(() => {
if (!mappings.isFetching && page > pageCount) {
setPage(pageCount)
}
}, [mappings.isFetching, page, pageCount])
const remove = useMutation({
mutationFn: (gh: string) => api.adminDeleteUserMapping(gh),
onSuccess: () => void mappings.refetch(),
onError: (e: Error) => setError(e.message),
})
const upsert = useMutation({
mutationFn: () =>
api.adminUpsertUserMapping({
github_login: newLogin.trim(),
work_email: newEmail.trim(),
}),
onSuccess: () => {
setNewLogin("")
setNewEmail("")
setError(null)
void mappings.refetch()
},
onError: (e: Error) => setError(e.message),
})
const canSubmit =
newLogin.trim().length > 0 &&
newEmail.trim().length > 0 &&
!upsert.isPending
const items = mappings.data?.items ?? []
return (
<SettingsSection
title="User mappings"
description="Mappings link a GitHub login to a work email. They are created automatically when users connect Slack from settings; admins can also add or remove them here."
>
<div className="flex flex-col gap-3 p-4">
{error && <span className="text-xs text-destructive">{error}</span>}
<form
className="flex flex-col gap-2 sm:flex-row sm:items-center"
onSubmit={(e) => {
e.preventDefault()
if (canSubmit) upsert.mutate()
}}
>
<Input
value={newLogin}
onChange={(e) => setNewLogin(e.target.value)}
placeholder="github-login"
aria-label="GitHub login"
className="h-8 text-xs"
/>
<Input
value={newEmail}
onChange={(e) => setNewEmail(e.target.value)}
placeholder="work@example.com"
aria-label="Work email"
type="email"
className="h-8 text-xs"
/>
<Button type="submit" size="sm" disabled={!canSubmit}>
{upsert.isPending ? "Saving…" : "Add / update"}
</Button>
</form>
<div className="flex flex-col gap-0.5">
{mappings.isLoading ? (
<Skeleton className="h-32" />
) : !items.length ? (
<p className="text-xs text-muted-foreground">No mappings yet.</p>
) : (
items.map((m: UserMapping) => (
<div
key={m.github_login}
className="flex items-center justify-between gap-2 border-b border-border py-1.5 text-xs last:border-b-0"
>
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium">{m.github_login}</span>
<span className="truncate text-xs text-muted-foreground">
{m.work_email}
{m.slack_user_id ? ` · ${m.slack_user_id}` : ""}
{m.source ? ` · ${m.source}` : ""}
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => remove.mutate(m.github_login)}
disabled={remove.isPending}
>
Remove
</Button>
</div>
))
)}
</div>
{total > PAGE_SIZE && (
<div className="flex items-center justify-between pt-1 text-xs text-muted-foreground">
<span>
{total} mapping{total === 1 ? "" : "s"} · page {page} of{" "}
{pageCount}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1 || mappings.isFetching}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(pageCount, p + 1))}
disabled={page >= pageCount || mappings.isFetching}
>
Next
</Button>
</div>
</div>
)}
</div>
</SettingsSection>
)
}
function ObservabilityCredentialsSection() {
const qc = useQueryClient()
const creds = useQuery({
queryKey: ["teamCredentials"],
queryFn: api.getTeamCredentials,
})
const [error, setError] = useState<string | null>(null)
const [ddSite, setDdSite] = useState("datadoghq.com")
const [ddApiKey, setDdApiKey] = useState("")
const [ddAppKey, setDdAppKey] = useState("")
const [lsApiKey, setLsApiKey] = useState("")
const [lsEndpoint, setLsEndpoint] = useState("")
const onError = (e: Error) => setError(e.message)
const onSuccess = (
saved: Awaited<ReturnType<typeof api.getTeamCredentials>>
) => {
qc.setQueryData(["teamCredentials"], saved)
setError(null)
}
const connectDd = useMutation({
mutationFn: (body: DatadogConnectBody) => api.connectDatadog(body),
onSuccess: (saved) => {
onSuccess(saved)
setDdApiKey("")
setDdAppKey("")
},
onError,
})
const disconnectDd = useMutation({
mutationFn: () => api.disconnectDatadog(),
onSuccess,
onError,
})
const connectLs = useMutation({
mutationFn: (body: LangSmithConnectBody) => api.connectLangSmith(body),
onSuccess: (saved) => {
onSuccess(saved)
setLsApiKey("")
},
onError,
})
const disconnectLs = useMutation({
mutationFn: () => api.disconnectLangSmith(),
onSuccess,
onError,
})
const datadog = creds.data?.datadog
const langsmith = creds.data?.langsmith
const busy = creds.isLoading
return (
<SettingsSection
title="Observability credentials"
description="Team-wide Datadog and LangSmith credentials. Stored encrypted server-side and never exposed to the sandbox. Connecting enables read-only observability tools for agent runs."
>
<div className="divide-y divide-border">
<SettingsRow
label="Datadog"
description={
datadog?.connected
? `Connected · ${datadog.site ?? ""} · key ••••${datadog.api_key_last4 ?? ""}`
: "Connect Datadog to enable read-only metrics, logs, traces, and monitor tools."
}
control={
datadog?.connected ? (
<Button
variant="outline"
size="sm"
onClick={() => disconnectDd.mutate()}
disabled={disconnectDd.isPending}
>
Disconnect
</Button>
) : (
<div className="flex flex-col items-end gap-2">
<Input
className="w-56"
placeholder="datadoghq.com"
value={ddSite}
onChange={(e) => setDdSite(e.target.value)}
disabled={busy}
/>
<Input
className="w-56"
placeholder="API key"
type="password"
value={ddApiKey}
onChange={(e) => setDdApiKey(e.target.value)}
disabled={busy}
/>
<Input
className="w-56"
placeholder="Application key"
type="password"
value={ddAppKey}
onChange={(e) => setDdAppKey(e.target.value)}
disabled={busy}
/>
<Button
size="sm"
onClick={() =>
connectDd.mutate({
site: ddSite.trim(),
api_key: ddApiKey.trim(),
app_key: ddAppKey.trim(),
})
}
disabled={
connectDd.isPending ||
!ddSite.trim() ||
!ddApiKey.trim() ||
!ddAppKey.trim()
}
>
Connect
</Button>
</div>
)
}
/>
<SettingsRow
label="LangSmith"
description={
langsmith?.connected
? `Connected · key ••••${langsmith.api_key_last4 ?? ""}${langsmith.endpoint ? ` · ${langsmith.endpoint}` : ""}`
: "Connect LangSmith to enable read-only trace and run lookup tools."
}
control={
langsmith?.connected ? (
<Button
variant="outline"
size="sm"
onClick={() => disconnectLs.mutate()}
disabled={disconnectLs.isPending}
>
Disconnect
</Button>
) : (
<div className="flex flex-col items-end gap-2">
<Input
className="w-56"
placeholder="API key"
type="password"
value={lsApiKey}
onChange={(e) => setLsApiKey(e.target.value)}
disabled={busy}
/>
<Input
className="w-56"
placeholder="Endpoint (optional)"
value={lsEndpoint}
onChange={(e) => setLsEndpoint(e.target.value)}
disabled={busy}
/>
<Button
size="sm"
onClick={() =>
connectLs.mutate({
api_key: lsApiKey.trim(),
endpoint: lsEndpoint.trim() || null,
})
}
disabled={connectLs.isPending || !lsApiKey.trim()}
>
Connect
</Button>
</div>
)
}
/>
</div>
{error && <p className="px-4 pb-3 text-xs text-destructive">{error}</p>}
</SettingsSection>
)
}
type GatewayMode = "inherit" | "enabled" | "disabled"
function gatewayMode(value: boolean | null | undefined): GatewayMode {
if (value === true) return "enabled"
if (value === false) return "disabled"
return "inherit"
}
function gatewayModeValue(mode: GatewayMode): boolean | null {
if (mode === "enabled") return true
if (mode === "disabled") return false
return null
}
function LLMGatewaySection() {
const qc = useQueryClient()
const settings = useQuery({
queryKey: ["teamSettings"],
queryFn: api.getTeamSettings,
})
const [error, setError] = useState<string | null>(null)
const save = useMutation({
mutationFn: (body: TeamSettings) => api.saveTeamSettings(body),
onSuccess: (saved) => {
qc.setQueryData(["teamSettings"], saved)
setError(null)
},
onError: (e: Error) => setError(e.message),
})
const mode = gatewayMode(settings.data?.gateway_enabled)
return (
<SettingsSection
title="LLM Gateway"
description="Route agent and reviewer LLM calls through the LangSmith LLM Gateway. It authenticates with the workspace LangSmith API key and resolves provider keys from Provider Secrets, so no provider keys are needed at runtime. Requires the gateway (private beta) enabled for your organization."
>
<div className="divide-y divide-border">
<SettingsRow
label="Route through the gateway"
description="Inherit uses the LANGSMITH_GATEWAY_ENABLED deployment default. OpenAI, Anthropic, Fireworks, and Google Gemini are routed through the gateway. Bedrock (AWS) calls the provider directly and is not routed; other providers not listed also call the provider directly."
control={
<Select
value={mode}
onValueChange={(next) =>
settings.data &&
save.mutate({
...settings.data,
gateway_enabled: gatewayModeValue(next as GatewayMode),
})
}
disabled={!settings.data || save.isPending}
>
<SelectTrigger className="w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="inherit">Inherit deployment default</SelectItem>
<SelectItem value="enabled">Enabled</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem>
</SelectContent>
</Select>
}
/>
</div>
{error && <p className="px-4 pb-3 text-xs text-destructive">{error}</p>}
</SettingsSection>
)
}
function PRTraceResolutionSection() {
const qc = useQueryClient()
const settings = useQuery({
queryKey: ["teamSettings"],
queryFn: api.getTeamSettings,
})
const [projectDraft, setProjectDraft] = useState("")
const [error, setError] = useState<string | null>(null)
useEffect(() => {
setProjectDraft(settings.data?.review_tracing_project ?? "")
}, [settings.data?.review_tracing_project])
const save = useMutation({
mutationFn: (body: TeamSettings) => api.saveTeamSettings(body),
onSuccess: (saved) => {
qc.setQueryData(["teamSettings"], saved)
setError(null)
},
onError: (e: Error) => setError(e.message),
})
const savedProject = settings.data?.review_tracing_project ?? ""
const projectDirty = projectDraft.trim() !== savedProject
const saveProject = () => {
if (!settings.data || !projectDirty) return
save.mutate({
...settings.data,
review_tracing_project: projectDraft.trim() || null,
})
}
return (
<SettingsSection
title="PR Trace Resolution"
description="Allow Open SWE Review to resolve PRs to author coding-agent traces in a configured LangSmith project. Requires connected LangSmith credentials."
>
<div className="divide-y divide-border">
<SettingsRow
label="Tracing project"
description="LangSmith project name or ID to search for author traces. Leave blank to disable trace resolution."
control={
<div className="flex items-center gap-2">
<Input
className="w-64"
placeholder="Project name or ID"
value={projectDraft}
onChange={(e) => setProjectDraft(e.target.value)}
onBlur={saveProject}
disabled={!settings.data || save.isPending}
/>
<Button
size="sm"
variant="outline"
onClick={saveProject}
disabled={!settings.data || !projectDirty || save.isPending}
>
Save
</Button>
</div>
}
/>
</div>
{error && <p className="px-4 pb-3 text-xs text-destructive">{error}</p>}
</SettingsSection>
)
}
function GlobalDefaultsSection({ models }: { models: Array<ModelOption> }) {
const qc = useQueryClient()
const settings = useQuery({
queryKey: ["teamSettings"],
queryFn: api.getTeamSettings,
})
const [error, setError] = useState<string | null>(null)
const [defaultRepoDraft, setDefaultRepoDraft] = useState("")
useEffect(() => {
setDefaultRepoDraft(settings.data?.default_repo ?? "")
}, [settings.data?.default_repo])
const save = useMutation({
mutationFn: (body: TeamSettings) => api.saveTeamSettings(body),
onSuccess: (saved) => {
qc.setQueryData(["teamSettings"], saved)
setError(null)
},
onError: (e: Error) => setError(e.message),
})
return (
<SettingsSection
title="Global defaults"
description="Workspace-wide model defaults. Per-user Cloud Agent selections override the agent defaults."
>
<div className="divide-y divide-border">
<RolePicker
label="Open SWE Agent"
description="Model used for code-writing runs triggered from Slack, Linear, GitHub, and the Open SWE Agent."
models={models}
model={settings.data?.default_agent_model ?? null}
effort={settings.data?.default_agent_reasoning_effort ?? null}
onChange={(model, effort) =>
settings.data &&
save.mutate({
...settings.data,
default_agent_model: model,
default_agent_reasoning_effort: effort,
})
}
disabled={!settings.data || save.isPending}
/>
<RolePicker
label="Open SWE Agent subagents"
description="Model used by delegated main-agent tasks."
models={models}
model={settings.data?.default_agent_subagent_model ?? null}
effort={
settings.data?.default_agent_subagent_reasoning_effort ?? null
}
onChange={(model, effort) =>
settings.data &&
save.mutate({
...settings.data,
default_agent_subagent_model: model,
default_agent_subagent_reasoning_effort: effort,
})
}
disabled={!settings.data || save.isPending}
/>
<SettingsRow
label="Default Repository"
description="Global fallback used when a run has no explicit repo and the user has no profile default. Use owner/repo."
control={
<Input
className="w-56"
placeholder="owner/repo"
value={defaultRepoDraft}
onChange={(e) => setDefaultRepoDraft(e.target.value)}
onBlur={() =>
settings.data &&
save.mutate({
...settings.data,
default_repo: defaultRepoDraft.trim() || null,
})
}
disabled={!settings.data || save.isPending}
/>
}
/>
<RolePicker
label="Open SWE Reviewer"
description="Model used for PR review runs."
models={models}
model={settings.data?.default_reviewer_model ?? null}
effort={settings.data?.default_reviewer_reasoning_effort ?? null}
onChange={(model, effort) =>
settings.data &&
save.mutate({
...settings.data,
default_reviewer_model: model,
default_reviewer_reasoning_effort: effort,
})
}
disabled={!settings.data || save.isPending}
/>
<RolePicker
label="Open SWE Reviewer subagents"
description="Model used by delegated reviewer tasks."
models={models}
model={settings.data?.default_reviewer_subagent_model ?? null}
effort={
settings.data?.default_reviewer_subagent_reasoning_effort ?? null
}
onChange={(model, effort) =>
settings.data &&
save.mutate({
...settings.data,
default_reviewer_subagent_model: model,
default_reviewer_subagent_reasoning_effort: effort,
})
}
disabled={!settings.data || save.isPending}
/>
<RolePicker
label="Open SWE Review Diff Grouping"
description="Model used for the review's 'AI sorted' view that groups changed files into a logical walkthrough. Inherits the Reviewer subagent default when unset."
models={models}
model={settings.data?.default_grouping_model ?? null}
effort={settings.data?.default_grouping_reasoning_effort ?? null}
inheritLabel="Reviewer subagent default"
onInherit={() =>
settings.data &&
save.mutate({
...settings.data,
default_grouping_model: null,
default_grouping_reasoning_effort: null,
})
}
onChange={(model, effort) =>
settings.data &&
save.mutate({
...settings.data,
default_grouping_model: model,
default_grouping_reasoning_effort: effort,
})
}
disabled={!settings.data || save.isPending}
/>
<RolePicker
label="Open SWE Review Chat"
description="Model used by the 'chat with this PR' assistant on the review page. Inherits the Agent default when unset."
models={models}
model={settings.data?.default_chat_model ?? null}
effort={settings.data?.default_chat_reasoning_effort ?? null}
inheritLabel="Agent default"
onInherit={() =>
settings.data &&
save.mutate({
...settings.data,
default_chat_model: null,
default_chat_reasoning_effort: null,
})
}
onChange={(model, effort) =>
settings.data &&
save.mutate({
...settings.data,
default_chat_model: model,
default_chat_reasoning_effort: effort,
})
}
disabled={!settings.data || save.isPending}
/>
</div>
{error && <p className="px-4 pb-3 text-xs text-destructive">{error}</p>}
</SettingsSection>
)
}
interface RolePickerProps {
label: string
description: string
models: Array<ModelOption>
model: string | null
effort: string | null
onChange: (model: string, effort: string) => void
disabled: boolean
/**
* When set, the model dropdown gains a leading "inherit" option with this
* label. Selecting it calls {@link onInherit} (clearing the override); an
* unset `model` renders as this option.
*/
inheritLabel?: string
onInherit?: () => void
}
const INHERIT_VALUE = "__inherit__"
function RolePicker({
label,
description,
models,
model,
effort,
onChange,
disabled,
inheritLabel,
onInherit,
}: RolePickerProps) {
const inheritFallback = inheritLabel ? INHERIT_VALUE : ""
const [localModel, setLocalModel] = useState<string>(model ?? inheritFallback)
const [localEffort, setLocalEffort] = useState<string>(effort ?? "")
useEffect(() => {
setLocalModel(model ?? inheritFallback)
setLocalEffort(effort ?? "")
}, [model, effort, inheritFallback])
const isInherit = localModel === INHERIT_VALUE
const selectedModel = models.find((m) => m.id === localModel)
const availableEfforts = selectedModel?.efforts ?? []
const handleModelChange = (value: string | null) => {
if (!value) return
if (value === INHERIT_VALUE) {
setLocalModel(INHERIT_VALUE)
setLocalEffort("")
onInherit?.()
return
}
const nextModel = models.find((m) => m.id === value)
if (!nextModel) return
const nextEffort = nextModel.efforts.includes(localEffort)
? localEffort
: nextModel.default_effort
setLocalModel(value)
setLocalEffort(nextEffort)
onChange(value, nextEffort)
}
const handleEffortChange = (value: string | null) => {
if (!value || !localModel || isInherit) return
setLocalEffort(value)
onChange(localModel, value)
}
return (
<SettingsRow
label={label}
description={description}
control={
<div className="flex items-center gap-2">
<Select
value={localModel}
onValueChange={handleModelChange}
disabled={disabled}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{inheritLabel && (
<SelectItem value={INHERIT_VALUE}>{inheritLabel}</SelectItem>
)}
{models.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={localEffort}
onValueChange={handleEffortChange}
disabled={disabled || !localModel || isInherit}
>
<SelectTrigger className="w-28">
<SelectValue placeholder="effort" />
</SelectTrigger>
<SelectContent>
{availableEfforts.map((e) => (
<SelectItem key={e} value={e}>
{e}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
}
/>
)
}