You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by zh...@apache.org on 2026/08/31 15:10:10 UTC

(maka) 02/02: feat(desktop): add host-backed API key enrollment flow

This is an automated email from the ASF dual-hosted git repository.

me2seeks pushed a commit to branch feat/desktop-api-key-host-onboarding
in repository https://gitbox.apache.org/repos/asf/maka.git

commit 28e6f5f4c21f6e25f442e1fafb346a2e4cf77390
Author: me2seeks <me...@gmail.com>
AuthorDate: Mon Aug 31 23:04:20 2026 +0800

    feat(desktop): add host-backed API key enrollment flow
---
 .../src/renderer/locales/settings-provider-copy.ts |  12 +-
 .../src/renderer/settings/provider-add-form.tsx    | 335 ++++++++++++++++++++-
 .../renderer/settings/provider-catalog-page.tsx    |  18 +-
 .../src/renderer/settings/providers-panel.tsx      | 145 +++++++--
 .../src/renderer/settings/settings-surface.tsx     |  10 +
 .../stories/settings/provider-settings.stories.tsx | 305 ++++++++++++++++++-
 6 files changed, 773 insertions(+), 52 deletions(-)

diff --git a/apps/desktop/src/renderer/locales/settings-provider-copy.ts b/apps/desktop/src/renderer/locales/settings-provider-copy.ts
index c2ad504ded..d46309144e 100644
--- a/apps/desktop/src/renderer/locales/settings-provider-copy.ts
+++ b/apps/desktop/src/renderer/locales/settings-provider-copy.ts
@@ -177,7 +177,7 @@ const zhCopy = {
     tabs: { all: '全部', recommended: '推荐', accounts: '账号', plans: '模型计划', api: 'API', aggregators: '聚合服务', local: '本地' },
     loadFailed: '载入模型连接失败', loadingAria: '正在加载模型供应商', connections: '模型连接',
     retry: '点击重试。', empty: '还没有模型连接', connectionRemoved: '原连接已被删除或移除,已返回模型连接列表。',
-    connectedLoading: '账号已连接,正在载入新的模型连接…', connectedLoadFailed: '账号已连接,但暂时无法载入新的模型连接。',
+    connectedLoading: '连接已添加,正在载入详情…', connectedLoadFailed: '连接已添加,但暂时无法刷新连接列表。',
     connectionIdentityChanged: '新连接的身份与登录结果不一致,请返回连接列表后重试。',
     emptyHelp: '从下方选择一种连接方式开始。', default: '默认', setDefault: '设为默认', setDefaultTitle: '让新任务默认使用这个连接', setDefaultPending: '设置中…', setDefaultFailed: '设为默认失败', addHelp: '选择账号登录、模型计划、API、聚合服务或本地运行时。',
     categoriesAria: '模型供应商分类', searchPlaceholder: '搜索服务商', searchAria: '搜索模型服务商', noMatch: '没有匹配的服务商', clearSearch: '清除搜索',
@@ -203,6 +203,10 @@ const zhCopy = {
     saving: '保存中…', save: '保存供应商', keyRequired: (name: string) => `请填写 ${name} API Key`,
     apiKeyLabel: 'API Key', accountIdLabel: 'Cloudflare Account ID', endpointLabel: '服务地址',
     defaultModel: '默认模型', defaultModelPlaceholder: '留空即可,保存后自动拉取', defaultModelHelp: '保存后 Maka 会向该端点拉取模型目录。只有当端点不提供目录时,才需要在这里手填一个模型 ID。',
+    onboardingVerifyAndChoose: '验证并选择模型', onboardingVerifying: '正在验证密钥并获取模型…',
+    onboardingChooseModels: '选择此连接使用的模型', onboardingChooseModelsHelp: '添加后仍可在连接详情中启用其他模型。', onboardingEnabledModels: '启用的模型', onboardingSearchModels: '搜索模型', onboardingAddConnection: '添加连接', onboardingBack: '返回修改',
+    onboardingAuthFailed: '密钥验证失败,请检查后重试。', onboardingTimeout: '验证超时,请检查网络或代理后重试。', onboardingNetwork: '无法连接模型服务,请检查网络、代理或服务地址。', onboardingUnavailable: '模型连接服务暂时不可用,请稍后重试。', onboardingInvalidResponse: '模型服务返回了无法识别的结果,请稍后重试。', onboardingCatalogFull: '连接数量已达上限,请先删除不再使用的连接。', onboardingModelsChanged: '可用模型已发生变化,请重新验证后选择。', onboardingNoModels: '没有发现可用模型,当前未创建连接。', onboardingSelectModel: '请至少启用一个模型。',
+    onboardingOutcomeUnknown: '保存结果暂时无法确认', onboardingOutcomeUnknownDetail: '请勿再次添加,以免创建重复连接。重新加载连接列表并检查该连接是否已经出现;仍不确定时请先重连 Runtime Host。', onboardingReloadConnections: '重新加载连接列表', onboardingRestart: '仍要添加另一个连接',
     ...zhCapabilitiesCopy,
   },
   oauthFlow: {
@@ -333,7 +337,7 @@ const enCopy: ProviderSettingsCopy = {
     tabs: { all: 'All', recommended: 'Recommended', accounts: 'Accounts', plans: 'Model plans', api: 'API', aggregators: 'Aggregators', local: 'Local' },
     loadFailed: 'Failed to load model connections', loadingAria: 'Loading model providers', connections: 'Connections',
     retry: 'Select to retry.', empty: 'No model connections yet', connectionRemoved: 'The original connection was deleted or removed. Returned to the connection list.',
-    connectedLoading: 'Account connected. Loading the new model connection…', connectedLoadFailed: 'Account connected, but the new model connection could not be loaded yet.',
+    connectedLoading: 'Connection added. Loading its details…', connectedLoadFailed: 'Connection added, but the connection list could not be refreshed yet.',
     connectionIdentityChanged: 'The new connection identity did not match the sign-in result. Return to the connection list and try again.',
     emptyHelp: 'Choose a connection method below to begin.', default: 'Default', setDefault: 'Set as default', setDefaultTitle: 'New chats will use this connection', setDefaultPending: 'Setting…', setDefaultFailed: 'Could not set as default', addHelp: 'Choose account sign-in, a model plan, API, aggregator, or local runtime.',
     categoriesAria: 'Model provider categories', searchPlaceholder: 'Search providers', searchAria: 'Search model providers', noMatch: 'No matching providers', clearSearch: 'Clear search',
@@ -359,6 +363,10 @@ const enCopy: ProviderSettingsCopy = {
     saving: 'Saving…', save: 'Save provider', keyRequired: (name: string) => `Enter the ${name} API key`,
     apiKeyLabel: 'API key', accountIdLabel: 'Cloudflare Account ID', endpointLabel: 'Service URL',
     defaultModel: 'Default model', defaultModelPlaceholder: 'Leave empty — fetched after saving', defaultModelHelp: 'Maka fetches the model catalog from this endpoint after saving. Type a model id here only if the endpoint serves no catalog.',
+    onboardingVerifyAndChoose: 'Verify and choose models', onboardingVerifying: 'Verifying the key and loading models…',
+    onboardingChooseModels: 'Choose models for this connection', onboardingChooseModelsHelp: 'You can enable more models from the connection details later.', onboardingEnabledModels: 'Enabled models', onboardingSearchModels: 'Search models', onboardingAddConnection: 'Add connection', onboardingBack: 'Back to edit',
+    onboardingAuthFailed: 'The key could not be verified. Check it and try again.', onboardingTimeout: 'Verification timed out. Check the network or proxy and try again.', onboardingNetwork: 'Could not reach the model provider. Check the network, proxy, or service URL.', onboardingUnavailable: 'The model connection service is unavailable. Try again later.', onboardingInvalidResponse: 'The model provider returned an unrecognized response. Try again later.', onboardingCatalogFull: 'The con [...]
+    onboardingOutcomeUnknown: 'The save result cannot be confirmed', onboardingOutcomeUnknownDetail: 'Do not add it again yet, because that could create a duplicate connection. Reload the connection list and check whether this connection appeared; reconnect the Runtime Host if the result is still unclear.', onboardingReloadConnections: 'Reload connection list', onboardingRestart: 'Add another connection anyway',
     ...enCapabilitiesCopy,
   },
   oauthFlow: {
diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx
index a3f9c592d6..88a8af26af 100644
--- a/apps/desktop/src/renderer/settings/provider-add-form.tsx
+++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx
@@ -27,7 +27,7 @@ import {
   providerAuthRequiresSecret,
   providerAuthSupportsApiKey,
 } from '@maka/core/llm-connections';
-import { Banner, HStack, VStack } from '@astryxdesign/core';
+import { Banner, HStack, MultiSelector, Text, VStack } from '@astryxdesign/core';
 import { Collapsible } from '@astryxdesign/core/Collapsible';
 import {
   Button,
@@ -44,7 +44,9 @@ import { useActionGuard } from './use-action-guard';
 import {
   categoryLabel,
   providerPanelActionErrorMessage,
+  type ApiKeyOnboardingBridge,
   type ConnectionsBridge,
+  type DesktopConnectionOnboardingIdentity,
 } from './provider-panel-shared';
 import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
 import {
@@ -55,6 +57,10 @@ import {
 } from './request-customization-editor';
 import {
   createProviderWithDiscovery,
+  apiKeyOnboardingRoute,
+  initialOnboardingModelIds,
+  initialManagedOnboardingPhase,
+  stableOnboardingModels,
   validateAddProviderDraft,
   type AddProviderIssue,
 } from './provider-add-submission';
@@ -70,12 +76,27 @@ type ProviderFormError = {
   message: string;
 };
 
+type ManagedOnboardingPhase =
+  | { readonly kind: 'input' }
+  | {
+      readonly kind: 'models';
+      readonly models: ReturnType<typeof stableOnboardingModels>;
+      readonly selectedIds: readonly string[];
+    }
+  | { readonly kind: 'outcome_unknown' };
+
 export function AddProviderForm(props: {
   bridge: ConnectionsBridge;
+  apiKeyOnboardingBridge?: ApiKeyOnboardingBridge;
   providerType: ProviderType;
   existingSlugs: string[];
   onCancel(): void;
   onCreated(slug: string, modelDiscoveryError?: unknown): Promise<void>;
+  onOnboarded?(identity: DesktopConnectionOnboardingIdentity): Promise<void>;
+  onOnboardingOutcomeUnknown?(): Promise<void>;
+  managedSaveDispatched?: boolean;
+  onManagedSaveDispatched?(): number | undefined;
+  onManagedSaveSettled?(attemptId: number | undefined): void;
 }) {
   const locale = useUiLocale();
   const copy = getProviderSettingsCopy(locale).add;
@@ -93,7 +114,14 @@ export function AddProviderForm(props: {
   const [requestHeaders, setRequestHeaders] = useState<RequestHeaderDraft[]>([]);
   const [requestBodyText, setRequestBodyText] = useState('');
   const [advancedOpen, setAdvancedOpen] = useState(false);
-  const [error, setError] = useState<ProviderFormError | null>(null);
+  const [formState, setFormState] = useState<{
+    readonly managedPhase: ManagedOnboardingPhase;
+    readonly error: ProviderFormError | null;
+  }>(() => ({
+    managedPhase: { kind: initialManagedOnboardingPhase(props.managedSaveDispatched === true) },
+    error: null,
+  }));
+  const { managedPhase, error } = formState;
   const [busy, setBusy] = useState(false);
   const submitGuard = useActionGuard<'submit'>();
   const addProviderMountedRef = useMountedRef();
@@ -106,6 +134,27 @@ export function AddProviderForm(props: {
   const requiresApiKey = providerAuthRequiresSecret(props.providerType) && supportsApiKey;
   const usesApiKeyDialog = usesQuickApiKeyDialog(props.providerType);
 
+  function setManagedPhase(next: ManagedOnboardingPhase) {
+    setFormState((current) => ({ ...current, managedPhase: next }));
+  }
+
+  function setError(
+    next:
+      | ProviderFormError
+      | null
+      | ((current: ProviderFormError | null) => ProviderFormError | null),
+  ) {
+    setFormState((current) => ({
+      ...current,
+      error: typeof next === 'function' ? next(current.error) : next,
+    }));
+  }
+
+  function resetManagedVerification(options?: { clearKey?: boolean }) {
+    setManagedPhase({ kind: 'input' });
+    if (options?.clearKey) setApiKey('');
+  }
+
   function clearFieldError(field: ProviderFormField) {
     setError((current) =>
       current?.field === field ? null : current,
@@ -129,18 +178,141 @@ export function AddProviderForm(props: {
     return copy.accountLogin;
   }
 
+  function onboardingFailureMessage(
+    result:
+      | Exclude<Awaited<ReturnType<ApiKeyOnboardingBridge['verify']>>, { kind: 'verified' }>
+      | Exclude<
+          Extract<Awaited<ReturnType<ApiKeyOnboardingBridge['save']>>, { kind: 'result' }>['result'],
+          { kind: 'saved' }
+        >,
+  ): string {
+    if (result.kind === 'failed') {
+      if (result.errorClass === 'auth') return copy.onboardingAuthFailed;
+      if (result.errorClass === 'timeout') return copy.onboardingTimeout;
+      if (result.errorClass === 'network') return copy.onboardingNetwork;
+      if (result.errorClass === 'provider_unavailable') return copy.onboardingUnavailable;
+      return copy.onboardingInvalidResponse;
+    }
+    if (result.reason === 'catalog_full') return copy.onboardingCatalogFull;
+    if (result.reason === 'model_unavailable' || result.reason === 'superseded') {
+      return copy.onboardingModelsChanged;
+    }
+    if (result.reason === 'credential_not_configured') return copy.keyRequired(display.name);
+    return copy.onboardingUnavailable;
+  }
+
+  function enterOutcomeUnknown() {
+    setApiKey('');
+    setBusy(false);
+    setManagedPhase({ kind: 'outcome_unknown' });
+  }
+
+  async function verifyManagedApiKey(normalizedApiKey: string) {
+    const onboarding = props.apiKeyOnboardingBridge;
+    if (!onboarding) return;
+    submitGuard.begin('submit');
+    setBusy(true);
+    try {
+      const result = await onboarding.verify({
+        target: { kind: 'create', providerType: props.providerType },
+        apiKey: normalizedApiKey || null,
+        baseUrl: null,
+      });
+      if (!addProviderMountedRef.current) return;
+      if (result.kind !== 'verified') {
+        setError({
+          field:
+            result.kind === 'failed' && result.errorClass === 'auth'
+              ? 'apiKey'
+              : 'form',
+          message: onboardingFailureMessage(result),
+        });
+        return;
+      }
+      const models = stableOnboardingModels(result.models);
+      const selectedIds = initialOnboardingModelIds(models, recommendedDefaultModel);
+      if (selectedIds.length === 0) {
+        setError({ field: 'form', message: copy.onboardingNoModels });
+        return;
+      }
+      setManagedPhase({ kind: 'models', models, selectedIds });
+    } catch (err) {
+      if (addProviderMountedRef.current) {
+        setError({ field: 'form', message: providerPanelActionErrorMessage(err, locale) });
+      }
+    } finally {
+      submitGuard.finish();
+      if (addProviderMountedRef.current) setBusy(false);
+    }
+  }
+
+  async function saveManagedApiKey(
+    normalizedApiKey: string,
+    phase: Extract<ManagedOnboardingPhase, { kind: 'models' }>,
+  ) {
+    const onboarding = props.apiKeyOnboardingBridge;
+    if (!onboarding || phase.selectedIds.length === 0) {
+      setError({ field: 'form', message: copy.onboardingSelectModel });
+      return;
+    }
+    const selected = new Set(phase.selectedIds);
+    const stableIds = phase.models
+      .map((model) => model.id)
+      .filter((modelId) => selected.has(modelId));
+    if (selected.has(recommendedDefaultModel)) {
+      stableIds.splice(stableIds.indexOf(recommendedDefaultModel), 1);
+      stableIds.unshift(recommendedDefaultModel);
+    }
+    submitGuard.begin('submit');
+    const attemptId = props.onManagedSaveDispatched?.();
+    setBusy(true);
+    try {
+      const outcome = await onboarding.save({
+        target: { kind: 'create', providerType: props.providerType },
+        apiKey: normalizedApiKey || null,
+        baseUrl: null,
+        enabledModelIds: stableIds,
+      });
+      if (outcome.kind !== 'outcome_unknown') props.onManagedSaveSettled?.(attemptId);
+      if (!addProviderMountedRef.current) return;
+      if (outcome.kind === 'outcome_unknown') {
+        enterOutcomeUnknown();
+        return;
+      }
+      if (outcome.kind === 'not_saved') {
+        setError({ field: 'form', message: copy.onboardingUnavailable });
+        return;
+      }
+      const result = outcome.result;
+      if (result.kind === 'saved') {
+        setApiKey('');
+        await props.onOnboarded?.(result.connection);
+        return;
+      }
+      if (
+        result.kind === 'rejected' &&
+        (result.reason === 'model_unavailable' || result.reason === 'superseded')
+      ) {
+        setManagedPhase({ kind: 'input' });
+      }
+      if (result.kind === 'failed' && result.errorClass === 'auth') {
+        setManagedPhase({ kind: 'input' });
+      }
+      setError({
+        field: result.kind === 'failed' && result.errorClass === 'auth' ? 'apiKey' : 'form',
+        message: onboardingFailureMessage(result),
+      });
+    } catch {
+      if (addProviderMountedRef.current) enterOutcomeUnknown();
+    } finally {
+      submitGuard.finish();
+      if (addProviderMountedRef.current && managedPhase.kind !== 'outcome_unknown') setBusy(false);
+    }
+  }
+
   async function submit() {
     if (submitGuard.current !== null) return;
     setError(null);
-    const issue = validateAddProviderDraft({
-      providerType: props.providerType,
-      slug,
-      existingSlugs: props.existingSlugs,
-      apiKey,
-      cloudflareAccountId,
-      baseUrl,
-    });
-    if (issue) return setError({ field: issue.field, message: issueMessage(issue) });
     const normalizedApiKey = apiKey.trim();
     const normalizedCloudflareAccountId = cloudflareAccountId.trim();
     const normalizedDefaultModel = defaultModel.trim();
@@ -153,6 +325,31 @@ export function AddProviderForm(props: {
       setAdvancedOpen(true);
       return setError({ field: 'advancedRequest', message: copy.requestCustomizationInvalid });
     }
+    const onboardingRoute = apiKeyOnboardingRoute({
+      providerType: props.providerType,
+      requestHeaderCount: Object.keys(normalizedRequestHeaders).length,
+      hasRequestBodyOverlay: requestBodyOverlay !== undefined,
+    });
+    if (onboardingRoute.kind === 'host' && props.apiKeyOnboardingBridge) {
+      if (requiresApiKey && !normalizedApiKey) {
+        return setError({ field: 'apiKey', message: copy.keyRequired(display.name) });
+      }
+      if (managedPhase.kind === 'models') {
+        await saveManagedApiKey(normalizedApiKey, managedPhase);
+      } else if (managedPhase.kind === 'input') {
+        await verifyManagedApiKey(normalizedApiKey);
+      }
+      return;
+    }
+    const issue = validateAddProviderDraft({
+      providerType: props.providerType,
+      slug,
+      existingSlugs: props.existingSlugs,
+      apiKey,
+      cloudflareAccountId,
+      baseUrl,
+    });
+    if (issue) return setError({ field: issue.field, message: issueMessage(issue) });
     submitGuard.begin('submit');
     setBusy(true);
     try {
@@ -209,11 +406,13 @@ export function AddProviderForm(props: {
           headers={requestHeaders}
           onHeadersChange={(headers) => {
             setRequestHeaders(headers);
+            resetManagedVerification();
             clearFieldError('advancedRequest');
           }}
           bodyText={requestBodyText}
           onBodyTextChange={(value) => {
             setRequestBodyText(value);
+            resetManagedVerification();
             clearFieldError('advancedRequest');
           }}
           disabled={busy}
@@ -235,6 +434,85 @@ export function AddProviderForm(props: {
       </VStack>
     </Collapsible>
   );
+  const quickUsesManagedOnboarding = Boolean(
+    props.apiKeyOnboardingBridge &&
+      apiKeyOnboardingRoute({
+        providerType: props.providerType,
+        requestHeaderCount: requestHeaders.length,
+        hasRequestBodyOverlay: requestBodyText.trim().length > 0,
+      }).kind === 'host',
+  );
+
+  if (usesApiKeyDialog && managedPhase.kind === 'outcome_unknown') {
+    return (
+      <VStack gap={3} data-maka-contract="api-key-onboarding-outcome-unknown">
+        <Banner
+          status="warning"
+          role="status"
+          title={copy.onboardingOutcomeUnknown}
+          description={copy.onboardingOutcomeUnknownDetail}
+        />
+        <HStack gap={2} justify="end">
+          <Button
+            variant="secondary"
+            label={copy.onboardingReloadConnections}
+            clickAction={async () => props.onOnboardingOutcomeUnknown?.()}
+          />
+        </HStack>
+      </VStack>
+    );
+  }
+
+  if (usesApiKeyDialog && managedPhase.kind === 'models') {
+    const options = managedPhase.models.map((model) => ({
+      value: model.id,
+      label: model.displayName?.trim() || model.id,
+    }));
+    return (
+      <VStack as="form" gap={3} onSubmit={submitApiKey} data-maka-contract="api-key-onboarding-models">
+        <VStack gap={1}>
+          <Text weight="semibold">{copy.onboardingChooseModels}</Text>
+          <Text type="supporting" color="secondary">{copy.onboardingChooseModelsHelp}</Text>
+        </VStack>
+        <MultiSelector
+          label={copy.onboardingEnabledModels}
+          options={options}
+          value={[...managedPhase.selectedIds]}
+          onChange={(selectedIds) => {
+            setManagedPhase({ ...managedPhase, selectedIds });
+            clearFieldError('form');
+          }}
+          isDisabled={busy}
+          placeholder={copy.onboardingSelectModel}
+          triggerDisplay="labels"
+          hasSearch
+          searchPlaceholder={copy.onboardingSearchModels}
+          width="100%"
+        />
+        <div role="status" aria-live="polite">
+          {busy ? <Text type="supporting">{copy.saving}</Text> : null}
+        </div>
+        {error?.field === 'form' && <Banner status="error" title={error.message} />}
+        <HStack gap={2} justify="end">
+          <Button
+            variant="ghost"
+            isDisabled={busy}
+            onClick={() => {
+              resetManagedVerification();
+              setError(null);
+            }}
+            label={copy.onboardingBack}
+          />
+          <Button
+            variant="primary"
+            type="submit"
+            isDisabled={busy || managedPhase.selectedIds.length === 0}
+            label={busy ? copy.saving : copy.onboardingAddConnection}
+          />
+        </HStack>
+      </VStack>
+    );
+  }
 
   if (usesApiKeyDialog) {
     return (
@@ -243,6 +521,7 @@ export function AddProviderForm(props: {
           value={apiKey}
           onChange={(next) => {
             setApiKey(next);
+            resetManagedVerification();
             clearFieldError('apiKey');
           }}
           placeholder={copy.apiKeyPlaceholder}
@@ -258,12 +537,30 @@ export function AddProviderForm(props: {
           hasAutoFocus
         />
         {advancedRequestEditor}
+        <div role="status" aria-live="polite">
+          {busy ? (
+            <Text type="supporting">
+              {quickUsesManagedOnboarding ? copy.onboardingVerifying : copy.saving}
+            </Text>
+          ) : null}
+        </div>
         {error?.field === 'form' && (
           <Banner status="error" title={error.message} />
         )}
         <HStack gap={2} justify="end">
           <Button variant="ghost" isDisabled={busy} onClick={props.onCancel} label={copy.cancel} />
-          <Button variant="primary" type="submit" isDisabled={busy} label={busy ? copy.saving : copy.save} />
+          <Button
+            variant="primary"
+            type="submit"
+            isDisabled={busy}
+            label={quickUsesManagedOnboarding
+              ? busy
+                ? copy.onboardingVerifying
+                : copy.onboardingVerifyAndChoose
+              : busy
+                ? copy.saving
+                : copy.save}
+          />
         </HStack>
       </VStack>
     );
@@ -283,6 +580,7 @@ export function AddProviderForm(props: {
             value={apiKey}
             onChange={(next) => {
               setApiKey(next);
+              resetManagedVerification();
               clearFieldError('apiKey');
             }}
             placeholder={copy.apiKeyPlaceholder}
@@ -301,6 +599,7 @@ export function AddProviderForm(props: {
           value={slug}
           onChange={(value) => {
             setSlug(value);
+            resetManagedVerification();
             clearFieldError('slug');
           }}
           placeholder="my-provider"
@@ -314,7 +613,10 @@ export function AddProviderForm(props: {
         />
         <TextInput
           value={name}
-          onChange={(value) => setName(value)}
+          onChange={(value) => {
+            setName(value);
+            resetManagedVerification();
+          }}
           placeholder={display.name}
           isDisabled={isExperimental || busy}
           label={copy.name}
@@ -324,6 +626,7 @@ export function AddProviderForm(props: {
             value={cloudflareAccountId}
             onChange={(value) => {
               setCloudflareAccountId(value);
+              resetManagedVerification();
               clearFieldError('accountId');
             }}
             placeholder={copy.accountIdPlaceholder}
@@ -341,6 +644,7 @@ export function AddProviderForm(props: {
             value={baseUrl}
             onChange={(value) => {
               setBaseUrl(value);
+              resetManagedVerification();
               clearFieldError('baseUrl');
             }}
             placeholder={defaults.baseUrl || 'https://…'}
@@ -357,7 +661,10 @@ export function AddProviderForm(props: {
         {showsDefaultModel && (
           <TextInput
             value={defaultModel}
-            onChange={setDefaultModel}
+            onChange={(value) => {
+              setDefaultModel(value);
+              resetManagedVerification();
+            }}
             placeholder={copy.defaultModelPlaceholder}
             isDisabled={isExperimental || busy}
             label={copy.defaultModel}
diff --git a/apps/desktop/src/renderer/settings/provider-catalog-page.tsx b/apps/desktop/src/renderer/settings/provider-catalog-page.tsx
index b1d3c8ecad..8b25a49388 100644
--- a/apps/desktop/src/renderer/settings/provider-catalog-page.tsx
+++ b/apps/desktop/src/renderer/settings/provider-catalog-page.tsx
@@ -39,7 +39,11 @@ import { Button, TextInput, useUiLocale } from '@maka/ui';
 import { AddProviderForm } from './provider-add-form';
 import { ProviderLogo, providerDisplay } from './provider-display';
 import { OAuthLoginPanel, useOAuthCards, type OAuthCardId } from './provider-oauth-section';
-import { type ConnectionsBridge } from './provider-panel-shared';
+import {
+  type ApiKeyOnboardingBridge,
+  type ConnectionsBridge,
+  type DesktopConnectionOnboardingIdentity,
+} from './provider-panel-shared';
 import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
 
 type CatalogCategory = ProviderCatalogGroup | 'all' | 'recommended' | 'accounts';
@@ -225,10 +229,16 @@ export function ProviderCatalogPage(props: {
  */
 export function ProviderSetupPage(props: {
   bridge: ConnectionsBridge;
+  apiKeyOnboardingBridge?: ApiKeyOnboardingBridge;
   target: SetupTarget;
   existingSlugs: string[];
   onCancel(): void;
   onCreated(slug: string, modelDiscoveryError?: unknown): Promise<void>;
+  onOnboarded?(identity: DesktopConnectionOnboardingIdentity): Promise<void>;
+  onOnboardingOutcomeUnknown?(): Promise<void>;
+  managedSaveDispatched?: boolean;
+  onManagedSaveDispatched?(): number | undefined;
+  onManagedSaveSettled?(attemptId: number | undefined): void;
   onAccountCreated(connection?: CreatedOAuthConnectionIdentity): Promise<void>;
   labelledBy?: string;
 }) {
@@ -244,10 +254,16 @@ export function ProviderSetupPage(props: {
       <AddProviderForm
         key={props.target.providerType}
         bridge={props.bridge}
+        apiKeyOnboardingBridge={props.apiKeyOnboardingBridge}
         providerType={props.target.providerType}
         existingSlugs={props.existingSlugs}
         onCancel={props.onCancel}
         onCreated={props.onCreated}
+        onOnboarded={props.onOnboarded}
+        onOnboardingOutcomeUnknown={props.onOnboardingOutcomeUnknown}
+        managedSaveDispatched={props.managedSaveDispatched}
+        onManagedSaveDispatched={props.onManagedSaveDispatched}
+        onManagedSaveSettled={props.onManagedSaveSettled}
       />
     </div>
   );
diff --git a/apps/desktop/src/renderer/settings/providers-panel.tsx b/apps/desktop/src/renderer/settings/providers-panel.tsx
index 9e95ed559b..4dd4d6db23 100644
--- a/apps/desktop/src/renderer/settings/providers-panel.tsx
+++ b/apps/desktop/src/renderer/settings/providers-panel.tsx
@@ -54,9 +54,17 @@ import { useSettingsRouteFocus } from './settings-route-focus';
 import { SettingsRouteHeader } from './settings-route-header';
 import { ProviderLogo, providerDisplay } from './provider-display';
 import { oauthPanelSubtitle } from './provider-oauth-section';
-import { providerPanelActionErrorMessage, type ConnectionsBridge } from './provider-panel-shared';
+import {
+  providerPanelActionErrorMessage,
+  type ApiKeyOnboardingBridge,
+  type ConnectionsBridge,
+  type DesktopConnectionOnboardingIdentity,
+} from './provider-panel-shared';
 import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
-import { useRuntimeHostSettingsErrorReporter } from './runtime-host-settings-target.js';
+import {
+  RuntimeHostSettingsGenerationBoundary,
+  useRuntimeHostSettingsErrorReporter,
+} from './runtime-host-settings-target.js';
 
 export type { ConnectionsBridge } from './provider-panel-shared';
 
@@ -80,8 +88,16 @@ export type { ConnectionsBridge } from './provider-panel-shared';
 type PanelRoute =
   | { kind: 'list' }
   | { kind: 'catalog' }
-  | { kind: 'setup'; target: SetupTarget; origin: 'list' | 'catalog' }
-  | { kind: 'adopting-oauth'; identity: CreatedOAuthConnectionIdentity }
+  | {
+      kind: 'setup';
+      target: SetupTarget;
+      origin: 'list' | 'catalog';
+      managedSaveDispatched?: true;
+    }
+  | {
+      kind: 'adopting-connection';
+      identity: DesktopConnectionOnboardingIdentity | CreatedOAuthConnectionIdentity;
+    }
   | { kind: 'detail'; connectionId: string };
 
 function backTarget(route: PanelRoute): PanelRoute {
@@ -89,8 +105,9 @@ function backTarget(route: PanelRoute): PanelRoute {
   return { kind: 'list' };
 }
 
-export function ProvidersPanel({ bridge, initialPage = 'connections', initialConnectionSlug, initialCreateProviderType, onInitialCatalogConsumed, onInitialCreateProviderConsumed }: {
+export function ProvidersPanel({ bridge, apiKeyOnboardingBridge, initialPage = 'connections', initialConnectionSlug, initialCreateProviderType, onInitialCatalogConsumed, onInitialCreateProviderConsumed }: {
   bridge: ConnectionsBridge;
+  apiKeyOnboardingBridge?: ApiKeyOnboardingBridge;
   initialPage?: 'connections' | 'catalog';
   /**
    * When set, open this connection's detail once the list has loaded.
@@ -111,6 +128,8 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
   const [connections, setConnections] = useState<IdentifiedLlmConnection[]>([]);
   const [defaultSlug, setDefaultSlug] = useState<string | null>(null);
   const [route, setRoute] = useState<PanelRoute>({ kind: 'list' });
+  const hasOnboardingUncertainty =
+    apiKeyOnboardingBridge?.saveUncertainty.isUncertain() === true;
   // Browsing state, not navigation state: it outlives the catalog so that
   // backing out of a provider returns the user to the search they typed.
   const [catalogFilter, setCatalogFilter] = useState<CatalogFilter>(CATALOG_INITIAL_FILTER);
@@ -165,15 +184,20 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
 
   const initialCatalogOpenedRef = useRef(false);
   useEffect(() => {
-    if (loading || initialPage !== 'catalog' || initialCatalogOpenedRef.current) return;
+    if (
+      loading ||
+      hasOnboardingUncertainty ||
+      initialPage !== 'catalog' ||
+      initialCatalogOpenedRef.current
+    ) return;
     initialCatalogOpenedRef.current = true;
     setRoute({ kind: 'catalog' });
     onInitialCatalogConsumed?.();
-  }, [initialPage, loading, onInitialCatalogConsumed]);
+  }, [hasOnboardingUncertainty, initialPage, loading, onInitialCatalogConsumed]);
 
   const initialConnectionDetailOpenedRef = useRef(false);
   useEffect(() => {
-    if (route.kind === 'adopting-oauth') {
+    if (route.kind === 'adopting-connection') {
       const created = connections.find(
         (connection) => connection.connectionId === route.identity.connectionId,
       );
@@ -199,7 +223,12 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
 
   const initialCreateOpenedRef = useRef(false);
   useEffect(() => {
-    if (loading || !initialCreateProviderType || initialCreateOpenedRef.current) return;
+    if (
+      loading ||
+      hasOnboardingUncertainty ||
+      !initialCreateProviderType ||
+      initialCreateOpenedRef.current
+    ) return;
     initialCreateOpenedRef.current = true;
     // Straight to the form, so `origin` is the list: the user never saw a
     // catalog and must not be dropped into one on the way out.
@@ -213,7 +242,7 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
       },
     });
     onInitialCreateProviderConsumed?.();
-  }, [loading, initialCreateProviderType, onInitialCreateProviderConsumed]);
+  }, [hasOnboardingUncertainty, loading, initialCreateProviderType, onInitialCreateProviderConsumed]);
 
   function goBack() {
     setRoute(backTarget(route));
@@ -236,12 +265,12 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
   const selected = route.kind === 'detail'
     ? connections.find((connection) => connection.connectionId === route.connectionId) ?? null
     : null;
-  const pendingOAuthIdentity = route.kind === 'adopting-oauth' ? route.identity : null;
+  const pendingConnectionIdentity = route.kind === 'adopting-connection' ? route.identity : null;
 
   // A detail route whose connection vanished (deleted in another window) is an
   // unsatisfiable route, not a state to correct: the list is what it renders
   // as. Deriving that beats scheduling a setState from inside render.
-  const level: Exclude<PanelRoute['kind'], 'adopting-oauth'> = route.kind === 'adopting-oauth'
+  const level: Exclude<PanelRoute['kind'], 'adopting-connection'> = route.kind === 'adopting-connection'
     ? 'list'
     : route.kind === 'detail' && !selected
       ? 'list'
@@ -394,6 +423,7 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
         <VStack gap={5}>
           <SettingsRouteHeader
             onBack={goBack}
+            isBackDisabled={route.managedSaveDispatched === true}
             backLabel={route.origin === 'catalog' ? copy.backToCatalog : copy.backToList}
             logo={<ProviderLogo type={route.target.providerType} compact />}
             titleId={setupTitleId}
@@ -404,18 +434,44 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
               ? oauthPanelSubtitle(route.target.cardId, providerCopy.oauthSection)
               : copy.createSubtitle}
           />
-          <ProviderSetupPage
-            bridge={bridge}
-            target={route.target}
-            existingSlugs={connections.map((connection) => connection.slug)}
-            labelledBy={setupTitleId}
-            onCancel={goBack}
-            onAccountCreated={async (identity) => {
-              if (!identity) return;
-              setRoute({ kind: 'adopting-oauth', identity });
-              await reload();
-            }}
-            onCreated={async (slug, modelDiscoveryError) => {
+          <RuntimeHostSettingsGenerationBoundary>
+            <ProviderSetupPage
+              bridge={bridge}
+              apiKeyOnboardingBridge={apiKeyOnboardingBridge}
+              target={route.target}
+              existingSlugs={connections.map((connection) => connection.slug)}
+              labelledBy={setupTitleId}
+              managedSaveDispatched={route.managedSaveDispatched}
+              onManagedSaveDispatched={() => {
+                const attemptId = apiKeyOnboardingBridge?.saveUncertainty.markDispatched();
+                setRoute((current) => current.kind === 'setup'
+                  ? { ...current, managedSaveDispatched: true }
+                  : current);
+                return attemptId;
+              }}
+              onManagedSaveSettled={(attemptId) => {
+                if (attemptId !== undefined) {
+                  apiKeyOnboardingBridge?.saveUncertainty.settle(attemptId);
+                }
+                setRoute((current) => current.kind === 'setup'
+                  ? { kind: 'setup', target: current.target, origin: current.origin }
+                  : current);
+              }}
+              onCancel={goBack}
+              onAccountCreated={async (identity) => {
+                if (!identity) return;
+                setRoute({ kind: 'adopting-connection', identity });
+                await reload();
+              }}
+              onOnboarded={async (identity) => {
+                setRoute({ kind: 'adopting-connection', identity });
+                await reload();
+              }}
+              onOnboardingOutcomeUnknown={async () => {
+                setRoute({ kind: 'list' });
+                await reload();
+              }}
+              onCreated={async (slug, modelDiscoveryError) => {
               const snapshot = await reload();
               if (!snapshot || !providersPanelMountedRef.current) return;
               // The new connection's detail, not the list: creating it is the
@@ -438,8 +494,9 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
                   ),
                 );
               }
-            }}
-          />
+              }}
+            />
+          </RuntimeHostSettingsGenerationBoundary>
         </VStack>
       ) : (
         <>
@@ -458,23 +515,49 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
               variant="primary"
               label={copy.addConnection}
               onClick={openCatalog}
-              isDisabled={pendingOAuthIdentity !== null}
+              isDisabled={pendingConnectionIdentity !== null || hasOnboardingUncertainty}
               data-maka-contract="add-connection"
             />
           </HStack>
-          {pendingOAuthIdentity && loadError ? (
+          {hasOnboardingUncertainty && (
+            <Banner
+              status="warning"
+              role="status"
+              title={providerCopy.add.onboardingOutcomeUnknown}
+              description={providerCopy.add.onboardingOutcomeUnknownDetail}
+              endContent={(
+                <HStack gap={2}>
+                  <Button
+                    variant="ghost"
+                    label={providerCopy.add.onboardingReloadConnections}
+                    onClick={() => void reload()}
+                  />
+                  <Button
+                    variant="secondary"
+                    label={providerCopy.add.onboardingRestart}
+                    onClick={() => {
+                      apiKeyOnboardingBridge?.saveUncertainty.restart();
+                      openCatalog();
+                    }}
+                  />
+                </HStack>
+              )}
+            />
+          )}
+          {pendingConnectionIdentity && loadError ? (
             <Banner
               status="warning"
               role="status"
               title={copy.connectedLoadFailed}
-              description={loadError}
+              description={`${pendingConnectionIdentity.slug} · ${loadError}`}
               endContent={<Button variant="ghost" label={copy.retry} onClick={() => void reload()} />}
             />
-          ) : pendingOAuthIdentity ? (
+          ) : pendingConnectionIdentity ? (
             <Banner
               status="info"
               role="status"
               title={copy.connectedLoading}
+              description={pendingConnectionIdentity.slug}
               endContent={<Button variant="ghost" label={copy.retry} onClick={() => void reload()} />}
             />
           ) : loadError ? (
@@ -489,7 +572,7 @@ export function ProvidersPanel({ bridge, initialPage = 'connections', initialCon
               icon={<Cpu size={ICON_SIZE.empty} />}
               title={copy.empty}
               description={copy.emptyHelp}
-              actions={<Button variant="primary" label={copy.addConnection} onClick={openCatalog} isDisabled={pendingOAuthIdentity !== null} />}
+              actions={<Button variant="primary" label={copy.addConnection} onClick={openCatalog} isDisabled={pendingConnectionIdentity !== null || hasOnboardingUncertainty} />}
             />
           ) : (
             <List hasDividers>
diff --git a/apps/desktop/src/renderer/settings/settings-surface.tsx b/apps/desktop/src/renderer/settings/settings-surface.tsx
index f202d45521..f4bf4a1870 100644
--- a/apps/desktop/src/renderer/settings/settings-surface.tsx
+++ b/apps/desktop/src/renderer/settings/settings-surface.tsx
@@ -91,6 +91,7 @@ import { WebSearchSettingsPage } from './web-search-settings-page';
 import type { UiLocaleUpdateGate } from './ui-locale-update-gate';
 import { getSettingsSharedCopy } from '../locales/settings-shared-copy.js';
 import {
+  runtimeHostApiKeyOnboardingBridge,
   runtimeHostConnectionsBridge,
   type RuntimeHostSettingsConnectionsBridge,
 } from './runtime-host-settings-bridge.js';
@@ -350,6 +351,12 @@ export function SettingsSurface(props: {
       : undefined,
     [selectedRuntimeHost],
   );
+  const apiKeyOnboardingBridge = useMemo(
+    () => selectedRuntimeHost
+      ? runtimeHostApiKeyOnboardingBridge(selectedRuntimeHost)
+      : undefined,
+    [selectedRuntimeHost],
+  );
   const selectedRuntimeHostKey = selectedRuntimeHost
     ? runtimeHostSettingsKey(selectedRuntimeHost)
     : undefined;
@@ -1011,6 +1018,7 @@ export function SettingsSurface(props: {
                             }
                             connections={connections}
                             connectionsBridge={connectionsBridge}
+                            apiKeyOnboardingBridge={apiKeyOnboardingBridge}
                             defaultSlug={defaultSlug}
                             runtimeHost={selectedRuntimeHost}
                             runtimeHostAvailabilityStatus={runtimeHostAvailabilityStatus}
@@ -1066,6 +1074,7 @@ function SettingsPageBody(props: {
   usageStats: UsageStats | null;
   connections: IdentifiedLlmConnection[];
   connectionsBridge: RuntimeHostSettingsConnectionsBridge | undefined;
+  apiKeyOnboardingBridge: ReturnType<typeof runtimeHostApiKeyOnboardingBridge> | undefined;
   defaultSlug: string | null;
   runtimeHost: DesktopRuntimeHostRef | undefined;
   runtimeHostAvailabilityStatus: RuntimeHostAvailabilityStatus;
@@ -1112,6 +1121,7 @@ function SettingsPageBody(props: {
         <SettingsPage className="settingsModelsPage">
           <ProvidersPanel
             bridge={props.connectionsBridge}
+            apiKeyOnboardingBridge={props.apiKeyOnboardingBridge}
             initialPage={props.openProviderCatalog ? 'catalog' : 'connections'}
             initialConnectionSlug={props.initialConnectionSlug}
             initialCreateProviderType={props.initialCreateProviderType}
diff --git a/apps/desktop/stories/settings/provider-settings.stories.tsx b/apps/desktop/stories/settings/provider-settings.stories.tsx
index d8b9b296ec..a8c2edb813 100644
--- a/apps/desktop/stories/settings/provider-settings.stories.tsx
+++ b/apps/desktop/stories/settings/provider-settings.stories.tsx
@@ -19,7 +19,7 @@
 
 import { useEffect, useRef, type ReactNode } from 'react';
 import type { Meta, StoryObj } from '@storybook/react-vite';
-import { expect, userEvent, within } from 'storybook/test';
+import { expect, userEvent, waitFor, within } from 'storybook/test';
 import { Layout, LayoutContent, LayoutHeader } from '@astryxdesign/core';
 import { ToastProvider } from '@maka/ui';
 import type {
@@ -33,6 +33,7 @@ import { buildChatModelChoices } from '@maka/core/chat-model-choice';
 import { ProvidersPanel, type ConnectionsBridge } from '../../src/renderer/settings/providers-panel';
 import { RuntimeHostSettingsTarget } from '../../src/renderer/settings/runtime-host-settings-target';
 import { SettingsPage } from '../../src/renderer/settings/settings-section';
+import type { ApiKeyOnboardingBridge } from '../../src/renderer/settings/provider-panel-shared';
 
 const NOW = Date.parse('2026-07-01T08:00:00Z');
 
@@ -245,16 +246,24 @@ const oauthConnections = [
   }),
 ];
 
+interface StoryConnectionsBridge extends ConnectionsBridge {
+  addFixtureConnection(connection: IdentifiedLlmConnection): void;
+}
+
 function createBridge(input: {
   connections?: IdentifiedLlmConnection[];
   defaultSlug?: string | null;
   failLoad?: boolean;
   loading?: boolean;
-}): ConnectionsBridge {
+}): StoryConnectionsBridge {
   let connections = [...(input.connections ?? [])];
   let defaultSlug: string | null = input.defaultSlug ?? connections[0]?.slug ?? null;
 
   return {
+    addFixtureConnection(connection) {
+      connections = [...connections, connection];
+      defaultSlug ??= connection.slug;
+    },
     async getSnapshot() {
       if (input.loading) return new Promise<never>(() => undefined);
       if (input.failLoad) throw new Error('模型连接服务暂时不可用');
@@ -341,6 +350,98 @@ function createBridge(input: {
   };
 }
 
+function createApiKeyOnboardingFixture(options: {
+  save?: 'saved' | 'outcome_unknown' | 'auth_failed';
+  failRefreshAfterSave?: boolean;
+  emptyCatalog?: boolean;
+} = {}) {
+  const bridge = createBridge({
+    connections: options.emptyCatalog
+      ? []
+      : [
+          ...configuredConnections,
+          makeConnection({
+            slug: 'deepseek',
+            name: 'DeepSeek',
+            providerType: 'deepseek',
+            defaultModel: 'deepseek-chat',
+          }),
+        ],
+    defaultSlug: options.emptyCatalog ? undefined : 'zai-live',
+  });
+  let failNextSnapshot = false;
+  const projectedBridge: ConnectionsBridge = {
+    ...bridge,
+    async getSnapshot() {
+      if (failNextSnapshot) {
+        failNextSnapshot = false;
+        throw new Error('模型连接服务暂时不可用');
+      }
+      return bridge.getSnapshot();
+    },
+  };
+  let uncertainAttemptId: number | undefined;
+  let nextAttemptId = 1;
+  const apiKeyOnboardingBridge: ApiKeyOnboardingBridge = {
+    saveUncertainty: {
+      isUncertain: () => uncertainAttemptId !== undefined,
+      markDispatched: () => {
+        const attemptId = nextAttemptId++;
+        uncertainAttemptId = attemptId;
+        return attemptId;
+      },
+      settle: (attemptId) => {
+        if (uncertainAttemptId === attemptId) uncertainAttemptId = undefined;
+      },
+      restart: () => { uncertainAttemptId = undefined; },
+    },
+    async verify() {
+      return {
+        kind: 'verified',
+        models: [
+          { id: 'deepseek-reasoner', displayName: 'DeepSeek Reasoner' },
+          { id: 'deepseek-chat', displayName: 'DeepSeek Chat' },
+        ],
+      };
+    },
+    async save() {
+      if (options.save === 'outcome_unknown') return { kind: 'outcome_unknown' };
+      if (options.save === 'auth_failed') {
+        return {
+          kind: 'result',
+          result: { kind: 'failed', errorClass: 'auth' },
+        };
+      }
+      const connection = makeConnection({
+        slug: 'deepseek-2',
+        name: 'DeepSeek',
+        providerType: 'deepseek',
+        defaultModel: '',
+        models: [
+          { id: 'deepseek-reasoner', displayName: 'DeepSeek Reasoner' },
+          { id: 'deepseek-chat', displayName: 'DeepSeek Chat' },
+        ],
+        modelSource: 'fetched',
+      });
+      bridge.addFixtureConnection(connection);
+      failNextSnapshot = options.failRefreshAfterSave === true;
+      return {
+        kind: 'result',
+        result: {
+          kind: 'saved',
+          connection: {
+            connectionId: connection.connectionId,
+            revision: 1,
+            slug: connection.slug,
+            providerType: connection.providerType,
+          },
+        },
+      };
+    },
+  };
+  return { bridge: projectedBridge, apiKeyOnboardingBridge };
+}
+
 function createOAuthSuccessLifecycleFixture() {
   const bridge = createBridge({ connections: oauthConnections });
   let eventHandler: (() => void) | undefined;
@@ -449,6 +550,7 @@ function browserSubscriptionFixture(state: {
 
 function ProviderStoryFrame(props: {
   bridge: ConnectionsBridge;
+  apiKeyOnboardingBridge?: ApiKeyOnboardingBridge;
   autoOpen?: AutoOpenTarget;
   onOAuthComplete?: () => void;
 }) {
@@ -511,7 +613,10 @@ function ProviderStoryFrame(props: {
             content={(
               <LayoutContent padding={6} isScrollable={false}>
                 <SettingsPage className="settingsModelsPage">
-                  <ProvidersPanel bridge={props.bridge} />
+                  <ProvidersPanel
+                    bridge={props.bridge}
+                    apiKeyOnboardingBridge={props.apiKeyOnboardingBridge}
+                  />
                 </SettingsPage>
               </LayoutContent>
             )}
@@ -588,16 +693,31 @@ function clickAutoOpenTarget(root: HTMLElement, target: AutoOpenTarget): boolean
 
 function ProviderStory(props: {
   bridge: ConnectionsBridge;
+  apiKeyOnboardingBridge?: ApiKeyOnboardingBridge;
   autoOpen?: AutoOpenTarget;
   onOAuthComplete?: () => void;
 }): ReactNode {
   return (
     <RuntimeHostSettingsTarget host={{ profileId: 'local', hostId: 'storybook-local-host' }}>
-      <ProviderStoryFrame bridge={props.bridge} autoOpen={props.autoOpen} onOAuthComplete={props.onOAuthComplete} />
+      <ProviderStoryFrame
+        bridge={props.bridge}
+        apiKeyOnboardingBridge={props.apiKeyOnboardingBridge}
+        autoOpen={props.autoOpen}
+        onOAuthComplete={props.onOAuthComplete}
+      />
     </RuntimeHostSettingsTarget>
   );
 }
 
+async function findApiKeyInput(canvasElement: HTMLElement): Promise<HTMLInputElement> {
+  let input: HTMLInputElement | null = null;
+  await waitFor(() => {
+    input = canvasElement.querySelector<HTMLInputElement>('input[type="password"]');
+    expect(input).not.toBeNull();
+  }, { timeout: 5_000 });
+  return input!;
+}
+
 // Real path: same page with several healthy connections and one of them set as default.
 export const ConfiguredProviders: Story = {
   render: () => <ProviderStory bridge={createBridge({ connections: configuredConnections, defaultSlug: 'zai-live' })} />,
@@ -770,3 +890,180 @@ export const AddProvider: Story = {
     />
   ),
 };
+
+// Real path: 设置 → 模型 → 添加连接 → DeepSeek. The common fixed-endpoint
+// API-key path is Host-owned before any write happens.
+export const ApiKeyOnboardingInput: Story = {
+  render: () => {
+    const fixture = createApiKeyOnboardingFixture();
+    return (
+      <ProviderStory
+        bridge={fixture.bridge}
+        apiKeyOnboardingBridge={fixture.apiKeyOnboardingBridge}
+        autoOpen="add"
+      />
+    );
+  },
+};
+
+export const ApiKeyOnboardingModels: Story = {
+  render: () => {
+    const fixture = createApiKeyOnboardingFixture();
+    return (
+      <ProviderStory
+        bridge={fixture.bridge}
+        apiKeyOnboardingBridge={fixture.apiKeyOnboardingBridge}
+        autoOpen="add"
+      />
+    );
+  },
+  play: async ({ canvasElement }) => {
+    const canvas = within(canvasElement);
+    await userEvent.type(await findApiKeyInput(canvasElement), 'sk-storybook');
+    await userEvent.click(await canvas.findByRole('button', { name: '验证并选择模型' }));
+    await expect(canvas.findByText('选择此连接使用的模型')).resolves.toBeTruthy();
+    await expect(canvas.findByRole('button', { name: '添加连接' })).resolves.toBeTruthy();
+  },
+};
+
+export const ApiKeyOnboardingBackInvalidatesVerification: Story = {
+  render: () => {
+    const fixture = createApiKeyOnboardingFixture();
+    return (
+      <ProviderStory
+        bridge={fixture.bridge}
+        apiKeyOnboardingBridge={fixture.apiKeyOnboardingBridge}
+        autoOpen="add"
+      />
+    );
+  },
+  play: async ({ canvasElement }) => {
+    const canvas = within(canvasElement);
+    const key = await findApiKeyInput(canvasElement);
+    await userEvent.type(key, 'sk-first');
+    await userEvent.click(await canvas.findByRole('button', { name: '验证并选择模型' }));
+    await userEvent.click(await canvas.findByRole('button', { name: '返回修改' }));
+    await userEvent.clear(await findApiKeyInput(canvasElement));
+    await userEvent.type(await findApiKeyInput(canvasElement), 'sk-second');
+    await expect(canvas.queryByText('选择此连接使用的模型')).toBeNull();
+    await expect(canvas.findByRole('button', { name: '验证并选择模型' })).resolves.toBeTruthy();
+  },
+};
+
+export const ApiKeyOnboardingAdoptsExactConnection: Story = {
+  render: () => {
+    const fixture = createApiKeyOnboardingFixture();
+    return (
+      <ProviderStory
+        bridge={fixture.bridge}
+        apiKeyOnboardingBridge={fixture.apiKeyOnboardingBridge}
+        autoOpen="add"
+      />
+    );
+  },
+  play: async ({ canvasElement }) => {
+    const canvas = within(canvasElement);
+    await userEvent.type(await findApiKeyInput(canvasElement), 'sk-storybook');
+    await userEvent.click(await canvas.findByRole('button', { name: '验证并选择模型' }));
+    await userEvent.click(await canvas.findByRole('button', { name: '添加连接' }));
+    await expect(canvas.findByRole('region', { name: 'DeepSeek · deepseek-2' })).resolves.toBeTruthy();
+  },
+};
+
+export const ApiKeyOnboardingRefreshWarning: Story = {
+  render: () => {
+    const fixture = createApiKeyOnboardingFixture({ failRefreshAfterSave: true });
+    return (
+      <ProviderStory
+        bridge={fixture.bridge}
+        apiKeyOnboardingBridge={fixture.apiKeyOnboardingBridge}
+        autoOpen="add"
+      />
+    );
+  },
+  play: async ({ canvasElement }) => {
+    const canvas = within(canvasElement);
+    await userEvent.type(await findApiKeyInput(canvasElement), 'sk-storybook');
+    await userEvent.click(await canvas.findByRole('button', { name: '验证并选择模型' }));
+    await userEvent.click(await canvas.findByRole('button', { name: '添加连接' }));
+    await expect(canvas.findByText('连接已添加,但暂时无法刷新连接列表。')).resolves.toBeTruthy();
+  },
+};
+
+export const ApiKeyOnboardingOutcomeUnknown: Story = {
+  render: () => {
+    const fixture = createApiKeyOnboardingFixture({ save: 'outcome_unknown' });
+    return (
+      <ProviderStory
+        bridge={fixture.bridge}
+        apiKeyOnboardingBridge={fixture.apiKeyOnboardingBridge}
+        autoOpen="add"
+      />
+    );
+  },
+  play: async ({ canvasElement }) => {
+    const canvas = within(canvasElement);
+    await userEvent.type(await findApiKeyInput(canvasElement), 'sk-storybook');
+    await userEvent.click(await canvas.findByRole('button', { name: '验证并选择模型' }));
+    await userEvent.click(await canvas.findByRole('button', { name: '添加连接' }));
+    await expect(canvas.findByText('保存结果暂时无法确认')).resolves.toBeTruthy();
+    await userEvent.click(await canvas.findByRole('button', { name: '重新加载连接列表' }));
+    await expect(canvas.findByRole('button', { name: '添加连接' })).resolves.toBeDisabled();
+    await expect(canvas.findByRole('button', { name: '仍要添加另一个连接' })).resolves.toBeTruthy();
+    const row = canvasElement.querySelector<HTMLElement>('[data-connection-slug="deepseek"]');
+    await expect(row).not.toBeNull();
+    await userEvent.click(within(row!).getByRole('button'));
+    await userEvent.click(await canvas.findByRole('button', { name: '返回模型连接' }));
+    await expect(canvas.findByText('保存结果暂时无法确认')).resolves.toBeTruthy();
+    await expect(canvas.findByRole('button', { name: '添加连接' })).resolves.toBeDisabled();
+  },
+};
+
+export const ApiKeyOnboardingOutcomeUnknownEmptyCatalog: Story = {
+  render: () => {
+    const fixture = createApiKeyOnboardingFixture({
+      save: 'outcome_unknown',
+      emptyCatalog: true,
+    });
+    return (
+      <ProviderStory
+        bridge={fixture.bridge}
+        apiKeyOnboardingBridge={fixture.apiKeyOnboardingBridge}
+        autoOpen="add"
+      />
+    );
+  },
+  play: async ({ canvasElement }) => {
+    const canvas = within(canvasElement);
+    await userEvent.type(await findApiKeyInput(canvasElement), 'sk-storybook');
+    await userEvent.click(await canvas.findByRole('button', { name: '验证并选择模型' }));
+    await userEvent.click(await canvas.findByRole('button', { name: '添加连接' }));
+    await userEvent.click(await canvas.findByRole('button', { name: '重新加载连接列表' }));
+    const addButtons = await canvas.findAllByRole('button', { name: '添加连接' });
+    for (const button of addButtons) await expect(button).toBeDisabled();
+  },
+};
+
+export const ApiKeyOnboardingSaveAuthFailure: Story = {
+  render: () => {
+    const fixture = createApiKeyOnboardingFixture({ save: 'auth_failed' });
+    return (
+      <ProviderStory
+        bridge={fixture.bridge}
+        apiKeyOnboardingBridge={fixture.apiKeyOnboardingBridge}
+        autoOpen="add"
+      />
+    );
+  },
+  play: async ({ canvasElement }) => {
+    const canvas = within(canvasElement);
+    await userEvent.type(await findApiKeyInput(canvasElement), 'sk-storybook');
+    await userEvent.click(await canvas.findByRole('button', { name: '验证并选择模型' }));
+    await userEvent.click(await canvas.findByRole('button', { name: '添加连接' }));
+    const key = await findApiKeyInput(canvasElement);
+    await waitFor(() => {
+      expect(canvas.queryAllByText('密钥验证失败,请检查后重试。')).toHaveLength(1);
+    });
+    await expect(key).toHaveValue('sk-storybook');
+  },
+};


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org