@shipfox/client-agent 22.0.1 → 22.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.storybook/preview.tsx +2 -2
- package/.turbo/turbo-build.log +1 -1
- package/CHANGELOG.md +28 -0
- package/dist/components/form-errors.d.ts.map +1 -1
- package/dist/components/form-errors.js +3 -0
- package/dist/components/form-errors.js.map +1 -1
- package/dist/components/harness-availability.d.ts +2 -2
- package/dist/components/harness-availability.d.ts.map +1 -1
- package/dist/components/harness-availability.js +4 -3
- package/dist/components/harness-availability.js.map +1 -1
- package/dist/components/harnesses-section.d.ts.map +1 -1
- package/dist/components/harnesses-section.js +4 -2
- package/dist/components/harnesses-section.js.map +1 -1
- package/dist/components/model-provider-usage-modal.d.ts.map +1 -1
- package/dist/components/model-provider-usage-modal.js +23 -5
- package/dist/components/model-provider-usage-modal.js.map +1 -1
- package/dist/components/model-provider-usage-target.d.ts +6 -6
- package/dist/components/model-provider-usage-target.d.ts.map +1 -1
- package/dist/components/model-provider-usage-target.js +3 -1
- package/dist/components/model-provider-usage-target.js.map +1 -1
- package/dist/components/model-providers-section.d.ts.map +1 -1
- package/dist/components/model-providers-section.js +277 -170
- package/dist/components/model-providers-section.js.map +1 -1
- package/dist/components/model-providers-section.stories.d.ts +2 -1
- package/dist/components/model-providers-section.stories.d.ts.map +1 -1
- package/dist/components/model-providers-section.stories.js +45 -1
- package/dist/components/model-providers-section.stories.js.map +1 -1
- package/dist/core/harness-policy.d.ts +5 -2
- package/dist/core/harness-policy.d.ts.map +1 -1
- package/dist/core/harness-policy.js +7 -1
- package/dist/core/harness-policy.js.map +1 -1
- package/dist/core/models.d.ts +3 -0
- package/dist/core/models.d.ts.map +1 -1
- package/dist/core/models.js.map +1 -1
- package/dist/core/provider-policy.d.ts +3 -1
- package/dist/core/provider-policy.d.ts.map +1 -1
- package/dist/core/provider-policy.js +7 -0
- package/dist/core/provider-policy.js.map +1 -1
- package/dist/hooks/api/model-provider-mapper.d.ts.map +1 -1
- package/dist/hooks/api/model-provider-mapper.js +5 -1
- package/dist/hooks/api/model-provider-mapper.js.map +1 -1
- package/dist/pages/model-provider-onboarding-page.d.ts +1 -1
- package/dist/pages/model-provider-onboarding-page.d.ts.map +1 -1
- package/dist/pages/model-provider-onboarding-page.js +58 -44
- package/dist/pages/model-provider-onboarding-page.js.map +1 -1
- package/dist/pages/model-provider-onboarding-page.stories.d.ts.map +1 -1
- package/dist/pages/model-provider-onboarding-page.stories.js +5 -17
- package/dist/pages/model-provider-onboarding-page.stories.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/src/components/form-errors.test.ts +9 -0
- package/src/components/form-errors.ts +3 -0
- package/src/components/harness-availability.test.ts +49 -2
- package/src/components/harness-availability.ts +18 -3
- package/src/components/harnesses-section.test.tsx +51 -9
- package/src/components/harnesses-section.tsx +4 -1
- package/src/components/model-provider-usage-modal.test.tsx +23 -0
- package/src/components/model-provider-usage-modal.tsx +32 -6
- package/src/components/model-provider-usage-target.ts +9 -3
- package/src/components/model-providers-section.stories.tsx +33 -1
- package/src/components/model-providers-section.test.tsx +41 -0
- package/src/components/model-providers-section.tsx +264 -155
- package/src/core/harness-policy.ts +21 -1
- package/src/core/models.ts +3 -0
- package/src/core/provider-policy.ts +12 -0
- package/src/hooks/api/model-provider-mapper.test.ts +18 -0
- package/src/hooks/api/model-provider-mapper.ts +6 -1
- package/src/hooks/api/model-providers.test.ts +11 -0
- package/src/pages/model-provider-onboarding-page.stories.tsx +10 -14
- package/src/pages/model-provider-onboarding-page.test.tsx +133 -24
- package/src/pages/model-provider-onboarding-page.tsx +47 -34
- package/test/fixtures/model-providers.ts +22 -1
- package/tsconfig.build.tsbuildinfo +1 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipfox/client-agent",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "22.0.
|
|
4
|
+
"version": "22.0.3",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/ShipfoxHQ/shipfox.git",
|
|
@@ -28,11 +28,11 @@
|
|
|
28
28
|
"@swc/helpers": "^0.5.17",
|
|
29
29
|
"@tanstack/react-form": "^1.32.0",
|
|
30
30
|
"@tanstack/react-query": "^5.101.0",
|
|
31
|
-
"@shipfox/api-agent-dto": "
|
|
31
|
+
"@shipfox/api-agent-dto": "13.1.0",
|
|
32
32
|
"@shipfox/client-api": "6.0.1",
|
|
33
|
-
"@shipfox/client-shell": "22.0.
|
|
34
|
-
"@shipfox/client-ui": "22.0.
|
|
35
|
-
"@shipfox/react-ui": "2.
|
|
33
|
+
"@shipfox/client-shell": "22.0.3",
|
|
34
|
+
"@shipfox/client-ui": "22.0.3",
|
|
35
|
+
"@shipfox/react-ui": "2.2.0"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"@tanstack/react-query": "^5.101.0",
|
|
@@ -80,6 +80,15 @@ describe('modelProviderConfigErrorToFormError', () => {
|
|
|
80
80
|
}),
|
|
81
81
|
'Configure this provider before setting it as the default.',
|
|
82
82
|
],
|
|
83
|
+
[
|
|
84
|
+
new ApiError({
|
|
85
|
+
code: 'workspace-providers-disabled',
|
|
86
|
+
message: 'Provider policy rejected the request.',
|
|
87
|
+
status: 422,
|
|
88
|
+
details: {message: 'This instance only supports provider `shipfox`.'},
|
|
89
|
+
}),
|
|
90
|
+
'This instance only supports provider `shipfox`.',
|
|
91
|
+
],
|
|
83
92
|
[
|
|
84
93
|
new ApiError({code: 'not-found', message: 'Provider config not found', status: 404}),
|
|
85
94
|
'This custom provider no longer exists. Refresh and try again.',
|
|
@@ -76,6 +76,9 @@ function apiErrorMessage(error: ApiError): string {
|
|
|
76
76
|
if (error.code === 'provider-not-configured') {
|
|
77
77
|
return 'Configure this provider before setting it as the default.';
|
|
78
78
|
}
|
|
79
|
+
if (error.code === 'workspace-providers-disabled') {
|
|
80
|
+
return typeof details.message === 'string' ? details.message : error.message;
|
|
81
|
+
}
|
|
79
82
|
return error.message;
|
|
80
83
|
}
|
|
81
84
|
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import {getHarness} from '#core/harness-policy.js';
|
|
2
|
-
import {toProviderConfig} from '#hooks/api/model-provider-mapper.js';
|
|
3
|
-
import {
|
|
2
|
+
import {toProviderCatalog, toProviderConfig} from '#hooks/api/model-provider-mapper.js';
|
|
3
|
+
import {
|
|
4
|
+
customModelProviderConfig,
|
|
5
|
+
managedModelProviderEntry,
|
|
6
|
+
modelProviderCatalogResponse,
|
|
7
|
+
modelProviderConfig,
|
|
8
|
+
} from '#test/fixtures/model-providers.js';
|
|
4
9
|
import {compatibleHarnessIds, isHarnessAvailable} from './harness-availability.js';
|
|
5
10
|
|
|
6
11
|
describe('harness availability', () => {
|
|
@@ -37,6 +42,32 @@ describe('harness availability', () => {
|
|
|
37
42
|
expect(isHarnessAvailable(getHarness('claude'), [])).toBe(false);
|
|
38
43
|
});
|
|
39
44
|
|
|
45
|
+
test('marks harnesses available from a managed-only catalog without workspace configs', () => {
|
|
46
|
+
const catalog = toProviderCatalog(
|
|
47
|
+
modelProviderCatalogResponse([managedModelProviderEntry()], 'disabled'),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(isHarnessAvailable(getHarness('pi'), [], catalog)).toBe(true);
|
|
51
|
+
expect(isHarnessAvailable(getHarness('claude'), [], catalog)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('does not mark Claude available when managed models do not support Anthropic messages', () => {
|
|
55
|
+
const catalog = toProviderCatalog(
|
|
56
|
+
modelProviderCatalogResponse(
|
|
57
|
+
[
|
|
58
|
+
managedModelProviderEntry({
|
|
59
|
+
default_model: 'gpt-5.5-pro',
|
|
60
|
+
models: [{id: 'gpt-5.5-pro', label: 'GPT-5.5 Pro', api: 'openai-responses'}],
|
|
61
|
+
}),
|
|
62
|
+
],
|
|
63
|
+
'disabled',
|
|
64
|
+
),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
expect(isHarnessAvailable(getHarness('pi'), [], catalog)).toBe(true);
|
|
68
|
+
expect(isHarnessAvailable(getHarness('claude'), [], catalog)).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
40
71
|
test('returns compatible harnesses for builtin and custom providers', () => {
|
|
41
72
|
expect(compatibleHarnessIds({isCustom: false, providerId: 'anthropic'})).toEqual([
|
|
42
73
|
'pi',
|
|
@@ -44,5 +75,21 @@ describe('harness availability', () => {
|
|
|
44
75
|
]);
|
|
45
76
|
expect(compatibleHarnessIds({isCustom: false, providerId: 'openai'})).toEqual(['pi']);
|
|
46
77
|
expect(compatibleHarnessIds({isCustom: true, providerId: 'custom-provider'})).toEqual(['pi']);
|
|
78
|
+
expect(
|
|
79
|
+
compatibleHarnessIds({
|
|
80
|
+
isCustom: false,
|
|
81
|
+
isManaged: true,
|
|
82
|
+
models: [{api: 'openai-responses'}, {api: 'anthropic-messages'}],
|
|
83
|
+
providerId: 'shipfox',
|
|
84
|
+
}),
|
|
85
|
+
).toEqual(['pi', 'claude']);
|
|
86
|
+
expect(
|
|
87
|
+
compatibleHarnessIds({
|
|
88
|
+
isCustom: false,
|
|
89
|
+
isManaged: true,
|
|
90
|
+
models: [{api: 'openai-responses'}],
|
|
91
|
+
providerId: 'shipfox',
|
|
92
|
+
}),
|
|
93
|
+
).toEqual(['pi']);
|
|
47
94
|
});
|
|
48
95
|
});
|
|
@@ -1,11 +1,26 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
compatibleHarnessIds,
|
|
3
|
+
configSupportsHarness,
|
|
4
|
+
managedModelSupportsHarness,
|
|
5
|
+
} from '#core/harness-policy.js';
|
|
6
|
+
import type {HarnessDescriptor, ProviderCatalog, ProviderConfig} from '#core/models.js';
|
|
7
|
+
import {isManagedOnlyCatalog, isSupportedProvider} from '#core/provider-policy.js';
|
|
3
8
|
|
|
4
9
|
export function isHarnessAvailable(
|
|
5
10
|
descriptor: HarnessDescriptor,
|
|
6
11
|
configs: readonly ProviderConfig[],
|
|
12
|
+
catalog?: ProviderCatalog,
|
|
7
13
|
): boolean {
|
|
8
|
-
return
|
|
14
|
+
return (
|
|
15
|
+
configs.some((config) => configSupportsHarness(config, descriptor)) ||
|
|
16
|
+
(isManagedOnlyCatalog(catalog) &&
|
|
17
|
+
(catalog?.providers.some(
|
|
18
|
+
(provider) =>
|
|
19
|
+
isSupportedProvider(provider) &&
|
|
20
|
+
provider.models.some((model) => managedModelSupportsHarness(descriptor.id, model)),
|
|
21
|
+
) ??
|
|
22
|
+
false))
|
|
23
|
+
);
|
|
9
24
|
}
|
|
10
25
|
|
|
11
26
|
export {compatibleHarnessIds};
|
|
@@ -6,6 +6,7 @@ import userEvent from '@testing-library/user-event';
|
|
|
6
6
|
import type {ReactElement} from 'react';
|
|
7
7
|
import {
|
|
8
8
|
AGENT_TEST_WORKSPACE_ID,
|
|
9
|
+
modelProviderCatalogResponse,
|
|
9
10
|
modelProviderConfig,
|
|
10
11
|
modelProviderConfigsResponse,
|
|
11
12
|
} from '#test/fixtures/model-providers.js';
|
|
@@ -19,6 +20,13 @@ function jsonResponse(body: unknown, init: ResponseInit = {}) {
|
|
|
19
20
|
});
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
function harnessRequestResponse(input: RequestInfo | URL, body: unknown) {
|
|
24
|
+
const url = new URL(input instanceof Request ? input.url : input.toString());
|
|
25
|
+
return url.pathname.endsWith('/agent/model-provider-catalog')
|
|
26
|
+
? jsonResponse(modelProviderCatalogResponse())
|
|
27
|
+
: jsonResponse(body);
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
function renderHarnesses(element: ReactElement) {
|
|
23
31
|
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}});
|
|
24
32
|
const result = render(
|
|
@@ -48,7 +56,11 @@ describe('WorkspaceHarnessesSection', () => {
|
|
|
48
56
|
const user = userEvent.setup();
|
|
49
57
|
configureApiClient({
|
|
50
58
|
baseUrl: 'https://api.example.test',
|
|
51
|
-
fetchImpl: vi
|
|
59
|
+
fetchImpl: vi
|
|
60
|
+
.fn()
|
|
61
|
+
.mockImplementation((input: RequestInfo | URL) =>
|
|
62
|
+
Promise.resolve(harnessRequestResponse(input, modelProviderConfigsResponse())),
|
|
63
|
+
),
|
|
52
64
|
});
|
|
53
65
|
|
|
54
66
|
renderHarnesses(<WorkspaceHarnessesSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
|
|
@@ -69,11 +81,14 @@ describe('WorkspaceHarnessesSection', () => {
|
|
|
69
81
|
const user = userEvent.setup();
|
|
70
82
|
configureApiClient({
|
|
71
83
|
baseUrl: 'https://api.example.test',
|
|
72
|
-
fetchImpl: vi.fn().
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
84
|
+
fetchImpl: vi.fn().mockImplementation((input: RequestInfo | URL) =>
|
|
85
|
+
Promise.resolve(
|
|
86
|
+
harnessRequestResponse(
|
|
87
|
+
input,
|
|
88
|
+
modelProviderConfigsResponse({
|
|
89
|
+
configs: [modelProviderConfig({provider_id: 'openai'})],
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
77
92
|
),
|
|
78
93
|
),
|
|
79
94
|
});
|
|
@@ -91,6 +106,33 @@ describe('WorkspaceHarnessesSection', () => {
|
|
|
91
106
|
expect(screen.getByRole('menuitem', {name: 'Set as default'})).toHaveAttribute('data-disabled');
|
|
92
107
|
});
|
|
93
108
|
|
|
109
|
+
test('keeps harnesses unavailable when the catalog fails and no providers are configured', async () => {
|
|
110
|
+
const user = userEvent.setup();
|
|
111
|
+
configureApiClient({
|
|
112
|
+
baseUrl: 'https://api.example.test',
|
|
113
|
+
fetchImpl: vi.fn().mockImplementation((input: RequestInfo | URL) => {
|
|
114
|
+
const url = new URL(input instanceof Request ? input.url : input.toString());
|
|
115
|
+
if (url.pathname.endsWith('/agent/model-provider-catalog')) {
|
|
116
|
+
return Promise.resolve(jsonResponse({code: 'server-error'}, {status: 500}));
|
|
117
|
+
}
|
|
118
|
+
return Promise.resolve(
|
|
119
|
+
harnessRequestResponse(input, modelProviderConfigsResponse({configs: []})),
|
|
120
|
+
);
|
|
121
|
+
}),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
renderHarnesses(<WorkspaceHarnessesSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
|
|
125
|
+
|
|
126
|
+
const claudeRow = (await screen.findByText('Claude')).closest('li');
|
|
127
|
+
if (claudeRow === null) throw new Error('Expected Claude row');
|
|
128
|
+
expect(
|
|
129
|
+
within(claudeRow).getByText('Configure a compatible model provider to use this harness.'),
|
|
130
|
+
).toHaveClass('sr-only');
|
|
131
|
+
|
|
132
|
+
await openHarnessActions(user, 'Claude');
|
|
133
|
+
expect(screen.getByRole('menuitem', {name: 'Set as default'})).toHaveAttribute('data-disabled');
|
|
134
|
+
});
|
|
135
|
+
|
|
94
136
|
test('sets the default harness and shows a success toast', async () => {
|
|
95
137
|
const user = userEvent.setup();
|
|
96
138
|
let requestBody: unknown;
|
|
@@ -101,7 +143,7 @@ describe('WorkspaceHarnessesSection', () => {
|
|
|
101
143
|
requestBody = await request.clone().json();
|
|
102
144
|
return await updateResponse.promise;
|
|
103
145
|
}
|
|
104
|
-
return
|
|
146
|
+
return harnessRequestResponse(input, modelProviderConfigsResponse());
|
|
105
147
|
});
|
|
106
148
|
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
107
149
|
|
|
@@ -128,7 +170,7 @@ describe('WorkspaceHarnessesSection', () => {
|
|
|
128
170
|
if (request.method === 'PUT') {
|
|
129
171
|
return Promise.resolve(jsonResponse({code: 'server-error'}, {status: 500}));
|
|
130
172
|
}
|
|
131
|
-
return Promise.resolve(
|
|
173
|
+
return Promise.resolve(harnessRequestResponse(input, modelProviderConfigsResponse()));
|
|
132
174
|
});
|
|
133
175
|
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
134
176
|
|
|
@@ -154,7 +196,7 @@ describe('WorkspaceHarnessesSection', () => {
|
|
|
154
196
|
if (request.method === 'PUT') {
|
|
155
197
|
return updateResponse.promise;
|
|
156
198
|
}
|
|
157
|
-
return Promise.resolve(
|
|
199
|
+
return Promise.resolve(harnessRequestResponse(input, modelProviderConfigsResponse()));
|
|
158
200
|
});
|
|
159
201
|
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
160
202
|
const nextWorkspaceId = '22222222-2222-4222-8222-222222222222';
|
|
@@ -17,6 +17,7 @@ import {useRef, useState} from 'react';
|
|
|
17
17
|
import {DEFAULT_HARNESS, listHarnesses} from '#core/harness-policy.js';
|
|
18
18
|
import type {HarnessDescriptor, HarnessId} from '#core/models.js';
|
|
19
19
|
import {
|
|
20
|
+
useModelProviderCatalogQuery,
|
|
20
21
|
useModelProviderConfigsQuery,
|
|
21
22
|
useSetDefaultHarnessMutation,
|
|
22
23
|
} from '#hooks/api/model-providers.js';
|
|
@@ -24,6 +25,7 @@ import {modelProviderConfigErrorToFormError} from './form-errors.js';
|
|
|
24
25
|
import {isHarnessAvailable} from './harness-availability.js';
|
|
25
26
|
|
|
26
27
|
export function WorkspaceHarnessesSection({workspaceId}: {workspaceId: string}) {
|
|
28
|
+
const catalogQuery = useModelProviderCatalogQuery();
|
|
27
29
|
const configsQuery = useModelProviderConfigsQuery(workspaceId);
|
|
28
30
|
const setDefaultHarness = useSetDefaultHarnessMutation();
|
|
29
31
|
const activeWorkspaceIdRef = useRef(workspaceId);
|
|
@@ -37,6 +39,7 @@ export function WorkspaceHarnessesSection({workspaceId}: {workspaceId: string})
|
|
|
37
39
|
{workspaceId: string; harnessId: HarnessId; message: string} | undefined
|
|
38
40
|
>();
|
|
39
41
|
const configs = configsQuery.data?.configs ?? [];
|
|
42
|
+
const catalog = catalogQuery.data;
|
|
40
43
|
const defaultHarnessId = configsQuery.data?.defaultHarnessId ?? DEFAULT_HARNESS;
|
|
41
44
|
activeWorkspaceIdRef.current = workspaceId;
|
|
42
45
|
|
|
@@ -103,7 +106,7 @@ export function WorkspaceHarnessesSection({workspaceId}: {workspaceId: string})
|
|
|
103
106
|
key={harness.id}
|
|
104
107
|
harness={harness}
|
|
105
108
|
isDefault={harness.id === defaultHarnessId}
|
|
106
|
-
isAvailable={isHarnessAvailable(harness, configs)}
|
|
109
|
+
isAvailable={isHarnessAvailable(harness, configs, catalog)}
|
|
107
110
|
isSettingDefault={pendingDefaultHarness?.workspaceId === workspaceId}
|
|
108
111
|
defaultError={
|
|
109
112
|
defaultError?.workspaceId === workspaceId &&
|
|
@@ -77,6 +77,29 @@ describe('ModelProviderUsageModal', () => {
|
|
|
77
77
|
);
|
|
78
78
|
});
|
|
79
79
|
|
|
80
|
+
test('does not offer Claude when managed models do not support Anthropic messages', async () => {
|
|
81
|
+
const onOpenChange = vi.fn();
|
|
82
|
+
const entry = supportedProvider({
|
|
83
|
+
id: 'shipfox',
|
|
84
|
+
label: 'Shipfox Managed',
|
|
85
|
+
defaultModel: 'gpt-5.5-pro',
|
|
86
|
+
models: [{id: 'gpt-5.5-pro', label: 'GPT-5.5 Pro', api: 'openai-responses'}],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
render(
|
|
90
|
+
<ModelProviderUsageModal
|
|
91
|
+
target={usageTargetFromCatalogEntry(entry, {isManaged: true})}
|
|
92
|
+
initialModel="gpt-5.5-pro"
|
|
93
|
+
open
|
|
94
|
+
onOpenChange={onOpenChange}
|
|
95
|
+
/>,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const dialog = await screen.findByRole('dialog', {name: 'Use Shipfox Managed in a workflow'});
|
|
99
|
+
expect(dialog).toHaveTextContent('harness: pi');
|
|
100
|
+
expect(screen.queryByRole('button', {name: 'Harness'})).not.toBeInTheDocument();
|
|
101
|
+
});
|
|
102
|
+
|
|
80
103
|
test('uses the workspace default harness when it is compatible', async () => {
|
|
81
104
|
const onOpenChange = vi.fn();
|
|
82
105
|
const entry = supportedProvider();
|
|
@@ -22,7 +22,12 @@ import {
|
|
|
22
22
|
import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
|
|
23
23
|
import {Code, Text} from '@shipfox/react-ui/typography';
|
|
24
24
|
import {useEffect, useMemo, useRef, useState} from 'react';
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
DEFAULT_HARNESS,
|
|
27
|
+
getHarness,
|
|
28
|
+
listHarnesses,
|
|
29
|
+
managedModelSupportsHarness,
|
|
30
|
+
} from '#core/harness-policy.js';
|
|
26
31
|
import type {HarnessId} from '#core/models.js';
|
|
27
32
|
import {buildAgentWorkflowExample} from './agent-workflow-example.js';
|
|
28
33
|
import {compatibleHarnessIds} from './harness-availability.js';
|
|
@@ -59,6 +64,8 @@ export function ModelProviderUsageModal({
|
|
|
59
64
|
if (!target) return;
|
|
60
65
|
const nextCompatibleHarnessIds = compatibleHarnessIds({
|
|
61
66
|
isCustom: target.isCustom,
|
|
67
|
+
isManaged: target.isManaged,
|
|
68
|
+
models: target.models,
|
|
62
69
|
providerId: target.id,
|
|
63
70
|
});
|
|
64
71
|
setSelectedHarness(selectInitialHarness(nextCompatibleHarnessIds, workspaceDefaultHarnessId));
|
|
@@ -67,7 +74,12 @@ export function ModelProviderUsageModal({
|
|
|
67
74
|
const compatibleIds = useMemo(
|
|
68
75
|
() =>
|
|
69
76
|
target
|
|
70
|
-
? compatibleHarnessIds({
|
|
77
|
+
? compatibleHarnessIds({
|
|
78
|
+
isCustom: target.isCustom,
|
|
79
|
+
isManaged: target.isManaged,
|
|
80
|
+
models: target.models,
|
|
81
|
+
providerId: target.id,
|
|
82
|
+
})
|
|
71
83
|
: ([] as HarnessId[]),
|
|
72
84
|
[target],
|
|
73
85
|
);
|
|
@@ -79,10 +91,24 @@ export function ModelProviderUsageModal({
|
|
|
79
91
|
})),
|
|
80
92
|
[compatibleIds],
|
|
81
93
|
);
|
|
94
|
+
const compatibleModels = useMemo(() => {
|
|
95
|
+
if (!target) return [];
|
|
96
|
+
return target.isManaged
|
|
97
|
+
? target.models.filter((model) => managedModelSupportsHarness(selectedHarness, model))
|
|
98
|
+
: target.models;
|
|
99
|
+
}, [selectedHarness, target]);
|
|
82
100
|
const modelOptions = useMemo(
|
|
83
|
-
() =>
|
|
84
|
-
[
|
|
101
|
+
() => compatibleModels.map((model) => ({value: model.id, label: model.label})),
|
|
102
|
+
[compatibleModels],
|
|
85
103
|
);
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!target || compatibleModels.length === 0) return;
|
|
106
|
+
setSelectedModel((current) =>
|
|
107
|
+
compatibleModels.some((model) => model.id === current)
|
|
108
|
+
? current
|
|
109
|
+
: (compatibleModels[0]?.id ?? ''),
|
|
110
|
+
);
|
|
111
|
+
}, [compatibleModels, target]);
|
|
86
112
|
const example = target
|
|
87
113
|
? buildAgentWorkflowExample({
|
|
88
114
|
harness: selectedHarness,
|
|
@@ -212,10 +238,10 @@ export function ModelProviderUsageModal({
|
|
|
212
238
|
|
|
213
239
|
<div className="flex flex-col gap-inline">
|
|
214
240
|
<Text size="sm" bold>
|
|
215
|
-
Available models ({
|
|
241
|
+
Available models ({compatibleModels.length})
|
|
216
242
|
</Text>
|
|
217
243
|
<ul className="rounded-8 border border-border-neutral-base">
|
|
218
|
-
{
|
|
244
|
+
{compatibleModels.map((model) => (
|
|
219
245
|
<ModelProviderModelRow key={model.id} label={model.label} id={model.id} />
|
|
220
246
|
))}
|
|
221
247
|
</ul>
|
|
@@ -1,19 +1,24 @@
|
|
|
1
|
-
import type {CustomProviderConfig, SupportedProvider} from '#core/models.js';
|
|
1
|
+
import type {AgentModel, CustomProviderConfig, SupportedProvider} from '#core/models.js';
|
|
2
2
|
|
|
3
3
|
export interface ModelProviderUsageTarget {
|
|
4
4
|
id: string;
|
|
5
5
|
label: string;
|
|
6
6
|
isCustom: boolean;
|
|
7
|
-
|
|
7
|
+
isManaged: boolean;
|
|
8
|
+
models: ReadonlyArray<AgentModel>;
|
|
8
9
|
defaultModel: string | null;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
export function usageTargetFromCatalogEntry(
|
|
12
|
+
export function usageTargetFromCatalogEntry(
|
|
13
|
+
entry: SupportedProvider,
|
|
14
|
+
options: {isManaged?: boolean} = {},
|
|
15
|
+
): ModelProviderUsageTarget {
|
|
12
16
|
const provider = entry;
|
|
13
17
|
return {
|
|
14
18
|
id: provider.id,
|
|
15
19
|
label: provider.label,
|
|
16
20
|
isCustom: false,
|
|
21
|
+
isManaged: options.isManaged ?? false,
|
|
17
22
|
models: provider.models,
|
|
18
23
|
defaultModel: provider.defaultModel,
|
|
19
24
|
};
|
|
@@ -27,6 +32,7 @@ export function usageTargetFromCustomConfig(
|
|
|
27
32
|
id: provider.providerId,
|
|
28
33
|
label: provider.displayName,
|
|
29
34
|
isCustom: true,
|
|
35
|
+
isManaged: false,
|
|
30
36
|
models: provider.models,
|
|
31
37
|
defaultModel: provider.defaultModel,
|
|
32
38
|
};
|
|
@@ -22,6 +22,7 @@ type Scenario =
|
|
|
22
22
|
| 'loading'
|
|
23
23
|
| 'configs-error'
|
|
24
24
|
| 'catalog-error'
|
|
25
|
+
| 'managed-only'
|
|
25
26
|
| 'long-names';
|
|
26
27
|
|
|
27
28
|
interface ModelProvidersStoryProps {
|
|
@@ -138,6 +139,15 @@ type Story = StoryObj<typeof meta>;
|
|
|
138
139
|
|
|
139
140
|
export const Playground: Story = {};
|
|
140
141
|
|
|
142
|
+
export const ManagedOnly: Story = {
|
|
143
|
+
args: {scenario: 'managed-only'},
|
|
144
|
+
play: async ({canvasElement}) => {
|
|
145
|
+
const canvas = within(canvasElement);
|
|
146
|
+
await canvas.findByText('Managed by this instance. No workspace credentials are required.');
|
|
147
|
+
await canvas.findByRole('button', {name: 'Use in a workflow'});
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
|
|
141
151
|
export const EmptyConfigured: Story = {
|
|
142
152
|
args: {scenario: 'empty-configured'},
|
|
143
153
|
};
|
|
@@ -226,7 +236,12 @@ function fetchForScenario(scenario: Scenario): typeof fetch {
|
|
|
226
236
|
if (scenario === 'loading') return new Promise<Response>(() => undefined);
|
|
227
237
|
if (url.pathname.endsWith('/agent/model-provider-catalog')) {
|
|
228
238
|
if (scenario === 'catalog-error') return Promise.resolve(errorResponse());
|
|
229
|
-
return Promise.resolve(
|
|
239
|
+
return Promise.resolve(
|
|
240
|
+
jsonResponse({
|
|
241
|
+
providers: catalogForScenario(scenario),
|
|
242
|
+
...(scenario === 'managed-only' ? {workspace_providers: 'disabled'} : {}),
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
230
245
|
}
|
|
231
246
|
if (url.pathname.endsWith('/agent/model-providers')) {
|
|
232
247
|
if (scenario === 'configs-error') return Promise.resolve(errorResponse());
|
|
@@ -237,6 +252,20 @@ function fetchForScenario(scenario: Scenario): typeof fetch {
|
|
|
237
252
|
}
|
|
238
253
|
|
|
239
254
|
function catalogForScenario(scenario: Scenario): ModelProviderCatalogEntryDto[] {
|
|
255
|
+
if (scenario === 'managed-only') {
|
|
256
|
+
return [
|
|
257
|
+
providerEntry({
|
|
258
|
+
id: 'shipfox',
|
|
259
|
+
label: 'Shipfox Managed',
|
|
260
|
+
default_model: 'claude-opus-4-8',
|
|
261
|
+
credential_fields: [],
|
|
262
|
+
models: [
|
|
263
|
+
{id: 'claude-opus-4-8', label: 'Claude Opus 4.8', api: 'anthropic-messages'},
|
|
264
|
+
{id: 'gpt-5.5-pro', label: 'GPT-5.5 Pro', api: 'openai-responses'},
|
|
265
|
+
],
|
|
266
|
+
}),
|
|
267
|
+
];
|
|
268
|
+
}
|
|
240
269
|
if (scenario !== 'long-names') return CATALOG;
|
|
241
270
|
return [
|
|
242
271
|
providerEntry({
|
|
@@ -254,6 +283,9 @@ function catalogForScenario(scenario: Scenario): ModelProviderCatalogEntryDto[]
|
|
|
254
283
|
}
|
|
255
284
|
|
|
256
285
|
function configsForScenario(scenario: Scenario) {
|
|
286
|
+
if (scenario === 'managed-only') {
|
|
287
|
+
return {configs: [], default_provider_id: null, default_harness_id: null};
|
|
288
|
+
}
|
|
257
289
|
if (scenario === 'empty-configured' || scenario === 'configs-error') {
|
|
258
290
|
return {configs: [], default_provider_id: null, default_harness_id: null};
|
|
259
291
|
}
|
|
@@ -7,6 +7,7 @@ import userEvent from '@testing-library/user-event';
|
|
|
7
7
|
import type {ReactElement} from 'react';
|
|
8
8
|
import {
|
|
9
9
|
AGENT_TEST_WORKSPACE_ID,
|
|
10
|
+
managedModelProviderEntry,
|
|
10
11
|
modelProviderCatalogResponse,
|
|
11
12
|
modelProviderConfig,
|
|
12
13
|
modelProviderConfigsResponse,
|
|
@@ -60,6 +61,46 @@ function customProviderConfig(): CustomModelProviderConfigDto {
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
describe('WorkspaceModelProvidersSection', () => {
|
|
64
|
+
test('renders managed provider models without exposing workspace provider setup', async () => {
|
|
65
|
+
const user = userEvent.setup();
|
|
66
|
+
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
67
|
+
if (requestPath(input).endsWith('/agent/model-provider-catalog')) {
|
|
68
|
+
return Promise.resolve(
|
|
69
|
+
jsonResponse(modelProviderCatalogResponse([managedModelProviderEntry()], 'disabled')),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return Promise.resolve(
|
|
73
|
+
jsonResponse(modelProviderConfigsResponse({configs: [], default_provider_id: null})),
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
|
|
77
|
+
|
|
78
|
+
renderModelProviders(<WorkspaceModelProvidersSection workspaceId={AGENT_TEST_WORKSPACE_ID} />);
|
|
79
|
+
|
|
80
|
+
expect(await screen.findByText('Managed provider')).toBeVisible();
|
|
81
|
+
expect(screen.getByText('Shipfox Managed')).toBeVisible();
|
|
82
|
+
expect(screen.getByRole('list', {name: 'Shipfox Managed models'})).toHaveTextContent(
|
|
83
|
+
'Claude Opus 4.8',
|
|
84
|
+
);
|
|
85
|
+
expect(screen.getByRole('list', {name: 'Shipfox Managed models'})).toHaveTextContent(
|
|
86
|
+
'GPT-5.5 Pro',
|
|
87
|
+
);
|
|
88
|
+
expect(screen.queryByText('Available providers')).not.toBeInTheDocument();
|
|
89
|
+
expect(screen.queryByText('Unsupported providers')).not.toBeInTheDocument();
|
|
90
|
+
expect(
|
|
91
|
+
screen.queryByRole('button', {name: 'Configure custom provider'}),
|
|
92
|
+
).not.toBeInTheDocument();
|
|
93
|
+
expect(screen.queryByLabelText('API key')).not.toBeInTheDocument();
|
|
94
|
+
|
|
95
|
+
await user.click(screen.getByRole('button', {name: 'Use in a workflow'}));
|
|
96
|
+
|
|
97
|
+
const usageDialog = await screen.findByRole('dialog', {
|
|
98
|
+
name: 'Use Shipfox Managed in a workflow',
|
|
99
|
+
});
|
|
100
|
+
expect(usageDialog).toHaveTextContent('provider: shipfox');
|
|
101
|
+
expect(usageDialog).toHaveTextContent('model: claude-opus-4-8');
|
|
102
|
+
});
|
|
103
|
+
|
|
63
104
|
test('renders configured, available, and unsupported providers', async () => {
|
|
64
105
|
const fetchImpl = vi.fn((input: RequestInfo | URL) => {
|
|
65
106
|
if (requestPath(input).endsWith('/agent/model-provider-catalog')) {
|