@cat-factory/app 0.288.2 → 0.289.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.
@@ -34,6 +34,9 @@ const formatLabel = computed<Record<ApiContractFormat, string>>(() => ({
34
34
  openapi: t('foundational.format.openapi'),
35
35
  'toad-contract': t('foundational.format.toadContract'),
36
36
  'lokalise-api-contract': t('foundational.format.lokaliseApiContract'),
37
+ asyncapi: t('foundational.format.asyncapi'),
38
+ graphql: t('foundational.format.graphql'),
39
+ grpc: t('foundational.format.grpc'),
37
40
  }))
38
41
  // `as const` keeps the literal colour names assignable to UBadge's `color` union.
39
42
  const tierColor = {
@@ -20,6 +20,7 @@ import FoundationalServiceCatalogList from '~/components/foundational/Foundation
20
20
  import FoundationalServiceRegistry from '~/components/foundational/FoundationalServiceRegistry.vue'
21
21
  import FoundationalServiceSources from '~/components/foundational/FoundationalServiceSources.vue'
22
22
  import FoundationalSuppressions from '~/components/foundational/FoundationalSuppressions.vue'
23
+ import ServiceCatalogConnection from '~/components/foundational/ServiceCatalogConnection.vue'
23
24
 
24
25
  const props = withDefaults(
25
26
  defineProps<{
@@ -50,7 +51,7 @@ watch(
50
51
  { immediate: true },
51
52
  )
52
53
 
53
- type Tab = 'catalog' | 'registry' | 'sources'
54
+ type Tab = 'catalog' | 'registry' | 'sources' | 'portal'
54
55
  const tab = ref<Tab>(props.showCatalog ? 'catalog' : 'registry')
55
56
 
56
57
  const ownerLabel = computed(() =>
@@ -62,10 +63,14 @@ const tabs = computed(() => {
62
63
  { value: 'registry' as const, label: ownerLabel.value, slot: 'registry' },
63
64
  { value: 'sources' as const, label: t('foundational.tab.sources'), slot: 'sources' },
64
65
  ]
66
+ // The developer-portal import is WORKSPACE-only, so it rides the same flag the merged catalog
67
+ // does rather than a second one: the credential is workspace-keyed, and an account tab would
68
+ // offer a connection the backend serves at no scope.
65
69
  if (!props.showCatalog) return items
66
70
  return [
67
71
  { value: 'catalog' as const, label: t('foundational.tab.catalog'), slot: 'catalog' },
68
72
  ...items,
73
+ { value: 'portal' as const, label: t('foundational.tab.portal'), slot: 'portal' },
69
74
  ]
70
75
  })
71
76
 
@@ -116,6 +121,9 @@ const activeTab = computed({
116
121
  <template #sources>
117
122
  <FoundationalServiceSources :kind="props.kind" :owner-id="props.ownerId" />
118
123
  </template>
124
+ <template #portal>
125
+ <ServiceCatalogConnection />
126
+ </template>
119
127
  </UTabs>
120
128
  </div>
121
129
  </template>
@@ -37,6 +37,9 @@ const formatLabel = computed<Record<ApiContractFormat, string>>(() => ({
37
37
  openapi: t('foundational.format.openapi'),
38
38
  'toad-contract': t('foundational.format.toadContract'),
39
39
  'lokalise-api-contract': t('foundational.format.lokaliseApiContract'),
40
+ asyncapi: t('foundational.format.asyncapi'),
41
+ graphql: t('foundational.format.graphql'),
42
+ grpc: t('foundational.format.grpc'),
40
43
  }))
41
44
  const formatItems = computed(() =>
42
45
  (Object.keys(formatLabel.value) as ApiContractFormat[]).map((value) => ({
@@ -0,0 +1,392 @@
1
+ <script setup lang="ts">
2
+ // The workspace's SERVICE CATALOG connection: the developer portal (Backstage) whose services are
3
+ // imported into the foundational-services catalog as `workspace`-tier entries
4
+ // (backend/docs/service-catalog-import.md).
5
+ //
6
+ // A THIRD supply route beside the registry tab (upload) and the sources tab (a linked repo), so it
7
+ // lives beside them rather than in a settings page of its own: what it produces is the same
8
+ // catalog, and an operator deciding where a service came from should not have to look in two
9
+ // places.
10
+ //
11
+ // The form's shape follows the auth vocabulary, which is closed for a reason: these are the ways
12
+ // organisations actually run a self-hosted Backstage, and each needs a different request built. The
13
+ // fields shown switch on the selected mode, so a static token is one box and nothing else.
14
+ import { computed, reactive, ref } from 'vue'
15
+ import type { ConnectServiceCatalogInput, ServiceCatalogAuthMode } from '~/types/domain'
16
+ import { useFoundationalServicesStore } from '~/stores/foundationalServices'
17
+ import {
18
+ SERVICE_CATALOG_AUTH_KEYS,
19
+ SERVICE_CATALOG_AUTH_ORDER,
20
+ SERVICE_CATALOG_STATUS_COLORS,
21
+ serviceCatalogStatusKey,
22
+ } from '~/utils/serviceCatalog'
23
+
24
+ const catalog = useFoundationalServicesStore()
25
+ const toast = useToast()
26
+ const { present } = usePipelineErrorToast()
27
+ const { t, d } = useI18n()
28
+ const { confirm } = useConfirm()
29
+
30
+ const authMode = ref<ServiceCatalogAuthMode>('static-token')
31
+ const form = reactive({
32
+ baseUrl: '',
33
+ token: '',
34
+ sharedSecret: '',
35
+ tokenUrl: '',
36
+ clientId: '',
37
+ clientSecret: '',
38
+ scope: '',
39
+ audience: '',
40
+ username: '',
41
+ password: '',
42
+ headerName: '',
43
+ headerValue: '',
44
+ secondHeaderName: '',
45
+ secondHeaderValue: '',
46
+ entityFilter: '',
47
+ includeApis: true,
48
+ maxServices: 200,
49
+ })
50
+ const busy = ref<'connect' | 'probe' | 'import' | 'disconnect' | null>(null)
51
+
52
+ const connection = computed(() => catalog.serviceCatalog)
53
+
54
+ // Both vocabularies map to their keys in `~/utils/serviceCatalog`, whose spec asserts every entry
55
+ // against the base catalog: these are reached through a lookup rather than a literal key written
56
+ // out at the call site, so the typed-message-key guard cannot see them.
57
+ const authModeItems = computed(() =>
58
+ SERVICE_CATALOG_AUTH_ORDER.map((value) => ({
59
+ value,
60
+ label: t(SERVICE_CATALOG_AUTH_KEYS[value]),
61
+ })),
62
+ )
63
+
64
+ const authModeLabel = (mode: ServiceCatalogAuthMode) => t(SERVICE_CATALOG_AUTH_KEYS[mode])
65
+ const statusLabel = computed(() =>
66
+ t(serviceCatalogStatusKey(connection.value?.lastSyncStatus ?? null)),
67
+ )
68
+ const syncStatusColor = computed(() => {
69
+ const status = connection.value?.lastSyncStatus
70
+ return status ? SERVICE_CATALOG_STATUS_COLORS[status] : 'neutral'
71
+ })
72
+
73
+ /**
74
+ * The body both `connect` and `probe` send.
75
+ *
76
+ * ONE builder for both, deliberately: the probe exists to test what the operator has just typed,
77
+ * and a second builder is how a probe ends up testing a slightly different credential from the one
78
+ * that gets stored.
79
+ */
80
+ function buildInput(): ConnectServiceCatalogInput {
81
+ const terms = form.entityFilter
82
+ .split(/[\n,]/)
83
+ .map((term) => term.trim())
84
+ .filter(Boolean)
85
+ return {
86
+ baseUrl: form.baseUrl.trim(),
87
+ auth: buildAuth(),
88
+ ...(terms.length > 0 ? { entityFilter: terms } : {}),
89
+ includeApis: form.includeApis,
90
+ maxServices: form.maxServices,
91
+ }
92
+ }
93
+
94
+ function buildAuth(): ConnectServiceCatalogInput['auth'] {
95
+ switch (authMode.value) {
96
+ case 'none':
97
+ return { mode: 'none' }
98
+ case 'static-token':
99
+ return { mode: 'static-token', token: form.token.trim() }
100
+ case 'legacy-shared-secret':
101
+ return { mode: 'legacy-shared-secret', sharedSecret: form.sharedSecret.trim() }
102
+ case 'oauth2-client-credentials':
103
+ return {
104
+ mode: 'oauth2-client-credentials',
105
+ tokenUrl: form.tokenUrl.trim(),
106
+ clientId: form.clientId.trim(),
107
+ clientSecret: form.clientSecret.trim(),
108
+ ...(form.scope.trim() ? { scope: form.scope.trim() } : {}),
109
+ ...(form.audience.trim() ? { audience: form.audience.trim() } : {}),
110
+ }
111
+ case 'basic':
112
+ return { mode: 'basic', username: form.username.trim(), password: form.password }
113
+ case 'headers':
114
+ return {
115
+ mode: 'headers',
116
+ headers: [
117
+ { name: form.headerName.trim(), value: form.headerValue },
118
+ ...(form.secondHeaderName.trim()
119
+ ? [{ name: form.secondHeaderName.trim(), value: form.secondHeaderValue }]
120
+ : []),
121
+ ],
122
+ }
123
+ }
124
+ }
125
+
126
+ async function withBusy(kind: NonNullable<typeof busy.value>, fn: () => Promise<void>) {
127
+ if (busy.value) return
128
+ busy.value = kind
129
+ try {
130
+ await fn()
131
+ } finally {
132
+ busy.value = null
133
+ }
134
+ }
135
+
136
+ async function connect() {
137
+ await withBusy('connect', async () => {
138
+ try {
139
+ await catalog.connectServiceCatalog(buildInput())
140
+ toast.add({ title: t('serviceCatalog.toast.connected'), color: 'success' })
141
+ } catch (error) {
142
+ present(error, 'serviceCatalog.toast.connectFailed')
143
+ return
144
+ }
145
+ // The first import follows the connect, and is REPORTED as its own outcome. The connection is
146
+ // stored by the time it runs, so a revoked token surfacing here is an import failure with an
147
+ // import remedy; presenting it under "could not connect" would deny what the panel is already
148
+ // showing and bury the remedy under a title that says the opposite.
149
+ await runImport()
150
+ })
151
+ }
152
+
153
+ async function probe() {
154
+ await withBusy('probe', async () => {
155
+ try {
156
+ const result = await catalog.probeServiceCatalog(buildInput())
157
+ toast.add({
158
+ title: result.ok
159
+ ? t('serviceCatalog.toast.probeOk')
160
+ : t('serviceCatalog.toast.probeFailed'),
161
+ description: result.message,
162
+ color: result.ok ? 'success' : 'error',
163
+ })
164
+ } catch (error) {
165
+ present(error, 'serviceCatalog.toast.probeFailed')
166
+ }
167
+ })
168
+ }
169
+
170
+ async function importNow() {
171
+ await withBusy('import', runImport)
172
+ }
173
+
174
+ /** One import and its toast, shared by the Import button and the connect flow's first pass. */
175
+ async function runImport(): Promise<void> {
176
+ try {
177
+ const result = await catalog.importServiceCatalog()
178
+ toast.add({
179
+ title: t('serviceCatalog.toast.imported'),
180
+ description: t('serviceCatalog.toast.importedDetail', {
181
+ upserted: result.upserted,
182
+ unchanged: result.unchanged,
183
+ tombstoned: result.tombstoned,
184
+ }),
185
+ color: result.status === 'ok' ? 'success' : 'warning',
186
+ })
187
+ } catch (error) {
188
+ present(error, 'serviceCatalog.toast.importFailed')
189
+ }
190
+ }
191
+
192
+ async function disconnect() {
193
+ // The imported services are TOMBSTONED with the connection, which is destructive enough to
194
+ // confirm: an operator who expected the rows to stay would otherwise lose a board's whole
195
+ // imported estate on one click.
196
+ if (
197
+ !(await confirm({
198
+ title: t('serviceCatalog.disconnect.title'),
199
+ description: t('serviceCatalog.disconnect.body'),
200
+ confirmLabel: t('serviceCatalog.disconnect.confirm'),
201
+ variant: 'destructive',
202
+ }))
203
+ ) {
204
+ return
205
+ }
206
+ await withBusy('disconnect', async () => {
207
+ try {
208
+ await catalog.disconnectServiceCatalog()
209
+ toast.add({ title: t('serviceCatalog.toast.disconnected'), color: 'success' })
210
+ } catch (error) {
211
+ present(error, 'serviceCatalog.toast.disconnectFailed')
212
+ }
213
+ })
214
+ }
215
+ </script>
216
+
217
+ <template>
218
+ <div class="flex flex-col gap-4" data-testid="service-catalog-connection">
219
+ <!-- Unwired is stated, never offered as a form that would fail with a raw 503. -->
220
+ <div
221
+ v-if="catalog.serviceCatalogAvailable === false"
222
+ class="rounded-md border border-slate-800 bg-slate-900/40 p-3 text-sm text-slate-400"
223
+ >
224
+ {{ t('serviceCatalog.unavailable') }}
225
+ </div>
226
+
227
+ <template v-else>
228
+ <p class="text-sm text-slate-400">{{ t('serviceCatalog.intro') }}</p>
229
+
230
+ <!-- The CONNECTED state, with what the last import concluded. `lastSyncMessage` is the
231
+ load-bearing line: it is what says a catalog is a PREFIX of the portal's estate. -->
232
+ <div
233
+ v-if="connection"
234
+ class="flex flex-col gap-2 rounded-md border border-slate-800 bg-slate-900/40 p-3"
235
+ data-testid="service-catalog-connected"
236
+ >
237
+ <div class="flex flex-wrap items-center gap-2">
238
+ <UBadge :color="syncStatusColor" variant="subtle">{{ statusLabel }}</UBadge>
239
+ <span class="font-mono text-xs text-slate-300">{{ connection.baseUrl }}</span>
240
+ <span class="text-xs text-slate-500">{{ authModeLabel(connection.authMode) }}</span>
241
+ <span v-if="connection.lastSyncedAt" class="text-xs text-slate-500">
242
+ {{ d(new Date(connection.lastSyncedAt), 'short') }}
243
+ </span>
244
+ </div>
245
+ <p class="text-xs text-slate-400">
246
+ {{
247
+ t('serviceCatalog.summary', {
248
+ filter: connection.entityFilter.join(', '),
249
+ max: connection.maxServices,
250
+ })
251
+ }}
252
+ </p>
253
+ <p v-if="connection.lastSyncMessage" class="text-xs text-amber-400">
254
+ {{ connection.lastSyncMessage }}
255
+ </p>
256
+ <div class="flex gap-2">
257
+ <UButton size="xs" :loading="busy === 'import'" :disabled="!!busy" @click="importNow">
258
+ {{ t('serviceCatalog.action.import') }}
259
+ </UButton>
260
+ <UButton
261
+ size="xs"
262
+ color="error"
263
+ variant="soft"
264
+ :loading="busy === 'disconnect'"
265
+ :disabled="!!busy"
266
+ @click="disconnect"
267
+ >
268
+ {{ t('serviceCatalog.action.disconnect') }}
269
+ </UButton>
270
+ </div>
271
+ </div>
272
+
273
+ <!-- The connect / re-connect form. Shown alongside a live connection too, because rotating
274
+ a token is the routine reason to come here. -->
275
+ <div class="flex flex-col gap-3 rounded-md border border-slate-800 p-3">
276
+ <h4 class="text-sm font-medium text-slate-200">
277
+ {{ connection ? t('serviceCatalog.form.replace') : t('serviceCatalog.form.connect') }}
278
+ </h4>
279
+
280
+ <UFormField
281
+ :label="t('serviceCatalog.field.baseUrl')"
282
+ :help="t('serviceCatalog.help.baseUrl')"
283
+ >
284
+ <UInput v-model="form.baseUrl" placeholder="https://backstage.example.com" />
285
+ </UFormField>
286
+
287
+ <UFormField :label="t('serviceCatalog.field.authMode')">
288
+ <USelect v-model="authMode" :items="authModeItems" value-key="value" />
289
+ </UFormField>
290
+
291
+ <UFormField
292
+ v-if="authMode === 'static-token'"
293
+ :label="t('serviceCatalog.field.token')"
294
+ :help="t('serviceCatalog.help.token')"
295
+ >
296
+ <UInput v-model="form.token" type="password" />
297
+ </UFormField>
298
+
299
+ <UFormField
300
+ v-if="authMode === 'legacy-shared-secret'"
301
+ :label="t('serviceCatalog.field.sharedSecret')"
302
+ :help="t('serviceCatalog.help.sharedSecret')"
303
+ >
304
+ <UInput v-model="form.sharedSecret" type="password" />
305
+ </UFormField>
306
+
307
+ <template v-if="authMode === 'oauth2-client-credentials'">
308
+ <UFormField :label="t('serviceCatalog.field.tokenUrl')">
309
+ <UInput v-model="form.tokenUrl" placeholder="https://idp.example.com/oauth2/token" />
310
+ </UFormField>
311
+ <UFormField :label="t('serviceCatalog.field.clientId')">
312
+ <UInput v-model="form.clientId" />
313
+ </UFormField>
314
+ <UFormField :label="t('serviceCatalog.field.clientSecret')">
315
+ <UInput v-model="form.clientSecret" type="password" />
316
+ </UFormField>
317
+ <UFormField :label="t('serviceCatalog.field.scope')">
318
+ <UInput v-model="form.scope" />
319
+ </UFormField>
320
+ <UFormField :label="t('serviceCatalog.field.audience')">
321
+ <UInput v-model="form.audience" />
322
+ </UFormField>
323
+ </template>
324
+
325
+ <template v-if="authMode === 'basic'">
326
+ <UFormField :label="t('serviceCatalog.field.username')">
327
+ <UInput v-model="form.username" />
328
+ </UFormField>
329
+ <UFormField :label="t('serviceCatalog.field.password')">
330
+ <UInput v-model="form.password" type="password" />
331
+ </UFormField>
332
+ </template>
333
+
334
+ <template v-if="authMode === 'headers'">
335
+ <UFormField
336
+ :label="t('serviceCatalog.field.headerName')"
337
+ :help="t('serviceCatalog.help.headers')"
338
+ >
339
+ <UInput v-model="form.headerName" placeholder="CF-Access-Client-Id" />
340
+ </UFormField>
341
+ <UFormField :label="t('serviceCatalog.field.headerValue')">
342
+ <UInput v-model="form.headerValue" type="password" />
343
+ </UFormField>
344
+ <UFormField :label="t('serviceCatalog.field.secondHeaderName')">
345
+ <UInput v-model="form.secondHeaderName" placeholder="CF-Access-Client-Secret" />
346
+ </UFormField>
347
+ <UFormField :label="t('serviceCatalog.field.secondHeaderValue')">
348
+ <UInput v-model="form.secondHeaderValue" type="password" />
349
+ </UFormField>
350
+ </template>
351
+
352
+ <UFormField
353
+ :label="t('serviceCatalog.field.entityFilter')"
354
+ :help="t('serviceCatalog.help.entityFilter')"
355
+ >
356
+ <UTextarea v-model="form.entityFilter" :rows="2" placeholder="kind=component" />
357
+ </UFormField>
358
+
359
+ <UFormField
360
+ :label="t('serviceCatalog.field.maxServices')"
361
+ :help="t('serviceCatalog.help.maxServices')"
362
+ >
363
+ <UInput v-model.number="form.maxServices" type="number" :min="1" :max="1000" />
364
+ </UFormField>
365
+
366
+ <UCheckbox v-model="form.includeApis" :label="t('serviceCatalog.field.includeApis')" />
367
+
368
+ <div class="flex gap-2">
369
+ <UButton
370
+ size="xs"
371
+ :loading="busy === 'connect'"
372
+ :disabled="!!busy || !form.baseUrl.trim()"
373
+ @click="connect"
374
+ >
375
+ {{
376
+ connection ? t('serviceCatalog.action.replace') : t('serviceCatalog.action.connect')
377
+ }}
378
+ </UButton>
379
+ <UButton
380
+ size="xs"
381
+ variant="soft"
382
+ :loading="busy === 'probe'"
383
+ :disabled="!!busy || !form.baseUrl.trim()"
384
+ @click="probe"
385
+ >
386
+ {{ t('serviceCatalog.action.probe') }}
387
+ </UButton>
388
+ </div>
389
+ </div>
390
+ </template>
391
+ </div>
392
+ </template>
@@ -848,6 +848,13 @@ function exportJson() {
848
848
  :title="c.model"
849
849
  >
850
850
  {{ c.provider }}:{{ c.model }}
851
+ <!-- Which upstream a GATEWAY routed to. Without it every `openrouter` row
852
+ reads alike, and an upstream having a bad day cannot be told from the
853
+ gateway having one. Absent for a direct vendor, where `provider`
854
+ already names who served the call. -->
855
+ <span v-if="c.upstreamProvider" class="text-slate-600">
856
+ {{ t('observability.call.viaUpstream', { upstream: c.upstreamProvider }) }}
857
+ </span>
851
858
  </span>
852
859
  <div
853
860
  class="ms-auto flex items-center gap-2.5 text-[11px] tabular-nums text-slate-400"
@@ -927,6 +934,18 @@ function exportJson() {
927
934
  <span>{{
928
935
  t('observability.call.total', { duration: formatMs(c.totalMs) })
929
936
  }}</span>
937
+ <!-- The one MEASURED cost on the row: what the gateway's own ledger says,
938
+ in USD, against the derived estimate every other figure on this panel
939
+ is. `!= null` and not truthiness: a reported 0 is a free route saying
940
+ so, which is a different fact from a producer that reports nothing and
941
+ must not be hidden as if it were. -->
942
+ <span v-if="c.reportedCostUsd != null" class="text-slate-400">
943
+ {{
944
+ t('observability.call.reportedCost', {
945
+ cost: formatCost(c.reportedCostUsd, 'USD'),
946
+ })
947
+ }}
948
+ </span>
930
949
  </div>
931
950
  <div>
932
951
  <div
@@ -16,6 +16,7 @@
16
16
  // - With `accountId`: manage ACCOUNT-wide keys (shared by every workspace in the
17
17
  // account); admin-only, enforced server-side. Surfaced from account/team settings.
18
18
  import { computed, ref, watch } from 'vue'
19
+ import { providerCachesPrompts } from '@cat-factory/contracts'
19
20
  import type { ApiKey, ApiKeyProvider } from '~/types/domain'
20
21
  import SecretInput from '~/components/common/SecretInput.vue'
21
22
 
@@ -48,18 +49,29 @@ interface ProviderMeta {
48
49
  label: string
49
50
  url: string
50
51
  steps: string[]
51
- /**
52
- * Whether this provider caches the re-sent prompt prefix. Connecting a key here
53
- * upgrades its models to the caching `direct` flavour, so a long agentic run stops
54
- * re-billing its whole growing prompt every turn. Mirrors the backend
55
- * `providerCachePolicy`; the gateways are pass-through (no caching we rely on yet).
56
- */
57
- caches?: boolean
52
+ }
53
+
54
+ /**
55
+ * Whether connecting a key here upgrades its models to the caching flavour, so a long agentic
56
+ * run stops re-billing its whole growing prompt every turn.
57
+ *
58
+ * READ from the shared rule rather than kept as a flag beside each entry. A hand-kept copy was
59
+ * already stale: the backend answers per (provider, MODEL) for a gateway, so it now reports
60
+ * caching for several OpenRouter routes while a boolean here still said the gateways cache
61
+ * nothing. A per-provider question is the one this page can ask, and asking the same function
62
+ * the picker and the call paths ask is what keeps the three answers from diverging again.
63
+ *
64
+ * A GATEWAY therefore answers false here, honestly: whether an OpenRouter key caches depends on
65
+ * which model the run picks, which is a per-model badge in the picker rather than a promise this
66
+ * page can make about a key.
67
+ */
68
+ function cachesPrompts(provider: ApiKeyProvider): boolean {
69
+ return providerCachesPrompts(provider)
58
70
  }
59
71
 
60
72
  // Provider metadata. Labels + step instructions resolve through i18n (reactive to the
61
73
  // locale), so each `t(...)` uses a literal key (kept tier-1 typed-key checkable); only the
62
- // `value`/`url`/`caches` differentiators stay inline.
74
+ // `value`/`url` differentiators stay inline.
63
75
  /** Direct vendors: the key reaches that one vendor's own endpoint. */
64
76
  const DIRECT_PROVIDERS = computed<ProviderMeta[]>(() => [
65
77
  {
@@ -70,7 +82,6 @@ const DIRECT_PROVIDERS = computed<ProviderMeta[]>(() => [
70
82
  t('providers.apiKeys.providers.openai.step1'),
71
83
  t('providers.apiKeys.providers.openai.step2'),
72
84
  ],
73
- caches: true,
74
85
  },
75
86
  {
76
87
  value: 'anthropic',
@@ -80,7 +91,6 @@ const DIRECT_PROVIDERS = computed<ProviderMeta[]>(() => [
80
91
  t('providers.apiKeys.providers.anthropic.step1'),
81
92
  t('providers.apiKeys.providers.anthropic.step2'),
82
93
  ],
83
- caches: true,
84
94
  },
85
95
  {
86
96
  value: 'qwen',
@@ -90,7 +100,6 @@ const DIRECT_PROVIDERS = computed<ProviderMeta[]>(() => [
90
100
  t('providers.apiKeys.providers.qwen.step1'),
91
101
  t('providers.apiKeys.providers.qwen.step2'),
92
102
  ],
93
- caches: true,
94
103
  },
95
104
  {
96
105
  value: 'deepseek',
@@ -100,7 +109,6 @@ const DIRECT_PROVIDERS = computed<ProviderMeta[]>(() => [
100
109
  t('providers.apiKeys.providers.deepseek.step1'),
101
110
  t('providers.apiKeys.providers.deepseek.step2'),
102
111
  ],
103
- caches: true,
104
112
  },
105
113
  {
106
114
  value: 'moonshot',
@@ -116,7 +124,6 @@ const DIRECT_PROVIDERS = computed<ProviderMeta[]>(() => [
116
124
  label: t('providers.apiKeys.providers.xai.label'),
117
125
  url: 'https://console.x.ai/',
118
126
  steps: [t('providers.apiKeys.providers.xai.step1'), t('providers.apiKeys.providers.xai.step2')],
119
- caches: true,
120
127
  },
121
128
  ])
122
129
 
@@ -334,7 +341,10 @@ async function remove(k: ApiKey) {
334
341
 
335
342
  <!-- caching capability: connecting a direct key that caches upgrades its models to
336
343
  the caching flavour, so long agentic runs stop re-billing the whole prompt. -->
337
- <p v-if="selected.caches" class="flex items-center gap-1.5 text-[12px] text-emerald-400/90">
344
+ <p
345
+ v-if="cachesPrompts(selected.value)"
346
+ class="flex items-center gap-1.5 text-[12px] text-emerald-400/90"
347
+ >
338
348
  <UIcon name="i-lucide-zap" class="h-3.5 w-3.5 shrink-0" />
339
349
  {{ t('providers.apiKeys.cachingNote', { provider: selected.label }) }}
340
350
  </p>
@@ -351,6 +351,14 @@ function manageKeys() {
351
351
  <span class="min-w-0 flex-1">
352
352
  <span class="block truncate text-slate-200">{{ m.name }}</span>
353
353
  <span class="block truncate font-mono text-[11px] text-slate-500">{{ m.id }}</span>
354
+ <!--
355
+ A withdrawal date is the one fact about a model that fails SILENTLY: past it the
356
+ route simply stops answering and the run falls through to whatever the picker
357
+ offers next, so the moment to see it is while choosing.
358
+ -->
359
+ <span v-if="m.expirationDate" class="block truncate text-[11px] text-amber-400">{{
360
+ t('settings.openRouterCatalog.retiresOn', { date: m.expirationDate })
361
+ }}</span>
354
362
  </span>
355
363
  <span class="shrink-0 text-end text-[11px] text-slate-500">
356
364
  <span v-if="m.contextLength" class="block">{{
@@ -1,6 +1,11 @@
1
1
  import {
2
+ connectServiceCatalogContract,
2
3
  createFoundationalServiceContract,
3
4
  deleteFoundationalServiceContract,
5
+ disconnectServiceCatalogContract,
6
+ getServiceCatalogContract,
7
+ probeServiceCatalogContract,
8
+ syncServiceCatalogContract,
4
9
  foundationalServiceSourceStatusContract,
5
10
  getFoundationalServiceContractsContract,
6
11
  linkFoundationalServiceSourceContract,
@@ -15,6 +20,7 @@ import {
15
20
  updateFoundationalServiceContract,
16
21
  } from '@cat-factory/contracts'
17
22
  import type {
23
+ ConnectServiceCatalogInput,
18
24
  CreateFoundationalServiceInput,
19
25
  FoundationalServiceOwnerKind,
20
26
  LinkFoundationalServiceSourceInput,
@@ -136,5 +142,23 @@ export function foundationalServicesApi({ send, ws, scope }: ApiContext) {
136
142
  pathPrefix: scope(kind, id),
137
143
  pathParams: { id: sourceId },
138
144
  }),
145
+
146
+ // ---- the SERVICE CATALOG connection (a developer portal) --------------
147
+ // Workspace-only, and not by omission: the portal credential rides the workspace-keyed secret
148
+ // delegation, so there is no account-scoped shape of this connection to serve.
149
+ getServiceCatalog: (workspaceId: string) =>
150
+ send(getServiceCatalogContract, { pathPrefix: ws(workspaceId) }),
151
+
152
+ connectServiceCatalog: (workspaceId: string, body: ConnectServiceCatalogInput) =>
153
+ send(connectServiceCatalogContract, { pathPrefix: ws(workspaceId), body }),
154
+
155
+ disconnectServiceCatalog: (workspaceId: string) =>
156
+ send(disconnectServiceCatalogContract, { pathPrefix: ws(workspaceId) }),
157
+
158
+ probeServiceCatalog: (workspaceId: string, body: ConnectServiceCatalogInput) =>
159
+ send(probeServiceCatalogContract, { pathPrefix: ws(workspaceId), body }),
160
+
161
+ syncServiceCatalog: (workspaceId: string) =>
162
+ send(syncServiceCatalogContract, { pathPrefix: ws(workspaceId) }),
139
163
  }
140
164
  }
@@ -358,6 +358,11 @@ const UNAVAILABLE_DESCRIPTION_KEYS: Record<UnavailableReason, string> = {
358
358
  connection_credentials_unreadable:
359
359
  'errors.unavailable.description.connection_credentials_unreadable',
360
360
  vcs_capability_unsupported: 'errors.unavailable.description.vcs_capability_unsupported',
361
+ service_catalog_unreachable: 'errors.unavailable.description.service_catalog_unreachable',
362
+ service_catalog_unauthorized: 'errors.unavailable.description.service_catalog_unauthorized',
363
+ service_catalog_filter_missing: 'errors.unavailable.description.service_catalog_filter_missing',
364
+ service_catalog_response_too_large:
365
+ 'errors.unavailable.description.service_catalog_response_too_large',
361
366
  }
362
367
 
363
368
  /**