@saadnvd1/agent-os 0.3.471 → 0.3.472

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +100 -0
  2. package/app/api/models/route.ts +34 -0
  3. package/app/api/models/test/route.ts +17 -0
  4. package/app/api/sessions/[id]/summarize/route.ts +4 -1
  5. package/app/api/tasks/[id]/model/route.ts +23 -0
  6. package/app/api/tasks/route.ts +5 -1
  7. package/app/api/usage/compare/route.ts +21 -0
  8. package/app/api/usage/route.ts +7 -4
  9. package/app/api/workspaces/[id]/models/route.ts +27 -0
  10. package/components/CommandPalette/useAppCommands.ts +19 -1
  11. package/components/Draft/DraftChips.tsx +28 -0
  12. package/components/Draft/DraftPanel.tsx +1 -1
  13. package/components/Models/ModelSelect.tsx +67 -0
  14. package/components/Models/ModelsSection.tsx +199 -0
  15. package/components/Models/ProviderCard.tsx +271 -0
  16. package/components/SessionList/SidebarHeader.tsx +5 -1
  17. package/components/Settings/SettingsDialog.test.ts +3 -0
  18. package/components/Settings/SettingsDialog.tsx +9 -0
  19. package/components/Settings/commands.ts +19 -0
  20. package/components/Settings/entry-points.test.ts +1 -0
  21. package/components/Tasks/ResumeOnModel.tsx +72 -0
  22. package/components/Tasks/TaskRow.tsx +4 -0
  23. package/components/Usage/CompareView.tsx +211 -0
  24. package/components/Usage/ModelSpend.tsx +133 -0
  25. package/components/Usage/UsageDialog.tsx +38 -3
  26. package/components/Usage/format.ts +9 -0
  27. package/components/Workspaces/WorkspaceModelsForm.tsx +71 -0
  28. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  29. package/data/models/index.ts +125 -0
  30. package/data/usage/index.ts +3 -0
  31. package/lib/agents/launch.ts +11 -2
  32. package/lib/chat/context.ts +1 -1
  33. package/lib/chat/runner.ts +5 -0
  34. package/lib/chat/worker/extras.ts +12 -0
  35. package/lib/chat/worker/host.ts +13 -2
  36. package/lib/chat/worker/main.ts +5 -2
  37. package/lib/db/migrations.ts +28 -0
  38. package/lib/db/types.ts +6 -0
  39. package/lib/drafts.ts +3 -0
  40. package/lib/models/config.ts +218 -0
  41. package/lib/models/launch.ts +93 -0
  42. package/lib/models/plan-step.ts +94 -0
  43. package/lib/models/providers.ts +163 -0
  44. package/lib/models/resolve.ts +246 -0
  45. package/lib/models/resume.ts +99 -0
  46. package/lib/models/roles.ts +98 -0
  47. package/lib/models/secrets.ts +86 -0
  48. package/lib/models/session-model.ts +40 -0
  49. package/lib/models/test-provider.ts +116 -0
  50. package/lib/orchestrator/act.ts +13 -1
  51. package/lib/orchestrator/brief.ts +2 -1
  52. package/lib/orchestrator/claude-cli.ts +44 -15
  53. package/lib/orchestrator/home.ts +2 -2
  54. package/lib/orchestrator/review.ts +2 -0
  55. package/lib/orchestrator/scope.ts +2 -0
  56. package/lib/orchestrator/serve.ts +13 -0
  57. package/lib/orchestrator/tool-names.ts +1 -0
  58. package/lib/orchestrator/tool-schemas.ts +38 -0
  59. package/lib/orchestrator/tools.ts +3 -1
  60. package/lib/pr-generation.ts +5 -2
  61. package/lib/providers/registry.ts +1 -1
  62. package/lib/session-titles.ts +1 -1
  63. package/lib/sessions/launch.ts +19 -4
  64. package/lib/settings-url.ts +1 -0
  65. package/lib/tasks/index.ts +59 -8
  66. package/lib/tasks/queue.ts +14 -5
  67. package/lib/tasks/start.ts +7 -2
  68. package/lib/usage/by-model.ts +105 -0
  69. package/lib/usage/check-runs.ts +5 -2
  70. package/lib/usage/compare.ts +280 -0
  71. package/lib/usage/cost.ts +71 -0
  72. package/lib/usage/report.ts +26 -0
  73. package/lib/usage/turns.ts +79 -18
  74. package/package.json +1 -1
  75. package/stores/usageUi.ts +11 -2
package/README.md CHANGED
@@ -33,6 +33,17 @@ sign-off. A workspace's running task limit (Settings → Workspaces) caps how ma
33
33
  wait under **Queued** with their place in line and start on their own, and
34
34
  `aos task --after` holds one until another finishes. [More](#tasks)
35
35
 
36
+ ### Each kind of work on the right model
37
+
38
+ Planning and gate reviews run on Opus, implementation, the orchestrator and
39
+ scope checks on Sonnet, titles and summaries on Haiku, each editable in
40
+ Settings → Models and per workspace. A task plans on one model and
41
+ implements on another in the same conversation, can be resumed on any
42
+ model, and can run on another provider (DeepSeek, GLM, any
43
+ Anthropic-compatible endpoint) whose key stays a reference.
44
+ **Usage → Compare** shows Claude-only and routed tasks side by side.
45
+ [More](#model-routing)
46
+
36
47
  ![A task's chat: the edit, the tests, the code review and the PR it opened](screenshots/task-chat.png)
37
48
 
38
49
  ![Two queued tasks in the sidebar, one in line and one waiting on another task, with its menu open](screenshots/queued.png)
@@ -437,6 +448,95 @@ and the same actions in each row's menu; a row becomes the task's own row
437
448
  when it starts. Why it works
438
449
  this way: [docs/decisions/2026-10-10-queued-tasks.md](docs/decisions/2026-10-10-queued-tasks.md).
439
450
 
451
+ ## Model routing
452
+
453
+ Each kind of work AgentOS launches an agent for runs on its own model, set in
454
+ **Settings → Models** (⌘K → Model settings). A model is named
455
+ `provider/model`:
456
+
457
+ | Role | Default |
458
+ | --------------------------------------------------- | ------------------------------------- |
459
+ | Task planning (a task's plan step) | `anthropic/claude-opus-5-5` |
460
+ | Gate review | `anthropic/claude-opus-5-5` |
461
+ | Task implementation | `anthropic/claude-sonnet-5-5` |
462
+ | Orchestrator | `anthropic/claude-sonnet-5-5` |
463
+ | Scope check | `anthropic/claude-sonnet-5-5` |
464
+ | Interactive sessions | `anthropic/claude-opus-5-5` |
465
+ | Chat & summary helpers (titles, summaries, PR text) | `anthropic/claude-haiku-4-5-20251001` |
466
+
467
+ The most specific setting wins: a task's own model, then its workspace's
468
+ (**Settings → Workspaces → Models**), then Settings, then the default.
469
+ `AGENTOS_REVIEW_MODEL` (gate review and scope check) and
470
+ `AGENTOS_TITLE_MODEL` (helpers) in the server's environment still replace a
471
+ role's default where Settings names none. A
472
+ Claude session started without a model (the orchestrator's `start_session`,
473
+ the API) runs on the interactive role's; one you pick a model for keeps it.
474
+
475
+ **Plan, then implement.** A chat task whose planning and implementation
476
+ models differ starts with a plan step: its first turn runs on the planning
477
+ model and writes the plan to `.agentos/plan.md` in its worktree (ignored by
478
+ git), changing nothing else. When that turn ends, the same conversation
479
+ switches to the implementation model (a live model switch, or a resume with
480
+ `--model` when the provider changes) and is told to follow the plan. Turn it
481
+ off in Settings, or per task with `plan: false`. Terminal tasks skip it.
482
+
483
+ **Per task.** The task draft has a **Model** chip ("Routed by role", or one
484
+ model); `POST /api/tasks` and the orchestrator's `start_task` take `model`, as
485
+ `"provider/model"` for the task's own roles or a map such as
486
+ `{"implement": "zai/glm-5.3"}`, and `plan`. Its gate review and scope check
487
+ never follow a task's model, only Settings and its workspace, so work can't
488
+ pick its own reviewer. **Resume on model…** on a task's row
489
+ (or `POST /api/tasks/<id>/model`, or the orchestrator's `set_task_model`)
490
+ moves a paused task to another model: same conversation, same worktree, and
491
+ it keeps that model from then on. Each turn records the model, provider and
492
+ role it ran as.
493
+
494
+ **Providers.** `anthropic` is your Claude login and needs no key. Others are
495
+ Anthropic-compatible endpoints (OpenAI-compatible ones can be defined and
496
+ tested, but Claude Code can't run on them): a base URL, where the key lives,
497
+ the models, and Claude Code's opus/sonnet/haiku tiers mapped to them so
498
+ subagents and background calls stay on that provider. DeepSeek
499
+ (`https://api.deepseek.com/anthropic`) and Z.ai GLM
500
+ (`https://api.z.ai/api/anthropic`) ship as presets, off until given a key.
501
+ Claude stays the default: another provider runs only where a role or a task
502
+ picks it. If a role's provider is turned off or its key can't be read,
503
+ helper runs, reviews and the orchestrator fall back to the role's default
504
+ rather than fail. **Test provider** sends one tiny request and says whether it
505
+ answered. Changing models, providers or the test runs only from this machine
506
+ or over Tailscale, never from a paired device: a provider's URL and key
507
+ reference decide where a key is sent.
508
+
509
+ The key is never stored by AgentOS. A provider names where to read it: a
510
+ tokenvault entry (`tv get <project> <desc>`), a macOS Keychain item
511
+ (`security find-generic-password -s <service> -w`) or an environment
512
+ variable named `*_API_KEY` (not `ANTHROPIC_`, `CLAUDE_` or `AGENTOS_`). It's read when an agent launches and goes only into that agent's
513
+ environment (`ANTHROPIC_BASE_URL` with `ANTHROPIC_AUTH_TOKEN` or
514
+ `ANTHROPIC_API_KEY`), never its command line, the database, a prompt or a
515
+ log; a terminal task's shell reads it from the reference itself. Errors that
516
+ might echo a key are scrubbed of it.
517
+
518
+ **Usage by model.** **Usage → By model** shows tokens and estimated cost per
519
+ role and per model in the plan's 5-hour window and the last 24 hours, from
520
+ the price table in Settings → Models (dollars per million input, cache
521
+ write, cache read and output tokens, editable).
522
+
523
+ **How to read the comparison.** **Usage → Compare** (or ⌘K → Model
524
+ comparison, or the sidebar menu) puts Claude-only tasks (the baseline,
525
+ including everything from before routing) beside routed ones, per provider,
526
+ over 7, 30 or 90 days, with a total column. A task counts under the provider
527
+ its own planning and implementation turns ran on; its reviews and checks add
528
+ to its cost wherever they ran. For each column: tasks, turns, tokens,
529
+ estimated dollars (list prices; for Claude, what the API would charge),
530
+ Claude window points per task (how far each turn moved the plan's 5-hour
531
+ window, an estimate), agent time, CI green on the first push, review passed
532
+ on the first try, gate failures and rework commits per task (heads checked
533
+ after the first review), and merged or dropped. Outcomes come from the
534
+ orchestrator's checks, so tasks it didn't run have cost but no outcome. A
535
+ column with fewer than 10 tasks is marked a small sample: a hint, not a
536
+ result. Parity means the routed column's outcome rows match the baseline's
537
+ while its window points per task drop. Why it works this way:
538
+ [docs/decisions/2026-10-11-model-routing.md](docs/decisions/2026-10-11-model-routing.md).
539
+
440
540
  ## New sessions
441
541
 
442
542
  ⌥N (the **+** on a phone) opens a draft: a composer in the current project, or
@@ -0,0 +1,34 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import {
3
+ effectiveModels,
4
+ savedModelSettings,
5
+ setModelSettings,
6
+ } from "@/lib/models/config";
7
+ import { DEFAULT_ROLE_MODELS } from "@/lib/models/roles";
8
+ import { requireLocalTrust } from "@/lib/security/route-guard";
9
+
10
+ // GET /api/models - model routing: what's saved, what's in force, and the
11
+ // role defaults. Providers carry key references only, never a key.
12
+ export async function GET() {
13
+ return NextResponse.json({
14
+ saved: savedModelSettings(),
15
+ effective: effectiveModels(),
16
+ defaults: DEFAULT_ROLE_MODELS,
17
+ });
18
+ }
19
+
20
+ // PUT /api/models {roles?, providers?, prices?, planStep?} - replaces the
21
+ // saved settings. Only from this machine or the tailnet: a provider's base
22
+ // URL and key reference decide where a key is sent, so a stolen device
23
+ // token mustn't be able to point them anywhere.
24
+ export async function PUT(request: NextRequest) {
25
+ const refused = requireLocalTrust(request);
26
+ if (refused) return refused;
27
+ try {
28
+ setModelSettings(await request.json());
29
+ return GET();
30
+ } catch (error) {
31
+ const message = error instanceof Error ? error.message : String(error);
32
+ return NextResponse.json({ error: message }, { status: 400 });
33
+ }
34
+ }
@@ -0,0 +1,17 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { testProvider } from "@/lib/models/test-provider";
3
+ import { requireLocalTrust } from "@/lib/security/route-guard";
4
+
5
+ // POST /api/models/test {provider, model?} - one tiny request to the
6
+ // provider with its key; says whether it answered, never the key. It sends
7
+ // a key, so only from this machine or the tailnet.
8
+ export async function POST(request: NextRequest) {
9
+ const refused = requireLocalTrust(request);
10
+ if (refused) return refused;
11
+ const { provider, model } = await request.json().catch(() => ({}));
12
+ if (typeof provider !== "string" || !provider)
13
+ return NextResponse.json({ error: "Name a provider" }, { status: 400 });
14
+ return NextResponse.json(
15
+ await testProvider(provider, typeof model === "string" ? model : undefined)
16
+ );
17
+ }
@@ -1,3 +1,4 @@
1
+ import { routedRun } from "@/lib/models/launch";
1
2
  import { NextRequest, NextResponse } from "next/server";
2
3
  import { isRemoteHost } from "@/lib/hosts";
3
4
  import { spawn } from "child_process";
@@ -130,8 +131,10 @@ async function getTmuxCwd(sessionName: string): Promise<string | null> {
130
131
  async function generateSummary(conversation: string): Promise<string> {
131
132
  const prompt = `Summarize this Claude Code conversation in under 300 words. Focus on: what was built, key files changed, current state, and any pending work. Be specific.`;
132
133
 
134
+ const routed = await routedRun("helper");
133
135
  return new Promise((resolve, reject) => {
134
- const claude = spawn("claude", ["-p", prompt], {
136
+ const claude = spawn("claude", ["-p", prompt, "--model", routed.model], {
137
+ env: { ...process.env, ...routed.env },
135
138
  stdio: ["pipe", "pipe", "pipe"],
136
139
  });
137
140
 
@@ -0,0 +1,23 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { resumeOnModel } from "@/lib/models/resume";
3
+
4
+ // POST /api/tasks/:id/model {model, message?} - resume the task on another
5
+ // model ("provider/model" or a map of role to one), keeping its
6
+ // conversation and worktree.
7
+ export async function POST(
8
+ request: NextRequest,
9
+ { params }: { params: Promise<{ id: string }> }
10
+ ) {
11
+ const { model, message } = await request.json().catch(() => ({}));
12
+ try {
13
+ const result = await resumeOnModel(
14
+ (await params).id,
15
+ model,
16
+ typeof message === "string" && message.trim() ? message : undefined
17
+ );
18
+ return NextResponse.json(result);
19
+ } catch (error) {
20
+ const message = error instanceof Error ? error.message : String(error);
21
+ return NextResponse.json({ error: message }, { status: 409 });
22
+ }
23
+ }
@@ -1,3 +1,4 @@
1
+ import { parseOverride } from "@/lib/models/resolve";
1
2
  import { NextRequest, NextResponse } from "next/server";
2
3
  import { createTask, listTasks, movedTasks } from "@/lib/tasks";
3
4
  import { startOrQueue } from "@/lib/tasks/queue";
@@ -33,6 +34,7 @@ export async function POST(request: NextRequest) {
33
34
  baseBranch,
34
35
  after,
35
36
  view,
37
+ plan,
36
38
  } = await request.json();
37
39
  if (id !== undefined && !isSessionId(id)) throw new Error("Bad task id");
38
40
  const pid =
@@ -42,7 +44,9 @@ export async function POST(request: NextRequest) {
42
44
  const opts = {
43
45
  projectId: pid,
44
46
  prompt: String(prompt ?? ""),
45
- model,
47
+ // Checked now, so a queued task can't fail on it later.
48
+ model: parseOverride(model) ?? undefined,
49
+ plan: typeof plan === "boolean" ? plan : undefined,
46
50
  name: typeof name === "string" ? name : undefined,
47
51
  hostId: typeof hostId === "string" && hostId ? hostId : undefined,
48
52
  baseBranch: typeof baseBranch === "string" ? baseBranch : undefined,
@@ -0,0 +1,21 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { comparisonReport } from "@/lib/usage/compare";
3
+
4
+ const DAY = 24 * 3600 * 1000;
5
+
6
+ // GET /api/usage/compare?days=30 (or from=&to= in ms) - Claude-only tasks
7
+ // against routed ones: cost and outcomes per provider, and spend per role
8
+ // and model.
9
+ export async function GET(request: NextRequest) {
10
+ const q = request.nextUrl.searchParams;
11
+ const now = Date.now();
12
+ const days = Math.min(Math.max(Number(q.get("days")) || 30, 1), 365);
13
+ const to = Number(q.get("to")) || now;
14
+ const from = Number(q.get("from")) || to - days * DAY;
15
+ if (from >= to)
16
+ return NextResponse.json(
17
+ { error: "from must be before to" },
18
+ { status: 400 }
19
+ );
20
+ return NextResponse.json(comparisonReport(from, to));
21
+ }
@@ -1,17 +1,20 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { USAGE_RANGES, type UsageRange } from "@/lib/usage/aggregate";
3
- import { checkRunReport, usageReport } from "@/lib/usage/report";
3
+ import { checkRunReport, modelUsage, usageReport } from "@/lib/usage/report";
4
4
  import { usageWindows } from "@/lib/usage/windows";
5
5
 
6
6
  // GET /api/usage?range=today|7d|30d - chat spend by day, session and
7
- // workspace, what the orchestrator's reviews cost by PR, and the account's
8
- // rolling windows
7
+ // workspace, what the orchestrator's reviews cost by PR, the account's
8
+ // rolling windows, and spend by model and role in the 5-hour window and the
9
+ // last 24 hours
9
10
  export async function GET(request: NextRequest) {
10
11
  const asked = request.nextUrl.searchParams.get("range") as UsageRange | null;
11
12
  const range = asked && USAGE_RANGES.includes(asked) ? asked : "7d";
13
+ const windows = await usageWindows();
12
14
  return NextResponse.json({
13
15
  report: usageReport(range),
14
16
  reviews: checkRunReport(range),
15
- windows: await usageWindows(),
17
+ windows,
18
+ models: modelUsage(windows.fiveHour?.resetsIn),
16
19
  });
17
20
  }
@@ -0,0 +1,27 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { setWorkspaceModels, workspaceModels } from "@/lib/models/config";
3
+ import { getWorkspace } from "@/lib/workspaces";
4
+ import { requireLocalTrust } from "@/lib/security/route-guard";
5
+
6
+ type RouteParams = { params: Promise<{ id: string }> };
7
+
8
+ // GET/PUT /api/workspaces/:id/models {roles} - the workspace's own role
9
+ // models, over Settings'.
10
+ export async function GET(_request: NextRequest, { params }: RouteParams) {
11
+ return NextResponse.json(workspaceModels((await params).id));
12
+ }
13
+
14
+ // Writes only from this machine or the tailnet, like Settings' own.
15
+ export async function PUT(request: NextRequest, { params }: RouteParams) {
16
+ const refused = requireLocalTrust(request);
17
+ if (refused) return refused;
18
+ const { id } = await params;
19
+ if (!getWorkspace(id))
20
+ return NextResponse.json({ error: "Workspace not found" }, { status: 404 });
21
+ try {
22
+ return NextResponse.json(setWorkspaceModels(id, await request.json()));
23
+ } catch (error) {
24
+ const message = error instanceof Error ? error.message : String(error);
25
+ return NextResponse.json({ error: message }, { status: 400 });
26
+ }
27
+ }
@@ -17,6 +17,7 @@ import {
17
17
  Plus,
18
18
  Sun,
19
19
  Workflow,
20
+ Scale,
20
21
  } from "lucide-react";
21
22
  import type { Session } from "@/lib/db";
22
23
  import type { PaletteCommand } from "@/lib/palette/registry";
@@ -147,7 +148,24 @@ export function useAppCommands({
147
148
  group: "Go to",
148
149
  keywords: ["cost", "tokens", "limits", "spend", "window"],
149
150
  icon: Gauge,
150
- run: usageUiActions.open,
151
+ run: () => usageUiActions.open(),
152
+ },
153
+ {
154
+ id: "app.model-comparison",
155
+ title: "Model comparison",
156
+ group: "Go to",
157
+ keywords: [
158
+ "models",
159
+ "routing",
160
+ "providers",
161
+ "before after",
162
+ "quality",
163
+ "deepseek",
164
+ "glm",
165
+ "cost",
166
+ ],
167
+ icon: Scale,
168
+ run: () => usageUiActions.open("compare"),
151
169
  },
152
170
  {
153
171
  id: "app.tasks",
@@ -3,6 +3,7 @@
3
3
  import { useEffect } from "react";
4
4
  import {
5
5
  Bot,
6
+ Cpu,
6
7
  FolderGit2,
7
8
  GitBranch,
8
9
  GitPullRequest,
@@ -17,6 +18,7 @@ import { resolveModelForAgent } from "@/lib/model-catalog";
17
18
  import type { GitCheck } from "@/data/git/queries";
18
19
  import { useAgentStatusQuery } from "@/data/agents";
19
20
  import { useProjectMachinesQuery } from "@/data/projects";
21
+ import { modelChoices, useModelsQuery } from "@/data/models";
20
22
  import type { AgentProbe } from "@/lib/agents/probe";
21
23
  import { ChipHeading, ChipItem, ChipMenu, ChipToggle } from "./Chip";
22
24
 
@@ -64,6 +66,7 @@ export function DraftChips({
64
66
  const { data: localProbes } = useAgentStatusQuery();
65
67
  const probes = local ? localProbes : undefined;
66
68
  const base = draft.baseBranch ?? git?.defaultBranch ?? "main";
69
+ const { data: models } = useModelsQuery(draft.openPr);
67
70
  // A project elsewhere runs where it lives; a scratch chat, a task or a
68
71
  // project here can go to another machine.
69
72
  const canMove = !project || draft.openPr || projectHere;
@@ -232,6 +235,31 @@ export function DraftChips({
232
235
  onChange={(openPr) => onChange({ openPr })}
233
236
  />
234
237
  )}
238
+ {project && draft.openPr && local && (
239
+ <ChipMenu
240
+ icon={Cpu}
241
+ name="Model"
242
+ label={draft.taskModel ?? "Routed by role"}
243
+ >
244
+ <ChipItem
245
+ checked={!draft.taskModel}
246
+ onSelect={() => onChange({ taskModel: null })}
247
+ hint="Plans and implements on the role models in Settings"
248
+ >
249
+ Routed by role
250
+ </ChipItem>
251
+ {modelChoices(models?.effective).map((c) => (
252
+ <ChipItem
253
+ key={c.ref}
254
+ checked={c.ref === draft.taskModel}
255
+ onSelect={() => onChange({ taskModel: c.ref })}
256
+ hint={c.provider}
257
+ >
258
+ {c.label}
259
+ </ChipItem>
260
+ ))}
261
+ </ChipMenu>
262
+ )}
235
263
  {project && draft.openPr && (
236
264
  <ChipToggle
237
265
  icon={SquareTerminal}
@@ -65,7 +65,7 @@ export function DraftPanel({
65
65
  const { session, queued } = await createTask.mutateAsync({
66
66
  projectId: project.id,
67
67
  prompt: text,
68
- model: draft.model,
68
+ model: draft.taskModel ?? undefined,
69
69
  baseBranch: draft.baseBranch ?? undefined,
70
70
  hostId: draft.hostId !== project.host_id ? draft.hostId : undefined,
71
71
  view: draft.terminal ? "terminal" : undefined,
@@ -0,0 +1,67 @@
1
+ "use client";
2
+
3
+ import {
4
+ Select,
5
+ SelectContent,
6
+ SelectGroup,
7
+ SelectItem,
8
+ SelectLabel,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ } from "@/components/ui/select";
12
+ import { modelChoices } from "@/data/models";
13
+ import type { EffectiveModels } from "@/lib/models/config";
14
+
15
+ const INHERIT = "__inherit";
16
+
17
+ // A "provider/model" picker over every enabled provider's models, grouped
18
+ // by provider. With `inheritLabel`, a first option that clears it.
19
+ export function ModelSelect({
20
+ id,
21
+ value,
22
+ effective,
23
+ inheritLabel,
24
+ disabled,
25
+ onChange,
26
+ }: {
27
+ id?: string;
28
+ value: string | null;
29
+ effective: EffectiveModels | undefined;
30
+ inheritLabel?: string;
31
+ disabled?: boolean;
32
+ onChange: (ref: string | null) => void;
33
+ }) {
34
+ const choices = modelChoices(effective);
35
+ const groups = [...new Set(choices.map((c) => c.provider))];
36
+ // A saved model no provider lists any more still shows as itself.
37
+ const missing = value && !choices.some((c) => c.ref === value);
38
+ return (
39
+ <Select
40
+ value={value ?? INHERIT}
41
+ onValueChange={(v) => onChange(v === INHERIT ? null : v)}
42
+ disabled={disabled}
43
+ >
44
+ <SelectTrigger id={id} className="h-11 md:h-9">
45
+ <SelectValue />
46
+ </SelectTrigger>
47
+ <SelectContent>
48
+ {inheritLabel && (
49
+ <SelectItem value={INHERIT}>{inheritLabel}</SelectItem>
50
+ )}
51
+ {missing && <SelectItem value={value}>{value}</SelectItem>}
52
+ {groups.map((g) => (
53
+ <SelectGroup key={g}>
54
+ <SelectLabel>{g}</SelectLabel>
55
+ {choices
56
+ .filter((c) => c.provider === g)
57
+ .map((c) => (
58
+ <SelectItem key={c.ref} value={c.ref}>
59
+ {c.label}
60
+ </SelectItem>
61
+ ))}
62
+ </SelectGroup>
63
+ ))}
64
+ </SelectContent>
65
+ </Select>
66
+ );
67
+ }