@shipfox/client-agent 22.0.2 → 22.0.3

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.
Files changed (73) hide show
  1. package/.storybook/preview.tsx +2 -2
  2. package/.turbo/turbo-build.log +1 -1
  3. package/CHANGELOG.md +19 -0
  4. package/dist/components/form-errors.d.ts.map +1 -1
  5. package/dist/components/form-errors.js +3 -0
  6. package/dist/components/form-errors.js.map +1 -1
  7. package/dist/components/harness-availability.d.ts +2 -2
  8. package/dist/components/harness-availability.d.ts.map +1 -1
  9. package/dist/components/harness-availability.js +4 -3
  10. package/dist/components/harness-availability.js.map +1 -1
  11. package/dist/components/harnesses-section.d.ts.map +1 -1
  12. package/dist/components/harnesses-section.js +4 -2
  13. package/dist/components/harnesses-section.js.map +1 -1
  14. package/dist/components/model-provider-usage-modal.d.ts.map +1 -1
  15. package/dist/components/model-provider-usage-modal.js +23 -5
  16. package/dist/components/model-provider-usage-modal.js.map +1 -1
  17. package/dist/components/model-provider-usage-target.d.ts +6 -6
  18. package/dist/components/model-provider-usage-target.d.ts.map +1 -1
  19. package/dist/components/model-provider-usage-target.js +3 -1
  20. package/dist/components/model-provider-usage-target.js.map +1 -1
  21. package/dist/components/model-providers-section.d.ts.map +1 -1
  22. package/dist/components/model-providers-section.js +277 -170
  23. package/dist/components/model-providers-section.js.map +1 -1
  24. package/dist/components/model-providers-section.stories.d.ts +2 -1
  25. package/dist/components/model-providers-section.stories.d.ts.map +1 -1
  26. package/dist/components/model-providers-section.stories.js +45 -1
  27. package/dist/components/model-providers-section.stories.js.map +1 -1
  28. package/dist/core/harness-policy.d.ts +5 -2
  29. package/dist/core/harness-policy.d.ts.map +1 -1
  30. package/dist/core/harness-policy.js +7 -1
  31. package/dist/core/harness-policy.js.map +1 -1
  32. package/dist/core/models.d.ts +3 -0
  33. package/dist/core/models.d.ts.map +1 -1
  34. package/dist/core/models.js.map +1 -1
  35. package/dist/core/provider-policy.d.ts +3 -1
  36. package/dist/core/provider-policy.d.ts.map +1 -1
  37. package/dist/core/provider-policy.js +7 -0
  38. package/dist/core/provider-policy.js.map +1 -1
  39. package/dist/hooks/api/model-provider-mapper.d.ts.map +1 -1
  40. package/dist/hooks/api/model-provider-mapper.js +5 -1
  41. package/dist/hooks/api/model-provider-mapper.js.map +1 -1
  42. package/dist/pages/model-provider-onboarding-page.d.ts +1 -1
  43. package/dist/pages/model-provider-onboarding-page.d.ts.map +1 -1
  44. package/dist/pages/model-provider-onboarding-page.js +58 -44
  45. package/dist/pages/model-provider-onboarding-page.js.map +1 -1
  46. package/dist/pages/model-provider-onboarding-page.stories.d.ts.map +1 -1
  47. package/dist/pages/model-provider-onboarding-page.stories.js +5 -17
  48. package/dist/pages/model-provider-onboarding-page.stories.js.map +1 -1
  49. package/dist/tsconfig.test.tsbuildinfo +1 -1
  50. package/package.json +5 -5
  51. package/src/components/form-errors.test.ts +9 -0
  52. package/src/components/form-errors.ts +3 -0
  53. package/src/components/harness-availability.test.ts +49 -2
  54. package/src/components/harness-availability.ts +18 -3
  55. package/src/components/harnesses-section.test.tsx +51 -9
  56. package/src/components/harnesses-section.tsx +4 -1
  57. package/src/components/model-provider-usage-modal.test.tsx +23 -0
  58. package/src/components/model-provider-usage-modal.tsx +32 -6
  59. package/src/components/model-provider-usage-target.ts +9 -3
  60. package/src/components/model-providers-section.stories.tsx +33 -1
  61. package/src/components/model-providers-section.test.tsx +41 -0
  62. package/src/components/model-providers-section.tsx +264 -155
  63. package/src/core/harness-policy.ts +21 -1
  64. package/src/core/models.ts +3 -0
  65. package/src/core/provider-policy.ts +12 -0
  66. package/src/hooks/api/model-provider-mapper.test.ts +18 -0
  67. package/src/hooks/api/model-provider-mapper.ts +6 -1
  68. package/src/hooks/api/model-providers.test.ts +11 -0
  69. package/src/pages/model-provider-onboarding-page.stories.tsx +10 -14
  70. package/src/pages/model-provider-onboarding-page.test.tsx +133 -24
  71. package/src/pages/model-provider-onboarding-page.tsx +47 -34
  72. package/test/fixtures/model-providers.ts +22 -1
  73. package/tsconfig.build.tsbuildinfo +1 -1
@@ -21,7 +21,7 @@ import {Panel, PanelBody, PanelRow} from '@shipfox/react-ui/panel';
21
21
  import {Skeleton} from '@shipfox/react-ui/skeleton';
22
22
  import {toast} from '@shipfox/react-ui/toast';
23
23
  import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
24
- import {Header, Text} from '@shipfox/react-ui/typography';
24
+ import {Code, Header, Text} from '@shipfox/react-ui/typography';
25
25
  import {useEffect, useMemo, useReducer, useRef, useState} from 'react';
26
26
  import {
27
27
  type ManagementModal,
@@ -36,7 +36,9 @@ import type {
36
36
  } from '#core/models.js';
37
37
  import {
38
38
  customProviderCardMatchesSearch,
39
+ isManagedOnlyCatalog,
39
40
  isSupportedProvider,
41
+ managedProviderFromCatalog,
40
42
  availableProviders as selectAvailableProviders,
41
43
  } from '#core/provider-policy.js';
42
44
  import {
@@ -67,6 +69,78 @@ type UsageTarget = {
67
69
 
68
70
  const USAGE_MODAL_OPEN_DELAY_MS = 250;
69
71
 
72
+ function ManagedProviderSection({
73
+ provider,
74
+ onShowUsage,
75
+ }: {
76
+ provider: SupportedProvider | undefined;
77
+ onShowUsage: () => void;
78
+ }) {
79
+ return (
80
+ <section className="flex flex-col gap-group" aria-label="Managed provider">
81
+ <div className="flex flex-col gap-tight">
82
+ <Header variant="h3">Managed provider</Header>
83
+ </div>
84
+
85
+ {provider ? (
86
+ <Panel>
87
+ <PanelBody className="gap-group p-panel">
88
+ <div className="flex min-w-0 flex-col gap-tight">
89
+ <Text size="md" bold>
90
+ {provider.label}
91
+ </Text>
92
+ <Text size="sm" className="text-foreground-neutral-muted">
93
+ Managed by this instance. No workspace credentials are required.
94
+ </Text>
95
+ </div>
96
+
97
+ <div className="flex flex-col gap-inline">
98
+ <Text size="sm" bold>
99
+ Available models ({provider.models.length})
100
+ </Text>
101
+ <ul
102
+ aria-label={`${provider.label} models`}
103
+ className="rounded-8 border border-border-neutral-base"
104
+ >
105
+ {provider.models.map((model) => (
106
+ <li
107
+ key={model.id}
108
+ className="flex min-w-0 items-center justify-between gap-inline border-b border-border-neutral-base px-row py-row last:border-b-0"
109
+ >
110
+ <Text as="span" size="sm" bold className="min-w-0 truncate">
111
+ {model.label}
112
+ </Text>
113
+ <Code
114
+ as="span"
115
+ variant="label"
116
+ className="min-w-0 truncate text-foreground-neutral-muted"
117
+ >
118
+ {model.id}
119
+ </Code>
120
+ </li>
121
+ ))}
122
+ </ul>
123
+ </div>
124
+
125
+ <Button type="button" size="sm" variant="secondary" onClick={onShowUsage}>
126
+ Use in a workflow
127
+ </Button>
128
+ </PanelBody>
129
+ </Panel>
130
+ ) : (
131
+ <Panel>
132
+ <EmptyState
133
+ icon="errorWarningLine"
134
+ title="Managed provider unavailable"
135
+ description="This instance is configured without workspace providers, but no managed provider was returned."
136
+ variant="panel"
137
+ />
138
+ </Panel>
139
+ )}
140
+ </section>
141
+ );
142
+ }
143
+
70
144
  export function WorkspaceModelProvidersSection({workspaceId}: {workspaceId: string}) {
71
145
  const catalogQuery = useModelProviderCatalogQuery();
72
146
  const configsQuery = useModelProviderConfigsQuery(workspaceId);
@@ -78,6 +152,8 @@ export function WorkspaceModelProvidersSection({workspaceId}: {workspaceId: stri
78
152
  const configs = configsQuery.data?.configs ?? [];
79
153
  const configsLoaded = configsQuery.data !== undefined;
80
154
  const defaultProviderId = configsQuery.data?.defaultProviderId ?? null;
155
+ const managedOnly = isManagedOnlyCatalog(catalogQuery.data);
156
+ const managedProvider = managedProviderFromCatalog(catalogQuery.data);
81
157
  const providerById = useMemo(
82
158
  () =>
83
159
  new Map<string, ProviderCatalogEntry>(providers.map((provider) => [provider.id, provider])),
@@ -107,174 +183,207 @@ export function WorkspaceModelProvidersSection({workspaceId}: {workspaceId: stri
107
183
  const config = configs.find((item) => item.providerId === modal.providerId);
108
184
  if (config?.kind === 'custom') return usageTargetFromCustomConfig(config);
109
185
  const entry = providerById.get(modal.providerId);
110
- return entry && isSupportedProvider(entry) ? usageTargetFromCatalogEntry(entry) : null;
111
- }, [configs, modal, providerById]);
186
+ return entry && isSupportedProvider(entry)
187
+ ? usageTargetFromCatalogEntry(entry, {isManaged: managedOnly})
188
+ : null;
189
+ }, [configs, managedOnly, modal, providerById]);
112
190
 
113
191
  return (
114
192
  <div className="flex min-w-0 flex-col gap-region">
115
- <section
116
- ref={configuredProvidersRegionRef}
117
- className="flex flex-col gap-group outline-none"
118
- aria-label="Configured providers"
119
- tabIndex={-1}
120
- >
121
- <div className="flex flex-col gap-tight">
122
- <Header variant="h3">Configured providers</Header>
123
- </div>
193
+ {managedOnly ? (
194
+ <ManagedProviderSection
195
+ provider={managedProvider}
196
+ onShowUsage={() => {
197
+ if (!managedProvider) return;
198
+ dispatchModal({
199
+ type: 'show-usage',
200
+ providerId: managedProvider.id,
201
+ initialModel: managedProvider.defaultModel,
202
+ restoreFocusToConfiguredProviders: false,
203
+ });
204
+ }}
205
+ />
206
+ ) : (
207
+ <>
208
+ <section
209
+ ref={configuredProvidersRegionRef}
210
+ className="flex flex-col gap-group outline-none"
211
+ aria-label="Configured providers"
212
+ tabIndex={-1}
213
+ >
214
+ <div className="flex flex-col gap-tight">
215
+ <Header variant="h3">Configured providers</Header>
216
+ </div>
124
217
 
125
- {configsQuery.isPending ? (
126
- <ModelProviderRowsSkeleton label="Loading configured providers" />
127
- ) : null}
218
+ {configsQuery.isPending ? (
219
+ <ModelProviderRowsSkeleton label="Loading configured providers" />
220
+ ) : null}
128
221
 
129
- {configsQuery.isError && configsQuery.data === undefined ? (
130
- <Panel>
131
- <QueryLoadError query={configsQuery} subject="model provider configs" variant="panel" />
132
- </Panel>
133
- ) : null}
222
+ {configsQuery.isError && configsQuery.data === undefined ? (
223
+ <Panel>
224
+ <QueryLoadError
225
+ query={configsQuery}
226
+ subject="model provider configs"
227
+ variant="panel"
228
+ />
229
+ </Panel>
230
+ ) : null}
134
231
 
135
- {configsQuery.data !== undefined && configs.length === 0 ? (
136
- <Panel>
137
- <EmptyState
138
- icon="key2Line"
139
- title="No providers configured"
140
- description="Configure a provider below to run agent steps with workspace-managed credentials."
141
- variant="panel"
142
- />
143
- </Panel>
144
- ) : null}
232
+ {configsQuery.data !== undefined && configs.length === 0 ? (
233
+ <Panel>
234
+ <EmptyState
235
+ icon="key2Line"
236
+ title="No providers configured"
237
+ description="Configure a provider below to run agent steps with workspace-managed credentials."
238
+ variant="panel"
239
+ />
240
+ </Panel>
241
+ ) : null}
145
242
 
146
- {configs.length > 0 ? (
147
- <Panel>
148
- <PanelBody asChild>
149
- <ul>
150
- {configs.map((config) => {
151
- const catalogEntry = providerById.get(config.providerId);
152
- const entry =
153
- catalogEntry && isSupportedProvider(catalogEntry) ? catalogEntry : undefined;
154
- const builtinConfig = isBuiltinModelProviderConfig(config) ? config : undefined;
155
- const customConfig = isCustomModelProviderConfig(config) ? config : undefined;
156
- return (
157
- <ConfiguredProviderRow
158
- key={config.providerId}
159
- workspaceId={workspaceId}
160
- config={config}
161
- entry={entry}
162
- isDefault={config.providerId === defaultProviderId}
163
- onEdit={() => {
164
- if (entry && builtinConfig) {
165
- dispatchModal({
166
- type: 'edit-builtin',
167
- provider: entry,
168
- config: builtinConfig,
169
- });
170
- } else if (customConfig) {
171
- dispatchModal({type: 'edit-custom', config: customConfig});
172
- }
173
- }}
174
- onChangeDefaultModel={() => {
175
- if (entry && builtinConfig)
176
- dispatchModal({
177
- type: 'change-default-model',
178
- provider: entry,
179
- config: builtinConfig,
180
- });
181
- }}
182
- onShowUsage={() => {
183
- if (entry && builtinConfig) {
184
- setPendingUsageTarget(null);
185
- dispatchModal({
186
- type: 'show-usage',
187
- providerId: entry.id,
188
- initialModel: builtinConfig.defaultModel,
189
- restoreFocusToConfiguredProviders: false,
190
- });
191
- } else if (customConfig) {
192
- setPendingUsageTarget(null);
193
- dispatchModal({
194
- type: 'show-usage',
195
- providerId: customConfig.providerId,
196
- initialModel: customConfig.defaultModel,
197
- restoreFocusToConfiguredProviders: false,
198
- });
199
- }
200
- }}
201
- />
202
- );
203
- })}
204
- </ul>
205
- </PanelBody>
206
- </Panel>
207
- ) : null}
208
- </section>
243
+ {configs.length > 0 ? (
244
+ <Panel>
245
+ <PanelBody asChild>
246
+ <ul>
247
+ {configs.map((config) => {
248
+ const catalogEntry = providerById.get(config.providerId);
249
+ const entry =
250
+ catalogEntry && isSupportedProvider(catalogEntry)
251
+ ? catalogEntry
252
+ : undefined;
253
+ const builtinConfig = isBuiltinModelProviderConfig(config)
254
+ ? config
255
+ : undefined;
256
+ const customConfig = isCustomModelProviderConfig(config) ? config : undefined;
257
+ return (
258
+ <ConfiguredProviderRow
259
+ key={config.providerId}
260
+ workspaceId={workspaceId}
261
+ config={config}
262
+ entry={entry}
263
+ isDefault={config.providerId === defaultProviderId}
264
+ onEdit={() => {
265
+ if (entry && builtinConfig) {
266
+ dispatchModal({
267
+ type: 'edit-builtin',
268
+ provider: entry,
269
+ config: builtinConfig,
270
+ });
271
+ } else if (customConfig) {
272
+ dispatchModal({type: 'edit-custom', config: customConfig});
273
+ }
274
+ }}
275
+ onChangeDefaultModel={() => {
276
+ if (entry && builtinConfig)
277
+ dispatchModal({
278
+ type: 'change-default-model',
279
+ provider: entry,
280
+ config: builtinConfig,
281
+ });
282
+ }}
283
+ onShowUsage={() => {
284
+ if (entry && builtinConfig) {
285
+ setPendingUsageTarget(null);
286
+ dispatchModal({
287
+ type: 'show-usage',
288
+ providerId: entry.id,
289
+ initialModel: builtinConfig.defaultModel,
290
+ restoreFocusToConfiguredProviders: false,
291
+ });
292
+ } else if (customConfig) {
293
+ setPendingUsageTarget(null);
294
+ dispatchModal({
295
+ type: 'show-usage',
296
+ providerId: customConfig.providerId,
297
+ initialModel: customConfig.defaultModel,
298
+ restoreFocusToConfiguredProviders: false,
299
+ });
300
+ }
301
+ }}
302
+ />
303
+ );
304
+ })}
305
+ </ul>
306
+ </PanelBody>
307
+ </Panel>
308
+ ) : null}
309
+ </section>
209
310
 
210
- <section className="flex flex-col gap-group" aria-label="Available providers">
211
- <div className="flex flex-col gap-tight">
212
- <Header variant="h3">Available providers</Header>
213
- </div>
311
+ <section className="flex flex-col gap-group" aria-label="Available providers">
312
+ <div className="flex flex-col gap-tight">
313
+ <Header variant="h3">Available providers</Header>
314
+ </div>
214
315
 
215
- {catalogQuery.isPending || configsQuery.isPending ? (
216
- <ModelProviderGridSkeleton label="Loading available providers" />
217
- ) : null}
316
+ {catalogQuery.isPending || configsQuery.isPending ? (
317
+ <ModelProviderGridSkeleton label="Loading available providers" />
318
+ ) : null}
218
319
 
219
- {catalogQuery.isError && catalogQuery.data === undefined ? (
220
- <Panel>
221
- <QueryLoadError query={catalogQuery} subject="model provider catalog" variant="panel" />
222
- </Panel>
223
- ) : null}
320
+ {catalogQuery.isError && catalogQuery.data === undefined ? (
321
+ <Panel>
322
+ <QueryLoadError
323
+ query={catalogQuery}
324
+ subject="model provider catalog"
325
+ variant="panel"
326
+ />
327
+ </Panel>
328
+ ) : null}
224
329
 
225
- {configsLoaded ? (
226
- <AvailableProvidersGrid
227
- entries={availableProviders}
228
- onSelect={(entry) => dispatchModal({type: 'configure-builtin', provider: entry})}
229
- trailingCard={
230
- <AddCustomProviderCard onConfigure={() => dispatchModal({type: 'create-custom'})} />
231
- }
232
- trailingCardMatchesSearch={customProviderCardMatchesSearch}
233
- />
234
- ) : null}
235
- </section>
330
+ {configsLoaded ? (
331
+ <AvailableProvidersGrid
332
+ entries={availableProviders}
333
+ onSelect={(entry) => dispatchModal({type: 'configure-builtin', provider: entry})}
334
+ trailingCard={
335
+ <AddCustomProviderCard
336
+ onConfigure={() => dispatchModal({type: 'create-custom'})}
337
+ />
338
+ }
339
+ trailingCardMatchesSearch={customProviderCardMatchesSearch}
340
+ />
341
+ ) : null}
342
+ </section>
236
343
 
237
- <section className="flex flex-col gap-group" aria-label="Unsupported providers">
238
- <div className="flex flex-col gap-tight">
239
- <Header variant="h3">Unsupported providers</Header>
240
- </div>
344
+ <section className="flex flex-col gap-group" aria-label="Unsupported providers">
345
+ <div className="flex flex-col gap-tight">
346
+ <Header variant="h3">Unsupported providers</Header>
347
+ </div>
241
348
 
242
- {catalogQuery.isPending ? (
243
- <ModelProviderRowsSkeleton label="Loading unsupported providers" />
244
- ) : null}
349
+ {catalogQuery.isPending ? (
350
+ <ModelProviderRowsSkeleton label="Loading unsupported providers" />
351
+ ) : null}
245
352
 
246
- {unsupportedProviders.length > 0 ? (
247
- <Panel>
248
- <PanelBody asChild>
249
- <ul>
250
- {unsupportedProviders.map((entry) => (
251
- <PanelRow
252
- asChild
253
- className="items-start justify-start gap-cluster opacity-70 hover:bg-background-neutral-base"
254
- key={entry.id}
255
- >
256
- <li>
257
- <Icon
258
- name="forbid2Line"
259
- className="mt-[2px] size-18 shrink-0 text-foreground-neutral-muted"
260
- aria-hidden
261
- />
262
- <div className="flex min-w-0 flex-1 flex-col gap-tight">
263
- <Text size="md" bold className="truncate">
264
- {entry.label}
265
- </Text>
266
- <Text size="sm" className="text-foreground-neutral-muted">
267
- {entry.unsupportedReason}
268
- </Text>
269
- </div>
270
- </li>
271
- </PanelRow>
272
- ))}
273
- </ul>
274
- </PanelBody>
275
- </Panel>
276
- ) : null}
277
- </section>
353
+ {unsupportedProviders.length > 0 ? (
354
+ <Panel>
355
+ <PanelBody asChild>
356
+ <ul>
357
+ {unsupportedProviders.map((entry) => (
358
+ <PanelRow
359
+ asChild
360
+ className="items-start justify-start gap-cluster opacity-70 hover:bg-background-neutral-base"
361
+ key={entry.id}
362
+ >
363
+ <li>
364
+ <Icon
365
+ name="forbid2Line"
366
+ className="mt-[2px] size-18 shrink-0 text-foreground-neutral-muted"
367
+ aria-hidden
368
+ />
369
+ <div className="flex min-w-0 flex-1 flex-col gap-tight">
370
+ <Text size="md" bold className="truncate">
371
+ {entry.label}
372
+ </Text>
373
+ <Text size="sm" className="text-foreground-neutral-muted">
374
+ {entry.unsupportedReason}
375
+ </Text>
376
+ </div>
377
+ </li>
378
+ </PanelRow>
379
+ ))}
380
+ </ul>
381
+ </PanelBody>
382
+ </Panel>
383
+ ) : null}
384
+ </section>
385
+ </>
386
+ )}
278
387
 
279
388
  <Modal
280
389
  open={modal.kind === 'configure-builtin' || modal.kind === 'edit-builtin'}
@@ -1,4 +1,4 @@
1
- import type {HarnessDescriptor, HarnessId, ProviderConfig} from './models.js';
1
+ import type {AgentModel, HarnessDescriptor, HarnessId, ProviderConfig} from './models.js';
2
2
 
3
3
  export const DEFAULT_HARNESS: HarnessId = 'pi';
4
4
 
@@ -71,14 +71,34 @@ export function configSupportsHarness(config: ProviderConfig, harness: HarnessDe
71
71
  : harness.supportedProviderIds.includes(config.providerId);
72
72
  }
73
73
 
74
+ export function managedModelSupportsHarness(
75
+ harnessId: HarnessId,
76
+ model: Pick<AgentModel, 'api'>,
77
+ ): boolean {
78
+ return harnessId === 'pi' || model.api === undefined || model.api === 'anthropic-messages';
79
+ }
80
+
74
81
  export function compatibleHarnessIds({
75
82
  isCustom,
83
+ isManaged = false,
84
+ models,
76
85
  providerId,
77
86
  }: {
78
87
  isCustom: boolean;
88
+ isManaged?: boolean;
89
+ models?: readonly Pick<AgentModel, 'api'>[];
79
90
  providerId: string;
80
91
  }): HarnessId[] {
81
92
  if (isCustom) return [DEFAULT_HARNESS];
93
+ if (isManaged) {
94
+ return harnesses
95
+ .filter(
96
+ (harness) =>
97
+ models === undefined ||
98
+ models.some((model) => managedModelSupportsHarness(harness.id, model)),
99
+ )
100
+ .map((harness) => harness.id);
101
+ }
82
102
  return harnesses
83
103
  .filter((harness) => harness.supportedProviderIds.includes(providerId))
84
104
  .map((harness) => harness.id);
@@ -1,4 +1,5 @@
1
1
  export type HarnessId = 'pi' | 'claude';
2
+ export type WorkspaceProvidersPolicy = 'enabled' | 'disabled';
2
3
  export type ProviderApi =
3
4
  | 'openai-completions'
4
5
  | 'openai-responses'
@@ -15,6 +16,7 @@ export interface HarnessDescriptor {
15
16
  export interface AgentModel {
16
17
  readonly id: string;
17
18
  readonly label: string;
19
+ readonly api?: ProviderApi | undefined;
18
20
  readonly contextWindow?: number | undefined;
19
21
  readonly maxOutputTokens?: number | undefined;
20
22
  readonly inputImage?: boolean | undefined;
@@ -76,6 +78,7 @@ export type ProviderConfig = BuiltinProviderConfig | CustomProviderConfig;
76
78
 
77
79
  export interface ProviderCatalog {
78
80
  readonly providers: readonly ProviderCatalogEntry[];
81
+ readonly workspaceProviders: WorkspaceProvidersPolicy;
79
82
  }
80
83
 
81
84
  export interface ProviderConfiguration {
@@ -1,5 +1,6 @@
1
1
  import type {
2
2
  HarnessDescriptor,
3
+ ProviderCatalog,
3
4
  ProviderCatalogEntry,
4
5
  ProviderConfig,
5
6
  SupportedProvider,
@@ -13,6 +14,17 @@ export function supportsProvider(harness: HarnessDescriptor, providerId: string)
13
14
  return harness.supportedProviderIds.includes(providerId);
14
15
  }
15
16
 
17
+ export function isManagedOnlyCatalog(catalog: ProviderCatalog | undefined): boolean {
18
+ return catalog?.workspaceProviders === 'disabled';
19
+ }
20
+
21
+ export function managedProviderFromCatalog(
22
+ catalog: ProviderCatalog | undefined,
23
+ ): SupportedProvider | undefined {
24
+ if (!isManagedOnlyCatalog(catalog)) return undefined;
25
+ return catalog?.providers.find(isSupportedProvider);
26
+ }
27
+
16
28
  export function availableProviders(
17
29
  catalog: readonly ProviderCatalogEntry[],
18
30
  configs: readonly ProviderConfig[],
@@ -1,4 +1,5 @@
1
1
  import {
2
+ managedModelProviderEntry,
2
3
  modelProviderCatalogResponse,
3
4
  modelProviderConfigsResponse,
4
5
  modelProviderEntry,
@@ -17,6 +18,23 @@ test('maps provider catalog entries before they reach the client domain', () =>
17
18
  ]);
18
19
  });
19
20
 
21
+ test('maps the managed-only workspace policy and managed provider models', () => {
22
+ const catalog = toProviderCatalog(
23
+ modelProviderCatalogResponse([managedModelProviderEntry()], 'disabled'),
24
+ );
25
+
26
+ expect(catalog.workspaceProviders).toBe('disabled');
27
+ expect(catalog.providers[0]).toEqual(
28
+ expect.objectContaining({
29
+ id: 'shipfox',
30
+ models: [
31
+ {id: 'claude-opus-4-8', label: 'Claude Opus 4.8', api: 'anthropic-messages'},
32
+ {id: 'gpt-5.5-pro', label: 'GPT-5.5 Pro', api: 'openai-responses'},
33
+ ],
34
+ }),
35
+ );
36
+ });
37
+
20
38
  test('maps configuration response defaults and provider config fields', () => {
21
39
  const configuration = toProviderConfiguration(modelProviderConfigsResponse());
22
40
 
@@ -21,7 +21,10 @@ import type {
21
21
  } from '#core/models.js';
22
22
 
23
23
  export function toProviderCatalog(response: ModelProviderCatalogResponseDto): ProviderCatalog {
24
- return {providers: response.providers.map(toProviderCatalogEntry)};
24
+ return {
25
+ providers: response.providers.map(toProviderCatalogEntry),
26
+ workspaceProviders: response.workspace_providers ?? 'enabled',
27
+ };
25
28
  }
26
29
 
27
30
  export function toProviderCatalogEntry(entry: ModelProviderCatalogEntryDto): ProviderCatalogEntry {
@@ -104,6 +107,7 @@ export function toCustomProviderConfig(config: CustomModelProviderConfigDto): Cu
104
107
  function toAgentModel(model: {
105
108
  id: string;
106
109
  label: string;
110
+ api?: AgentModel['api'];
107
111
  context_window?: number | undefined;
108
112
  max_output_tokens?: number | undefined;
109
113
  input_image?: boolean | undefined;
@@ -112,6 +116,7 @@ function toAgentModel(model: {
112
116
  return {
113
117
  id: model.id,
114
118
  label: model.label,
119
+ ...(model.api === undefined ? {} : {api: model.api}),
115
120
  ...(model.context_window === undefined ? {} : {contextWindow: model.context_window}),
116
121
  ...(model.max_output_tokens === undefined ? {} : {maxOutputTokens: model.max_output_tokens}),
117
122
  ...(model.input_image === undefined ? {} : {inputImage: model.input_image}),
@@ -40,6 +40,17 @@ describe('model provider transport', () => {
40
40
  expect(request.method).toBe('GET');
41
41
  });
42
42
 
43
+ test('preserves the managed-only workspace policy from the catalog', async () => {
44
+ const fetchImpl = vi
45
+ .fn()
46
+ .mockResolvedValue(jsonResponse(modelProviderCatalogResponse(undefined, 'disabled')));
47
+ configureApiClient({fetchImpl});
48
+
49
+ const result = await getModelProviderCatalog();
50
+
51
+ expect(result.workspaceProviders).toBe('disabled');
52
+ });
53
+
43
54
  test('fetches workspace model provider configs', async () => {
44
55
  const fetchImpl = vi.fn().mockResolvedValue(jsonResponse(modelProviderConfigsResponse()));
45
56
  configureApiClient({fetchImpl});