@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.
- package/.storybook/main.ts +1 -0
- package/.storybook/preview.css +4 -0
- package/.storybook/preview.tsx +56 -0
- package/.turbo/turbo-build.log +2 -0
- package/.turbo/turbo-type$colon$emit.log +1 -0
- package/.turbo/turbo-type.log +1 -0
- package/CHANGELOG.md +93 -0
- package/LICENSE +21 -0
- package/dist/components/add-custom-provider-card.d.ts +4 -0
- package/dist/components/add-custom-provider-card.d.ts.map +1 -0
- package/dist/components/add-custom-provider-card.js +41 -0
- package/dist/components/add-custom-provider-card.js.map +1 -0
- package/dist/components/agent-workflow-example.d.ts +11 -0
- package/dist/components/agent-workflow-example.d.ts.map +1 -0
- package/dist/components/agent-workflow-example.js +38 -0
- package/dist/components/agent-workflow-example.js.map +1 -0
- package/dist/components/available-provider-card.d.ts +6 -0
- package/dist/components/available-provider-card.d.ts.map +1 -0
- package/dist/components/available-provider-card.js +41 -0
- package/dist/components/available-provider-card.js.map +1 -0
- package/dist/components/available-providers-grid.d.ts +10 -0
- package/dist/components/available-providers-grid.d.ts.map +1 -0
- package/dist/components/available-providers-grid.js +89 -0
- package/dist/components/available-providers-grid.js.map +1 -0
- package/dist/components/change-default-model-form.d.ts +9 -0
- package/dist/components/change-default-model-form.d.ts.map +1 -0
- package/dist/components/change-default-model-form.js +98 -0
- package/dist/components/change-default-model-form.js.map +1 -0
- package/dist/components/custom-model-provider-api-options.d.ts +7 -0
- package/dist/components/custom-model-provider-api-options.d.ts.map +1 -0
- package/dist/components/custom-model-provider-api-options.js +23 -0
- package/dist/components/custom-model-provider-api-options.js.map +1 -0
- package/dist/components/custom-model-provider-form.d.ts +8 -0
- package/dist/components/custom-model-provider-form.d.ts.map +1 -0
- package/dist/components/custom-model-provider-form.js +667 -0
- package/dist/components/custom-model-provider-form.js.map +1 -0
- package/dist/components/custom-model-provider-form.stories.d.ts +20 -0
- package/dist/components/custom-model-provider-form.stories.d.ts.map +1 -0
- package/dist/components/custom-model-provider-form.stories.js +104 -0
- package/dist/components/custom-model-provider-form.stories.js.map +1 -0
- package/dist/components/custom-model-provider-payload.d.ts +39 -0
- package/dist/components/custom-model-provider-payload.d.ts.map +1 -0
- package/dist/components/custom-model-provider-payload.js +203 -0
- package/dist/components/custom-model-provider-payload.js.map +1 -0
- package/dist/components/custom-model-provider-slug.d.ts +3 -0
- package/dist/components/custom-model-provider-slug.d.ts.map +1 -0
- package/dist/components/custom-model-provider-slug.js +14 -0
- package/dist/components/custom-model-provider-slug.js.map +1 -0
- package/dist/components/default-model-field.d.ts +16 -0
- package/dist/components/default-model-field.d.ts.map +1 -0
- package/dist/components/default-model-field.js +60 -0
- package/dist/components/default-model-field.js.map +1 -0
- package/dist/components/form-errors.d.ts +11 -0
- package/dist/components/form-errors.d.ts.map +1 -0
- package/dist/components/form-errors.js +101 -0
- package/dist/components/form-errors.js.map +1 -0
- package/dist/components/harness-availability.d.ts +8 -0
- package/dist/components/harness-availability.d.ts.map +1 -0
- package/dist/components/harness-availability.js +16 -0
- package/dist/components/harness-availability.js.map +1 -0
- package/dist/components/harnesses-section.d.ts +4 -0
- package/dist/components/harnesses-section.d.ts.map +1 -0
- package/dist/components/harnesses-section.js +249 -0
- package/dist/components/harnesses-section.js.map +1 -0
- package/dist/components/harnesses-section.stories.d.ts +25 -0
- package/dist/components/harnesses-section.stories.d.ts.map +1 -0
- package/dist/components/harnesses-section.stories.js +138 -0
- package/dist/components/harnesses-section.stories.js.map +1 -0
- package/dist/components/model-provider-usage-modal.d.ts +11 -0
- package/dist/components/model-provider-usage-modal.d.ts.map +1 -0
- package/dist/components/model-provider-usage-modal.js +349 -0
- package/dist/components/model-provider-usage-modal.js.map +1 -0
- package/dist/components/model-provider-usage-modal.stories.d.ts +21 -0
- package/dist/components/model-provider-usage-modal.stories.d.ts.map +1 -0
- package/dist/components/model-provider-usage-modal.stories.js +115 -0
- package/dist/components/model-provider-usage-modal.stories.js.map +1 -0
- package/dist/components/model-provider-usage-target.d.ts +14 -0
- package/dist/components/model-provider-usage-target.d.ts.map +1 -0
- package/dist/components/model-provider-usage-target.js +20 -0
- package/dist/components/model-provider-usage-target.js.map +1 -0
- package/dist/components/model-providers-section.d.ts +4 -0
- package/dist/components/model-providers-section.d.ts.map +1 -0
- package/dist/components/model-providers-section.js +668 -0
- package/dist/components/model-providers-section.js.map +1 -0
- package/dist/components/model-providers-section.stories.d.ts +32 -0
- package/dist/components/model-providers-section.stories.d.ts.map +1 -0
- package/dist/components/model-providers-section.stories.js +442 -0
- package/dist/components/model-providers-section.stories.js.map +1 -0
- package/dist/components/provider-search.d.ts +4 -0
- package/dist/components/provider-search.d.ts.map +1 -0
- package/dist/components/provider-search.js +13 -0
- package/dist/components/provider-search.js.map +1 -0
- package/dist/components/supported-model-provider-catalog-entry.d.ts +8 -0
- package/dist/components/supported-model-provider-catalog-entry.d.ts.map +1 -0
- package/dist/components/supported-model-provider-catalog-entry.js +9 -0
- package/dist/components/supported-model-provider-catalog-entry.js.map +1 -0
- package/dist/components/test-and-save-form.d.ts +10 -0
- package/dist/components/test-and-save-form.d.ts.map +1 -0
- package/dist/components/test-and-save-form.js +138 -0
- package/dist/components/test-and-save-form.js.map +1 -0
- package/dist/feature.d.ts +9 -0
- package/dist/feature.d.ts.map +1 -0
- package/dist/feature.js +13 -0
- package/dist/feature.js.map +1 -0
- package/dist/hooks/api/model-providers.d.ts +334 -0
- package/dist/hooks/api/model-providers.d.ts.map +1 -0
- package/dist/hooks/api/model-providers.js +190 -0
- package/dist/hooks/api/model-providers.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/pages/model-provider-onboarding-page.d.ts +6 -0
- package/dist/pages/model-provider-onboarding-page.d.ts.map +1 -0
- package/dist/pages/model-provider-onboarding-page.js +295 -0
- package/dist/pages/model-provider-onboarding-page.js.map +1 -0
- package/dist/pages/model-provider-onboarding-page.stories.d.ts +27 -0
- package/dist/pages/model-provider-onboarding-page.stories.d.ts.map +1 -0
- package/dist/pages/model-provider-onboarding-page.stories.js +368 -0
- package/dist/pages/model-provider-onboarding-page.stories.js.map +1 -0
- package/dist/routes/model-provider.d.ts +5 -0
- package/dist/routes/model-provider.d.ts.map +1 -0
- package/dist/routes/model-provider.js +28 -0
- package/dist/routes/model-provider.js.map +1 -0
- package/dist/state/model-provider-onboarding.d.ts +3 -0
- package/dist/state/model-provider-onboarding.d.ts.map +1 -0
- package/dist/state/model-provider-onboarding.js +28 -0
- package/dist/state/model-provider-onboarding.js.map +1 -0
- package/dist/tsconfig.test.tsbuildinfo +1 -0
- package/package.json +114 -0
- package/src/components/add-custom-provider-card.tsx +32 -0
- package/src/components/agent-workflow-example.test.ts +74 -0
- package/src/components/agent-workflow-example.ts +55 -0
- package/src/components/available-provider-card.tsx +39 -0
- package/src/components/available-providers-grid.test.tsx +55 -0
- package/src/components/available-providers-grid.tsx +106 -0
- package/src/components/change-default-model-form.tsx +104 -0
- package/src/components/custom-model-provider-api-options.test.ts +18 -0
- package/src/components/custom-model-provider-api-options.ts +12 -0
- package/src/components/custom-model-provider-form.stories.tsx +91 -0
- package/src/components/custom-model-provider-form.tsx +710 -0
- package/src/components/custom-model-provider-payload.test.ts +170 -0
- package/src/components/custom-model-provider-payload.ts +264 -0
- package/src/components/custom-model-provider-slug.test.ts +40 -0
- package/src/components/custom-model-provider-slug.ts +23 -0
- package/src/components/default-model-field.tsx +85 -0
- package/src/components/form-errors.test.ts +159 -0
- package/src/components/form-errors.ts +102 -0
- package/src/components/harness-availability.test.ts +45 -0
- package/src/components/harness-availability.ts +35 -0
- package/src/components/harnesses-section.stories.tsx +129 -0
- package/src/components/harnesses-section.test.tsx +188 -0
- package/src/components/harnesses-section.tsx +245 -0
- package/src/components/model-provider-usage-modal.stories.tsx +106 -0
- package/src/components/model-provider-usage-modal.test.tsx +210 -0
- package/src/components/model-provider-usage-modal.tsx +326 -0
- package/src/components/model-provider-usage-target.ts +36 -0
- package/src/components/model-providers-section.stories.tsx +345 -0
- package/src/components/model-providers-section.test.tsx +831 -0
- package/src/components/model-providers-section.tsx +645 -0
- package/src/components/provider-search.test.ts +45 -0
- package/src/components/provider-search.ts +18 -0
- package/src/components/supported-model-provider-catalog-entry.ts +19 -0
- package/src/components/test-and-save-form.tsx +166 -0
- package/src/feature.ts +12 -0
- package/src/hooks/api/model-providers.test.ts +167 -0
- package/src/hooks/api/model-providers.ts +281 -0
- package/src/index.ts +8 -0
- package/src/pages/model-provider-onboarding-page.stories.tsx +284 -0
- package/src/pages/model-provider-onboarding-page.test.tsx +368 -0
- package/src/pages/model-provider-onboarding-page.tsx +290 -0
- package/src/routes/model-provider.tsx +20 -0
- package/src/state/model-provider-onboarding.test.ts +55 -0
- package/src/state/model-provider-onboarding.ts +34 -0
- package/test/fixtures/model-providers.ts +111 -0
- package/test/setup.ts +3 -0
- package/tsconfig.build.json +9 -0
- package/tsconfig.build.tsbuildinfo +1 -0
- package/tsconfig.json +3 -0
- package/tsconfig.test.json +8 -0
- package/vercel.json +8 -0
- package/vitest.config.ts +72 -0
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CustomModelProviderConfigDto,
|
|
3
|
+
ModelProviderCatalogEntryDto,
|
|
4
|
+
ModelProviderConfigDto,
|
|
5
|
+
ModelProviderConfigResponseDto,
|
|
6
|
+
} from '@shipfox/api-agent-dto';
|
|
7
|
+
import {QueryLoadError} from '@shipfox/client-ui';
|
|
8
|
+
import {Button, IconButton} from '@shipfox/react-ui/button';
|
|
9
|
+
import {Callout} from '@shipfox/react-ui/callout';
|
|
10
|
+
import {
|
|
11
|
+
DropdownMenu,
|
|
12
|
+
DropdownMenuContent,
|
|
13
|
+
DropdownMenuItem,
|
|
14
|
+
DropdownMenuTrigger,
|
|
15
|
+
} from '@shipfox/react-ui/dropdown-menu';
|
|
16
|
+
import {EmptyState} from '@shipfox/react-ui/empty-state';
|
|
17
|
+
import {Icon} from '@shipfox/react-ui/icon';
|
|
18
|
+
import {
|
|
19
|
+
Modal,
|
|
20
|
+
ModalBody,
|
|
21
|
+
ModalContent,
|
|
22
|
+
ModalFooter,
|
|
23
|
+
ModalHeader,
|
|
24
|
+
ModalTitle,
|
|
25
|
+
} from '@shipfox/react-ui/modal';
|
|
26
|
+
import {Skeleton} from '@shipfox/react-ui/skeleton';
|
|
27
|
+
import {toast} from '@shipfox/react-ui/toast';
|
|
28
|
+
import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
|
|
29
|
+
import {Header, Text} from '@shipfox/react-ui/typography';
|
|
30
|
+
import {cn} from '@shipfox/react-ui/utils';
|
|
31
|
+
import {useEffect, useMemo, useRef, useState} from 'react';
|
|
32
|
+
import {
|
|
33
|
+
useDeleteModelProviderConfigMutation,
|
|
34
|
+
useModelProviderCatalogQuery,
|
|
35
|
+
useModelProviderConfigsQuery,
|
|
36
|
+
useSetDefaultModelProviderMutation,
|
|
37
|
+
} from '#hooks/api/model-providers.js';
|
|
38
|
+
import {AddCustomProviderCard} from './add-custom-provider-card.js';
|
|
39
|
+
import {AvailableProvidersGrid, PROVIDER_GRID_CLASS} from './available-providers-grid.js';
|
|
40
|
+
import {ChangeDefaultModelForm} from './change-default-model-form.js';
|
|
41
|
+
import {CustomModelProviderForm} from './custom-model-provider-form.js';
|
|
42
|
+
import {modelProviderConfigErrorToFormError} from './form-errors.js';
|
|
43
|
+
import {ModelProviderUsageModal} from './model-provider-usage-modal.js';
|
|
44
|
+
import {
|
|
45
|
+
type ModelProviderUsageTarget,
|
|
46
|
+
usageTargetFromCatalogEntry,
|
|
47
|
+
usageTargetFromCustomConfig,
|
|
48
|
+
} 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
|
+
import {ModelProviderTestAndSaveForm} from './test-and-save-form.js';
|
|
56
|
+
|
|
57
|
+
const SURFACE_CLASS =
|
|
58
|
+
'overflow-hidden rounded-8 border border-border-neutral-base bg-background-neutral-base';
|
|
59
|
+
|
|
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
|
+
type UsageTarget = {
|
|
68
|
+
target: ModelProviderUsageTarget;
|
|
69
|
+
initialModel: string | null;
|
|
70
|
+
restoreFocusToConfiguredProviders: boolean;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const USAGE_MODAL_OPEN_DELAY_MS = 250;
|
|
74
|
+
|
|
75
|
+
export function WorkspaceModelProvidersSection({workspaceId}: {workspaceId: string}) {
|
|
76
|
+
const catalogQuery = useModelProviderCatalogQuery();
|
|
77
|
+
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);
|
|
82
|
+
const [pendingUsageTarget, setPendingUsageTarget] = useState<UsageTarget | null>(null);
|
|
83
|
+
const configuredProvidersRegionRef = useRef<HTMLElement | null>(null);
|
|
84
|
+
|
|
85
|
+
const providers = catalogQuery.data?.providers ?? [];
|
|
86
|
+
const configs = configsQuery.data?.configs ?? [];
|
|
87
|
+
const configsLoaded = configsQuery.data !== undefined;
|
|
88
|
+
const defaultProviderId = configsQuery.data?.default_provider_id ?? null;
|
|
89
|
+
const providerById = useMemo(
|
|
90
|
+
() =>
|
|
91
|
+
new Map<string, ModelProviderCatalogEntryDto>(
|
|
92
|
+
providers.map((provider) => [provider.id, provider]),
|
|
93
|
+
),
|
|
94
|
+
[providers],
|
|
95
|
+
);
|
|
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
|
+
}
|
|
121
|
+
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (pendingUsageTarget === null || formState !== null || customFormState !== null)
|
|
124
|
+
return undefined;
|
|
125
|
+
|
|
126
|
+
const timer = window.setTimeout(() => {
|
|
127
|
+
setUsageTarget(pendingUsageTarget);
|
|
128
|
+
setPendingUsageTarget(null);
|
|
129
|
+
}, USAGE_MODAL_OPEN_DELAY_MS);
|
|
130
|
+
|
|
131
|
+
return () => window.clearTimeout(timer);
|
|
132
|
+
}, [customFormState, formState, pendingUsageTarget]);
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<div className="flex min-w-0 flex-col gap-32">
|
|
136
|
+
<section
|
|
137
|
+
ref={configuredProvidersRegionRef}
|
|
138
|
+
className="flex flex-col gap-16 outline-none"
|
|
139
|
+
aria-label="Configured providers"
|
|
140
|
+
tabIndex={-1}
|
|
141
|
+
>
|
|
142
|
+
<div className="flex flex-col gap-4">
|
|
143
|
+
<Header variant="h3">Configured providers</Header>
|
|
144
|
+
<Text size="sm" className="text-foreground-neutral-muted">
|
|
145
|
+
Workspace credentials available to agent steps.
|
|
146
|
+
</Text>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{configsQuery.isPending ? (
|
|
150
|
+
<ModelProviderRowsSkeleton label="Loading configured providers" />
|
|
151
|
+
) : null}
|
|
152
|
+
|
|
153
|
+
{configsQuery.isError && configsQuery.data === undefined ? (
|
|
154
|
+
<div className={cn(SURFACE_CLASS, 'px-16')}>
|
|
155
|
+
<QueryLoadError query={configsQuery} subject="model provider configs" />
|
|
156
|
+
</div>
|
|
157
|
+
) : null}
|
|
158
|
+
|
|
159
|
+
{configsQuery.data !== undefined && configs.length === 0 ? (
|
|
160
|
+
<div className={cn(SURFACE_CLASS, 'px-16')}>
|
|
161
|
+
<EmptyState
|
|
162
|
+
icon="key2Line"
|
|
163
|
+
title="No providers configured"
|
|
164
|
+
description="Configure a provider below to run agent steps with workspace-managed credentials."
|
|
165
|
+
/>
|
|
166
|
+
</div>
|
|
167
|
+
) : null}
|
|
168
|
+
|
|
169
|
+
{configs.length > 0 ? (
|
|
170
|
+
<ul className={cn('divide-y divide-border-neutral-base', SURFACE_CLASS)}>
|
|
171
|
+
{configs.map((config) => {
|
|
172
|
+
const entry = toSupportedCatalogEntry(providerById.get(config.provider_id));
|
|
173
|
+
const builtinConfig = isBuiltinModelProviderConfig(config) ? config : undefined;
|
|
174
|
+
const customConfig = isCustomModelProviderConfig(config) ? config : undefined;
|
|
175
|
+
return (
|
|
176
|
+
<ConfiguredProviderRow
|
|
177
|
+
key={config.provider_id}
|
|
178
|
+
workspaceId={workspaceId}
|
|
179
|
+
config={config}
|
|
180
|
+
entry={entry}
|
|
181
|
+
isDefault={config.provider_id === defaultProviderId}
|
|
182
|
+
onEdit={() => {
|
|
183
|
+
if (entry && builtinConfig) {
|
|
184
|
+
setFormState({mode: 'edit', entry, config: builtinConfig});
|
|
185
|
+
} else if (customConfig) {
|
|
186
|
+
setCustomFormState({mode: 'edit', config: customConfig});
|
|
187
|
+
}
|
|
188
|
+
}}
|
|
189
|
+
onChangeDefaultModel={() => {
|
|
190
|
+
if (entry && builtinConfig) setModelFormState({entry, config: builtinConfig});
|
|
191
|
+
}}
|
|
192
|
+
onShowUsage={() => {
|
|
193
|
+
if (entry && builtinConfig) {
|
|
194
|
+
setPendingUsageTarget(null);
|
|
195
|
+
setUsageTarget({
|
|
196
|
+
target: usageTargetFromCatalogEntry(entry),
|
|
197
|
+
initialModel: builtinConfig.default_model,
|
|
198
|
+
restoreFocusToConfiguredProviders: false,
|
|
199
|
+
});
|
|
200
|
+
} else if (customConfig) {
|
|
201
|
+
setPendingUsageTarget(null);
|
|
202
|
+
setUsageTarget({
|
|
203
|
+
target: usageTargetFromCustomConfig(customConfig),
|
|
204
|
+
initialModel: customConfig.default_model,
|
|
205
|
+
restoreFocusToConfiguredProviders: false,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}}
|
|
209
|
+
/>
|
|
210
|
+
);
|
|
211
|
+
})}
|
|
212
|
+
</ul>
|
|
213
|
+
) : null}
|
|
214
|
+
</section>
|
|
215
|
+
|
|
216
|
+
<section className="flex flex-col gap-16" aria-label="Available providers">
|
|
217
|
+
<div className="flex flex-col gap-4">
|
|
218
|
+
<Header variant="h3">Available providers</Header>
|
|
219
|
+
<Text size="sm" className="text-foreground-neutral-muted">
|
|
220
|
+
Providers that can be configured for agent steps in this workspace.
|
|
221
|
+
</Text>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
{catalogQuery.isPending || configsQuery.isPending ? <ModelProviderGridSkeleton /> : null}
|
|
225
|
+
|
|
226
|
+
{catalogQuery.isError && catalogQuery.data === undefined ? (
|
|
227
|
+
<div className={cn(SURFACE_CLASS, 'px-16')}>
|
|
228
|
+
<QueryLoadError query={catalogQuery} subject="model provider catalog" />
|
|
229
|
+
</div>
|
|
230
|
+
) : null}
|
|
231
|
+
|
|
232
|
+
{configsLoaded ? (
|
|
233
|
+
<AvailableProvidersGrid
|
|
234
|
+
entries={availableProviders}
|
|
235
|
+
onSelect={(entry) => setFormState({mode: 'configure', entry})}
|
|
236
|
+
trailingCard={
|
|
237
|
+
<AddCustomProviderCard onConfigure={() => setCustomFormState({mode: 'create'})} />
|
|
238
|
+
}
|
|
239
|
+
trailingCardMatchesSearch={customProviderCardMatchesSearch}
|
|
240
|
+
/>
|
|
241
|
+
) : null}
|
|
242
|
+
</section>
|
|
243
|
+
|
|
244
|
+
<section className="flex flex-col gap-16" aria-label="Unsupported providers">
|
|
245
|
+
<div className="flex flex-col gap-4">
|
|
246
|
+
<Header variant="h3">Unsupported providers</Header>
|
|
247
|
+
<Text size="sm" className="text-foreground-neutral-muted">
|
|
248
|
+
Providers that cannot be configured in this workspace yet.
|
|
249
|
+
</Text>
|
|
250
|
+
</div>
|
|
251
|
+
|
|
252
|
+
{catalogQuery.isPending ? (
|
|
253
|
+
<ModelProviderRowsSkeleton label="Loading unsupported providers" />
|
|
254
|
+
) : null}
|
|
255
|
+
|
|
256
|
+
{unsupportedProviders.length > 0 ? (
|
|
257
|
+
<ul className={cn('divide-y divide-border-neutral-base', SURFACE_CLASS)}>
|
|
258
|
+
{unsupportedProviders.map((entry) => (
|
|
259
|
+
<li key={entry.id} className="flex items-start gap-12 px-16 py-12 opacity-70">
|
|
260
|
+
<Icon
|
|
261
|
+
name="forbid2Line"
|
|
262
|
+
className="mt-2 size-18 shrink-0 text-foreground-neutral-muted"
|
|
263
|
+
aria-hidden
|
|
264
|
+
/>
|
|
265
|
+
<div className="flex min-w-0 flex-1 flex-col gap-2">
|
|
266
|
+
<Text size="md" bold className="truncate">
|
|
267
|
+
{entry.label}
|
|
268
|
+
</Text>
|
|
269
|
+
<Text size="sm" className="text-foreground-neutral-muted">
|
|
270
|
+
{entry.unsupported_reason}
|
|
271
|
+
</Text>
|
|
272
|
+
</div>
|
|
273
|
+
</li>
|
|
274
|
+
))}
|
|
275
|
+
</ul>
|
|
276
|
+
) : null}
|
|
277
|
+
</section>
|
|
278
|
+
|
|
279
|
+
<Modal open={formState !== null} onOpenChange={(open) => (open ? undefined : closeForm())}>
|
|
280
|
+
<ModalContent aria-describedby={undefined}>
|
|
281
|
+
<ModalTitle className="sr-only">{modelProviderFormTitle(formState)}</ModalTitle>
|
|
282
|
+
<ModalHeader>
|
|
283
|
+
<Text
|
|
284
|
+
size="lg"
|
|
285
|
+
aria-hidden="true"
|
|
286
|
+
className="overflow-ellipsis overflow-hidden whitespace-nowrap"
|
|
287
|
+
>
|
|
288
|
+
{modelProviderFormTitle(formState)}
|
|
289
|
+
</Text>
|
|
290
|
+
</ModalHeader>
|
|
291
|
+
{formState ? (
|
|
292
|
+
<ModelProviderTestAndSaveForm
|
|
293
|
+
workspaceId={workspaceId}
|
|
294
|
+
entry={formState.entry}
|
|
295
|
+
existingConfig={formState.config}
|
|
296
|
+
onSaved={(savedDefaultModel) => {
|
|
297
|
+
toast.success(`${formState.entry.label} saved`);
|
|
298
|
+
if (formState.mode === 'configure' && configs.length === 0) {
|
|
299
|
+
setPendingUsageTarget({
|
|
300
|
+
target: usageTargetFromCatalogEntry(formState.entry),
|
|
301
|
+
initialModel: savedDefaultModel,
|
|
302
|
+
restoreFocusToConfiguredProviders: true,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
closeForm();
|
|
306
|
+
}}
|
|
307
|
+
/>
|
|
308
|
+
) : null}
|
|
309
|
+
</ModalContent>
|
|
310
|
+
</Modal>
|
|
311
|
+
|
|
312
|
+
<Modal
|
|
313
|
+
open={modelFormState !== null}
|
|
314
|
+
onOpenChange={(open) => (open ? undefined : closeModelForm())}
|
|
315
|
+
>
|
|
316
|
+
<ModalContent aria-describedby={undefined}>
|
|
317
|
+
<ModalTitle className="sr-only">Change default model</ModalTitle>
|
|
318
|
+
<ModalHeader>
|
|
319
|
+
<Text
|
|
320
|
+
size="lg"
|
|
321
|
+
aria-hidden="true"
|
|
322
|
+
className="overflow-ellipsis overflow-hidden whitespace-nowrap"
|
|
323
|
+
>
|
|
324
|
+
Change default model for {modelFormState?.entry.label}
|
|
325
|
+
</Text>
|
|
326
|
+
</ModalHeader>
|
|
327
|
+
{modelFormState ? (
|
|
328
|
+
<ChangeDefaultModelForm
|
|
329
|
+
workspaceId={workspaceId}
|
|
330
|
+
entry={modelFormState.entry}
|
|
331
|
+
config={modelFormState.config}
|
|
332
|
+
onSaved={() => {
|
|
333
|
+
toast.success(`${modelFormState.entry.label} default model saved`);
|
|
334
|
+
closeModelForm();
|
|
335
|
+
}}
|
|
336
|
+
/>
|
|
337
|
+
) : null}
|
|
338
|
+
</ModalContent>
|
|
339
|
+
</Modal>
|
|
340
|
+
|
|
341
|
+
<ModelProviderUsageModal
|
|
342
|
+
target={usageTarget?.target ?? null}
|
|
343
|
+
initialModel={usageTarget?.initialModel ?? null}
|
|
344
|
+
workspaceDefaultHarnessId={configsQuery.data?.default_harness_id ?? null}
|
|
345
|
+
open={usageTarget !== null}
|
|
346
|
+
closeFocusTarget={
|
|
347
|
+
usageTarget?.restoreFocusToConfiguredProviders
|
|
348
|
+
? configuredProvidersRegionRef.current
|
|
349
|
+
: null
|
|
350
|
+
}
|
|
351
|
+
onOpenChange={(open) => {
|
|
352
|
+
if (!open) setUsageTarget(null);
|
|
353
|
+
}}
|
|
354
|
+
/>
|
|
355
|
+
|
|
356
|
+
<Modal
|
|
357
|
+
open={customFormState !== null}
|
|
358
|
+
onOpenChange={(open) => (open ? undefined : closeCustomForm())}
|
|
359
|
+
>
|
|
360
|
+
<ModalContent
|
|
361
|
+
aria-describedby={undefined}
|
|
362
|
+
className="max-h-[calc(100vh-32px)] max-w-[760px]"
|
|
363
|
+
>
|
|
364
|
+
<ModalTitle className="sr-only">
|
|
365
|
+
{customModelProviderFormTitle(customFormState)}
|
|
366
|
+
</ModalTitle>
|
|
367
|
+
<ModalHeader>
|
|
368
|
+
<div className="flex min-w-0 flex-col gap-2">
|
|
369
|
+
<Text size="lg" aria-hidden="true" className="truncate">
|
|
370
|
+
{customModelProviderFormTitle(customFormState)}
|
|
371
|
+
</Text>
|
|
372
|
+
<Text size="sm" className="text-foreground-neutral-muted">
|
|
373
|
+
Connect an OpenAI-, Anthropic-, or Gemini-compatible endpoint.
|
|
374
|
+
</Text>
|
|
375
|
+
</div>
|
|
376
|
+
</ModalHeader>
|
|
377
|
+
{customFormState ? (
|
|
378
|
+
<CustomModelProviderForm
|
|
379
|
+
workspaceId={workspaceId}
|
|
380
|
+
existingConfig={customFormState.mode === 'edit' ? customFormState.config : undefined}
|
|
381
|
+
onSaved={() => {
|
|
382
|
+
toast.success(
|
|
383
|
+
customFormState.mode === 'edit'
|
|
384
|
+
? `${customFormState.config.display_name} saved`
|
|
385
|
+
: 'Custom provider saved',
|
|
386
|
+
);
|
|
387
|
+
closeCustomForm();
|
|
388
|
+
}}
|
|
389
|
+
/>
|
|
390
|
+
) : null}
|
|
391
|
+
</ModalContent>
|
|
392
|
+
</Modal>
|
|
393
|
+
</div>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
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}`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function customModelProviderFormTitle(formState: CustomModelProviderFormState | null): string {
|
|
404
|
+
if (formState === null) return '';
|
|
405
|
+
if (formState.mode === 'edit') return `Edit ${formState.config.display_name}`;
|
|
406
|
+
return 'Add custom provider';
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function isBuiltinModelProviderConfig(
|
|
410
|
+
config: ModelProviderConfigResponseDto,
|
|
411
|
+
): config is ModelProviderConfigDto {
|
|
412
|
+
return config.kind === 'builtin';
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function isCustomModelProviderConfig(
|
|
416
|
+
config: ModelProviderConfigResponseDto,
|
|
417
|
+
): config is CustomModelProviderConfigDto {
|
|
418
|
+
return config.kind === 'custom';
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function ConfiguredProviderRow({
|
|
422
|
+
workspaceId,
|
|
423
|
+
config,
|
|
424
|
+
entry,
|
|
425
|
+
isDefault,
|
|
426
|
+
onEdit,
|
|
427
|
+
onChangeDefaultModel,
|
|
428
|
+
onShowUsage,
|
|
429
|
+
}: {
|
|
430
|
+
workspaceId: string;
|
|
431
|
+
config: ModelProviderConfigResponseDto;
|
|
432
|
+
entry: SupportedModelProviderCatalogEntry | undefined;
|
|
433
|
+
isDefault: boolean;
|
|
434
|
+
onEdit: () => void;
|
|
435
|
+
onChangeDefaultModel: () => void;
|
|
436
|
+
onShowUsage: () => void;
|
|
437
|
+
}) {
|
|
438
|
+
const setDefault = useSetDefaultModelProviderMutation();
|
|
439
|
+
const deleteConfig = useDeleteModelProviderConfigMutation();
|
|
440
|
+
const [defaultError, setDefaultError] = useState<string | undefined>();
|
|
441
|
+
const [deleteOpen, setDeleteOpen] = useState(false);
|
|
442
|
+
const [deleteError, setDeleteError] = useState<string | undefined>();
|
|
443
|
+
const customConfig = isCustomModelProviderConfig(config) ? config : undefined;
|
|
444
|
+
const label = customConfig?.display_name ?? entry?.label ?? config.provider_id;
|
|
445
|
+
const canUse = entry !== undefined || customConfig !== undefined;
|
|
446
|
+
const canEdit = entry !== undefined || customConfig !== undefined;
|
|
447
|
+
const isBuiltinConfig = isBuiltinModelProviderConfig(config);
|
|
448
|
+
|
|
449
|
+
async function handleSetDefault() {
|
|
450
|
+
setDefaultError(undefined);
|
|
451
|
+
try {
|
|
452
|
+
await setDefault.mutateAsync({
|
|
453
|
+
workspaceId,
|
|
454
|
+
body: {provider_id: config.provider_id},
|
|
455
|
+
});
|
|
456
|
+
toast.success(`${label} is now the default provider`);
|
|
457
|
+
} catch (error) {
|
|
458
|
+
const mapped = modelProviderConfigErrorToFormError(error);
|
|
459
|
+
setDefaultError(mapped.message);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function handleDelete() {
|
|
464
|
+
setDeleteError(undefined);
|
|
465
|
+
try {
|
|
466
|
+
await deleteConfig.mutateAsync({workspaceId, providerId: config.provider_id});
|
|
467
|
+
toast.success(`${label} deleted`);
|
|
468
|
+
setDeleteOpen(false);
|
|
469
|
+
} catch (error) {
|
|
470
|
+
const mapped = modelProviderConfigErrorToFormError(error);
|
|
471
|
+
setDeleteError(mapped.message);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function handleDeleteOpenChange(nextOpen: boolean) {
|
|
476
|
+
setDeleteOpen(nextOpen);
|
|
477
|
+
if (nextOpen) {
|
|
478
|
+
setDeleteError(undefined);
|
|
479
|
+
deleteConfig.reset();
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return (
|
|
484
|
+
<li className="flex flex-col gap-10 px-16 py-12 transition-colors hover:bg-background-components-hover">
|
|
485
|
+
<div className="flex items-center justify-between gap-12">
|
|
486
|
+
<div className="flex min-w-0 items-center gap-8">
|
|
487
|
+
{isDefault ? (
|
|
488
|
+
<>
|
|
489
|
+
<Tooltip>
|
|
490
|
+
<TooltipTrigger asChild>
|
|
491
|
+
<span className="inline-flex size-16 shrink-0 items-center justify-center">
|
|
492
|
+
<Icon
|
|
493
|
+
name="starLine"
|
|
494
|
+
className="size-16 text-foreground-neutral-muted"
|
|
495
|
+
aria-hidden
|
|
496
|
+
/>
|
|
497
|
+
</span>
|
|
498
|
+
</TooltipTrigger>
|
|
499
|
+
<TooltipContent>Default provider</TooltipContent>
|
|
500
|
+
</Tooltip>
|
|
501
|
+
<span className="sr-only">Default provider</span>
|
|
502
|
+
</>
|
|
503
|
+
) : null}
|
|
504
|
+
<Text size="md" bold className="truncate">
|
|
505
|
+
{label}
|
|
506
|
+
</Text>
|
|
507
|
+
</div>
|
|
508
|
+
<DropdownMenu>
|
|
509
|
+
<DropdownMenuTrigger asChild>
|
|
510
|
+
<IconButton
|
|
511
|
+
size="sm"
|
|
512
|
+
variant="transparent"
|
|
513
|
+
icon="more2Line"
|
|
514
|
+
aria-label={`Open ${label} provider actions`}
|
|
515
|
+
/>
|
|
516
|
+
</DropdownMenuTrigger>
|
|
517
|
+
<DropdownMenuContent align="end">
|
|
518
|
+
{!isDefault ? (
|
|
519
|
+
<DropdownMenuItem
|
|
520
|
+
icon="starLine"
|
|
521
|
+
disabled={setDefault.isPending || (!entry && !customConfig)}
|
|
522
|
+
onSelect={() => {
|
|
523
|
+
void handleSetDefault();
|
|
524
|
+
}}
|
|
525
|
+
>
|
|
526
|
+
Set as default
|
|
527
|
+
</DropdownMenuItem>
|
|
528
|
+
) : null}
|
|
529
|
+
{isBuiltinConfig ? (
|
|
530
|
+
<DropdownMenuItem
|
|
531
|
+
icon="settings3Line"
|
|
532
|
+
disabled={!entry}
|
|
533
|
+
onSelect={onChangeDefaultModel}
|
|
534
|
+
>
|
|
535
|
+
Change default model
|
|
536
|
+
</DropdownMenuItem>
|
|
537
|
+
) : null}
|
|
538
|
+
<DropdownMenuItem icon="bookOpenLine" disabled={!canUse} onSelect={onShowUsage}>
|
|
539
|
+
View workflow example
|
|
540
|
+
</DropdownMenuItem>
|
|
541
|
+
<DropdownMenuItem icon="editLine" disabled={!canEdit} onSelect={onEdit}>
|
|
542
|
+
{customConfig ? 'Edit' : 'Edit credentials'}
|
|
543
|
+
</DropdownMenuItem>
|
|
544
|
+
<DropdownMenuItem
|
|
545
|
+
icon="deleteBinLine"
|
|
546
|
+
onSelect={() => {
|
|
547
|
+
setDeleteOpen(true);
|
|
548
|
+
}}
|
|
549
|
+
>
|
|
550
|
+
Delete
|
|
551
|
+
</DropdownMenuItem>
|
|
552
|
+
</DropdownMenuContent>
|
|
553
|
+
</DropdownMenu>
|
|
554
|
+
</div>
|
|
555
|
+
{defaultError ? (
|
|
556
|
+
<Callout role="alert" type="error">
|
|
557
|
+
<Text size="sm">{defaultError}</Text>
|
|
558
|
+
</Callout>
|
|
559
|
+
) : null}
|
|
560
|
+
<DeleteModelProviderDialog
|
|
561
|
+
open={deleteOpen}
|
|
562
|
+
onOpenChange={handleDeleteOpenChange}
|
|
563
|
+
label={label}
|
|
564
|
+
errorMessage={deleteError}
|
|
565
|
+
isLoading={deleteConfig.isPending}
|
|
566
|
+
onDelete={handleDelete}
|
|
567
|
+
/>
|
|
568
|
+
</li>
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function DeleteModelProviderDialog({
|
|
573
|
+
open,
|
|
574
|
+
onOpenChange,
|
|
575
|
+
label,
|
|
576
|
+
errorMessage,
|
|
577
|
+
isLoading,
|
|
578
|
+
onDelete,
|
|
579
|
+
}: {
|
|
580
|
+
open: boolean;
|
|
581
|
+
onOpenChange: (open: boolean) => void;
|
|
582
|
+
label: string;
|
|
583
|
+
errorMessage: string | undefined;
|
|
584
|
+
isLoading: boolean;
|
|
585
|
+
onDelete: () => void;
|
|
586
|
+
}) {
|
|
587
|
+
return (
|
|
588
|
+
<Modal open={open} onOpenChange={onOpenChange}>
|
|
589
|
+
<ModalContent aria-describedby={undefined} className="max-w-[420px]">
|
|
590
|
+
<ModalTitle className="sr-only">Delete model provider</ModalTitle>
|
|
591
|
+
<ModalHeader title="Delete model provider" />
|
|
592
|
+
<ModalBody className="gap-16">
|
|
593
|
+
<Text size="sm" className="text-foreground-neutral-muted">
|
|
594
|
+
Delete {label} credentials from this workspace? Agent jobs cannot use this provider
|
|
595
|
+
until it is configured again.
|
|
596
|
+
</Text>
|
|
597
|
+
{errorMessage ? (
|
|
598
|
+
<Callout role="alert" type="error">
|
|
599
|
+
<Text size="sm">{errorMessage}</Text>
|
|
600
|
+
</Callout>
|
|
601
|
+
) : null}
|
|
602
|
+
</ModalBody>
|
|
603
|
+
<ModalFooter>
|
|
604
|
+
<Button size="sm" variant="secondary" onClick={() => onOpenChange(false)}>
|
|
605
|
+
Cancel
|
|
606
|
+
</Button>
|
|
607
|
+
<Button size="sm" variant="danger" isLoading={isLoading} onClick={onDelete}>
|
|
608
|
+
Delete
|
|
609
|
+
</Button>
|
|
610
|
+
</ModalFooter>
|
|
611
|
+
</ModalContent>
|
|
612
|
+
</Modal>
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function ModelProviderRowsSkeleton({label}: {label: string}) {
|
|
617
|
+
return (
|
|
618
|
+
<ul
|
|
619
|
+
role="status"
|
|
620
|
+
aria-label={label}
|
|
621
|
+
className={cn('divide-y divide-border-neutral-base', SURFACE_CLASS)}
|
|
622
|
+
>
|
|
623
|
+
{[0, 1, 2].map((row) => (
|
|
624
|
+
<li key={row} className="flex items-center gap-12 px-16 py-12">
|
|
625
|
+
<Skeleton className="size-32 shrink-0" />
|
|
626
|
+
<div className="flex min-w-0 flex-1 flex-col gap-6">
|
|
627
|
+
<Skeleton className="h-16 w-120" />
|
|
628
|
+
<Skeleton className="h-14 w-180" />
|
|
629
|
+
</div>
|
|
630
|
+
<Skeleton className="h-28 w-96 shrink-0" />
|
|
631
|
+
</li>
|
|
632
|
+
))}
|
|
633
|
+
</ul>
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function ModelProviderGridSkeleton() {
|
|
638
|
+
return (
|
|
639
|
+
<div role="status" aria-label="Loading available providers" className={PROVIDER_GRID_CLASS}>
|
|
640
|
+
{[0, 1, 2, 3].map((card) => (
|
|
641
|
+
<Skeleton key={card} className="h-136 w-full" />
|
|
642
|
+
))}
|
|
643
|
+
</div>
|
|
644
|
+
);
|
|
645
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import {modelProviderEntry} from '#test/fixtures/model-providers.js';
|
|
2
|
+
import {providerMatchesSearch} from './provider-search.js';
|
|
3
|
+
|
|
4
|
+
describe('providerMatchesSearch', () => {
|
|
5
|
+
test('matches empty and whitespace queries', () => {
|
|
6
|
+
const entry = modelProviderEntry();
|
|
7
|
+
|
|
8
|
+
expect(providerMatchesSearch(entry, '')).toBe(true);
|
|
9
|
+
expect(providerMatchesSearch(entry, ' ')).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('matches provider labels case-insensitively', () => {
|
|
13
|
+
const entry = modelProviderEntry({label: 'Anthropic'});
|
|
14
|
+
|
|
15
|
+
const result = providerMatchesSearch(entry, 'anthro');
|
|
16
|
+
|
|
17
|
+
expect(result).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('matches provider id substrings', () => {
|
|
21
|
+
const entry = modelProviderEntry({id: 'azure-openai-responses', label: 'Azure OpenAI'});
|
|
22
|
+
|
|
23
|
+
const result = providerMatchesSearch(entry, 'responses');
|
|
24
|
+
|
|
25
|
+
expect(result).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('matches model ids and labels', () => {
|
|
29
|
+
const entry = modelProviderEntry({
|
|
30
|
+
models: [{id: 'claude-opus-4-8', label: 'Claude Opus 4.8'}],
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const result = providerMatchesSearch(entry, 'claude');
|
|
34
|
+
|
|
35
|
+
expect(result).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('returns false when no searchable field matches', () => {
|
|
39
|
+
const entry = modelProviderEntry();
|
|
40
|
+
|
|
41
|
+
const result = providerMatchesSearch(entry, 'gemini');
|
|
42
|
+
|
|
43
|
+
expect(result).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
});
|