diff --git a/apps/desktop/src/main/__tests__/provider-add-catalog-probe.test.ts b/apps/desktop/src/main/__tests__/provider-add-catalog-probe.test.ts new file mode 100644 index 0000000000..da7367e8fc --- /dev/null +++ b/apps/desktop/src/main/__tests__/provider-add-catalog-probe.test.ts @@ -0,0 +1,87 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { ConnectionCatalogProbeOutcome } from '../../shared/connection-catalog-probe.js'; +import { + catalogProbeChoices, + catalogProbeRequest, + runCatalogProbe, + type CatalogProbeDraft, +} from '../../renderer/settings/provider-add-catalog-probe.js'; + +function draft(over: Partial = {}): CatalogProbeDraft { + return { + providerType: 'openai-compatible', + baseUrl: 'https://relay.example.test/v1', + apiKey: 'sk-relay', + ...over, + }; +} + +test('refuses to probe before the form has an endpoint', () => { + assert.equal(catalogProbeRequest(draft({ baseUrl: ' ' })), null); + assert.equal(catalogProbeRequest(draft({ baseUrl: '' })), null); +}); + +test('trims the endpoint and passes a trimmed key, or none when the key is blank', () => { + assert.deepEqual( + catalogProbeRequest(draft({ baseUrl: ' https://relay.example.test/v1 ', apiKey: ' sk-relay ' })), + { + providerType: 'openai-compatible', + baseUrl: 'https://relay.example.test/v1', + apiKey: 'sk-relay', + }, + ); + assert.deepEqual(catalogProbeRequest(draft({ apiKey: ' ' })), { + providerType: 'openai-compatible', + baseUrl: 'https://relay.example.test/v1', + apiKey: null, + }); +}); + +test('sends the request as the form drafted it and returns the host verdict', async () => { + const outcome: ConnectionCatalogProbeOutcome = { + kind: 'ready', + models: [{ id: 'relay-model' }], + }; + const probed = await runCatalogProbe(async (request) => { + assert.deepEqual(request, catalogProbeRequest(draft())); + return outcome; + }, catalogProbeRequest(draft())!); + assert.equal(probed, outcome); +}); + +test('turns a rejected IPC frame into a failed verdict instead of throwing', async () => { + const probed = await runCatalogProbe(async () => { + throw new Error('An endpoint is required to probe the model catalog'); + }, catalogProbeRequest(draft())!); + assert.deepEqual(probed, { kind: 'failed', errorClass: 'unknown' }); +}); + +test('offers the model id as both the chooser value and its label', () => { + assert.deepEqual( + catalogProbeChoices([ + { id: 'relay-model', contextWindow: 128_000 }, + { id: 'vision-model', displayName: 'Relay Vision' }, + ]), + [{ value: 'relay-model', label: 'relay-model' }, { value: 'vision-model', label: 'vision-model' }], + ); +}); 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 7eda0a88ce..f4e3f06a3c 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,11 +21,14 @@ 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 type { ConnectionOnboardingVerifyInput } from '@maka/runtime-host/protocol'; import { + projectConnectionCatalogProbe, projectHostConnections, projectHostConnectionTest, registerRuntimeHostConnectionsIpc, } from '../runtime-host-connections-ipc-main.js'; +import { normalizeConnectionCatalogProbeInput } from '../connections-ipc-validation.js'; test('registers pure Connection reads for replacement-Host retry', () => { const reads = new Set(); @@ -351,6 +354,108 @@ test('preserves the Host-tested model and diagnostics for the existing Desktop U ); }); +test('probes a relay catalog through the host before a connection exists', async () => { + const handlers = new Map unknown>(); + registerRuntimeHostConnectionsIpc({ + ipcMain: { + handle: (channel, handler) => { + handlers.set(channel, handler as (...args: unknown[]) => unknown); + }, + }, + client: { + probeConnectionModels: async (input: ConnectionOnboardingVerifyInput) => { + assert.deepEqual(input, { + providerType: 'openai-compatible', + // No connection exists to edit yet: the probe targets the canonical + // slug as null, exactly like the post-create discovery fetch. + connectionId: null, + apiKey: 'relay-key', + baseUrl: 'https://relay.example.test/v1', + }); + return { + kind: 'verified', + models: [{ id: 'relay-model', contextWindow: 128_000 }], + }; + }, + } as never, + emitConnectionListChanged() {}, + }); + + // Endpoint and key are canonicalized at the boundary before reaching the + // host; the handler never sees the surrounding whitespace the user typed. + const outcome = await handlers.get('connections:probeModels')?.( + {}, + { + providerType: 'openai-compatible', + baseUrl: ' https://relay.example.test/v1 ', + apiKey: ' relay-key ', + }, + ); + assert.deepEqual(outcome, { + kind: 'ready', + models: [{ id: 'relay-model', contextWindow: 128_000 }], + }); +}); + +test('projects the host onboarding verdict onto the probe outcome contract', () => { + assert.deepEqual( + projectConnectionCatalogProbe({ + kind: 'verified', + models: [{ id: 'relay-model' }], + }), + { kind: 'ready', models: [{ id: 'relay-model' }] }, + ); + assert.deepEqual( + projectConnectionCatalogProbe({ kind: 'rejected', reason: 'credential_not_configured' }), + { kind: 'rejected', reason: 'credential_not_configured' }, + ); + assert.deepEqual( + projectConnectionCatalogProbe({ kind: 'rejected', reason: 'base_url_not_configured' }), + { kind: 'rejected', reason: 'base_url_not_configured' }, + ); + // A fresh probe never names an existing connection, so a connection-identity + // rejection collapses to a generic failure rather than a reason the form + // cannot render. + assert.deepEqual( + projectConnectionCatalogProbe({ kind: 'rejected', reason: 'connection_not_found' }), + { kind: 'failed', errorClass: 'connection_not_found' }, + ); + assert.deepEqual( + projectConnectionCatalogProbe({ kind: 'failed', errorClass: 'auth' }), + { kind: 'failed', errorClass: 'auth' }, + ); +}); + +test('refuses a catalog probe without a probeable endpoint', () => { + assert.throws( + () => + normalizeConnectionCatalogProbeInput({ + providerType: 'openai-compatible', + baseUrl: ' ', + apiKey: null, + }), + /endpoint is required/u, + ); + assert.throws( + () => + normalizeConnectionCatalogProbeInput({ + providerType: 'openai-compatible', + baseUrl: 'not a url', + apiKey: 'relay-key', + }), + /valid URL/u, + ); + assert.throws( + () => + normalizeConnectionCatalogProbeInput({ + providerType: 'no-such-provider', + baseUrl: 'https://relay.example.test/v1', + apiKey: null, + }), + /Invalid provider type/u, + ); +}); + function catalog(): ConnectionCatalogSnapshot { return { revision: 7, diff --git a/apps/desktop/src/main/connections-ipc-validation.ts b/apps/desktop/src/main/connections-ipc-validation.ts index 603ee5a65a..2e6d93f967 100644 --- a/apps/desktop/src/main/connections-ipc-validation.ts +++ b/apps/desktop/src/main/connections-ipc-validation.ts @@ -20,6 +20,7 @@ import { normalizeConnectionBaseUrl, type CreateConnectionInput, + type ProviderType, type UpdateConnectionInput, } from '@maka/core/llm-connections'; import { normalizeOptionalRequestBodyOverlay, normalizeRequestHeaders } from '@maka/core/runtime-policy'; @@ -130,3 +131,39 @@ export function normalizeConnectionBaseUrlValueForIpc( if (!result.ok) throw new Error(result.error); return result.value; } + +/** The probe's contract over the renderer boundary: a provider type, the + * endpoint the catalog should be read from, and an optional API key. */ +export interface ConnectionCatalogProbeInput { + readonly providerType: ProviderType; + readonly baseUrl: string; + readonly apiKey: string | null; +} + +/** + * Validate and canonicalize the catalog-probe IPC payload. The endpoint is + * required — this probe exists to read a catalog from an endpoint the app + * has not met yet, so there is nothing to probe without one. Reuses the + * connection base URL normalization so a malformed endpoint rejects with the + * same copy the connection form already shows. + */ +export function normalizeConnectionCatalogProbeInput(value: unknown): ConnectionCatalogProbeInput { + if (typeof value !== 'object' || value === null) { + throw new Error('Invalid connection catalog probe'); + } + const raw = value as Record; + const providerType = raw.providerType; + if (typeof providerType !== 'string' || !PROVIDER_DEFAULTS[providerType as ProviderType]) { + throw new Error('Invalid provider type'); + } + const typedProviderType = providerType as ProviderType; + const apiKey = typeof raw.apiKey === 'string' && raw.apiKey.trim().length > 0 + ? raw.apiKey.trim() + : null; + const baseUrl = normalizeConnectionBaseUrlValueForIpc( + typedProviderType, + String(raw.baseUrl ?? ''), + ); + if (baseUrl.length === 0) throw new Error('An endpoint is required to probe the model catalog'); + return { providerType: typedProviderType, baseUrl, apiKey }; +} diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index bec3cb65ca..8d79399cce 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -402,6 +402,12 @@ export class DesktopRuntimeHostClient { return this.request("connection.models.fetch", { connectionId }); } + probeConnectionModels( + input: OperationInput<"connection.onboarding.verify">, + ): Promise> { + return this.request("connection.onboarding.verify", input); + } + testConnection( connectionId: string, modelId?: string, 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 64716cf624..76aa998f33 100644 --- a/apps/desktop/src/main/runtime-host-connections-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-connections-ipc-main.ts @@ -38,7 +38,12 @@ import type { CredentialLocator, } from '@maka/core/runtime-policy'; import { normalizeRequestHeaderUpdates } from '@maka/core/runtime-policy'; -import type { ConnectionTestRunResult } from '@maka/runtime-host/protocol'; +import type { + ConnectionOnboardingVerifyResult, + ConnectionTestRunResult, +} from '@maka/runtime-host/protocol'; +import { normalizeConnectionCatalogProbeInput } from './connections-ipc-validation.js'; +import type { ConnectionCatalogProbeOutcome } from '../shared/connection-catalog-probe.js'; import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; import { handleReconnectableRead, @@ -59,6 +64,7 @@ type HostConnectionsClient = Pick< | 'fetchConnectionModels' | 'getConnectionRequestHeaders' | 'loadConnectionCatalog' + | 'probeConnectionModels' | 'queryCredential' | 'removeConnection' | 'replaceConnectionRequestHeaders' @@ -288,6 +294,20 @@ export function registerRuntimeHostConnectionsIpc( fetchedAt: result.fetchedAt, }; }); + // Probe the model catalog an endpoint serves BEFORE a connection exists, so + // the custom-relay form can offer a real choice instead of a guess. Runs the + // same discovery the post-create fetch uses against a transient connection; + // `connectionId` is null because there is nothing to edit yet. + deps.ipcMain.handle('connections:probeModels', async (_event, raw: unknown) => { + const input = normalizeConnectionCatalogProbeInput(raw); + const result = await deps.client.probeConnectionModels({ + providerType: input.providerType, + connectionId: null, + apiKey: input.apiKey, + baseUrl: input.baseUrl, + }); + return projectConnectionCatalogProbe(result); + }); deps.ipcMain.handle( 'connections:test', async (_event, slug: unknown, options?: { model?: unknown }) => { @@ -325,6 +345,25 @@ export function projectHostConnectionTest(result: ConnectionTestRunResult): Conn }; } +/** Project the host's onboarding-verify result onto the renderer's shared + * probe contract. The ready branch carries the discovered catalog; the + * rejected and failed branches report why there is none. */ +export function projectConnectionCatalogProbe( + result: ConnectionOnboardingVerifyResult, +): ConnectionCatalogProbeOutcome { + if (result.kind === 'verified') return { kind: 'ready', models: [...result.models] }; + if (result.kind === 'rejected') { + // The probe targets no existing connection (a fresh relay), so a + // connection-identity rejection cannot describe it — surface it as a + // generic failure instead of a rejected reason the form cannot render. + if (result.reason === 'connection_not_found') { + return { kind: 'failed', errorClass: 'connection_not_found' }; + } + return { kind: 'rejected', reason: result.reason }; + } + return { kind: 'failed', errorClass: result.errorClass }; +} + export function projectHostConnections(catalog: ConnectionCatalogSnapshot): LlmConnection[] { return catalog.connections.map((connection) => { const defaultModel = diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index a8cb3c31cf..5860a91af9 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -150,6 +150,10 @@ export type AppIconImportResult = export type { DesktopSessionSummary } from '../shared/desktop-session-projection.js'; import type { DesktopConnectionSnapshot } from '../shared/desktop-connection-snapshot.js'; +import type { + ConnectionCatalogProbeOutcome, + ConnectionCatalogProbeRequest, +} from '../shared/connection-catalog-probe.js'; import type { DesktopExternalSessionCatalogItem } from './external-session-catalog.js'; import type { DesktopDiagnosticInput } from './diagnostics-contract.js'; import type { Result } from '@maka/core/result'; @@ -943,6 +947,10 @@ export interface MakaBridge { delete(slug: string, host?: DesktopRuntimeHostRef): Promise; test(slug: string, opts?: { model?: string }, host?: DesktopRuntimeHostRef): Promise; fetchModels(slug: string, host?: DesktopRuntimeHostRef): Promise; + probeModels( + request: ConnectionCatalogProbeRequest, + host?: DesktopRuntimeHostRef, + ): Promise; hasSecret(slug: string, host?: DesktopRuntimeHostRef): Promise; getRequestHeaders(slug: string, host?: DesktopRuntimeHostRef): Promise; setRequestHeaders( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 883adb1f93..eef09bdc44 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -212,6 +212,10 @@ import { type DesktopTargetScope, } from '../shared/runtime-host-identity.js'; import type { GoalArmOutcome, GoalArmRequest } from '../shared/goal-arm.js'; +import type { + ConnectionCatalogProbeOutcome, + ConnectionCatalogProbeRequest, +} from '../shared/connection-catalog-probe.js'; import { invokeProjectedSessionRuntimeHost as invokeProjectedSessionRuntimeHostBridge, projectProtocolSessionIds, @@ -2165,6 +2169,12 @@ const makaBridge = { fetchModels(slug: string, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'connections:fetchModels', slug); }, + probeModels( + request: ConnectionCatalogProbeRequest, + host?: DesktopRuntimeHostRef, + ): Promise { + return invokeSelectedRuntimeHost(host, 'connections:probeModels', request); + }, hasSecret(slug: string, host?: DesktopRuntimeHostRef): Promise { return invokeSelectedRuntimeHost(host, 'connections:hasSecret', slug); }, diff --git a/apps/desktop/src/renderer/locales/settings-provider-copy.ts b/apps/desktop/src/renderer/locales/settings-provider-copy.ts index bc4b84d4e4..43dfaef36a 100644 --- a/apps/desktop/src/renderer/locales/settings-provider-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-provider-copy.ts @@ -193,6 +193,16 @@ const zhCopy = { saving: '保存中…', save: '保存供应商', keyRequired: (name: string) => `请填写 ${name} API Key`, apiKeyLabel: 'API Key', accountIdLabel: 'Cloudflare Account ID', endpointLabel: '服务地址', defaultModel: '默认模型', defaultModelPlaceholder: '留空即可,保存后自动拉取', defaultModelHelp: '保存后 Maka 会向该端点拉取模型目录。只有当端点不提供目录时,才需要在这里手填一个模型 ID。', + probeCatalog: '读取模型目录', + probeCatalogHelp: '从上方服务地址读取该端点提供的模型,选中后填入默认模型;端点没有模型目录时照旧手填。', + probeCatalogReading: '读取中…', + probeCatalogLoaded: (count: number) => `已读取 ${count} 个模型`, + probeCatalogEmpty: '该端点没有返回模型。', + probeCatalogNeedsKey: '该端点需要 API Key,请先填写再读取。', + probeCatalogNeedsEndpoint: '该端点未配置服务地址,请先填写再读取。', + probeCatalogFailed: '读取失败,请确认服务地址与密钥后重试。', + probeCatalogUnsupported: '该服务商不提供模型目录。', + probeCatalogPickPlaceholder: '从目录选择默认模型', ...zhCapabilitiesCopy, }, oauthFlow: { @@ -338,6 +348,17 @@ 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.', + probeCatalog: 'Read model catalog', + probeCatalogHelp: 'Reads the models this endpoint serves and fills the default model when you pick one. If the endpoint has no catalog, type a model ID as before.', + probeCatalogReading: 'Reading…', + probeCatalogLoaded: (count: number) => + count === 1 ? 'Read 1 model' : `Read ${count} models`, + probeCatalogEmpty: 'This endpoint returned no models.', + probeCatalogNeedsKey: 'This endpoint requires an API key. Enter one first.', + probeCatalogNeedsEndpoint: 'This endpoint has no service URL configured. Enter one first.', + probeCatalogFailed: 'Could not read the catalog. Check the service URL and key, then try again.', + probeCatalogUnsupported: 'This provider does not expose a model catalog.', + probeCatalogPickPlaceholder: 'Pick a default model from the catalog', ...enCapabilitiesCopy, }, oauthFlow: { diff --git a/apps/desktop/src/renderer/settings/provider-add-catalog-probe.ts b/apps/desktop/src/renderer/settings/provider-add-catalog-probe.ts new file mode 100644 index 0000000000..414122b701 --- /dev/null +++ b/apps/desktop/src/renderer/settings/provider-add-catalog-probe.ts @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { ProviderType } from '@maka/core/llm-connections'; +import type { + ConnectionCatalogProbeModel, + ConnectionCatalogProbeOutcome, + ConnectionCatalogProbeRequest, +} from '../../shared/connection-catalog-probe.js'; + +/** + * The catalog probe behind the custom-relay add form. + * + * The form used to ask a relay for a hand-typed default model id before it + * could see the catalog the endpoint serves (#3442). This module owns the + * two decisions that are not layout: what the probe asks for given the form + * draft, and how a bridge reply reduces onto the chooser's view. Keeping them + * outside the component means the request shape and the outcome switch are + * unit-testable without a DOM, in the same spirit as provider-add-submission. + */ + +export interface CatalogProbeDraft { + readonly providerType: ProviderType; + readonly baseUrl: string; + readonly apiKey: string; +} + +/** + * Build the probe request from the form draft, or null when there is no + * endpoint to read from yet. The key is optional: a local relay may accept a + * catalog read without credentials, and the host rejects cleanly when the + * provider actually demands one (credential_not_configured). + */ +export function catalogProbeRequest( + draft: CatalogProbeDraft, +): ConnectionCatalogProbeRequest | null { + const baseUrl = draft.baseUrl.trim(); + if (baseUrl.length === 0) return null; + const apiKey = draft.apiKey.trim(); + return { + providerType: draft.providerType, + baseUrl, + apiKey: apiKey.length > 0 ? apiKey : null, + }; +} + +/** The chooser's view of a probe run: idle and probing are form-local; the + * rest is the host's verdict verbatim, so a copy change never has to touch + * this switch. */ +export type CatalogProbeView = + | { readonly kind: 'idle' } + | { readonly kind: 'probing' } + | ConnectionCatalogProbeOutcome; + +/** + * Run the probe through the bridge and reduce its promise onto a view. + * A rejected IPC frame surfaces as a failed outcome rather than an + * unhandled rejection, so the form always has a message to show. + */ +export async function runCatalogProbe( + probe: ( + request: ConnectionCatalogProbeRequest, + ) => Promise, + request: ConnectionCatalogProbeRequest, +): Promise { + try { + return await probe(request); + } catch { + return { kind: 'failed', errorClass: 'unknown' }; + } +} + +/** The chooser options a ready probe offers: the model id is both the value + * and the label, because the id is exactly what the default-model field + * needs — a display name would hide the thing that gets submitted. */ +export function catalogProbeChoices( + models: readonly ConnectionCatalogProbeModel[], +): readonly { readonly value: string; readonly label: string }[] { + return models.map(({ id }) => ({ value: id, label: id })); +} diff --git a/apps/desktop/src/renderer/settings/provider-add-form.tsx b/apps/desktop/src/renderer/settings/provider-add-form.tsx index a3f9c592d6..7fa3138102 100644 --- a/apps/desktop/src/renderer/settings/provider-add-form.tsx +++ b/apps/desktop/src/renderer/settings/provider-add-form.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { useState, type FormEvent } from 'react'; +import { useEffect, useRef, useState, type FormEvent } from 'react'; import { OPENCODE_FREE_DEFAULT_ENABLED_MODELS, type ProviderType, @@ -27,11 +27,12 @@ import { providerAuthRequiresSecret, providerAuthSupportsApiKey, } from '@maka/core/llm-connections'; -import { Banner, HStack, VStack } from '@astryxdesign/core'; +import { Banner, HStack, Text, VStack } from '@astryxdesign/core'; import { Collapsible } from '@astryxdesign/core/Collapsible'; import { Button, FormLayout, + Selector, TextInput, useMountedRef, useUiLocale, @@ -58,6 +59,16 @@ import { validateAddProviderDraft, type AddProviderIssue, } from './provider-add-submission'; +import { + catalogProbeChoices, + catalogProbeRequest, + runCatalogProbe, + type CatalogProbeView, +} from './provider-add-catalog-probe'; +import type { + ConnectionCatalogProbeOutcome, + ConnectionCatalogProbeRequest, +} from '../../shared/connection-catalog-probe.js'; /* No `defaultModel`: the creation gate has no rule that can fail on the model id, so an error could never be reported against that field. The union is @@ -355,14 +366,25 @@ export function AddProviderForm(props: { /> )} {showsDefaultModel && ( - + + + props.bridge.probeModels(request)} + onPickModel={setDefaultModel} + /> + )} {advancedRequestEditor} @@ -377,6 +399,111 @@ export function AddProviderForm(props: { ); } +/** The catalog probe for a custom relay: a button that reads the model list + * the endpoint serves, then a chooser that fills the default-model field from + * it. The view is the host's verdict (see provider-add-catalog-probe); it + * resets the moment the endpoint or key the user typed changes, so a picked + * model can never belong to a catalog probed from different inputs. */ +function CatalogProbeField(props: { + providerType: ProviderType; + baseUrl: string; + apiKey: string; + value: string; + isDisabled: boolean; + probeModels: ( + request: ConnectionCatalogProbeRequest, + ) => Promise; + onPickModel: (modelId: string) => void; +}) { + const locale = useUiLocale(); + const copy = getProviderSettingsCopy(locale).add; + const probeGuard = useActionGuard<'probe'>(); + const mountedRef = useMountedRef(); + const [view, setView] = useState({ kind: 'idle' }); + const requestToken = useRef(0); + + useEffect(() => { + requestToken.current += 1; + setView({ kind: 'idle' }); + }, [props.baseUrl, props.apiKey]); + + async function probe() { + const request = catalogProbeRequest({ + providerType: props.providerType, + baseUrl: props.baseUrl, + apiKey: props.apiKey, + }); + if (!request || !probeGuard.begin('probe')) return; + const token = ++requestToken.current; + setView({ kind: 'probing' }); + try { + const outcome = await runCatalogProbe(props.probeModels, request); + // Discard a verdict for inputs that changed while the probe ran. + if (requestToken.current !== token || !mountedRef.current) return; + setView(outcome); + } finally { + probeGuard.finish(); + } + } + + const busy = props.isDisabled || view.kind === 'probing'; + const models = view.kind === 'ready' ? view.models : []; + const selectedInCatalog = + view.kind === 'ready' && models.some(({ id }) => id === props.value); + + let message: string | null = null; + if (view.kind === 'rejected') { + message = + view.reason === 'credential_not_configured' + ? copy.probeCatalogNeedsKey + : view.reason === 'base_url_not_configured' + ? copy.probeCatalogNeedsEndpoint + : view.reason === 'provider_unsupported' + ? copy.probeCatalogUnsupported + : copy.probeCatalogFailed; + } else if (view.kind === 'failed') { + message = copy.probeCatalogFailed; + } + + return ( + + +