diff --git a/src/features/providers/api/catalog.test.ts b/src/features/providers/api/catalog.test.ts index 9a06dc9d4..3a8cd69c4 100644 --- a/src/features/providers/api/catalog.test.ts +++ b/src/features/providers/api/catalog.test.ts @@ -55,7 +55,7 @@ describe("provider setup catalog API", () => { docsUrl: "https://docs.anthropic.com/en/docs/claude-code", group: "default", showOnlyWhenInstalled: false, - aliases: ["claude-acp", "claude_code", "claude"], + aliases: ["claude-acp", "claude_code", "claude-code", "claude"], supportsInstall: true, supportsAuth: true, supportsAuthStatus: true, @@ -154,7 +154,7 @@ describe("selectSetupCatalogModelProviders", () => { ).toEqual(["openai", "databricks_v2", "anthropic", "ollama"]); }); - it("selects only the editable Databricks host field", () => { + it("selects the Databricks setup fields when an editable host is available", () => { expect( selectDatabricksHostConfigProvider([ { @@ -175,6 +175,6 @@ describe("selectSetupCatalogModelProviders", () => { ], }, ])?.fields?.map((field) => field.key), - ).toEqual(["DATABRICKS_HOST"]); + ).toEqual(["DATABRICKS_HOST", "DATABRICKS_TOKEN"]); }); }); diff --git a/src/features/providers/api/catalog.ts b/src/features/providers/api/catalog.ts index ad9eeeaca..7aa02b604 100644 --- a/src/features/providers/api/catalog.ts +++ b/src/features/providers/api/catalog.ts @@ -1,4 +1,5 @@ import type { ProviderSetupCatalogEntryDto } from "@aaif/goose-sdk"; +import { CURATED_PROVIDER_CATALOG_BY_ID } from "@/features/providers/curatedProviders"; import { getClient } from "@/shared/api/acpConnection"; import type { ProviderCatalogEntry } from "@/shared/types/providers"; import { perfLog } from "@/shared/lib/perfLog"; @@ -6,21 +7,29 @@ import { perfLog } from "@/shared/lib/perfLog"; export function mapProviderSetupCatalogEntryDto( dto: ProviderSetupCatalogEntryDto, ): ProviderCatalogEntry { + // Goose owns current setup data, while Berd's curated catalog carries stable + // client identity and native-connect capabilities. A fetched same-id entry + // must add to that metadata rather than make credentials and actions vanish. + const curatedEntry = CURATED_PROVIDER_CATALOG_BY_ID.get(dto.providerId); + const aliases = [ + ...new Set([...(curatedEntry?.aliases ?? []), ...(dto.aliases ?? [])]), + ]; + const nativeConnectQuery = + dto.nativeConnectQuery ?? curatedEntry?.nativeConnectQuery; + return { id: dto.providerId, displayName: dto.name, category: dto.category, description: dto.description, setupMethod: dto.setupMethod, - ...(dto.nativeConnectQuery - ? { nativeConnectQuery: dto.nativeConnectQuery } - : {}), + ...(nativeConnectQuery ? { nativeConnectQuery } : {}), ...(dto.fields?.length ? { fields: dto.fields } : {}), ...(dto.binaryName ? { binaryName: dto.binaryName } : {}), ...(dto.docUrl ? { docsUrl: dto.docUrl } : {}), group: dto.group, showOnlyWhenInstalled: dto.showOnlyWhenInstalled, - ...(dto.aliases?.length ? { aliases: dto.aliases } : {}), + ...(aliases.length ? { aliases } : {}), supportsInstall: dto.supportsInstall, supportsAuth: dto.supportsAuth, supportsAuthStatus: dto.supportsAuthStatus, @@ -68,10 +77,10 @@ export function selectDatabricksHostConfigProvider( const entry = entries.find( (candidate) => candidate.id === SETUP_CATALOG_DATABRICKS_PROVIDER_ID, ); - const fields = entry?.fields?.filter( + const hasHostField = entry?.fields?.some( (field) => field.key === SETUP_CATALOG_DATABRICKS_HOST_FIELD_KEY, ); - return entry && fields?.length ? { ...entry, fields } : null; + return entry && hasHostField ? entry : null; } export async function listProviderSetupCatalog(): Promise< diff --git a/src/features/providers/runtimeProviderConfig.test.ts b/src/features/providers/runtimeProviderConfig.test.ts index 1a8e6dce9..110d7df46 100644 --- a/src/features/providers/runtimeProviderConfig.test.ts +++ b/src/features/providers/runtimeProviderConfig.test.ts @@ -4,6 +4,8 @@ import { type RuntimeConfig, } from "@/shared/runtime-config/schema"; import type { ProviderCatalogEntry } from "@/shared/types/providers"; +import { mapProviderSetupCatalogEntryDto } from "./api/catalog"; +import { isCredentialedProvider } from "./lib/providerConnectionPolicy"; import { getModelCacheRefreshProviderIds } from "./modelCacheRefresh"; import { applyRuntimeProviderConfig, @@ -214,6 +216,66 @@ describe("mergeRuntimeProviderCatalog", () => { ]); }); + it("preserves curated Databricks identity through setup and runtime catalog composition", () => { + const fetchedSetupEntry = mapProviderSetupCatalogEntryDto({ + providerId: "databricks_v2", + name: "Databricks AI Gateway", + category: "model", + description: "Databricks AI Gateway models", + setupMethod: "host_with_oauth_fallback", + fields: [ + { + key: "DATABRICKS_HOST", + label: "Host", + secret: false, + required: true, + }, + { + key: "DATABRICKS_TOKEN", + label: "Token", + secret: true, + required: false, + }, + ], + group: "default", + showOnlyWhenInstalled: false, + aliases: ["databricks_ai_gateway"], + supportsInstall: false, + supportsAuth: true, + supportsAuthStatus: false, + }); + + const merged = mergeRuntimeProviderCatalog( + [fetchedSetupEntry], + MANAGED_RUNTIME_CONFIG, + ); + const databricks = merged.find((entry) => entry.id === "databricks_v2"); + if (!databricks) { + throw new Error("Expected Databricks in the composed provider catalog"); + } + expect(databricks.nativeConnectQuery).toBe("databricks"); + expect(databricks.aliases).toEqual( + expect.arrayContaining([ + "databricks_v2", + "databricks", + "databricks_ai_gateway", + ]), + ); + + const credentialed = isCredentialedProvider( + databricks, + new Set(["databricks"]), + ); + expect(credentialed).toBe(true); + expect( + getModelCacheRefreshProviderIds(MANAGED_RUNTIME_CONFIG, { + byoKeyProvidersEnabled: true, + catalogEntries: merged, + configuredProviderIds: credentialed ? ["databricks_v2"] : [], + }), + ).toContain("databricks_v2"); + }); + it("keeps managed Databricks setup fields hidden", () => { const existing: ProviderCatalogEntry[] = [ { @@ -261,7 +323,7 @@ describe("mergeRuntimeProviderCatalog", () => { expect(databricks.displayName).toBe("Databricks AI Gateway"); }); - it("keeps only the Databricks host field when runtime config has no endpoint env", () => { + it("keeps Databricks host and token fields when runtime config has no endpoint env", () => { const configWithoutEndpointEnv: RuntimeConfig = { ...DEFAULT_RUNTIME_CONFIG, goose: { @@ -301,6 +363,7 @@ describe("mergeRuntimeProviderCatalog", () => { expect(databricks?.fields?.map((field) => field.key)).toEqual([ "DATABRICKS_HOST", + "DATABRICKS_TOKEN", ]); }); }); diff --git a/src/features/providers/runtimeProviderConfig.ts b/src/features/providers/runtimeProviderConfig.ts index 55f2beced..3a6b3cf66 100644 --- a/src/features/providers/runtimeProviderConfig.ts +++ b/src/features/providers/runtimeProviderConfig.ts @@ -16,7 +16,6 @@ import type { ProviderCatalogEntry } from "@/shared/types/providers"; const GOOSE_AGENT_PROVIDER_ID = "goose"; const DATABRICKS_PROVIDER_ID = "databricks_v2"; -const DATABRICKS_HOST_FIELD_KEY = "DATABRICKS_HOST"; const DEFAULT_MODEL_INVENTORY_MODE: RuntimeModelInventoryMode = "authoritative"; export function defaultModelInventoryModeForLoadResult( @@ -108,9 +107,7 @@ export function mergeRuntimeProviderCatalog( if (databricksCatalogEntry) { databricksCatalogEntry.fields = databricks.endpointEnv ? undefined - : databricksSetupEntry.fields.filter( - (field) => field.key === DATABRICKS_HOST_FIELD_KEY, - ); + : databricksSetupEntry.fields; } } diff --git a/src/features/providers/ui/ModelProviderRow.tsx b/src/features/providers/ui/ModelProviderRow.tsx index 5babac78c..f2cabc5a4 100644 --- a/src/features/providers/ui/ModelProviderRow.tsx +++ b/src/features/providers/ui/ModelProviderRow.tsx @@ -330,7 +330,7 @@ export function ModelProviderRow({ panelRef.current?.focus({ preventScroll: true }); }); - function runNativeConnect() { + async function runNativeConnect() { if (!provider.nativeConnectQuery) { return; } @@ -340,13 +340,22 @@ export function ModelProviderRow({ setError(""); setShowSavedState(false); - // Kick off the backend-owned `goose configure` sign-in; the store mirrors - // its progress and the success effect runs the post-success refresh. The - // operation keeps running (and is observable) even if this row unmounts or - // the window reloads. - void startSetup(provider.id, { - providerLabel: provider.nativeConnectQuery, - }); + try { + // Kick off the backend-owned Berd sign-in; the store mirrors its progress + // and the success effect runs the post-success refresh. The operation + // keeps running (and is observable) even if this row unmounts or the + // window reloads. + await startSetup(provider.id, { + providerLabel: provider.nativeConnectQuery, + }); + } catch (nextError) { + setOperation(provider.id, { + phase: "idle", + status: "failed", + output: setupOutputLines, + error: formatAcpErrorMessage(nextError, "Couldn't start sign-in"), + }); + } } function handleExpandedChange(nextExpanded: boolean) { @@ -438,24 +447,39 @@ export function ModelProviderRow({ return nextValue !== (currentValue.value ?? ""); }); - if (fieldsToSave.length === 0) { - setError(""); - return; - } + const shouldStartNativeAuthentication = + provider.setupMethod === "host_with_oauth_fallback" && + supportsNativeConnect && + !fields.some( + (field) => + field.secret && (draftValues[field.key]?.trim() ?? "").length > 0, + ); setError(""); try { - await onSaveFields( - fieldsToSave.map((field) => ({ - key: field.key, - value: draftValues[field.key]?.trim() ?? "", - isSecret: field.secret, - })), - ); - fieldsToSave.forEach((field) => { - dirtyDraftKeys.current.delete(field.key); - }); - void loadConfig(); + if (fieldsToSave.length > 0) { + await onSaveFields( + fieldsToSave.map((field) => ({ + key: field.key, + value: draftValues[field.key]?.trim() ?? "", + isSecret: field.secret, + })), + ); + fieldsToSave.forEach((field) => { + dirtyDraftKeys.current.delete(field.key); + }); + void loadConfig(); + } + + if (shouldStartNativeAuthentication) { + await runNativeConnect(); + return; + } + + if (fieldsToSave.length === 0) { + return; + } + onProviderConnected?.(provider.id); setShowSavedState(false); } catch (nextError) { @@ -602,6 +626,10 @@ export function ModelProviderRow({ error={error} setupMethod={provider.setupMethod} setupMessage={setupMessage} + authenticating={authenticating} + setupOutputLines={setupOutputLines} + setupOutputRef={outputRef} + setupError={setupError} onDraftChange={handleDraftChange} onSaveSetup={() => void handleSaveSetup()} /> diff --git a/src/features/settings/ui/ModelProviderPanels.tsx b/src/features/settings/ui/ModelProviderPanels.tsx index 2020d3301..3b4c97a77 100644 --- a/src/features/settings/ui/ModelProviderPanels.tsx +++ b/src/features/settings/ui/ModelProviderPanels.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; import { Spinner } from "@/shared/ui/spinner"; +import { ProviderSetupOutput } from "@/features/settings/ui/ProviderSetupOutput"; import type { ProviderField, ProviderFieldValue, @@ -240,6 +241,10 @@ interface SetupFieldsPanelProps { error: string; setupMethod: ProviderSetupMethod; setupMessage: string | null; + authenticating: boolean; + setupOutputLines: string[]; + setupOutputRef: RefObject; + setupError: string; onDraftChange: (key: string, value: string) => void; onSaveSetup: () => void; } @@ -256,23 +261,30 @@ export function SetupFieldsPanel({ error, setupMethod, setupMessage, + authenticating, + setupOutputLines, + setupOutputRef, + setupError, onDraftChange, onSaveSetup, }: SetupFieldsPanelProps) { const { t } = useTranslation(["settings", "common"]); const showInlineSave = fields.length === 1; + const busy = saving || authenticating; const saveButton = (