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:09 UTC

(maka) 01/02: feat(desktop): route standard API key onboarding through host

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 55860198e50e199e3f229998299d25a5e9b633b3
Author: me2seeks <me...@gmail.com>
AuthorDate: Mon Aug 31 23:04:12 2026 +0800

    feat(desktop): route standard API key onboarding through host
---
 apps/desktop/renderer-architecture.json            |   4 +-
 .../main/__tests__/provider-add-submission.test.ts |  72 ++++++++++
 .../runtime-host-connections-ipc-main.test.ts      | 146 +++++++++++++++++++++
 apps/desktop/src/main/runtime-host-client.ts       |  12 ++
 .../src/main/runtime-host-connections-ipc-main.ts  |  38 +++++-
 apps/desktop/src/preload/bridge-contract.d.ts      |  21 +++
 apps/desktop/src/preload/preload.ts                |   6 +
 .../renderer/settings/provider-add-submission.ts   |  54 ++++++++
 .../src/renderer/settings/provider-panel-shared.ts |  73 +++++++++++
 .../settings/runtime-host-settings-bridge.ts       |  36 ++++-
 10 files changed, 459 insertions(+), 3 deletions(-)

diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json
index 3cad453abf..e7ec31526e 100644
--- a/apps/desktop/renderer-architecture.json
+++ b/apps/desktop/renderer-architecture.json
@@ -3974,12 +3974,14 @@
           "window.maka.connections.getRequestHeaders": 1,
           "window.maka.connections.getSnapshot": 1,
           "window.maka.connections.hasSecret": 1,
+          "window.maka.connections.saveOnboarding": 1,
           "window.maka.connections.setDefault": 1,
           "window.maka.connections.setDefaultModel": 1,
           "window.maka.connections.setRequestHeaders": 1,
           "window.maka.connections.subscribeEvents": 1,
           "window.maka.connections.test": 1,
-          "window.maka.connections.update": 1
+          "window.maka.connections.update": 1,
+          "window.maka.connections.verifyOnboarding": 1
         },
         "environmentCapabilities": {},
         "hookCalls": {},
diff --git a/apps/desktop/src/main/__tests__/provider-add-submission.test.ts b/apps/desktop/src/main/__tests__/provider-add-submission.test.ts
index 5bf2621988..4e9c1e6efc 100644
--- a/apps/desktop/src/main/__tests__/provider-add-submission.test.ts
+++ b/apps/desktop/src/main/__tests__/provider-add-submission.test.ts
@@ -21,10 +21,15 @@ import assert from 'node:assert/strict';
 import { test } from 'node:test';
 import {
   createProviderWithDiscovery,
+  apiKeyOnboardingRoute,
+  initialOnboardingModelIds,
+  initialManagedOnboardingPhase,
+  stableOnboardingModels,
   validateAddProviderDraft,
   type AddProviderDraft,
   type AddProviderField,
 } from '../../renderer/settings/provider-add-submission.js';
+import { runtimeHostApiKeyOnboardingBridge } from '../../renderer/settings/runtime-host-settings-bridge.js';
 import {
   PROVIDER_DEFAULTS,
   providerSupportsModelDiscovery,
@@ -223,3 +228,70 @@ test('a duplicate slug outranks a missing key, so one fix is asked for at a time
     { field: 'slug', reason: 'duplicate' },
   );
 });
+
+test('routes only fixed-endpoint API-key drafts without request customization to Host onboarding', () => {
+  assert.deepEqual(apiKeyOnboardingRoute({
+    providerType: 'openai',
+    requestHeaderCount: 0,
+    hasRequestBodyOverlay: false,
+  }), { kind: 'host' });
+  assert.deepEqual(apiKeyOnboardingRoute({
+    providerType: 'openai',
+    requestHeaderCount: 1,
+    hasRequestBodyOverlay: false,
+  }), { kind: 'legacy', reason: 'request_headers' });
+  assert.deepEqual(apiKeyOnboardingRoute({
+    providerType: 'openai',
+    requestHeaderCount: 0,
+    hasRequestBodyOverlay: true,
+  }), { kind: 'legacy', reason: 'request_body' });
+  assert.deepEqual(apiKeyOnboardingRoute({
+    providerType: 'openai-compatible',
+    requestHeaderCount: 0,
+    hasRequestBodyOverlay: false,
+  }), { kind: 'legacy', reason: 'custom_endpoint' });
+  assert.deepEqual(apiKeyOnboardingRoute({
+    providerType: 'cloudflare-workers-ai',
+    requestHeaderCount: 0,
+    hasRequestBodyOverlay: false,
+  }), { kind: 'legacy', reason: 'cloudflare' });
+});
+
+test('uses a stable discovered-model order and prefers the registered recommendation', () => {
+  const models = [
+    { id: 'z-model', displayName: 'Zulu' },
+    { id: 'a-model', displayName: 'Alpha' },
+  ];
+  assert.deepEqual(stableOnboardingModels(models).map(({ id }) => id), [
+    'a-model',
+    'z-model',
+  ]);
+  assert.deepEqual(initialOnboardingModelIds(models, 'z-model'), ['z-model']);
+  assert.deepEqual(initialOnboardingModelIds(models, 'missing'), ['a-model']);
+  assert.deepEqual(initialOnboardingModelIds([], 'missing'), []);
+});
+
+test('a remounted setup becomes uncertain only after save dispatch', () => {
+  assert.equal(initialManagedOnboardingPhase(false), 'input');
+  assert.equal(initialManagedOnboardingPhase(true), 'outcome_unknown');
+});
+
+test('a late old save cannot clear the uncertainty lease for a newer attempt', () => {
+  const firstView = runtimeHostApiKeyOnboardingBridge({
+    profileId: 'local',
+    hostId: 'same-host',
+  });
+  const oldAttempt = firstView.saveUncertainty.markDispatched();
+  firstView.saveUncertainty.restart();
+
+  const replacementView = runtimeHostApiKeyOnboardingBridge({
+    profileId: 'local',
+    hostId: 'same-host',
+  });
+  const newAttempt = replacementView.saveUncertainty.markDispatched();
+  firstView.saveUncertainty.settle(oldAttempt);
+  assert.equal(replacementView.saveUncertainty.isUncertain(), true);
+
+  replacementView.saveUncertainty.settle(newAttempt);
+  assert.equal(firstView.saveUncertainty.isUncertain(), false);
+});
diff --git a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts
index 96e730be8b..b4f10597dd 100644
--- a/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts
+++ b/apps/desktop/src/main/__tests__/runtime-host-connections-ipc-main.test.ts
@@ -21,6 +21,10 @@ import assert from 'node:assert/strict';
 import test from 'node:test';
 import { OPENCODE_FREE_DEFAULT_ENABLED_MODELS } from '@maka/core/llm-connections';
 import type { ConnectionCatalogSnapshot } from '@maka/core/runtime-policy';
+import {
+  RuntimeHostOperationError,
+  RuntimeHostRequestInterruptedError,
+} from '@maka/runtime-host/client';
 import {
   projectHostConnections,
   projectHostConnectionTest,
@@ -49,9 +53,151 @@ test('registers pure Connection reads for replacement-Host retry', () => {
     'connections:hasSecret',
   ]);
   assert.ok(effects.has('connections:create'));
+  assert.ok(effects.has('connections:onboardingVerify'));
+  assert.ok(effects.has('connections:onboardingSave'));
   assert.ok(effects.has('connections:test'));
 });
 
+test('forwards managed onboarding and emits only after a canonical save', async () => {
+  const handlers = new Map<string, (...args: unknown[]) => unknown>();
+  const calls: unknown[] = [];
+  let changed = 0;
+  registerRuntimeHostConnectionsIpc({
+    ipcMain: {
+      handle: (channel, handler) => {
+        handlers.set(channel, handler as (...args: unknown[]) => unknown);
+      },
+    },
+    client: {
+      verifyConnectionOnboarding: async (input: unknown) => {
+        calls.push(['verify', input]);
+        return { kind: 'verified', models: [{ id: 'gpt-5' }] };
+      },
+      saveConnectionOnboarding: async (input: unknown) => {
+        calls.push(['save', input]);
+        return {
+          kind: 'saved',
+          connection: {
+            connectionId: 'connection-openai-2',
+            revision: 1,
+            slug: 'openai-2',
+            providerType: 'openai',
+          },
+        };
+      },
+    } as never,
+    emitConnectionListChanged() {
+      changed += 1;
+    },
+  });
+
+  const base = {
+    target: { kind: 'create', providerType: 'openai' },
+    apiKey: 'test-key',
+    baseUrl: null,
+  } as const;
+  assert.deepEqual(await handlers.get('connections:onboardingVerify')?.({}, base), {
+    kind: 'verified',
+    models: [{ id: 'gpt-5' }],
+  });
+  assert.deepEqual(
+    await handlers.get('connections:onboardingSave')?.({}, {
+      ...base,
+      enabledModelIds: ['gpt-5'],
+    }),
+    {
+      kind: 'result',
+      result: {
+        kind: 'saved',
+        connection: {
+          connectionId: 'connection-openai-2',
+          revision: 1,
+          slug: 'openai-2',
+          providerType: 'openai',
+        },
+      },
+    },
+  );
+  assert.equal(changed, 1);
+  assert.equal(calls.length, 2);
+});
+
+test('keeps a dispatched onboarding save interruption outcome unknown', async () => {
+  const handlers = new Map<string, (...args: unknown[]) => unknown>();
+  registerRuntimeHostConnectionsIpc({
+    ipcMain: {
+      handle: (channel, handler) => {
+        handlers.set(channel, handler as (...args: unknown[]) => unknown);
+      },
+    },
+    client: {
+      saveConnectionOnboarding: async () => {
+        throw new RuntimeHostRequestInterruptedError(
+          'connection.onboarding.save',
+          'command',
+          'dispatched',
+          'connection_lost',
+        );
+      },
+    } as never,
+    emitConnectionListChanged() {},
+  });
+
+  assert.deepEqual(
+    await handlers.get('connections:onboardingSave')?.({}, {
+      target: { kind: 'create', providerType: 'openai' },
+      apiKey: 'test-key',
+      baseUrl: null,
+      enabledModelIds: ['gpt-5'],
+    }),
+    { kind: 'outcome_unknown' },
+  );
+});
+
+test('keeps Host commit_outcome_unknown distinct from a safe non-save', async () => {
+  const handlers = new Map<string, (...args: unknown[]) => unknown>();
+  let attempt = 0;
+  registerRuntimeHostConnectionsIpc({
+    ipcMain: {
+      handle: (channel, handler) => {
+        handlers.set(channel, handler as (...args: unknown[]) => unknown);
+      },
+    },
+    client: {
+      saveConnectionOnboarding: async () => {
+        attempt += 1;
+        if (attempt === 1) {
+          throw new RuntimeHostOperationError(
+            'connection.onboarding.save',
+            'commit_outcome_unknown',
+            'unknown',
+          );
+        }
+        throw new RuntimeHostRequestInterruptedError(
+          'connection.onboarding.save',
+          'command',
+          'not_dispatched',
+          'connection_lost',
+        );
+      },
+    } as never,
+    emitConnectionListChanged() {},
+  });
+  const input = {
+    target: { kind: 'create', providerType: 'openai' },
+    apiKey: 'test-key',
+    baseUrl: null,
+    enabledModelIds: ['gpt-5'],
+  };
+
+  assert.deepEqual(await handlers.get('connections:onboardingSave')?.({}, input), {
+    kind: 'outcome_unknown',
+  });
+  assert.deepEqual(await handlers.get('connections:onboardingSave')?.({}, input), {
+    kind: 'not_saved',
+  });
+});
+
 test('retries connection delete after a stale revision instead of failing permanently', async () => {
   const handlers = new Map<string, (...args: unknown[]) => unknown>();
   let revision = 1;
diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts
index e2ffc31762..64882c6787 100644
--- a/apps/desktop/src/main/runtime-host-client.ts
+++ b/apps/desktop/src/main/runtime-host-client.ts
@@ -477,6 +477,18 @@ export class DesktopRuntimeHostClient {
     });
   }
 
+  verifyConnectionOnboarding(
+    input: OperationInput<"connection.onboarding.verify">,
+  ): Promise<OperationOutput<"connection.onboarding.verify">> {
+    return this.request("connection.onboarding.verify", input);
+  }
+
+  saveConnectionOnboarding(
+    input: OperationInput<"connection.onboarding.save">,
+  ): Promise<OperationOutput<"connection.onboarding.save">> {
+    return this.request("connection.onboarding.save", input);
+  }
+
   startOAuthLogin(
     attemptId: string,
     target: OperationInput<"oauth.login.start">["target"],
diff --git a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts
index 64ef266438..c97bf1dbe6 100644
--- a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts
+++ b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts
@@ -39,7 +39,14 @@ import type {
   CredentialLocator,
 } from '@maka/core/runtime-policy';
 import { normalizeRequestHeaderUpdates } from '@maka/core/runtime-policy';
-import type { ConnectionTestRunResult } from '@maka/runtime-host/protocol';
+import {
+  CONNECTION_EFFECT_OPERATION_SPECS,
+  type ConnectionTestRunResult,
+} from '@maka/runtime-host/protocol';
+import {
+  RuntimeHostOperationError,
+  RuntimeHostRequestInterruptedError,
+} from '@maka/runtime-host/client';
 import type { DesktopRuntimeHostClient } from './runtime-host-client.js';
 import {
   handleReconnectableRead,
@@ -55,6 +62,7 @@ import type {
   DesktopConnectionIdentity,
   DesktopConnectionSnapshot,
 } from '../shared/desktop-connection-snapshot.js';
+import type { DesktopConnectionOnboardingSaveOutcome } from '../preload/bridge-contract.js';
 
 type HostConnectionsClient = Pick<
   DesktopRuntimeHostClient,
@@ -70,6 +78,8 @@ type HostConnectionsClient = Pick<
   | 'setDefaultConnectionTarget'
   | 'testConnection'
   | 'updateConnection'
+  | 'verifyConnectionOnboarding'
+  | 'saveConnectionOnboarding'
 >;
 
 export interface RuntimeHostConnectionsIpcDeps {
@@ -155,6 +165,32 @@ export function registerRuntimeHostConnectionsIpc(
     );
     deps.emitConnectionListChanged();
   });
+  deps.ipcMain.handle('connections:onboardingVerify', async (_event, raw: unknown) => {
+    const input = CONNECTION_EFFECT_OPERATION_SPECS[
+      'connection.onboarding.verify'
+    ].decodeInput(raw);
+    return deps.client.verifyConnectionOnboarding(input);
+  });
+  deps.ipcMain.handle('connections:onboardingSave', async (_event, raw: unknown) => {
+    const input = CONNECTION_EFFECT_OPERATION_SPECS[
+      'connection.onboarding.save'
+    ].decodeInput(raw);
+    try {
+      const result = await deps.client.saveConnectionOnboarding(input);
+      if (result.kind === 'saved') deps.emitConnectionListChanged();
+      return { kind: 'result', result } satisfies DesktopConnectionOnboardingSaveOutcome;
+    } catch (error) {
+      if (
+        (error instanceof RuntimeHostOperationError &&
+          error.code === 'commit_outcome_unknown') ||
+        (error instanceof RuntimeHostRequestInterruptedError &&
+          error.dispatch === 'dispatched')
+      ) {
+        return { kind: 'outcome_unknown' } satisfies DesktopConnectionOnboardingSaveOutcome;
+      }
+      return { kind: 'not_saved' } satisfies DesktopConnectionOnboardingSaveOutcome;
+    }
+  });
   deps.ipcMain.handle('connections:create', async (_event, raw: unknown) => {
     const input = normalizeCreateInput(raw);
     const catalog = await snapshot();
diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts
index 1525a4c34e..1016cc2c80 100644
--- a/apps/desktop/src/preload/bridge-contract.d.ts
+++ b/apps/desktop/src/preload/bridge-contract.d.ts
@@ -352,6 +352,19 @@ export interface DesktopRuntimeHostRef {
   readonly hostId: string;
 }
 
+export type DesktopConnectionOnboardingIdentity = Extract<
+  OperationOutput<'connection.onboarding.save'>,
+  { readonly kind: 'saved' }
+>['connection'];
+
+export type DesktopConnectionOnboardingSaveOutcome =
+  | {
+      readonly kind: 'result';
+      readonly result: OperationOutput<'connection.onboarding.save'>;
+    }
+  | { readonly kind: 'not_saved' }
+  | { readonly kind: 'outcome_unknown' };
+
 /** Desktop-local OAuth intent. Runtime Host remains the identity allocator. */
 export type DesktopOAuthLoginTarget =
   | { readonly kind: 'create' }
@@ -1338,6 +1351,14 @@ export interface MakaBridge {
     setDefault(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity | string | null, host?: DesktopRuntimeHostRef): Promise<void>;
     setDefaultModel(input: { slug: string; model: string } | null, host?: DesktopRuntimeHostRef): Promise<void>;
     create(input: CreateConnectionInput, host?: DesktopRuntimeHostRef): Promise<import('@maka/core/llm-connections').IdentifiedLlmConnection>;
+    verifyOnboarding(
+      input: OperationInput<'connection.onboarding.verify'>,
+      host?: DesktopRuntimeHostRef,
+    ): Promise<OperationOutput<'connection.onboarding.verify'>>;
+    saveOnboarding(
+      input: OperationInput<'connection.onboarding.save'>,
+      host?: DesktopRuntimeHostRef,
+    ): Promise<DesktopConnectionOnboardingSaveOutcome>;
     update(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise<LlmConnection>;
     delete(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity, host?: DesktopRuntimeHostRef): Promise<void>;
     test(connection: import('../shared/desktop-connection-snapshot').DesktopConnectionIdentity | string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise<ConnectionTestResult>;
diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts
index 5a64dd317a..20518f1dac 100644
--- a/apps/desktop/src/preload/preload.ts
+++ b/apps/desktop/src/preload/preload.ts
@@ -2596,6 +2596,12 @@ const makaBridge = {
     create(input: CreateConnectionInput, host?: DesktopRuntimeHostRef): Promise<import('@maka/core/llm-connections').IdentifiedLlmConnection> {
       return invokeSelectedRuntimeHost(host, 'connections:create', input);
     },
+    verifyOnboarding(input, host) {
+      return invokeSelectedRuntimeHost(host, 'connections:onboardingVerify', input);
+    },
+    saveOnboarding(input, host) {
+      return invokeSelectedRuntimeHost(host, 'connections:onboardingSave', input);
+    },
     update(connection: import('../shared/desktop-connection-snapshot.js').DesktopConnectionIdentity, patch: UpdateConnectionInput, host?: DesktopRuntimeHostRef): Promise<LlmConnection> {
       return invokeSelectedRuntimeHost(host, 'connections:update', connection, patch);
     },
diff --git a/apps/desktop/src/renderer/settings/provider-add-submission.ts b/apps/desktop/src/renderer/settings/provider-add-submission.ts
index 8b3b8ec734..8ce4e6f6c2 100644
--- a/apps/desktop/src/renderer/settings/provider-add-submission.ts
+++ b/apps/desktop/src/renderer/settings/provider-add-submission.ts
@@ -23,9 +23,63 @@ import {
   providerAuthSupportsApiKey,
   providerSupportsModelDiscovery,
   validateSlug,
+  type ModelInfo,
   type ProviderType,
 } from '@maka/core/llm-connections';
 import type { CreateConnectionInput, IdentifiedLlmConnection } from '@maka/core/llm-connections';
+export type ApiKeyOnboardingRoute =
+  | { readonly kind: 'host' }
+  | {
+      readonly kind: 'legacy';
+      readonly reason:
+        | 'provider_auth'
+        | 'custom_endpoint'
+        | 'cloudflare'
+        | 'request_headers'
+        | 'request_body';
+    };
+
+export function initialManagedOnboardingPhase(
+  saveDispatched: boolean,
+): 'input' | 'outcome_unknown' {
+  return saveDispatched ? 'outcome_unknown' : 'input';
+}
+
+/** Decide the only writer before either writer performs a side effect. */
+export function apiKeyOnboardingRoute(input: {
+  readonly providerType: ProviderType;
+  readonly requestHeaderCount: number;
+  readonly hasRequestBodyOverlay: boolean;
+}): ApiKeyOnboardingRoute {
+  const definition = PROVIDER_DEFAULTS[input.providerType];
+  if (!providerAuthSupportsApiKey(input.providerType) || definition.authKind !== 'api_key') {
+    return { kind: 'legacy', reason: 'provider_auth' };
+  }
+  if (input.providerType === 'cloudflare-workers-ai') {
+    return { kind: 'legacy', reason: 'cloudflare' };
+  }
+  if (!definition.baseUrl) return { kind: 'legacy', reason: 'custom_endpoint' };
+  if (input.requestHeaderCount > 0) return { kind: 'legacy', reason: 'request_headers' };
+  if (input.hasRequestBodyOverlay) return { kind: 'legacy', reason: 'request_body' };
+  return { kind: 'host' };
+}
+
+export function stableOnboardingModels(models: readonly ModelInfo[]): ModelInfo[] {
+  return [...models].sort((left, right) => {
+    const leftLabel = left.displayName?.trim() || left.id;
+    const rightLabel = right.displayName?.trim() || right.id;
+    return leftLabel.localeCompare(rightLabel) || left.id.localeCompare(right.id);
+  });
+}
+
+export function initialOnboardingModelIds(
+  models: readonly ModelInfo[],
+  recommendedModelId: string,
+): string[] {
+  if (models.some((model) => model.id === recommendedModelId)) return [recommendedModelId];
+  const first = stableOnboardingModels(models)[0];
+  return first ? [first.id] : [];
+}
 
 /**
  * The two decisions 添加连接 makes that are not layout: which fields a provider
diff --git a/apps/desktop/src/renderer/settings/provider-panel-shared.ts b/apps/desktop/src/renderer/settings/provider-panel-shared.ts
index 7c9e6454a9..e45a2002d4 100644
--- a/apps/desktop/src/renderer/settings/provider-panel-shared.ts
+++ b/apps/desktop/src/renderer/settings/provider-panel-shared.ts
@@ -24,6 +24,7 @@ import {
   type IdentifiedLlmConnection,
   type LlmConnection,
   type ModelDiscoveryResult,
+  type ModelInfo,
   type RequestHeaderUpdate,
   type SavedRequestHeaders,
   type ProviderCategory,
@@ -38,6 +39,78 @@ import type {
   DesktopConnectionSnapshot,
 } from '../../shared/desktop-connection-snapshot.js';
 
+export type DesktopConnectionOnboardingIdentity = {
+  readonly connectionId: string;
+  readonly revision: number;
+  readonly slug: string;
+  readonly providerType: ProviderType;
+};
+
+export type ApiKeyOnboardingTarget =
+  | { readonly kind: 'create'; readonly providerType: ProviderType }
+  | { readonly kind: 'existing'; readonly connectionId: string };
+
+export type ApiKeyOnboardingVerifyResult =
+  | { readonly kind: 'verified'; readonly models: readonly ModelInfo[] }
+  | {
+      readonly kind: 'rejected';
+      readonly reason:
+        | 'provider_unsupported'
+        | 'connection_not_found'
+        | 'credential_not_configured'
+        | 'base_url_not_configured'
+        | 'catalog_full';
+    }
+  | {
+      readonly kind: 'failed';
+      readonly errorClass:
+        | 'auth'
+        | 'timeout'
+        | 'provider_unavailable'
+        | 'network'
+        | 'invalid_response'
+        | 'unknown';
+    };
+
+export type ApiKeyOnboardingSaveResult =
+  | { readonly kind: 'saved'; readonly connection: DesktopConnectionOnboardingIdentity }
+  | {
+      readonly kind: 'rejected';
+      readonly reason:
+        | 'provider_unsupported'
+        | 'connection_not_found'
+        | 'credential_not_configured'
+        | 'base_url_not_configured'
+        | 'catalog_full'
+        | 'model_unavailable'
+        | 'superseded';
+    }
+  | Exclude<ApiKeyOnboardingVerifyResult, { readonly kind: 'verified' | 'rejected' }>;
+
+export interface ApiKeyOnboardingBridge {
+  readonly saveUncertainty: {
+    isUncertain(): boolean;
+    markDispatched(): number;
+    settle(attemptId: number): void;
+    restart(): void;
+  };
+  verify(input: {
+    readonly target: ApiKeyOnboardingTarget;
+    readonly apiKey: string | null;
+    readonly baseUrl: string | null;
+  }): Promise<ApiKeyOnboardingVerifyResult>;
+  save(input: {
+    readonly target: ApiKeyOnboardingTarget;
+    readonly apiKey: string | null;
+    readonly baseUrl: string | null;
+    readonly enabledModelIds: readonly string[];
+  }): Promise<
+    | { readonly kind: 'result'; readonly result: ApiKeyOnboardingSaveResult }
+    | { readonly kind: 'not_saved' }
+    | { readonly kind: 'outcome_unknown' }
+  >;
+}
+
 export interface ConnectionsBridge {
   getSnapshot(): Promise<DesktopConnectionSnapshot>;
   setDefault(connection: DesktopConnectionIdentity | null): Promise<void>;
diff --git a/apps/desktop/src/renderer/settings/runtime-host-settings-bridge.ts b/apps/desktop/src/renderer/settings/runtime-host-settings-bridge.ts
index 041bd4d09c..0a4aa702d7 100644
--- a/apps/desktop/src/renderer/settings/runtime-host-settings-bridge.ts
+++ b/apps/desktop/src/renderer/settings/runtime-host-settings-bridge.ts
@@ -22,7 +22,10 @@ import type {
   DesktopRuntimeHostRef,
   MakaBridge,
 } from '../../preload/bridge-contract.js';
-import type { ConnectionsBridge } from './provider-panel-shared.js';
+import type {
+  ApiKeyOnboardingBridge,
+  ConnectionsBridge,
+} from './provider-panel-shared.js';
 import type {
   OAuthAccountFlowBridge,
   OAuthAuthorizationFlowBridge,
@@ -53,6 +56,37 @@ export function runtimeHostConnectionsBridge(
   };
 }
 
+export function runtimeHostApiKeyOnboardingBridge(
+  host: DesktopRuntimeHostRef,
+): ApiKeyOnboardingBridge {
+  const targetKey = `${host.profileId}\u0000${host.hostId}`;
+  return {
+    saveUncertainty: {
+      isUncertain: () => uncertainApiKeyOnboardingTargets.has(targetKey),
+      markDispatched: () => {
+        const attemptId = nextApiKeyOnboardingAttemptId++;
+        uncertainApiKeyOnboardingTargets.set(targetKey, attemptId);
+        return attemptId;
+      },
+      settle: (attemptId) => {
+        if (uncertainApiKeyOnboardingTargets.get(targetKey) === attemptId) {
+          uncertainApiKeyOnboardingTargets.delete(targetKey);
+        }
+      },
+      restart: () => uncertainApiKeyOnboardingTargets.delete(targetKey),
+    },
+    verify: (input) => window.maka.connections.verifyOnboarding(input, host),
+    save: (input) => window.maka.connections.saveOnboarding(input, host),
+  };
+}
+
+// A Host replacement remounts the settings projection while a dispatched save
+// may still settle. Keep the safety gate target-scoped instead of form-scoped;
+// durable reconciliation across a Desktop process restart belongs to the
+// follow-up Host journal protocol.
+const uncertainApiKeyOnboardingTargets = new Map<string, number>();
+let nextApiKeyOnboardingAttemptId = 1;
+
 type RuntimeHostOAuthBridge = MakaBridge['openAiCodex'] | MakaBridge['xaiOAuth'];
 
 export function runtimeHostOAuthAuthorizationBridge(


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