@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,170 @@
|
|
|
1
|
+
import type {CustomModelProviderConfigDto} from '@shipfox/api-agent-dto';
|
|
2
|
+
import {
|
|
3
|
+
buildCreateCustomModelProviderBody,
|
|
4
|
+
buildDiscoverModelsBody,
|
|
5
|
+
buildDiscoverModelsBySlugBody,
|
|
6
|
+
buildUpdateCustomModelProviderBody,
|
|
7
|
+
customHeadersDirty,
|
|
8
|
+
customModelsDirty,
|
|
9
|
+
editCustomModelProviderFormValues,
|
|
10
|
+
formatBaseUrlHost,
|
|
11
|
+
} from './custom-model-provider-payload.js';
|
|
12
|
+
|
|
13
|
+
describe('custom model provider payload mappers', () => {
|
|
14
|
+
test('builds create bodies from complete form rows and filters blanks', () => {
|
|
15
|
+
const body = buildCreateCustomModelProviderBody({
|
|
16
|
+
slug: ' local-vllm ',
|
|
17
|
+
display_name: ' Local vLLM ',
|
|
18
|
+
api: 'openai-completions',
|
|
19
|
+
base_url: ' http://localhost:8000/v1 ',
|
|
20
|
+
api_key: ' sk-local ',
|
|
21
|
+
headers: [
|
|
22
|
+
{
|
|
23
|
+
client_id: 'header-1',
|
|
24
|
+
name: ' Authorization ',
|
|
25
|
+
value: ' Bearer token ',
|
|
26
|
+
secret: true,
|
|
27
|
+
hasStoredValue: false,
|
|
28
|
+
storedName: '',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
client_id: 'header-2',
|
|
32
|
+
name: '',
|
|
33
|
+
value: '',
|
|
34
|
+
secret: false,
|
|
35
|
+
hasStoredValue: false,
|
|
36
|
+
storedName: '',
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
models: [
|
|
40
|
+
{
|
|
41
|
+
client_id: 'model-1',
|
|
42
|
+
id: ' llama-3.1 ',
|
|
43
|
+
label: ' Llama 3.1 ',
|
|
44
|
+
context_window: '128000',
|
|
45
|
+
max_output_tokens: '16384',
|
|
46
|
+
input_image: true,
|
|
47
|
+
reasoning: false,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
client_id: 'model-2',
|
|
51
|
+
id: '',
|
|
52
|
+
label: '',
|
|
53
|
+
context_window: '',
|
|
54
|
+
max_output_tokens: '',
|
|
55
|
+
input_image: false,
|
|
56
|
+
reasoning: false,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
default_model: ' llama-3.1 ',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(body).toEqual({
|
|
63
|
+
slug: 'local-vllm',
|
|
64
|
+
display_name: 'Local vLLM',
|
|
65
|
+
api: 'openai-completions',
|
|
66
|
+
base_url: 'http://localhost:8000/v1',
|
|
67
|
+
api_key: 'sk-local',
|
|
68
|
+
headers: [{name: 'authorization', value: 'Bearer token', secret: true}],
|
|
69
|
+
models: [
|
|
70
|
+
{
|
|
71
|
+
id: 'llama-3.1',
|
|
72
|
+
label: 'Llama 3.1',
|
|
73
|
+
context_window: 128000,
|
|
74
|
+
max_output_tokens: 16384,
|
|
75
|
+
input_image: true,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
default_model: 'llama-3.1',
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('keeps unchanged stored secret headers out of update bodies', () => {
|
|
83
|
+
const config = customConfig();
|
|
84
|
+
const values = editCustomModelProviderFormValues(config);
|
|
85
|
+
|
|
86
|
+
const body = buildUpdateCustomModelProviderBody(config, values);
|
|
87
|
+
|
|
88
|
+
expect(customHeadersDirty(config, values.headers)).toBe(false);
|
|
89
|
+
expect(customModelsDirty(config, values.models)).toBe(false);
|
|
90
|
+
expect(body).toEqual({});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('serializes kept secret headers for slug-scoped discovery', () => {
|
|
94
|
+
const config = customConfig();
|
|
95
|
+
const values = editCustomModelProviderFormValues(config);
|
|
96
|
+
|
|
97
|
+
const body = buildDiscoverModelsBySlugBody(config, values);
|
|
98
|
+
|
|
99
|
+
expect(body).toEqual({
|
|
100
|
+
headers: [
|
|
101
|
+
{name: 'x-region', value: 'us', secret: false},
|
|
102
|
+
{name: 'authorization', secret: true, keep: true},
|
|
103
|
+
],
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('requires secret values again when stored secret headers are renamed', () => {
|
|
108
|
+
const config = customConfig();
|
|
109
|
+
const values = editCustomModelProviderFormValues(config);
|
|
110
|
+
values.headers = values.headers.map((header) =>
|
|
111
|
+
header.secret ? {...header, name: 'x-authorization'} : header,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const body = buildUpdateCustomModelProviderBody(config, values);
|
|
115
|
+
|
|
116
|
+
expect(body.headers).toEqual([
|
|
117
|
+
{name: 'x-region', value: 'us', secret: false},
|
|
118
|
+
{name: 'x-authorization', value: '', secret: true},
|
|
119
|
+
]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('builds unsaved discovery bodies without keep markers', () => {
|
|
123
|
+
const config = customConfig();
|
|
124
|
+
const values = editCustomModelProviderFormValues(config);
|
|
125
|
+
values.api_key = 'sk-new';
|
|
126
|
+
values.headers = values.headers.map((header) =>
|
|
127
|
+
header.secret ? {...header, value: 'Bearer new'} : header,
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const body = buildDiscoverModelsBody(values);
|
|
131
|
+
|
|
132
|
+
expect(body).toEqual({
|
|
133
|
+
api: 'openai-responses',
|
|
134
|
+
base_url: 'https://llm.example.test/v1',
|
|
135
|
+
api_key: 'sk-new',
|
|
136
|
+
headers: [
|
|
137
|
+
{name: 'x-region', value: 'us'},
|
|
138
|
+
{name: 'authorization', value: 'Bearer new'},
|
|
139
|
+
],
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('formats valid base URL hosts and falls back for raw values', () => {
|
|
144
|
+
expect(formatBaseUrlHost('https://llm.example.test/v1')).toBe('llm.example.test');
|
|
145
|
+
expect(formatBaseUrlHost('not a url')).toBe('not a url');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
function customConfig(): CustomModelProviderConfigDto {
|
|
150
|
+
return {
|
|
151
|
+
kind: 'custom',
|
|
152
|
+
provider_id: 'local-vllm',
|
|
153
|
+
display_name: 'Local vLLM',
|
|
154
|
+
api: 'openai-responses',
|
|
155
|
+
base_url: 'https://llm.example.test/v1',
|
|
156
|
+
headers: [{name: 'x-region', value: 'us'}],
|
|
157
|
+
secret_header_names: ['authorization'],
|
|
158
|
+
models: [
|
|
159
|
+
{
|
|
160
|
+
id: 'llama-3.1',
|
|
161
|
+
label: 'Llama 3.1',
|
|
162
|
+
context_window: 128000,
|
|
163
|
+
max_output_tokens: 16384,
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
default_model: 'llama-3.1',
|
|
167
|
+
created_at: '2026-05-08T00:00:00.000Z',
|
|
168
|
+
updated_at: '2026-05-08T00:00:00.000Z',
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CreateCustomModelProviderBodyDto,
|
|
3
|
+
CustomAgentModelDto,
|
|
4
|
+
CustomModelProviderConfigDto,
|
|
5
|
+
DiscoverCustomModelProviderModelsBodyDto,
|
|
6
|
+
DiscoverCustomModelProviderModelsBySlugBodyDto,
|
|
7
|
+
ModelProviderApi,
|
|
8
|
+
UpdateCustomModelProviderBodyDto,
|
|
9
|
+
} from '@shipfox/api-agent-dto';
|
|
10
|
+
|
|
11
|
+
type CreateHeaderRequest = NonNullable<CreateCustomModelProviderBodyDto['headers']>[number];
|
|
12
|
+
type UpdateHeaderRequest = NonNullable<UpdateCustomModelProviderBodyDto['headers']>[number];
|
|
13
|
+
|
|
14
|
+
export interface CustomModelProviderHeaderFormValue {
|
|
15
|
+
client_id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
value: string;
|
|
18
|
+
secret: boolean;
|
|
19
|
+
hasStoredValue: boolean;
|
|
20
|
+
storedName: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CustomModelProviderModelFormValue {
|
|
24
|
+
client_id: string;
|
|
25
|
+
id: string;
|
|
26
|
+
label: string;
|
|
27
|
+
context_window: string;
|
|
28
|
+
max_output_tokens: string;
|
|
29
|
+
input_image: boolean;
|
|
30
|
+
reasoning: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CustomModelProviderFormValues {
|
|
34
|
+
slug: string;
|
|
35
|
+
display_name: string;
|
|
36
|
+
api: ModelProviderApi;
|
|
37
|
+
base_url: string;
|
|
38
|
+
api_key: string;
|
|
39
|
+
headers: CustomModelProviderHeaderFormValue[];
|
|
40
|
+
models: CustomModelProviderModelFormValue[];
|
|
41
|
+
default_model: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function createCustomModelProviderFormValues(): CustomModelProviderFormValues {
|
|
45
|
+
return {
|
|
46
|
+
slug: '',
|
|
47
|
+
display_name: '',
|
|
48
|
+
api: 'openai-completions',
|
|
49
|
+
base_url: '',
|
|
50
|
+
api_key: '',
|
|
51
|
+
headers: [],
|
|
52
|
+
models: [
|
|
53
|
+
{
|
|
54
|
+
client_id: createFormRowId(),
|
|
55
|
+
id: '',
|
|
56
|
+
label: '',
|
|
57
|
+
context_window: '',
|
|
58
|
+
max_output_tokens: '',
|
|
59
|
+
input_image: false,
|
|
60
|
+
reasoning: false,
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
default_model: '',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function editCustomModelProviderFormValues(
|
|
68
|
+
config: CustomModelProviderConfigDto,
|
|
69
|
+
): CustomModelProviderFormValues {
|
|
70
|
+
return {
|
|
71
|
+
slug: config.provider_id,
|
|
72
|
+
display_name: config.display_name,
|
|
73
|
+
api: config.api,
|
|
74
|
+
base_url: config.base_url,
|
|
75
|
+
api_key: '',
|
|
76
|
+
headers: [
|
|
77
|
+
...config.headers.map((header) => ({
|
|
78
|
+
client_id: createFormRowId(),
|
|
79
|
+
name: header.name,
|
|
80
|
+
value: header.value,
|
|
81
|
+
secret: false,
|
|
82
|
+
hasStoredValue: false,
|
|
83
|
+
storedName: header.name,
|
|
84
|
+
})),
|
|
85
|
+
...config.secret_header_names.map((name) => ({
|
|
86
|
+
client_id: createFormRowId(),
|
|
87
|
+
name,
|
|
88
|
+
value: '',
|
|
89
|
+
secret: true,
|
|
90
|
+
hasStoredValue: true,
|
|
91
|
+
storedName: normalizeHeaderName(name),
|
|
92
|
+
})),
|
|
93
|
+
],
|
|
94
|
+
models: config.models.map(modelFormValue),
|
|
95
|
+
default_model: config.default_model ?? '',
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function buildCreateCustomModelProviderBody(
|
|
100
|
+
values: CustomModelProviderFormValues,
|
|
101
|
+
): CreateCustomModelProviderBodyDto {
|
|
102
|
+
return {
|
|
103
|
+
slug: values.slug.trim(),
|
|
104
|
+
display_name: values.display_name.trim(),
|
|
105
|
+
api: values.api,
|
|
106
|
+
base_url: values.base_url.trim(),
|
|
107
|
+
...(values.api_key.trim() ? {api_key: values.api_key.trim()} : {}),
|
|
108
|
+
...(requestHeaders(values.headers).length > 0 ? {headers: requestHeaders(values.headers)} : {}),
|
|
109
|
+
models: requestModels(values.models),
|
|
110
|
+
...(values.default_model.trim() ? {default_model: values.default_model.trim()} : {}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function buildUpdateCustomModelProviderBody(
|
|
115
|
+
config: CustomModelProviderConfigDto,
|
|
116
|
+
values: CustomModelProviderFormValues,
|
|
117
|
+
): UpdateCustomModelProviderBodyDto {
|
|
118
|
+
const body: UpdateCustomModelProviderBodyDto = {};
|
|
119
|
+
if (values.display_name.trim() !== config.display_name)
|
|
120
|
+
body.display_name = values.display_name.trim();
|
|
121
|
+
if (values.api !== config.api) body.api = values.api;
|
|
122
|
+
if (values.base_url.trim() !== config.base_url) body.base_url = values.base_url.trim();
|
|
123
|
+
if (values.api_key.trim()) body.api_key = values.api_key.trim();
|
|
124
|
+
if (customHeadersDirty(config, values.headers)) body.headers = updateHeaders(values.headers);
|
|
125
|
+
if (customModelsDirty(config, values.models)) body.models = requestModels(values.models);
|
|
126
|
+
|
|
127
|
+
const nextDefault = values.default_model.trim() || null;
|
|
128
|
+
if (nextDefault !== config.default_model) body.default_model = nextDefault;
|
|
129
|
+
return body;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function customHeadersDirty(
|
|
133
|
+
config: CustomModelProviderConfigDto,
|
|
134
|
+
headers: CustomModelProviderHeaderFormValue[],
|
|
135
|
+
): boolean {
|
|
136
|
+
return (
|
|
137
|
+
JSON.stringify(updateHeaders(headers)) !==
|
|
138
|
+
JSON.stringify([
|
|
139
|
+
...config.headers.map((header) => ({
|
|
140
|
+
name: normalizeHeaderName(header.name),
|
|
141
|
+
value: header.value,
|
|
142
|
+
secret: false,
|
|
143
|
+
})),
|
|
144
|
+
...config.secret_header_names.map((name) => ({
|
|
145
|
+
name: normalizeHeaderName(name),
|
|
146
|
+
secret: true,
|
|
147
|
+
keep: true,
|
|
148
|
+
})),
|
|
149
|
+
])
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function customModelsDirty(
|
|
154
|
+
config: CustomModelProviderConfigDto,
|
|
155
|
+
models: CustomModelProviderModelFormValue[],
|
|
156
|
+
): boolean {
|
|
157
|
+
return JSON.stringify(requestModels(models)) !== JSON.stringify(config.models);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function buildDiscoverModelsBody(
|
|
161
|
+
values: CustomModelProviderFormValues,
|
|
162
|
+
): DiscoverCustomModelProviderModelsBodyDto {
|
|
163
|
+
return {
|
|
164
|
+
api: values.api,
|
|
165
|
+
base_url: values.base_url.trim(),
|
|
166
|
+
...(values.api_key.trim() ? {api_key: values.api_key.trim()} : {}),
|
|
167
|
+
...(requestHeaders(values.headers).length > 0
|
|
168
|
+
? {
|
|
169
|
+
headers: requestHeaders(values.headers).map(({name, value}) => ({
|
|
170
|
+
name,
|
|
171
|
+
value,
|
|
172
|
+
})),
|
|
173
|
+
}
|
|
174
|
+
: {}),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function buildDiscoverModelsBySlugBody(
|
|
179
|
+
config: CustomModelProviderConfigDto,
|
|
180
|
+
values: CustomModelProviderFormValues,
|
|
181
|
+
): DiscoverCustomModelProviderModelsBySlugBodyDto {
|
|
182
|
+
const body: DiscoverCustomModelProviderModelsBySlugBodyDto = {};
|
|
183
|
+
if (values.api !== config.api) body.api = values.api;
|
|
184
|
+
if (values.base_url.trim() !== config.base_url) body.base_url = values.base_url.trim();
|
|
185
|
+
if (values.api_key.trim()) body.api_key = values.api_key.trim();
|
|
186
|
+
body.headers = updateHeaders(values.headers);
|
|
187
|
+
return body;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function formatBaseUrlHost(baseUrl: string): string {
|
|
191
|
+
try {
|
|
192
|
+
const url = new URL(baseUrl);
|
|
193
|
+
return url.host;
|
|
194
|
+
} catch {
|
|
195
|
+
return baseUrl;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function createFormRowId(): string {
|
|
200
|
+
return crypto.randomUUID();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function updateHeaders(headers: CustomModelProviderHeaderFormValue[]): UpdateHeaderRequest[] {
|
|
204
|
+
const result: UpdateHeaderRequest[] = [];
|
|
205
|
+
for (const header of headers) {
|
|
206
|
+
const name = normalizeHeaderName(header.name);
|
|
207
|
+
if (!name) continue;
|
|
208
|
+
if (
|
|
209
|
+
header.secret &&
|
|
210
|
+
header.hasStoredValue &&
|
|
211
|
+
header.value.trim() === '' &&
|
|
212
|
+
name === header.storedName
|
|
213
|
+
) {
|
|
214
|
+
result.push({name, secret: true, keep: true});
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
result.push({name, value: header.value.trim(), secret: header.secret});
|
|
218
|
+
}
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function requestHeaders(headers: CustomModelProviderHeaderFormValue[]): CreateHeaderRequest[] {
|
|
223
|
+
return updateHeaders(headers).flatMap((header) =>
|
|
224
|
+
'value' in header && header.value
|
|
225
|
+
? [{name: header.name, value: header.value, secret: header.secret}]
|
|
226
|
+
: [],
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function requestModels(models: CustomModelProviderModelFormValue[]): CustomAgentModelDto[] {
|
|
231
|
+
return models.flatMap((model) => {
|
|
232
|
+
const id = model.id.trim();
|
|
233
|
+
const label = model.label.trim();
|
|
234
|
+
if (!id || !label) return [];
|
|
235
|
+
return [
|
|
236
|
+
{
|
|
237
|
+
id,
|
|
238
|
+
label,
|
|
239
|
+
...(model.context_window.trim() ? {context_window: Number(model.context_window)} : {}),
|
|
240
|
+
...(model.max_output_tokens.trim()
|
|
241
|
+
? {max_output_tokens: Number(model.max_output_tokens)}
|
|
242
|
+
: {}),
|
|
243
|
+
...(model.input_image ? {input_image: true} : {}),
|
|
244
|
+
...(model.reasoning ? {reasoning: true} : {}),
|
|
245
|
+
},
|
|
246
|
+
];
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function modelFormValue(model: CustomAgentModelDto): CustomModelProviderModelFormValue {
|
|
251
|
+
return {
|
|
252
|
+
client_id: createFormRowId(),
|
|
253
|
+
id: model.id,
|
|
254
|
+
label: model.label,
|
|
255
|
+
context_window: model.context_window?.toString() ?? '',
|
|
256
|
+
max_output_tokens: model.max_output_tokens?.toString() ?? '',
|
|
257
|
+
input_image: model.input_image ?? false,
|
|
258
|
+
reasoning: model.reasoning ?? false,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function normalizeHeaderName(name: string): string {
|
|
263
|
+
return name.trim().toLowerCase();
|
|
264
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {
|
|
2
|
+
customModelProviderSlugError,
|
|
3
|
+
deriveCustomModelProviderSlug,
|
|
4
|
+
} from './custom-model-provider-slug.js';
|
|
5
|
+
|
|
6
|
+
describe('deriveCustomModelProviderSlug', () => {
|
|
7
|
+
test('derives lowercase dash-separated slugs from display names', () => {
|
|
8
|
+
const result = deriveCustomModelProviderSlug('My OpenAI Gateway!');
|
|
9
|
+
|
|
10
|
+
expect(result).toBe('my-openai-gateway');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('removes accents and trims dash boundaries', () => {
|
|
14
|
+
const result = deriveCustomModelProviderSlug(' Équipe LLM ');
|
|
15
|
+
|
|
16
|
+
expect(result).toBe('equipe-llm');
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('customModelProviderSlugError', () => {
|
|
21
|
+
test('accepts valid custom provider ids', () => {
|
|
22
|
+
const result = customModelProviderSlugError('local-vllm');
|
|
23
|
+
|
|
24
|
+
expect(result).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('rejects reserved built-in provider ids', () => {
|
|
28
|
+
const result = customModelProviderSlugError('anthropic');
|
|
29
|
+
|
|
30
|
+
expect(result).toBe('This id is reserved for a built-in provider.');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('rejects malformed provider ids', () => {
|
|
34
|
+
const result = customModelProviderSlugError('Local_VLLM');
|
|
35
|
+
|
|
36
|
+
expect(result).toBe(
|
|
37
|
+
'Use 3-40 lowercase letters, digits, and dashes; start and end with a letter or digit.',
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {isReservedModelProviderId, MODEL_PROVIDER_SLUG_PATTERN} from '@shipfox/api-agent-dto';
|
|
2
|
+
|
|
3
|
+
const MAX_SLUG_LENGTH = 40;
|
|
4
|
+
const SLUG_FORMAT_ERROR =
|
|
5
|
+
'Use 3-40 lowercase letters, digits, and dashes; start and end with a letter or digit.';
|
|
6
|
+
|
|
7
|
+
export function deriveCustomModelProviderSlug(displayName: string): string {
|
|
8
|
+
return displayName
|
|
9
|
+
.normalize('NFKD')
|
|
10
|
+
.replace(/\p{Diacritic}/gu, '')
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
13
|
+
.replace(/^-+|-+$/g, '')
|
|
14
|
+
.slice(0, MAX_SLUG_LENGTH)
|
|
15
|
+
.replace(/-+$/g, '');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function customModelProviderSlugError(slug: string): string | undefined {
|
|
19
|
+
const trimmed = slug.trim();
|
|
20
|
+
if (!MODEL_PROVIDER_SLUG_PATTERN.test(trimmed)) return SLUG_FORMAT_ERROR;
|
|
21
|
+
if (isReservedModelProviderId(trimmed)) return 'This id is reserved for a built-in provider.';
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type {ModelProviderCatalogEntryDto} from '@shipfox/api-agent-dto';
|
|
2
|
+
import {FormField, useFormField} from '@shipfox/react-ui/form-field';
|
|
3
|
+
import {cn} from '@shipfox/react-ui/utils';
|
|
4
|
+
import type {ComponentProps, ReactNode} from 'react';
|
|
5
|
+
|
|
6
|
+
export const LATEST_MODEL_VALUE = '__latest__';
|
|
7
|
+
|
|
8
|
+
export function DefaultModelField({
|
|
9
|
+
entry,
|
|
10
|
+
value,
|
|
11
|
+
error,
|
|
12
|
+
children,
|
|
13
|
+
onChange,
|
|
14
|
+
onBlur,
|
|
15
|
+
}: {
|
|
16
|
+
entry: ModelProviderCatalogEntryDto;
|
|
17
|
+
value: string;
|
|
18
|
+
error: string | undefined;
|
|
19
|
+
children?: ReactNode;
|
|
20
|
+
onChange: ComponentProps<'select'>['onChange'];
|
|
21
|
+
onBlur: ComponentProps<'select'>['onBlur'];
|
|
22
|
+
}) {
|
|
23
|
+
return (
|
|
24
|
+
<FormField
|
|
25
|
+
label="Default model"
|
|
26
|
+
id={`model-provider-${entry.id}-default-model`}
|
|
27
|
+
error={error}
|
|
28
|
+
description={defaultModelDescription(entry, value)}
|
|
29
|
+
>
|
|
30
|
+
<FormFieldSelect value={value} onChange={onChange} onBlur={onBlur}>
|
|
31
|
+
<option value={LATEST_MODEL_VALUE}>Latest</option>
|
|
32
|
+
{entry.models.map((model) => (
|
|
33
|
+
<option key={model.id} value={model.id}>
|
|
34
|
+
{model.label}
|
|
35
|
+
</option>
|
|
36
|
+
))}
|
|
37
|
+
</FormFieldSelect>
|
|
38
|
+
{children}
|
|
39
|
+
</FormField>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function defaultModelFormValue(defaultModel: string | null | undefined): string {
|
|
44
|
+
return defaultModel ?? LATEST_MODEL_VALUE;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function selectedModelForCredentialsPayload(selectedModel: string): string | null {
|
|
48
|
+
return selectedModel === LATEST_MODEL_VALUE ? null : selectedModel.trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function selectedModelForModelPayload(selectedModel: string): string | null {
|
|
52
|
+
return selectedModel === LATEST_MODEL_VALUE ? null : selectedModel.trim();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function defaultModelLabel(entry: ModelProviderCatalogEntryDto): string {
|
|
56
|
+
const defaultModel = entry.default_model ?? entry.models[0]?.id ?? '';
|
|
57
|
+
return entry.models.find((model) => model.id === defaultModel)?.label ?? defaultModel;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function defaultModelDescription(
|
|
61
|
+
entry: ModelProviderCatalogEntryDto,
|
|
62
|
+
selectedModel: string | undefined,
|
|
63
|
+
): string | undefined {
|
|
64
|
+
if (selectedModel === LATEST_MODEL_VALUE) {
|
|
65
|
+
return `Latest follows the provider catalog default. Currently resolves to ${defaultModelLabel(entry)}.`;
|
|
66
|
+
}
|
|
67
|
+
if (!selectedModel) return undefined;
|
|
68
|
+
return selectedModel;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function FormFieldSelect({className, ...props}: ComponentProps<'select'>) {
|
|
72
|
+
const wiring = useFormField();
|
|
73
|
+
return (
|
|
74
|
+
<select
|
|
75
|
+
className={cn(
|
|
76
|
+
'w-full min-w-0 rounded-6 bg-background-field-base px-8 py-6 text-sm leading-20 text-foreground-neutral-base shadow-button-neutral outline-none transition-[color,box-shadow]',
|
|
77
|
+
'hover:bg-background-field-hover focus-visible:shadow-border-interactive-with-active disabled:cursor-not-allowed disabled:bg-background-neutral-disabled disabled:text-foreground-neutral-disabled',
|
|
78
|
+
'aria-invalid:shadow-border-error',
|
|
79
|
+
className,
|
|
80
|
+
)}
|
|
81
|
+
{...props}
|
|
82
|
+
{...wiring}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
}
|