@worca/ui 0.44.0 → 0.46.0
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/app/main.bundle.js +2412 -2207
- package/app/main.bundle.js.map +4 -4
- package/app/styles.css +332 -0
- package/package.json +1 -1
- package/server/models-routes.js +573 -0
- package/server/process-manager.js +3 -0
- package/server/project-routes.js +26 -1
- package/server/settings-merge.js +68 -9
- package/server/templates-routes.js +41 -3
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier-aware model alias CRUD spanning user-global and project tiers, with
|
|
3
|
+
* co-located per-model pricing. Mirrors the Pipeline Templates tier model
|
|
4
|
+
* (builtin / user / project) and the storage-split rules from
|
|
5
|
+
* model-env-routes.js (id+pricing in settings.json, env in settings.local.json).
|
|
6
|
+
*
|
|
7
|
+
* Resolution rule (matches src/worca/utils/settings.py): an alias resolves
|
|
8
|
+
* from exactly one tier — whole-entry replace across tiers, no field-level
|
|
9
|
+
* deep-merge. Within a single tier id/env still compose (the .json + .local.json
|
|
10
|
+
* sibling pair is one logical tier).
|
|
11
|
+
*
|
|
12
|
+
* Mount at /api/projects/:projectId/models (per-project scoped) or
|
|
13
|
+
* /api/models (unscoped, for single-project mode).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import { dirname, join, resolve as resolvePath } from 'node:path';
|
|
19
|
+
import { Router } from 'express';
|
|
20
|
+
import { atomicWriteSync } from './atomic-write.js';
|
|
21
|
+
import { globalSettingsPath, templatesDir } from './paths.js';
|
|
22
|
+
import { localPathFor } from './settings-merge.js';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const denylist = require('./reserved-env-keys.json');
|
|
26
|
+
const RESERVED_KEYS = new Set(denylist.keys);
|
|
27
|
+
const RESERVED_PREFIXES = denylist.prefixes;
|
|
28
|
+
|
|
29
|
+
// Mirror of _DEFAULT_MODEL_MAP in src/worca/utils/settings.py.
|
|
30
|
+
// Surfaced as the read-only "builtin" tier on the Models page.
|
|
31
|
+
const BUILTIN_MODELS = Object.freeze({
|
|
32
|
+
opus: 'claude-opus-4-7',
|
|
33
|
+
sonnet: 'claude-sonnet-4-6',
|
|
34
|
+
haiku: 'claude-haiku-4-5-20251001',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Pricing fields displayed in the per-model pricing accordion. Aligned with
|
|
38
|
+
// the legacy Settings → Pricing tab's PRICING_FIELDS (input/cache_read/cache_write/output).
|
|
39
|
+
const PRICING_FIELDS = Object.freeze([
|
|
40
|
+
'input_per_mtok',
|
|
41
|
+
'cache_read_per_mtok',
|
|
42
|
+
'cache_write_per_mtok',
|
|
43
|
+
'output_per_mtok',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
// Env keys whose presence flips an alias into "alt-endpoint" mode — pricing
|
|
47
|
+
// then becomes authoritative (worca overrides Claude CLI's cost). Matches
|
|
48
|
+
// _ALT_ENDPOINT_ENV_KEYS in src/worca/orchestrator/stages.py.
|
|
49
|
+
const ALT_ENDPOINT_ENV_KEYS = Object.freeze([
|
|
50
|
+
'ANTHROPIC_BASE_URL',
|
|
51
|
+
'ANTHROPIC_AUTH_TOKEN',
|
|
52
|
+
'ANTHROPIC_API_KEY',
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const ALIAS_RE = /^[a-zA-Z0-9_-]{1,64}$/;
|
|
56
|
+
|
|
57
|
+
function isReservedKey(key) {
|
|
58
|
+
if (RESERVED_KEYS.has(key)) return true;
|
|
59
|
+
return RESERVED_PREFIXES.some((p) => key.startsWith(p));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function readJsonOr(path, fallback) {
|
|
63
|
+
if (!existsSync(path)) return fallback;
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
66
|
+
} catch {
|
|
67
|
+
return fallback;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function tierSettingsPath(tier, projectSettingsPath) {
|
|
72
|
+
if (tier === 'project') return projectSettingsPath || null;
|
|
73
|
+
if (tier === 'user') return globalSettingsPath();
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Read all aliases at a given tier, composing the within-tier id/env split.
|
|
79
|
+
* Returns { alias: { id, env, pricing } } where pricing may be {}.
|
|
80
|
+
*/
|
|
81
|
+
function readTierAliases(tier, projectSettingsPath) {
|
|
82
|
+
const path = tierSettingsPath(tier, projectSettingsPath);
|
|
83
|
+
if (!path) return {};
|
|
84
|
+
const base = readJsonOr(path, {});
|
|
85
|
+
const local = readJsonOr(localPathFor(path), {});
|
|
86
|
+
const baseModels = base?.worca?.models || {};
|
|
87
|
+
const localModels = local?.worca?.models || {};
|
|
88
|
+
const basePricing = base?.worca?.pricing?.models || {};
|
|
89
|
+
const localPricing = local?.worca?.pricing?.models || {};
|
|
90
|
+
const aliases = new Set([
|
|
91
|
+
...Object.keys(baseModels),
|
|
92
|
+
...Object.keys(localModels),
|
|
93
|
+
...Object.keys(basePricing),
|
|
94
|
+
...Object.keys(localPricing),
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
const out = {};
|
|
98
|
+
for (const alias of aliases) {
|
|
99
|
+
const baseRaw = baseModels[alias];
|
|
100
|
+
const localRaw = localModels[alias];
|
|
101
|
+
let id = null;
|
|
102
|
+
if (typeof baseRaw === 'string') id = baseRaw;
|
|
103
|
+
else if (baseRaw && typeof baseRaw === 'object') id = baseRaw.id || null;
|
|
104
|
+
// Local carries only env by convention, but tolerate legacy shapes.
|
|
105
|
+
let env = {};
|
|
106
|
+
if (localRaw && typeof localRaw === 'object' && localRaw.env) {
|
|
107
|
+
env = localRaw.env;
|
|
108
|
+
} else if (baseRaw && typeof baseRaw === 'object' && baseRaw.env) {
|
|
109
|
+
env = baseRaw.env;
|
|
110
|
+
}
|
|
111
|
+
const pricing = {
|
|
112
|
+
...(basePricing[alias] || {}),
|
|
113
|
+
...(localPricing[alias] || {}),
|
|
114
|
+
};
|
|
115
|
+
// Bundle attribution metadata (set by `worca templates import` and
|
|
116
|
+
// dropped on the first UI save). Surfaces as a card/editor badge so
|
|
117
|
+
// users remember which alias originated from a shared template bundle.
|
|
118
|
+
const importedFrom =
|
|
119
|
+
baseRaw &&
|
|
120
|
+
typeof baseRaw === 'object' &&
|
|
121
|
+
typeof baseRaw._imported_from === 'string'
|
|
122
|
+
? baseRaw._imported_from
|
|
123
|
+
: null;
|
|
124
|
+
out[alias] = { id, env, pricing, importedFrom };
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function makeRow(tier, alias, entry) {
|
|
130
|
+
const env = entry.env || {};
|
|
131
|
+
const envCount = Object.keys(env).length;
|
|
132
|
+
const hasAlt = ALT_ENDPOINT_ENV_KEYS.some((k) => env[k] != null);
|
|
133
|
+
const pricing =
|
|
134
|
+
entry.pricing && Object.keys(entry.pricing).length ? entry.pricing : null;
|
|
135
|
+
return {
|
|
136
|
+
tier,
|
|
137
|
+
alias,
|
|
138
|
+
id: entry.id || null,
|
|
139
|
+
env,
|
|
140
|
+
env_count: envCount,
|
|
141
|
+
pricing,
|
|
142
|
+
has_alt_endpoint: hasAlt,
|
|
143
|
+
builtin: tier === 'builtin',
|
|
144
|
+
imported_from: entry.importedFrom || null,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function readSingleModel(tier, projectSettingsPath, alias) {
|
|
149
|
+
if (tier === 'builtin') {
|
|
150
|
+
if (!(alias in BUILTIN_MODELS)) return null;
|
|
151
|
+
return makeRow('builtin', alias, {
|
|
152
|
+
id: BUILTIN_MODELS[alias],
|
|
153
|
+
env: {},
|
|
154
|
+
pricing: null,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
const map = readTierAliases(tier, projectSettingsPath);
|
|
158
|
+
if (!(alias in map)) return null;
|
|
159
|
+
return makeRow(tier, alias, map[alias]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function listAllModels(projectSettingsPath) {
|
|
163
|
+
const all = [];
|
|
164
|
+
for (const [alias, modelId] of Object.entries(BUILTIN_MODELS)) {
|
|
165
|
+
all.push(
|
|
166
|
+
makeRow('builtin', alias, { id: modelId, env: {}, pricing: null }),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
for (const [alias, entry] of Object.entries(
|
|
170
|
+
readTierAliases('user', projectSettingsPath),
|
|
171
|
+
)) {
|
|
172
|
+
all.push(makeRow('user', alias, entry));
|
|
173
|
+
}
|
|
174
|
+
if (projectSettingsPath) {
|
|
175
|
+
for (const [alias, entry] of Object.entries(
|
|
176
|
+
readTierAliases('project', projectSettingsPath),
|
|
177
|
+
)) {
|
|
178
|
+
all.push(makeRow('project', alias, entry));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return all;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Write rules:
|
|
186
|
+
* - id and pricing live in settings.json (committed)
|
|
187
|
+
* - env lives in settings.local.json (gitignored)
|
|
188
|
+
* - if env exists, settings.json entry MUST be object form {id} so the
|
|
189
|
+
* within-tier deepMerge({id}, {env}) preserves both. String form is
|
|
190
|
+
* the default when env is empty (minimal JSON).
|
|
191
|
+
*/
|
|
192
|
+
function writeModelEntry(
|
|
193
|
+
tier,
|
|
194
|
+
projectSettingsPath,
|
|
195
|
+
alias,
|
|
196
|
+
{ id, env, pricing },
|
|
197
|
+
) {
|
|
198
|
+
const settingsPath = tierSettingsPath(tier, projectSettingsPath);
|
|
199
|
+
if (!settingsPath) {
|
|
200
|
+
throw new Error(`tier "${tier}" has no writable settings path`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const safeEnv = {};
|
|
204
|
+
for (const [k, v] of Object.entries(env || {})) {
|
|
205
|
+
if (typeof k !== 'string' || k === '') continue;
|
|
206
|
+
if (isReservedKey(k)) continue;
|
|
207
|
+
if (typeof v !== 'string') continue;
|
|
208
|
+
safeEnv[k] = v;
|
|
209
|
+
}
|
|
210
|
+
const hasEnv = Object.keys(safeEnv).length > 0;
|
|
211
|
+
|
|
212
|
+
// settings.json side: id (always written) + pricing (optional)
|
|
213
|
+
const base = readJsonOr(settingsPath, {});
|
|
214
|
+
if (!base.worca) base.worca = {};
|
|
215
|
+
if (!base.worca.models) base.worca.models = {};
|
|
216
|
+
if (!base.worca.pricing) base.worca.pricing = {};
|
|
217
|
+
if (!base.worca.pricing.models) base.worca.pricing.models = {};
|
|
218
|
+
|
|
219
|
+
if (id) {
|
|
220
|
+
base.worca.models[alias] = hasEnv ? { id } : id;
|
|
221
|
+
} else {
|
|
222
|
+
delete base.worca.models[alias];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const pricingClean = {};
|
|
226
|
+
for (const field of PRICING_FIELDS) {
|
|
227
|
+
const v = pricing?.[field];
|
|
228
|
+
if (v != null && Number.isFinite(Number(v))) {
|
|
229
|
+
pricingClean[field] = Number(v);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
if (Object.keys(pricingClean).length > 0) {
|
|
233
|
+
base.worca.pricing.models[alias] = pricingClean;
|
|
234
|
+
} else {
|
|
235
|
+
delete base.worca.pricing.models[alias];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
atomicWriteSync(settingsPath, `${JSON.stringify(base, null, 2)}\n`);
|
|
239
|
+
|
|
240
|
+
// settings.local.json side: env (if any), strip env from local if empty
|
|
241
|
+
const localPath = localPathFor(settingsPath);
|
|
242
|
+
const local = readJsonOr(localPath, {});
|
|
243
|
+
if (!local.worca) local.worca = {};
|
|
244
|
+
if (!local.worca.models) local.worca.models = {};
|
|
245
|
+
if (hasEnv) {
|
|
246
|
+
local.worca.models[alias] = { env: safeEnv };
|
|
247
|
+
} else {
|
|
248
|
+
delete local.worca.models[alias];
|
|
249
|
+
}
|
|
250
|
+
atomicWriteSync(localPath, `${JSON.stringify(local, null, 2)}\n`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function deleteModelEntry(tier, projectSettingsPath, alias) {
|
|
254
|
+
const settingsPath = tierSettingsPath(tier, projectSettingsPath);
|
|
255
|
+
if (!settingsPath) throw new Error(`tier "${tier}" not writable`);
|
|
256
|
+
|
|
257
|
+
let fromBase = false;
|
|
258
|
+
let fromLocal = false;
|
|
259
|
+
if (existsSync(settingsPath)) {
|
|
260
|
+
const base = readJsonOr(settingsPath, {});
|
|
261
|
+
let changed = false;
|
|
262
|
+
if (base?.worca?.models && alias in base.worca.models) {
|
|
263
|
+
delete base.worca.models[alias];
|
|
264
|
+
changed = true;
|
|
265
|
+
}
|
|
266
|
+
if (base?.worca?.pricing?.models && alias in base.worca.pricing.models) {
|
|
267
|
+
delete base.worca.pricing.models[alias];
|
|
268
|
+
changed = true;
|
|
269
|
+
}
|
|
270
|
+
if (changed) {
|
|
271
|
+
atomicWriteSync(settingsPath, `${JSON.stringify(base, null, 2)}\n`);
|
|
272
|
+
fromBase = true;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const localPath = localPathFor(settingsPath);
|
|
276
|
+
if (existsSync(localPath)) {
|
|
277
|
+
const local = readJsonOr(localPath, {});
|
|
278
|
+
if (local?.worca?.models && alias in local.worca.models) {
|
|
279
|
+
delete local.worca.models[alias];
|
|
280
|
+
atomicWriteSync(localPath, `${JSON.stringify(local, null, 2)}\n`);
|
|
281
|
+
fromLocal = true;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return { fromBase, fromLocal };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Find templates (across all tiers we can read from disk) that reference a
|
|
289
|
+
* given alias name in `config.agents.<name>.model`. Used by the editor's
|
|
290
|
+
* "Applied by" section.
|
|
291
|
+
*
|
|
292
|
+
* Reads project + user template.json files directly off disk. Builtin
|
|
293
|
+
* templates are skipped — they're shipped inside the worca-cc Python
|
|
294
|
+
* package and reaching their on-disk path from the JS server would require
|
|
295
|
+
* spawning Python; the cost isn't worth it for an advisory list. Best-effort
|
|
296
|
+
* — silently returns [] when nothing is found or directories don't exist.
|
|
297
|
+
*
|
|
298
|
+
* @returns {Array<{tier: string, template_id: string, agent: string}>}
|
|
299
|
+
*/
|
|
300
|
+
function findReferencingTemplates(alias, projectSettingsPath) {
|
|
301
|
+
const refs = [];
|
|
302
|
+
|
|
303
|
+
const projectDir = projectSettingsPath
|
|
304
|
+
? join(dirname(projectSettingsPath), 'templates')
|
|
305
|
+
: null;
|
|
306
|
+
const userDir = templatesDir();
|
|
307
|
+
|
|
308
|
+
for (const [tier, dir] of [
|
|
309
|
+
['project', projectDir],
|
|
310
|
+
['user', userDir],
|
|
311
|
+
]) {
|
|
312
|
+
if (!dir || !existsSync(dir)) continue;
|
|
313
|
+
let entries;
|
|
314
|
+
try {
|
|
315
|
+
entries = readdirSync(dir);
|
|
316
|
+
} catch {
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
for (const entry of entries) {
|
|
320
|
+
const manifestPath = resolvePath(dir, entry, 'template.json');
|
|
321
|
+
if (!existsSync(manifestPath)) continue;
|
|
322
|
+
try {
|
|
323
|
+
const st = statSync(manifestPath);
|
|
324
|
+
if (!st.isFile()) continue;
|
|
325
|
+
const data = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
326
|
+
const agents = data?.config?.agents || {};
|
|
327
|
+
for (const [agentName, agentCfg] of Object.entries(agents)) {
|
|
328
|
+
if (
|
|
329
|
+
agentCfg &&
|
|
330
|
+
typeof agentCfg === 'object' &&
|
|
331
|
+
agentCfg.model === alias
|
|
332
|
+
) {
|
|
333
|
+
refs.push({
|
|
334
|
+
tier,
|
|
335
|
+
template_id: data.id || entry,
|
|
336
|
+
agent: agentName,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
} catch {
|
|
341
|
+
// skip unreadable / malformed template.json
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return refs;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export function createModelsRouter({ settingsPath: staticPath } = {}) {
|
|
349
|
+
const router = Router({ mergeParams: true });
|
|
350
|
+
|
|
351
|
+
function resolveProjectSettingsPath(req) {
|
|
352
|
+
return req.project?.settingsPath || staticPath || null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// GET / — list all aliases across tiers (flat, each row carries `tier`)
|
|
356
|
+
router.get('/', (req, res) => {
|
|
357
|
+
const projectPath = resolveProjectSettingsPath(req);
|
|
358
|
+
try {
|
|
359
|
+
res.json({ ok: true, models: listAllModels(projectPath) });
|
|
360
|
+
} catch (err) {
|
|
361
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// GET /:tier/:alias — single entry
|
|
366
|
+
router.get('/:tier/:alias', (req, res) => {
|
|
367
|
+
const { tier, alias } = req.params;
|
|
368
|
+
if (!['builtin', 'user', 'project'].includes(tier)) {
|
|
369
|
+
return res.status(400).json({ ok: false, error: 'invalid tier' });
|
|
370
|
+
}
|
|
371
|
+
const projectPath = resolveProjectSettingsPath(req);
|
|
372
|
+
const entry = readSingleModel(tier, projectPath, alias);
|
|
373
|
+
if (!entry) {
|
|
374
|
+
return res.status(404).json({ ok: false, error: 'not found' });
|
|
375
|
+
}
|
|
376
|
+
res.json({
|
|
377
|
+
ok: true,
|
|
378
|
+
model: entry,
|
|
379
|
+
applied_by: findReferencingTemplates(alias, projectPath),
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// PUT /:tier/:alias — create or update (supports rename via body.alias)
|
|
384
|
+
router.put('/:tier/:alias', (req, res) => {
|
|
385
|
+
const { tier, alias: urlAlias } = req.params;
|
|
386
|
+
if (tier === 'builtin') {
|
|
387
|
+
return res
|
|
388
|
+
.status(403)
|
|
389
|
+
.json({ ok: false, error: 'builtin tier is read-only' });
|
|
390
|
+
}
|
|
391
|
+
if (!['user', 'project'].includes(tier)) {
|
|
392
|
+
return res.status(400).json({ ok: false, error: 'invalid tier' });
|
|
393
|
+
}
|
|
394
|
+
const projectPath = resolveProjectSettingsPath(req);
|
|
395
|
+
if (tier === 'project' && !projectPath) {
|
|
396
|
+
return res
|
|
397
|
+
.status(501)
|
|
398
|
+
.json({ ok: false, error: 'project settings path not configured' });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const body = req.body || {};
|
|
402
|
+
const newAlias =
|
|
403
|
+
typeof body.alias === 'string' && body.alias ? body.alias : urlAlias;
|
|
404
|
+
if (!ALIAS_RE.test(newAlias)) {
|
|
405
|
+
return res.status(400).json({
|
|
406
|
+
ok: false,
|
|
407
|
+
error: 'alias must match [a-zA-Z0-9_-], 1-64 chars',
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
if (typeof body.id !== 'string' || !body.id) {
|
|
411
|
+
return res.status(400).json({ ok: false, error: 'id is required' });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const env = body.env || {};
|
|
415
|
+
if (typeof env !== 'object' || Array.isArray(env)) {
|
|
416
|
+
return res
|
|
417
|
+
.status(400)
|
|
418
|
+
.json({ ok: false, error: 'env must be an object' });
|
|
419
|
+
}
|
|
420
|
+
for (const [k, v] of Object.entries(env)) {
|
|
421
|
+
if (typeof k !== 'string' || k === '') {
|
|
422
|
+
return res
|
|
423
|
+
.status(400)
|
|
424
|
+
.json({ ok: false, error: 'env keys must be non-empty strings' });
|
|
425
|
+
}
|
|
426
|
+
if (isReservedKey(k)) {
|
|
427
|
+
return res.status(400).json({
|
|
428
|
+
ok: false,
|
|
429
|
+
key: k,
|
|
430
|
+
error: `Key "${k}" is reserved and cannot be used as a model env var`,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
if (typeof v !== 'string') {
|
|
434
|
+
return res.status(400).json({
|
|
435
|
+
ok: false,
|
|
436
|
+
key: k,
|
|
437
|
+
error: `value for "${k}" must be a string`,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const pricingRaw = body.pricing || {};
|
|
443
|
+
if (typeof pricingRaw !== 'object' || Array.isArray(pricingRaw)) {
|
|
444
|
+
return res
|
|
445
|
+
.status(400)
|
|
446
|
+
.json({ ok: false, error: 'pricing must be an object' });
|
|
447
|
+
}
|
|
448
|
+
const pricing = {};
|
|
449
|
+
for (const field of PRICING_FIELDS) {
|
|
450
|
+
const v = pricingRaw[field];
|
|
451
|
+
if (v == null || v === '') continue;
|
|
452
|
+
const num = Number(v);
|
|
453
|
+
if (Number.isNaN(num) || num < 0) {
|
|
454
|
+
return res.status(400).json({
|
|
455
|
+
ok: false,
|
|
456
|
+
field,
|
|
457
|
+
error: `pricing.${field} must be a non-negative number`,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
pricing[field] = num;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Rename collision check
|
|
464
|
+
if (newAlias !== urlAlias) {
|
|
465
|
+
const existing = readSingleModel(tier, projectPath, newAlias);
|
|
466
|
+
if (existing) {
|
|
467
|
+
return res.status(409).json({
|
|
468
|
+
ok: false,
|
|
469
|
+
error: `alias "${newAlias}" already exists in ${tier} tier`,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
try {
|
|
475
|
+
if (newAlias !== urlAlias) {
|
|
476
|
+
// Rename: delete the old entry first so we don't leave a stale
|
|
477
|
+
// sibling behind in either settings.json or settings.local.json.
|
|
478
|
+
const existing = readSingleModel(tier, projectPath, urlAlias);
|
|
479
|
+
if (existing) deleteModelEntry(tier, projectPath, urlAlias);
|
|
480
|
+
}
|
|
481
|
+
writeModelEntry(tier, projectPath, newAlias, {
|
|
482
|
+
id: body.id,
|
|
483
|
+
env,
|
|
484
|
+
pricing,
|
|
485
|
+
});
|
|
486
|
+
const result = readSingleModel(tier, projectPath, newAlias);
|
|
487
|
+
res.json({ ok: true, model: result });
|
|
488
|
+
} catch (err) {
|
|
489
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
// DELETE /:tier/:alias — remove from both settings.json and settings.local.json
|
|
494
|
+
router.delete('/:tier/:alias', (req, res) => {
|
|
495
|
+
const { tier, alias } = req.params;
|
|
496
|
+
if (tier === 'builtin') {
|
|
497
|
+
return res
|
|
498
|
+
.status(403)
|
|
499
|
+
.json({ ok: false, error: 'builtin tier is read-only' });
|
|
500
|
+
}
|
|
501
|
+
if (!['user', 'project'].includes(tier)) {
|
|
502
|
+
return res.status(400).json({ ok: false, error: 'invalid tier' });
|
|
503
|
+
}
|
|
504
|
+
const projectPath = resolveProjectSettingsPath(req);
|
|
505
|
+
if (tier === 'project' && !projectPath) {
|
|
506
|
+
return res
|
|
507
|
+
.status(501)
|
|
508
|
+
.json({ ok: false, error: 'project settings path not configured' });
|
|
509
|
+
}
|
|
510
|
+
try {
|
|
511
|
+
const result = deleteModelEntry(tier, projectPath, alias);
|
|
512
|
+
res.json({
|
|
513
|
+
ok: true,
|
|
514
|
+
alias,
|
|
515
|
+
removed: result.fromBase || result.fromLocal,
|
|
516
|
+
from_base: result.fromBase,
|
|
517
|
+
from_local: result.fromLocal,
|
|
518
|
+
});
|
|
519
|
+
} catch (err) {
|
|
520
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// POST /:tier/:alias/duplicate — copy to a destination tier+alias
|
|
525
|
+
router.post('/:tier/:alias/duplicate', (req, res) => {
|
|
526
|
+
const { tier: srcTier, alias: srcAlias } = req.params;
|
|
527
|
+
const body = req.body || {};
|
|
528
|
+
const dstTier = body.dst_tier || 'project';
|
|
529
|
+
const dstAlias = body.dst_alias;
|
|
530
|
+
if (!['user', 'project'].includes(dstTier)) {
|
|
531
|
+
return res.status(400).json({ ok: false, error: 'invalid dst_tier' });
|
|
532
|
+
}
|
|
533
|
+
if (typeof dstAlias !== 'string' || !ALIAS_RE.test(dstAlias)) {
|
|
534
|
+
return res.status(400).json({
|
|
535
|
+
ok: false,
|
|
536
|
+
error: 'dst_alias must match [a-zA-Z0-9_-], 1-64 chars',
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
const projectPath = resolveProjectSettingsPath(req);
|
|
540
|
+
if (dstTier === 'project' && !projectPath) {
|
|
541
|
+
return res
|
|
542
|
+
.status(501)
|
|
543
|
+
.json({ ok: false, error: 'project settings path not configured' });
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const src = readSingleModel(srcTier, projectPath, srcAlias);
|
|
547
|
+
if (!src) {
|
|
548
|
+
return res.status(404).json({ ok: false, error: 'source not found' });
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
const existing = readSingleModel(dstTier, projectPath, dstAlias);
|
|
552
|
+
if (existing) {
|
|
553
|
+
return res.status(409).json({
|
|
554
|
+
ok: false,
|
|
555
|
+
error: `alias "${dstAlias}" already exists in ${dstTier} tier`,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
try {
|
|
560
|
+
writeModelEntry(dstTier, projectPath, dstAlias, {
|
|
561
|
+
id: src.id,
|
|
562
|
+
env: src.env || {},
|
|
563
|
+
pricing: src.pricing || {},
|
|
564
|
+
});
|
|
565
|
+
const result = readSingleModel(dstTier, projectPath, dstAlias);
|
|
566
|
+
res.json({ ok: true, model: result });
|
|
567
|
+
} catch (err) {
|
|
568
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
569
|
+
}
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
return router;
|
|
573
|
+
}
|
|
@@ -544,6 +544,9 @@ export class ProcessManager {
|
|
|
544
544
|
if (opts.maxBeads != null) {
|
|
545
545
|
args.push('--max-beads', String(opts.maxBeads));
|
|
546
546
|
}
|
|
547
|
+
if (opts.claudeMdMode != null) {
|
|
548
|
+
args.push('--claude-md-mode', String(opts.claudeMdMode));
|
|
549
|
+
}
|
|
547
550
|
if (opts.planFile) {
|
|
548
551
|
args.push('--plan', opts.planFile);
|
|
549
552
|
}
|
package/server/project-routes.js
CHANGED
|
@@ -29,6 +29,7 @@ import { getDefaultBranch } from './git-helpers.js';
|
|
|
29
29
|
import { extractAndStripGlobalKeys } from './global-keys.js';
|
|
30
30
|
import { LaunchLock } from './launch-lock.js';
|
|
31
31
|
import { createModelEnvRouter } from './model-env-routes.js';
|
|
32
|
+
import { createModelsRouter } from './models-routes.js';
|
|
32
33
|
import { globalSettingsPath, preferencesPath } from './paths.js';
|
|
33
34
|
import { readPreferences } from './preferences.js';
|
|
34
35
|
import { ProcessManager } from './process-manager.js';
|
|
@@ -462,9 +463,12 @@ export function createProjectScopedRoutes({
|
|
|
462
463
|
res.json({ ok: true, files });
|
|
463
464
|
});
|
|
464
465
|
|
|
465
|
-
// --- Model env endpoints (writes wholesale to settings.local.json) ---
|
|
466
|
+
// --- Model env endpoints (legacy, writes wholesale to settings.local.json) ---
|
|
466
467
|
router.use('/settings/model-env', createModelEnvRouter());
|
|
467
468
|
|
|
469
|
+
// --- Models CRUD (tier-aware: builtin/user/project + co-located pricing) ---
|
|
470
|
+
router.use('/models', createModelsRouter());
|
|
471
|
+
|
|
468
472
|
// --- Template CRUD endpoints (templates-routes.js) ---
|
|
469
473
|
router.use(createTemplatesRoutes());
|
|
470
474
|
|
|
@@ -955,6 +959,7 @@ export function createProjectScopedRoutes({
|
|
|
955
959
|
maxBeads,
|
|
956
960
|
branch,
|
|
957
961
|
template,
|
|
962
|
+
claudeMdMode,
|
|
958
963
|
} = body;
|
|
959
964
|
if (body.inputType && sourceType === undefined) {
|
|
960
965
|
if (body.inputType === 'prompt') {
|
|
@@ -1025,6 +1030,25 @@ export function createProjectScopedRoutes({
|
|
|
1025
1030
|
}
|
|
1026
1031
|
}
|
|
1027
1032
|
|
|
1033
|
+
const CLAUDE_MD_MODES = new Set([
|
|
1034
|
+
'all',
|
|
1035
|
+
'project',
|
|
1036
|
+
'project+local',
|
|
1037
|
+
'none',
|
|
1038
|
+
]);
|
|
1039
|
+
if (claudeMdMode !== undefined && claudeMdMode !== null) {
|
|
1040
|
+
if (
|
|
1041
|
+
typeof claudeMdMode !== 'string' ||
|
|
1042
|
+
!CLAUDE_MD_MODES.has(claudeMdMode)
|
|
1043
|
+
) {
|
|
1044
|
+
return res.status(400).json({
|
|
1045
|
+
ok: false,
|
|
1046
|
+
error:
|
|
1047
|
+
'claudeMdMode must be one of: all, project, project+local, none',
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1028
1052
|
const hasSource = sourceType !== 'none' && sourceValue;
|
|
1029
1053
|
const hasPlan = typeof planFile === 'string' && planFile.trim().length > 0;
|
|
1030
1054
|
const hasPrompt = typeof prompt === 'string' && prompt.length > 0;
|
|
@@ -1077,6 +1101,7 @@ export function createProjectScopedRoutes({
|
|
|
1077
1101
|
planFile: hasPlan ? planFile.trim() : undefined,
|
|
1078
1102
|
branch: branch || undefined,
|
|
1079
1103
|
template: template || undefined,
|
|
1104
|
+
claudeMdMode: claudeMdMode || undefined,
|
|
1080
1105
|
});
|
|
1081
1106
|
const { broadcast } = req.app.locals;
|
|
1082
1107
|
if (broadcast) broadcast('run-started', { pid: result.pid });
|