Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/features/providers/api/catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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([
{
Expand All @@ -175,6 +175,6 @@ describe("selectSetupCatalogModelProviders", () => {
],
},
])?.fields?.map((field) => field.key),
).toEqual(["DATABRICKS_HOST"]);
).toEqual(["DATABRICKS_HOST", "DATABRICKS_TOKEN"]);
});
});
21 changes: 15 additions & 6 deletions src/features/providers/api/catalog.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,35 @@
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";

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,
Expand Down Expand Up @@ -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<
Expand Down
65 changes: 64 additions & 1 deletion src/features/providers/runtimeProviderConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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[] = [
{
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -301,6 +363,7 @@ describe("mergeRuntimeProviderCatalog", () => {

expect(databricks?.fields?.map((field) => field.key)).toEqual([
"DATABRICKS_HOST",
"DATABRICKS_TOKEN",
]);
});
});
Expand Down
5 changes: 1 addition & 4 deletions src/features/providers/runtimeProviderConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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;
}
}

Expand Down
74 changes: 51 additions & 23 deletions src/features/providers/ui/ModelProviderRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ export function ModelProviderRow({
panelRef.current?.focus({ preventScroll: true });
});

function runNativeConnect() {
async function runNativeConnect() {
if (!provider.nativeConnectQuery) {
return;
}
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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()}
/>
Expand Down
38 changes: 33 additions & 5 deletions src/features/settings/ui/ModelProviderPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -240,6 +241,10 @@ interface SetupFieldsPanelProps {
error: string;
setupMethod: ProviderSetupMethod;
setupMessage: string | null;
authenticating: boolean;
setupOutputLines: string[];
setupOutputRef: RefObject<HTMLDivElement | null>;
setupError: string;
onDraftChange: (key: string, value: string) => void;
onSaveSetup: () => void;
}
Expand All @@ -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 = (
<Button
type="button"
feedbackState={saving ? "loading" : showSavedState ? "success" : "idle"}
loadingLabel={t("providers.saving")}
feedbackState={busy ? "loading" : showSavedState ? "success" : "idle"}
loadingLabel={
authenticating ? t("providers.waitingForSignIn") : t("providers.saving")
}
successLabel={t("providers.saved")}
loadingVisual="text"
loadingDelayMs={250}
preserveWidth
size="sm"
onClick={() => onSaveSetup()}
disabled={saving || showSavedState}
disabled={busy || showSavedState}
className="h-8"
>
{t("common:actions.save")}
Expand Down Expand Up @@ -311,7 +323,7 @@ export function SetupFieldsPanel({
onChange={(event) =>
onDraftChange(field.key, event.target.value)
}
disabled={saving}
disabled={busy}
className="h-8 flex-1 text-sm"
/>
{showInlineSave ? saveButton : null}
Expand All @@ -325,13 +337,29 @@ export function SetupFieldsPanel({
) : null}
{setupMethod === "host_with_oauth_fallback"
? renderInlineCodeMessage(
t("providers.models.setup.hostWithOauthFallbackTerminal"),
t("providers.models.setup.hostWithOauthFallbackNative"),
)
: null}
{setupMethod === "cloud_credentials" && setupMessage
? renderSetupMessage(setupMessage)
: null}
<ModelRefreshMessage syncing={modelSyncing} warning={modelWarning} />
{authenticating ? (
<p
role="status"
className="flex items-center gap-2 text-sm text-muted-foreground"
>
<Spinner className="size-3.5 text-primary" />
<span>{t("providers.waitingForSignIn")}</span>
</p>
) : null}
<ProviderSetupOutput
lines={setupOutputLines.map((text, index) => ({ id: index, text }))}
scrollRef={setupOutputRef}
/>
{setupError ? (
<p className="text-sm text-destructive">{setupError}</p>
) : null}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
);
Expand Down
Loading