@shipfox/client-agent 0.2.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.
Files changed (182) hide show
  1. package/.storybook/main.ts +1 -0
  2. package/.storybook/preview.css +4 -0
  3. package/.storybook/preview.tsx +56 -0
  4. package/.turbo/turbo-build.log +2 -0
  5. package/.turbo/turbo-type$colon$emit.log +1 -0
  6. package/.turbo/turbo-type.log +1 -0
  7. package/CHANGELOG.md +93 -0
  8. package/LICENSE +21 -0
  9. package/dist/components/add-custom-provider-card.d.ts +4 -0
  10. package/dist/components/add-custom-provider-card.d.ts.map +1 -0
  11. package/dist/components/add-custom-provider-card.js +41 -0
  12. package/dist/components/add-custom-provider-card.js.map +1 -0
  13. package/dist/components/agent-workflow-example.d.ts +11 -0
  14. package/dist/components/agent-workflow-example.d.ts.map +1 -0
  15. package/dist/components/agent-workflow-example.js +38 -0
  16. package/dist/components/agent-workflow-example.js.map +1 -0
  17. package/dist/components/available-provider-card.d.ts +6 -0
  18. package/dist/components/available-provider-card.d.ts.map +1 -0
  19. package/dist/components/available-provider-card.js +41 -0
  20. package/dist/components/available-provider-card.js.map +1 -0
  21. package/dist/components/available-providers-grid.d.ts +10 -0
  22. package/dist/components/available-providers-grid.d.ts.map +1 -0
  23. package/dist/components/available-providers-grid.js +89 -0
  24. package/dist/components/available-providers-grid.js.map +1 -0
  25. package/dist/components/change-default-model-form.d.ts +9 -0
  26. package/dist/components/change-default-model-form.d.ts.map +1 -0
  27. package/dist/components/change-default-model-form.js +98 -0
  28. package/dist/components/change-default-model-form.js.map +1 -0
  29. package/dist/components/custom-model-provider-api-options.d.ts +7 -0
  30. package/dist/components/custom-model-provider-api-options.d.ts.map +1 -0
  31. package/dist/components/custom-model-provider-api-options.js +23 -0
  32. package/dist/components/custom-model-provider-api-options.js.map +1 -0
  33. package/dist/components/custom-model-provider-form.d.ts +8 -0
  34. package/dist/components/custom-model-provider-form.d.ts.map +1 -0
  35. package/dist/components/custom-model-provider-form.js +667 -0
  36. package/dist/components/custom-model-provider-form.js.map +1 -0
  37. package/dist/components/custom-model-provider-form.stories.d.ts +20 -0
  38. package/dist/components/custom-model-provider-form.stories.d.ts.map +1 -0
  39. package/dist/components/custom-model-provider-form.stories.js +104 -0
  40. package/dist/components/custom-model-provider-form.stories.js.map +1 -0
  41. package/dist/components/custom-model-provider-payload.d.ts +39 -0
  42. package/dist/components/custom-model-provider-payload.d.ts.map +1 -0
  43. package/dist/components/custom-model-provider-payload.js +203 -0
  44. package/dist/components/custom-model-provider-payload.js.map +1 -0
  45. package/dist/components/custom-model-provider-slug.d.ts +3 -0
  46. package/dist/components/custom-model-provider-slug.d.ts.map +1 -0
  47. package/dist/components/custom-model-provider-slug.js +14 -0
  48. package/dist/components/custom-model-provider-slug.js.map +1 -0
  49. package/dist/components/default-model-field.d.ts +16 -0
  50. package/dist/components/default-model-field.d.ts.map +1 -0
  51. package/dist/components/default-model-field.js +60 -0
  52. package/dist/components/default-model-field.js.map +1 -0
  53. package/dist/components/form-errors.d.ts +11 -0
  54. package/dist/components/form-errors.d.ts.map +1 -0
  55. package/dist/components/form-errors.js +101 -0
  56. package/dist/components/form-errors.js.map +1 -0
  57. package/dist/components/harness-availability.d.ts +8 -0
  58. package/dist/components/harness-availability.d.ts.map +1 -0
  59. package/dist/components/harness-availability.js +16 -0
  60. package/dist/components/harness-availability.js.map +1 -0
  61. package/dist/components/harnesses-section.d.ts +4 -0
  62. package/dist/components/harnesses-section.d.ts.map +1 -0
  63. package/dist/components/harnesses-section.js +249 -0
  64. package/dist/components/harnesses-section.js.map +1 -0
  65. package/dist/components/harnesses-section.stories.d.ts +25 -0
  66. package/dist/components/harnesses-section.stories.d.ts.map +1 -0
  67. package/dist/components/harnesses-section.stories.js +138 -0
  68. package/dist/components/harnesses-section.stories.js.map +1 -0
  69. package/dist/components/model-provider-usage-modal.d.ts +11 -0
  70. package/dist/components/model-provider-usage-modal.d.ts.map +1 -0
  71. package/dist/components/model-provider-usage-modal.js +349 -0
  72. package/dist/components/model-provider-usage-modal.js.map +1 -0
  73. package/dist/components/model-provider-usage-modal.stories.d.ts +21 -0
  74. package/dist/components/model-provider-usage-modal.stories.d.ts.map +1 -0
  75. package/dist/components/model-provider-usage-modal.stories.js +115 -0
  76. package/dist/components/model-provider-usage-modal.stories.js.map +1 -0
  77. package/dist/components/model-provider-usage-target.d.ts +14 -0
  78. package/dist/components/model-provider-usage-target.d.ts.map +1 -0
  79. package/dist/components/model-provider-usage-target.js +20 -0
  80. package/dist/components/model-provider-usage-target.js.map +1 -0
  81. package/dist/components/model-providers-section.d.ts +4 -0
  82. package/dist/components/model-providers-section.d.ts.map +1 -0
  83. package/dist/components/model-providers-section.js +668 -0
  84. package/dist/components/model-providers-section.js.map +1 -0
  85. package/dist/components/model-providers-section.stories.d.ts +32 -0
  86. package/dist/components/model-providers-section.stories.d.ts.map +1 -0
  87. package/dist/components/model-providers-section.stories.js +442 -0
  88. package/dist/components/model-providers-section.stories.js.map +1 -0
  89. package/dist/components/provider-search.d.ts +4 -0
  90. package/dist/components/provider-search.d.ts.map +1 -0
  91. package/dist/components/provider-search.js +13 -0
  92. package/dist/components/provider-search.js.map +1 -0
  93. package/dist/components/supported-model-provider-catalog-entry.d.ts +8 -0
  94. package/dist/components/supported-model-provider-catalog-entry.d.ts.map +1 -0
  95. package/dist/components/supported-model-provider-catalog-entry.js +9 -0
  96. package/dist/components/supported-model-provider-catalog-entry.js.map +1 -0
  97. package/dist/components/test-and-save-form.d.ts +10 -0
  98. package/dist/components/test-and-save-form.d.ts.map +1 -0
  99. package/dist/components/test-and-save-form.js +138 -0
  100. package/dist/components/test-and-save-form.js.map +1 -0
  101. package/dist/feature.d.ts +9 -0
  102. package/dist/feature.d.ts.map +1 -0
  103. package/dist/feature.js +13 -0
  104. package/dist/feature.js.map +1 -0
  105. package/dist/hooks/api/model-providers.d.ts +334 -0
  106. package/dist/hooks/api/model-providers.d.ts.map +1 -0
  107. package/dist/hooks/api/model-providers.js +190 -0
  108. package/dist/hooks/api/model-providers.js.map +1 -0
  109. package/dist/index.d.ts +9 -0
  110. package/dist/index.d.ts.map +1 -0
  111. package/dist/index.js +10 -0
  112. package/dist/index.js.map +1 -0
  113. package/dist/pages/model-provider-onboarding-page.d.ts +6 -0
  114. package/dist/pages/model-provider-onboarding-page.d.ts.map +1 -0
  115. package/dist/pages/model-provider-onboarding-page.js +295 -0
  116. package/dist/pages/model-provider-onboarding-page.js.map +1 -0
  117. package/dist/pages/model-provider-onboarding-page.stories.d.ts +27 -0
  118. package/dist/pages/model-provider-onboarding-page.stories.d.ts.map +1 -0
  119. package/dist/pages/model-provider-onboarding-page.stories.js +368 -0
  120. package/dist/pages/model-provider-onboarding-page.stories.js.map +1 -0
  121. package/dist/routes/model-provider.d.ts +5 -0
  122. package/dist/routes/model-provider.d.ts.map +1 -0
  123. package/dist/routes/model-provider.js +28 -0
  124. package/dist/routes/model-provider.js.map +1 -0
  125. package/dist/state/model-provider-onboarding.d.ts +3 -0
  126. package/dist/state/model-provider-onboarding.d.ts.map +1 -0
  127. package/dist/state/model-provider-onboarding.js +28 -0
  128. package/dist/state/model-provider-onboarding.js.map +1 -0
  129. package/dist/tsconfig.test.tsbuildinfo +1 -0
  130. package/package.json +114 -0
  131. package/src/components/add-custom-provider-card.tsx +32 -0
  132. package/src/components/agent-workflow-example.test.ts +74 -0
  133. package/src/components/agent-workflow-example.ts +55 -0
  134. package/src/components/available-provider-card.tsx +39 -0
  135. package/src/components/available-providers-grid.test.tsx +55 -0
  136. package/src/components/available-providers-grid.tsx +106 -0
  137. package/src/components/change-default-model-form.tsx +104 -0
  138. package/src/components/custom-model-provider-api-options.test.ts +18 -0
  139. package/src/components/custom-model-provider-api-options.ts +12 -0
  140. package/src/components/custom-model-provider-form.stories.tsx +91 -0
  141. package/src/components/custom-model-provider-form.tsx +710 -0
  142. package/src/components/custom-model-provider-payload.test.ts +170 -0
  143. package/src/components/custom-model-provider-payload.ts +264 -0
  144. package/src/components/custom-model-provider-slug.test.ts +40 -0
  145. package/src/components/custom-model-provider-slug.ts +23 -0
  146. package/src/components/default-model-field.tsx +85 -0
  147. package/src/components/form-errors.test.ts +159 -0
  148. package/src/components/form-errors.ts +102 -0
  149. package/src/components/harness-availability.test.ts +45 -0
  150. package/src/components/harness-availability.ts +35 -0
  151. package/src/components/harnesses-section.stories.tsx +129 -0
  152. package/src/components/harnesses-section.test.tsx +188 -0
  153. package/src/components/harnesses-section.tsx +245 -0
  154. package/src/components/model-provider-usage-modal.stories.tsx +106 -0
  155. package/src/components/model-provider-usage-modal.test.tsx +210 -0
  156. package/src/components/model-provider-usage-modal.tsx +326 -0
  157. package/src/components/model-provider-usage-target.ts +36 -0
  158. package/src/components/model-providers-section.stories.tsx +345 -0
  159. package/src/components/model-providers-section.test.tsx +831 -0
  160. package/src/components/model-providers-section.tsx +645 -0
  161. package/src/components/provider-search.test.ts +45 -0
  162. package/src/components/provider-search.ts +18 -0
  163. package/src/components/supported-model-provider-catalog-entry.ts +19 -0
  164. package/src/components/test-and-save-form.tsx +166 -0
  165. package/src/feature.ts +12 -0
  166. package/src/hooks/api/model-providers.test.ts +167 -0
  167. package/src/hooks/api/model-providers.ts +281 -0
  168. package/src/index.ts +8 -0
  169. package/src/pages/model-provider-onboarding-page.stories.tsx +284 -0
  170. package/src/pages/model-provider-onboarding-page.test.tsx +368 -0
  171. package/src/pages/model-provider-onboarding-page.tsx +290 -0
  172. package/src/routes/model-provider.tsx +20 -0
  173. package/src/state/model-provider-onboarding.test.ts +55 -0
  174. package/src/state/model-provider-onboarding.ts +34 -0
  175. package/test/fixtures/model-providers.ts +111 -0
  176. package/test/setup.ts +3 -0
  177. package/tsconfig.build.json +9 -0
  178. package/tsconfig.build.tsbuildinfo +1 -0
  179. package/tsconfig.json +3 -0
  180. package/tsconfig.test.json +8 -0
  181. package/vercel.json +8 -0
  182. package/vitest.config.ts +72 -0
@@ -0,0 +1,710 @@
1
+ import type {CustomModelProviderConfigDto, ModelProviderApi} from '@shipfox/api-agent-dto';
2
+ import {Badge} from '@shipfox/react-ui/badge';
3
+ import {Button, IconButton} from '@shipfox/react-ui/button';
4
+ import {Callout} from '@shipfox/react-ui/callout';
5
+ import {Collapsible, CollapsibleContent, CollapsibleTrigger} from '@shipfox/react-ui/collapsible';
6
+ import {FormField, FormFieldInput, fieldError} from '@shipfox/react-ui/form-field';
7
+ import {ModalBody, ModalFooter} from '@shipfox/react-ui/modal';
8
+ import {Switch} from '@shipfox/react-ui/switch';
9
+ import {Text} from '@shipfox/react-ui/typography';
10
+ import {useForm} from '@tanstack/react-form';
11
+ import type {ReactNode, RefObject} from 'react';
12
+ import {useEffect, useRef, useState} from 'react';
13
+ import {
14
+ useCreateCustomModelProviderMutation,
15
+ useDiscoverCustomModelProviderModelsBySlugMutation,
16
+ useDiscoverCustomModelProviderModelsMutation,
17
+ useUpdateCustomModelProviderMutation,
18
+ } from '#hooks/api/model-providers.js';
19
+ import {MODEL_PROVIDER_API_OPTIONS} from './custom-model-provider-api-options.js';
20
+ import {
21
+ buildCreateCustomModelProviderBody,
22
+ buildDiscoverModelsBody,
23
+ buildDiscoverModelsBySlugBody,
24
+ buildUpdateCustomModelProviderBody,
25
+ type CustomModelProviderFormValues,
26
+ type CustomModelProviderHeaderFormValue,
27
+ type CustomModelProviderModelFormValue,
28
+ createCustomModelProviderFormValues,
29
+ createFormRowId,
30
+ editCustomModelProviderFormValues,
31
+ } from './custom-model-provider-payload.js';
32
+ import {
33
+ customModelProviderSlugError,
34
+ deriveCustomModelProviderSlug,
35
+ } from './custom-model-provider-slug.js';
36
+ import {FormFieldSelect} from './default-model-field.js';
37
+ import {modelProviderConfigErrorField} from './form-errors.js';
38
+
39
+ export const CUSTOM_MODEL_PROVIDER_FORM_ID = 'custom-model-provider-form';
40
+
41
+ export function CustomModelProviderForm({
42
+ workspaceId,
43
+ existingConfig,
44
+ onSaved,
45
+ }: {
46
+ workspaceId: string;
47
+ existingConfig?: CustomModelProviderConfigDto | undefined;
48
+ onSaved: () => void;
49
+ }) {
50
+ const createMutation = useCreateCustomModelProviderMutation();
51
+ const updateMutation = useUpdateCustomModelProviderMutation();
52
+ const discoverMutation = useDiscoverCustomModelProviderModelsMutation();
53
+ const discoverBySlugMutation = useDiscoverCustomModelProviderModelsBySlugMutation();
54
+ const [slugEdited, setSlugEdited] = useState(existingConfig !== undefined);
55
+ const [formError, setFormError] = useState<string | undefined>();
56
+ const [discoveryStatus, setDiscoveryStatus] = useState<string | undefined>();
57
+ const [removedDefaultNotice, setRemovedDefaultNotice] = useState(false);
58
+ const mountedRef = useRef(true);
59
+ const form = useForm({
60
+ defaultValues: existingConfig
61
+ ? editCustomModelProviderFormValues(existingConfig)
62
+ : createCustomModelProviderFormValues(),
63
+ onSubmit: async ({value}) => {
64
+ setFormError(undefined);
65
+
66
+ try {
67
+ if (existingConfig) {
68
+ await updateMutation.mutateAsync({
69
+ workspaceId,
70
+ providerId: existingConfig.provider_id,
71
+ body: buildUpdateCustomModelProviderBody(existingConfig, value),
72
+ });
73
+ } else {
74
+ await createMutation.mutateAsync({
75
+ workspaceId,
76
+ body: buildCreateCustomModelProviderBody(value),
77
+ });
78
+ }
79
+ onSaved();
80
+ } catch (error) {
81
+ applyServerError(error);
82
+ }
83
+ },
84
+ });
85
+
86
+ const isPending =
87
+ createMutation.isPending ||
88
+ updateMutation.isPending ||
89
+ discoverMutation.isPending ||
90
+ discoverBySlugMutation.isPending;
91
+
92
+ useEffect(() => {
93
+ return () => {
94
+ mountedRef.current = false;
95
+ };
96
+ }, []);
97
+
98
+ async function handleDiscoverModels(values: CustomModelProviderFormValues) {
99
+ setDiscoveryStatus(undefined);
100
+ try {
101
+ const response = existingConfig
102
+ ? await discoverBySlugMutation.mutateAsync({
103
+ workspaceId,
104
+ providerId: existingConfig.provider_id,
105
+ body: buildDiscoverModelsBySlugBody(existingConfig, values),
106
+ })
107
+ : await discoverMutation.mutateAsync({
108
+ workspaceId,
109
+ body: buildDiscoverModelsBody(values),
110
+ });
111
+ if (!mountedRef.current) return;
112
+ const existingIds = new Set(values.models.map((model) => model.id.trim()).filter(Boolean));
113
+ const discovered = response.models.filter((model) => !existingIds.has(model.id));
114
+ form.setFieldValue('models', [
115
+ ...values.models,
116
+ ...discovered.map((model) => ({
117
+ client_id: createFormRowId(),
118
+ id: model.id,
119
+ label: model.label,
120
+ context_window: '',
121
+ max_output_tokens: '',
122
+ input_image: false,
123
+ reasoning: false,
124
+ })),
125
+ ]);
126
+ setDiscoveryStatus(
127
+ discovered.length > 0
128
+ ? `Discovered ${discovered.length} models.`
129
+ : 'No models found - that is normal for some servers. Add models manually.',
130
+ );
131
+ } catch (error) {
132
+ if (!mountedRef.current) return;
133
+ applyServerError(error);
134
+ }
135
+ }
136
+
137
+ function applyServerError(error: unknown) {
138
+ const mapped = modelProviderConfigErrorField(error);
139
+ if (mapped.kind === 'field') {
140
+ form.setFieldMeta(mapped.field, (prev) => ({
141
+ ...prev,
142
+ errorMap: {...prev.errorMap, onServer: mapped.message},
143
+ }));
144
+ return;
145
+ }
146
+ setFormError(mapped.message);
147
+ }
148
+
149
+ return (
150
+ <>
151
+ <ModalBody className="min-h-0 flex-1 gap-0 overflow-y-auto overflow-x-clip scrollbar">
152
+ <form
153
+ id={CUSTOM_MODEL_PROVIDER_FORM_ID}
154
+ className="flex w-full flex-col gap-24"
155
+ noValidate
156
+ onSubmit={(event) => {
157
+ event.preventDefault();
158
+ event.stopPropagation();
159
+ void form.handleSubmit();
160
+ }}
161
+ >
162
+ <form.Subscribe selector={(state) => state.values}>
163
+ {(values) => (
164
+ <>
165
+ <FormGroup title="Provider">
166
+ <div className="grid grid-cols-2 gap-12 max-[640px]:grid-cols-1">
167
+ <form.Field
168
+ name="display_name"
169
+ validators={{
170
+ onBlur: ({value}) =>
171
+ value.trim() ? undefined : 'Display name is required.',
172
+ onSubmit: ({value}) =>
173
+ value.trim() ? undefined : 'Display name is required.',
174
+ }}
175
+ >
176
+ {(field) => (
177
+ <FormField
178
+ label="Display name"
179
+ id="custom-provider-display-name"
180
+ error={fieldError(field)}
181
+ >
182
+ <FormFieldInput
183
+ value={field.state.value}
184
+ onChange={(event) => {
185
+ const displayName = event.target.value;
186
+ field.handleChange(displayName);
187
+ if (!slugEdited && !existingConfig) {
188
+ form.setFieldValue(
189
+ 'slug',
190
+ deriveCustomModelProviderSlug(displayName),
191
+ );
192
+ }
193
+ }}
194
+ onBlur={field.handleBlur}
195
+ />
196
+ </FormField>
197
+ )}
198
+ </form.Field>
199
+ <form.Field
200
+ name="slug"
201
+ validators={{
202
+ onBlur: ({value}) =>
203
+ existingConfig ? undefined : customModelProviderSlugError(value),
204
+ onSubmit: ({value}) =>
205
+ existingConfig ? undefined : customModelProviderSlugError(value),
206
+ }}
207
+ >
208
+ {(field) => (
209
+ <FormField
210
+ label="Provider ID"
211
+ id="custom-provider-slug"
212
+ error={fieldError(field)}
213
+ description="Use this ID in workflow files."
214
+ >
215
+ <FormFieldInput
216
+ className="font-code"
217
+ value={field.state.value}
218
+ disabled={existingConfig !== undefined}
219
+ onChange={(event) => {
220
+ setSlugEdited(event.target.value.trim() !== '');
221
+ field.handleChange(event.target.value);
222
+ }}
223
+ onBlur={field.handleBlur}
224
+ />
225
+ </FormField>
226
+ )}
227
+ </form.Field>
228
+ </div>
229
+
230
+ <div className="grid grid-cols-2 gap-12 max-[640px]:grid-cols-1">
231
+ <form.Field name="api">
232
+ {(field) => (
233
+ <FormField label="Protocol" id="custom-provider-api">
234
+ <FormFieldSelect
235
+ value={field.state.value}
236
+ onChange={(event) =>
237
+ field.handleChange(event.target.value as ModelProviderApi)
238
+ }
239
+ onBlur={field.handleBlur}
240
+ >
241
+ {MODEL_PROVIDER_API_OPTIONS.map((option) => (
242
+ <option key={option.value} value={option.value}>
243
+ {option.label}
244
+ </option>
245
+ ))}
246
+ </FormFieldSelect>
247
+ </FormField>
248
+ )}
249
+ </form.Field>
250
+ <form.Field
251
+ name="base_url"
252
+ validators={{
253
+ onBlur: ({value}) =>
254
+ canParseUrl(value)
255
+ ? undefined
256
+ : 'Enter a full URL, like https://api.openai.com/v1',
257
+ onSubmit: ({value}) =>
258
+ canParseUrl(value)
259
+ ? undefined
260
+ : 'Enter a full URL, like https://api.openai.com/v1',
261
+ }}
262
+ >
263
+ {(field) => (
264
+ <FormField
265
+ label="Base URL"
266
+ id="custom-provider-base-url"
267
+ error={fieldError(field)}
268
+ >
269
+ <FormFieldInput
270
+ className="font-code"
271
+ placeholder="https://api.openai.com/v1"
272
+ value={field.state.value}
273
+ onChange={(event) => field.handleChange(event.target.value)}
274
+ onBlur={field.handleBlur}
275
+ />
276
+ </FormField>
277
+ )}
278
+ </form.Field>
279
+ </div>
280
+
281
+ <form.Field name="api_key">
282
+ {(field) => (
283
+ <FormField
284
+ label="API key"
285
+ id="custom-provider-api-key"
286
+ description={
287
+ existingConfig
288
+ ? 'Leave blank to keep the current key.'
289
+ : 'Leave blank when the endpoint does not require a key.'
290
+ }
291
+ >
292
+ <FormFieldInput
293
+ type="password"
294
+ autoComplete="off"
295
+ value={field.state.value}
296
+ onChange={(event) => field.handleChange(event.target.value)}
297
+ onBlur={field.handleBlur}
298
+ />
299
+ </FormField>
300
+ )}
301
+ </form.Field>
302
+ </FormGroup>
303
+
304
+ <FormGroup title="Headers">
305
+ <form.Field name="headers">
306
+ {(field) => (
307
+ <HeaderRows
308
+ rows={field.state.value}
309
+ onChange={(headers) => field.handleChange(headers)}
310
+ />
311
+ )}
312
+ </form.Field>
313
+ </FormGroup>
314
+
315
+ <FormGroup title="Models">
316
+ <div className="flex flex-wrap items-center justify-between gap-8">
317
+ <Button
318
+ type="button"
319
+ size="sm"
320
+ variant="secondary"
321
+ iconLeft="downloadLine"
322
+ disabled={!canParseUrl(values.base_url)}
323
+ isLoading={discoverMutation.isPending || discoverBySlugMutation.isPending}
324
+ onClick={() => {
325
+ void handleDiscoverModels(values);
326
+ }}
327
+ >
328
+ Fetch models
329
+ </Button>
330
+ {discoveryStatus ? (
331
+ <Text size="sm" className="text-foreground-neutral-muted" role="status">
332
+ {discoveryStatus}
333
+ </Text>
334
+ ) : null}
335
+ </div>
336
+
337
+ <form.Field
338
+ name="models"
339
+ validators={{
340
+ onSubmit: ({value}) =>
341
+ value.some((model) => model.id.trim() && model.label.trim())
342
+ ? undefined
343
+ : 'Add at least one model.',
344
+ }}
345
+ >
346
+ {(field) => (
347
+ <>
348
+ <ModelRows
349
+ rows={field.state.value}
350
+ defaultModel={values.default_model}
351
+ onDefaultRemoved={() => setRemovedDefaultNotice(true)}
352
+ onChange={(models) => field.handleChange(models)}
353
+ onDefaultChange={(defaultModel) => {
354
+ setRemovedDefaultNotice(false);
355
+ form.setFieldValue('default_model', defaultModel);
356
+ }}
357
+ />
358
+ {fieldError(field) ? (
359
+ <Text size="sm" className="text-foreground-highlight-error">
360
+ {fieldError(field)}
361
+ </Text>
362
+ ) : null}
363
+ </>
364
+ )}
365
+ </form.Field>
366
+
367
+ {removedDefaultNotice ? (
368
+ <Callout role="alert" type="warning">
369
+ <Text size="sm">
370
+ Default model removed - choose a new default or keep none.
371
+ </Text>
372
+ </Callout>
373
+ ) : null}
374
+ <form.Field
375
+ name="default_model"
376
+ validators={{
377
+ onBlur: ({value}) => validateDefaultModel(value, values.models),
378
+ onSubmit: ({value}) => validateDefaultModel(value, values.models),
379
+ }}
380
+ >
381
+ {(field) => (
382
+ <FormField
383
+ label="Default model"
384
+ id="custom-provider-default-model"
385
+ error={fieldError(field)}
386
+ >
387
+ <FormFieldSelect
388
+ value={field.state.value}
389
+ onChange={(event) => {
390
+ setRemovedDefaultNotice(false);
391
+ field.handleChange(event.target.value);
392
+ }}
393
+ onBlur={field.handleBlur}
394
+ >
395
+ <option value="">No default</option>
396
+ {values.models
397
+ .filter((model) => model.id.trim())
398
+ .map((model) => (
399
+ <option key={model.client_id} value={model.id}>
400
+ {model.label.trim() || model.id}
401
+ </option>
402
+ ))}
403
+ </FormFieldSelect>
404
+ </FormField>
405
+ )}
406
+ </form.Field>
407
+ </FormGroup>
408
+ </>
409
+ )}
410
+ </form.Subscribe>
411
+ </form>
412
+ {formError ? (
413
+ <Callout role="alert" type="error" className="mt-16">
414
+ <div className="flex flex-col gap-8">
415
+ <Text size="sm" bold>
416
+ Could not save provider
417
+ </Text>
418
+ <Text size="sm">{formError}</Text>
419
+ </div>
420
+ </Callout>
421
+ ) : null}
422
+ </ModalBody>
423
+ <ModalFooter>
424
+ <Text size="sm" className="mr-auto text-foreground-neutral-muted">
425
+ Saving runs a live test against the endpoint.
426
+ </Text>
427
+ <Button type="submit" form={CUSTOM_MODEL_PROVIDER_FORM_ID} isLoading={isPending}>
428
+ Test & save
429
+ </Button>
430
+ </ModalFooter>
431
+ </>
432
+ );
433
+ }
434
+
435
+ function FormGroup({title, children}: {title: string; children: ReactNode}) {
436
+ return (
437
+ <section className="flex flex-col gap-16 border-t border-border-neutral-base pt-16 first:border-t-0 first:pt-0">
438
+ <Text size="xs" className="font-medium uppercase text-foreground-neutral-muted">
439
+ {title}
440
+ </Text>
441
+ {children}
442
+ </section>
443
+ );
444
+ }
445
+
446
+ function HeaderRows({
447
+ rows,
448
+ onChange,
449
+ }: {
450
+ rows: CustomModelProviderHeaderFormValue[];
451
+ onChange: (rows: CustomModelProviderHeaderFormValue[]) => void;
452
+ }) {
453
+ const addButtonRef = useRef<HTMLButtonElement>(null);
454
+
455
+ return (
456
+ <div className="flex flex-col gap-10">
457
+ {rows.map((row, index) => (
458
+ <div
459
+ key={row.client_id}
460
+ className="grid grid-cols-[1fr_1fr_auto_auto] items-end gap-x-8 gap-y-4 max-[760px]:grid-cols-1"
461
+ >
462
+ <FormField label="Name" id={`custom-header-name-${row.client_id}`}>
463
+ <FormFieldInput
464
+ className="font-code"
465
+ value={row.name}
466
+ onChange={(event) =>
467
+ updateRow(rows, index, {...row, name: event.target.value}, onChange)
468
+ }
469
+ />
470
+ </FormField>
471
+ <FormField label="Value" id={`custom-header-value-${row.client_id}`}>
472
+ <FormFieldInput
473
+ type={row.secret ? 'password' : 'text'}
474
+ placeholder={row.hasStoredValue ? 'Unchanged' : undefined}
475
+ value={row.value}
476
+ onChange={(event) =>
477
+ updateRow(rows, index, {...row, value: event.target.value}, onChange)
478
+ }
479
+ />
480
+ </FormField>
481
+ <div className="flex h-32 items-center gap-8 text-sm text-foreground-neutral-muted">
482
+ <Switch
483
+ size="sm"
484
+ aria-label={`Mark header ${index + 1} as secret`}
485
+ checked={row.secret}
486
+ onCheckedChange={(checked) =>
487
+ updateRow(rows, index, {...row, secret: checked}, onChange)
488
+ }
489
+ />
490
+ Secret
491
+ </div>
492
+ <IconButton
493
+ type="button"
494
+ size="sm"
495
+ variant="transparent"
496
+ icon="deleteBinLine"
497
+ aria-label="Remove header"
498
+ onClick={() => {
499
+ onChange(rows.filter((_, rowIndex) => rowIndex !== index));
500
+ focusAfterRender(addButtonRef);
501
+ }}
502
+ />
503
+ </div>
504
+ ))}
505
+ <Button
506
+ ref={addButtonRef}
507
+ type="button"
508
+ size="sm"
509
+ variant="secondary"
510
+ iconLeft="addLine"
511
+ onClick={() => {
512
+ const clientId = createFormRowId();
513
+ onChange([
514
+ ...rows,
515
+ {
516
+ client_id: clientId,
517
+ name: '',
518
+ value: '',
519
+ secret: false,
520
+ hasStoredValue: false,
521
+ storedName: '',
522
+ },
523
+ ]);
524
+ focusAfterRender(`custom-header-name-${clientId}`);
525
+ }}
526
+ >
527
+ Add header
528
+ </Button>
529
+ </div>
530
+ );
531
+ }
532
+
533
+ function ModelRows({
534
+ rows,
535
+ defaultModel,
536
+ onChange,
537
+ onDefaultChange,
538
+ onDefaultRemoved,
539
+ }: {
540
+ rows: CustomModelProviderModelFormValue[];
541
+ defaultModel: string;
542
+ onChange: (rows: CustomModelProviderModelFormValue[]) => void;
543
+ onDefaultChange: (value: string) => void;
544
+ onDefaultRemoved: () => void;
545
+ }) {
546
+ const addButtonRef = useRef<HTMLButtonElement>(null);
547
+
548
+ return (
549
+ <div className="flex flex-col gap-10">
550
+ {rows.map((row, index) => (
551
+ <div key={row.client_id} className="rounded-8 border border-border-neutral-base p-12">
552
+ <div className="grid grid-cols-[1fr_1fr_auto] items-end gap-8 max-[760px]:grid-cols-1">
553
+ <FormField label="Model id" id={`custom-model-id-${row.client_id}`}>
554
+ <FormFieldInput
555
+ className="font-code"
556
+ value={row.id}
557
+ onChange={(event) =>
558
+ updateRow(rows, index, {...row, id: event.target.value}, onChange)
559
+ }
560
+ />
561
+ </FormField>
562
+ <FormField label="Label" id={`custom-model-label-${row.client_id}`}>
563
+ <FormFieldInput
564
+ value={row.label}
565
+ onChange={(event) =>
566
+ updateRow(rows, index, {...row, label: event.target.value}, onChange)
567
+ }
568
+ />
569
+ </FormField>
570
+ <div className="flex items-center gap-8">
571
+ {defaultModel && row.id === defaultModel ? (
572
+ <Badge variant="neutral">Default</Badge>
573
+ ) : null}
574
+ <IconButton
575
+ type="button"
576
+ size="sm"
577
+ variant="transparent"
578
+ icon="deleteBinLine"
579
+ aria-label="Remove model"
580
+ onClick={() => {
581
+ const nextRows = rows.filter((_, rowIndex) => rowIndex !== index);
582
+ onChange(nextRows);
583
+ focusAfterRender(addButtonRef);
584
+ if (row.id === defaultModel) {
585
+ onDefaultChange('');
586
+ onDefaultRemoved();
587
+ }
588
+ }}
589
+ />
590
+ </div>
591
+ </div>
592
+ <Collapsible>
593
+ <CollapsibleTrigger asChild>
594
+ <Button type="button" size="sm" variant="transparentMuted" className="mt-8">
595
+ Defaults: 128k context, 16k output, text-only
596
+ </Button>
597
+ </CollapsibleTrigger>
598
+ <CollapsibleContent>
599
+ <div className="mt-8 grid grid-cols-2 gap-8 max-[640px]:grid-cols-1">
600
+ <FormField label="Context window" id={`custom-model-context-${row.client_id}`}>
601
+ <FormFieldInput
602
+ type="number"
603
+ inputMode="numeric"
604
+ placeholder="128000"
605
+ value={row.context_window}
606
+ onChange={(event) =>
607
+ updateRow(rows, index, {...row, context_window: event.target.value}, onChange)
608
+ }
609
+ />
610
+ </FormField>
611
+ <FormField label="Max output tokens" id={`custom-model-output-${row.client_id}`}>
612
+ <FormFieldInput
613
+ type="number"
614
+ inputMode="numeric"
615
+ placeholder="16384"
616
+ value={row.max_output_tokens}
617
+ onChange={(event) =>
618
+ updateRow(
619
+ rows,
620
+ index,
621
+ {...row, max_output_tokens: event.target.value},
622
+ onChange,
623
+ )
624
+ }
625
+ />
626
+ </FormField>
627
+ <div className="flex h-32 items-center gap-8 text-sm text-foreground-neutral-muted">
628
+ <Switch
629
+ size="sm"
630
+ aria-label={`Enable image input for model ${index + 1}`}
631
+ checked={row.input_image}
632
+ onCheckedChange={(checked) =>
633
+ updateRow(rows, index, {...row, input_image: checked}, onChange)
634
+ }
635
+ />
636
+ Image input
637
+ </div>
638
+ <div className="flex h-32 items-center gap-8 text-sm text-foreground-neutral-muted">
639
+ <Switch
640
+ size="sm"
641
+ aria-label={`Enable reasoning for model ${index + 1}`}
642
+ checked={row.reasoning}
643
+ onCheckedChange={(checked) =>
644
+ updateRow(rows, index, {...row, reasoning: checked}, onChange)
645
+ }
646
+ />
647
+ Reasoning
648
+ </div>
649
+ </div>
650
+ </CollapsibleContent>
651
+ </Collapsible>
652
+ </div>
653
+ ))}
654
+ <Button
655
+ ref={addButtonRef}
656
+ type="button"
657
+ size="sm"
658
+ variant="secondary"
659
+ iconLeft="addLine"
660
+ onClick={() => {
661
+ const clientId = createFormRowId();
662
+ onChange([
663
+ ...rows,
664
+ {
665
+ client_id: clientId,
666
+ id: '',
667
+ label: '',
668
+ context_window: '',
669
+ max_output_tokens: '',
670
+ input_image: false,
671
+ reasoning: false,
672
+ },
673
+ ]);
674
+ focusAfterRender(`custom-model-id-${clientId}`);
675
+ }}
676
+ >
677
+ Add model
678
+ </Button>
679
+ </div>
680
+ );
681
+ }
682
+
683
+ function updateRow<T>(rows: T[], index: number, row: T, onChange: (rows: T[]) => void) {
684
+ onChange(rows.map((candidate, rowIndex) => (rowIndex === index ? row : candidate)));
685
+ }
686
+
687
+ function focusAfterRender(target: string | RefObject<HTMLElement | null>): void {
688
+ requestAnimationFrame(() => {
689
+ const element = typeof target === 'string' ? document.getElementById(target) : target.current;
690
+ element?.focus();
691
+ });
692
+ }
693
+
694
+ function validateDefaultModel(
695
+ value: string,
696
+ models: CustomModelProviderModelFormValue[],
697
+ ): string | undefined {
698
+ return value.trim() && !models.some((model) => model.id.trim() === value.trim())
699
+ ? 'Choose a model from the models list.'
700
+ : undefined;
701
+ }
702
+
703
+ function canParseUrl(value: string): boolean {
704
+ try {
705
+ new URL(value);
706
+ return true;
707
+ } catch {
708
+ return false;
709
+ }
710
+ }