arkaos 4.1.1 → 4.3.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/README.md +1 -1
- package/VERSION +1 -1
- package/arka/SKILL.md +1 -1
- package/arka/skills/flow/SKILL.md +15 -0
- package/arka/skills/fusion/SKILL.md +91 -0
- package/config/constitution.yaml +15 -7
- package/config/hooks/session-start.sh +22 -0
- package/config/models.yaml +70 -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/cli.py +37 -0
- package/core/fusion/engine.py +153 -0
- package/core/governance/__pycache__/redo_counter.cpython-313.pyc +0 -0
- package/core/governance/redo_counter.py +81 -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/pre_tool_use.py +55 -3
- package/core/obsidian/__pycache__/relator.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_provider.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/llm_provider.cpython-314.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__/ollama_discovery.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/openrouter_provider.cpython-313.pyc +0 -0
- package/core/runtime/__pycache__/openrouter_provider.cpython-314.pyc +0 -0
- package/core/runtime/llm_provider.py +10 -1
- package/core/runtime/model_router.py +204 -0
- package/core/runtime/model_router_cli.py +151 -0
- package/core/runtime/ollama_discovery.py +96 -0
- package/core/runtime/openrouter_provider.py +172 -0
- package/core/sync/__pycache__/__init__.cpython-312.pyc +0 -0
- package/core/sync/__pycache__/engine.cpython-312.pyc +0 -0
- package/core/sync/__pycache__/manifest.cpython-312.pyc +0 -0
- package/core/workflow/__pycache__/frontend_gate.cpython-313.pyc +0 -0
- package/core/workflow/frontend_gate.py +162 -0
- package/dashboard/app/layouts/default.vue +7 -0
- package/dashboard/app/pages/models.vue +404 -0
- package/installer/autostart.js +16 -2
- package/installer/cli.js +23 -0
- package/installer/keys.js +1 -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 +79 -0
- package/scripts/start-dashboard.sh +25 -1
|
@@ -0,0 +1,404 @@
|
|
|
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
|
+
}
|
|
12
|
+
|
|
13
|
+
interface OllamaModel {
|
|
14
|
+
name: string
|
|
15
|
+
size_gb: number
|
|
16
|
+
family: string
|
|
17
|
+
parameter_size: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ModelsOverview {
|
|
21
|
+
source: string
|
|
22
|
+
config_path: string
|
|
23
|
+
roles: ResolvedRole[]
|
|
24
|
+
providers: Record<string, { type: string }>
|
|
25
|
+
aliases: Record<string, Record<string, string>>
|
|
26
|
+
fusion: { judge: Record<string, string>, panel: Array<Record<string, string>> }
|
|
27
|
+
ollama: { installed: boolean, running: boolean, host: string, models: OllamaModel[] }
|
|
28
|
+
keys: { openrouter: boolean, anthropic: boolean }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface BreakdownRow {
|
|
32
|
+
total_cost_usd: number | null
|
|
33
|
+
total_tokens_in: number
|
|
34
|
+
total_tokens_out: number
|
|
35
|
+
call_count: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface UsageSummary {
|
|
39
|
+
period: string
|
|
40
|
+
call_count: number
|
|
41
|
+
total_cost_usd: number | null
|
|
42
|
+
total_tokens_in: number
|
|
43
|
+
total_tokens_out: number
|
|
44
|
+
total_cached_tokens: number
|
|
45
|
+
by_model: Record<string, BreakdownRow>
|
|
46
|
+
by_provider: Record<string, BreakdownRow>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type Period = 'today' | 'week' | 'month' | 'all'
|
|
50
|
+
|
|
51
|
+
const { fetchApi, apiBase } = useApi()
|
|
52
|
+
const toast = useToast()
|
|
53
|
+
|
|
54
|
+
const {
|
|
55
|
+
data: overview,
|
|
56
|
+
status,
|
|
57
|
+
error,
|
|
58
|
+
refresh,
|
|
59
|
+
} = fetchApi<ModelsOverview>('/api/models')
|
|
60
|
+
|
|
61
|
+
const period = ref<Period>('week')
|
|
62
|
+
const periodOptions: { label: string, value: Period }[] = [
|
|
63
|
+
{ label: 'Today', value: 'today' },
|
|
64
|
+
{ label: '7 days', value: 'week' },
|
|
65
|
+
{ label: '30 days', value: 'month' },
|
|
66
|
+
{ label: 'All time', value: 'all' },
|
|
67
|
+
]
|
|
68
|
+
const { data: usage, refresh: refreshUsage } = fetchApi<UsageSummary>(
|
|
69
|
+
'/api/models/usage',
|
|
70
|
+
{ query: computed(() => ({ period: period.value })) },
|
|
71
|
+
)
|
|
72
|
+
watch(period, () => refreshUsage())
|
|
73
|
+
|
|
74
|
+
// ─── Provider lanes (signature element: live availability strip) ────────
|
|
75
|
+
|
|
76
|
+
const QUALITY_ROLES = new Set(['design', 'review', 'architecture', 'strategy', 'quality_gate'])
|
|
77
|
+
|
|
78
|
+
const laneStyles: Record<string, { badge: string, dot: string }> = {
|
|
79
|
+
runtime: { badge: 'text-primary bg-primary/10', dot: 'bg-primary' },
|
|
80
|
+
ollama: { badge: 'text-amber-600 dark:text-amber-400 bg-amber-500/10', dot: 'bg-amber-500' },
|
|
81
|
+
openrouter: { badge: 'text-violet-600 dark:text-violet-400 bg-violet-500/10', dot: 'bg-violet-500' },
|
|
82
|
+
anthropic: { badge: 'text-sky-600 dark:text-sky-400 bg-sky-500/10', dot: 'bg-sky-500' },
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function laneStyle(provider: string) {
|
|
86
|
+
return laneStyles[provider] ?? { badge: 'text-muted bg-muted/10', dot: 'bg-muted' }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface Lane {
|
|
90
|
+
id: string
|
|
91
|
+
label: string
|
|
92
|
+
live: boolean
|
|
93
|
+
detail: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const lanes = computed<Lane[]>(() => {
|
|
97
|
+
const o = overview.value
|
|
98
|
+
if (!o) return []
|
|
99
|
+
const ollama = o.ollama ?? { installed: false, running: false, models: [] }
|
|
100
|
+
const keys = o.keys ?? { openrouter: false, anthropic: false }
|
|
101
|
+
const ollamaDetail = ollama.running
|
|
102
|
+
? `${(ollama.models ?? []).length} local models`
|
|
103
|
+
: ollama.installed ? 'installed, not running' : 'not installed'
|
|
104
|
+
return [
|
|
105
|
+
{ id: 'runtime', label: 'Runtime', live: true, detail: 'active CLI session' },
|
|
106
|
+
{ id: 'ollama', label: 'Ollama', live: ollama.running, detail: ollamaDetail },
|
|
107
|
+
{ id: 'openrouter', label: 'OpenRouter', live: keys.openrouter, detail: keys.openrouter ? 'key configured' : 'no key' },
|
|
108
|
+
{ id: 'anthropic', label: 'Anthropic', live: keys.anthropic, detail: keys.anthropic ? 'key configured' : 'no key' },
|
|
109
|
+
]
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// ─── Inline role re-routing ──────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
const editingRole = ref<string | null>(null)
|
|
115
|
+
const editTarget = ref('')
|
|
116
|
+
const saving = ref(false)
|
|
117
|
+
|
|
118
|
+
const targetOptions = computed(() => {
|
|
119
|
+
const o = overview.value
|
|
120
|
+
if (!o) return []
|
|
121
|
+
const options: { label: string, value: string }[] = []
|
|
122
|
+
for (const alias of ['best', 'default', 'fast']) {
|
|
123
|
+
options.push({ label: `runtime / ${alias}`, value: `runtime/${alias}` })
|
|
124
|
+
}
|
|
125
|
+
for (const m of o.ollama?.models ?? []) {
|
|
126
|
+
options.push({ label: `ollama / ${m.name}`, value: `ollama/${m.name}` })
|
|
127
|
+
}
|
|
128
|
+
if (o.keys?.anthropic) {
|
|
129
|
+
for (const alias of ['best', 'default', 'fast']) {
|
|
130
|
+
options.push({ label: `anthropic / ${alias}`, value: `anthropic/${alias}` })
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (o.keys?.openrouter) {
|
|
134
|
+
options.push({ label: 'openrouter / (type model id)', value: 'openrouter/' })
|
|
135
|
+
}
|
|
136
|
+
return options
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
function startEdit(row: ResolvedRole) {
|
|
140
|
+
editingRole.value = row.role
|
|
141
|
+
editTarget.value = `${row.provider}/${row.model}`
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function saveEdit(role: string) {
|
|
145
|
+
if (!editTarget.value.includes('/')) {
|
|
146
|
+
toast.add({ title: 'Target must be provider/model', color: 'error' })
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
saving.value = true
|
|
150
|
+
try {
|
|
151
|
+
await $fetch(`${apiBase.value}/api/models/role`, {
|
|
152
|
+
method: 'POST',
|
|
153
|
+
body: { role, target: editTarget.value },
|
|
154
|
+
})
|
|
155
|
+
toast.add({ title: `${role} re-routed`, description: editTarget.value, color: 'success', icon: 'i-lucide-route' })
|
|
156
|
+
editingRole.value = null
|
|
157
|
+
await refresh()
|
|
158
|
+
} catch (err) {
|
|
159
|
+
toast.add({
|
|
160
|
+
title: 'Re-route failed',
|
|
161
|
+
description: err instanceof Error ? err.message : 'unknown error',
|
|
162
|
+
color: 'error',
|
|
163
|
+
})
|
|
164
|
+
} finally {
|
|
165
|
+
saving.value = false
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function useForMechanical(model: OllamaModel) {
|
|
170
|
+
editTarget.value = `ollama/${model.name}`
|
|
171
|
+
await saveEdit('mechanical')
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─── Usage bars ──────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
const usageRows = computed<Array<[string, BreakdownRow]>>(() =>
|
|
177
|
+
Object.entries(usage.value?.by_model ?? {}).sort(
|
|
178
|
+
(a, b) => (b[1].total_tokens_in + b[1].total_tokens_out) - (a[1].total_tokens_in + a[1].total_tokens_out),
|
|
179
|
+
).slice(0, 10),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
const maxUsageTokens = computed(() => {
|
|
183
|
+
const max = usageRows.value.reduce(
|
|
184
|
+
(acc, [, r]) => Math.max(acc, r.total_tokens_in + r.total_tokens_out), 0,
|
|
185
|
+
)
|
|
186
|
+
return max > 0 ? max : 1
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
function formatTokens(value: number): string {
|
|
190
|
+
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
|
|
191
|
+
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
|
|
192
|
+
return value.toString()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function formatCost(value: number | null): string {
|
|
196
|
+
if (value === null || value === undefined) return 'n/a'
|
|
197
|
+
if (value === 0) return '$0'
|
|
198
|
+
if (value < 0.01) return `$${value.toFixed(4)}`
|
|
199
|
+
return `$${value.toFixed(2)}`
|
|
200
|
+
}
|
|
201
|
+
</script>
|
|
202
|
+
|
|
203
|
+
<template>
|
|
204
|
+
<UDashboardPanel id="models">
|
|
205
|
+
<template #header>
|
|
206
|
+
<UDashboardNavbar title="Models">
|
|
207
|
+
<template #right>
|
|
208
|
+
<UButton
|
|
209
|
+
label="Refresh"
|
|
210
|
+
variant="ghost"
|
|
211
|
+
icon="i-lucide-refresh-cw"
|
|
212
|
+
size="sm"
|
|
213
|
+
@click="() => { refresh(); refreshUsage() }"
|
|
214
|
+
/>
|
|
215
|
+
</template>
|
|
216
|
+
</UDashboardNavbar>
|
|
217
|
+
</template>
|
|
218
|
+
|
|
219
|
+
<template #body>
|
|
220
|
+
<DashboardState
|
|
221
|
+
:status="status"
|
|
222
|
+
:error="error"
|
|
223
|
+
loading-label="Loading model fabric"
|
|
224
|
+
:on-retry="() => refresh()"
|
|
225
|
+
>
|
|
226
|
+
<div class="space-y-6">
|
|
227
|
+
<!-- Provider lanes: live availability strip -->
|
|
228
|
+
<UCard>
|
|
229
|
+
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
230
|
+
<div
|
|
231
|
+
v-for="lane in lanes"
|
|
232
|
+
:key="lane.id"
|
|
233
|
+
class="flex items-start gap-3"
|
|
234
|
+
>
|
|
235
|
+
<span class="relative flex h-2.5 w-2.5 mt-1.5">
|
|
236
|
+
<span
|
|
237
|
+
v-if="lane.live"
|
|
238
|
+
class="animate-ping absolute inline-flex h-full w-full rounded-full opacity-60 motion-reduce:hidden"
|
|
239
|
+
:class="laneStyle(lane.id).dot"
|
|
240
|
+
/>
|
|
241
|
+
<span
|
|
242
|
+
class="relative inline-flex rounded-full h-2.5 w-2.5"
|
|
243
|
+
:class="lane.live ? laneStyle(lane.id).dot : 'bg-muted/40'"
|
|
244
|
+
/>
|
|
245
|
+
</span>
|
|
246
|
+
<div>
|
|
247
|
+
<p class="text-sm font-semibold">{{ lane.label }}</p>
|
|
248
|
+
<p class="text-xs text-muted">{{ lane.detail }}</p>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
</UCard>
|
|
253
|
+
|
|
254
|
+
<!-- Role routing -->
|
|
255
|
+
<UCard>
|
|
256
|
+
<template #header>
|
|
257
|
+
<div class="flex items-center justify-between">
|
|
258
|
+
<p class="text-xs font-semibold text-muted uppercase tracking-wider">
|
|
259
|
+
Role routing
|
|
260
|
+
</p>
|
|
261
|
+
<p class="text-xs text-muted font-mono hidden md:block">{{ overview?.config_path }}</p>
|
|
262
|
+
</div>
|
|
263
|
+
</template>
|
|
264
|
+
<div class="divide-y divide-default">
|
|
265
|
+
<div
|
|
266
|
+
v-for="row in overview?.roles ?? []"
|
|
267
|
+
:key="row.role"
|
|
268
|
+
class="flex items-center gap-3 py-2.5"
|
|
269
|
+
>
|
|
270
|
+
<UIcon
|
|
271
|
+
:name="QUALITY_ROLES.has(row.role) ? 'i-lucide-gem' : 'i-lucide-cog'"
|
|
272
|
+
class="size-4 shrink-0"
|
|
273
|
+
:class="QUALITY_ROLES.has(row.role) ? 'text-primary' : 'text-muted'"
|
|
274
|
+
/>
|
|
275
|
+
<span class="w-32 shrink-0 text-sm font-medium">{{ row.role }}</span>
|
|
276
|
+
<template v-if="editingRole === row.role">
|
|
277
|
+
<UInputMenu
|
|
278
|
+
v-model="editTarget"
|
|
279
|
+
:items="targetOptions"
|
|
280
|
+
value-key="value"
|
|
281
|
+
create-item
|
|
282
|
+
size="sm"
|
|
283
|
+
class="flex-1 max-w-xs"
|
|
284
|
+
placeholder="provider/model"
|
|
285
|
+
@create="(item: string) => { editTarget = item }"
|
|
286
|
+
/>
|
|
287
|
+
<UButton size="xs" label="Save" :loading="saving" @click="saveEdit(row.role)" />
|
|
288
|
+
<UButton size="xs" variant="ghost" label="Cancel" @click="editingRole = null" />
|
|
289
|
+
</template>
|
|
290
|
+
<template v-else>
|
|
291
|
+
<span
|
|
292
|
+
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
|
|
293
|
+
:class="laneStyle(row.provider).badge"
|
|
294
|
+
>
|
|
295
|
+
{{ row.provider }}
|
|
296
|
+
</span>
|
|
297
|
+
<span class="text-sm font-mono truncate">
|
|
298
|
+
{{ row.model || '(unset)' }}
|
|
299
|
+
</span>
|
|
300
|
+
<UBadge variant="subtle" size="sm" color="neutral">{{ row.effort }}</UBadge>
|
|
301
|
+
<UButton
|
|
302
|
+
icon="i-lucide-pencil"
|
|
303
|
+
variant="ghost"
|
|
304
|
+
size="xs"
|
|
305
|
+
class="ml-auto"
|
|
306
|
+
:aria-label="`Edit ${row.role} routing`"
|
|
307
|
+
@click="startEdit(row)"
|
|
308
|
+
/>
|
|
309
|
+
</template>
|
|
310
|
+
</div>
|
|
311
|
+
</div>
|
|
312
|
+
</UCard>
|
|
313
|
+
|
|
314
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
315
|
+
<!-- Local Ollama models -->
|
|
316
|
+
<UCard>
|
|
317
|
+
<template #header>
|
|
318
|
+
<p class="text-xs font-semibold text-muted uppercase tracking-wider">
|
|
319
|
+
Local models (Ollama)
|
|
320
|
+
</p>
|
|
321
|
+
</template>
|
|
322
|
+
<div v-if="overview?.ollama?.running && (overview.ollama?.models ?? []).length" class="divide-y divide-default">
|
|
323
|
+
<div
|
|
324
|
+
v-for="m in (overview.ollama?.models ?? [])"
|
|
325
|
+
:key="m.name"
|
|
326
|
+
class="flex items-center gap-3 py-2"
|
|
327
|
+
>
|
|
328
|
+
<UIcon name="i-lucide-hard-drive" class="size-4 text-amber-500 shrink-0" />
|
|
329
|
+
<div class="min-w-0 flex-1">
|
|
330
|
+
<p class="text-sm font-mono truncate">{{ m.name }}</p>
|
|
331
|
+
<p class="text-xs text-muted">
|
|
332
|
+
{{ m.family || 'unknown family' }}
|
|
333
|
+
<template v-if="m.parameter_size"> · {{ m.parameter_size }}</template>
|
|
334
|
+
<template v-if="m.size_gb"> · {{ m.size_gb }} GB</template>
|
|
335
|
+
<template v-if="!m.size_gb"> · cloud-proxied</template>
|
|
336
|
+
</p>
|
|
337
|
+
</div>
|
|
338
|
+
<UButton
|
|
339
|
+
size="xs"
|
|
340
|
+
variant="soft"
|
|
341
|
+
label="Use for mechanical"
|
|
342
|
+
:loading="saving"
|
|
343
|
+
@click="useForMechanical(m)"
|
|
344
|
+
/>
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
<div v-else-if="overview?.ollama?.installed" class="py-6 text-center">
|
|
348
|
+
<p class="text-sm text-muted">Ollama is installed but not running.</p>
|
|
349
|
+
<p class="text-xs text-muted mt-1 font-mono">ollama serve</p>
|
|
350
|
+
</div>
|
|
351
|
+
<div v-else class="py-6 text-center">
|
|
352
|
+
<p class="text-sm text-muted">No local models yet.</p>
|
|
353
|
+
<p class="text-xs text-muted mt-1">
|
|
354
|
+
Install Ollama to run free local models for mechanical work and fusion panels.
|
|
355
|
+
</p>
|
|
356
|
+
</div>
|
|
357
|
+
</UCard>
|
|
358
|
+
|
|
359
|
+
<!-- Usage by model -->
|
|
360
|
+
<UCard>
|
|
361
|
+
<template #header>
|
|
362
|
+
<div class="flex items-center justify-between">
|
|
363
|
+
<p class="text-xs font-semibold text-muted uppercase tracking-wider">
|
|
364
|
+
Usage by model
|
|
365
|
+
</p>
|
|
366
|
+
<USelect v-model="period" :items="periodOptions" size="xs" class="min-w-24" aria-label="Usage period" />
|
|
367
|
+
</div>
|
|
368
|
+
</template>
|
|
369
|
+
<div v-if="usageRows.length" class="space-y-3">
|
|
370
|
+
<div class="flex items-baseline justify-between">
|
|
371
|
+
<p class="text-sm">
|
|
372
|
+
<span class="font-semibold">{{ usage?.call_count ?? 0 }}</span>
|
|
373
|
+
<span class="text-muted"> calls · </span>
|
|
374
|
+
<span class="font-semibold">{{ formatTokens((usage?.total_tokens_in ?? 0) + (usage?.total_tokens_out ?? 0)) }}</span>
|
|
375
|
+
<span class="text-muted"> tokens</span>
|
|
376
|
+
</p>
|
|
377
|
+
<p class="text-sm font-semibold">{{ formatCost(usage?.total_cost_usd ?? null) }}</p>
|
|
378
|
+
</div>
|
|
379
|
+
<div v-for="[name, row] in usageRows" :key="name" class="space-y-1">
|
|
380
|
+
<div class="flex items-center justify-between text-xs">
|
|
381
|
+
<span class="font-mono truncate">{{ name || '(unattributed)' }}</span>
|
|
382
|
+
<span class="text-muted shrink-0 ml-2">
|
|
383
|
+
{{ formatTokens(row.total_tokens_in + row.total_tokens_out) }} · {{ row.call_count }} calls
|
|
384
|
+
</span>
|
|
385
|
+
</div>
|
|
386
|
+
<div class="h-1.5 rounded-full bg-muted/20 overflow-hidden">
|
|
387
|
+
<div
|
|
388
|
+
class="h-full rounded-full bg-primary"
|
|
389
|
+
:style="{ width: `${Math.max(2, ((row.total_tokens_in + row.total_tokens_out) / maxUsageTokens) * 100)}%` }"
|
|
390
|
+
/>
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
<div v-else class="py-6 text-center">
|
|
395
|
+
<p class="text-sm text-muted">No usage recorded for this period.</p>
|
|
396
|
+
<p class="text-xs text-muted mt-1">Calls made through the Model Fabric land here automatically.</p>
|
|
397
|
+
</div>
|
|
398
|
+
</UCard>
|
|
399
|
+
</div>
|
|
400
|
+
</div>
|
|
401
|
+
</DashboardState>
|
|
402
|
+
</template>
|
|
403
|
+
</UDashboardPanel>
|
|
404
|
+
</template>
|
package/installer/autostart.js
CHANGED
|
@@ -29,6 +29,9 @@ export function unitFor(os, { repoRoot, home }) {
|
|
|
29
29
|
if (os === "darwin") {
|
|
30
30
|
const startScript = `${repoRoot}/scripts/start-dashboard.sh`;
|
|
31
31
|
const log = `${home}/.arkaos/logs/dashboard-autostart.log`;
|
|
32
|
+
// AbandonProcessGroup: the launcher backgrounds the API/UI and exits;
|
|
33
|
+
// without it launchd kills the whole process group on exit and the
|
|
34
|
+
// dashboard dies seconds after boot. `ensure` keeps re-loads idempotent.
|
|
32
35
|
const content = `<?xml version="1.0" encoding="UTF-8"?>
|
|
33
36
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
34
37
|
<plist version="1.0">
|
|
@@ -39,14 +42,19 @@ export function unitFor(os, { repoRoot, home }) {
|
|
|
39
42
|
<array>
|
|
40
43
|
<string>/bin/bash</string>
|
|
41
44
|
<string>${startScript}</string>
|
|
45
|
+
<string>ensure</string>
|
|
42
46
|
</array>
|
|
43
47
|
<key>EnvironmentVariables</key>
|
|
44
48
|
<dict>
|
|
45
49
|
<key>ARKAOS_ROOT</key>
|
|
46
50
|
<string>${repoRoot}</string>
|
|
51
|
+
<key>ARKAOS_NO_BROWSER</key>
|
|
52
|
+
<string>1</string>
|
|
47
53
|
</dict>
|
|
48
54
|
<key>RunAtLoad</key>
|
|
49
55
|
<true/>
|
|
56
|
+
<key>AbandonProcessGroup</key>
|
|
57
|
+
<true/>
|
|
50
58
|
<key>StandardOutPath</key>
|
|
51
59
|
<string>${log}</string>
|
|
52
60
|
<key>StandardErrorPath</key>
|
|
@@ -63,14 +71,19 @@ export function unitFor(os, { repoRoot, home }) {
|
|
|
63
71
|
|
|
64
72
|
if (os === "linux") {
|
|
65
73
|
const startScript = `${repoRoot}/scripts/start-dashboard.sh`;
|
|
74
|
+
// oneshot + RemainAfterExit: the launcher backgrounds the API/UI and
|
|
75
|
+
// exits; Type=simple would mark the unit dead on exit and systemd would
|
|
76
|
+
// reap the children with the cgroup. `ensure` keeps restarts idempotent.
|
|
66
77
|
const content = `[Unit]
|
|
67
78
|
Description=ArkaOS Dashboard (Python API + UI)
|
|
68
79
|
After=network.target
|
|
69
80
|
|
|
70
81
|
[Service]
|
|
71
|
-
Type=
|
|
82
|
+
Type=oneshot
|
|
83
|
+
RemainAfterExit=yes
|
|
72
84
|
Environment=ARKAOS_ROOT=${repoRoot}
|
|
73
|
-
|
|
85
|
+
Environment=ARKAOS_NO_BROWSER=1
|
|
86
|
+
ExecStart=/bin/bash ${startScript} ensure
|
|
74
87
|
Restart=on-failure
|
|
75
88
|
|
|
76
89
|
[Install]
|
|
@@ -87,6 +100,7 @@ WantedBy=default.target
|
|
|
87
100
|
const ps1 = join(repoRoot, "scripts", "start-dashboard.ps1");
|
|
88
101
|
const content = `@echo off
|
|
89
102
|
rem ArkaOS dashboard autostart (login).
|
|
103
|
+
set ARKAOS_NO_BROWSER=1
|
|
90
104
|
powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "${ps1}"
|
|
91
105
|
`;
|
|
92
106
|
return {
|
package/installer/cli.js
CHANGED
|
@@ -25,6 +25,9 @@ const { values, positionals } = parseArgs({
|
|
|
25
25
|
// `values.fix` rather than as a free positional under strict:false.
|
|
26
26
|
// Eliminates the dead-branch fallback flagged by Marta in PR2's QG.
|
|
27
27
|
fix: { type: "boolean" },
|
|
28
|
+
// Model Fabric PR-A — `npx arkaos models [--json] [set ... --effort max]`
|
|
29
|
+
json: { type: "boolean" },
|
|
30
|
+
effort: { type: "string" },
|
|
28
31
|
},
|
|
29
32
|
allowPositionals: true,
|
|
30
33
|
strict: false,
|
|
@@ -51,6 +54,8 @@ Usage:
|
|
|
51
54
|
npx arkaos dashboard Start monitoring dashboard
|
|
52
55
|
npx arkaos autostart <enable|disable|status> Start dashboard on boot
|
|
53
56
|
npx arkaos keys Manage API keys (OpenAI, fal.ai, etc.)
|
|
57
|
+
npx arkaos models Model Fabric: which model runs each role
|
|
58
|
+
npx arkaos models set <role> <provider>/<model> Re-route a role
|
|
54
59
|
npx arkaos doctor Run health checks
|
|
55
60
|
npx arkaos uninstall Remove ArkaOS
|
|
56
61
|
|
|
@@ -154,6 +159,24 @@ async function main() {
|
|
|
154
159
|
break;
|
|
155
160
|
}
|
|
156
161
|
|
|
162
|
+
case "models": {
|
|
163
|
+
const { execSync } = await import("node:child_process");
|
|
164
|
+
const repoRootModels = join(__dirname, "..");
|
|
165
|
+
const pyModels = getArkaosPython();
|
|
166
|
+
if (!pyModels) { console.error("No Python found. Run: npx arkaos install"); process.exit(1); }
|
|
167
|
+
const modelArgs = positionals.slice(1).map((a) => `"${a}"`).join(" ");
|
|
168
|
+
const effortFlag = values.effort ? ` --effort "${values.effort}"` : "";
|
|
169
|
+
const jsonFlag = values.json ? " --json" : "";
|
|
170
|
+
try {
|
|
171
|
+
execSync(`"${pyModels}" -m core.runtime.model_router_cli ${modelArgs}${effortFlag}${jsonFlag}`, {
|
|
172
|
+
stdio: "inherit",
|
|
173
|
+
cwd: repoRootModels,
|
|
174
|
+
env: { ...process.env, ARKAOS_ROOT: repoRootModels, PYTHONPATH: repoRootModels },
|
|
175
|
+
});
|
|
176
|
+
} catch { process.exit(1); }
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
|
|
157
180
|
case "index": {
|
|
158
181
|
const { execSync } = await import("node:child_process");
|
|
159
182
|
const indexArgs = positionals.slice(1).join(" ");
|
package/installer/keys.js
CHANGED
|
@@ -6,6 +6,7 @@ const KEYS_PATH = join(homedir(), ".arkaos", "keys.json");
|
|
|
6
6
|
|
|
7
7
|
const PROVIDERS = {
|
|
8
8
|
OPENAI_API_KEY: { name: "OpenAI", used_for: "Whisper transcription, embeddings, GPT" },
|
|
9
|
+
OPENROUTER_API_KEY: { name: "OpenRouter", used_for: "Model Fabric: one key, hundreds of models (Kimi, DeepSeek, GPT, Gemini) for roles + fusion" },
|
|
9
10
|
GOOGLE_API_KEY: { name: "Google", used_for: "Gemini API, Nano Banana, Google Cloud AI" },
|
|
10
11
|
FAL_API_KEY: { name: "fal.ai", used_for: "Image generation, video generation" },
|
|
11
12
|
MAGIC_API_KEY: { name: "21st.dev Magic", used_for: "Frontend UI/UX component generation (Magic MCP)" },
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
Binary file
|
package/scripts/dashboard-api.py
CHANGED
|
@@ -864,6 +864,85 @@ 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 USER_CONFIG_PATH, load_config, resolve_all
|
|
871
|
+
from core.runtime.ollama_discovery import discover
|
|
872
|
+
|
|
873
|
+
config, source = load_config()
|
|
874
|
+
keys_path = Path.home() / ".arkaos" / "keys.json"
|
|
875
|
+
try:
|
|
876
|
+
keys = json.loads(keys_path.read_text(encoding="utf-8"))
|
|
877
|
+
except (OSError, json.JSONDecodeError):
|
|
878
|
+
keys = {}
|
|
879
|
+
return {
|
|
880
|
+
"source": source,
|
|
881
|
+
"config_path": str(USER_CONFIG_PATH),
|
|
882
|
+
"roles": [item.model_dump() for item in resolve_all()],
|
|
883
|
+
"providers": {
|
|
884
|
+
name: {"type": spec.get("type", "")}
|
|
885
|
+
for name, spec in config.providers.items()
|
|
886
|
+
},
|
|
887
|
+
"aliases": config.aliases,
|
|
888
|
+
"fusion": config.fusion.model_dump(),
|
|
889
|
+
"ollama": discover().to_dict(),
|
|
890
|
+
"keys": {
|
|
891
|
+
"openrouter": bool(
|
|
892
|
+
os.environ.get("OPENROUTER_API_KEY")
|
|
893
|
+
or keys.get("OPENROUTER_API_KEY")
|
|
894
|
+
),
|
|
895
|
+
"anthropic": bool(os.environ.get("ANTHROPIC_API_KEY")),
|
|
896
|
+
},
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
@app.post("/api/models/role")
|
|
901
|
+
async def models_set_role(request: Request):
|
|
902
|
+
"""Re-route a role: {role, target: 'provider/model', effort?}."""
|
|
903
|
+
from core.runtime.model_router import set_role
|
|
904
|
+
|
|
905
|
+
body = await request.json()
|
|
906
|
+
role = str(body.get("role", "")).strip()
|
|
907
|
+
target = str(body.get("target", "")).strip()
|
|
908
|
+
effort = body.get("effort") or None
|
|
909
|
+
if not role or not target:
|
|
910
|
+
return JSONResponse(
|
|
911
|
+
{"error": "role and target (provider/model) are required"},
|
|
912
|
+
status_code=422,
|
|
913
|
+
)
|
|
914
|
+
try:
|
|
915
|
+
item = set_role(role, target, effort=effort)
|
|
916
|
+
except ValueError as exc:
|
|
917
|
+
return JSONResponse({"error": str(exc)}, status_code=422)
|
|
918
|
+
return item.model_dump()
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
@app.get("/api/models/usage")
|
|
922
|
+
def models_usage(period: str = "today"):
|
|
923
|
+
"""Per-model/provider/category token + cost aggregation."""
|
|
924
|
+
from core.runtime.llm_cost_telemetry import VALID_PERIODS, summarise
|
|
925
|
+
|
|
926
|
+
if period not in VALID_PERIODS:
|
|
927
|
+
return JSONResponse(
|
|
928
|
+
{"error": f"period must be one of {list(VALID_PERIODS)}"},
|
|
929
|
+
status_code=422,
|
|
930
|
+
)
|
|
931
|
+
summary = summarise(period=period)
|
|
932
|
+
return {
|
|
933
|
+
"period": summary.period,
|
|
934
|
+
"call_count": summary.call_count,
|
|
935
|
+
"total_cost_usd": summary.total_cost_usd,
|
|
936
|
+
"total_tokens_in": summary.total_tokens_in,
|
|
937
|
+
"total_tokens_out": summary.total_tokens_out,
|
|
938
|
+
"total_cached_tokens": summary.total_cached_tokens,
|
|
939
|
+
"cache_hit_rate": summary.cache_hit_rate,
|
|
940
|
+
"by_model": summary.by_model,
|
|
941
|
+
"by_provider": summary.by_provider,
|
|
942
|
+
"by_category": summary.by_category,
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
|
|
867
946
|
@app.get("/api/budget")
|
|
868
947
|
def budget_all():
|
|
869
948
|
mgr = _get_budget_manager()
|
|
@@ -10,6 +10,30 @@ VENV_PYTHON="$HOME/.arkaos/venv/bin/python"
|
|
|
10
10
|
|
|
11
11
|
mkdir -p "$HOME/.arkaos"
|
|
12
12
|
|
|
13
|
+
# ── Args ──
|
|
14
|
+
# `ensure` — idempotent mode: exit 0 if API+UI already healthy,
|
|
15
|
+
# otherwise fall through to a full start. Safe to call from
|
|
16
|
+
# SessionStart hooks and launchd/systemd boot units.
|
|
17
|
+
# `--no-browser` — do not open the browser after start (also via
|
|
18
|
+
# ARKAOS_NO_BROWSER=1 env, for boot/hook contexts).
|
|
19
|
+
MODE="start"
|
|
20
|
+
for arg in "$@"; do
|
|
21
|
+
case "$arg" in
|
|
22
|
+
ensure) MODE="ensure" ;;
|
|
23
|
+
--no-browser) ARKAOS_NO_BROWSER=1 ;;
|
|
24
|
+
esac
|
|
25
|
+
done
|
|
26
|
+
|
|
27
|
+
if [ "$MODE" = "ensure" ] && [ -f "$PORT_FILE" ]; then
|
|
28
|
+
# shellcheck disable=SC1090
|
|
29
|
+
source "$PORT_FILE"
|
|
30
|
+
if curl -sf --max-time 2 "http://localhost:${API_PORT:-0}/api/overview" >/dev/null 2>&1 \
|
|
31
|
+
&& curl -sf --max-time 2 "http://localhost:${UI_PORT:-0}/" >/dev/null 2>&1; then
|
|
32
|
+
echo " ✓ Dashboard already running (API :${API_PORT}, UI :${UI_PORT})"
|
|
33
|
+
exit 0
|
|
34
|
+
fi
|
|
35
|
+
fi
|
|
36
|
+
|
|
13
37
|
# ── Venv guard (PR2 v3.73.1 — Force Specialist Dispatch dogfood) ──
|
|
14
38
|
# Previously the dashboard fell back to ambient `python3` when the venv
|
|
15
39
|
# wasn't available. That hid broken-venv conditions (Homebrew patch
|
|
@@ -133,7 +157,7 @@ echo " or kill \$(cat $PID_FILE)"
|
|
|
133
157
|
echo ""
|
|
134
158
|
|
|
135
159
|
# Wait for UI to be ready
|
|
136
|
-
if [ -n "$UI_PID" ]; then
|
|
160
|
+
if [ -n "$UI_PID" ] && [ -z "${ARKAOS_NO_BROWSER:-}" ]; then
|
|
137
161
|
sleep 5
|
|
138
162
|
# Open browser
|
|
139
163
|
if command -v open >/dev/null 2>&1; then
|