@cat-factory/app 0.7.4 → 0.8.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.
@@ -1,8 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import { useBoardFlow } from '~/composables/useBoardFlow'
3
3
  import NotificationsInbox from '~/components/layout/NotificationsInbox.vue'
4
- import VendorCredentialsModal from '~/components/providers/VendorCredentialsModal.vue'
5
- import PersonalCredentialModal from '~/components/providers/PersonalCredentialModal.vue'
6
4
 
7
5
  const ui = useUiStore()
8
6
  const board = useBoardStore()
@@ -127,20 +125,6 @@ const decisionItems = computed(() =>
127
125
  <!-- human-actionable notifications (merge review, pipeline complete, CI failed) -->
128
126
  <NotificationsInbox />
129
127
 
130
- <!-- LLM vendor subscriptions (Claude Code / Codex token pool) -->
131
- <UButton
132
- color="neutral"
133
- variant="ghost"
134
- size="sm"
135
- icon="i-lucide-key-round"
136
- title="Connect LLM vendor subscriptions (Claude Code / Codex)"
137
- @click="ui.openVendorCredentials()"
138
- >
139
- Vendors
140
- </UButton>
141
- <VendorCredentialsModal />
142
- <PersonalCredentialModal />
143
-
144
128
  <!-- spend safeguard usage -->
145
129
  <UButton
146
130
  v-if="showSpend"
@@ -306,6 +306,18 @@ watch(
306
306
  >
307
307
  Default service best practices
308
308
  </UButton>
309
+ <UButton
310
+ block
311
+ color="primary"
312
+ variant="soft"
313
+ size="sm"
314
+ icon="i-lucide-key-round"
315
+ class="justify-start"
316
+ title="Connect LLM vendor subscriptions + provider API keys"
317
+ @click="ui.openVendorCredentials()"
318
+ >
319
+ Vendors &amp; keys
320
+ </UButton>
309
321
  <UButton
310
322
  block
311
323
  color="primary"
@@ -317,6 +329,17 @@ watch(
317
329
  >
318
330
  My local runners
319
331
  </UButton>
332
+ <UButton
333
+ block
334
+ color="primary"
335
+ variant="soft"
336
+ size="sm"
337
+ icon="i-lucide-waypoints"
338
+ class="justify-start"
339
+ @click="ui.openOpenRouter()"
340
+ >
341
+ OpenRouter models
342
+ </UButton>
320
343
  </div>
321
344
  </section>
322
345
 
@@ -0,0 +1,176 @@
1
+ <script setup lang="ts">
2
+ // Workspace settings: "OpenRouter models" — browse OpenRouter's 300+ gateway models and
3
+ // enable a subset for this workspace. OpenRouter is reached via the workspace's API-key pool
4
+ // (connect an OpenRouter key first under "Provider keys"); "Refresh" probes its live catalog
5
+ // server-side, then tick the models to enable. Enabled models — with their context window and
6
+ // price — appear automatically in the model picker and meter against the spend budget.
7
+ import { computed, ref, watch } from 'vue'
8
+ import type { OpenRouterModelMeta } from '~/types/openrouter'
9
+
10
+ const ui = useUiStore()
11
+ const workspace = useWorkspaceStore()
12
+ const store = useOpenRouterStore()
13
+ const toast = useToast()
14
+
15
+ const open = computed({
16
+ get: () => ui.openRouterOpen,
17
+ set: (v: boolean) => (v ? ui.openOpenRouter() : ui.closeOpenRouter()),
18
+ })
19
+
20
+ // The enabled slugs the user has ticked (seeded from the persisted catalog on open).
21
+ const selected = ref<Set<string>>(new Set())
22
+ const filter = ref('')
23
+ const busy = ref(false)
24
+
25
+ // Load the persisted catalog whenever the panel opens; seed the tick selection from it.
26
+ watch(open, (isOpen) => {
27
+ if (!isOpen || !workspace.workspaceId) return
28
+ void store.load(workspace.workspaceId).then(() => {
29
+ selected.value = new Set(store.enabled.map((m) => m.id))
30
+ })
31
+ })
32
+
33
+ // The list to show: the live browse list once refreshed, else the persisted enabled set.
34
+ const source = computed<OpenRouterModelMeta[]>(() =>
35
+ store.browse.length ? store.browse : store.enabled,
36
+ )
37
+
38
+ const visible = computed(() => {
39
+ const q = filter.value.trim().toLowerCase()
40
+ if (!q) return source.value
41
+ return source.value.filter(
42
+ (m) => m.id.toLowerCase().includes(q) || m.name.toLowerCase().includes(q),
43
+ )
44
+ })
45
+
46
+ const selectedCount = computed(() => selected.value.size)
47
+
48
+ function contextLabel(tokens: number | undefined): string {
49
+ if (!tokens) return ''
50
+ return tokens >= 1000 ? `${Math.round(tokens / 1000)}K ctx` : `${tokens} ctx`
51
+ }
52
+
53
+ function priceLabel(m: OpenRouterModelMeta): string {
54
+ return `${m.inputPerMillion}/${m.outputPerMillion} per Mtok`
55
+ }
56
+
57
+ function toggle(id: string, on: boolean) {
58
+ const next = new Set(selected.value)
59
+ if (on) next.add(id)
60
+ else next.delete(id)
61
+ selected.value = next
62
+ }
63
+
64
+ async function refresh() {
65
+ if (!workspace.workspaceId) return
66
+ const result = await store.refresh(workspace.workspaceId)
67
+ if (!result.reachable) {
68
+ toast.add({
69
+ title: 'Could not reach OpenRouter',
70
+ description: store.refreshError ?? 'Connect an OpenRouter key under Provider keys first.',
71
+ icon: 'i-lucide-triangle-alert',
72
+ color: 'error',
73
+ })
74
+ }
75
+ }
76
+
77
+ async function save() {
78
+ if (!workspace.workspaceId) return
79
+ busy.value = true
80
+ try {
81
+ // Persist the ticked models, carrying the metadata from whichever list they came from.
82
+ const byId = new Map(source.value.map((m) => [m.id, m]))
83
+ const models = [...selected.value]
84
+ .map((id) => byId.get(id))
85
+ .filter((m): m is OpenRouterModelMeta => !!m)
86
+ await store.save(workspace.workspaceId, models)
87
+ toast.add({ title: 'OpenRouter catalog saved', icon: 'i-lucide-check', color: 'success' })
88
+ } catch (e) {
89
+ toast.add({
90
+ title: 'Could not save catalog',
91
+ description: e instanceof Error ? e.message : String(e),
92
+ icon: 'i-lucide-triangle-alert',
93
+ color: 'error',
94
+ })
95
+ } finally {
96
+ busy.value = false
97
+ }
98
+ }
99
+ </script>
100
+
101
+ <template>
102
+ <UModal v-model:open="open" title="OpenRouter models" :ui="{ content: 'max-w-2xl' }">
103
+ <template #body>
104
+ <div class="space-y-4">
105
+ <p class="text-xs text-slate-400">
106
+ Reach <strong>300+ models</strong> through one gateway. Connect an OpenRouter key under
107
+ <span class="text-slate-300">Provider keys</span> first, then
108
+ <span class="text-slate-300">Refresh</span> to browse the live catalog and enable the
109
+ models you want. Enabled models appear in the model picker with their context window and
110
+ price, and meter against your spend budget.
111
+ </p>
112
+
113
+ <div class="flex items-center gap-2">
114
+ <UButton
115
+ color="neutral"
116
+ variant="soft"
117
+ size="sm"
118
+ icon="i-lucide-refresh-cw"
119
+ :loading="store.refreshing"
120
+ @click="refresh()"
121
+ >
122
+ Refresh catalog
123
+ </UButton>
124
+ <UInput
125
+ v-model="filter"
126
+ size="sm"
127
+ class="flex-1"
128
+ icon="i-lucide-search"
129
+ placeholder="Filter by name or slug…"
130
+ />
131
+ </div>
132
+
133
+ <p v-if="store.refreshError" class="text-xs text-rose-400">{{ store.refreshError }}</p>
134
+
135
+ <div v-if="visible.length" class="max-h-96 space-y-1 overflow-y-auto pr-1">
136
+ <label
137
+ v-for="m in visible"
138
+ :key="m.id"
139
+ class="flex items-center gap-2 rounded-md border border-slate-800 bg-slate-900/40 px-2.5 py-1.5 text-sm"
140
+ >
141
+ <UCheckbox
142
+ :model-value="selected.has(m.id)"
143
+ @update:model-value="(v: boolean | 'indeterminate') => toggle(m.id, v === true)"
144
+ />
145
+ <span class="min-w-0 flex-1">
146
+ <span class="block truncate text-slate-200">{{ m.name }}</span>
147
+ <span class="block truncate font-mono text-[11px] text-slate-500">{{ m.id }}</span>
148
+ </span>
149
+ <span class="shrink-0 text-right text-[11px] text-slate-500">
150
+ <span v-if="m.contextLength" class="block">{{ contextLabel(m.contextLength) }}</span>
151
+ <span class="block">{{ priceLabel(m) }}</span>
152
+ </span>
153
+ </label>
154
+ </div>
155
+ <p v-else class="text-xs text-slate-500">
156
+ No models yet — hit <span class="text-slate-300">Refresh catalog</span> to load
157
+ OpenRouter's live list.
158
+ </p>
159
+
160
+ <div class="flex items-center justify-between">
161
+ <span class="text-xs text-slate-500">{{ selectedCount }} enabled</span>
162
+ <UButton
163
+ color="primary"
164
+ variant="soft"
165
+ size="sm"
166
+ icon="i-lucide-save"
167
+ :loading="busy"
168
+ @click="save()"
169
+ >
170
+ Save
171
+ </UButton>
172
+ </div>
173
+ </div>
174
+ </template>
175
+ </UModal>
176
+ </template>
@@ -16,6 +16,11 @@ import type {
16
16
  TestLocalModelEndpointInput,
17
17
  UpsertLocalModelEndpointInput,
18
18
  } from '~/types/localModels'
19
+ import type {
20
+ OpenRouterCatalog,
21
+ OpenRouterRefreshResult,
22
+ UpsertOpenRouterCatalogInput,
23
+ } from '~/types/openrouter'
19
24
  import type { ApiContext } from './context'
20
25
 
21
26
  /**
@@ -100,6 +105,19 @@ export function modelsApi({ http, ws }: ApiContext) {
100
105
  body,
101
106
  }),
102
107
 
108
+ // ---- OpenRouter dynamic catalog (per-workspace gateway models) --------
109
+ // Browse OpenRouter's live catalog (`refresh`, leasing the workspace's pooled
110
+ // OpenRouter key server-side) and enable a subset; enabled models then surface
111
+ // in the per-workspace `/models` catalog with their context + price.
112
+ getOpenRouterCatalog: (workspaceId: string) =>
113
+ http<OpenRouterCatalog>(`${ws(workspaceId)}/openrouter/catalog`),
114
+
115
+ setOpenRouterCatalog: (workspaceId: string, body: UpsertOpenRouterCatalogInput) =>
116
+ http<OpenRouterCatalog>(`${ws(workspaceId)}/openrouter/catalog`, { method: 'PUT', body }),
117
+
118
+ refreshOpenRouterCatalog: (workspaceId: string) =>
119
+ http<OpenRouterRefreshResult>(`${ws(workspaceId)}/openrouter/refresh`, { method: 'POST' }),
120
+
103
121
  // ---- per-agent-kind default models (workspace routing overrides) ------
104
122
  // The workspace's map of agentKind → model id; a kind absent from the map
105
123
  // falls back to the deployment's env routing. `setModelDefaults` replaces the
@@ -32,6 +32,9 @@ import DatadogPanel from '~/components/settings/DatadogPanel.vue'
32
32
  import ModelDefaultsPanel from '~/components/settings/ModelDefaultsPanel.vue'
33
33
  import ServiceFragmentDefaultsPanel from '~/components/settings/ServiceFragmentDefaultsPanel.vue'
34
34
  import LocalModelEndpointsPanel from '~/components/settings/LocalModelEndpointsPanel.vue'
35
+ import OpenRouterCatalogPanel from '~/components/settings/OpenRouterCatalogPanel.vue'
36
+ import VendorCredentialsModal from '~/components/providers/VendorCredentialsModal.vue'
37
+ import PersonalCredentialModal from '~/components/providers/PersonalCredentialModal.vue'
35
38
 
36
39
  const workspace = useWorkspaceStore()
37
40
  const github = useGitHubStore()
@@ -122,6 +125,9 @@ watch(
122
125
  <ModelDefaultsPanel />
123
126
  <ServiceFragmentDefaultsPanel />
124
127
  <LocalModelEndpointsPanel />
128
+ <OpenRouterCatalogPanel />
129
+ <VendorCredentialsModal />
130
+ <PersonalCredentialModal />
125
131
  </template>
126
132
 
127
133
  <!-- Backend unreachable / bootstrap failed -->
@@ -0,0 +1,52 @@
1
+ import { defineStore } from 'pinia'
2
+ import { ref } from 'vue'
3
+ import type { OpenRouterModelMeta, OpenRouterRefreshResult } from '~/types/openrouter'
4
+
5
+ // The workspace's OpenRouter dynamic catalog: the enabled subset of OpenRouter's 300+
6
+ // gateway models. `enabled` is what's persisted (and surfaced in the model picker);
7
+ // `browse` is the live catalog from the last `refresh` (not persisted) the user picks from.
8
+ // Scoped to the workspace (its key lives in the workspace API-key pool).
9
+ export const useOpenRouterStore = defineStore('openrouter', () => {
10
+ const api = useApi()
11
+ const enabled = ref<OpenRouterModelMeta[]>([])
12
+ const browse = ref<OpenRouterModelMeta[]>([])
13
+ const loading = ref(false)
14
+ const refreshing = ref(false)
15
+ const refreshError = ref<string | null>(null)
16
+
17
+ async function load(workspaceId: string) {
18
+ loading.value = true
19
+ try {
20
+ const catalog = await api.getOpenRouterCatalog(workspaceId)
21
+ enabled.value = catalog.models
22
+ } catch {
23
+ // Auth disabled / not signed in / feature off → nothing surfaces.
24
+ enabled.value = []
25
+ } finally {
26
+ loading.value = false
27
+ }
28
+ }
29
+
30
+ /** Probe OpenRouter's live catalog (leases the workspace's pooled key server-side). */
31
+ async function refresh(workspaceId: string): Promise<OpenRouterRefreshResult> {
32
+ refreshing.value = true
33
+ refreshError.value = null
34
+ try {
35
+ const result = await api.refreshOpenRouterCatalog(workspaceId)
36
+ browse.value = result.models
37
+ if (!result.reachable) refreshError.value = result.error ?? 'OpenRouter is unreachable'
38
+ return result
39
+ } finally {
40
+ refreshing.value = false
41
+ }
42
+ }
43
+
44
+ /** Persist the enabled subset (the supplied models carry their browse-list metadata). */
45
+ async function save(workspaceId: string, models: OpenRouterModelMeta[]) {
46
+ const catalog = await api.setOpenRouterCatalog(workspaceId, { models })
47
+ enabled.value = catalog.models
48
+ return catalog
49
+ }
50
+
51
+ return { enabled, browse, loading, refreshing, refreshError, load, refresh, save }
52
+ })
package/app/stores/ui.ts CHANGED
@@ -81,6 +81,8 @@ export const useUiStore = defineStore('ui', () => {
81
81
  const vendorCredentialsOpen = ref(false)
82
82
  // Per-user settings panel: the signed-in user's own-machine local model runners.
83
83
  const localModelsOpen = ref(false)
84
+ // Per-workspace settings panel: the OpenRouter dynamic catalog (browse/enable gateway models).
85
+ const openRouterOpen = ref(false)
84
86
 
85
87
  // Dedicated result-view overlay: a step whose agent kind declares a bespoke
86
88
  // visualization (via the archetype's `resultView`) opens here instead of the generic
@@ -326,6 +328,12 @@ export const useUiStore = defineStore('ui', () => {
326
328
  function closeLocalModels() {
327
329
  localModelsOpen.value = false
328
330
  }
331
+ function openOpenRouter() {
332
+ openRouterOpen.value = true
333
+ }
334
+ function closeOpenRouter() {
335
+ openRouterOpen.value = false
336
+ }
329
337
  function openRequirementReview(blockId: string) {
330
338
  resultView.value = { view: 'requirements-review', blockId, instanceId: null, stepIndex: null }
331
339
  }
@@ -376,6 +384,7 @@ export const useUiStore = defineStore('ui', () => {
376
384
  serviceFragmentDefaultsOpen,
377
385
  vendorCredentialsOpen,
378
386
  localModelsOpen,
387
+ openRouterOpen,
379
388
  resultView,
380
389
  closeResultView,
381
390
  stepDetail,
@@ -435,6 +444,8 @@ export const useUiStore = defineStore('ui', () => {
435
444
  closeVendorCredentials,
436
445
  openLocalModels,
437
446
  closeLocalModels,
447
+ openOpenRouter,
448
+ closeOpenRouter,
438
449
  openRequirementReview,
439
450
  openClarityReview,
440
451
  closeRequirementReview,
@@ -19,15 +19,15 @@ export interface ModelCost {
19
19
  * A selectable LLM model, resolved to the flavour in use for this deployment
20
20
  * (served by `GET /models`). Mirrors `ModelOption` in `@cat-factory/contracts`.
21
21
  * The base `flavor`/`provider`/`model` is the always-available fallback
22
- * (cloudflare/direct), or the subscription itself for subscription-only models.
23
- * `subscription` (when present) is the alternative the picker prefers once the
24
- * workspace has a token for its vendor.
22
+ * (cloudflare/direct/openrouter), or the subscription itself for subscription-only
23
+ * models. `subscription` (when present) is the alternative the picker prefers once
24
+ * the workspace has a token for its vendor.
25
25
  */
26
26
  export interface ModelOption {
27
27
  id: string
28
28
  label: string
29
29
  description: string
30
- flavor: 'cloudflare' | 'direct' | 'subscription'
30
+ flavor: 'cloudflare' | 'direct' | 'openrouter' | 'subscription'
31
31
  /**
32
32
  * Whether this model is actually selectable for the workspace: a direct API key for
33
33
  * its provider, a connected subscription vendor, or Cloudflare AI enabled. Absent on
@@ -0,0 +1,45 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Per-workspace OpenRouter dynamic catalog. OpenRouter is a single OpenAI-
3
+ // compatible gateway to 300+ models reached via the workspace's API-key pool. A
4
+ // workspace browses the live catalog and enables a subset; the enabled models
5
+ // surface automatically in the per-workspace model picker (with their context +
6
+ // price) and feed the spend budget.
7
+ //
8
+ // Mirrors the `@cat-factory/contracts` `openrouter` schemas exactly, so a payload
9
+ // returned by the backend drops straight into the Pinia store without translation.
10
+ // ---------------------------------------------------------------------------
11
+
12
+ /** Metadata for one OpenRouter model (prices per 1M tokens, in the spend currency). */
13
+ export interface OpenRouterModelMeta {
14
+ /** OpenRouter `vendor/model` slug, e.g. `anthropic/claude-opus-4.8`. */
15
+ id: string
16
+ /** Human-readable model name from OpenRouter's catalog. */
17
+ name: string
18
+ /** Total context window (input + output tokens), when reported. */
19
+ contextLength?: number
20
+ /** Input price per 1M tokens, in the spend currency. */
21
+ inputPerMillion: number
22
+ /** Output price per 1M tokens, in the spend currency. */
23
+ outputPerMillion: number
24
+ }
25
+
26
+ /** A workspace's enabled OpenRouter models (the persisted subset). */
27
+ export interface OpenRouterCatalog {
28
+ models: OpenRouterModelMeta[]
29
+ createdAt: number
30
+ updatedAt: number
31
+ }
32
+
33
+ /** Replace a workspace's enabled OpenRouter models with this subset (+ metadata). */
34
+ export interface UpsertOpenRouterCatalogInput {
35
+ models: OpenRouterModelMeta[]
36
+ }
37
+
38
+ /** The result of probing OpenRouter's live `/models` for the browse list. */
39
+ export interface OpenRouterRefreshResult {
40
+ reachable: boolean
41
+ /** Every model OpenRouter currently serves (empty when unreachable). */
42
+ models: OpenRouterModelMeta[]
43
+ /** Human-readable failure reason when `reachable` is false. */
44
+ error?: string
45
+ }
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.7.4",
3
+ "version": "0.8.0",
4
+ "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
4
5
  "repository": {
5
6
  "type": "git",
6
7
  "url": "git+https://github.com/kibertoad/cat-factory.git",
7
8
  "directory": "frontend/app"
8
9
  },
9
- "description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
10
10
  "files": [
11
11
  "app",
12
12
  "nuxt.config.ts"