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
233 changes: 233 additions & 0 deletions src/browser/features/ChatInput/ProviderNotConfiguredBanner.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { GlobalWindow } from "happy-dom";
import { cleanup, fireEvent, render } from "@testing-library/react";
import {
ProviderNotConfiguredBanner,
getUnconfiguredProvider,
} from "./ProviderNotConfiguredBanner";
import type { ProvidersConfigMap } from "@/common/orpc/types";

describe("ProviderNotConfiguredBanner", () => {
beforeEach(() => {
globalThis.window = new GlobalWindow() as unknown as Window & typeof globalThis;
globalThis.document = globalThis.window.document;
});

afterEach(() => {
cleanup();
globalThis.window = undefined as unknown as Window & typeof globalThis;
globalThis.document = undefined as unknown as Document;
});

test("renders when provider is not configured", () => {
const onOpenProviders = mock(() => undefined);
const config: ProvidersConfigMap = {
anthropic: { apiKeySet: false, isEnabled: true, isConfigured: false },
};

const view = render(
<ProviderNotConfiguredBanner
activeModel="anthropic:claude-sonnet-4-5"
providersConfig={config}
onOpenProviders={onOpenProviders}
/>
);

expect(view.getByTestId("provider-not-configured-banner")).toBeTruthy();
expect(view.getByText("API key required for Anthropic.")).toBeTruthy();
expect(view.getByText("Providers")).toBeTruthy();

fireEvent.click(view.getByText("Providers"));
expect(onOpenProviders).toHaveBeenCalledTimes(1);
});

test("renders disabled message when provider is disabled", () => {
const config: ProvidersConfigMap = {
openai: { apiKeySet: true, isEnabled: false, isConfigured: true },
};

const view = render(
<ProviderNotConfiguredBanner
activeModel="openai:gpt-4o"
providersConfig={config}
onOpenProviders={() => undefined}
/>
);

expect(view.getByTestId("provider-not-configured-banner")).toBeTruthy();
expect(view.getByText("OpenAI provider is disabled.")).toBeTruthy();
});

test("does not render when provider is configured and enabled", () => {
const config: ProvidersConfigMap = {
anthropic: { apiKeySet: true, isEnabled: true, isConfigured: true },
};

const view = render(
<ProviderNotConfiguredBanner
activeModel="anthropic:claude-sonnet-4-5"
providersConfig={config}
onOpenProviders={() => undefined}
/>
);

expect(view.queryByTestId("provider-not-configured-banner")).toBeNull();
});

test("does not render when config is still loading", () => {
const view = render(
<ProviderNotConfiguredBanner
activeModel="anthropic:claude-sonnet-4-5"
providersConfig={null}
onOpenProviders={() => undefined}
/>
);

expect(view.queryByTestId("provider-not-configured-banner")).toBeNull();
});

test("does not render for unknown providers", () => {
const config: ProvidersConfigMap = {
anthropic: { apiKeySet: true, isEnabled: true, isConfigured: true },
};

const view = render(
<ProviderNotConfiguredBanner
activeModel="custom-provider:some-model"
providersConfig={config}
onOpenProviders={() => undefined}
/>
);

expect(view.queryByTestId("provider-not-configured-banner")).toBeNull();
});

test("does not render when model is routed through Mux Gateway", () => {
const config: ProvidersConfigMap = {
anthropic: { apiKeySet: false, isEnabled: true, isConfigured: false },
"mux-gateway": {
apiKeySet: false,
isEnabled: true,
isConfigured: true,
couponCodeSet: true,
gatewayModels: ["anthropic:claude-sonnet-4-5"],
},
};

const view = render(
<ProviderNotConfiguredBanner
activeModel="anthropic:claude-sonnet-4-5"
providersConfig={config}
onOpenProviders={() => undefined}
/>
);

expect(view.queryByTestId("provider-not-configured-banner")).toBeNull();
});

test("renders when model's provider is unsupported by gateway even if gateway is active", () => {
const config: ProvidersConfigMap = {
ollama: { apiKeySet: false, isEnabled: true, isConfigured: false },
"mux-gateway": {
apiKeySet: false,
isEnabled: true,
isConfigured: true,
couponCodeSet: true,
gatewayModels: [],
},
};

const view = render(
<ProviderNotConfiguredBanner
activeModel="ollama:llama3"
providersConfig={config}
onOpenProviders={() => undefined}
/>
);

expect(view.getByTestId("provider-not-configured-banner")).toBeTruthy();
});

test("renders when gateway is active but model is not enrolled", () => {
const config: ProvidersConfigMap = {
anthropic: { apiKeySet: false, isEnabled: true, isConfigured: false },
"mux-gateway": {
apiKeySet: false,
isEnabled: true,
isConfigured: true,
couponCodeSet: true,
gatewayModels: ["openai:gpt-4o"],
},
};

const view = render(
<ProviderNotConfiguredBanner
activeModel="anthropic:claude-sonnet-4-5"
providersConfig={config}
onOpenProviders={() => undefined}
/>
);

expect(view.getByTestId("provider-not-configured-banner")).toBeTruthy();
});
});

describe("getUnconfiguredProvider", () => {
test("returns null when config is null", () => {
expect(getUnconfiguredProvider("anthropic:claude-sonnet-4-5", null)).toBeNull();
});

test("returns provider when not configured", () => {
const config: ProvidersConfigMap = {
anthropic: { apiKeySet: false, isEnabled: true, isConfigured: false },
};
expect(getUnconfiguredProvider("anthropic:claude-sonnet-4-5", config)).toBe("anthropic");
});

test("returns provider when disabled", () => {
const config: ProvidersConfigMap = {
openai: { apiKeySet: true, isEnabled: false, isConfigured: true },
};
expect(getUnconfiguredProvider("openai:gpt-4o", config)).toBe("openai");
});

test("returns null when configured and enabled", () => {
const config: ProvidersConfigMap = {
anthropic: { apiKeySet: true, isEnabled: true, isConfigured: true },
};
expect(getUnconfiguredProvider("anthropic:claude-sonnet-4-5", config)).toBeNull();
});

test("returns null for model without provider prefix", () => {
const config: ProvidersConfigMap = {};
expect(getUnconfiguredProvider("some-model-no-colon", config)).toBeNull();
});

test("returns null when gateway routes the model", () => {
const config: ProvidersConfigMap = {
anthropic: { apiKeySet: false, isEnabled: true, isConfigured: false },
"mux-gateway": {
apiKeySet: false,
isEnabled: true,
isConfigured: true,
couponCodeSet: true,
gatewayModels: ["anthropic:claude-sonnet-4-5"],
},
};
expect(getUnconfiguredProvider("anthropic:claude-sonnet-4-5", config)).toBeNull();
});

test("returns provider when gateway is disabled", () => {
const config: ProvidersConfigMap = {
anthropic: { apiKeySet: false, isEnabled: true, isConfigured: false },
"mux-gateway": {
apiKeySet: false,
isEnabled: false,
isConfigured: true,
couponCodeSet: true,
gatewayModels: ["anthropic:claude-sonnet-4-5"],
},
};
expect(getUnconfiguredProvider("anthropic:claude-sonnet-4-5", config)).toBe("anthropic");
});
});
95 changes: 95 additions & 0 deletions src/browser/features/ChatInput/ProviderNotConfiguredBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { AlertTriangle } from "lucide-react";
import { Button } from "@/browser/components/Button/Button";
import { getModelProvider } from "@/common/utils/ai/models";
import {
PROVIDER_DEFINITIONS,
PROVIDER_DISPLAY_NAMES,
type ProviderName,
} from "@/common/constants/providers";
import type { ProvidersConfigMap } from "@/common/orpc/types";
import { isProviderSupported } from "@/browser/hooks/useGatewayModels";

interface Props {
activeModel: string;
providersConfig: ProvidersConfigMap | null;
onOpenProviders: () => void;
}

/**
* Returns the provider key if the active model's provider is not configured (disabled or
* missing credentials), and the model is NOT being routed through Mux Gateway.
* Returns null when no warning is needed.
*/
export function getUnconfiguredProvider(
activeModel: string,
config: ProvidersConfigMap | null
): string | null {
if (config == null) return null; // Config still loading — avoid false positives.

const provider = getModelProvider(activeModel);
if (!provider) return null;

const info = config[provider];
// Unknown providers are treated as available (same logic as useModelsFromSettings).
if (!info) return null;

if (info.isEnabled && info.isConfigured) return null;

// If the model is routed through Mux Gateway, the native provider credentials aren't needed.
const gwConfig = config["mux-gateway"];
const gatewayActive = (gwConfig?.couponCodeSet ?? false) && (gwConfig?.isEnabled ?? true);
if (gatewayActive && isProviderSupported(activeModel)) {
const gatewayModels = gwConfig?.gatewayModels ?? [];
if (gatewayModels.includes(activeModel)) return null;
}

return provider;
}

export function ProviderNotConfiguredBanner(props: Props) {
const provider = getUnconfiguredProvider(props.activeModel, props.providersConfig);
if (!provider) return null;

const displayName = PROVIDER_DISPLAY_NAMES[provider as ProviderName] ?? provider;
const info = props.providersConfig?.[provider];
const isDisabled = info != null && !info.isEnabled;
const definition = PROVIDER_DEFINITIONS[provider as ProviderName];
// Providers like bedrock/ollama don't use API keys — use generic guidance.
const usesApiKey = definition?.requiresApiKey !== false;

return (
<div
data-testid="provider-not-configured-banner"
className="bg-warning/10 border-warning/30 text-warning mt-1 mb-2 flex items-start justify-between gap-3 rounded-md border px-3 py-2 text-xs"
>
<div className="flex min-w-0 items-start gap-2">
<AlertTriangle aria-hidden="true" className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<p className="leading-relaxed">
<span className="font-medium">
{isDisabled
? `${displayName} provider is disabled.`
: usesApiKey
? `API key required for ${displayName}.`
: `${displayName} is not configured.`}
</span>{" "}
Open Settings → Providers to{" "}
{isDisabled
? "enable this provider"
: usesApiKey
? "add an API key"
: "configure this provider"}{" "}
before sending.
</p>
</div>
<Button
type="button"
variant="outline"
size="xs"
onClick={props.onOpenProviders}
className="border-warning/40 text-warning hover:bg-warning/15 hover:text-warning shrink-0"
>
Providers
</Button>
</div>
);
}
10 changes: 10 additions & 0 deletions src/browser/features/ChatInput/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ import {
getModelCapabilities,
getModelCapabilitiesResolved,
} from "@/common/utils/ai/modelCapabilities";
import { getModelProvider } from "@/common/utils/ai/models";
import { KNOWN_MODELS, MODEL_ABBREVIATION_EXAMPLES } from "@/common/constants/knownModels";
import { useTelemetry } from "@/browser/hooks/useTelemetry";
import { trackCommandUsed } from "@/common/telemetry";
Expand All @@ -123,6 +124,7 @@ import type { ChatInputProps, ChatInputAPI, QueueDispatchMode } from "./types";
import { CreationControls } from "./CreationControls";
import { SEND_DISPATCH_MODES } from "./sendDispatchModes";
import { CodexOauthWarningBanner } from "./CodexOauthWarningBanner";
import { ProviderNotConfiguredBanner } from "./ProviderNotConfiguredBanner";
import { useCreationWorkspace } from "./useCreationWorkspace";
import { useCoderWorkspace } from "@/browser/hooks/useCoderWorkspace";
import { useTutorial } from "@/browser/contexts/TutorialContext";
Expand Down Expand Up @@ -2486,6 +2488,14 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
onOpenProviders={() => open("providers", { expandProvider: "openai" })}
/>

<ProviderNotConfiguredBanner
activeModel={baseModel}
providersConfig={providersConfig}
onOpenProviders={() => {
open("providers", { expandProvider: getModelProvider(baseModel) });
}}
/>

{/* File path suggestions (@src/foo.ts) */}
<CommandSuggestions
suggestions={atMentionSuggestions}
Expand Down
Loading
Loading