Skip to content

Commit 41c4fde

Browse files
authored
fix(workbench): require models when adding providers (#458)
Amp-Thread-ID: https://ampcode.com/threads/T-01a013f3-4dbc-7295-8590-ef97be89b856
1 parent 4e710cd commit 41c4fde

5 files changed

Lines changed: 119 additions & 13 deletions

File tree

packages/client/workbench/src/settings/providers/__tests__/add-flow.test.tsx

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
55
import { afterEach, describe, expect, it, vi } from 'vitest';
66
import type { AgentRuntimeOnboarding } from '../../../agent-runtime/onboarding';
77
import { AddAccountForm, ServiceCatalogView } from '../add-flow';
8+
import type { ModelSources } from '../model-selection';
89

910
function translateKey(key: string): string {
1011
return key;
@@ -39,6 +40,13 @@ function signedOutRuntimes(): AgentRuntimes {
3940
};
4041
}
4142

43+
function addModel(id = 'test-model'): void {
44+
fireEvent.change(screen.getByPlaceholderText('models.addPlaceholder'), {
45+
target: { value: id },
46+
});
47+
fireEvent.click(screen.getByRole('button', { name: 'models.add' }));
48+
}
49+
4250
describe('subscription account creation', () => {
4351
it('starts Claude login and creates the account only from the success callback', () => {
4452
const login = vi.fn();
@@ -57,6 +65,8 @@ describe('subscription account creation', () => {
5765
/>,
5866
);
5967

68+
expect(screen.queryByRole('button', { name: 'login' })).toBeNull();
69+
addModel('claude-sonnet-5');
6070
fireEvent.click(screen.getByRole('button', { name: 'login' }));
6171
expect(login).toHaveBeenCalledWith('claude-code', expect.any(Function));
6272
expect(onSubmit).not.toHaveBeenCalled();
@@ -67,6 +77,7 @@ describe('subscription account creation', () => {
6777
expect.objectContaining({
6878
service: 'claude-sub',
6979
credential: { type: 'oauth', agent: 'claude-code' },
80+
models: [{ id: 'claude-sonnet-5' }],
7081
}),
7182
);
7283
});
@@ -126,12 +137,17 @@ describe('subscription account creation', () => {
126137
/>,
127138
);
128139

129-
fireEvent.click(screen.getByRole('button', { name: 'form.submit' }));
140+
const submit = screen.getByRole('button', { name: 'form.submit' });
141+
expect(submit).toHaveProperty('disabled', true);
142+
expect(screen.getByText('models.required')).toBeTruthy();
143+
addModel('gpt-5.6-sol');
144+
fireEvent.click(submit);
130145
expect(login).not.toHaveBeenCalled();
131146
expect(onSubmit).toHaveBeenCalledWith(
132147
expect.objectContaining({
133148
service: 'chatgpt-sub',
134149
credential: { type: 'oauth', agent: 'codex' },
150+
models: [{ id: 'gpt-5.6-sol' }],
135151
}),
136152
);
137153
});
@@ -154,12 +170,16 @@ describe('non-subscription account creation', () => {
154170

155171
fireEvent.change(screen.getByPlaceholderText('sk-ant-…'), { target: { value: 'sk-ant-test' } });
156172
fireEvent.click(screen.getByRole('button', { name: 'form.submit' }));
173+
await waitFor(() => expect(onSubmit).not.toHaveBeenCalled());
174+
addModel('claude-sonnet-5');
175+
fireEvent.click(screen.getByRole('button', { name: 'form.submit' }));
157176
expect(login).not.toHaveBeenCalled();
158177
await waitFor(() =>
159178
expect(onSubmit).toHaveBeenCalledWith(
160179
expect.objectContaining({
161180
service: 'anthropic-api',
162181
credential: { type: 'api-key', key: 'sk-ant-test' },
182+
models: [{ id: 'claude-sonnet-5' }],
163183
}),
164184
),
165185
);
@@ -187,13 +207,15 @@ describe('non-subscription account creation', () => {
187207
const secret = document.querySelector<HTMLInputElement>('input[type="password"]');
188208
if (!secret) throw new Error('credential input missing');
189209
fireEvent.change(secret, { target: { value: 'stepfun-test-key' } });
210+
addModel('step-3.5-flash');
190211
fireEvent.click(screen.getByRole('button', { name: 'form.submit' }));
191212

192213
await waitFor(() =>
193214
expect(onSubmit).toHaveBeenCalledWith(
194215
expect.objectContaining({
195216
service: 'stepfun',
196217
credential: { type: 'api-key', key: 'stepfun-test-key' },
218+
models: [{ id: 'step-3.5-flash' }],
197219
}),
198220
),
199221
);
@@ -217,10 +239,17 @@ describe('non-subscription account creation', () => {
217239

218240
it('adds LinkCode Gateway only after the explicit user action', async () => {
219241
const createKey = vi.fn().mockResolvedValue('lc-gateway-key');
242+
const probeInline = vi.fn().mockResolvedValue([{ id: 'anthropic/claude-sonnet-5' }]);
243+
const sources: ModelSources = {
244+
probeInline,
245+
probeAccount: vi.fn(),
246+
oauth: vi.fn(),
247+
};
220248
const onSubmit = vi.fn();
221249
render(
222250
<AddAccountForm
223251
serviceId="linkcode-gateway"
252+
sources={sources}
224253
runtimes={undefined}
225254
onboarding={onboarding()}
226255
busy={false}
@@ -241,10 +270,15 @@ describe('non-subscription account creation', () => {
241270

242271
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
243272
expect(createKey).toHaveBeenCalledWith('serviceName.linkcode-gateway');
273+
expect(probeInline).toHaveBeenCalledWith('linkcode-gateway', {
274+
type: 'auth-token',
275+
token: 'lc-gateway-key',
276+
});
244277
expect(onSubmit).toHaveBeenCalledWith(
245278
expect.objectContaining({
246279
service: 'linkcode-gateway',
247280
credential: { type: 'auth-token', token: 'lc-gateway-key' },
281+
models: [{ id: 'anthropic/claude-sonnet-5' }],
248282
}),
249283
);
250284
expect(onSubmit.mock.calls[0]?.[0]).not.toHaveProperty('endpoint');
@@ -282,6 +316,7 @@ describe('non-subscription account creation', () => {
282316
const secret = container.querySelector('input[type="password"]');
283317
if (!secret) throw new Error('credential input missing');
284318
fireEvent.change(secret, { target: { value: 'cf-token' } });
319+
addModel('gateway-model');
285320
fireEvent.click(screen.getByRole('button', { name: 'form.submit' }));
286321

287322
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
@@ -290,8 +325,40 @@ describe('non-subscription account creation', () => {
290325
service: 'cloudflare-gateway',
291326
credential: { type: 'auth-token', token: 'cf-token' },
292327
endpointParams: { account_id: '8f3a', gateway_id: 'prod' },
328+
models: [{ id: 'gateway-model' }],
293329
});
294330
// One key can resolve to a different endpoint per agent, so none is pinned here.
295331
expect(account).not.toHaveProperty('endpoint');
296332
});
333+
334+
it('requires a model for a custom endpoint', async () => {
335+
const onSubmit = vi.fn();
336+
const { container } = render(
337+
<AddAccountForm
338+
serviceId="custom"
339+
runtimes={undefined}
340+
onboarding={onboarding()}
341+
busy={false}
342+
onBack={vi.fn()}
343+
onSubmit={onSubmit}
344+
/>,
345+
);
346+
347+
fireEvent.change(screen.getByRole('textbox', { name: 'form.label' }), {
348+
target: { value: 'Private endpoint' },
349+
});
350+
const secret = container.querySelector('input[type="password"]');
351+
if (!secret) throw new Error('credential input missing');
352+
fireEvent.change(secret, { target: { value: 'private-key' } });
353+
fireEvent.click(screen.getByRole('button', { name: 'form.submit' }));
354+
await waitFor(() => expect(onSubmit).not.toHaveBeenCalled());
355+
356+
addModel('private-model');
357+
fireEvent.click(screen.getByRole('button', { name: 'form.submit' }));
358+
await waitFor(() =>
359+
expect(onSubmit).toHaveBeenCalledWith(
360+
expect.objectContaining({ models: [{ id: 'private-model' }] }),
361+
),
362+
);
363+
});
297364
});

packages/client/workbench/src/settings/providers/add-flow.tsx

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ import {
99
serviceProtocols,
1010
templatePlaceholders,
1111
} from '@linkcode/providers';
12-
import type { Account, AccountModel, AccountProtocol, AgentRuntimes } from '@linkcode/schema';
12+
import type {
13+
Account,
14+
AccountModel,
15+
AccountProtocol,
16+
AccountSecret,
17+
AgentRuntimes,
18+
} from '@linkcode/schema';
1319
import { AccountModelSchema } from '@linkcode/schema';
1420
import { AgentOnboardingCard, ServiceIcon } from '@linkcode/ui';
1521
import { Button } from 'coss-ui/components/button';
@@ -49,13 +55,13 @@ function newAccountBase(label: string): Pick<Account, 'id' | 'label' | 'createdA
4955
function oauthAccount(
5056
service: Extract<ServiceDescriptor, { kind: 'oauth' }>,
5157
label: string,
52-
models: AccountModel[] = [],
58+
models: AccountModel[],
5359
): Account {
5460
return {
5561
...newAccountBase(label),
5662
service: service.id,
5763
credential: { type: 'oauth', agent: service.agent },
58-
...(models.length > 0 && { models }),
64+
models,
5965
};
6066
}
6167

@@ -75,7 +81,7 @@ function catalogAccount(service: EndpointService, draft: CatalogDraft): Account
7581
? { type: 'auth-token', token: draft.secret }
7682
: { type: 'api-key', key: draft.secret },
7783
...(!isObjectEmpty(trimmed) && { endpointParams: trimmed }),
78-
...(draft.models.length > 0 && { models: draft.models }),
84+
models: draft.models,
7985
};
8086
}
8187

@@ -214,6 +220,7 @@ export function AddAccountForm({
214220
<LinkCodeGatewayForm
215221
service={service}
216222
access={linkCodeGateway}
223+
sources={sources}
217224
busy={busy}
218225
onSubmit={onSubmit}
219226
/>
@@ -239,11 +246,13 @@ type LinkCodeGatewayDraft = z.infer<typeof LinkCodeGatewayDraftSchema>;
239246
function LinkCodeGatewayForm({
240247
service,
241248
access,
249+
sources,
242250
busy,
243251
onSubmit,
244252
}: {
245253
service: Extract<ServiceDescriptor, { kind: 'endpoint' }>;
246254
access: LinkCodeGatewayAccess | undefined;
255+
sources: ModelSources | undefined;
247256
busy: boolean;
248257
onSubmit: (account: Account) => void;
249258
}): React.ReactNode {
@@ -257,6 +266,7 @@ function LinkCodeGatewayForm({
257266
resolver: zodResolver(LinkCodeGatewayDraftSchema),
258267
defaultValues: { label: t(`serviceName.${service.id}`) },
259268
});
269+
const [createdKey, setCreatedKey] = useState<string | undefined>(undefined);
260270

261271
if (!access?.signedIn) {
262272
return (
@@ -281,11 +291,17 @@ function LinkCodeGatewayForm({
281291
className="flex flex-col gap-3"
282292
onSubmit={handleSubmit(async ({ label }) => {
283293
try {
284-
const key = await access.createKey(label);
294+
if (!sources) throw new Error(t('models.fetchFailed'));
295+
const key = createdKey ?? (await access.createKey(label));
296+
setCreatedKey(key);
297+
const credential: AccountSecret = { type: 'auth-token', token: key };
298+
const models = await sources.probeInline(service.id, credential);
299+
if (models.length === 0) throw new Error(t('models.required'));
285300
onSubmit({
286301
...newAccountBase(label),
287302
service: service.id,
288-
credential: { type: 'auth-token', token: key },
303+
credential,
304+
models,
289305
});
290306
} catch (error) {
291307
setError('root', {
@@ -305,7 +321,7 @@ function LinkCodeGatewayForm({
305321
</p>
306322
) : null}
307323
<div className="flex justify-end pt-1">
308-
<Button type="submit" size="sm" disabled={busy || isSubmitting}>
324+
<Button type="submit" size="sm" disabled={busy || isSubmitting || !sources}>
309325
{t('linkCodeUseGateway')}
310326
</Button>
311327
</div>
@@ -444,6 +460,7 @@ function OauthCreateForm({
444460
const cue = onboarding.cues[service.agent] ?? { state: 'needs-login', phase: 'idle' as const };
445461
const loginInProgress =
446462
cue.state === 'needs-login' && (cue.phase === 'opening' || cue.phase === 'awaiting-code');
463+
const hasModels = models.length > 0;
447464

448465
return (
449466
<div className="flex flex-col gap-3">
@@ -461,6 +478,7 @@ function OauthCreateForm({
461478
disabled={busy || loginInProgress}
462479
onChange={setModels}
463480
onFetch={fetchModels}
481+
required
464482
selected={models}
465483
/>
466484
{loggedIn ? (
@@ -474,7 +492,7 @@ function OauthCreateForm({
474492
<Button
475493
type="button"
476494
size="sm"
477-
disabled={busy || label.trim() === ''}
495+
disabled={busy || label.trim() === '' || !hasModels}
478496
onClick={() => onSubmit(oauthAccount(service, label, models))}
479497
>
480498
{t('form.submit')}
@@ -488,7 +506,7 @@ function OauthCreateForm({
488506
onDownload={onboarding.download}
489507
onContinueUnverified={onboarding.acknowledgeUnverified}
490508
onLogin={
491-
busy || label.trim() === ''
509+
!hasModels || busy || label.trim() === ''
492510
? undefined
493511
: (kind) => {
494512
onboarding.login(kind, () => onSubmit(oauthAccount(service, label, models)));
@@ -506,7 +524,7 @@ const CatalogDraftSchema = z.object({
506524
label: z.string().min(1),
507525
secret: z.string().min(1),
508526
placeholders: z.record(z.string(), z.string()),
509-
models: z.array(AccountModelSchema),
527+
models: z.array(AccountModelSchema).min(1),
510528
});
511529
type CatalogDraft = z.infer<typeof CatalogDraftSchema>;
512530

@@ -606,6 +624,7 @@ function CatalogAccountForm({
606624
disabled={busy}
607625
onChange={field.onChange}
608626
onFetch={fetchModels}
627+
required
609628
selected={field.value}
610629
/>
611630
)}
@@ -630,6 +649,9 @@ const CustomDraftSchema = z.object({
630649
protocol: z.string(),
631650
models: z.array(AccountModelSchema),
632651
});
652+
const CustomCreateDraftSchema = CustomDraftSchema.extend({
653+
models: z.array(AccountModelSchema).min(1),
654+
});
633655
type CustomDraft = z.infer<typeof CustomDraftSchema>;
634656

635657
/** The full free-form account form (any endpoint, any protocol) — no catalog seeding. */
@@ -651,7 +673,7 @@ function CustomAccountForm({
651673
handleSubmit,
652674
formState: { isSubmitting },
653675
} = useForm<CustomDraft>({
654-
resolver: zodResolver(CustomDraftSchema),
676+
resolver: zodResolver(account === undefined ? CustomCreateDraftSchema : CustomDraftSchema),
655677
defaultValues: {
656678
label: account?.label ?? '',
657679
type:
@@ -747,6 +769,7 @@ function CustomAccountForm({
747769
disabled={busy}
748770
onChange={field.onChange}
749771
onFetch={fetchModels}
772+
required={account === undefined}
750773
selected={field.value}
751774
/>
752775
)}

packages/client/workbench/src/settings/providers/model-selection.tsx

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface ModelSelectionProps {
2121
selected: AccountModel[];
2222
onChange: (models: AccountModel[]) => void;
2323
disabled?: boolean;
24+
required?: boolean;
2425
}
2526

2627
/**
@@ -72,6 +73,7 @@ export function ModelSelection({
7273
selected,
7374
onChange,
7475
disabled = false,
76+
required = false,
7577
}: ModelSelectionProps): React.ReactNode {
7678
const t = useTranslations('settings.providers');
7779
const [fetched, setFetched] = useState<AccountModel[]>([]);
@@ -112,7 +114,14 @@ export function ModelSelection({
112114
return (
113115
<div className="flex flex-col gap-2">
114116
<div className="flex items-center justify-between gap-2">
115-
<span className="font-medium text-sm">{t('models.label')}</span>
117+
<span className="font-medium text-sm">
118+
{t('models.label')}
119+
{required ? (
120+
<span aria-hidden="true" className="text-destructive">
121+
{' *'}
122+
</span>
123+
) : null}
124+
</span>
116125
{onFetch ? (
117126
<Button
118127
type="button"
@@ -131,6 +140,11 @@ export function ModelSelection({
131140
<p className="text-muted-foreground text-xs">
132141
{onFetch ? t('models.hint') : t('models.hintUnlistable')}
133142
</p>
143+
{required && selected.length === 0 ? (
144+
<p aria-live="polite" className="text-destructive text-xs">
145+
{t('models.required')}
146+
</p>
147+
) : null}
134148
{error !== undefined ? <p className="text-destructive text-xs">{error}</p> : null}
135149
{listed.length > 0 ? (
136150
<div className="flex max-h-56 flex-col gap-1 overflow-y-auto rounded-lg border border-border p-2">

0 commit comments

Comments
 (0)