@shipfox/client-agent 5.0.0 → 6.0.1

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 (163) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +45 -0
  3. package/dist/components/available-provider-card.d.ts +2 -2
  4. package/dist/components/available-provider-card.d.ts.map +1 -1
  5. package/dist/components/available-provider-card.js.map +1 -1
  6. package/dist/components/available-providers-grid.d.ts +2 -2
  7. package/dist/components/available-providers-grid.d.ts.map +1 -1
  8. package/dist/components/available-providers-grid.js +1 -1
  9. package/dist/components/available-providers-grid.js.map +1 -1
  10. package/dist/components/change-default-model-form.d.ts +3 -4
  11. package/dist/components/change-default-model-form.d.ts.map +1 -1
  12. package/dist/components/change-default-model-form.js +2 -4
  13. package/dist/components/change-default-model-form.js.map +1 -1
  14. package/dist/components/custom-model-provider-api-options.d.ts +3 -3
  15. package/dist/components/custom-model-provider-api-options.d.ts.map +1 -1
  16. package/dist/components/custom-model-provider-api-options.js.map +1 -1
  17. package/dist/components/custom-model-provider-form.d.ts +2 -2
  18. package/dist/components/custom-model-provider-form.d.ts.map +1 -1
  19. package/dist/components/custom-model-provider-form.js +12 -10
  20. package/dist/components/custom-model-provider-form.js.map +1 -1
  21. package/dist/components/custom-model-provider-form.stories.d.ts.map +1 -1
  22. package/dist/components/custom-model-provider-form.stories.js +11 -11
  23. package/dist/components/custom-model-provider-form.stories.js.map +1 -1
  24. package/dist/components/custom-model-provider-payload.d.ts +9 -9
  25. package/dist/components/custom-model-provider-payload.d.ts.map +1 -1
  26. package/dist/components/custom-model-provider-payload.js +42 -26
  27. package/dist/components/custom-model-provider-payload.js.map +1 -1
  28. package/dist/components/custom-model-provider-slug.d.ts +1 -1
  29. package/dist/components/custom-model-provider-slug.d.ts.map +1 -1
  30. package/dist/components/custom-model-provider-slug.js +3 -3
  31. package/dist/components/custom-model-provider-slug.js.map +1 -1
  32. package/dist/components/default-model-field.d.ts +2 -2
  33. package/dist/components/default-model-field.d.ts.map +1 -1
  34. package/dist/components/default-model-field.js +1 -1
  35. package/dist/components/default-model-field.js.map +1 -1
  36. package/dist/components/harness-availability.d.ts +4 -7
  37. package/dist/components/harness-availability.d.ts.map +1 -1
  38. package/dist/components/harness-availability.js +2 -11
  39. package/dist/components/harness-availability.js.map +1 -1
  40. package/dist/components/harnesses-section.d.ts.map +1 -1
  41. package/dist/components/harnesses-section.js +4 -6
  42. package/dist/components/harnesses-section.js.map +1 -1
  43. package/dist/components/model-provider-usage-modal.d.ts +2 -2
  44. package/dist/components/model-provider-usage-modal.d.ts.map +1 -1
  45. package/dist/components/model-provider-usage-modal.js +4 -4
  46. package/dist/components/model-provider-usage-modal.js.map +1 -1
  47. package/dist/components/model-provider-usage-modal.stories.d.ts.map +1 -1
  48. package/dist/components/model-provider-usage-modal.stories.js +14 -16
  49. package/dist/components/model-provider-usage-modal.stories.js.map +1 -1
  50. package/dist/components/model-provider-usage-target.d.ts +5 -5
  51. package/dist/components/model-provider-usage-target.d.ts.map +1 -1
  52. package/dist/components/model-provider-usage-target.js +10 -8
  53. package/dist/components/model-provider-usage-target.js.map +1 -1
  54. package/dist/components/model-providers-section.d.ts.map +1 -1
  55. package/dist/components/model-providers-section.js +112 -94
  56. package/dist/components/model-providers-section.js.map +1 -1
  57. package/dist/components/model-providers-section.stories.js +8 -4
  58. package/dist/components/model-providers-section.stories.js.map +1 -1
  59. package/dist/components/test-and-save-form.d.ts +3 -3
  60. package/dist/components/test-and-save-form.d.ts.map +1 -1
  61. package/dist/components/test-and-save-form.js +7 -7
  62. package/dist/components/test-and-save-form.js.map +1 -1
  63. package/dist/core/harness-policy.d.ts +11 -0
  64. package/dist/core/harness-policy.d.ts.map +1 -0
  65. package/dist/core/harness-policy.js +71 -0
  66. package/dist/core/harness-policy.js.map +1 -0
  67. package/dist/core/model-provider-management-reducer.d.ts +51 -0
  68. package/dist/core/model-provider-management-reducer.d.ts.map +1 -0
  69. package/dist/core/model-provider-management-reducer.js +43 -0
  70. package/dist/core/model-provider-management-reducer.js.map +1 -0
  71. package/dist/core/models.d.ts +120 -0
  72. package/dist/core/models.d.ts.map +1 -0
  73. package/dist/core/models.js +3 -0
  74. package/dist/core/models.js.map +1 -0
  75. package/dist/core/onboarding-reducer.d.ts +36 -0
  76. package/dist/core/onboarding-reducer.d.ts.map +1 -0
  77. package/dist/core/onboarding-reducer.js +54 -0
  78. package/dist/core/onboarding-reducer.js.map +1 -0
  79. package/dist/core/provider-policy.d.ts +9 -0
  80. package/dist/core/provider-policy.d.ts.map +1 -0
  81. package/dist/core/provider-policy.js +37 -0
  82. package/dist/core/provider-policy.js.map +1 -0
  83. package/dist/feature.d.ts +7 -0
  84. package/dist/feature.d.ts.map +1 -1
  85. package/dist/feature.js +10 -9
  86. package/dist/feature.js.map +1 -1
  87. package/dist/hooks/api/model-provider-command-mapper.d.ts +11 -0
  88. package/dist/hooks/api/model-provider-command-mapper.d.ts.map +1 -0
  89. package/dist/hooks/api/model-provider-command-mapper.js +144 -0
  90. package/dist/hooks/api/model-provider-command-mapper.js.map +1 -0
  91. package/dist/hooks/api/model-provider-mapper.d.ts +11 -0
  92. package/dist/hooks/api/model-provider-mapper.d.ts.map +1 -0
  93. package/dist/hooks/api/model-provider-mapper.js +94 -0
  94. package/dist/hooks/api/model-provider-mapper.js.map +1 -0
  95. package/dist/hooks/api/model-providers.d.ts +60 -295
  96. package/dist/hooks/api/model-providers.d.ts.map +1 -1
  97. package/dist/hooks/api/model-providers.js +51 -37
  98. package/dist/hooks/api/model-providers.js.map +1 -1
  99. package/dist/pages/model-provider-onboarding-page.d.ts.map +1 -1
  100. package/dist/pages/model-provider-onboarding-page.js +57 -45
  101. package/dist/pages/model-provider-onboarding-page.js.map +1 -1
  102. package/dist/routes/inputs.d.ts +4 -0
  103. package/dist/routes/inputs.d.ts.map +1 -0
  104. package/dist/routes/inputs.js +12 -0
  105. package/dist/routes/inputs.js.map +1 -0
  106. package/dist/routes/model-provider.d.ts.map +1 -1
  107. package/dist/routes/model-provider.js +4 -5
  108. package/dist/routes/model-provider.js.map +1 -1
  109. package/dist/tsconfig.test.tsbuildinfo +1 -1
  110. package/package.json +9 -10
  111. package/src/components/available-provider-card.tsx +2 -2
  112. package/src/components/available-providers-grid.tsx +3 -3
  113. package/src/components/change-default-model-form.tsx +5 -6
  114. package/src/components/custom-model-provider-api-options.ts +3 -3
  115. package/src/components/custom-model-provider-form.stories.tsx +13 -13
  116. package/src/components/custom-model-provider-form.tsx +19 -12
  117. package/src/components/custom-model-provider-payload.test.ts +22 -22
  118. package/src/components/custom-model-provider-payload.ts +69 -54
  119. package/src/components/custom-model-provider-slug.test.ts +1 -1
  120. package/src/components/custom-model-provider-slug.ts +6 -3
  121. package/src/components/default-model-field.tsx +5 -5
  122. package/src/components/harness-availability.test.ts +13 -10
  123. package/src/components/harness-availability.ts +4 -28
  124. package/src/components/harnesses-section.tsx +7 -11
  125. package/src/components/model-provider-usage-modal.stories.tsx +18 -23
  126. package/src/components/model-provider-usage-modal.test.tsx +35 -7
  127. package/src/components/model-provider-usage-modal.tsx +12 -16
  128. package/src/components/model-provider-usage-target.ts +15 -18
  129. package/src/components/model-providers-section.stories.tsx +4 -1
  130. package/src/components/model-providers-section.tsx +112 -125
  131. package/src/components/test-and-save-form.tsx +13 -17
  132. package/src/core/harness-policy.test.ts +15 -0
  133. package/src/core/harness-policy.ts +83 -0
  134. package/src/core/model-provider-management-reducer.test.ts +59 -0
  135. package/src/core/model-provider-management-reducer.ts +72 -0
  136. package/src/core/models.ts +143 -0
  137. package/src/core/onboarding-reducer.test.ts +44 -0
  138. package/src/core/onboarding-reducer.ts +64 -0
  139. package/src/core/provider-policy.ts +53 -0
  140. package/src/feature.ts +12 -10
  141. package/src/hooks/api/model-provider-command-mapper.ts +122 -0
  142. package/src/hooks/api/model-provider-mapper.test.ts +27 -0
  143. package/src/hooks/api/model-provider-mapper.ts +120 -0
  144. package/src/hooks/api/model-providers.test.ts +15 -15
  145. package/src/hooks/api/model-providers.ts +146 -81
  146. package/src/pages/model-provider-onboarding-page.tsx +62 -56
  147. package/src/routes/inputs.test.ts +11 -0
  148. package/src/routes/inputs.ts +9 -0
  149. package/src/routes/model-provider.tsx +4 -3
  150. package/test/fixtures/model-providers.ts +1 -1
  151. package/tsconfig.build.tsbuildinfo +1 -1
  152. package/vercel.json +5 -0
  153. package/dist/components/provider-search.d.ts +0 -4
  154. package/dist/components/provider-search.d.ts.map +0 -1
  155. package/dist/components/provider-search.js +0 -13
  156. package/dist/components/provider-search.js.map +0 -1
  157. package/dist/components/supported-model-provider-catalog-entry.d.ts +0 -8
  158. package/dist/components/supported-model-provider-catalog-entry.d.ts.map +0 -1
  159. package/dist/components/supported-model-provider-catalog-entry.js +0 -9
  160. package/dist/components/supported-model-provider-catalog-entry.js.map +0 -1
  161. package/src/components/provider-search.test.ts +0 -45
  162. package/src/components/provider-search.ts +0 -18
  163. package/src/components/supported-model-provider-catalog-entry.ts +0 -19
@@ -1,9 +1,3 @@
1
- import type {
2
- CustomModelProviderConfigDto,
3
- ModelProviderCatalogEntryDto,
4
- ModelProviderConfigDto,
5
- ModelProviderConfigResponseDto,
6
- } from '@shipfox/api-agent-dto';
7
1
  import {QueryLoadError} from '@shipfox/client-ui';
8
2
  import {Button, IconButton} from '@shipfox/react-ui/button';
9
3
  import {Callout} from '@shipfox/react-ui/callout';
@@ -28,7 +22,23 @@ import {toast} from '@shipfox/react-ui/toast';
28
22
  import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
29
23
  import {Header, Text} from '@shipfox/react-ui/typography';
30
24
  import {cn} from '@shipfox/react-ui/utils';
31
- import {useEffect, useMemo, useRef, useState} from 'react';
25
+ import {useEffect, useMemo, useReducer, useRef, useState} from 'react';
26
+ import {
27
+ type ManagementModal,
28
+ managementModalReducer,
29
+ } from '#core/model-provider-management-reducer.js';
30
+ import type {
31
+ BuiltinProviderConfig,
32
+ CustomProviderConfig,
33
+ ProviderCatalogEntry,
34
+ ProviderConfig,
35
+ SupportedProvider,
36
+ } from '#core/models.js';
37
+ import {
38
+ customProviderCardMatchesSearch,
39
+ isSupportedProvider,
40
+ availableProviders as selectAvailableProviders,
41
+ } from '#core/provider-policy.js';
32
42
  import {
33
43
  useDeleteModelProviderConfigMutation,
34
44
  useModelProviderCatalogQuery,
@@ -46,24 +56,11 @@ import {
46
56
  usageTargetFromCatalogEntry,
47
57
  usageTargetFromCustomConfig,
48
58
  } from './model-provider-usage-target.js';
49
- import {customProviderCardMatchesSearch} from './provider-search.js';
50
- import {
51
- isSupportedCatalogEntry,
52
- type SupportedModelProviderCatalogEntry,
53
- toSupportedCatalogEntry,
54
- } from './supported-model-provider-catalog-entry.js';
55
59
  import {ModelProviderTestAndSaveForm} from './test-and-save-form.js';
56
60
 
57
61
  const SURFACE_CLASS =
58
62
  'overflow-hidden rounded-8 border border-border-neutral-base bg-background-neutral-base';
59
63
 
60
- type ModelProviderFormState =
61
- | {mode: 'configure'; entry: SupportedModelProviderCatalogEntry; config?: undefined}
62
- | {mode: 'edit'; entry: SupportedModelProviderCatalogEntry; config: ModelProviderConfigDto};
63
- type CustomModelProviderFormState =
64
- | {mode: 'create'}
65
- | {mode: 'edit'; config: CustomModelProviderConfigDto};
66
- type ModelFormState = {entry: SupportedModelProviderCatalogEntry; config: ModelProviderConfigDto};
67
64
  type UsageTarget = {
68
65
  target: ModelProviderUsageTarget;
69
66
  initialModel: string | null;
@@ -75,61 +72,45 @@ const USAGE_MODAL_OPEN_DELAY_MS = 250;
75
72
  export function WorkspaceModelProvidersSection({workspaceId}: {workspaceId: string}) {
76
73
  const catalogQuery = useModelProviderCatalogQuery();
77
74
  const configsQuery = useModelProviderConfigsQuery(workspaceId);
78
- const [formState, setFormState] = useState<ModelProviderFormState | null>(null);
79
- const [customFormState, setCustomFormState] = useState<CustomModelProviderFormState | null>(null);
80
- const [modelFormState, setModelFormState] = useState<ModelFormState | null>(null);
81
- const [usageTarget, setUsageTarget] = useState<UsageTarget | null>(null);
75
+ const [modal, dispatchModal] = useReducer(managementModalReducer, {kind: 'closed'});
82
76
  const [pendingUsageTarget, setPendingUsageTarget] = useState<UsageTarget | null>(null);
83
77
  const configuredProvidersRegionRef = useRef<HTMLElement | null>(null);
84
78
 
85
79
  const providers = catalogQuery.data?.providers ?? [];
86
80
  const configs = configsQuery.data?.configs ?? [];
87
81
  const configsLoaded = configsQuery.data !== undefined;
88
- const defaultProviderId = configsQuery.data?.default_provider_id ?? null;
82
+ const defaultProviderId = configsQuery.data?.defaultProviderId ?? null;
89
83
  const providerById = useMemo(
90
84
  () =>
91
- new Map<string, ModelProviderCatalogEntryDto>(
92
- providers.map((provider) => [provider.id, provider]),
93
- ),
85
+ new Map<string, ProviderCatalogEntry>(providers.map((provider) => [provider.id, provider])),
94
86
  [providers],
95
87
  );
96
- const configuredIds = useMemo(
97
- () => new Set<string>(configs.map((config) => config.provider_id)),
98
- [configs],
99
- );
100
- const availableProviders = configsLoaded
101
- ? providers.filter(
102
- (provider): provider is SupportedModelProviderCatalogEntry =>
103
- isSupportedCatalogEntry(provider) && !configuredIds.has(provider.id),
104
- )
105
- : [];
106
- const unsupportedProviders = providers.filter(
107
- (provider) => provider.support_status === 'unsupported',
108
- );
109
-
110
- function closeForm() {
111
- setFormState(null);
112
- }
113
-
114
- function closeModelForm() {
115
- setModelFormState(null);
116
- }
117
-
118
- function closeCustomForm() {
119
- setCustomFormState(null);
120
- }
88
+ const availableProviders = configsLoaded ? selectAvailableProviders(providers, configs) : [];
89
+ const unsupportedProviders = providers.filter((provider) => !isSupportedProvider(provider));
121
90
 
122
91
  useEffect(() => {
123
- if (pendingUsageTarget === null || formState !== null || customFormState !== null)
124
- return undefined;
92
+ if (pendingUsageTarget === null || modal.kind !== 'closed') return undefined;
125
93
 
126
94
  const timer = window.setTimeout(() => {
127
- setUsageTarget(pendingUsageTarget);
95
+ dispatchModal({
96
+ type: 'show-usage',
97
+ providerId: pendingUsageTarget.target.id,
98
+ initialModel: pendingUsageTarget.initialModel,
99
+ restoreFocusToConfiguredProviders: pendingUsageTarget.restoreFocusToConfiguredProviders,
100
+ });
128
101
  setPendingUsageTarget(null);
129
102
  }, USAGE_MODAL_OPEN_DELAY_MS);
130
103
 
131
104
  return () => window.clearTimeout(timer);
132
- }, [customFormState, formState, pendingUsageTarget]);
105
+ }, [modal.kind, pendingUsageTarget]);
106
+
107
+ const usageTarget = useMemo(() => {
108
+ if (modal.kind !== 'show-usage') return null;
109
+ const config = configs.find((item) => item.providerId === modal.providerId);
110
+ if (config?.kind === 'custom') return usageTargetFromCustomConfig(config);
111
+ const entry = providerById.get(modal.providerId);
112
+ return entry && isSupportedProvider(entry) ? usageTargetFromCatalogEntry(entry) : null;
113
+ }, [configs, modal, providerById]);
133
114
 
134
115
  return (
135
116
  <div className="flex min-w-0 flex-col gap-32">
@@ -169,39 +150,48 @@ export function WorkspaceModelProvidersSection({workspaceId}: {workspaceId: stri
169
150
  {configs.length > 0 ? (
170
151
  <ul className={cn('divide-y divide-border-neutral-base', SURFACE_CLASS)}>
171
152
  {configs.map((config) => {
172
- const entry = toSupportedCatalogEntry(providerById.get(config.provider_id));
153
+ const catalogEntry = providerById.get(config.providerId);
154
+ const entry =
155
+ catalogEntry && isSupportedProvider(catalogEntry) ? catalogEntry : undefined;
173
156
  const builtinConfig = isBuiltinModelProviderConfig(config) ? config : undefined;
174
157
  const customConfig = isCustomModelProviderConfig(config) ? config : undefined;
175
158
  return (
176
159
  <ConfiguredProviderRow
177
- key={config.provider_id}
160
+ key={config.providerId}
178
161
  workspaceId={workspaceId}
179
162
  config={config}
180
163
  entry={entry}
181
- isDefault={config.provider_id === defaultProviderId}
164
+ isDefault={config.providerId === defaultProviderId}
182
165
  onEdit={() => {
183
166
  if (entry && builtinConfig) {
184
- setFormState({mode: 'edit', entry, config: builtinConfig});
167
+ dispatchModal({type: 'edit-builtin', provider: entry, config: builtinConfig});
185
168
  } else if (customConfig) {
186
- setCustomFormState({mode: 'edit', config: customConfig});
169
+ dispatchModal({type: 'edit-custom', config: customConfig});
187
170
  }
188
171
  }}
189
172
  onChangeDefaultModel={() => {
190
- if (entry && builtinConfig) setModelFormState({entry, config: builtinConfig});
173
+ if (entry && builtinConfig)
174
+ dispatchModal({
175
+ type: 'change-default-model',
176
+ provider: entry,
177
+ config: builtinConfig,
178
+ });
191
179
  }}
192
180
  onShowUsage={() => {
193
181
  if (entry && builtinConfig) {
194
182
  setPendingUsageTarget(null);
195
- setUsageTarget({
196
- target: usageTargetFromCatalogEntry(entry),
197
- initialModel: builtinConfig.default_model,
183
+ dispatchModal({
184
+ type: 'show-usage',
185
+ providerId: entry.id,
186
+ initialModel: builtinConfig.defaultModel,
198
187
  restoreFocusToConfiguredProviders: false,
199
188
  });
200
189
  } else if (customConfig) {
201
190
  setPendingUsageTarget(null);
202
- setUsageTarget({
203
- target: usageTargetFromCustomConfig(customConfig),
204
- initialModel: customConfig.default_model,
191
+ dispatchModal({
192
+ type: 'show-usage',
193
+ providerId: customConfig.providerId,
194
+ initialModel: customConfig.defaultModel,
205
195
  restoreFocusToConfiguredProviders: false,
206
196
  });
207
197
  }
@@ -232,9 +222,9 @@ export function WorkspaceModelProvidersSection({workspaceId}: {workspaceId: stri
232
222
  {configsLoaded ? (
233
223
  <AvailableProvidersGrid
234
224
  entries={availableProviders}
235
- onSelect={(entry) => setFormState({mode: 'configure', entry})}
225
+ onSelect={(entry) => dispatchModal({type: 'configure-builtin', provider: entry})}
236
226
  trailingCard={
237
- <AddCustomProviderCard onConfigure={() => setCustomFormState({mode: 'create'})} />
227
+ <AddCustomProviderCard onConfigure={() => dispatchModal({type: 'create-custom'})} />
238
228
  }
239
229
  trailingCardMatchesSearch={customProviderCardMatchesSearch}
240
230
  />
@@ -267,7 +257,7 @@ export function WorkspaceModelProvidersSection({workspaceId}: {workspaceId: stri
267
257
  {entry.label}
268
258
  </Text>
269
259
  <Text size="sm" className="text-foreground-neutral-muted">
270
- {entry.unsupported_reason}
260
+ {entry.unsupportedReason}
271
261
  </Text>
272
262
  </div>
273
263
  </li>
@@ -276,33 +266,36 @@ export function WorkspaceModelProvidersSection({workspaceId}: {workspaceId: stri
276
266
  ) : null}
277
267
  </section>
278
268
 
279
- <Modal open={formState !== null} onOpenChange={(open) => (open ? undefined : closeForm())}>
269
+ <Modal
270
+ open={modal.kind === 'configure-builtin' || modal.kind === 'edit-builtin'}
271
+ onOpenChange={(open) => (open ? undefined : dispatchModal({type: 'close'}))}
272
+ >
280
273
  <ModalContent aria-describedby={undefined}>
281
- <ModalTitle className="sr-only">{modelProviderFormTitle(formState)}</ModalTitle>
274
+ <ModalTitle className="sr-only">{modelProviderFormTitle(modal)}</ModalTitle>
282
275
  <ModalHeader>
283
276
  <Text
284
277
  size="lg"
285
278
  aria-hidden="true"
286
279
  className="overflow-ellipsis overflow-hidden whitespace-nowrap"
287
280
  >
288
- {modelProviderFormTitle(formState)}
281
+ {modelProviderFormTitle(modal)}
289
282
  </Text>
290
283
  </ModalHeader>
291
- {formState ? (
284
+ {modal.kind === 'configure-builtin' || modal.kind === 'edit-builtin' ? (
292
285
  <ModelProviderTestAndSaveForm
293
286
  workspaceId={workspaceId}
294
- entry={formState.entry}
295
- existingConfig={formState.config}
287
+ entry={modal.provider}
288
+ existingConfig={modal.kind === 'edit-builtin' ? modal.config : undefined}
296
289
  onSaved={(savedDefaultModel) => {
297
- toast.success(`${formState.entry.label} saved`);
298
- if (formState.mode === 'configure' && configs.length === 0) {
290
+ toast.success(`${modal.provider.label} saved`);
291
+ if (modal.kind === 'configure-builtin' && configs.length === 0) {
299
292
  setPendingUsageTarget({
300
- target: usageTargetFromCatalogEntry(formState.entry),
293
+ target: usageTargetFromCatalogEntry(modal.provider),
301
294
  initialModel: savedDefaultModel,
302
295
  restoreFocusToConfiguredProviders: true,
303
296
  });
304
297
  }
305
- closeForm();
298
+ dispatchModal({type: 'close'});
306
299
  }}
307
300
  />
308
301
  ) : null}
@@ -310,8 +303,8 @@ export function WorkspaceModelProvidersSection({workspaceId}: {workspaceId: stri
310
303
  </Modal>
311
304
 
312
305
  <Modal
313
- open={modelFormState !== null}
314
- onOpenChange={(open) => (open ? undefined : closeModelForm())}
306
+ open={modal.kind === 'change-default-model'}
307
+ onOpenChange={(open) => (open ? undefined : dispatchModal({type: 'close'}))}
315
308
  >
316
309
  <ModalContent aria-describedby={undefined}>
317
310
  <ModalTitle className="sr-only">Change default model</ModalTitle>
@@ -321,17 +314,18 @@ export function WorkspaceModelProvidersSection({workspaceId}: {workspaceId: stri
321
314
  aria-hidden="true"
322
315
  className="overflow-ellipsis overflow-hidden whitespace-nowrap"
323
316
  >
324
- Change default model for {modelFormState?.entry.label}
317
+ Change default model for{' '}
318
+ {modal.kind === 'change-default-model' ? modal.provider.label : ''}
325
319
  </Text>
326
320
  </ModalHeader>
327
- {modelFormState ? (
321
+ {modal.kind === 'change-default-model' ? (
328
322
  <ChangeDefaultModelForm
329
323
  workspaceId={workspaceId}
330
- entry={modelFormState.entry}
331
- config={modelFormState.config}
324
+ entry={modal.provider}
325
+ config={modal.config}
332
326
  onSaved={() => {
333
- toast.success(`${modelFormState.entry.label} default model saved`);
334
- closeModelForm();
327
+ toast.success(`${modal.provider.label} default model saved`);
328
+ dispatchModal({type: 'close'});
335
329
  }}
336
330
  />
337
331
  ) : null}
@@ -339,52 +333,50 @@ export function WorkspaceModelProvidersSection({workspaceId}: {workspaceId: stri
339
333
  </Modal>
340
334
 
341
335
  <ModelProviderUsageModal
342
- target={usageTarget?.target ?? null}
343
- initialModel={usageTarget?.initialModel ?? null}
344
- workspaceDefaultHarnessId={configsQuery.data?.default_harness_id ?? null}
345
- open={usageTarget !== null}
336
+ target={usageTarget}
337
+ initialModel={modal.kind === 'show-usage' ? modal.initialModel : null}
338
+ workspaceDefaultHarnessId={configsQuery.data?.defaultHarnessId ?? null}
339
+ open={modal.kind === 'show-usage'}
346
340
  closeFocusTarget={
347
- usageTarget?.restoreFocusToConfiguredProviders
341
+ modal.kind === 'show-usage' && modal.restoreFocusToConfiguredProviders
348
342
  ? configuredProvidersRegionRef.current
349
343
  : null
350
344
  }
351
345
  onOpenChange={(open) => {
352
- if (!open) setUsageTarget(null);
346
+ if (!open) dispatchModal({type: 'close'});
353
347
  }}
354
348
  />
355
349
 
356
350
  <Modal
357
- open={customFormState !== null}
358
- onOpenChange={(open) => (open ? undefined : closeCustomForm())}
351
+ open={modal.kind === 'create-custom' || modal.kind === 'edit-custom'}
352
+ onOpenChange={(open) => (open ? undefined : dispatchModal({type: 'close'}))}
359
353
  >
360
354
  <ModalContent
361
355
  aria-describedby={undefined}
362
356
  className="max-h-[calc(100vh-32px)] max-w-[760px]"
363
357
  >
364
- <ModalTitle className="sr-only">
365
- {customModelProviderFormTitle(customFormState)}
366
- </ModalTitle>
358
+ <ModalTitle className="sr-only">{customModelProviderFormTitle(modal)}</ModalTitle>
367
359
  <ModalHeader>
368
360
  <div className="flex min-w-0 flex-col gap-2">
369
361
  <Text size="lg" aria-hidden="true" className="truncate">
370
- {customModelProviderFormTitle(customFormState)}
362
+ {customModelProviderFormTitle(modal)}
371
363
  </Text>
372
364
  <Text size="sm" className="text-foreground-neutral-muted">
373
365
  Connect an OpenAI-, Anthropic-, or Gemini-compatible endpoint.
374
366
  </Text>
375
367
  </div>
376
368
  </ModalHeader>
377
- {customFormState ? (
369
+ {modal.kind === 'create-custom' || modal.kind === 'edit-custom' ? (
378
370
  <CustomModelProviderForm
379
371
  workspaceId={workspaceId}
380
- existingConfig={customFormState.mode === 'edit' ? customFormState.config : undefined}
372
+ existingConfig={modal.kind === 'edit-custom' ? modal.config : undefined}
381
373
  onSaved={() => {
382
374
  toast.success(
383
- customFormState.mode === 'edit'
384
- ? `${customFormState.config.display_name} saved`
375
+ modal.kind === 'edit-custom'
376
+ ? `${modal.config.displayName} saved`
385
377
  : 'Custom provider saved',
386
378
  );
387
- closeCustomForm();
379
+ dispatchModal({type: 'close'});
388
380
  }}
389
381
  />
390
382
  ) : null}
@@ -394,27 +386,22 @@ export function WorkspaceModelProvidersSection({workspaceId}: {workspaceId: stri
394
386
  );
395
387
  }
396
388
 
397
- function modelProviderFormTitle(formState: ModelProviderFormState | null): string {
398
- if (formState === null) return '';
399
- if (formState.mode === 'edit') return `Edit credentials for ${formState.entry.label}`;
400
- return `Configure ${formState.entry.label}`;
389
+ function modelProviderFormTitle(modal: ManagementModal): string {
390
+ if (modal.kind === 'edit-builtin') return `Edit credentials for ${modal.provider.label}`;
391
+ if (modal.kind === 'configure-builtin') return `Configure ${modal.provider.label}`;
392
+ return '';
401
393
  }
402
394
 
403
- function customModelProviderFormTitle(formState: CustomModelProviderFormState | null): string {
404
- if (formState === null) return '';
405
- if (formState.mode === 'edit') return `Edit ${formState.config.display_name}`;
395
+ function customModelProviderFormTitle(modal: ManagementModal): string {
396
+ if (modal.kind === 'edit-custom') return `Edit ${modal.config.displayName}`;
406
397
  return 'Add custom provider';
407
398
  }
408
399
 
409
- function isBuiltinModelProviderConfig(
410
- config: ModelProviderConfigResponseDto,
411
- ): config is ModelProviderConfigDto {
400
+ function isBuiltinModelProviderConfig(config: ProviderConfig): config is BuiltinProviderConfig {
412
401
  return config.kind === 'builtin';
413
402
  }
414
403
 
415
- function isCustomModelProviderConfig(
416
- config: ModelProviderConfigResponseDto,
417
- ): config is CustomModelProviderConfigDto {
404
+ function isCustomModelProviderConfig(config: ProviderConfig): config is CustomProviderConfig {
418
405
  return config.kind === 'custom';
419
406
  }
420
407
 
@@ -428,8 +415,8 @@ function ConfiguredProviderRow({
428
415
  onShowUsage,
429
416
  }: {
430
417
  workspaceId: string;
431
- config: ModelProviderConfigResponseDto;
432
- entry: SupportedModelProviderCatalogEntry | undefined;
418
+ config: ProviderConfig;
419
+ entry: SupportedProvider | undefined;
433
420
  isDefault: boolean;
434
421
  onEdit: () => void;
435
422
  onChangeDefaultModel: () => void;
@@ -441,7 +428,7 @@ function ConfiguredProviderRow({
441
428
  const [deleteOpen, setDeleteOpen] = useState(false);
442
429
  const [deleteError, setDeleteError] = useState<string | undefined>();
443
430
  const customConfig = isCustomModelProviderConfig(config) ? config : undefined;
444
- const label = customConfig?.display_name ?? entry?.label ?? config.provider_id;
431
+ const label = customConfig?.displayName ?? entry?.label ?? config.providerId;
445
432
  const canUse = entry !== undefined || customConfig !== undefined;
446
433
  const canEdit = entry !== undefined || customConfig !== undefined;
447
434
  const isBuiltinConfig = isBuiltinModelProviderConfig(config);
@@ -451,7 +438,7 @@ function ConfiguredProviderRow({
451
438
  try {
452
439
  await setDefault.mutateAsync({
453
440
  workspaceId,
454
- body: {provider_id: config.provider_id},
441
+ providerId: config.providerId,
455
442
  });
456
443
  toast.success(`${label} is now the default provider`);
457
444
  } catch (error) {
@@ -463,7 +450,7 @@ function ConfiguredProviderRow({
463
450
  async function handleDelete() {
464
451
  setDeleteError(undefined);
465
452
  try {
466
- await deleteConfig.mutateAsync({workspaceId, providerId: config.provider_id});
453
+ await deleteConfig.mutateAsync({workspaceId, providerId: config.providerId});
467
454
  toast.success(`${label} deleted`);
468
455
  setDeleteOpen(false);
469
456
  } catch (error) {
@@ -1,8 +1,3 @@
1
- import type {
2
- ModelProviderCatalogEntryDto,
3
- ModelProviderConfigDto,
4
- SupportedModelProviderId,
5
- } from '@shipfox/api-agent-dto';
6
1
  import {Button} from '@shipfox/react-ui/button';
7
2
  import {Callout} from '@shipfox/react-ui/callout';
8
3
  import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
@@ -10,6 +5,7 @@ import {ModalBody, ModalFooter} from '@shipfox/react-ui/modal';
10
5
  import {Text} from '@shipfox/react-ui/typography';
11
6
  import {useForm} from '@tanstack/react-form';
12
7
  import {useState} from 'react';
8
+ import type {BuiltinProviderConfig, SupportedProvider} from '#core/models.js';
13
9
  import {useUpsertModelProviderConfigMutation} from '#hooks/api/model-providers.js';
14
10
  import {
15
11
  DefaultModelField,
@@ -29,8 +25,8 @@ export function ModelProviderTestAndSaveForm({
29
25
  setAsDefaultOnSave = false,
30
26
  }: {
31
27
  workspaceId: string;
32
- entry: ModelProviderCatalogEntryDto;
33
- existingConfig?: ModelProviderConfigDto | undefined;
28
+ entry: SupportedProvider;
29
+ existingConfig?: BuiltinProviderConfig | undefined;
34
30
  onSaved: (savedDefaultModel: string | null) => void;
35
31
  setAsDefaultOnSave?: boolean | undefined;
36
32
  }) {
@@ -41,7 +37,7 @@ export function ModelProviderTestAndSaveForm({
41
37
  onSubmit: async ({value}) => {
42
38
  setFormError(undefined);
43
39
  const credentials = Object.fromEntries(
44
- entry.credential_fields.map((credentialField) => [
40
+ entry.credentialFields.map((credentialField) => [
45
41
  credentialField.key,
46
42
  value[credentialField.key]?.trim() ?? '',
47
43
  ]),
@@ -55,11 +51,11 @@ export function ModelProviderTestAndSaveForm({
55
51
  try {
56
52
  await upsertConfig.mutateAsync({
57
53
  workspaceId,
58
- providerId: entry.id as SupportedModelProviderId,
59
- body: {
60
- ...(defaultModel !== undefined ? {default_model: defaultModel} : {}),
54
+ providerId: entry.id,
55
+ command: {
56
+ ...(defaultModel !== undefined ? {defaultModel} : {}),
61
57
  credentials,
62
- ...(setAsDefaultOnSave ? {set_as_default: true} : {}),
58
+ ...(setAsDefaultOnSave ? {setAsDefault: true} : {}),
63
59
  },
64
60
  });
65
61
  onSaved(selectedModelForCredentialsPayload(selectedModel));
@@ -102,7 +98,7 @@ export function ModelProviderTestAndSaveForm({
102
98
  )}
103
99
  </form.Field>
104
100
  ) : null}
105
- {entry.credential_fields.map((credentialField) => (
101
+ {entry.credentialFields.map((credentialField) => (
106
102
  <form.Field
107
103
  key={credentialField.key}
108
104
  name={credentialField.key}
@@ -156,11 +152,11 @@ export function ModelProviderTestAndSaveForm({
156
152
  }
157
153
 
158
154
  function defaultFormValues(
159
- entry: ModelProviderCatalogEntryDto,
160
- existingConfig: ModelProviderConfigDto | undefined,
155
+ entry: SupportedProvider,
156
+ existingConfig: BuiltinProviderConfig | undefined,
161
157
  ): Record<string, string> {
162
158
  return {
163
- default_model: defaultModelFormValue(existingConfig?.default_model),
164
- ...Object.fromEntries(entry.credential_fields.map((field) => [field.key, ''])),
159
+ default_model: defaultModelFormValue(existingConfig?.defaultModel),
160
+ ...Object.fromEntries(entry.credentialFields.map((field) => [field.key, ''])),
165
161
  };
166
162
  }
@@ -0,0 +1,15 @@
1
+ import {listHarnessDescriptors} from '@shipfox/api-agent-dto';
2
+ import {listHarnesses} from './harness-policy.js';
3
+
4
+ describe('harness policy', () => {
5
+ test('stays aligned with the shared harness compatibility contract', () => {
6
+ expect(listHarnesses()).toEqual(
7
+ listHarnessDescriptors().map(({id, label, description, supportedProviderIds}) => ({
8
+ id,
9
+ label,
10
+ description,
11
+ supportedProviderIds,
12
+ })),
13
+ );
14
+ });
15
+ });
@@ -0,0 +1,83 @@
1
+ import type {HarnessDescriptor, HarnessId, ProviderConfig} from './models.js';
2
+
3
+ export const DEFAULT_HARNESS: HarnessId = 'pi';
4
+
5
+ const harnesses: readonly HarnessDescriptor[] = [
6
+ {
7
+ id: 'pi',
8
+ label: 'pi',
9
+ description: 'Works with 30+ model providers',
10
+ supportedProviderIds: [
11
+ 'anthropic',
12
+ 'ant-ling',
13
+ 'azure-openai-responses',
14
+ 'openai',
15
+ 'deepseek',
16
+ 'nvidia',
17
+ 'google',
18
+ 'mistral',
19
+ 'groq',
20
+ 'cerebras',
21
+ 'cloudflare-ai-gateway',
22
+ 'cloudflare-workers-ai',
23
+ 'xai',
24
+ 'openrouter',
25
+ 'vercel-ai-gateway',
26
+ 'zai',
27
+ 'zai-coding-cn',
28
+ 'opencode',
29
+ 'opencode-go',
30
+ 'huggingface',
31
+ 'fireworks',
32
+ 'together',
33
+ 'kimi-coding',
34
+ 'minimax',
35
+ 'minimax-cn',
36
+ 'moonshotai',
37
+ 'moonshotai-cn',
38
+ 'xiaomi',
39
+ 'xiaomi-token-plan-cn',
40
+ 'xiaomi-token-plan-ams',
41
+ 'xiaomi-token-plan-sgp',
42
+ ],
43
+ },
44
+ {
45
+ id: 'claude',
46
+ label: 'Claude',
47
+ description: 'Runs on your Anthropic API key',
48
+ supportedProviderIds: ['anthropic'],
49
+ },
50
+ ];
51
+
52
+ export function listHarnesses(): readonly HarnessDescriptor[] {
53
+ return harnesses;
54
+ }
55
+
56
+ export function getHarness(id: HarnessId): HarnessDescriptor {
57
+ const harness = harnesses.find((item) => item.id === id);
58
+ if (harness === undefined) throw new Error(`Unknown harness: ${id}`);
59
+ return harness;
60
+ }
61
+
62
+ export function harnessSupportsProvider(harnessId: HarnessId, providerId: string): boolean {
63
+ return getHarness(harnessId).supportedProviderIds.includes(providerId);
64
+ }
65
+
66
+ export function configSupportsHarness(config: ProviderConfig, harness: HarnessDescriptor): boolean {
67
+ return config.kind === 'custom'
68
+ ? harness.id === DEFAULT_HARNESS
69
+ : harness.supportedProviderIds.includes(config.providerId);
70
+ }
71
+
72
+ export function compatibleHarnessIds({
73
+ isCustom,
74
+ providerId,
75
+ }: {
76
+ isCustom: boolean;
77
+ providerId: string;
78
+ }): HarnessId[] {
79
+ if (isCustom) return [DEFAULT_HARNESS];
80
+ return harnesses
81
+ .filter((harness) => harness.supportedProviderIds.includes(providerId))
82
+ .map((harness) => harness.id);
83
+ }