@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,831 @@
|
|
|
1
|
+
import type {CustomModelProviderConfigDto} from '@shipfox/api-agent-dto';
|
|
2
|
+
import {configureApiClient} from '@shipfox/client-api';
|
|
3
|
+
import {Toaster} from '@shipfox/react-ui/toast';
|
|
4
|
+
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
|
|
5
|
+
import {render, screen, waitFor, within} from '@testing-library/react';
|
|
6
|
+
import userEvent from '@testing-library/user-event';
|
|
7
|
+
import type {ReactElement} from 'react';
|
|
8
|
+
import {
|
|
9
|
+
AGENT_TEST_WORKSPACE_ID,
|
|
10
|
+
modelProviderCatalogResponse,
|
|
11
|
+
modelProviderConfig,
|
|
12
|
+
modelProviderConfigsResponse,
|
|
13
|
+
modelProviderEntry,
|
|
14
|
+
testModelProviderEntries,
|
|
15
|
+
unsupportedModelProviderEntry,
|
|
16
|
+
} from '#test/fixtures/model-providers.js';
|
|
17
|
+
import {WorkspaceModelProvidersSection} from './model-providers-section.js';
|
|
18
|
+
|
|
19
|
+
function jsonResponse(body: unknown, init: ResponseInit = {}) {
|
|
20
|
+
return new Response(JSON.stringify(body), {
|
|
21
|
+
status: 200,
|
|
22
|
+
headers: {'content-type': 'application/json'},
|
|
23
|
+
...init,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function renderModelProviders(element: ReactElement) {
|
|
28
|
+
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}});
|
|
29
|
+
|
|
30
|
+
return render(
|
|
31
|
+
<QueryClientProvider client={queryClient}>
|
|
32
|
+
{element}
|
|
33
|
+
<Toaster />
|
|
34
|
+
</QueryClientProvider>,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function requestPath(input: RequestInfo | URL): string {
|
|
39
|
+
return new URL((input as Request).url).pathname;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function openProviderActions(user: ReturnType<typeof userEvent.setup>, label: string) {
|
|
43
|
+
await user.click(screen.getByRole('button', {name: `Open ${label} provider actions`}));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function customProviderConfig(): CustomModelProviderConfigDto {
|
|
47
|
+
return {
|
|
48
|
+
kind: 'custom',
|
|
49
|
+
provider_id: 'local-vllm',
|
|
50
|
+
display_name: 'Local vLLM',
|
|
51
|
+
api: 'openai-completions',
|
|
52
|
+
base_url: 'http://localhost:8000/v1',
|
|
53
|
+
headers: [{name: 'x-region', value: 'us'}],
|
|
54
|
+
secret_header_names: ['authorization'],
|
|
55
|
+
models: [{id: 'llama-3.1', label: 'Llama 3.1'}],
|
|
56
|
+
default_model: 'llama-3.1',
|
|
57
|
+
created_at: '2026-05-08T00:00:00.000Z',
|
|
58
|
+
updated_at: '2026-05-08T00:00:00.000Z',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('WorkspaceModelProvidersSection', () => {
|
|
63
|
+
test('renders configured, available, and unsupported providers', async () => {
|
|
64
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
65
|
+
if (requestPath(input).endsWith('/agent/model-provider-catalog')) {
|
|
66
|
+
return Promise.resolve(
|
|
67
|
+
jsonResponse(
|
|
68
|
+
modelProviderCatalogResponse([
|
|
69
|
+
modelProviderEntry(),
|
|
70
|
+
modelProviderEntry({
|
|
71
|
+
id: 'openai',
|
|
72
|
+
label: 'OpenAI',
|
|
73
|
+
default_model: 'gpt-5.5-pro',
|
|
74
|
+
models: [{id: 'gpt-5.5-pro', label: 'GPT-5.5 Pro'}],
|
|
75
|
+
}),
|
|
76
|
+
unsupportedModelProviderEntry(),
|
|
77
|
+
]),
|
|
78
|
+
),
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return Promise.resolve(jsonResponse(modelProviderConfigsResponse()));
|
|
82
|
+
});
|
|
83
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
84
|
+
|
|
85
|
+
renderModelProviders(<WorkspaceModelProvidersSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
|
|
86
|
+
|
|
87
|
+
expect(await screen.findByText('Configured providers')).toBeVisible();
|
|
88
|
+
expect(await screen.findByText('Anthropic')).toBeVisible();
|
|
89
|
+
expect(screen.getByText('Default provider')).toHaveClass('sr-only');
|
|
90
|
+
expect(screen.getByText('Available providers')).toBeVisible();
|
|
91
|
+
expect(
|
|
92
|
+
screen.getByText('Providers that can be configured for agent steps in this workspace.'),
|
|
93
|
+
).toBeVisible();
|
|
94
|
+
expect(screen.getByText('OpenAI')).toBeVisible();
|
|
95
|
+
const customProviderButton = screen.getByRole('button', {name: 'Configure custom provider'});
|
|
96
|
+
expect(within(customProviderButton).getByText('Custom')).toBeVisible();
|
|
97
|
+
expect(within(customProviderButton).getByText('Configure')).toBeVisible();
|
|
98
|
+
expect(screen.getByText('Unsupported providers')).toBeVisible();
|
|
99
|
+
expect(screen.getByText('Amazon Bedrock')).toBeVisible();
|
|
100
|
+
expect(screen.getByText('AWS cloud credentials are not supported yet.')).toBeVisible();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('renders custom configured providers and preserves built-in model actions', async () => {
|
|
104
|
+
const user = userEvent.setup();
|
|
105
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
106
|
+
if (requestPath(input).endsWith('/agent/model-provider-catalog')) {
|
|
107
|
+
return Promise.resolve(jsonResponse(modelProviderCatalogResponse()));
|
|
108
|
+
}
|
|
109
|
+
return Promise.resolve(
|
|
110
|
+
jsonResponse(
|
|
111
|
+
modelProviderConfigsResponse({
|
|
112
|
+
configs: [modelProviderConfig(), customProviderConfig()],
|
|
113
|
+
default_provider_id: 'local-vllm',
|
|
114
|
+
}),
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
119
|
+
|
|
120
|
+
renderModelProviders(<WorkspaceModelProvidersSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
|
|
121
|
+
|
|
122
|
+
const customProviderRow = (await screen.findByText('Local vLLM')).closest('li');
|
|
123
|
+
if (customProviderRow === null) {
|
|
124
|
+
throw new Error('Expected custom provider row to render');
|
|
125
|
+
}
|
|
126
|
+
expect(within(customProviderRow).queryByText('Custom')).not.toBeInTheDocument();
|
|
127
|
+
expect(screen.queryByText('localhost:8000')).not.toBeInTheDocument();
|
|
128
|
+
expect(screen.queryByText('OpenAI Chat Completions')).not.toBeInTheDocument();
|
|
129
|
+
expect(screen.queryByText('1 secret header')).not.toBeInTheDocument();
|
|
130
|
+
|
|
131
|
+
await openProviderActions(user, 'Anthropic');
|
|
132
|
+
expect(screen.getByRole('menuitem', {name: 'Change default model'})).toBeVisible();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('filters available providers and clears back to the full available list', async () => {
|
|
136
|
+
const user = userEvent.setup();
|
|
137
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
138
|
+
if (requestPath(input).endsWith('/agent/model-provider-catalog')) {
|
|
139
|
+
return Promise.resolve(
|
|
140
|
+
jsonResponse(modelProviderCatalogResponse(testModelProviderEntries(9))),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return Promise.resolve(
|
|
144
|
+
jsonResponse(modelProviderConfigsResponse({configs: [], default_provider_id: null})),
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
148
|
+
|
|
149
|
+
renderModelProviders(<WorkspaceModelProvidersSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
|
|
150
|
+
const search = await screen.findByRole('searchbox', {name: 'Search providers'});
|
|
151
|
+
|
|
152
|
+
await user.type(search, 'provider 7');
|
|
153
|
+
|
|
154
|
+
expect(screen.getByRole('button', {name: 'Configure Provider 7'})).toBeVisible();
|
|
155
|
+
expect(screen.queryByRole('button', {name: 'Configure Provider 1'})).not.toBeInTheDocument();
|
|
156
|
+
await user.clear(search);
|
|
157
|
+
await user.type(search, 'missing');
|
|
158
|
+
expect(screen.getByText('No providers match "missing"')).toBeVisible();
|
|
159
|
+
await user.click(screen.getByRole('button', {name: 'Clear search'}));
|
|
160
|
+
|
|
161
|
+
expect(screen.getByRole('button', {name: 'Configure Provider 1'})).toBeVisible();
|
|
162
|
+
await waitFor(() => expect(search).toHaveFocus());
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('hides available provider search when the unfiltered list is small', async () => {
|
|
166
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
167
|
+
if (requestPath(input).endsWith('/agent/model-provider-catalog')) {
|
|
168
|
+
return Promise.resolve(
|
|
169
|
+
jsonResponse(modelProviderCatalogResponse(testModelProviderEntries(8))),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return Promise.resolve(
|
|
173
|
+
jsonResponse(modelProviderConfigsResponse({configs: [], default_provider_id: null})),
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
177
|
+
|
|
178
|
+
renderModelProviders(<WorkspaceModelProvidersSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
|
|
179
|
+
|
|
180
|
+
expect(await screen.findByRole('button', {name: 'Configure Provider 0'})).toBeVisible();
|
|
181
|
+
expect(screen.queryByRole('searchbox', {name: 'Search providers'})).not.toBeInTheDocument();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('waits for configured providers before rendering available provider cards', async () => {
|
|
185
|
+
let resolveConfigs!: (response: Response) => void;
|
|
186
|
+
const configsResponse = new Promise<Response>((resolve) => {
|
|
187
|
+
resolveConfigs = resolve;
|
|
188
|
+
});
|
|
189
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
190
|
+
if (requestPath(input).endsWith('/agent/model-provider-catalog')) {
|
|
191
|
+
return Promise.resolve(
|
|
192
|
+
jsonResponse(
|
|
193
|
+
modelProviderCatalogResponse([
|
|
194
|
+
modelProviderEntry({
|
|
195
|
+
id: 'openai',
|
|
196
|
+
label: 'OpenAI',
|
|
197
|
+
default_model: 'gpt-5.5-pro',
|
|
198
|
+
models: [{id: 'gpt-5.5-pro', label: 'GPT-5.5 Pro'}],
|
|
199
|
+
}),
|
|
200
|
+
]),
|
|
201
|
+
),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
return configsResponse;
|
|
205
|
+
});
|
|
206
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
207
|
+
|
|
208
|
+
renderModelProviders(<WorkspaceModelProvidersSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
|
|
209
|
+
|
|
210
|
+
expect(await screen.findByRole('status', {name: 'Loading available providers'})).toBeVisible();
|
|
211
|
+
expect(screen.queryByRole('button', {name: 'Configure OpenAI'})).not.toBeInTheDocument();
|
|
212
|
+
|
|
213
|
+
resolveConfigs(
|
|
214
|
+
jsonResponse(modelProviderConfigsResponse({configs: [], default_provider_id: null})),
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
expect(await screen.findByRole('button', {name: 'Configure OpenAI'})).toBeVisible();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('configures a provider, opens the usage modal, and moves focus back on close', async () => {
|
|
221
|
+
const user = userEvent.setup();
|
|
222
|
+
let isConfigured = false;
|
|
223
|
+
let requestBody: unknown;
|
|
224
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
225
|
+
const request = input as Request;
|
|
226
|
+
if (requestPath(input).endsWith('/agent/model-provider-catalog')) {
|
|
227
|
+
return Promise.resolve(
|
|
228
|
+
jsonResponse(
|
|
229
|
+
modelProviderCatalogResponse([
|
|
230
|
+
modelProviderEntry({
|
|
231
|
+
id: 'openai',
|
|
232
|
+
label: 'OpenAI',
|
|
233
|
+
default_model: 'gpt-5.5-pro',
|
|
234
|
+
models: [
|
|
235
|
+
{id: 'gpt-5.5-pro', label: 'GPT-5.5 Pro'},
|
|
236
|
+
{id: 'gpt-5-mini', label: 'GPT-5 Mini'},
|
|
237
|
+
],
|
|
238
|
+
}),
|
|
239
|
+
]),
|
|
240
|
+
),
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
if (request.method === 'PUT') {
|
|
244
|
+
isConfigured = true;
|
|
245
|
+
void request
|
|
246
|
+
.clone()
|
|
247
|
+
.json()
|
|
248
|
+
.then((body) => {
|
|
249
|
+
requestBody = body;
|
|
250
|
+
});
|
|
251
|
+
return Promise.resolve(
|
|
252
|
+
jsonResponse(
|
|
253
|
+
modelProviderConfig({
|
|
254
|
+
provider_id: 'openai',
|
|
255
|
+
default_model: 'gpt-5-mini',
|
|
256
|
+
}),
|
|
257
|
+
),
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return Promise.resolve(
|
|
261
|
+
jsonResponse(
|
|
262
|
+
modelProviderConfigsResponse({
|
|
263
|
+
configs: isConfigured
|
|
264
|
+
? [
|
|
265
|
+
modelProviderConfig({
|
|
266
|
+
provider_id: 'openai',
|
|
267
|
+
default_model: 'gpt-5-mini',
|
|
268
|
+
}),
|
|
269
|
+
]
|
|
270
|
+
: [],
|
|
271
|
+
default_provider_id: null,
|
|
272
|
+
}),
|
|
273
|
+
),
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
277
|
+
|
|
278
|
+
renderModelProviders(<WorkspaceModelProvidersSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
|
|
279
|
+
expect(await screen.findByText('No providers configured')).toBeVisible();
|
|
280
|
+
await user.click(screen.getByRole('button', {name: 'Configure OpenAI'}));
|
|
281
|
+
expect(await screen.findByLabelText('Default model')).toHaveValue('__latest__');
|
|
282
|
+
expect(screen.getByRole('option', {name: 'Latest'})).toBeVisible();
|
|
283
|
+
expect(
|
|
284
|
+
screen.getByText(
|
|
285
|
+
'Latest follows the provider catalog default. Currently resolves to GPT-5.5 Pro.',
|
|
286
|
+
),
|
|
287
|
+
).toBeVisible();
|
|
288
|
+
await user.selectOptions(screen.getByLabelText('Default model'), 'gpt-5-mini');
|
|
289
|
+
expect(
|
|
290
|
+
screen.queryByText(
|
|
291
|
+
'Latest follows the provider catalog default. Currently resolves to GPT-5.5 Pro.',
|
|
292
|
+
),
|
|
293
|
+
).not.toBeInTheDocument();
|
|
294
|
+
expect(screen.getByText('gpt-5-mini')).toBeVisible();
|
|
295
|
+
await user.type(await screen.findByLabelText('API key'), 'sk-proj-secret');
|
|
296
|
+
await user.click(screen.getByRole('button', {name: 'Test & save'}));
|
|
297
|
+
|
|
298
|
+
const usageDialog = await screen.findByRole('dialog', {name: 'Use OpenAI in a workflow'});
|
|
299
|
+
expect(within(usageDialog).getByText('model: gpt-5-mini')).toBeVisible();
|
|
300
|
+
await waitFor(() =>
|
|
301
|
+
expect(requestBody).toEqual({
|
|
302
|
+
default_model: 'gpt-5-mini',
|
|
303
|
+
credentials: {api_key: 'sk-proj-secret'},
|
|
304
|
+
}),
|
|
305
|
+
);
|
|
306
|
+
await user.click(within(usageDialog).getByRole('button', {name: 'Done'}));
|
|
307
|
+
|
|
308
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
309
|
+
await waitFor(() =>
|
|
310
|
+
expect(document.activeElement).toBe(
|
|
311
|
+
screen.getByRole('region', {name: 'Configured providers'}),
|
|
312
|
+
),
|
|
313
|
+
);
|
|
314
|
+
expect(await screen.findByText('OpenAI')).toBeVisible();
|
|
315
|
+
expect(screen.queryByText('GPT-5 Mini')).not.toBeInTheDocument();
|
|
316
|
+
expect(screen.queryByDisplayValue('sk-proj-secret')).not.toBeInTheDocument();
|
|
317
|
+
}, 10_000);
|
|
318
|
+
|
|
319
|
+
test('does not automatically open the usage modal after configuring an additional provider', async () => {
|
|
320
|
+
const user = userEvent.setup();
|
|
321
|
+
let isOpenAiConfigured = false;
|
|
322
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
323
|
+
const request = input as Request;
|
|
324
|
+
if (requestPath(input).endsWith('/agent/model-provider-catalog')) {
|
|
325
|
+
return Promise.resolve(
|
|
326
|
+
jsonResponse(
|
|
327
|
+
modelProviderCatalogResponse([
|
|
328
|
+
modelProviderEntry(),
|
|
329
|
+
modelProviderEntry({
|
|
330
|
+
id: 'openai',
|
|
331
|
+
label: 'OpenAI',
|
|
332
|
+
default_model: 'gpt-5.5-pro',
|
|
333
|
+
models: [{id: 'gpt-5.5-pro', label: 'GPT-5.5 Pro'}],
|
|
334
|
+
}),
|
|
335
|
+
]),
|
|
336
|
+
),
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
if (request.method === 'PUT') {
|
|
340
|
+
isOpenAiConfigured = true;
|
|
341
|
+
return Promise.resolve(
|
|
342
|
+
jsonResponse(
|
|
343
|
+
modelProviderConfig({
|
|
344
|
+
provider_id: 'openai',
|
|
345
|
+
default_model: 'gpt-5.5-pro',
|
|
346
|
+
}),
|
|
347
|
+
),
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
return Promise.resolve(
|
|
351
|
+
jsonResponse(
|
|
352
|
+
modelProviderConfigsResponse({
|
|
353
|
+
configs: [
|
|
354
|
+
modelProviderConfig(),
|
|
355
|
+
...(isOpenAiConfigured
|
|
356
|
+
? [
|
|
357
|
+
modelProviderConfig({
|
|
358
|
+
provider_id: 'openai',
|
|
359
|
+
default_model: 'gpt-5.5-pro',
|
|
360
|
+
}),
|
|
361
|
+
]
|
|
362
|
+
: []),
|
|
363
|
+
],
|
|
364
|
+
default_provider_id: 'anthropic',
|
|
365
|
+
}),
|
|
366
|
+
),
|
|
367
|
+
);
|
|
368
|
+
});
|
|
369
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
370
|
+
|
|
371
|
+
renderModelProviders(<WorkspaceModelProvidersSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
|
|
372
|
+
expect(await screen.findByText('Anthropic')).toBeVisible();
|
|
373
|
+
await user.click(screen.getByRole('button', {name: 'Configure OpenAI'}));
|
|
374
|
+
await user.type(await screen.findByLabelText('API key'), 'sk-proj-secret');
|
|
375
|
+
await user.click(screen.getByRole('button', {name: 'Test & save'}));
|
|
376
|
+
|
|
377
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
378
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
379
|
+
expect(
|
|
380
|
+
screen.queryByRole('dialog', {name: 'Use OpenAI in a workflow'}),
|
|
381
|
+
).not.toBeInTheDocument();
|
|
382
|
+
expect(await screen.findByText('OpenAI')).toBeVisible();
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test('submits null default model when configuring a provider with Latest selected', async () => {
|
|
386
|
+
const user = userEvent.setup();
|
|
387
|
+
let requestBody: unknown;
|
|
388
|
+
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
|
389
|
+
const request = input as Request;
|
|
390
|
+
if (requestPath(input).endsWith('/agent/model-provider-catalog')) {
|
|
391
|
+
return jsonResponse(
|
|
392
|
+
modelProviderCatalogResponse([
|
|
393
|
+
modelProviderEntry({
|
|
394
|
+
id: 'openai',
|
|
395
|
+
label: 'OpenAI',
|
|
396
|
+
default_model: 'gpt-5.5-pro',
|
|
397
|
+
models: [{id: 'gpt-5.5-pro', label: 'GPT-5.5 Pro'}],
|
|
398
|
+
}),
|
|
399
|
+
]),
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
if (request.method === 'PUT') {
|
|
403
|
+
requestBody = await request.clone().json();
|
|
404
|
+
return jsonResponse(
|
|
405
|
+
modelProviderConfig({
|
|
406
|
+
provider_id: 'openai',
|
|
407
|
+
default_model: null,
|
|
408
|
+
}),
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
return jsonResponse(modelProviderConfigsResponse({configs: [], default_provider_id: null}));
|
|
412
|
+
});
|
|
413
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
414
|
+
|
|
415
|
+
renderModelProviders(<WorkspaceModelProvidersSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
|
|
416
|
+
await user.click(await screen.findByRole('button', {name: 'Configure OpenAI'}));
|
|
417
|
+
await user.type(await screen.findByLabelText('API key'), 'sk-proj-secret');
|
|
418
|
+
await user.click(screen.getByRole('button', {name: 'Test & save'}));
|
|
419
|
+
|
|
420
|
+
await waitFor(() =>
|
|
421
|
+
expect(requestBody).toEqual({
|
|
422
|
+
default_model: null,
|
|
423
|
+
credentials: {api_key: 'sk-proj-secret'},
|
|
424
|
+
}),
|
|
425
|
+
);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
test('shows current fingerprints without echoing secret values while editing', async () => {
|
|
429
|
+
const user = userEvent.setup();
|
|
430
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
431
|
+
if (requestPath(input).endsWith('/agent/model-provider-catalog')) {
|
|
432
|
+
return Promise.resolve(jsonResponse(modelProviderCatalogResponse()));
|
|
433
|
+
}
|
|
434
|
+
return Promise.resolve(jsonResponse(modelProviderConfigsResponse()));
|
|
435
|
+
});
|
|
436
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
437
|
+
|
|
438
|
+
renderModelProviders(<WorkspaceModelProvidersSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
|
|
439
|
+
expect(await screen.findByText('Anthropic')).toBeVisible();
|
|
440
|
+
await openProviderActions(user, 'Anthropic');
|
|
441
|
+
await user.click(screen.getByRole('menuitem', {name: 'Edit credentials'}));
|
|
442
|
+
|
|
443
|
+
await waitFor(() =>
|
|
444
|
+
expect(screen.getAllByText('Edit credentials for Anthropic').length).toBeGreaterThan(0),
|
|
445
|
+
);
|
|
446
|
+
expect(screen.queryByLabelText('Default model')).not.toBeInTheDocument();
|
|
447
|
+
expect(screen.getByLabelText('API key')).toHaveValue('');
|
|
448
|
+
expect(screen.queryByDisplayValue('sk-ant-secret')).not.toBeInTheDocument();
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
test('edits credentials without submitting default model fields', async () => {
|
|
452
|
+
const user = userEvent.setup();
|
|
453
|
+
let requestBody: unknown;
|
|
454
|
+
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
|
455
|
+
const request = input as Request;
|
|
456
|
+
if (requestPath(input).endsWith('/agent/model-provider-catalog')) {
|
|
457
|
+
return jsonResponse(
|
|
458
|
+
modelProviderCatalogResponse([
|
|
459
|
+
modelProviderEntry({
|
|
460
|
+
default_model: 'claude-opus-4-8',
|
|
461
|
+
models: [
|
|
462
|
+
{id: 'claude-opus-4-8', label: 'Claude Opus 4.8'},
|
|
463
|
+
{id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5'},
|
|
464
|
+
],
|
|
465
|
+
}),
|
|
466
|
+
]),
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
if (
|
|
470
|
+
requestPath(input).endsWith('/agent/model-providers/anthropic') &&
|
|
471
|
+
request.method === 'PUT'
|
|
472
|
+
) {
|
|
473
|
+
requestBody = await request.clone().json();
|
|
474
|
+
return jsonResponse(modelProviderConfig({default_model: 'claude-haiku-4-5'}));
|
|
475
|
+
}
|
|
476
|
+
return jsonResponse(
|
|
477
|
+
modelProviderConfigsResponse({
|
|
478
|
+
configs: [modelProviderConfig({default_model: 'claude-haiku-4-5'})],
|
|
479
|
+
default_provider_id: 'anthropic',
|
|
480
|
+
}),
|
|
481
|
+
);
|
|
482
|
+
});
|
|
483
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
484
|
+
|
|
485
|
+
renderModelProviders(<WorkspaceModelProvidersSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
|
|
486
|
+
expect(await screen.findByText('Anthropic')).toBeVisible();
|
|
487
|
+
await openProviderActions(user, 'Anthropic');
|
|
488
|
+
await user.click(screen.getByRole('menuitem', {name: 'Edit credentials'}));
|
|
489
|
+
await waitFor(() =>
|
|
490
|
+
expect(screen.getAllByText('Edit credentials for Anthropic').length).toBeGreaterThan(0),
|
|
491
|
+
);
|
|
492
|
+
expect(screen.queryByLabelText('Default model')).not.toBeInTheDocument();
|
|
493
|
+
await user.type(screen.getByLabelText('API key'), 'sk-ant-rotated');
|
|
494
|
+
await user.click(screen.getByRole('button', {name: 'Test & save'}));
|
|
495
|
+
|
|
496
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
497
|
+
expect(
|
|
498
|
+
screen.queryByRole('dialog', {name: 'Use Anthropic in a workflow'}),
|
|
499
|
+
).not.toBeInTheDocument();
|
|
500
|
+
await waitFor(() => expect(requestBody).toEqual({credentials: {api_key: 'sk-ant-rotated'}}));
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test('opens the usage modal from a configured provider action', async () => {
|
|
504
|
+
const user = userEvent.setup();
|
|
505
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
506
|
+
if (requestPath(input).endsWith('/agent/model-provider-catalog')) {
|
|
507
|
+
return Promise.resolve(
|
|
508
|
+
jsonResponse(
|
|
509
|
+
modelProviderCatalogResponse([
|
|
510
|
+
modelProviderEntry({
|
|
511
|
+
default_model: 'claude-opus-4-8',
|
|
512
|
+
models: [
|
|
513
|
+
{id: 'claude-opus-4-8', label: 'Claude Opus 4.8'},
|
|
514
|
+
{id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5'},
|
|
515
|
+
],
|
|
516
|
+
}),
|
|
517
|
+
]),
|
|
518
|
+
),
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
return Promise.resolve(
|
|
522
|
+
jsonResponse(
|
|
523
|
+
modelProviderConfigsResponse({
|
|
524
|
+
configs: [modelProviderConfig({default_model: 'claude-haiku-4-5'})],
|
|
525
|
+
default_provider_id: 'anthropic',
|
|
526
|
+
}),
|
|
527
|
+
),
|
|
528
|
+
);
|
|
529
|
+
});
|
|
530
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
531
|
+
|
|
532
|
+
renderModelProviders(<WorkspaceModelProvidersSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
|
|
533
|
+
expect(await screen.findByText('Anthropic')).toBeVisible();
|
|
534
|
+
await openProviderActions(user, 'Anthropic');
|
|
535
|
+
await user.click(screen.getByRole('menuitem', {name: 'View workflow example'}));
|
|
536
|
+
|
|
537
|
+
const usageDialog = await screen.findByRole('dialog', {name: 'Use Anthropic in a workflow'});
|
|
538
|
+
expect(usageDialog).toHaveTextContent('model: claude-haiku-4-5');
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test('surfaces provider validation errors without clearing the form', async () => {
|
|
542
|
+
const user = userEvent.setup();
|
|
543
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
544
|
+
const request = input as Request;
|
|
545
|
+
if (requestPath(input).endsWith('/agent/model-provider-catalog')) {
|
|
546
|
+
return Promise.resolve(jsonResponse(modelProviderCatalogResponse()));
|
|
547
|
+
}
|
|
548
|
+
if (request.method === 'PUT') {
|
|
549
|
+
return Promise.resolve(
|
|
550
|
+
jsonResponse(
|
|
551
|
+
{
|
|
552
|
+
code: 'provider-validation-failed',
|
|
553
|
+
message: 'Provider validation failed',
|
|
554
|
+
details: {
|
|
555
|
+
provider_id: 'anthropic',
|
|
556
|
+
message: 'Provider rejected the key.',
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
{status: 422},
|
|
560
|
+
),
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
return Promise.resolve(
|
|
564
|
+
jsonResponse(modelProviderConfigsResponse({configs: [], default_provider_id: null})),
|
|
565
|
+
);
|
|
566
|
+
});
|
|
567
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
568
|
+
|
|
569
|
+
renderModelProviders(<WorkspaceModelProvidersSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
|
|
570
|
+
await screen.findByText('No providers configured');
|
|
571
|
+
await user.click(screen.getByRole('button', {name: 'Configure Anthropic'}));
|
|
572
|
+
await user.type(await screen.findByLabelText('API key'), 'sk-ant-secret');
|
|
573
|
+
await user.click(screen.getByRole('button', {name: 'Test & save'}));
|
|
574
|
+
|
|
575
|
+
expect(await screen.findByText('Could not save provider')).toBeVisible();
|
|
576
|
+
expect(screen.getByText('Provider rejected the key.')).toBeVisible();
|
|
577
|
+
expect(screen.getByLabelText('API key')).toHaveValue('sk-ant-secret');
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
test('sets a configured provider as the default', async () => {
|
|
581
|
+
const user = userEvent.setup();
|
|
582
|
+
let defaultProviderId: 'anthropic' | 'openai' = 'openai';
|
|
583
|
+
let requestBody: unknown;
|
|
584
|
+
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
|
585
|
+
const request = input as Request;
|
|
586
|
+
if (requestPath(input).endsWith('/agent/model-provider-catalog')) {
|
|
587
|
+
return jsonResponse(
|
|
588
|
+
modelProviderCatalogResponse([
|
|
589
|
+
modelProviderEntry(),
|
|
590
|
+
modelProviderEntry({
|
|
591
|
+
id: 'openai',
|
|
592
|
+
label: 'OpenAI',
|
|
593
|
+
default_model: 'gpt-5.5-pro',
|
|
594
|
+
models: [{id: 'gpt-5.5-pro', label: 'GPT-5.5 Pro'}],
|
|
595
|
+
}),
|
|
596
|
+
]),
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
if (requestPath(input).endsWith('/agent/default-model-provider')) {
|
|
600
|
+
requestBody = await request.clone().json();
|
|
601
|
+
defaultProviderId = 'anthropic';
|
|
602
|
+
return jsonResponse({default_provider_id: 'anthropic'});
|
|
603
|
+
}
|
|
604
|
+
return jsonResponse(
|
|
605
|
+
modelProviderConfigsResponse({
|
|
606
|
+
configs: [
|
|
607
|
+
modelProviderConfig(),
|
|
608
|
+
modelProviderConfig({
|
|
609
|
+
provider_id: 'openai',
|
|
610
|
+
}),
|
|
611
|
+
],
|
|
612
|
+
default_provider_id: defaultProviderId,
|
|
613
|
+
}),
|
|
614
|
+
);
|
|
615
|
+
});
|
|
616
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
617
|
+
|
|
618
|
+
renderModelProviders(<WorkspaceModelProvidersSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
|
|
619
|
+
expect(await screen.findByText('Anthropic')).toBeVisible();
|
|
620
|
+
await openProviderActions(user, 'Anthropic');
|
|
621
|
+
await user.click(screen.getByRole('menuitem', {name: 'Set as default'}));
|
|
622
|
+
|
|
623
|
+
await waitFor(() => expect(requestBody).toEqual({provider_id: 'anthropic'}));
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
test('changes a configured provider default model without credentials', async () => {
|
|
627
|
+
const user = userEvent.setup();
|
|
628
|
+
let requestBody: unknown;
|
|
629
|
+
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
|
|
630
|
+
const request = input as Request;
|
|
631
|
+
if (requestPath(input).endsWith('/agent/model-provider-catalog')) {
|
|
632
|
+
return jsonResponse(
|
|
633
|
+
modelProviderCatalogResponse([
|
|
634
|
+
modelProviderEntry({
|
|
635
|
+
default_model: 'claude-opus-4-8',
|
|
636
|
+
models: [
|
|
637
|
+
{id: 'claude-opus-4-8', label: 'Claude Opus 4.8'},
|
|
638
|
+
{id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5'},
|
|
639
|
+
],
|
|
640
|
+
}),
|
|
641
|
+
]),
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
if (requestPath(input).endsWith('/agent/model-providers/anthropic/default-model')) {
|
|
645
|
+
requestBody = await request.clone().json();
|
|
646
|
+
return jsonResponse(modelProviderConfig({default_model: null}));
|
|
647
|
+
}
|
|
648
|
+
return jsonResponse(
|
|
649
|
+
modelProviderConfigsResponse({
|
|
650
|
+
configs: [modelProviderConfig({default_model: 'claude-haiku-4-5'})],
|
|
651
|
+
default_provider_id: 'anthropic',
|
|
652
|
+
}),
|
|
653
|
+
);
|
|
654
|
+
});
|
|
655
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
656
|
+
|
|
657
|
+
renderModelProviders(<WorkspaceModelProvidersSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
|
|
658
|
+
expect(await screen.findByText('Anthropic')).toBeVisible();
|
|
659
|
+
await openProviderActions(user, 'Anthropic');
|
|
660
|
+
await user.click(screen.getByRole('menuitem', {name: 'Change default model'}));
|
|
661
|
+
expect(await screen.findByLabelText('Default model')).toHaveValue('claude-haiku-4-5');
|
|
662
|
+
await user.selectOptions(screen.getByLabelText('Default model'), '__latest__');
|
|
663
|
+
expect(
|
|
664
|
+
screen.getByText(
|
|
665
|
+
'Latest follows the provider catalog default. Currently resolves to Claude Opus 4.8.',
|
|
666
|
+
),
|
|
667
|
+
).toBeVisible();
|
|
668
|
+
await user.click(screen.getByRole('button', {name: 'Save model'}));
|
|
669
|
+
|
|
670
|
+
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
|
671
|
+
await waitFor(() => expect(requestBody).toEqual({default_model: null}));
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test('shows a recoverable set-default error', async () => {
|
|
675
|
+
const user = userEvent.setup();
|
|
676
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
677
|
+
if (requestPath(input).endsWith('/agent/model-provider-catalog')) {
|
|
678
|
+
return Promise.resolve(
|
|
679
|
+
jsonResponse(
|
|
680
|
+
modelProviderCatalogResponse([
|
|
681
|
+
modelProviderEntry(),
|
|
682
|
+
modelProviderEntry({
|
|
683
|
+
id: 'openai',
|
|
684
|
+
label: 'OpenAI',
|
|
685
|
+
default_model: 'gpt-5.5-pro',
|
|
686
|
+
models: [{id: 'gpt-5.5-pro', label: 'GPT-5.5 Pro'}],
|
|
687
|
+
}),
|
|
688
|
+
]),
|
|
689
|
+
),
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
if (requestPath(input).endsWith('/agent/default-model-provider')) {
|
|
693
|
+
return Promise.resolve(
|
|
694
|
+
jsonResponse(
|
|
695
|
+
{code: 'provider-not-configured', message: 'Provider is not configured'},
|
|
696
|
+
{status: 422},
|
|
697
|
+
),
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
return Promise.resolve(
|
|
701
|
+
jsonResponse(
|
|
702
|
+
modelProviderConfigsResponse({
|
|
703
|
+
configs: [
|
|
704
|
+
modelProviderConfig(),
|
|
705
|
+
modelProviderConfig({
|
|
706
|
+
provider_id: 'openai',
|
|
707
|
+
}),
|
|
708
|
+
],
|
|
709
|
+
default_provider_id: 'openai',
|
|
710
|
+
}),
|
|
711
|
+
),
|
|
712
|
+
);
|
|
713
|
+
});
|
|
714
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
715
|
+
|
|
716
|
+
renderModelProviders(<WorkspaceModelProvidersSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
|
|
717
|
+
expect(await screen.findByText('Anthropic')).toBeVisible();
|
|
718
|
+
await openProviderActions(user, 'Anthropic');
|
|
719
|
+
await user.click(screen.getByRole('menuitem', {name: 'Set as default'}));
|
|
720
|
+
|
|
721
|
+
expect(
|
|
722
|
+
await screen.findByText('Configure this provider before setting it as the default.'),
|
|
723
|
+
).toBeVisible();
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
test('deletes a configured provider after confirmation', async () => {
|
|
727
|
+
const user = userEvent.setup();
|
|
728
|
+
let isDeleted = false;
|
|
729
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
730
|
+
const request = input as Request;
|
|
731
|
+
if (requestPath(input).endsWith('/agent/model-provider-catalog')) {
|
|
732
|
+
return Promise.resolve(jsonResponse(modelProviderCatalogResponse()));
|
|
733
|
+
}
|
|
734
|
+
if (request.method === 'DELETE') {
|
|
735
|
+
isDeleted = true;
|
|
736
|
+
return Promise.resolve(new Response(null, {status: 204}));
|
|
737
|
+
}
|
|
738
|
+
return Promise.resolve(
|
|
739
|
+
jsonResponse(
|
|
740
|
+
modelProviderConfigsResponse({
|
|
741
|
+
configs: isDeleted ? [] : [modelProviderConfig()],
|
|
742
|
+
default_provider_id: isDeleted ? null : 'anthropic',
|
|
743
|
+
}),
|
|
744
|
+
),
|
|
745
|
+
);
|
|
746
|
+
});
|
|
747
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
748
|
+
|
|
749
|
+
renderModelProviders(<WorkspaceModelProvidersSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
|
|
750
|
+
expect(await screen.findByText('Anthropic')).toBeVisible();
|
|
751
|
+
await openProviderActions(user, 'Anthropic');
|
|
752
|
+
await user.click(screen.getByRole('menuitem', {name: 'Delete'}));
|
|
753
|
+
await user.click(screen.getByRole('button', {name: 'Delete'}));
|
|
754
|
+
|
|
755
|
+
expect(await screen.findByText('No providers configured')).toBeVisible();
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
test('deletes a configured provider that is missing from the catalog', async () => {
|
|
759
|
+
const user = userEvent.setup();
|
|
760
|
+
let isDeleted = false;
|
|
761
|
+
let deletedPath: string | undefined;
|
|
762
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
763
|
+
const request = input as Request;
|
|
764
|
+
if (requestPath(input).endsWith('/agent/model-provider-catalog')) {
|
|
765
|
+
return Promise.resolve(jsonResponse(modelProviderCatalogResponse([])));
|
|
766
|
+
}
|
|
767
|
+
if (request.method === 'DELETE') {
|
|
768
|
+
isDeleted = true;
|
|
769
|
+
deletedPath = requestPath(input);
|
|
770
|
+
return Promise.resolve(new Response(null, {status: 204}));
|
|
771
|
+
}
|
|
772
|
+
return Promise.resolve(
|
|
773
|
+
jsonResponse(
|
|
774
|
+
modelProviderConfigsResponse({
|
|
775
|
+
configs: isDeleted
|
|
776
|
+
? []
|
|
777
|
+
: [
|
|
778
|
+
modelProviderConfig({
|
|
779
|
+
provider_id: 'local-vllm',
|
|
780
|
+
}),
|
|
781
|
+
],
|
|
782
|
+
default_provider_id: null,
|
|
783
|
+
}),
|
|
784
|
+
),
|
|
785
|
+
);
|
|
786
|
+
});
|
|
787
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
788
|
+
|
|
789
|
+
renderModelProviders(<WorkspaceModelProvidersSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
|
|
790
|
+
expect(await screen.findByText('local-vllm')).toBeVisible();
|
|
791
|
+
await openProviderActions(user, 'local-vllm');
|
|
792
|
+
await user.click(screen.getByRole('menuitem', {name: 'Delete'}));
|
|
793
|
+
await user.click(screen.getByRole('button', {name: 'Delete'}));
|
|
794
|
+
|
|
795
|
+
await waitFor(() =>
|
|
796
|
+
expect(deletedPath).toBe(
|
|
797
|
+
`/workspaces/${AGENT_TEST_WORKSPACE_ID}/agent/model-providers/local-vllm`,
|
|
798
|
+
),
|
|
799
|
+
);
|
|
800
|
+
expect(await screen.findByText('No providers configured')).toBeVisible();
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
test('disables catalog-backed actions when a configured provider is missing from the catalog', async () => {
|
|
804
|
+
const user = userEvent.setup();
|
|
805
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
806
|
+
if (requestPath(input).endsWith('/agent/model-provider-catalog')) {
|
|
807
|
+
return Promise.resolve(jsonResponse(modelProviderCatalogResponse([])));
|
|
808
|
+
}
|
|
809
|
+
return Promise.resolve(
|
|
810
|
+
jsonResponse(modelProviderConfigsResponse({default_provider_id: null})),
|
|
811
|
+
);
|
|
812
|
+
});
|
|
813
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
814
|
+
|
|
815
|
+
renderModelProviders(<WorkspaceModelProvidersSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
|
|
816
|
+
|
|
817
|
+
expect(await screen.findByText('anthropic')).toBeVisible();
|
|
818
|
+
await openProviderActions(user, 'anthropic');
|
|
819
|
+
expect(screen.getByRole('menuitem', {name: 'Set as default'})).toHaveAttribute('data-disabled');
|
|
820
|
+
expect(screen.getByRole('menuitem', {name: 'Change default model'})).toHaveAttribute(
|
|
821
|
+
'data-disabled',
|
|
822
|
+
);
|
|
823
|
+
expect(screen.getByRole('menuitem', {name: 'View workflow example'})).toHaveAttribute(
|
|
824
|
+
'data-disabled',
|
|
825
|
+
);
|
|
826
|
+
expect(screen.getByRole('menuitem', {name: 'Edit credentials'})).toHaveAttribute(
|
|
827
|
+
'data-disabled',
|
|
828
|
+
);
|
|
829
|
+
expect(screen.getByRole('menuitem', {name: 'Delete'})).not.toHaveAttribute('data-disabled');
|
|
830
|
+
});
|
|
831
|
+
});
|