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