arkaos 4.2.0 → 4.3.1

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 (47) hide show
  1. package/README.md +1 -1
  2. package/VERSION +1 -1
  3. package/arka/SKILL.md +1 -1
  4. package/arka/skills/fusion/SKILL.md +98 -0
  5. package/config/hooks/session-start.sh +23 -0
  6. package/core/fusion/__init__.py +5 -0
  7. package/core/fusion/__pycache__/__init__.cpython-313.pyc +0 -0
  8. package/core/fusion/__pycache__/cli.cpython-313.pyc +0 -0
  9. package/core/fusion/__pycache__/engine.cpython-313.pyc +0 -0
  10. package/core/fusion/__pycache__/panel_builder.cpython-313.pyc +0 -0
  11. package/core/fusion/cli.py +88 -0
  12. package/core/fusion/engine.py +153 -0
  13. package/core/fusion/panel_builder.py +58 -0
  14. package/core/governance/__pycache__/redo_counter.cpython-313.pyc +0 -0
  15. package/core/governance/redo_counter.py +81 -0
  16. package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
  17. package/core/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
  18. package/core/hooks/__pycache__/pre_tool_use.cpython-314.pyc +0 -0
  19. package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
  20. package/core/hooks/post_tool_use.py +26 -12
  21. package/core/hooks/pre_tool_use.py +4 -0
  22. package/core/hooks/user_prompt_submit.py +7 -0
  23. package/core/memory/__pycache__/rehydrator.cpython-312.pyc +0 -0
  24. package/core/memory/__pycache__/session_store.cpython-312.pyc +0 -0
  25. package/core/runtime/__pycache__/model_router.cpython-313.pyc +0 -0
  26. package/core/runtime/__pycache__/model_router_cli.cpython-313.pyc +0 -0
  27. package/core/runtime/__pycache__/model_routing_context.cpython-313.pyc +0 -0
  28. package/core/runtime/__pycache__/ollama_discovery.cpython-313.pyc +0 -0
  29. package/core/runtime/__pycache__/runtime_models.cpython-313.pyc +0 -0
  30. package/core/runtime/model_router.py +17 -0
  31. package/core/runtime/model_router_cli.py +70 -1
  32. package/core/runtime/model_routing_context.py +82 -0
  33. package/core/runtime/ollama_discovery.py +96 -0
  34. package/core/runtime/runtime_models.py +51 -0
  35. package/core/workflow/__pycache__/flow_authorization.cpython-313.pyc +0 -0
  36. package/core/workflow/__pycache__/flow_authorization.cpython-314.pyc +0 -0
  37. package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
  38. package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
  39. package/core/workflow/flow_authorization.py +181 -0
  40. package/core/workflow/flow_enforcer.py +56 -11
  41. package/dashboard/app/layouts/default.vue +7 -0
  42. package/dashboard/app/pages/models.vue +423 -0
  43. package/installer/cli.js +21 -0
  44. package/package.json +1 -1
  45. package/pyproject.toml +1 -1
  46. package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
  47. package/scripts/dashboard-api.py +90 -0
@@ -0,0 +1,423 @@
1
+ <script setup lang="ts">
2
+ // Model Fabric (PR-C) — who runs what, what this machine offers, where
3
+ // tokens flow. Backed by /api/models, /api/models/role, /api/models/usage.
4
+
5
+ interface ResolvedRole {
6
+ role: string
7
+ provider: string
8
+ model: string
9
+ effort: string
10
+ source: string
11
+ description?: string
12
+ }
13
+
14
+ interface RuntimeModel {
15
+ value: string
16
+ label: string
17
+ tier: string
18
+ note: string
19
+ }
20
+
21
+ interface OllamaModel {
22
+ name: string
23
+ size_gb: number
24
+ family: string
25
+ parameter_size: string
26
+ }
27
+
28
+ interface ModelsOverview {
29
+ source: string
30
+ config_path: string
31
+ roles: ResolvedRole[]
32
+ providers: Record<string, { type: string }>
33
+ aliases: Record<string, Record<string, string>>
34
+ fusion: { judge: Record<string, string>, panel: Array<Record<string, string>> }
35
+ runtime: { id: string, models: RuntimeModel[] }
36
+ ollama: { installed: boolean, running: boolean, host: string, models: OllamaModel[] }
37
+ keys: { openrouter: boolean, anthropic: boolean }
38
+ }
39
+
40
+ interface BreakdownRow {
41
+ total_cost_usd: number | null
42
+ total_tokens_in: number
43
+ total_tokens_out: number
44
+ call_count: number
45
+ }
46
+
47
+ interface UsageSummary {
48
+ period: string
49
+ call_count: number
50
+ total_cost_usd: number | null
51
+ total_tokens_in: number
52
+ total_tokens_out: number
53
+ total_cached_tokens: number
54
+ by_model: Record<string, BreakdownRow>
55
+ by_provider: Record<string, BreakdownRow>
56
+ }
57
+
58
+ type Period = 'today' | 'week' | 'month' | 'all'
59
+
60
+ const { fetchApi, apiBase } = useApi()
61
+ const toast = useToast()
62
+
63
+ const {
64
+ data: overview,
65
+ status,
66
+ error,
67
+ refresh,
68
+ } = fetchApi<ModelsOverview>('/api/models')
69
+
70
+ const period = ref<Period>('week')
71
+ const periodOptions: { label: string, value: Period }[] = [
72
+ { label: 'Today', value: 'today' },
73
+ { label: '7 days', value: 'week' },
74
+ { label: '30 days', value: 'month' },
75
+ { label: 'All time', value: 'all' },
76
+ ]
77
+ const { data: usage, refresh: refreshUsage } = fetchApi<UsageSummary>(
78
+ '/api/models/usage',
79
+ { query: computed(() => ({ period: period.value })) },
80
+ )
81
+ watch(period, () => refreshUsage())
82
+
83
+ // ─── Provider lanes (signature element: live availability strip) ────────
84
+
85
+ const QUALITY_ROLES = new Set(['design', 'review', 'architecture', 'strategy', 'quality_gate'])
86
+
87
+ const laneStyles: Record<string, { badge: string, dot: string }> = {
88
+ runtime: { badge: 'text-primary bg-primary/10', dot: 'bg-primary' },
89
+ ollama: { badge: 'text-amber-600 dark:text-amber-400 bg-amber-500/10', dot: 'bg-amber-500' },
90
+ openrouter: { badge: 'text-violet-600 dark:text-violet-400 bg-violet-500/10', dot: 'bg-violet-500' },
91
+ anthropic: { badge: 'text-sky-600 dark:text-sky-400 bg-sky-500/10', dot: 'bg-sky-500' },
92
+ }
93
+
94
+ function laneStyle(provider: string) {
95
+ return laneStyles[provider] ?? { badge: 'text-muted bg-muted/10', dot: 'bg-muted' }
96
+ }
97
+
98
+ interface Lane {
99
+ id: string
100
+ label: string
101
+ live: boolean
102
+ detail: string
103
+ }
104
+
105
+ const lanes = computed<Lane[]>(() => {
106
+ const o = overview.value
107
+ if (!o) return []
108
+ const ollama = o.ollama ?? { installed: false, running: false, models: [] }
109
+ const keys = o.keys ?? { openrouter: false, anthropic: false }
110
+ const ollamaDetail = ollama.running
111
+ ? `${(ollama.models ?? []).length} local models`
112
+ : ollama.installed ? 'installed, not running' : 'not installed'
113
+ return [
114
+ { id: 'runtime', label: 'Runtime', live: true, detail: 'active CLI session' },
115
+ { id: 'ollama', label: 'Ollama', live: ollama.running, detail: ollamaDetail },
116
+ { id: 'openrouter', label: 'OpenRouter', live: keys.openrouter, detail: keys.openrouter ? 'key configured' : 'no key' },
117
+ { id: 'anthropic', label: 'Anthropic', live: keys.anthropic, detail: keys.anthropic ? 'key configured' : 'no key' },
118
+ ]
119
+ })
120
+
121
+ // ─── Inline role re-routing ──────────────────────────────────────────────
122
+
123
+ const editingRole = ref<string | null>(null)
124
+ const editTarget = ref('')
125
+ const saving = ref(false)
126
+
127
+ const targetOptions = computed(() => {
128
+ const o = overview.value
129
+ if (!o) return []
130
+ const options: { label: string, value: string }[] = []
131
+ // Concrete runtime models first (Fable 5, Opus, Sonnet, Haiku) so the
132
+ // operator picks a real model, not just an abstract alias.
133
+ for (const m of o.runtime?.models ?? []) {
134
+ options.push({ label: `runtime / ${m.label} · ${m.note}`, value: `runtime/${m.value}` })
135
+ }
136
+ for (const alias of ['best', 'default', 'fast']) {
137
+ options.push({ label: `runtime / ${alias} (alias)`, value: `runtime/${alias}` })
138
+ }
139
+ for (const m of o.ollama?.models ?? []) {
140
+ options.push({ label: `ollama / ${m.name}`, value: `ollama/${m.name}` })
141
+ }
142
+ if (o.keys?.anthropic) {
143
+ for (const alias of ['best', 'default', 'fast']) {
144
+ options.push({ label: `anthropic / ${alias}`, value: `anthropic/${alias}` })
145
+ }
146
+ }
147
+ if (o.keys?.openrouter) {
148
+ options.push({ label: 'openrouter / (type model id)', value: 'openrouter/' })
149
+ }
150
+ return options
151
+ })
152
+
153
+ function startEdit(row: ResolvedRole) {
154
+ editingRole.value = row.role
155
+ editTarget.value = `${row.provider}/${row.model}`
156
+ }
157
+
158
+ async function saveEdit(role: string) {
159
+ if (!editTarget.value.includes('/')) {
160
+ toast.add({ title: 'Target must be provider/model', color: 'error' })
161
+ return
162
+ }
163
+ saving.value = true
164
+ try {
165
+ await $fetch(`${apiBase}/api/models/role`, {
166
+ method: 'POST',
167
+ body: { role, target: editTarget.value },
168
+ })
169
+ toast.add({ title: `${role} re-routed`, description: editTarget.value, color: 'success', icon: 'i-lucide-route' })
170
+ editingRole.value = null
171
+ await refresh()
172
+ } catch (err) {
173
+ toast.add({
174
+ title: 'Re-route failed',
175
+ description: err instanceof Error ? err.message : 'unknown error',
176
+ color: 'error',
177
+ })
178
+ } finally {
179
+ saving.value = false
180
+ }
181
+ }
182
+
183
+ async function useForMechanical(model: OllamaModel) {
184
+ editTarget.value = `ollama/${model.name}`
185
+ await saveEdit('mechanical')
186
+ }
187
+
188
+ // ─── Usage bars ──────────────────────────────────────────────────────────
189
+
190
+ const usageRows = computed<Array<[string, BreakdownRow]>>(() =>
191
+ Object.entries(usage.value?.by_model ?? {}).sort(
192
+ (a, b) => (b[1].total_tokens_in + b[1].total_tokens_out) - (a[1].total_tokens_in + a[1].total_tokens_out),
193
+ ).slice(0, 10),
194
+ )
195
+
196
+ const maxUsageTokens = computed(() => {
197
+ const max = usageRows.value.reduce(
198
+ (acc, [, r]) => Math.max(acc, r.total_tokens_in + r.total_tokens_out), 0,
199
+ )
200
+ return max > 0 ? max : 1
201
+ })
202
+
203
+ function formatTokens(value: number): string {
204
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
205
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
206
+ return value.toString()
207
+ }
208
+
209
+ function formatCost(value: number | null): string {
210
+ if (value === null || value === undefined) return 'n/a'
211
+ if (value === 0) return '$0'
212
+ if (value < 0.01) return `$${value.toFixed(4)}`
213
+ return `$${value.toFixed(2)}`
214
+ }
215
+ </script>
216
+
217
+ <template>
218
+ <UDashboardPanel id="models">
219
+ <template #header>
220
+ <UDashboardNavbar title="Models">
221
+ <template #right>
222
+ <UButton
223
+ label="Refresh"
224
+ variant="ghost"
225
+ icon="i-lucide-refresh-cw"
226
+ size="sm"
227
+ @click="() => { refresh(); refreshUsage() }"
228
+ />
229
+ </template>
230
+ </UDashboardNavbar>
231
+ </template>
232
+
233
+ <template #body>
234
+ <DashboardState
235
+ :status="status"
236
+ :error="error"
237
+ loading-label="Loading model fabric"
238
+ :on-retry="() => refresh()"
239
+ >
240
+ <div class="space-y-6">
241
+ <!-- Provider lanes: live availability strip -->
242
+ <UCard>
243
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-4">
244
+ <div
245
+ v-for="lane in lanes"
246
+ :key="lane.id"
247
+ class="flex items-start gap-3"
248
+ >
249
+ <span class="relative flex h-2.5 w-2.5 mt-1.5">
250
+ <span
251
+ v-if="lane.live"
252
+ class="animate-ping absolute inline-flex h-full w-full rounded-full opacity-60 motion-reduce:hidden"
253
+ :class="laneStyle(lane.id).dot"
254
+ />
255
+ <span
256
+ class="relative inline-flex rounded-full h-2.5 w-2.5"
257
+ :class="lane.live ? laneStyle(lane.id).dot : 'bg-muted/40'"
258
+ />
259
+ </span>
260
+ <div>
261
+ <p class="text-sm font-semibold">{{ lane.label }}</p>
262
+ <p class="text-xs text-muted">{{ lane.detail }}</p>
263
+ </div>
264
+ </div>
265
+ </div>
266
+ </UCard>
267
+
268
+ <!-- Role routing -->
269
+ <UCard>
270
+ <template #header>
271
+ <div class="flex items-center justify-between">
272
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider">
273
+ Role routing
274
+ </p>
275
+ <p class="text-xs text-muted font-mono hidden md:block">{{ overview?.config_path }}</p>
276
+ </div>
277
+ </template>
278
+ <div class="divide-y divide-default">
279
+ <div
280
+ v-for="row in overview?.roles ?? []"
281
+ :key="row.role"
282
+ class="flex items-center gap-3 py-2.5"
283
+ >
284
+ <UIcon
285
+ :name="QUALITY_ROLES.has(row.role) ? 'i-lucide-gem' : 'i-lucide-cog'"
286
+ class="size-4 shrink-0 mt-0.5 self-start"
287
+ :class="QUALITY_ROLES.has(row.role) ? 'text-primary' : 'text-muted'"
288
+ />
289
+ <div class="w-56 shrink-0">
290
+ <p class="text-sm font-medium">{{ row.role }}</p>
291
+ <p v-if="row.description" class="text-xs text-muted leading-snug">
292
+ {{ row.description }}
293
+ </p>
294
+ </div>
295
+ <template v-if="editingRole === row.role">
296
+ <UInputMenu
297
+ v-model="editTarget"
298
+ :items="targetOptions"
299
+ value-key="value"
300
+ create-item
301
+ size="sm"
302
+ class="flex-1 max-w-xs"
303
+ placeholder="provider/model"
304
+ @create="(item: string) => { editTarget = item }"
305
+ />
306
+ <UButton size="xs" label="Save" :loading="saving" @click="saveEdit(row.role)" />
307
+ <UButton size="xs" variant="ghost" label="Cancel" @click="editingRole = null" />
308
+ </template>
309
+ <template v-else>
310
+ <span
311
+ class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
312
+ :class="laneStyle(row.provider).badge"
313
+ >
314
+ {{ row.provider }}
315
+ </span>
316
+ <span class="text-sm font-mono truncate">
317
+ {{ row.model || '(unset)' }}
318
+ </span>
319
+ <UBadge variant="subtle" size="sm" color="neutral">{{ row.effort }}</UBadge>
320
+ <UButton
321
+ icon="i-lucide-pencil"
322
+ variant="ghost"
323
+ size="xs"
324
+ class="ml-auto"
325
+ :aria-label="`Edit ${row.role} routing`"
326
+ @click="startEdit(row)"
327
+ />
328
+ </template>
329
+ </div>
330
+ </div>
331
+ </UCard>
332
+
333
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
334
+ <!-- Local Ollama models -->
335
+ <UCard>
336
+ <template #header>
337
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider">
338
+ Local models (Ollama)
339
+ </p>
340
+ </template>
341
+ <div v-if="overview?.ollama?.running && (overview.ollama?.models ?? []).length" class="divide-y divide-default">
342
+ <div
343
+ v-for="m in (overview.ollama?.models ?? [])"
344
+ :key="m.name"
345
+ class="flex items-center gap-3 py-2"
346
+ >
347
+ <UIcon name="i-lucide-hard-drive" class="size-4 text-amber-500 shrink-0" />
348
+ <div class="min-w-0 flex-1">
349
+ <p class="text-sm font-mono truncate">{{ m.name }}</p>
350
+ <p class="text-xs text-muted">
351
+ {{ m.family || 'unknown family' }}
352
+ <template v-if="m.parameter_size"> · {{ m.parameter_size }}</template>
353
+ <template v-if="m.size_gb"> · {{ m.size_gb }} GB</template>
354
+ <template v-if="!m.size_gb"> · cloud-proxied</template>
355
+ </p>
356
+ </div>
357
+ <UButton
358
+ size="xs"
359
+ variant="soft"
360
+ label="Use for mechanical"
361
+ :loading="saving"
362
+ @click="useForMechanical(m)"
363
+ />
364
+ </div>
365
+ </div>
366
+ <div v-else-if="overview?.ollama?.installed" class="py-6 text-center">
367
+ <p class="text-sm text-muted">Ollama is installed but not running.</p>
368
+ <p class="text-xs text-muted mt-1 font-mono">ollama serve</p>
369
+ </div>
370
+ <div v-else class="py-6 text-center">
371
+ <p class="text-sm text-muted">No local models yet.</p>
372
+ <p class="text-xs text-muted mt-1">
373
+ Install Ollama to run free local models for mechanical work and fusion panels.
374
+ </p>
375
+ </div>
376
+ </UCard>
377
+
378
+ <!-- Usage by model -->
379
+ <UCard>
380
+ <template #header>
381
+ <div class="flex items-center justify-between">
382
+ <p class="text-xs font-semibold text-muted uppercase tracking-wider">
383
+ Usage by model
384
+ </p>
385
+ <USelect v-model="period" :items="periodOptions" size="xs" class="min-w-24" aria-label="Usage period" />
386
+ </div>
387
+ </template>
388
+ <div v-if="usageRows.length" class="space-y-3">
389
+ <div class="flex items-baseline justify-between">
390
+ <p class="text-sm">
391
+ <span class="font-semibold">{{ usage?.call_count ?? 0 }}</span>
392
+ <span class="text-muted"> calls · </span>
393
+ <span class="font-semibold">{{ formatTokens((usage?.total_tokens_in ?? 0) + (usage?.total_tokens_out ?? 0)) }}</span>
394
+ <span class="text-muted"> tokens</span>
395
+ </p>
396
+ <p class="text-sm font-semibold">{{ formatCost(usage?.total_cost_usd ?? null) }}</p>
397
+ </div>
398
+ <div v-for="[name, row] in usageRows" :key="name" class="space-y-1">
399
+ <div class="flex items-center justify-between text-xs">
400
+ <span class="font-mono truncate">{{ name || '(unattributed)' }}</span>
401
+ <span class="text-muted shrink-0 ml-2">
402
+ {{ formatTokens(row.total_tokens_in + row.total_tokens_out) }} · {{ row.call_count }} calls
403
+ </span>
404
+ </div>
405
+ <div class="h-1.5 rounded-full bg-muted/20 overflow-hidden">
406
+ <div
407
+ class="h-full rounded-full bg-primary"
408
+ :style="{ width: `${Math.max(2, ((row.total_tokens_in + row.total_tokens_out) / maxUsageTokens) * 100)}%` }"
409
+ />
410
+ </div>
411
+ </div>
412
+ </div>
413
+ <div v-else class="py-6 text-center">
414
+ <p class="text-sm text-muted">No usage recorded for this period.</p>
415
+ <p class="text-xs text-muted mt-1">Calls made through the Model Fabric land here automatically.</p>
416
+ </div>
417
+ </UCard>
418
+ </div>
419
+ </div>
420
+ </DashboardState>
421
+ </template>
422
+ </UDashboardPanel>
423
+ </template>
package/installer/cli.js CHANGED
@@ -28,6 +28,9 @@ const { values, positionals } = parseArgs({
28
28
  // Model Fabric PR-A — `npx arkaos models [--json] [set ... --effort max]`
29
29
  json: { type: "boolean" },
30
30
  effort: { type: "string" },
31
+ // Fusion — `npx arkaos fusion [--save|--show] "question"`
32
+ save: { type: "boolean" },
33
+ show: { type: "boolean" },
31
34
  },
32
35
  allowPositionals: true,
33
36
  strict: false,
@@ -177,6 +180,24 @@ async function main() {
177
180
  break;
178
181
  }
179
182
 
183
+ case "fusion": {
184
+ const { execSync } = await import("node:child_process");
185
+ const repoRootFusion = join(__dirname, "..");
186
+ const pyFusion = getArkaosPython();
187
+ if (!pyFusion) { console.error("No Python found. Run: npx arkaos install"); process.exit(1); }
188
+ const fusionArgs = positionals.slice(1).map((a) => `"${a}"`).join(" ");
189
+ const saveFlag = values.save ? " --save" : "";
190
+ const showFlag = values.show ? " --show" : "";
191
+ try {
192
+ execSync(`"${pyFusion}" -m core.fusion.cli${saveFlag}${showFlag} ${fusionArgs}`, {
193
+ stdio: "inherit",
194
+ cwd: repoRootFusion,
195
+ env: { ...process.env, ARKAOS_ROOT: repoRootFusion, PYTHONPATH: repoRootFusion },
196
+ });
197
+ } catch { process.exit(1); }
198
+ break;
199
+ }
200
+
180
201
  case "index": {
181
202
  const { execSync } = await import("node:child_process");
182
203
  const indexArgs = positionals.slice(1).join(" ");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "4.2.0",
3
+ "version": "4.3.1",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "4.2.0"
3
+ version = "4.3.1"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -864,6 +864,96 @@ def commands(dept: Optional[str] = Query(None), q: Optional[str] = Query(None)):
864
864
  return {"commands": data, "total": len(data)}
865
865
 
866
866
 
867
+ @app.get("/api/models")
868
+ def models_overview():
869
+ """Model Fabric: roles, providers, Ollama discovery, key presence."""
870
+ from core.runtime.model_router import (
871
+ USER_CONFIG_PATH,
872
+ load_config,
873
+ resolve_all,
874
+ role_description,
875
+ )
876
+ from core.runtime.ollama_discovery import discover
877
+ from core.runtime.runtime_models import detect_runtime_models
878
+
879
+ config, source = load_config()
880
+ keys_path = Path.home() / ".arkaos" / "keys.json"
881
+ try:
882
+ keys = json.loads(keys_path.read_text(encoding="utf-8"))
883
+ except (OSError, json.JSONDecodeError):
884
+ keys = {}
885
+ runtime_id, runtime_models = detect_runtime_models()
886
+ return {
887
+ "source": source,
888
+ "config_path": str(USER_CONFIG_PATH),
889
+ "roles": [
890
+ {**item.model_dump(), "description": role_description(item.role)}
891
+ for item in resolve_all()
892
+ ],
893
+ "providers": {
894
+ name: {"type": spec.get("type", "")}
895
+ for name, spec in config.providers.items()
896
+ },
897
+ "aliases": config.aliases,
898
+ "fusion": config.fusion.model_dump(),
899
+ "runtime": {"id": runtime_id, "models": runtime_models},
900
+ "ollama": discover().to_dict(),
901
+ "keys": {
902
+ "openrouter": bool(
903
+ os.environ.get("OPENROUTER_API_KEY")
904
+ or keys.get("OPENROUTER_API_KEY")
905
+ ),
906
+ "anthropic": bool(os.environ.get("ANTHROPIC_API_KEY")),
907
+ },
908
+ }
909
+
910
+
911
+ @app.post("/api/models/role")
912
+ async def models_set_role(request: Request):
913
+ """Re-route a role: {role, target: 'provider/model', effort?}."""
914
+ from core.runtime.model_router import set_role
915
+
916
+ body = await request.json()
917
+ role = str(body.get("role", "")).strip()
918
+ target = str(body.get("target", "")).strip()
919
+ effort = body.get("effort") or None
920
+ if not role or not target:
921
+ return JSONResponse(
922
+ {"error": "role and target (provider/model) are required"},
923
+ status_code=422,
924
+ )
925
+ try:
926
+ item = set_role(role, target, effort=effort)
927
+ except ValueError as exc:
928
+ return JSONResponse({"error": str(exc)}, status_code=422)
929
+ return item.model_dump()
930
+
931
+
932
+ @app.get("/api/models/usage")
933
+ def models_usage(period: str = "today"):
934
+ """Per-model/provider/category token + cost aggregation."""
935
+ from core.runtime.llm_cost_telemetry import VALID_PERIODS, summarise
936
+
937
+ if period not in VALID_PERIODS:
938
+ return JSONResponse(
939
+ {"error": f"period must be one of {list(VALID_PERIODS)}"},
940
+ status_code=422,
941
+ )
942
+ summary = summarise(period=period)
943
+ return {
944
+ "period": summary.period,
945
+ "call_count": summary.call_count,
946
+ "total_cost_usd": summary.total_cost_usd,
947
+ "total_tokens_in": summary.total_tokens_in,
948
+ "total_tokens_out": summary.total_tokens_out,
949
+ "total_cached_tokens": summary.total_cached_tokens,
950
+ "cache_hit_rate": summary.cache_hit_rate,
951
+ "by_model": summary.by_model,
952
+ "by_provider": summary.by_provider,
953
+ "by_category": summary.by_category,
954
+ }
955
+
956
+
867
957
  @app.get("/api/budget")
868
958
  def budget_all():
869
959
  mgr = _get_budget_manager()