@worca/ui 0.44.0 → 0.45.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.
@@ -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
+ }
@@ -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