@saadnvd1/agent-os 0.3.471 → 0.3.473
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.
- package/README.md +100 -0
- package/app/api/models/route.ts +34 -0
- package/app/api/models/test/route.ts +17 -0
- package/app/api/sessions/[id]/summarize/route.ts +4 -1
- package/app/api/tasks/[id]/model/route.ts +23 -0
- package/app/api/tasks/route.ts +5 -1
- package/app/api/usage/compare/route.ts +21 -0
- package/app/api/usage/route.ts +7 -4
- package/app/api/workspaces/[id]/models/route.ts +27 -0
- package/components/CommandPalette/useAppCommands.ts +19 -1
- package/components/Draft/DraftChips.tsx +28 -0
- package/components/Draft/DraftPanel.tsx +1 -1
- package/components/Models/ModelSelect.tsx +67 -0
- package/components/Models/ModelsSection.tsx +199 -0
- package/components/Models/ProviderCard.tsx +271 -0
- package/components/SessionList/SidebarHeader.tsx +5 -1
- package/components/Settings/SettingsDialog.test.ts +3 -0
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Settings/commands.ts +19 -0
- package/components/Settings/entry-points.test.ts +1 -0
- package/components/Tasks/ResumeOnModel.tsx +72 -0
- package/components/Tasks/TaskRow.tsx +4 -0
- package/components/Usage/CompareView.tsx +211 -0
- package/components/Usage/ModelSpend.tsx +133 -0
- package/components/Usage/UsageDialog.tsx +38 -3
- package/components/Usage/format.ts +9 -0
- package/components/Workspaces/WorkspaceModelsForm.tsx +71 -0
- package/components/Workspaces/WorkspaceSettings.tsx +2 -0
- package/data/models/index.ts +125 -0
- package/data/usage/index.ts +3 -0
- package/lib/agents/launch.ts +11 -2
- package/lib/chat/context.ts +1 -1
- package/lib/chat/runner.ts +5 -0
- package/lib/chat/worker/extras.ts +12 -0
- package/lib/chat/worker/host.ts +13 -2
- package/lib/chat/worker/main.ts +5 -2
- package/lib/db/migrations.ts +28 -0
- package/lib/db/types.ts +6 -0
- package/lib/drafts.ts +3 -0
- package/lib/models/config.ts +218 -0
- package/lib/models/launch.ts +93 -0
- package/lib/models/plan-step.ts +94 -0
- package/lib/models/providers.ts +163 -0
- package/lib/models/resolve.ts +246 -0
- package/lib/models/resume.ts +99 -0
- package/lib/models/roles.ts +98 -0
- package/lib/models/secrets.ts +86 -0
- package/lib/models/session-model.ts +40 -0
- package/lib/models/test-provider.ts +116 -0
- package/lib/orchestrator/act.ts +13 -1
- package/lib/orchestrator/brief.ts +2 -1
- package/lib/orchestrator/claude-cli.ts +44 -15
- package/lib/orchestrator/home.ts +2 -2
- package/lib/orchestrator/review.ts +2 -0
- package/lib/orchestrator/scope.ts +2 -0
- package/lib/orchestrator/serve.ts +13 -0
- package/lib/orchestrator/tool-names.ts +1 -0
- package/lib/orchestrator/tool-schemas.ts +43 -0
- package/lib/orchestrator/tools.ts +3 -1
- package/lib/pr-generation.ts +5 -2
- package/lib/providers/registry.ts +1 -1
- package/lib/session-titles.ts +1 -1
- package/lib/sessions/launch.ts +19 -4
- package/lib/settings-url.ts +1 -0
- package/lib/tasks/index.ts +59 -8
- package/lib/tasks/queue.ts +14 -5
- package/lib/tasks/start.ts +7 -2
- package/lib/usage/by-model.ts +105 -0
- package/lib/usage/check-runs.ts +5 -2
- package/lib/usage/compare.ts +280 -0
- package/lib/usage/cost.ts +71 -0
- package/lib/usage/report.ts +26 -0
- package/lib/usage/turns.ts +79 -18
- package/package.json +1 -1
- 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
|

|
|
37
48
|
|
|
38
49
|

|
|
@@ -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
|
+
}
|
package/app/api/tasks/route.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/app/api/usage/route.ts
CHANGED
|
@@ -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,
|
|
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
|
|
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.
|
|
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
|
+
}
|