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.
- package/README.md +1 -1
- package/VERSION +1 -1
- package/arka/SKILL.md +1 -1
- package/arka/skills/fusion/SKILL.md +98 -0
- package/config/hooks/session-start.sh +23 -0
- package/core/fusion/__init__.py +5 -0
- package/core/fusion/__pycache__/__init__.cpython-313.pyc +0 -0
- package/core/fusion/__pycache__/cli.cpython-313.pyc +0 -0
- package/core/fusion/__pycache__/engine.cpython-313.pyc +0 -0
- package/core/fusion/__pycache__/panel_builder.cpython-313.pyc +0 -0
- package/core/fusion/cli.py +88 -0
- package/core/fusion/engine.py +153 -0
- package/core/fusion/panel_builder.py +58 -0
- package/core/governance/__pycache__/redo_counter.cpython-313.pyc +0 -0
- package/core/governance/redo_counter.py +81 -0
- package/core/hooks/__pycache__/post_tool_use.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/pre_tool_use.cpython-313.pyc +0 -0
- package/core/hooks/__pycache__/pre_tool_use.cpython-314.pyc +0 -0
- package/core/hooks/__pycache__/user_prompt_submit.cpython-313.pyc +0 -0
- package/core/hooks/post_tool_use.py +26 -12
- package/core/hooks/pre_tool_use.py +4 -0
- package/core/hooks/user_prompt_submit.py +7 -0
- package/core/memory/__pycache__/rehydrator.cpython-312.pyc +0 -0
- package/core/memory/__pycache__/session_store.cpython-312.pyc +0 -0
- package/core/runtime/__pycache__/model_router.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/model_router_cli.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/model_routing_context.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/ollama_discovery.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/runtime_models.cpython-313.pyc +0 -0
- package/core/runtime/model_router.py +17 -0
- package/core/runtime/model_router_cli.py +70 -1
- package/core/runtime/model_routing_context.py +82 -0
- package/core/runtime/ollama_discovery.py +96 -0
- package/core/runtime/runtime_models.py +51 -0
- package/core/workflow/__pycache__/flow_authorization.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/flow_authorization.cpython-314.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-313.pyc +0 -0
- package/core/workflow/__pycache__/flow_enforcer.cpython-314.pyc +0 -0
- package/core/workflow/flow_authorization.py +181 -0
- package/core/workflow/flow_enforcer.py +56 -11
- package/dashboard/app/layouts/default.vue +7 -0
- package/dashboard/app/pages/models.vue +423 -0
- package/installer/cli.js +21 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
- 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
package/pyproject.toml
CHANGED
|
Binary file
|
package/scripts/dashboard-api.py
CHANGED
|
@@ -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()
|