From 6b37da2a53becd0364aea5abeae02ed7da9e0821 Mon Sep 17 00:00:00 2001
From: lucas77778 <3098274296@qq.com>
Date: Tue, 18 Aug 2026 08:56:01 +0000
Subject: [PATCH] fix(workbench): require models when adding providers
Amp-Thread-ID: https://ampcode.com/threads/T-01a013f3-4dbc-7295-8590-ef97be89b856
---
.../providers/__tests__/add-flow.test.tsx | 69 ++++++++++++++++++-
.../src/settings/providers/add-flow.tsx | 45 +++++++++---
.../settings/providers/model-selection.tsx | 16 ++++-
packages/presentation/i18n/src/locales/en.ts | 1 +
.../presentation/i18n/src/locales/zh-cn.ts | 1 +
5 files changed, 119 insertions(+), 13 deletions(-)
diff --git a/packages/client/workbench/src/settings/providers/__tests__/add-flow.test.tsx b/packages/client/workbench/src/settings/providers/__tests__/add-flow.test.tsx
index 1de5d1268..a410973fc 100644
--- a/packages/client/workbench/src/settings/providers/__tests__/add-flow.test.tsx
+++ b/packages/client/workbench/src/settings/providers/__tests__/add-flow.test.tsx
@@ -5,6 +5,7 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-libra
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { AgentRuntimeOnboarding } from '../../../agent-runtime/onboarding';
import { AddAccountForm, ServiceCatalogView } from '../add-flow';
+import type { ModelSources } from '../model-selection';
function translateKey(key: string): string {
return key;
@@ -39,6 +40,13 @@ function signedOutRuntimes(): AgentRuntimes {
};
}
+function addModel(id = 'test-model'): void {
+ fireEvent.change(screen.getByPlaceholderText('models.addPlaceholder'), {
+ target: { value: id },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'models.add' }));
+}
+
describe('subscription account creation', () => {
it('starts Claude login and creates the account only from the success callback', () => {
const login = vi.fn();
@@ -57,6 +65,8 @@ describe('subscription account creation', () => {
/>,
);
+ expect(screen.queryByRole('button', { name: 'login' })).toBeNull();
+ addModel('claude-sonnet-5');
fireEvent.click(screen.getByRole('button', { name: 'login' }));
expect(login).toHaveBeenCalledWith('claude-code', expect.any(Function));
expect(onSubmit).not.toHaveBeenCalled();
@@ -67,6 +77,7 @@ describe('subscription account creation', () => {
expect.objectContaining({
service: 'claude-sub',
credential: { type: 'oauth', agent: 'claude-code' },
+ models: [{ id: 'claude-sonnet-5' }],
}),
);
});
@@ -126,12 +137,17 @@ describe('subscription account creation', () => {
/>,
);
- fireEvent.click(screen.getByRole('button', { name: 'form.submit' }));
+ const submit = screen.getByRole('button', { name: 'form.submit' });
+ expect(submit).toHaveProperty('disabled', true);
+ expect(screen.getByText('models.required')).toBeTruthy();
+ addModel('gpt-5.6-sol');
+ fireEvent.click(submit);
expect(login).not.toHaveBeenCalled();
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
service: 'chatgpt-sub',
credential: { type: 'oauth', agent: 'codex' },
+ models: [{ id: 'gpt-5.6-sol' }],
}),
);
});
@@ -154,12 +170,16 @@ describe('non-subscription account creation', () => {
fireEvent.change(screen.getByPlaceholderText('sk-ant-…'), { target: { value: 'sk-ant-test' } });
fireEvent.click(screen.getByRole('button', { name: 'form.submit' }));
+ await waitFor(() => expect(onSubmit).not.toHaveBeenCalled());
+ addModel('claude-sonnet-5');
+ fireEvent.click(screen.getByRole('button', { name: 'form.submit' }));
expect(login).not.toHaveBeenCalled();
await waitFor(() =>
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
service: 'anthropic-api',
credential: { type: 'api-key', key: 'sk-ant-test' },
+ models: [{ id: 'claude-sonnet-5' }],
}),
),
);
@@ -187,6 +207,7 @@ describe('non-subscription account creation', () => {
const secret = document.querySelector('input[type="password"]');
if (!secret) throw new Error('credential input missing');
fireEvent.change(secret, { target: { value: 'stepfun-test-key' } });
+ addModel('step-3.5-flash');
fireEvent.click(screen.getByRole('button', { name: 'form.submit' }));
await waitFor(() =>
@@ -194,6 +215,7 @@ describe('non-subscription account creation', () => {
expect.objectContaining({
service: 'stepfun',
credential: { type: 'api-key', key: 'stepfun-test-key' },
+ models: [{ id: 'step-3.5-flash' }],
}),
),
);
@@ -217,10 +239,17 @@ describe('non-subscription account creation', () => {
it('adds LinkCode Gateway only after the explicit user action', async () => {
const createKey = vi.fn().mockResolvedValue('lc-gateway-key');
+ const probeInline = vi.fn().mockResolvedValue([{ id: 'anthropic/claude-sonnet-5' }]);
+ const sources: ModelSources = {
+ probeInline,
+ probeAccount: vi.fn(),
+ oauth: vi.fn(),
+ };
const onSubmit = vi.fn();
render(
{
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
expect(createKey).toHaveBeenCalledWith('serviceName.linkcode-gateway');
+ expect(probeInline).toHaveBeenCalledWith('linkcode-gateway', {
+ type: 'auth-token',
+ token: 'lc-gateway-key',
+ });
expect(onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
service: 'linkcode-gateway',
credential: { type: 'auth-token', token: 'lc-gateway-key' },
+ models: [{ id: 'anthropic/claude-sonnet-5' }],
}),
);
expect(onSubmit.mock.calls[0]?.[0]).not.toHaveProperty('endpoint');
@@ -282,6 +316,7 @@ describe('non-subscription account creation', () => {
const secret = container.querySelector('input[type="password"]');
if (!secret) throw new Error('credential input missing');
fireEvent.change(secret, { target: { value: 'cf-token' } });
+ addModel('gateway-model');
fireEvent.click(screen.getByRole('button', { name: 'form.submit' }));
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
@@ -290,8 +325,40 @@ describe('non-subscription account creation', () => {
service: 'cloudflare-gateway',
credential: { type: 'auth-token', token: 'cf-token' },
endpointParams: { account_id: '8f3a', gateway_id: 'prod' },
+ models: [{ id: 'gateway-model' }],
});
// One key can resolve to a different endpoint per agent, so none is pinned here.
expect(account).not.toHaveProperty('endpoint');
});
+
+ it('requires a model for a custom endpoint', async () => {
+ const onSubmit = vi.fn();
+ const { container } = render(
+ ,
+ );
+
+ fireEvent.change(screen.getByRole('textbox', { name: 'form.label' }), {
+ target: { value: 'Private endpoint' },
+ });
+ const secret = container.querySelector('input[type="password"]');
+ if (!secret) throw new Error('credential input missing');
+ fireEvent.change(secret, { target: { value: 'private-key' } });
+ fireEvent.click(screen.getByRole('button', { name: 'form.submit' }));
+ await waitFor(() => expect(onSubmit).not.toHaveBeenCalled());
+
+ addModel('private-model');
+ fireEvent.click(screen.getByRole('button', { name: 'form.submit' }));
+ await waitFor(() =>
+ expect(onSubmit).toHaveBeenCalledWith(
+ expect.objectContaining({ models: [{ id: 'private-model' }] }),
+ ),
+ );
+ });
});
diff --git a/packages/client/workbench/src/settings/providers/add-flow.tsx b/packages/client/workbench/src/settings/providers/add-flow.tsx
index be9e00d03..44534d63f 100644
--- a/packages/client/workbench/src/settings/providers/add-flow.tsx
+++ b/packages/client/workbench/src/settings/providers/add-flow.tsx
@@ -9,7 +9,13 @@ import {
serviceProtocols,
templatePlaceholders,
} from '@linkcode/providers';
-import type { Account, AccountModel, AccountProtocol, AgentRuntimes } from '@linkcode/schema';
+import type {
+ Account,
+ AccountModel,
+ AccountProtocol,
+ AccountSecret,
+ AgentRuntimes,
+} from '@linkcode/schema';
import { AccountModelSchema } from '@linkcode/schema';
import { AgentOnboardingCard, ServiceIcon } from '@linkcode/ui';
import { Button } from 'coss-ui/components/button';
@@ -49,13 +55,13 @@ function newAccountBase(label: string): Pick,
label: string,
- models: AccountModel[] = [],
+ models: AccountModel[],
): Account {
return {
...newAccountBase(label),
service: service.id,
credential: { type: 'oauth', agent: service.agent },
- ...(models.length > 0 && { models }),
+ models,
};
}
@@ -75,7 +81,7 @@ function catalogAccount(service: EndpointService, draft: CatalogDraft): Account
? { type: 'auth-token', token: draft.secret }
: { type: 'api-key', key: draft.secret },
...(!isObjectEmpty(trimmed) && { endpointParams: trimmed }),
- ...(draft.models.length > 0 && { models: draft.models }),
+ models: draft.models,
};
}
@@ -214,6 +220,7 @@ export function AddAccountForm({
@@ -239,11 +246,13 @@ type LinkCodeGatewayDraft = z.infer;
function LinkCodeGatewayForm({
service,
access,
+ sources,
busy,
onSubmit,
}: {
service: Extract;
access: LinkCodeGatewayAccess | undefined;
+ sources: ModelSources | undefined;
busy: boolean;
onSubmit: (account: Account) => void;
}): React.ReactNode {
@@ -257,6 +266,7 @@ function LinkCodeGatewayForm({
resolver: zodResolver(LinkCodeGatewayDraftSchema),
defaultValues: { label: t(`serviceName.${service.id}`) },
});
+ const [createdKey, setCreatedKey] = useState(undefined);
if (!access?.signedIn) {
return (
@@ -281,11 +291,17 @@ function LinkCodeGatewayForm({
className="flex flex-col gap-3"
onSubmit={handleSubmit(async ({ label }) => {
try {
- const key = await access.createKey(label);
+ if (!sources) throw new Error(t('models.fetchFailed'));
+ const key = createdKey ?? (await access.createKey(label));
+ setCreatedKey(key);
+ const credential: AccountSecret = { type: 'auth-token', token: key };
+ const models = await sources.probeInline(service.id, credential);
+ if (models.length === 0) throw new Error(t('models.required'));
onSubmit({
...newAccountBase(label),
service: service.id,
- credential: { type: 'auth-token', token: key },
+ credential,
+ models,
});
} catch (error) {
setError('root', {
@@ -305,7 +321,7 @@ function LinkCodeGatewayForm({
) : null}
-
+
{t('linkCodeUseGateway')}
@@ -444,6 +460,7 @@ function OauthCreateForm({
const cue = onboarding.cues[service.agent] ?? { state: 'needs-login', phase: 'idle' as const };
const loginInProgress =
cue.state === 'needs-login' && (cue.phase === 'opening' || cue.phase === 'awaiting-code');
+ const hasModels = models.length > 0;
return (
@@ -461,6 +478,7 @@ function OauthCreateForm({
disabled={busy || loginInProgress}
onChange={setModels}
onFetch={fetchModels}
+ required
selected={models}
/>
{loggedIn ? (
@@ -474,7 +492,7 @@ function OauthCreateForm({
onSubmit(oauthAccount(service, label, models))}
>
{t('form.submit')}
@@ -488,7 +506,7 @@ function OauthCreateForm({
onDownload={onboarding.download}
onContinueUnverified={onboarding.acknowledgeUnverified}
onLogin={
- busy || label.trim() === ''
+ !hasModels || busy || label.trim() === ''
? undefined
: (kind) => {
onboarding.login(kind, () => onSubmit(oauthAccount(service, label, models)));
@@ -506,7 +524,7 @@ const CatalogDraftSchema = z.object({
label: z.string().min(1),
secret: z.string().min(1),
placeholders: z.record(z.string(), z.string()),
- models: z.array(AccountModelSchema),
+ models: z.array(AccountModelSchema).min(1),
});
type CatalogDraft = z.infer;
@@ -606,6 +624,7 @@ function CatalogAccountForm({
disabled={busy}
onChange={field.onChange}
onFetch={fetchModels}
+ required
selected={field.value}
/>
)}
@@ -630,6 +649,9 @@ const CustomDraftSchema = z.object({
protocol: z.string(),
models: z.array(AccountModelSchema),
});
+const CustomCreateDraftSchema = CustomDraftSchema.extend({
+ models: z.array(AccountModelSchema).min(1),
+});
type CustomDraft = z.infer;
/** The full free-form account form (any endpoint, any protocol) — no catalog seeding. */
@@ -651,7 +673,7 @@ function CustomAccountForm({
handleSubmit,
formState: { isSubmitting },
} = useForm({
- resolver: zodResolver(CustomDraftSchema),
+ resolver: zodResolver(account === undefined ? CustomCreateDraftSchema : CustomDraftSchema),
defaultValues: {
label: account?.label ?? '',
type:
@@ -747,6 +769,7 @@ function CustomAccountForm({
disabled={busy}
onChange={field.onChange}
onFetch={fetchModels}
+ required={account === undefined}
selected={field.value}
/>
)}
diff --git a/packages/client/workbench/src/settings/providers/model-selection.tsx b/packages/client/workbench/src/settings/providers/model-selection.tsx
index 1f57a60f0..3554db750 100644
--- a/packages/client/workbench/src/settings/providers/model-selection.tsx
+++ b/packages/client/workbench/src/settings/providers/model-selection.tsx
@@ -21,6 +21,7 @@ export interface ModelSelectionProps {
selected: AccountModel[];
onChange: (models: AccountModel[]) => void;
disabled?: boolean;
+ required?: boolean;
}
/**
@@ -72,6 +73,7 @@ export function ModelSelection({
selected,
onChange,
disabled = false,
+ required = false,
}: ModelSelectionProps): React.ReactNode {
const t = useTranslations('settings.providers');
const [fetched, setFetched] = useState([]);
@@ -112,7 +114,14 @@ export function ModelSelection({
return (
-
{t('models.label')}
+
+ {t('models.label')}
+ {required ? (
+
+ {' *'}
+
+ ) : null}
+
{onFetch ? (
{onFetch ? t('models.hint') : t('models.hintUnlistable')}
+ {required && selected.length === 0 ? (
+
+ {t('models.required')}
+
+ ) : null}
{error !== undefined ? {error}
: null}
{listed.length > 0 ? (
diff --git a/packages/presentation/i18n/src/locales/en.ts b/packages/presentation/i18n/src/locales/en.ts
index 6afb64469..c908be79b 100644
--- a/packages/presentation/i18n/src/locales/en.ts
+++ b/packages/presentation/i18n/src/locales/en.ts
@@ -1080,6 +1080,7 @@ export const en = {
refresh: 'Fetch list',
fetchFailed: 'Could not read the model list',
secretFirst: 'Enter the key first, then fetch the model list',
+ required: 'Select at least one model before adding the account.',
add: 'Add',
addPlaceholder: 'Add a model id by hand',
},
diff --git a/packages/presentation/i18n/src/locales/zh-cn.ts b/packages/presentation/i18n/src/locales/zh-cn.ts
index 6dda18272..783a0aa41 100644
--- a/packages/presentation/i18n/src/locales/zh-cn.ts
+++ b/packages/presentation/i18n/src/locales/zh-cn.ts
@@ -1053,6 +1053,7 @@ export const zhCN = {
refresh: '获取列表',
fetchFailed: '获取模型列表失败',
secretFirst: '请先填写密钥,再获取模型列表',
+ required: '至少选择一个模型后才能添加账号。',
add: '添加',
addPlaceholder: '手动添加模型 ID',
},