@saadnvd1/agent-os 0.3.470 → 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 (95) hide show
  1. package/README.md +139 -36
  2. package/app/api/fresh-start/route.ts +9 -5
  3. package/app/api/models/route.ts +34 -0
  4. package/app/api/models/test/route.ts +17 -0
  5. package/app/api/sessions/[id]/summarize/route.ts +4 -1
  6. package/app/api/tasks/[id]/model/route.ts +23 -0
  7. package/app/api/tasks/route.ts +5 -1
  8. package/app/api/usage/compare/route.ts +21 -0
  9. package/app/api/usage/route.ts +8 -3
  10. package/app/api/workspaces/[id]/models/route.ts +27 -0
  11. package/components/Chat/ChatPanel.tsx +5 -5
  12. package/components/Chat/StaleChip.tsx +7 -6
  13. package/components/CommandPalette/useAppCommands.ts +19 -1
  14. package/components/Draft/DraftChips.tsx +28 -0
  15. package/components/Draft/DraftPanel.tsx +1 -1
  16. package/components/Models/ModelSelect.tsx +67 -0
  17. package/components/Models/ModelsSection.tsx +199 -0
  18. package/components/Models/ProviderCard.tsx +271 -0
  19. package/components/SessionList/SidebarHeader.tsx +5 -1
  20. package/components/Settings/SettingsDialog.test.ts +3 -0
  21. package/components/Settings/SettingsDialog.tsx +9 -0
  22. package/components/Settings/commands.ts +19 -0
  23. package/components/Settings/entry-points.test.ts +1 -0
  24. package/components/Tasks/ResumeOnModel.tsx +72 -0
  25. package/components/Tasks/TaskRow.tsx +4 -0
  26. package/components/Usage/CompareView.tsx +211 -0
  27. package/components/Usage/ModelSpend.tsx +133 -0
  28. package/components/Usage/UsageDialog.tsx +69 -5
  29. package/components/Usage/format.ts +9 -0
  30. package/components/Workspaces/FreshStartForm.tsx +13 -13
  31. package/components/Workspaces/WorkspaceModelsForm.tsx +71 -0
  32. package/components/Workspaces/WorkspaceSettings.tsx +2 -0
  33. package/data/models/index.ts +125 -0
  34. package/data/usage/index.ts +5 -0
  35. package/lib/agents/launch.ts +13 -2
  36. package/lib/chat/context.ts +1 -1
  37. package/lib/chat/refresh.ts +31 -13
  38. package/lib/chat/runner.ts +5 -0
  39. package/lib/chat/stale.ts +25 -16
  40. package/lib/chat/worker/extras.ts +12 -0
  41. package/lib/chat/worker/host.ts +70 -24
  42. package/lib/chat/worker/main.ts +5 -2
  43. package/lib/db/migrations.ts +70 -0
  44. package/lib/db/types.ts +6 -0
  45. package/lib/drafts.ts +3 -0
  46. package/lib/models/config.ts +218 -0
  47. package/lib/models/launch.ts +93 -0
  48. package/lib/models/plan-step.ts +94 -0
  49. package/lib/models/providers.ts +163 -0
  50. package/lib/models/resolve.ts +246 -0
  51. package/lib/models/resume.ts +99 -0
  52. package/lib/models/roles.ts +98 -0
  53. package/lib/models/secrets.ts +86 -0
  54. package/lib/models/session-model.ts +40 -0
  55. package/lib/models/test-provider.ts +116 -0
  56. package/lib/orchestrator/act.ts +13 -1
  57. package/lib/orchestrator/armed.ts +1 -2
  58. package/lib/orchestrator/brief.ts +5 -4
  59. package/lib/orchestrator/claude-cli.ts +48 -14
  60. package/lib/orchestrator/diff.ts +1 -1
  61. package/lib/orchestrator/external-gates.ts +8 -43
  62. package/lib/orchestrator/gates.ts +10 -10
  63. package/lib/orchestrator/home.ts +2 -2
  64. package/lib/orchestrator/pr-wake.ts +2 -2
  65. package/lib/orchestrator/rebase.ts +6 -45
  66. package/lib/orchestrator/review.ts +20 -5
  67. package/lib/orchestrator/scope.ts +11 -0
  68. package/lib/orchestrator/serve.ts +13 -0
  69. package/lib/orchestrator/signoff.ts +13 -8
  70. package/lib/orchestrator/tool-names.ts +1 -0
  71. package/lib/orchestrator/tool-schemas.ts +38 -0
  72. package/lib/orchestrator/tools.ts +3 -1
  73. package/lib/pr-generation.ts +5 -2
  74. package/lib/providers/registry.ts +1 -1
  75. package/lib/session-audit/render.ts +1 -1
  76. package/lib/session-titles.ts +1 -1
  77. package/lib/sessions/launch.ts +19 -4
  78. package/lib/settings-url.ts +1 -0
  79. package/lib/stacks/land.ts +23 -16
  80. package/lib/tasks/brief.ts +3 -14
  81. package/lib/tasks/code-review.ts +69 -107
  82. package/lib/tasks/gh.ts +3 -2
  83. package/lib/tasks/index.ts +60 -9
  84. package/lib/tasks/queue.ts +14 -5
  85. package/lib/tasks/start.ts +7 -2
  86. package/lib/tasks/state.ts +4 -4
  87. package/lib/usage/by-model.ts +105 -0
  88. package/lib/usage/check-runs.ts +170 -0
  89. package/lib/usage/compare.ts +280 -0
  90. package/lib/usage/cost.ts +71 -0
  91. package/lib/usage/report.ts +51 -0
  92. package/lib/usage/turns.ts +107 -18
  93. package/package.json +1 -1
  94. package/stores/usageUi.ts +11 -2
  95. package/scripts/check-code-review.ts +0 -21
@@ -29,7 +29,11 @@ import {
29
29
  } from "../store";
30
30
  import { claimNext, enqueue, listQueue, moveToFront } from "../queued";
31
31
  import { isHeld } from "../hold";
32
- import { recordTurn, startingTotals } from "../../usage/turns";
32
+ import {
33
+ recordCompaction,
34
+ recordTurn,
35
+ startingTotals,
36
+ } from "../../usage/turns";
33
37
  import {
34
38
  VISUALS_BRIEF,
35
39
  VISUALS_SERVER,
@@ -37,7 +41,7 @@ import {
37
41
  } from "../../artifacts/tools";
38
42
  import type { RoleExtras } from "./extras";
39
43
  import { refreshSettings, refreshedNote } from "../refresh";
40
- import { staleLabel, staleness, type Staleness } from "../stale";
44
+ import { compactionDue, staleLabel, type Staleness } from "../stale";
41
45
  import type { WorkerCommand, WorkerEvent } from "./protocol";
42
46
 
43
47
  // How soon after a stopped turn ends a turn the agent starts on its own
@@ -72,6 +76,8 @@ export class ChatHost {
72
76
  // resumed conversation's saved totals.
73
77
  private usage: UsageTotals | null;
74
78
  private turnsRecorded = 0;
79
+ // When the running turn started, for its wall time.
80
+ private turnStartedAt: number | null = null;
75
81
  // A permission mode change still on its way to the agent: a message sent
76
82
  // right after it (carrying out a plan) must not overtake it.
77
83
  private modeChange: Promise<void> = Promise.resolve();
@@ -127,15 +133,17 @@ export class ChatHost {
127
133
  private contextBase: number | null = null;
128
134
  // How full the conversation was at its last turn's end (lib/chat/stale).
129
135
  private context: ChatContext | null = null;
130
- // A compaction run ahead of a message to a stale chat: the message waits
131
- // at the front of the queue until it ends (compactFirst).
136
+ // A compaction run ahead of a message (compactFirst): the message waits
137
+ // at the front of the queue until it ends.
132
138
  private compacting: {
133
- idleMs: number;
139
+ // Set when it ran because the chat was stale.
140
+ idleMs?: number;
134
141
  before: number;
135
142
  done?: boolean;
136
143
  } | null = null;
137
144
  // Its line, until the agent's next measure gives the size after.
138
145
  private compactedLine: Extract<ChatItem, { kind: "compacted" }> | null = null;
146
+ private compactedBy = "threshold";
139
147
  // An orchestrator gone stale starts over rather than compacting.
140
148
  private staleRefresh: Staleness | null = null;
141
149
  readonly done: Promise<void>;
@@ -155,7 +163,7 @@ export class ChatHost {
155
163
  throw new Error(`${session.agent_type} can't enforce this role's tools`);
156
164
  this.atRest = driver.atRest !== false;
157
165
  const cwd = session.working_directory.replace(/^~/, os.homedir());
158
- const env = agentEnv(session.id);
166
+ const env = { ...agentEnv(session.id), ...extras.env };
159
167
  // Every chat can show visuals, asked for like any tool under its access
160
168
  // setting (they read files and reach the web). Not an orchestrator: it
161
169
  // reads other sessions' text, and a browser would be a way out for it.
@@ -253,8 +261,10 @@ export class ChatHost {
253
261
  // a failed one printed still shows.
254
262
  if (this.compacting?.done && e.item.kind === "command_output")
255
263
  continue;
256
- if (this.compacting && e.item.kind === "compacted")
257
- e.item = this.compactedItem(e.item);
264
+ if (e.item.kind === "compacted") {
265
+ if (this.compacting) e.item = this.compactedItem(e.item);
266
+ else this.logCompacted(e.item, e.item.trigger ?? "manual");
267
+ }
258
268
  if ("streaming" in e.item && e.item.streaming)
259
269
  this.streaming.set(e.item.id, e.item);
260
270
  else this.streaming.delete(e.item.id);
@@ -282,7 +292,15 @@ export class ChatHost {
282
292
  this.setSuggestion(e.text);
283
293
  this.retireIfDone();
284
294
  } else if (e.type === "usage") {
285
- this.usage = recordTurn(this.session, this.usage, e.totals);
295
+ const at = Date.now();
296
+ this.usage = recordTurn(
297
+ this.session,
298
+ this.usage,
299
+ e.totals,
300
+ at,
301
+ this.turnStartedAt === null ? null : at - this.turnStartedAt
302
+ );
303
+ this.turnStartedAt = null;
286
304
  this.turnsRecorded++;
287
305
  } else if (e.type === "usage_start") {
288
306
  // Too late once a turn was measured without it.
@@ -298,6 +316,7 @@ export class ChatHost {
298
316
  this.refreshIfFull(e.context.usedTokens);
299
317
  } else if (e.type === "turn_start") {
300
318
  this.turnsStarted++;
319
+ this.turnStartedAt ??= Date.now();
301
320
  if (this.behind) this.behind--;
302
321
  clearTimeout(this.restTimer);
303
322
  this.restTimer = undefined;
@@ -663,15 +682,19 @@ export class ChatHost {
663
682
  this.close();
664
683
  }
665
684
 
666
- // A stale chat (lib/chat/stale) is compacted before the message goes in,
667
- // only between turns: the message waits at the front of the queue, and
668
- // the compaction's end sends it. "yes" is the reader's choice, made on
669
- // what the composer showed; "auto" checks for itself.
685
+ // A chat over the compact threshold or stale (lib/chat/stale) is
686
+ // compacted before the message goes in, only between turns: the message
687
+ // waits at the front of the queue, and the compaction's end sends it.
688
+ // "yes" is the reader's choice, made on what the composer showed; "auto"
689
+ // checks for itself.
670
690
  private compactFirst(cmd: Extract<WorkerCommand, { type: "send" }>): boolean {
671
691
  if (!cmd.compact || this.busy() || this.closed || this.handingOver())
672
692
  return false;
673
693
  const stale = this.staleNow(cmd.compact);
674
- if (!stale) return false;
694
+ // An orchestrator starts over at its own threshold (lib/chat/refresh);
695
+ // only one gone stale does it early.
696
+ if (!stale || (this.session.role === "orchestrator" && !stale.stale))
697
+ return false;
675
698
  try {
676
699
  enqueue(this.session.id, cmd);
677
700
  } catch {
@@ -692,13 +715,16 @@ export class ChatHost {
692
715
  this.retireIfDone();
693
716
  return true;
694
717
  }
695
- this.compacting = { idleMs: stale.idleMs, before: stale.tokens };
718
+ this.compacting = {
719
+ idleMs: stale.stale ? stale.idleMs : undefined,
720
+ before: stale.tokens,
721
+ };
696
722
  this.currentTurn = null;
697
723
  this.stopping = null;
698
724
  this.conversation.send("/compact");
699
725
  this.setState("running");
700
726
  console.error(
701
- `${new Date().toISOString()} compacting ${this.session.id} before a message: idle ${Math.round(stale.idleMs / 60_000)}m, ${stale.tokens} tokens`
727
+ `${new Date().toISOString()} compacting ${this.session.id} before a message: ${stale.stale ? `idle ${Math.round(stale.idleMs / 60_000)}m, ` : ""}${stale.tokens} tokens`
702
728
  );
703
729
  return true;
704
730
  }
@@ -707,11 +733,17 @@ export class ChatHost {
707
733
  const context = this.context;
708
734
  // Only an agent that says which cache it uses has one to lose.
709
735
  if (!context?.cacheTtlMs) return null;
710
- if (compact === "yes")
711
- return { idleMs: Date.now() - context.at, tokens: context.usedTokens };
712
- return staleness(context, {
736
+ if (compact === "yes") {
737
+ const idleMs = Date.now() - context.at;
738
+ return {
739
+ idleMs,
740
+ tokens: context.usedTokens,
741
+ stale: idleMs > context.cacheTtlMs,
742
+ };
743
+ }
744
+ return compactionDue(context, {
713
745
  state: this.state,
714
- thresholdTokens: refreshSettings().staleCompactTokens,
746
+ thresholdTokens: refreshSettings().compactTokens,
715
747
  });
716
748
  }
717
749
 
@@ -724,8 +756,9 @@ export class ChatHost {
724
756
  idleMs: this.compacting?.idleMs,
725
757
  before: item.before ?? this.compacting?.before,
726
758
  };
759
+ this.compactedBy = line.idleMs !== undefined ? "stale" : "threshold";
727
760
  this.compactedLine = line.after === undefined ? line : null;
728
- if (line.after !== undefined) this.logCompacted(line);
761
+ if (line.after !== undefined) this.logCompacted(line, this.compactedBy);
729
762
  return line;
730
763
  }
731
764
 
@@ -736,13 +769,26 @@ export class ChatHost {
736
769
  this.compactedLine = null;
737
770
  const settled = { ...line, after: usedTokens };
738
771
  this.record(settled);
739
- this.logCompacted(settled);
772
+ this.logCompacted(settled, this.compactedBy);
740
773
  }
741
774
 
742
- private logCompacted(line: { before?: number; after?: number }): void {
775
+ // Logged, and kept for the usage history (lib/usage/turns).
776
+ private logCompacted(
777
+ line: { before?: number; after?: number },
778
+ trigger: string
779
+ ): void {
743
780
  console.error(
744
- `${new Date().toISOString()} compacted ${this.session.id}: ${line.before ?? "?"} -> ${line.after ?? "?"} tokens`
781
+ `${new Date().toISOString()} compacted ${this.session.id} (${trigger}): ${line.before ?? "?"} -> ${line.after ?? "?"} tokens`
745
782
  );
783
+ try {
784
+ recordCompaction(this.session, {
785
+ trigger,
786
+ before: line.before,
787
+ after: line.after,
788
+ });
789
+ } catch (e) {
790
+ console.error(`recording ${this.session.id}'s compaction:`, e);
791
+ }
746
792
  }
747
793
 
748
794
  // Past its threshold, once: it retires at the next turn boundary, as for
@@ -29,13 +29,16 @@ async function main(sessionId: string) {
29
29
  const { db } = await import("../../db");
30
30
  const { ChatHost } = await import("./host");
31
31
 
32
- const session = db
32
+ const row = db
33
33
  .prepare(`SELECT * FROM sessions WHERE id = ?`)
34
34
  .get(sessionId) as Session | undefined;
35
- if (!session) {
35
+ if (!row) {
36
36
  console.error(`No session ${sessionId}`);
37
37
  process.exit(1);
38
38
  }
39
+ const session = await (
40
+ await import("../../models/launch")
41
+ ).routeOrchestrator(row);
39
42
 
40
43
  const clients = new Set<net.Socket>();
41
44
  const emit = (e: WorkerEvent) => {
@@ -1358,6 +1358,76 @@ const migrations: Migration[] = [
1358
1358
  `);
1359
1359
  },
1360
1360
  },
1361
+ {
1362
+ id: 60,
1363
+ name: "check_runs_and_compactions",
1364
+ up: (db) => {
1365
+ // What each run of the independent reviewer and the scope check cost,
1366
+ // from its claude -p result: it leaves no transcript, so this is the
1367
+ // only record. And each compaction a chat's agent made, so tokens per
1368
+ // task can be compared before and after the threshold moved.
1369
+ db.exec(`
1370
+ CREATE TABLE IF NOT EXISTS check_runs (
1371
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1372
+ workspace_id TEXT NOT NULL,
1373
+ session_id TEXT NOT NULL,
1374
+ label TEXT NOT NULL,
1375
+ pr_number INTEGER,
1376
+ sha TEXT NOT NULL,
1377
+ kind TEXT NOT NULL,
1378
+ model TEXT,
1379
+ input_tokens INTEGER NOT NULL,
1380
+ cache_write_tokens INTEGER NOT NULL,
1381
+ cache_read_tokens INTEGER NOT NULL,
1382
+ output_tokens INTEGER NOT NULL,
1383
+ cost_usd REAL NOT NULL,
1384
+ duration_ms INTEGER,
1385
+ at INTEGER NOT NULL
1386
+ );
1387
+ CREATE INDEX IF NOT EXISTS idx_check_runs_at ON check_runs(at);
1388
+ CREATE INDEX IF NOT EXISTS idx_check_runs_sha ON check_runs(session_id, sha, kind);
1389
+ CREATE TABLE IF NOT EXISTS chat_compactions (
1390
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1391
+ session_id TEXT NOT NULL,
1392
+ session_name TEXT NOT NULL,
1393
+ workspace_id TEXT,
1394
+ at INTEGER NOT NULL,
1395
+ trigger TEXT NOT NULL,
1396
+ before_tokens INTEGER,
1397
+ after_tokens INTEGER
1398
+ );
1399
+ CREATE INDEX IF NOT EXISTS idx_chat_compactions_at ON chat_compactions(at);
1400
+ `);
1401
+ },
1402
+ },
1403
+ {
1404
+ id: 61,
1405
+ name: "model_routing",
1406
+ // One transaction, so a crash part-way leaves nothing to skip as
1407
+ // "duplicate column" on the next start.
1408
+ up: (db) =>
1409
+ db.transaction(() => {
1410
+ // What a session runs on beyond the model name: its provider (NULL is
1411
+ // Anthropic), a task's own models by role (JSON), and a task's plan
1412
+ // step (planning, then implementing). Each turn keeps the model,
1413
+ // provider and role it ran as, how long it took, and the plan's 5-hour
1414
+ // window when it ended, so spend compares by model. A check run keeps the
1415
+ // provider its model came from.
1416
+ for (const col of [
1417
+ `ALTER TABLE sessions ADD COLUMN model_provider TEXT`,
1418
+ `ALTER TABLE sessions ADD COLUMN model_overrides TEXT`,
1419
+ `ALTER TABLE sessions ADD COLUMN plan_phase TEXT`,
1420
+ `ALTER TABLE task_queue ADD COLUMN model_overrides TEXT`,
1421
+ `ALTER TABLE chat_turns ADD COLUMN model TEXT`,
1422
+ `ALTER TABLE chat_turns ADD COLUMN provider TEXT`,
1423
+ `ALTER TABLE chat_turns ADD COLUMN role TEXT`,
1424
+ `ALTER TABLE chat_turns ADD COLUMN duration_ms INTEGER`,
1425
+ `ALTER TABLE chat_turns ADD COLUMN window_pct REAL`,
1426
+ `ALTER TABLE check_runs ADD COLUMN provider TEXT`,
1427
+ ])
1428
+ db.exec(col);
1429
+ })(),
1430
+ },
1361
1431
  ];
1362
1432
 
1363
1433
  // `upTo`: stop after this id (tests that start from an older database).
package/lib/db/types.ts CHANGED
@@ -14,6 +14,12 @@ export interface Session {
14
14
  parent_session_id: string | null;
15
15
  claude_session_id: string | null;
16
16
  model: string;
17
+ // The model's provider (lib/models), NULL for Anthropic.
18
+ model_provider?: string | null;
19
+ // A task's own models: "provider/model", or JSON of role to one.
20
+ model_overrides?: string | null;
21
+ // A task's plan step: planning on the planning model, then implementing.
22
+ plan_phase?: "planning" | "implementing" | null;
17
23
  system_prompt: string | null;
18
24
  group_path: string; // Deprecated - use project_id
19
25
  project_id: string | null;
package/lib/drafts.ts CHANGED
@@ -23,6 +23,9 @@ export interface Draft {
23
23
  openPr: boolean;
24
24
  // A task that runs in a terminal rather than a chat (the default).
25
25
  terminal?: boolean;
26
+ // A task's own model, "provider/model"; unset, its roles' models
27
+ // (lib/models).
28
+ taskModel?: string | null;
26
29
  createdAt: number;
27
30
  }
28
31
 
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Model routing settings, kept in the settings table: the model each role
3
+ * runs on, the providers (the presets, edited or added to), the price table,
4
+ * and whether tasks plan first. A workspace may set its own role models.
5
+ * Nothing here is ever a key: a provider names where its key lives.
6
+ */
7
+
8
+ import { z } from "zod";
9
+ import { db } from "../db";
10
+ import { ENV_NAME, RESERVED_ENV } from "../project-config/schema";
11
+ import { KEY_ENV_NAME } from "./secrets";
12
+ import {
13
+ DEFAULT_PRICES,
14
+ PROVIDER_PRESETS,
15
+ type ModelPrice,
16
+ type ModelProvider,
17
+ } from "./providers";
18
+ import {
19
+ ANTHROPIC,
20
+ DEFAULT_ROLE_MODELS,
21
+ MODEL_ROLES,
22
+ REF_PATTERN,
23
+ type ModelRole,
24
+ } from "./roles";
25
+
26
+ // A name passed to `tv` or `security` as its own argument: no option, no
27
+ // line break, nothing a shell would read.
28
+ const refName = z
29
+ .string()
30
+ .trim()
31
+ .min(1)
32
+ .max(120)
33
+ .regex(/^[\w .@:+-]+$/, "letters, digits, spaces and . @ : + - _ only")
34
+ .refine((s) => !s.startsWith("-"), "can't start with -");
35
+
36
+ const secretRef = z.discriminatedUnion("kind", [
37
+ z.strictObject({
38
+ kind: z.literal("tokenvault"),
39
+ project: refName,
40
+ desc: refName.optional(),
41
+ }),
42
+ z.strictObject({
43
+ kind: z.literal("keychain"),
44
+ service: refName,
45
+ account: refName.optional(),
46
+ }),
47
+ z.strictObject({
48
+ kind: z.literal("env"),
49
+ name: z
50
+ .string()
51
+ .regex(
52
+ KEY_ENV_NAME,
53
+ "must be a *_API_KEY variable, not ANTHROPIC_, CLAUDE_ or AGENTOS_"
54
+ ),
55
+ }),
56
+ ]);
57
+
58
+ const modelId = z
59
+ .string()
60
+ .trim()
61
+ .min(1)
62
+ .max(120)
63
+ .regex(/^[\w.:@/-]+$/, "not a model id");
64
+
65
+ const baseUrl = z
66
+ .string()
67
+ .trim()
68
+ .url()
69
+ .refine(
70
+ (u) =>
71
+ u.startsWith("https://") ||
72
+ /^http:\/\/(localhost|127\.0\.0\.1|\[::1\])(:\d+)?(\/|$)/.test(u),
73
+ "must be https (or http on localhost)"
74
+ );
75
+
76
+ const providerSchema = z.strictObject({
77
+ id: z
78
+ .string()
79
+ .regex(/^[a-z0-9][a-z0-9_-]{0,39}$/, "lowercase letters, digits, - and _"),
80
+ label: z.string().trim().min(1).max(60),
81
+ api: z.enum(["anthropic", "openai"]),
82
+ baseUrl: baseUrl.optional(),
83
+ auth: secretRef.optional(),
84
+ authHeader: z.enum(["bearer", "x-api-key"]).optional(),
85
+ models: z.array(modelId).max(40),
86
+ tiers: z
87
+ .strictObject({
88
+ opus: modelId.optional(),
89
+ sonnet: modelId.optional(),
90
+ haiku: modelId.optional(),
91
+ })
92
+ .optional(),
93
+ // A key can't hide here: no name an agent CLI reads for credentials.
94
+ env: z
95
+ .record(
96
+ z
97
+ .string()
98
+ .regex(ENV_NAME)
99
+ .refine((n) => !RESERVED_ENV.test(n), "is reserved"),
100
+ z.string().max(200)
101
+ )
102
+ .optional(),
103
+ enabled: z.boolean(),
104
+ });
105
+
106
+ const price = z.strictObject({
107
+ input: z.number().min(0),
108
+ cacheWrite: z.number().min(0),
109
+ cacheRead: z.number().min(0),
110
+ output: z.number().min(0),
111
+ });
112
+
113
+ const roleRef = z.string().trim().regex(REF_PATTERN, "must be provider/model");
114
+ const roleMap = z.partialRecord(z.enum(MODEL_ROLES), roleRef);
115
+
116
+ export const modelSettingsSchema = z.strictObject({
117
+ roles: roleMap.optional(),
118
+ providers: z.array(providerSchema).optional(),
119
+ prices: z.record(z.string().regex(REF_PATTERN), price).optional(),
120
+ planStep: z.boolean().optional(),
121
+ });
122
+
123
+ export const workspaceModelsSchema = z.strictObject({
124
+ roles: roleMap.optional(),
125
+ });
126
+
127
+ export type ModelSettings = z.infer<typeof modelSettingsSchema>;
128
+ export type WorkspaceModels = z.infer<typeof workspaceModelsSchema>;
129
+ export type RoleMap = Partial<Record<ModelRole, string>>;
130
+ export const roleMapSchema = roleMap;
131
+
132
+ const GLOBAL_KEY = "models";
133
+ const workspaceKey = (id: string) => `models.workspace.${id}`;
134
+
135
+ function read<T>(key: string, schema: z.ZodType<T>): T | null {
136
+ const row = db
137
+ .prepare(`SELECT value FROM settings WHERE key = ?`)
138
+ .get(key) as { value: string } | undefined;
139
+ if (!row) return null;
140
+ try {
141
+ const got = schema.safeParse(JSON.parse(row.value));
142
+ return got.success ? got.data : null;
143
+ } catch {
144
+ return null;
145
+ }
146
+ }
147
+
148
+ function problems(error: z.ZodError): string {
149
+ return error.issues
150
+ .map((i) => `${i.path.join(".") || "(top level)"}: ${i.message}`)
151
+ .join("; ");
152
+ }
153
+
154
+ function write<T>(key: string, schema: z.ZodType<T>, value: unknown): T {
155
+ const got = schema.safeParse(value ?? {});
156
+ if (!got.success)
157
+ throw new Error(`Invalid model settings: ${problems(got.error)}`);
158
+ db.prepare(
159
+ `INSERT INTO settings (key, value) VALUES (?, ?)
160
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`
161
+ ).run(key, JSON.stringify(got.data));
162
+ return got.data;
163
+ }
164
+
165
+ export const savedModelSettings = (): ModelSettings =>
166
+ read(GLOBAL_KEY, modelSettingsSchema) ?? {};
167
+
168
+ export function setModelSettings(value: unknown): ModelSettings {
169
+ const parsed = modelSettingsSchema.safeParse(value ?? {});
170
+ if (!parsed.success)
171
+ throw new Error(`Invalid model settings: ${problems(parsed.error)}`);
172
+ const ids = parsed.data.providers?.map((p) => p.id) ?? [];
173
+ if (new Set(ids).size !== ids.length)
174
+ throw new Error("Invalid model settings: two providers share an id");
175
+ const anthropic = parsed.data.providers?.find((p) => p.id === ANTHROPIC);
176
+ if (anthropic && (anthropic.baseUrl || anthropic.auth || !anthropic.enabled))
177
+ throw new Error(
178
+ "Invalid model settings: anthropic is the Claude login; add another provider for an endpoint"
179
+ );
180
+ return write(GLOBAL_KEY, modelSettingsSchema, parsed.data);
181
+ }
182
+
183
+ export const workspaceModels = (workspaceId: string): WorkspaceModels =>
184
+ read(workspaceKey(workspaceId), workspaceModelsSchema) ?? {};
185
+
186
+ export function setWorkspaceModels(
187
+ workspaceId: string,
188
+ value: unknown
189
+ ): WorkspaceModels {
190
+ const saved = write(workspaceKey(workspaceId), workspaceModelsSchema, value);
191
+ if (!Object.keys(saved.roles ?? {}).length)
192
+ db.prepare(`DELETE FROM settings WHERE key = ?`).run(
193
+ workspaceKey(workspaceId)
194
+ );
195
+ return saved;
196
+ }
197
+
198
+ // The settings in force: the presets with any saved edits laid over them
199
+ // (by id), saved providers that aren't presets, and every role filled.
200
+ export interface EffectiveModels {
201
+ roles: Record<ModelRole, string>;
202
+ providers: ModelProvider[];
203
+ prices: Record<string, ModelPrice>;
204
+ planStep: boolean;
205
+ }
206
+
207
+ export function effectiveModels(
208
+ saved: ModelSettings = savedModelSettings()
209
+ ): EffectiveModels {
210
+ const byId = new Map(PROVIDER_PRESETS.map((p) => [p.id, p]));
211
+ for (const p of saved.providers ?? []) byId.set(p.id, p as ModelProvider);
212
+ return {
213
+ roles: { ...DEFAULT_ROLE_MODELS, ...saved.roles },
214
+ providers: [...byId.values()],
215
+ prices: { ...DEFAULT_PRICES, ...saved.prices },
216
+ planStep: saved.planStep ?? true,
217
+ };
218
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * What an agent is launched with to run on its routed model: the model
3
+ * name for its CLI, and the provider's endpoint and key in its environment.
4
+ */
5
+
6
+ import type { Session } from "../db";
7
+ import {
8
+ defaultRoute,
9
+ providerEnv,
10
+ providerFor,
11
+ providerShellEnv,
12
+ resolveRole,
13
+ setSessionModel,
14
+ type ModelOverride,
15
+ } from "./resolve";
16
+ import { ANTHROPIC, type ModelRole } from "./roles";
17
+ import { redact, type SecretCommand } from "./secrets";
18
+
19
+ const why = (error: unknown) =>
20
+ redact(error instanceof Error ? error.message : String(error));
21
+
22
+ // A chat session's provider environment, or null on Anthropic.
23
+ export async function sessionProviderEnv(
24
+ session: Pick<Session, "model" | "model_provider">,
25
+ exec?: SecretCommand
26
+ ): Promise<Record<string, string> | null> {
27
+ if (!session.model_provider) return null;
28
+ return providerEnv(providerFor(session.model_provider), session.model, exec);
29
+ }
30
+
31
+ // A terminal session's: plain settings for its tmux environment, and shell
32
+ // text that sets the key from its reference inside the pane.
33
+ export function sessionShellEnv(
34
+ session: Pick<Session, "model" | "model_provider">
35
+ ): { env: Record<string, string>; exportKey: string } {
36
+ if (!session.model_provider) return { env: {}, exportKey: "" };
37
+ return providerShellEnv(providerFor(session.model_provider), session.model);
38
+ }
39
+
40
+ export interface RoutedRun {
41
+ model: string;
42
+ provider: string;
43
+ env: Record<string, string>;
44
+ }
45
+
46
+ // A one-shot run (a review, a scope check, a title) on its role's model,
47
+ // or on the role's default when its provider is off or its key can't be
48
+ // read: a title or a review still happens.
49
+ export async function routedRun(
50
+ role: ModelRole,
51
+ opts: { workspaceId?: string | null; override?: ModelOverride | null } = {},
52
+ exec?: SecretCommand
53
+ ): Promise<RoutedRun> {
54
+ try {
55
+ const routed = resolveRole(role, opts);
56
+ return {
57
+ model: routed.model,
58
+ provider: routed.provider.id,
59
+ env: await providerEnv(routed.provider, routed.model, exec),
60
+ };
61
+ } catch (error) {
62
+ const routed = defaultRoute(role);
63
+ console.warn(`[models] ${role}: ${why(error)}; on ${routed.ref} instead`);
64
+ return { model: routed.model, provider: ANTHROPIC, env: {} };
65
+ }
66
+ }
67
+
68
+ // An orchestrator runs on its role's model, as its workspace and Settings
69
+ // say now: each worker start picks up a change. When that provider is off
70
+ // or its key can't be read, it starts on the default rather than not at all.
71
+ export async function routeOrchestrator(
72
+ session: Session,
73
+ exec?: SecretCommand
74
+ ): Promise<Session> {
75
+ if (session.role !== "orchestrator") return session;
76
+ let routed;
77
+ try {
78
+ routed = resolveRole("orchestrator", { workspaceId: session.workspace_id });
79
+ await providerEnv(routed.provider, routed.model, exec);
80
+ } catch (error) {
81
+ routed = defaultRoute("orchestrator");
82
+ console.warn(
83
+ `[models] orchestrator: ${why(error)}; on ${routed.ref} instead`
84
+ );
85
+ }
86
+ setSessionModel(session.id, routed);
87
+ return {
88
+ ...session,
89
+ model: routed.model,
90
+ model_provider:
91
+ routed.provider.id === ANTHROPIC ? null : routed.provider.id,
92
+ };
93
+ }