@cat-factory/app 0.10.0 → 0.11.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.
@@ -228,10 +228,13 @@ async function disconnectEmail() {
228
228
  </template>
229
229
  </section>
230
230
 
231
- <!-- account-wide provider API keys (admin-only) -->
232
- <section v-if="isAdmin">
231
+ <!-- account-wide provider API keys (admin-only): direct vendors + proxy gateways -->
232
+ <section v-if="isAdmin" class="space-y-6">
233
233
  <h3 class="mb-2 font-semibold text-white">Account API keys</h3>
234
- <ProvidersApiKeysSection :account-id="accountId" />
234
+ <ProvidersApiKeysSection :account-id="accountId" category="direct" />
235
+ <div class="border-t border-slate-800 pt-6">
236
+ <ProvidersApiKeysSection :account-id="accountId" category="proxy" />
237
+ </div>
235
238
  </section>
236
239
  </div>
237
240
  </template>
@@ -1,9 +1,15 @@
1
1
  <script setup lang="ts">
2
- // Direct-provider API keys: connect a vendor API key (OpenAI/Anthropic/Qwen/DeepSeek/
3
- // Moonshot) so agent steps and inline calls run on that provider. Keys are stored
4
- // encrypted in the DB, pooled and rotated by usage — replacing deployment env vars.
2
+ // Provider API keys: connect a vendor API key so agent steps and inline calls run on
3
+ // that provider. Keys are stored encrypted in the DB, pooled and rotated by usage —
4
+ // replacing deployment env vars.
5
5
  //
6
- // Two modes:
6
+ // `category` splits the providers into two kinds:
7
+ // - 'direct' (default): you reach the vendor directly (OpenAI/Anthropic/Qwen/DeepSeek/
8
+ // Moonshot).
9
+ // - 'proxy': an intermediary gateway that fronts many vendors behind one key
10
+ // (OpenRouter, LiteLLM). These are NOT direct vendors, so they get their own section.
11
+ //
12
+ // Two scopes:
7
13
  // - Default (no `accountId`): manage WORKSPACE keys (shared by the team) and YOUR own
8
14
  // keys (your personal pool, usable in any workspace), toggled by the Scope select.
9
15
  // - With `accountId`: manage ACCOUNT-wide keys (shared by every workspace in the
@@ -11,7 +17,9 @@
11
17
  import { computed, ref, watch } from 'vue'
12
18
  import type { ApiKey, ApiKeyProvider } from '~/types/domain'
13
19
 
14
- const props = defineProps<{ accountId?: string }>()
20
+ const props = withDefaults(defineProps<{ accountId?: string; category?: 'direct' | 'proxy' }>(), {
21
+ category: 'direct',
22
+ })
15
23
 
16
24
  const workspace = useWorkspaceStore()
17
25
  const keys = useApiKeysStore()
@@ -21,13 +29,15 @@ const toast = useToast()
21
29
  /** Account-wide mode (single account scope) vs the default workspace/user toggle. */
22
30
  const isAccount = computed(() => !!props.accountId)
23
31
 
24
- /** Where to obtain each provider's API key + a short note. */
25
- const PROVIDERS: {
32
+ interface ProviderMeta {
26
33
  value: ApiKeyProvider
27
34
  label: string
28
35
  url: string
29
36
  steps: string[]
30
- }[] = [
37
+ }
38
+
39
+ /** Direct vendors: the key reaches that one vendor's own endpoint. */
40
+ const DIRECT_PROVIDERS: ProviderMeta[] = [
31
41
  {
32
42
  value: 'openai',
33
43
  label: 'OpenAI',
@@ -73,6 +83,10 @@ const PROVIDERS: {
73
83
  'Copy the key; it authenticates the OpenAI-compatible Moonshot endpoint.',
74
84
  ],
75
85
  },
86
+ ]
87
+
88
+ /** Proxies / gateways: one key fronts many vendors. These are intermediaries, not vendors. */
89
+ const PROXY_PROVIDERS: ProviderMeta[] = [
76
90
  {
77
91
  value: 'openrouter',
78
92
  label: 'OpenRouter',
@@ -93,8 +107,12 @@ const PROVIDERS: {
93
107
  },
94
108
  ]
95
109
 
110
+ /** Providers for the requested category; labels everywhere fall back to the full set. */
111
+ const PROVIDERS = computed(() => (props.category === 'proxy' ? PROXY_PROVIDERS : DIRECT_PROVIDERS))
112
+ const ALL_PROVIDERS = [...DIRECT_PROVIDERS, ...PROXY_PROVIDERS]
113
+
96
114
  const scope = ref<'workspace' | 'user'>('workspace')
97
- const provider = ref<ApiKeyProvider>('openai')
115
+ const provider = ref<ApiKeyProvider>(props.category === 'proxy' ? 'openrouter' : 'openai')
98
116
  const label = ref('')
99
117
  const key = ref('')
100
118
  const busy = ref(false)
@@ -115,17 +133,23 @@ watch(
115
133
  { immediate: true },
116
134
  )
117
135
 
118
- const selected = computed(() => PROVIDERS.find((p) => p.value === provider.value)!)
119
- const connected = computed<ApiKey[]>(() =>
120
- isAccount.value
136
+ const selected = computed(
137
+ () => PROVIDERS.value.find((p) => p.value === provider.value) ?? PROVIDERS.value[0]!,
138
+ )
139
+
140
+ /** Keys for the active scope, narrowed to this section's category (direct vs proxy). */
141
+ const categoryProviders = computed(() => new Set(PROVIDERS.value.map((p) => p.value)))
142
+ const connected = computed<ApiKey[]>(() => {
143
+ const all = isAccount.value
121
144
  ? keys.accountKeys
122
145
  : scope.value === 'workspace'
123
146
  ? keys.workspaceKeys
124
- : keys.userKeys,
125
- )
147
+ : keys.userKeys
148
+ return all.filter((k) => categoryProviders.value.has(k.provider))
149
+ })
126
150
 
127
151
  function providerLabel(p: ApiKeyProvider): string {
128
- return PROVIDERS.find((x) => x.value === p)?.label ?? p
152
+ return ALL_PROVIDERS.find((x) => x.value === p)?.label ?? p
129
153
  }
130
154
 
131
155
  async function add() {
@@ -176,17 +200,34 @@ async function remove(k: ApiKey) {
176
200
  <div class="space-y-4">
177
201
  <div>
178
202
  <h4 class="text-xs font-semibold uppercase tracking-wide text-slate-500">
179
- Direct provider API keys
203
+ {{ category === 'proxy' ? 'Proxy / gateway API keys' : 'Direct provider API keys' }}
180
204
  </h4>
181
- <p v-if="isAccount" class="mt-1 text-sm text-slate-400">
182
- Connect a vendor API key shared by <strong>every workspace</strong> in this account. Keys
183
- are stored encrypted, pooled, and rotated by usage. Account keys are admin-managed.
184
- </p>
185
- <p v-else class="mt-1 text-sm text-slate-400">
186
- Connect a vendor API key so models run directly on that provider. Keys are stored encrypted,
187
- pooled, and rotated by usage. Scope a key to this <strong>workspace</strong> (shared with
188
- the team) or to <strong>you</strong> (your own pool, usable anywhere).
189
- </p>
205
+ <template v-if="category === 'proxy'">
206
+ <p v-if="isAccount" class="mt-1 text-sm text-slate-400">
207
+ Connect a <strong>proxy</strong> key (OpenRouter, LiteLLM) shared by
208
+ <strong>every workspace</strong> in this account. A proxy is an intermediary that fronts
209
+ many vendors behind a single key. Keys are stored encrypted, pooled, and rotated by usage.
210
+ </p>
211
+ <p v-else class="mt-1 text-sm text-slate-400">
212
+ Connect a <strong>proxy</strong> key (OpenRouter, LiteLLM). A proxy is an intermediary
213
+ that reaches many vendors' models through a single gateway, rather than one vendor
214
+ directly. Keys are stored encrypted, pooled, and rotated by usage. Scope a key to this
215
+ <strong>workspace</strong> (shared with the team) or to <strong>you</strong> (your own
216
+ pool, usable anywhere).
217
+ </p>
218
+ </template>
219
+ <template v-else>
220
+ <p v-if="isAccount" class="mt-1 text-sm text-slate-400">
221
+ Connect a vendor API key shared by <strong>every workspace</strong> in this account. Keys
222
+ are stored encrypted, pooled, and rotated by usage. Account keys are admin-managed.
223
+ </p>
224
+ <p v-else class="mt-1 text-sm text-slate-400">
225
+ Connect a vendor API key so models run directly on that provider. Keys are stored
226
+ encrypted, pooled, and rotated by usage. Scope a key to this
227
+ <strong>workspace</strong> (shared with the team) or to <strong>you</strong> (your own
228
+ pool, usable anywhere).
229
+ </p>
230
+ </template>
190
231
  </div>
191
232
 
192
233
  <!-- scope + provider -->
@@ -201,7 +242,7 @@ async function remove(k: ApiKey) {
201
242
  class="w-48"
202
243
  />
203
244
  </UFormField>
204
- <UFormField label="Provider">
245
+ <UFormField :label="category === 'proxy' ? 'Proxy' : 'Provider'">
205
246
  <USelect
206
247
  v-model="provider"
207
248
  :items="PROVIDERS.map((p) => ({ label: p.label, value: p.value }))"
@@ -18,6 +18,21 @@ const open = computed({
18
18
  set: (v: boolean) => (v ? ui.openVendorCredentials() : ui.closeVendorCredentials()),
19
19
  })
20
20
 
21
+ // Horizontal tabs replace the old long vertical scroll: each credential kind is its own
22
+ // section (pooled subscriptions, direct vendor keys, proxy/gateway keys, personal subs).
23
+ const activeTab = ref('pool')
24
+ const tabs = [
25
+ { value: 'pool', label: 'Workspace pool', icon: 'i-lucide-users', slot: 'pool' },
26
+ { value: 'direct', label: 'Direct providers', icon: 'i-lucide-key-round', slot: 'direct' },
27
+ { value: 'proxy', label: 'Proxies', icon: 'i-lucide-route', slot: 'proxy' },
28
+ {
29
+ value: 'personal',
30
+ label: 'Personal subscriptions',
31
+ icon: 'i-lucide-user',
32
+ slot: 'personal',
33
+ },
34
+ ]
35
+
21
36
  // Only commercial coding-plan vendors that permit team/organization use are poolable here.
22
37
  // Claude, GLM and ChatGPT/Codex are licensed for individual use only, so they are connected
23
38
  // per-user in the "Personal subscriptions" section below (PersonalSubscriptionSection).
@@ -102,96 +117,112 @@ function vendorLabel(v: SubscriptionVendor): string {
102
117
  <template>
103
118
  <UModal v-model:open="open" title="LLM Vendors" :ui="{ content: 'max-w-2xl' }">
104
119
  <template #body>
105
- <div class="space-y-5">
106
- <p class="text-sm text-slate-400">
107
- Connect a <strong>commercial</strong> coding-plan subscription (Kimi, DeepSeek) that
108
- permits team/organization use to run agent steps on the Claude Code harness instead of an
109
- API key. Tokens are stored encrypted, pooled, and rotated by usage. Subscription models
110
- are flat-rate quota — they don’t draw on your spend budget. Individual-use subscriptions
111
- (Claude, GLM, ChatGPT/Codex) are connected per-user in the Personal subscriptions section
112
- below.
113
- </p>
114
-
115
- <h4 class="text-xs font-semibold uppercase tracking-wide text-slate-500">
116
- Workspace pool (commercial coding plans)
117
- </h4>
118
-
119
- <!-- vendor picker -->
120
- <div class="flex flex-wrap items-end gap-3">
121
- <UFormField label="Vendor">
122
- <USelect
123
- v-model="vendor"
124
- :items="visibleVendors.map((v) => ({ label: v.label, value: v.value }))"
125
- class="w-64"
126
- />
127
- </UFormField>
128
- </div>
129
-
130
- <!-- guided steps -->
131
- <ol
132
- class="list-decimal space-y-1.5 rounded-lg border border-slate-700 bg-slate-900/60 p-4 pl-8 text-sm text-slate-300"
133
- >
134
- <li v-for="(step, i) in steps" :key="i">{{ step }}</li>
135
- </ol>
136
-
137
- <!-- add form -->
138
- <div class="space-y-2">
139
- <UFormField label="Label (optional)">
140
- <UInput v-model="label" placeholder="e.g. work account" />
141
- </UFormField>
142
- <UFormField label="Token">
143
- <UTextarea
144
- v-model="token"
145
- :rows="3"
146
- :placeholder="tokenPlaceholder"
147
- class="font-mono"
148
- />
149
- </UFormField>
150
- <div class="flex justify-end">
151
- <UButton :loading="busy" :disabled="!token.trim()" icon="i-lucide-plus" @click="add()">
152
- Connect
153
- </UButton>
154
- </div>
155
- </div>
156
-
157
- <!-- connected pool -->
158
- <div v-if="creds.credentials.length" class="space-y-2">
159
- <h4 class="text-xs font-semibold uppercase tracking-wide text-slate-500">
160
- Connected ({{ creds.credentials.length }})
161
- </h4>
162
- <div
163
- v-for="c in creds.credentials"
164
- :key="c.id"
165
- class="flex items-center justify-between rounded-md border border-slate-700 bg-slate-900/50 px-3 py-2 text-sm"
166
- >
167
- <div>
168
- <span class="font-medium text-slate-200">{{ c.label }}</span>
169
- <span class="ml-2 text-xs text-slate-500">{{ vendorLabel(c.vendor) }}</span>
170
- <div class="text-[11px] tabular-nums text-slate-500">
171
- {{ (c.inputTokens + c.outputTokens).toLocaleString() }} tok this window ·
172
- {{ c.requestCount }} run{{ c.requestCount === 1 ? '' : 's' }}
120
+ <UTabs
121
+ v-model="activeTab"
122
+ :items="tabs"
123
+ variant="link"
124
+ :ui="{ root: 'gap-4', list: 'overflow-x-auto' }"
125
+ >
126
+ <!-- Workspace pool (commercial coding-plan subscriptions) -->
127
+ <template #pool>
128
+ <div class="space-y-5">
129
+ <p class="text-sm text-slate-400">
130
+ Connect a <strong>commercial</strong> coding-plan subscription (Kimi, DeepSeek) that
131
+ permits team/organization use to run agent steps on the Claude Code harness instead of
132
+ an API key. Tokens are stored encrypted, pooled, and rotated by usage. Subscription
133
+ models are flat-rate quota — they don’t draw on your spend budget. Individual-use
134
+ subscriptions (Claude, GLM, ChatGPT/Codex) are connected per-user in the Personal
135
+ subscriptions tab.
136
+ </p>
137
+
138
+ <!-- vendor picker -->
139
+ <div class="flex flex-wrap items-end gap-3">
140
+ <UFormField label="Vendor">
141
+ <USelect
142
+ v-model="vendor"
143
+ :items="visibleVendors.map((v) => ({ label: v.label, value: v.value }))"
144
+ class="w-64"
145
+ />
146
+ </UFormField>
147
+ </div>
148
+
149
+ <!-- guided steps -->
150
+ <ol
151
+ class="list-decimal space-y-1.5 rounded-lg border border-slate-700 bg-slate-900/60 p-4 pl-8 text-sm text-slate-300"
152
+ >
153
+ <li v-for="(step, i) in steps" :key="i">{{ step }}</li>
154
+ </ol>
155
+
156
+ <!-- add form -->
157
+ <div class="space-y-2">
158
+ <UFormField label="Label (optional)">
159
+ <UInput v-model="label" placeholder="e.g. work account" />
160
+ </UFormField>
161
+ <UFormField label="Token">
162
+ <UTextarea
163
+ v-model="token"
164
+ :rows="3"
165
+ :placeholder="tokenPlaceholder"
166
+ class="font-mono"
167
+ />
168
+ </UFormField>
169
+ <div class="flex justify-end">
170
+ <UButton
171
+ :loading="busy"
172
+ :disabled="!token.trim()"
173
+ icon="i-lucide-plus"
174
+ @click="add()"
175
+ >
176
+ Connect
177
+ </UButton>
178
+ </div>
179
+ </div>
180
+
181
+ <!-- connected pool -->
182
+ <div v-if="creds.credentials.length" class="space-y-2">
183
+ <h4 class="text-xs font-semibold uppercase tracking-wide text-slate-500">
184
+ Connected ({{ creds.credentials.length }})
185
+ </h4>
186
+ <div
187
+ v-for="c in creds.credentials"
188
+ :key="c.id"
189
+ class="flex items-center justify-between rounded-md border border-slate-700 bg-slate-900/50 px-3 py-2 text-sm"
190
+ >
191
+ <div>
192
+ <span class="font-medium text-slate-200">{{ c.label }}</span>
193
+ <span class="ml-2 text-xs text-slate-500">{{ vendorLabel(c.vendor) }}</span>
194
+ <div class="text-[11px] tabular-nums text-slate-500">
195
+ {{ (c.inputTokens + c.outputTokens).toLocaleString() }} tok this window ·
196
+ {{ c.requestCount }} run{{ c.requestCount === 1 ? '' : 's' }}
197
+ </div>
198
+ </div>
199
+ <UButton
200
+ icon="i-lucide-trash-2"
201
+ color="error"
202
+ variant="ghost"
203
+ size="xs"
204
+ @click="remove(c.id)"
205
+ />
173
206
  </div>
174
207
  </div>
175
- <UButton
176
- icon="i-lucide-trash-2"
177
- color="error"
178
- variant="ghost"
179
- size="xs"
180
- @click="remove(c.id)"
181
- />
182
208
  </div>
183
- </div>
209
+ </template>
210
+
211
+ <!-- Direct provider API keys (OpenAI/Anthropic/Qwen/DeepSeek/Moonshot), pooled -->
212
+ <template #direct>
213
+ <ProvidersApiKeysSection category="direct" />
214
+ </template>
184
215
 
185
- <!-- direct provider API keys (OpenAI/Anthropic/Qwen/DeepSeek/Moonshot), pooled -->
186
- <div class="border-t border-slate-800 pt-5">
187
- <ProvidersApiKeysSection />
188
- </div>
216
+ <!-- Proxies / gateways (OpenRouter, LiteLLM): intermediaries that front many vendors -->
217
+ <template #proxy>
218
+ <ProvidersApiKeysSection category="proxy" />
219
+ </template>
189
220
 
190
- <!-- personal (individual-usage) subscriptions: Claude / GLM / Codex, per-user -->
191
- <div class="border-t border-slate-800 pt-5">
221
+ <!-- Personal (individual-usage) subscriptions: Claude / GLM / Codex, per-user -->
222
+ <template #personal>
192
223
  <ProvidersPersonalSubscriptionSection />
193
- </div>
194
- </div>
224
+ </template>
225
+ </UTabs>
195
226
  </template>
196
227
  </UModal>
197
228
  </template>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/app",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
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.",
5
5
  "repository": {
6
6
  "type": "git",