@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.
- 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 +38 -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
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a model runs. A provider is config: Anthropic through the Claude
|
|
3
|
+
* login (no key), or an Anthropic- or OpenAI-compatible endpoint with a base
|
|
4
|
+
* URL, a reference to its key and its model names. The key itself is never
|
|
5
|
+
* here: only where to fetch it from at launch (lib/models/secrets).
|
|
6
|
+
* Imported by the browser too, so no Node modules here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { ANTHROPIC } from "./roles";
|
|
10
|
+
|
|
11
|
+
// Where a provider's key lives. Never the key.
|
|
12
|
+
export type SecretRef =
|
|
13
|
+
| { kind: "tokenvault"; project: string; desc?: string }
|
|
14
|
+
| { kind: "keychain"; service: string; account?: string }
|
|
15
|
+
| { kind: "env"; name: string };
|
|
16
|
+
|
|
17
|
+
export type ProviderApi = "anthropic" | "openai";
|
|
18
|
+
|
|
19
|
+
export interface ModelTiers {
|
|
20
|
+
opus?: string;
|
|
21
|
+
sonnet?: string;
|
|
22
|
+
haiku?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ModelProvider {
|
|
26
|
+
id: string;
|
|
27
|
+
label: string;
|
|
28
|
+
api: ProviderApi;
|
|
29
|
+
// Absent for Anthropic itself, which uses the Claude login.
|
|
30
|
+
baseUrl?: string;
|
|
31
|
+
auth?: SecretRef;
|
|
32
|
+
// How the key is sent: Authorization: Bearer, or x-api-key.
|
|
33
|
+
authHeader?: "bearer" | "x-api-key";
|
|
34
|
+
models: string[];
|
|
35
|
+
// Claude Code's tiers mapped to this provider's models, so subagents and
|
|
36
|
+
// background work (which ask for "haiku" or "sonnet") route here too.
|
|
37
|
+
tiers?: ModelTiers;
|
|
38
|
+
// Plain settings the endpoint wants (a longer timeout, say). Never a key.
|
|
39
|
+
env?: Record<string, string>;
|
|
40
|
+
enabled: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ModelPrice {
|
|
44
|
+
input: number;
|
|
45
|
+
cacheWrite: number;
|
|
46
|
+
cacheRead: number;
|
|
47
|
+
output: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const ANTHROPIC_PROVIDER: ModelProvider = {
|
|
51
|
+
id: ANTHROPIC,
|
|
52
|
+
label: "Anthropic (Claude login)",
|
|
53
|
+
api: "anthropic",
|
|
54
|
+
models: [
|
|
55
|
+
"claude-opus-5-5",
|
|
56
|
+
"claude-sonnet-5-5",
|
|
57
|
+
"claude-haiku-4-5-20251001",
|
|
58
|
+
"claude-fable-5-1",
|
|
59
|
+
],
|
|
60
|
+
enabled: true,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// Checked against each provider's docs on 2026-10-11: DeepSeek
|
|
64
|
+
// (api-docs.deepseek.com/guides/anthropic_api) and Z.ai
|
|
65
|
+
// (docs.z.ai/devpack/tool/claude). Off until a key reference is set.
|
|
66
|
+
export const PROVIDER_PRESETS: ModelProvider[] = [
|
|
67
|
+
ANTHROPIC_PROVIDER,
|
|
68
|
+
{
|
|
69
|
+
id: "deepseek",
|
|
70
|
+
label: "DeepSeek",
|
|
71
|
+
api: "anthropic",
|
|
72
|
+
baseUrl: "https://api.deepseek.com/anthropic",
|
|
73
|
+
authHeader: "x-api-key",
|
|
74
|
+
models: ["deepseek-v4-pro", "deepseek-flash"],
|
|
75
|
+
tiers: {
|
|
76
|
+
opus: "deepseek-v4-pro",
|
|
77
|
+
sonnet: "deepseek-flash",
|
|
78
|
+
haiku: "deepseek-flash",
|
|
79
|
+
},
|
|
80
|
+
enabled: false,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "zai",
|
|
84
|
+
label: "Z.ai (GLM)",
|
|
85
|
+
api: "anthropic",
|
|
86
|
+
baseUrl: "https://api.z.ai/api/anthropic",
|
|
87
|
+
authHeader: "bearer",
|
|
88
|
+
models: ["glm-5.3", "glm-5.3-flash"],
|
|
89
|
+
tiers: { opus: "glm-5.3", sonnet: "glm-5.3", haiku: "glm-5.3-flash" },
|
|
90
|
+
env: { API_TIMEOUT_MS: "3000000" },
|
|
91
|
+
enabled: false,
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
// Dollars per million tokens, from each provider's price page on
|
|
96
|
+
// 2026-10-11. DeepSeek and GLM have no cache-write price (a write costs
|
|
97
|
+
// what input does), and DeepSeek's are its peak prices.
|
|
98
|
+
export const DEFAULT_PRICES: Record<string, ModelPrice> = {
|
|
99
|
+
"anthropic/claude-opus-5-5": {
|
|
100
|
+
input: 4,
|
|
101
|
+
cacheWrite: 5,
|
|
102
|
+
cacheRead: 0.2,
|
|
103
|
+
output: 20,
|
|
104
|
+
},
|
|
105
|
+
"anthropic/claude-sonnet-5-5": {
|
|
106
|
+
input: 2,
|
|
107
|
+
cacheWrite: 2.5,
|
|
108
|
+
cacheRead: 0.1,
|
|
109
|
+
output: 10,
|
|
110
|
+
},
|
|
111
|
+
"anthropic/claude-haiku-4-5-20251001": {
|
|
112
|
+
input: 1,
|
|
113
|
+
cacheWrite: 1.25,
|
|
114
|
+
cacheRead: 0.1,
|
|
115
|
+
output: 5,
|
|
116
|
+
},
|
|
117
|
+
"anthropic/claude-fable-5-1": {
|
|
118
|
+
input: 10,
|
|
119
|
+
cacheWrite: 12.5,
|
|
120
|
+
cacheRead: 0.25,
|
|
121
|
+
output: 50,
|
|
122
|
+
},
|
|
123
|
+
"deepseek/deepseek-v4-pro": {
|
|
124
|
+
input: 1.32,
|
|
125
|
+
cacheWrite: 1.32,
|
|
126
|
+
cacheRead: 0.044,
|
|
127
|
+
output: 3.96,
|
|
128
|
+
},
|
|
129
|
+
"deepseek/deepseek-flash": {
|
|
130
|
+
input: 0.3,
|
|
131
|
+
cacheWrite: 0.3,
|
|
132
|
+
cacheRead: 0.006,
|
|
133
|
+
output: 1.2,
|
|
134
|
+
},
|
|
135
|
+
"zai/glm-5.3": { input: 1.4, cacheWrite: 1.4, cacheRead: 0.26, output: 4.4 },
|
|
136
|
+
"zai/glm-5.3-flash": {
|
|
137
|
+
input: 0.15,
|
|
138
|
+
cacheWrite: 0.15,
|
|
139
|
+
cacheRead: 0.03,
|
|
140
|
+
output: 0.5,
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// Claude Code's family aliases, priced as the model each stands for.
|
|
145
|
+
export const ANTHROPIC_ALIASES: Record<string, string> = {
|
|
146
|
+
opus: "claude-opus-5-5",
|
|
147
|
+
sonnet: "claude-sonnet-5-5",
|
|
148
|
+
haiku: "claude-haiku-4-5-20251001",
|
|
149
|
+
fable: "claude-fable-5-1",
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
// Anthropic is billed by the plan, not per token: a turn there costs a share
|
|
153
|
+
// of the plan's window, and its dollars are what the API would have charged.
|
|
154
|
+
export const isMetered = (providerId: string) => providerId !== ANTHROPIC;
|
|
155
|
+
|
|
156
|
+
export function describeSecretRef(ref: SecretRef | undefined): string {
|
|
157
|
+
if (!ref) return "no key";
|
|
158
|
+
if (ref.kind === "tokenvault")
|
|
159
|
+
return `tokenvault ${ref.project}${ref.desc ? ` "${ref.desc}"` : ""}`;
|
|
160
|
+
if (ref.kind === "keychain")
|
|
161
|
+
return `Keychain ${ref.service}${ref.account ? ` (${ref.account})` : ""}`;
|
|
162
|
+
return `$${ref.name}`;
|
|
163
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which model a piece of work runs on, and what its agent needs to reach
|
|
3
|
+
* it. The most specific setting wins: the task's own override, then its
|
|
4
|
+
* workspace's, then Settings, then AGENTOS_REVIEW_MODEL or
|
|
5
|
+
* AGENTOS_TITLE_MODEL, then the default (lib/models/roles).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { db, type Session } from "../db";
|
|
9
|
+
import { getProject } from "../projects";
|
|
10
|
+
import {
|
|
11
|
+
effectiveModels,
|
|
12
|
+
roleMapSchema,
|
|
13
|
+
savedModelSettings,
|
|
14
|
+
workspaceModels,
|
|
15
|
+
type EffectiveModels,
|
|
16
|
+
type RoleMap,
|
|
17
|
+
} from "./config";
|
|
18
|
+
import { ANTHROPIC_PROVIDER, type ModelProvider } from "./providers";
|
|
19
|
+
import {
|
|
20
|
+
ANTHROPIC,
|
|
21
|
+
DEFAULT_ROLE_MODELS,
|
|
22
|
+
GATE_ROLES,
|
|
23
|
+
formatModelRef,
|
|
24
|
+
isModelRole,
|
|
25
|
+
parseModelRef,
|
|
26
|
+
sessionRole,
|
|
27
|
+
type ModelRole,
|
|
28
|
+
} from "./roles";
|
|
29
|
+
import {
|
|
30
|
+
resolveSecret,
|
|
31
|
+
secretShellExpansion,
|
|
32
|
+
type SecretCommand,
|
|
33
|
+
} from "./secrets";
|
|
34
|
+
|
|
35
|
+
export type RoleSource = "task" | "workspace" | "settings" | "env" | "default";
|
|
36
|
+
|
|
37
|
+
// The server's own environment can still stand in for a role's default:
|
|
38
|
+
// what AgentOS read before model routing existed.
|
|
39
|
+
const ENV_ROLE_MODELS: Partial<Record<ModelRole, string>> = {
|
|
40
|
+
review: "AGENTOS_REVIEW_MODEL",
|
|
41
|
+
scope: "AGENTOS_REVIEW_MODEL",
|
|
42
|
+
helper: "AGENTOS_TITLE_MODEL",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const envModel = (role: ModelRole): string | undefined => {
|
|
46
|
+
const name = ENV_ROLE_MODELS[role];
|
|
47
|
+
return (name && process.env[name]?.trim()) || undefined;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export interface RoutedModel {
|
|
51
|
+
role: ModelRole;
|
|
52
|
+
ref: string;
|
|
53
|
+
provider: ModelProvider;
|
|
54
|
+
model: string;
|
|
55
|
+
source: RoleSource;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// A task's override: one model for all its roles, or a model per role.
|
|
59
|
+
export type ModelOverride = string | RoleMap;
|
|
60
|
+
|
|
61
|
+
export function parseOverride(value: unknown): ModelOverride | null {
|
|
62
|
+
if (value == null || value === "") return null;
|
|
63
|
+
if (typeof value === "string") {
|
|
64
|
+
const ref = value.trim();
|
|
65
|
+
providerFor(parseModelRef(ref).provider);
|
|
66
|
+
return ref.includes("/") ? ref : formatModelRef(parseModelRef(ref));
|
|
67
|
+
}
|
|
68
|
+
const got = roleMapSchema.safeParse(value);
|
|
69
|
+
const gate = got.success && GATE_ROLES.find((r) => got.data[r]);
|
|
70
|
+
if (gate)
|
|
71
|
+
throw new Error(
|
|
72
|
+
`model: a task can't pick its own ${gate} model; set it in Settings or the workspace`
|
|
73
|
+
);
|
|
74
|
+
if (!got.success)
|
|
75
|
+
throw new Error(
|
|
76
|
+
`model: give "provider/model", or a map of role to "provider/model"`
|
|
77
|
+
);
|
|
78
|
+
for (const ref of Object.values(got.data))
|
|
79
|
+
if (ref) providerFor(parseModelRef(ref).provider);
|
|
80
|
+
return Object.keys(got.data).length ? got.data : null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const overrideFor = (o: ModelOverride | null | undefined, role: ModelRole) =>
|
|
84
|
+
GATE_ROLES.includes(role) ? undefined : typeof o === "string" ? o : o?.[role];
|
|
85
|
+
|
|
86
|
+
// The provider by id, or throws why it can't be used.
|
|
87
|
+
export function providerFor(
|
|
88
|
+
id: string,
|
|
89
|
+
models: EffectiveModels = effectiveModels()
|
|
90
|
+
): ModelProvider {
|
|
91
|
+
if (id === ANTHROPIC) return ANTHROPIC_PROVIDER;
|
|
92
|
+
const p = models.providers.find((x) => x.id === id);
|
|
93
|
+
if (!p) throw new Error(`No model provider "${id}" (Settings > Models)`);
|
|
94
|
+
if (!p.enabled) throw new Error(`The ${p.label} provider is turned off`);
|
|
95
|
+
if (!p.baseUrl || !p.auth)
|
|
96
|
+
throw new Error(`The ${p.label} provider needs a base URL and a key`);
|
|
97
|
+
if (p.api !== "anthropic")
|
|
98
|
+
throw new Error(
|
|
99
|
+
`${p.label} speaks the OpenAI API; Claude Code needs an Anthropic-compatible endpoint`
|
|
100
|
+
);
|
|
101
|
+
return p;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function resolveRole(
|
|
105
|
+
role: ModelRole,
|
|
106
|
+
opts: { workspaceId?: string | null; override?: ModelOverride | null } = {},
|
|
107
|
+
models: EffectiveModels = effectiveModels()
|
|
108
|
+
): RoutedModel {
|
|
109
|
+
const task = overrideFor(opts.override, role);
|
|
110
|
+
const ws = opts.workspaceId
|
|
111
|
+
? workspaceModels(opts.workspaceId).roles?.[role]
|
|
112
|
+
: undefined;
|
|
113
|
+
const saved = models.roles[role];
|
|
114
|
+
// The variable stands in only where Settings names nothing for the role.
|
|
115
|
+
const env =
|
|
116
|
+
saved === DEFAULT_ROLE_MODELS[role] && !savedModelSettings().roles?.[role]
|
|
117
|
+
? envModel(role)
|
|
118
|
+
: undefined;
|
|
119
|
+
const [ref, source]: [string, RoleSource] = task
|
|
120
|
+
? [task, "task"]
|
|
121
|
+
: ws
|
|
122
|
+
? [ws, "workspace"]
|
|
123
|
+
: saved !== DEFAULT_ROLE_MODELS[role]
|
|
124
|
+
? [saved, "settings"]
|
|
125
|
+
: env
|
|
126
|
+
? [env, "env"]
|
|
127
|
+
: [saved, "default"];
|
|
128
|
+
const parsed = parseModelRef(ref);
|
|
129
|
+
return {
|
|
130
|
+
role,
|
|
131
|
+
ref: formatModelRef(parsed),
|
|
132
|
+
provider: providerFor(parsed.provider, models),
|
|
133
|
+
model: parsed.model,
|
|
134
|
+
source,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// The role's built-in model on the Claude login: what work falls back to
|
|
139
|
+
// when the provider it's routed to can't be used.
|
|
140
|
+
export function defaultRoute(role: ModelRole): RoutedModel {
|
|
141
|
+
const parsed = parseModelRef(DEFAULT_ROLE_MODELS[role]);
|
|
142
|
+
return {
|
|
143
|
+
role,
|
|
144
|
+
ref: formatModelRef(parsed),
|
|
145
|
+
provider: ANTHROPIC_PROVIDER,
|
|
146
|
+
model: parsed.model,
|
|
147
|
+
source: "default",
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function resolveRoleOrDefault(
|
|
152
|
+
role: ModelRole,
|
|
153
|
+
opts: { workspaceId?: string | null } = {}
|
|
154
|
+
): RoutedModel {
|
|
155
|
+
try {
|
|
156
|
+
return resolveRole(role, opts);
|
|
157
|
+
} catch {
|
|
158
|
+
return defaultRoute(role);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// A session's workspace: an orchestrator's own, or its project's.
|
|
163
|
+
export function sessionWorkspace(
|
|
164
|
+
session: Pick<Session, "workspace_id" | "project_id">
|
|
165
|
+
): string | null {
|
|
166
|
+
if (session.workspace_id) return session.workspace_id;
|
|
167
|
+
return session.project_id
|
|
168
|
+
? (getProject(session.project_id)?.workspace_id ?? null)
|
|
169
|
+
: null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function taskOverride(
|
|
173
|
+
session: Pick<Session, "model_overrides">
|
|
174
|
+
): ModelOverride | null {
|
|
175
|
+
if (!session.model_overrides) return null;
|
|
176
|
+
try {
|
|
177
|
+
return parseOverride(JSON.parse(session.model_overrides));
|
|
178
|
+
} catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// What the agent's environment needs beyond the model name, with the key
|
|
184
|
+
// fetched now. Nothing for Anthropic: the Claude login is the default.
|
|
185
|
+
export async function providerEnv(
|
|
186
|
+
provider: ModelProvider,
|
|
187
|
+
model: string,
|
|
188
|
+
exec?: SecretCommand
|
|
189
|
+
): Promise<Record<string, string>> {
|
|
190
|
+
if (provider.id === ANTHROPIC) return {};
|
|
191
|
+
const key = await resolveSecret(provider.auth!, exec);
|
|
192
|
+
return { ...routingEnv(provider, model), [authVar(provider)]: key };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// The same for a terminal launch: the plain settings, and shell text that
|
|
196
|
+
// sets the key from its reference inside the agent's shell.
|
|
197
|
+
export function providerShellEnv(
|
|
198
|
+
provider: ModelProvider,
|
|
199
|
+
model: string
|
|
200
|
+
): { env: Record<string, string>; exportKey: string } {
|
|
201
|
+
if (provider.id === ANTHROPIC) return { env: {}, exportKey: "" };
|
|
202
|
+
return {
|
|
203
|
+
env: routingEnv(provider, model),
|
|
204
|
+
exportKey: `export ${authVar(provider)}=${secretShellExpansion(provider.auth!)};`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const authVar = (p: ModelProvider) =>
|
|
209
|
+
p.authHeader === "x-api-key" ? "ANTHROPIC_API_KEY" : "ANTHROPIC_AUTH_TOKEN";
|
|
210
|
+
|
|
211
|
+
// The endpoint, Claude Code's tiers mapped to its models (so subagents and
|
|
212
|
+
// background calls asking for "haiku" stay on it), and the other key
|
|
213
|
+
// variable emptied so a key in AgentOS's own environment isn't sent there.
|
|
214
|
+
function routingEnv(p: ModelProvider, model: string): Record<string, string> {
|
|
215
|
+
const other =
|
|
216
|
+
authVar(p) === "ANTHROPIC_API_KEY"
|
|
217
|
+
? "ANTHROPIC_AUTH_TOKEN"
|
|
218
|
+
: "ANTHROPIC_API_KEY";
|
|
219
|
+
return {
|
|
220
|
+
...p.env,
|
|
221
|
+
ANTHROPIC_BASE_URL: p.baseUrl!,
|
|
222
|
+
[other]: "",
|
|
223
|
+
ANTHROPIC_DEFAULT_OPUS_MODEL: p.tiers?.opus ?? model,
|
|
224
|
+
ANTHROPIC_DEFAULT_SONNET_MODEL: p.tiers?.sonnet ?? model,
|
|
225
|
+
ANTHROPIC_DEFAULT_HAIKU_MODEL: p.tiers?.haiku ?? model,
|
|
226
|
+
CLAUDE_CODE_SUBAGENT_MODEL: p.tiers?.sonnet ?? model,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Records what a session runs on: the model its CLI is given, and its
|
|
231
|
+
// provider (NULL for Anthropic).
|
|
232
|
+
export function setSessionModel(sessionId: string, routed: RoutedModel): void {
|
|
233
|
+
db.prepare(
|
|
234
|
+
`UPDATE sessions SET model = ?, model_provider = ? WHERE id = ?`
|
|
235
|
+
).run(
|
|
236
|
+
routed.model,
|
|
237
|
+
routed.provider.id === ANTHROPIC ? null : routed.provider.id,
|
|
238
|
+
sessionId
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export const sessionModelRef = (
|
|
243
|
+
s: Pick<Session, "model" | "model_provider">
|
|
244
|
+
): string => `${s.model_provider ?? ANTHROPIC}/${s.model}`;
|
|
245
|
+
|
|
246
|
+
export { isModelRole, sessionRole };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resuming a task on another model: its conversation and worktree carry on,
|
|
3
|
+
* only the model (and maybe the provider) under it changes. The choice is
|
|
4
|
+
* kept as the task's own override, so later turns and restarts stay on it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { db, type Session } from "../db";
|
|
8
|
+
import { chatState, sendChat } from "../chat/runner";
|
|
9
|
+
import { launchPending } from "../tasks/launch-gate";
|
|
10
|
+
import { killTmux } from "../tasks/start";
|
|
11
|
+
import { launchClaude } from "../agents/launch";
|
|
12
|
+
import { switchSessionModel } from "./session-model";
|
|
13
|
+
import {
|
|
14
|
+
parseOverride,
|
|
15
|
+
resolveRole,
|
|
16
|
+
sessionWorkspace,
|
|
17
|
+
setSessionModel,
|
|
18
|
+
taskOverride,
|
|
19
|
+
type ModelOverride,
|
|
20
|
+
} from "./resolve";
|
|
21
|
+
import { sessionRole } from "./roles";
|
|
22
|
+
|
|
23
|
+
export const RESUME_MESSAGE =
|
|
24
|
+
"You were paused. Carry on where you left off: check the worktree and the conversation above for where things stand, then finish the task.";
|
|
25
|
+
|
|
26
|
+
// A per-role map adds to the one it has; one model replaces it.
|
|
27
|
+
export function mergeOverride(
|
|
28
|
+
before: ModelOverride | null,
|
|
29
|
+
next: ModelOverride
|
|
30
|
+
): ModelOverride {
|
|
31
|
+
if (typeof next === "string" || !before) return next;
|
|
32
|
+
if (typeof before === "string") return next;
|
|
33
|
+
return { ...before, ...next };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ResumeResult {
|
|
37
|
+
ref: string;
|
|
38
|
+
// Whether a message went to its agent now: a task still setting up, or
|
|
39
|
+
// held, starts on the new model by itself.
|
|
40
|
+
resumed: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function resumeOnModel(
|
|
44
|
+
sessionId: string,
|
|
45
|
+
model: unknown,
|
|
46
|
+
message = RESUME_MESSAGE
|
|
47
|
+
): Promise<ResumeResult> {
|
|
48
|
+
const s = db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(sessionId) as
|
|
49
|
+
| Session
|
|
50
|
+
| undefined;
|
|
51
|
+
if (!s?.task_status) throw new Error("No such task");
|
|
52
|
+
if (s.task_status !== "running")
|
|
53
|
+
throw new Error(
|
|
54
|
+
`That task is ${s.task_status}; only a running one resumes`
|
|
55
|
+
);
|
|
56
|
+
const next = parseOverride(model);
|
|
57
|
+
if (!next) throw new Error("Pick a model");
|
|
58
|
+
const override = mergeOverride(taskOverride(s), next);
|
|
59
|
+
const routed = resolveRole(sessionRole(s), {
|
|
60
|
+
workspaceId: sessionWorkspace(s),
|
|
61
|
+
override,
|
|
62
|
+
});
|
|
63
|
+
const state = s.view === "chat" ? chatState(sessionId) : null;
|
|
64
|
+
if (state === "running" || state === "waiting")
|
|
65
|
+
throw new Error(
|
|
66
|
+
"It's mid-turn: stop it first, or wait for the turn to end"
|
|
67
|
+
);
|
|
68
|
+
db.prepare(`UPDATE sessions SET model_overrides = ? WHERE id = ?`).run(
|
|
69
|
+
JSON.stringify(override),
|
|
70
|
+
sessionId
|
|
71
|
+
);
|
|
72
|
+
if (launchPending(sessionId)) {
|
|
73
|
+
setSessionModel(sessionId, routed);
|
|
74
|
+
return { ref: routed.ref, resumed: false };
|
|
75
|
+
}
|
|
76
|
+
if (s.view === "chat") {
|
|
77
|
+
await switchSessionModel(sessionId, routed);
|
|
78
|
+
await sendChat(sessionId, {
|
|
79
|
+
text: message,
|
|
80
|
+
origin: { kind: "decision", label: `Resumed on ${routed.ref}` },
|
|
81
|
+
});
|
|
82
|
+
return { ref: routed.ref, resumed: true };
|
|
83
|
+
}
|
|
84
|
+
if (!s.claude_session_id || !s.worktree_path)
|
|
85
|
+
throw new Error("Its terminal conversation can't be resumed");
|
|
86
|
+
setSessionModel(sessionId, routed);
|
|
87
|
+
await killTmux(s.tmux_name);
|
|
88
|
+
await launchClaude({
|
|
89
|
+
sessionId,
|
|
90
|
+
tmuxName: s.tmux_name,
|
|
91
|
+
cwd: s.worktree_path,
|
|
92
|
+
model: routed.model,
|
|
93
|
+
provider: routed.provider.id === "anthropic" ? null : routed.provider.id,
|
|
94
|
+
prompt: message,
|
|
95
|
+
brief: s.task_brief ?? undefined,
|
|
96
|
+
resume: s.claude_session_id,
|
|
97
|
+
});
|
|
98
|
+
return { ref: routed.ref, resumed: true };
|
|
99
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The kinds of work AgentOS launches agents for, and the model each runs on
|
|
3
|
+
* unless Settings, a workspace or the task says otherwise. A model is named
|
|
4
|
+
* by a reference, "provider/model": the provider is one of lib/models/
|
|
5
|
+
* providers, and the model is that provider's own id. Imported by the
|
|
6
|
+
* browser too, so no Node modules here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const MODEL_ROLES = [
|
|
10
|
+
"plan",
|
|
11
|
+
"implement",
|
|
12
|
+
"orchestrator",
|
|
13
|
+
"review",
|
|
14
|
+
"scope",
|
|
15
|
+
"interactive",
|
|
16
|
+
"helper",
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
export type ModelRole = (typeof MODEL_ROLES)[number];
|
|
20
|
+
|
|
21
|
+
export const ROLE_LABELS: Record<ModelRole, { label: string; hint: string }> = {
|
|
22
|
+
plan: {
|
|
23
|
+
label: "Task planning",
|
|
24
|
+
hint: "A task's plan step, written to a file before any code changes",
|
|
25
|
+
},
|
|
26
|
+
implement: {
|
|
27
|
+
label: "Task implementation",
|
|
28
|
+
hint: "A task's agent, following the plan in its worktree",
|
|
29
|
+
},
|
|
30
|
+
orchestrator: { label: "Orchestrator", hint: "A workspace's orchestrator" },
|
|
31
|
+
review: {
|
|
32
|
+
label: "Gate review",
|
|
33
|
+
hint: "The independent review a PR passes before it merges",
|
|
34
|
+
},
|
|
35
|
+
scope: { label: "Scope check", hint: "Whether a diff stays in its card" },
|
|
36
|
+
interactive: {
|
|
37
|
+
label: "Interactive sessions",
|
|
38
|
+
hint: "Sessions you start and talk to yourself",
|
|
39
|
+
},
|
|
40
|
+
helper: {
|
|
41
|
+
label: "Chat & summary helpers",
|
|
42
|
+
hint: "Titles, summaries and digests",
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const ANTHROPIC = "anthropic";
|
|
47
|
+
const OPUS = `${ANTHROPIC}/claude-opus-5-5`;
|
|
48
|
+
const SONNET = `${ANTHROPIC}/claude-sonnet-5-5`;
|
|
49
|
+
const HAIKU = `${ANTHROPIC}/claude-haiku-4-5-20251001`;
|
|
50
|
+
|
|
51
|
+
// Claude stays the default for every role: another provider is opt-in.
|
|
52
|
+
export const DEFAULT_ROLE_MODELS: Record<ModelRole, string> = {
|
|
53
|
+
plan: OPUS,
|
|
54
|
+
review: OPUS,
|
|
55
|
+
implement: SONNET,
|
|
56
|
+
orchestrator: SONNET,
|
|
57
|
+
scope: SONNET,
|
|
58
|
+
interactive: OPUS,
|
|
59
|
+
helper: HAIKU,
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// The roles that gate a task's merge: only Settings and its workspace pick
|
|
63
|
+
// their model, never the task (or the orchestrator that started it), so
|
|
64
|
+
// work can't choose its own reviewer.
|
|
65
|
+
export const GATE_ROLES: readonly ModelRole[] = ["review", "scope"];
|
|
66
|
+
export const TASK_ROLES = MODEL_ROLES.filter((r) => !GATE_ROLES.includes(r));
|
|
67
|
+
|
|
68
|
+
export const isModelRole = (v: unknown): v is ModelRole =>
|
|
69
|
+
typeof v === "string" && (MODEL_ROLES as readonly string[]).includes(v);
|
|
70
|
+
|
|
71
|
+
export interface ModelRef {
|
|
72
|
+
provider: string;
|
|
73
|
+
model: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// "provider/model", split at the first slash (a model id may hold more).
|
|
77
|
+
// A bare model ("opus", "claude-sonnet-5-5") is Anthropic's.
|
|
78
|
+
export function parseModelRef(ref: string): ModelRef {
|
|
79
|
+
const s = ref.trim();
|
|
80
|
+
const at = s.indexOf("/");
|
|
81
|
+
if (at <= 0) return { provider: ANTHROPIC, model: s };
|
|
82
|
+
return { provider: s.slice(0, at), model: s.slice(at + 1) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export const formatModelRef = (r: ModelRef) => `${r.provider}/${r.model}`;
|
|
86
|
+
|
|
87
|
+
export const REF_PATTERN = /^[a-z0-9][a-z0-9_-]*\/[\w.:@/-]+$/i;
|
|
88
|
+
|
|
89
|
+
// The role a session's next turn runs as.
|
|
90
|
+
export function sessionRole(session: {
|
|
91
|
+
role?: string | null;
|
|
92
|
+
task_status?: string | null;
|
|
93
|
+
plan_phase?: string | null;
|
|
94
|
+
}): ModelRole {
|
|
95
|
+
if (session.role === "orchestrator") return "orchestrator";
|
|
96
|
+
if (!session.task_status) return "interactive";
|
|
97
|
+
return session.plan_phase === "planning" ? "plan" : "implement";
|
|
98
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A provider's key, fetched from where its reference points at the moment
|
|
3
|
+
* an agent launches, and handed straight to that agent's environment. It is
|
|
4
|
+
* never written anywhere: not the database, not a log, not a prompt. Every
|
|
5
|
+
* key fetched is remembered in memory only so redact() can scrub it from
|
|
6
|
+
* any text on its way to a log or an error.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execFile } from "child_process";
|
|
10
|
+
import { shellQuote } from "../hosts/ssh";
|
|
11
|
+
import type { SecretRef } from "./providers";
|
|
12
|
+
|
|
13
|
+
export type SecretCommand = (cmd: string, args: string[]) => Promise<string>;
|
|
14
|
+
|
|
15
|
+
const run: SecretCommand = (cmd, args) =>
|
|
16
|
+
new Promise((resolve, reject) =>
|
|
17
|
+
execFile(cmd, args, { timeout: 15_000 }, (err, stdout) =>
|
|
18
|
+
// Its stderr isn't passed on: a tool may echo what it was asked.
|
|
19
|
+
err ? reject(new Error(`${cmd} failed`)) : resolve(stdout)
|
|
20
|
+
)
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
// An environment variable a key may be read from: one named for an API key,
|
|
24
|
+
// never AgentOS's own nor its Claude login's, so a reference can't lift an
|
|
25
|
+
// unrelated secret out of the server's environment.
|
|
26
|
+
export const KEY_ENV_NAME =
|
|
27
|
+
/^(?!(?:ANTHROPIC|CLAUDE|AGENTOS)_)[A-Z][A-Z0-9_]*_API_KEY$/;
|
|
28
|
+
|
|
29
|
+
// The command that prints the key, as argv; null for an environment
|
|
30
|
+
// variable, which is read directly.
|
|
31
|
+
export function secretCommand(ref: SecretRef): string[] | null {
|
|
32
|
+
if (ref.kind === "tokenvault")
|
|
33
|
+
return ["tv", "get", ref.project, ...(ref.desc ? [ref.desc] : [])];
|
|
34
|
+
if (ref.kind === "keychain")
|
|
35
|
+
return [
|
|
36
|
+
"security",
|
|
37
|
+
"find-generic-password",
|
|
38
|
+
"-s",
|
|
39
|
+
ref.service,
|
|
40
|
+
...(ref.account ? ["-a", ref.account] : []),
|
|
41
|
+
"-w",
|
|
42
|
+
];
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The same, as shell text that expands to the key inside the agent's own
|
|
47
|
+
// shell (a terminal launch), so AgentOS never holds it at all.
|
|
48
|
+
export function secretShellExpansion(ref: SecretRef): string {
|
|
49
|
+
const argv = secretCommand(ref);
|
|
50
|
+
if (!argv) return `"\${${ref.kind === "env" ? envName(ref.name) : ""}}"`;
|
|
51
|
+
return `"$(${argv.map(shellQuote).join(" ")})"`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function envName(name: string): string {
|
|
55
|
+
if (!KEY_ENV_NAME.test(name))
|
|
56
|
+
throw new Error(
|
|
57
|
+
`A key can't be read from ${name}: name a *_API_KEY variable`
|
|
58
|
+
);
|
|
59
|
+
return name;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const seen = new Set<string>();
|
|
63
|
+
|
|
64
|
+
export async function resolveSecret(
|
|
65
|
+
ref: SecretRef,
|
|
66
|
+
exec: SecretCommand = run
|
|
67
|
+
): Promise<string> {
|
|
68
|
+
const argv = secretCommand(ref);
|
|
69
|
+
let value: string | undefined;
|
|
70
|
+
if (argv) {
|
|
71
|
+
const out = await exec(argv[0], argv.slice(1)).catch(() => {
|
|
72
|
+
throw new Error(`Couldn't read the key (${argv[0]} failed)`);
|
|
73
|
+
});
|
|
74
|
+
value = out.trim();
|
|
75
|
+
} else if (ref.kind === "env") value = process.env[envName(ref.name)]?.trim();
|
|
76
|
+
if (!value) throw new Error("The key reference resolved to nothing");
|
|
77
|
+
if (value.length >= 8) seen.add(value);
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Scrubs every key fetched so far out of the text.
|
|
82
|
+
export function redact(text: string): string {
|
|
83
|
+
let out = text;
|
|
84
|
+
for (const s of seen) out = out.split(s).join("[redacted]");
|
|
85
|
+
return out;
|
|
86
|
+
}
|