mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
refactor(ui): make LLM Credentials the one place to add provider credentials
The models page offered two ways to create the same Anthropic federation credential: the Add Provider wizard and the LLM Credentials modal. Only the wizard showed the public JWKS and ran discovery, so the two drifted. The wizard and its tab are gone The credential modal now carries what only the wizard had. Its footer puts Cancel on the left and a Test Connection button beside Add or Update Credential on the right. Test Connection runs live model discovery: in Edit it tests the saved credential by name and disables while the form has unsaved changes; in Add it sends only an API key and API base inline, because every other value is server-owned and refused in a request body, and otherwise it stays disabled with a hint to add the credential first. A saved LiteLLM-signed credential shows its public JWKS with a copy button in Edit The modal stores the provider in its display casing (Anthropic) while curl-created credentials store the litellm id, so discovery by name answered 400 and the JWKS export 404 for modal-created credentials. Both routes now normalize the stored value through one helper
This commit is contained in:
parent
669f0283e3
commit
3a46fa22ad
21 changed files with 554 additions and 1891 deletions
|
|
@ -20,7 +20,18 @@ from litellm.types.router import (
|
|||
server_owned_wif_fields_named,
|
||||
server_owned_wif_fields_present,
|
||||
)
|
||||
from litellm.types.utils import CredentialItem
|
||||
from litellm.types.utils import CredentialItem, LlmProviders
|
||||
|
||||
_LITELLM_PROVIDER_IDS: Final = frozenset(provider.value for provider in LlmProviders)
|
||||
|
||||
|
||||
def stored_credential_provider(credential_provider: object) -> str | None:
|
||||
"""The dashboard stores its display casing (``Anthropic``) on credentials it creates, so the
|
||||
provider a credential names is the lowercased value when that is a litellm provider id."""
|
||||
if not isinstance(credential_provider, str):
|
||||
return None
|
||||
lowered: Final = credential_provider.lower()
|
||||
return lowered if lowered in _LITELLM_PROVIDER_IDS else None
|
||||
|
||||
|
||||
def decrypted_or_stored(key: str, value: str) -> str:
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from litellm.proxy.common_utils.credential_hydration import (
|
|||
hydrate_named_credential,
|
||||
hydrate_named_credential_authoritative,
|
||||
named_credential_wif_fields,
|
||||
stored_credential_provider,
|
||||
)
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper
|
||||
from litellm.proxy.utils import handle_exception_on_proxy, jsonify_object
|
||||
|
|
@ -301,7 +302,12 @@ async def get_credential_internal_issuer_jwks(
|
|||
|
||||
try:
|
||||
credential: Final = await hydrate_named_credential_authoritative(credential_name, prisma_client)
|
||||
if credential is None or credential.credential_info.get("custom_llm_provider") != "anthropic":
|
||||
credential_provider: Final = (
|
||||
None
|
||||
if credential is None
|
||||
else stored_credential_provider(credential.credential_info.get("custom_llm_provider"))
|
||||
)
|
||||
if credential is None or credential_provider != "anthropic":
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={ # mutable-ok: starlette json.dumps()s HTTPException.detail raw, needs a real dict
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ from litellm.proxy.common_utils.credential_hydration import (
|
|||
effective_server_owned_wif_fields,
|
||||
hydrate_named_credential,
|
||||
hydrate_named_credential_authoritative,
|
||||
stored_credential_provider,
|
||||
)
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
|
||||
decrypt_value_helper,
|
||||
|
|
@ -1953,7 +1954,7 @@ async def _resolve_discovery_litellm_params(
|
|||
"error": f"Credential {data.litellm_credential_name!r} not found."
|
||||
},
|
||||
)
|
||||
credential_provider: Final = credential.credential_info.get("custom_llm_provider")
|
||||
credential_provider: Final = stored_credential_provider(credential.credential_info.get("custom_llm_provider"))
|
||||
if credential_provider is not None and credential_provider != data.custom_llm_provider:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
|
|
|
|||
|
|
@ -361,6 +361,30 @@ class TestCredentialJwksExport:
|
|||
assert "JWKS_TEST_SIGNING_KEY" not in response.text
|
||||
assert "PRIVATE KEY" not in response.text
|
||||
|
||||
def test_jwks_export_accepts_the_dashboard_provider_casing(self, restore_credential_list, monkeypatch):
|
||||
monkeypatch.setenv("JWKS_TEST_SIGNING_KEY", _generate_es256_pem())
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"credential_list",
|
||||
[
|
||||
CredentialItem(
|
||||
credential_name="anthropic-from-modal",
|
||||
credential_values={
|
||||
"anthropic_identity_source": "internal_issuer",
|
||||
"anthropic_issuer_url": "https://issuer.example.com",
|
||||
"anthropic_issuer_subject": "my-workload",
|
||||
"anthropic_issuer_signing_key_ref": "os.environ/JWKS_TEST_SIGNING_KEY",
|
||||
},
|
||||
credential_info={"custom_llm_provider": "Anthropic"},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
response = _get_jwks("anthropic-from-modal")
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["keys"][0]["kty"] == "EC"
|
||||
|
||||
def test_jwks_export_404s_for_a_non_anthropic_credential(self, restore_credential_list, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
|
|
|
|||
|
|
@ -5437,6 +5437,47 @@ class TestDiscoverProviderModels:
|
|||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_cased_credential_provider_is_accepted(self, monkeypatch):
|
||||
"""The LLM Credentials modal stores the provider under its display casing, so a saved
|
||||
``Anthropic`` credential must still resolve for an ``anthropic`` discovery request."""
|
||||
import litellm
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
discover_provider_models,
|
||||
)
|
||||
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
|
||||
ProviderModelDiscoveryRequest,
|
||||
)
|
||||
from litellm.types.utils import CredentialItem
|
||||
|
||||
monkeypatch.setattr(
|
||||
litellm,
|
||||
"credential_list",
|
||||
[
|
||||
CredentialItem(
|
||||
credential_name="anthropic-modal",
|
||||
credential_values={"api_key": "sk-ant-x"},
|
||||
credential_info={"custom_llm_provider": "Anthropic"},
|
||||
)
|
||||
],
|
||||
)
|
||||
with (
|
||||
patch( # test-quality-ok: the proxy wiring under test is what this patches
|
||||
"litellm.proxy.proxy_server.prisma_client", _prisma_without_stored_credentials()
|
||||
),
|
||||
patch( # test-quality-ok: the proxy wiring under test is what this patches
|
||||
"litellm.llms.anthropic.common_utils.AnthropicModelInfo.discover_models",
|
||||
return_value=["anthropic/claude-disc"],
|
||||
),
|
||||
):
|
||||
result = await discover_provider_models(
|
||||
data=ProviderModelDiscoveryRequest(
|
||||
custom_llm_provider="anthropic", litellm_credential_name="anthropic-modal"
|
||||
),
|
||||
user_api_key_dict=self._admin(),
|
||||
)
|
||||
assert result.models == ["anthropic/claude-disc"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_credential_name_is_404(self):
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
|
|
|
|||
|
|
@ -72,22 +72,24 @@ describe("ModelsAndEndpointsPage", () => {
|
|||
};
|
||||
});
|
||||
|
||||
// Provider credentials, Anthropic WIF included, have exactly one home: the LLM Credentials tab.
|
||||
it("renders the admin tab bar and the All Models panel by default", () => {
|
||||
renderPage();
|
||||
expect(screen.getByRole("tab", { name: "All Models" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "LLM Credentials" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Health Status" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "Add Provider" })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual([
|
||||
"All Models",
|
||||
"Add Model",
|
||||
"Auto-Routers Beta",
|
||||
"LLM Credentials",
|
||||
"Pass-Through Endpoints",
|
||||
"Health Status",
|
||||
"Model Retry Settings",
|
||||
"Model Group Alias",
|
||||
"Model Access Group Budgets Beta",
|
||||
"Price Data Reload",
|
||||
]);
|
||||
expect(screen.getByTestId("panel-all-models")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the write-only Add Provider tab from a view-only admin", () => {
|
||||
mockUseAuthorized.mockReturnValue(VIEW_ONLY_ADMIN);
|
||||
renderPage();
|
||||
expect(screen.getByRole("tab", { name: "All Models" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: "Add Provider" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches tabs in-memory, mounting only the active panel", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
|
|
@ -114,7 +116,6 @@ describe("ModelsAndEndpointsPage", () => {
|
|||
renderPage();
|
||||
expect(screen.queryByRole("tab", { name: "LLM Credentials" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: "Health Status" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: "Add Provider" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// POST /model/new 403s a proxy_admin_viewer, so the form's tab must not render for one.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import { useModelDashboardData } from "@/app/(dashboard)/models-and-endpoints/us
|
|||
import AllModelsPanel from "@/app/(dashboard)/models-and-endpoints/panels/AllModelsPanel";
|
||||
import AutoRoutersTabPanel from "@/app/(dashboard)/models-and-endpoints/panels/AutoRoutersTabPanel";
|
||||
import AddModelPanel from "@/app/(dashboard)/models-and-endpoints/panels/AddModelPanel";
|
||||
import AddProviderPanel from "@/app/(dashboard)/models-and-endpoints/panels/add-provider/AddProviderPanel";
|
||||
import LlmCredentialsPanel from "@/app/(dashboard)/models-and-endpoints/panels/LlmCredentialsPanel";
|
||||
import PassThroughPanel from "@/app/(dashboard)/models-and-endpoints/panels/PassThroughPanel";
|
||||
import HealthStatusPanel from "@/app/(dashboard)/models-and-endpoints/panels/HealthStatusPanel";
|
||||
|
|
@ -30,7 +29,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||
|
||||
type ModelTabSlug =
|
||||
| "add"
|
||||
| "add-provider"
|
||||
| "auto-routers"
|
||||
| "llm-credentials"
|
||||
| "pass-through"
|
||||
|
|
@ -44,7 +42,6 @@ const BASE_TAB_KEY = "all-models";
|
|||
|
||||
const TAB_LABELS: Record<ModelTabSlug, string> = {
|
||||
add: "Add Model",
|
||||
"add-provider": "Add Provider",
|
||||
"auto-routers": "Auto-Routers",
|
||||
"llm-credentials": "LLM Credentials",
|
||||
"pass-through": "Pass-Through Endpoints",
|
||||
|
|
@ -63,8 +60,6 @@ const renderPanel = (key: string) => {
|
|||
return <AutoRoutersTabPanel />;
|
||||
case "add":
|
||||
return <AddModelPanel />;
|
||||
case "add-provider":
|
||||
return <AddProviderPanel />;
|
||||
case "llm-credentials":
|
||||
return <LlmCredentialsPanel />;
|
||||
case "pass-through":
|
||||
|
|
@ -111,9 +106,6 @@ export default function ModelsAndEndpointsPage() {
|
|||
"",
|
||||
...(canCreate ? (["add"] as const) : []),
|
||||
...(isAdmin || canCreate ? (["auto-routers"] as const) : []),
|
||||
// effectiveSessionRole reports proxy_admin_viewer as "Admin", so isAdmin alone would show
|
||||
// a viewer this write-only wizard; only the raw-role isViewOnly separates them.
|
||||
...(isAdmin && !isViewOnly ? (["add-provider"] as const) : []),
|
||||
...(isAdmin
|
||||
? ([
|
||||
"llm-credentials",
|
||||
|
|
@ -126,7 +118,7 @@ export default function ModelsAndEndpointsPage() {
|
|||
] as const)
|
||||
: []),
|
||||
],
|
||||
[canCreate, isAdmin, isViewOnly],
|
||||
[canCreate, isAdmin],
|
||||
);
|
||||
|
||||
const allModelsLabel = isAdmin ? "All Models" : "Your Models";
|
||||
|
|
|
|||
|
|
@ -1,621 +0,0 @@
|
|||
import {
|
||||
chooseSelectOption,
|
||||
fireEvent,
|
||||
renderWithProviders,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "../../../../../../tests/test-utils";
|
||||
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AddProviderPanel from "./AddProviderPanel";
|
||||
|
||||
const discoverProviderModelsCall = vi.fn();
|
||||
const credentialCreateCall = vi.fn();
|
||||
const credentialUpdateCall = vi.fn();
|
||||
const createProviderModelCall = vi.fn();
|
||||
const listAllModelsCall = vi.fn();
|
||||
const getCallbacksCall = vi.fn();
|
||||
const setCallbacksCall = vi.fn();
|
||||
const getCredentialJwksCall = vi.fn();
|
||||
const mockAuthorized = vi.fn();
|
||||
|
||||
vi.mock("@/components/networking", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/components/networking")>();
|
||||
return {
|
||||
...actual,
|
||||
discoverProviderModelsCall: (...args: unknown[]) => discoverProviderModelsCall(...args),
|
||||
credentialCreateCall: (...args: unknown[]) => credentialCreateCall(...args),
|
||||
credentialUpdateCall: (...args: unknown[]) => credentialUpdateCall(...args),
|
||||
createProviderModelCall: (...args: unknown[]) => createProviderModelCall(...args),
|
||||
listAllModelsCall: (...args: unknown[]) => listAllModelsCall(...args),
|
||||
getCallbacksCall: (...args: unknown[]) => getCallbacksCall(...args),
|
||||
setCallbacksCall: (...args: unknown[]) => setCallbacksCall(...args),
|
||||
getCredentialJwksCall: (...args: unknown[]) => getCredentialJwksCall(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => mockAuthorized() }));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/credentials/useCredentials", () => ({
|
||||
useCredentials: () => ({ data: { credentials: [] } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/providers/useProviderFields", () => ({
|
||||
useProviderFields: () => ({
|
||||
data: [
|
||||
{
|
||||
provider: "Anthropic",
|
||||
provider_display_name: "Anthropic",
|
||||
litellm_provider: "anthropic",
|
||||
default_model_placeholder: "claude-3-opus",
|
||||
credential_fields: [
|
||||
{ key: "api_base", label: "Upstream API Base", field_type: "text" },
|
||||
{ key: "api_key", label: "API Key", field_type: "password" },
|
||||
],
|
||||
credential_variants: {
|
||||
selector_label: "Authentication method",
|
||||
default_variant: "api_key",
|
||||
field_definitions: [
|
||||
{ key: "api_base", label: "Upstream API Base", field_type: "text" },
|
||||
{ key: "api_key", label: "API Key", field_type: "password" },
|
||||
{
|
||||
key: "anthropic_federation_rule_id",
|
||||
label: "Federation Rule ID",
|
||||
field_type: "text",
|
||||
required: true,
|
||||
tooltip: "Can be left blank and filled in once the JWKS is registered.",
|
||||
},
|
||||
{ key: "anthropic_organization_id", label: "Organization ID", field_type: "text", required: true },
|
||||
{ key: "anthropic_service_account_id", label: "Service Account ID", field_type: "text", required: false },
|
||||
{ key: "anthropic_workspace_id", label: "Workspace ID", field_type: "text", required: false },
|
||||
{ key: "anthropic_issuer_url", label: "Issuer URL", field_type: "text", required: true },
|
||||
{ key: "anthropic_issuer_subject", label: "Issuer Subject", field_type: "text", required: true },
|
||||
{
|
||||
key: "anthropic_issuer_signing_key_ref",
|
||||
label: "Signing Key Reference",
|
||||
field_type: "text",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
variants: [
|
||||
{ id: "api_key", label: "API Key", field_keys: ["api_base", "api_key"], fixed_values: {} },
|
||||
{
|
||||
id: "wif_internal_issuer",
|
||||
label: "Workload Identity Federation (LiteLLM-signed)",
|
||||
field_keys: [
|
||||
"anthropic_issuer_url",
|
||||
"anthropic_issuer_subject",
|
||||
"anthropic_issuer_signing_key_ref",
|
||||
"anthropic_organization_id",
|
||||
"anthropic_federation_rule_id",
|
||||
"anthropic_service_account_id",
|
||||
"anthropic_workspace_id",
|
||||
],
|
||||
optional_field_keys: ["anthropic_organization_id", "anthropic_federation_rule_id"],
|
||||
fixed_values: { anthropic_identity_source: "internal_issuer" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: "OpenAI",
|
||||
provider_display_name: "OpenAI",
|
||||
litellm_provider: "openai",
|
||||
default_model_placeholder: "gpt-4o",
|
||||
credential_fields: [{ key: "api_key", label: "API Key", field_type: "password" }],
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}),
|
||||
}));
|
||||
|
||||
const PROXY_ADMIN = { accessToken: "test-access-token" };
|
||||
|
||||
const setup = async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
renderWithProviders(<AddProviderPanel />);
|
||||
await screen.findByLabelText("Provider");
|
||||
return { user };
|
||||
};
|
||||
|
||||
const chooseProvider = async (user: ReturnType<typeof userEvent.setup>, name: string) => {
|
||||
await user.click(screen.getByLabelText("Provider"));
|
||||
await user.click(await screen.findByText(name));
|
||||
};
|
||||
|
||||
const rowFor = (upstreamId: string) =>
|
||||
within(screen.getByRole("row", { name: (accessibleName) => accessibleName.startsWith(`${upstreamId} `) }));
|
||||
|
||||
const INTERNAL_ISSUER_CREATE_VALUES = {
|
||||
anthropic_issuer_url: "https://proxy.example.com",
|
||||
anthropic_issuer_subject: "litellm-proxy",
|
||||
anthropic_issuer_signing_key_ref: "os.environ/SIGNING_KEY",
|
||||
anthropic_identity_source: "internal_issuer",
|
||||
};
|
||||
|
||||
const saveInternalIssuerCredential = async (user: ReturnType<typeof userEvent.setup>, name: string) => {
|
||||
await chooseProvider(user, "Anthropic");
|
||||
await user.type(screen.getByLabelText("Credential name"), name);
|
||||
await user.click(screen.getByRole("button", { name: /Next/ }));
|
||||
await chooseSelectOption(
|
||||
user,
|
||||
await screen.findByRole("combobox", { name: "Authentication method" }),
|
||||
"Workload Identity Federation (LiteLLM-signed)",
|
||||
);
|
||||
fireEvent.change(await screen.findByLabelText("Issuer URL"), { target: { value: "https://proxy.example.com" } });
|
||||
fireEvent.change(screen.getByLabelText("Issuer Subject"), { target: { value: "litellm-proxy" } });
|
||||
fireEvent.change(screen.getByLabelText("Signing Key Reference"), { target: { value: "os.environ/SIGNING_KEY" } });
|
||||
await user.click(screen.getByRole("button", { name: "Save credential" }));
|
||||
await screen.findByText("Register this JWKS with Anthropic");
|
||||
};
|
||||
|
||||
const fillFederationIds = (ids: Record<string, string>) => {
|
||||
for (const [label, value] of Object.entries(ids)) {
|
||||
fireEvent.change(screen.getByLabelText(label), { target: { value } });
|
||||
}
|
||||
};
|
||||
|
||||
describe("AddProviderPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAuthorized.mockReturnValue(PROXY_ADMIN);
|
||||
credentialCreateCall.mockResolvedValue({});
|
||||
credentialUpdateCall.mockResolvedValue({});
|
||||
listAllModelsCall.mockResolvedValue({ data: [] });
|
||||
createProviderModelCall.mockResolvedValue({ model_id: "new-id" });
|
||||
getCallbacksCall.mockResolvedValue({ router_settings: {} });
|
||||
setCallbacksCall.mockResolvedValue({});
|
||||
getCredentialJwksCall.mockResolvedValue({ keys: [{ kid: "kid-1", kty: "RSA", n: "n", e: "AQAB" }] });
|
||||
});
|
||||
|
||||
it("walks provider -> credential -> discover -> review -> create, with blocked and aliases wired correctly", async () => {
|
||||
discoverProviderModelsCall.mockResolvedValue({ models: ["claude-3-opus", "claude-3-haiku"] });
|
||||
const { user } = await setup();
|
||||
|
||||
await chooseProvider(user, "Anthropic");
|
||||
await user.type(screen.getByLabelText("Credential name"), "anthropic-prod");
|
||||
await user.click(screen.getByRole("button", { name: /Next/ }));
|
||||
|
||||
await user.type(await screen.findByLabelText("API Key"), "sk-ant-test");
|
||||
await user.click(screen.getByRole("button", { name: "Save credential" }));
|
||||
|
||||
expect(await screen.findByText("claude-3-opus")).toBeInTheDocument();
|
||||
expect(credentialCreateCall).toHaveBeenCalledWith("test-access-token", {
|
||||
credential_name: "anthropic-prod",
|
||||
credential_values: { api_key: "sk-ant-test" },
|
||||
credential_info: { custom_llm_provider: "anthropic" },
|
||||
});
|
||||
expect(discoverProviderModelsCall).toHaveBeenCalledWith("test-access-token", {
|
||||
custom_llm_provider: "anthropic",
|
||||
litellm_credential_name: "anthropic-prod",
|
||||
});
|
||||
|
||||
// Disable the first discovered row.
|
||||
await user.click(rowFor("claude-3-opus").getByRole("switch"));
|
||||
|
||||
// Add an alternate name to the second discovered row.
|
||||
const haikuAltNames = rowFor("claude-3-haiku").getByRole("combobox");
|
||||
await user.type(haikuAltNames, "gpt-4o-mini");
|
||||
await user.click(await screen.findByText('Create "gpt-4o-mini"'));
|
||||
|
||||
// Add a manual (hidden) model.
|
||||
await user.type(screen.getByPlaceholderText("upstream model id"), "claude-hidden");
|
||||
await user.click(screen.getByRole("button", { name: "Add" }));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Create 3 models" }));
|
||||
|
||||
await waitFor(() => expect(createProviderModelCall).toHaveBeenCalledTimes(3));
|
||||
const disabledOpusCreation = {
|
||||
model_name: "claude-3-opus",
|
||||
litellm_params: { model: "anthropic/claude-3-opus", litellm_credential_name: "anthropic-prod" },
|
||||
model_info: {},
|
||||
blocked: true,
|
||||
};
|
||||
const renamedHaikuCreation = {
|
||||
model_name: "claude-3-haiku",
|
||||
litellm_params: { model: "anthropic/claude-3-haiku", litellm_credential_name: "anthropic-prod" },
|
||||
model_info: {},
|
||||
blocked: false,
|
||||
};
|
||||
const manualHiddenCreation = {
|
||||
model_name: "claude-hidden",
|
||||
litellm_params: { model: "anthropic/claude-hidden", litellm_credential_name: "anthropic-prod" },
|
||||
model_info: {},
|
||||
blocked: false,
|
||||
};
|
||||
expect(createProviderModelCall).toHaveBeenCalledWith("test-access-token", disabledOpusCreation);
|
||||
expect(createProviderModelCall).toHaveBeenCalledWith("test-access-token", renamedHaikuCreation);
|
||||
expect(createProviderModelCall).toHaveBeenCalledWith("test-access-token", manualHiddenCreation);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setCallbacksCall).toHaveBeenCalledWith("test-access-token", {
|
||||
router_settings: { model_group_alias: { "gpt-4o-mini": "claude-3-haiku" } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/claude-3-opus: created/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/claude-3-haiku: created/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/claude-hidden: created/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a sanitized discovery error with a working retry", async () => {
|
||||
discoverProviderModelsCall.mockRejectedValueOnce(new Error("upstream auth failed"));
|
||||
discoverProviderModelsCall.mockResolvedValueOnce({ models: ["claude-3-opus"] });
|
||||
const { user } = await setup();
|
||||
|
||||
await chooseProvider(user, "Anthropic");
|
||||
await user.type(screen.getByLabelText("Credential name"), "anthropic-prod");
|
||||
await user.click(screen.getByRole("button", { name: /Next/ }));
|
||||
await user.type(await screen.findByLabelText("API Key"), "sk-ant-test");
|
||||
await user.click(screen.getByRole("button", { name: "Save credential" }));
|
||||
|
||||
expect(await screen.findByText("Discovery failed")).toBeInTheDocument();
|
||||
expect(screen.getByText("upstream auth failed")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Retry" }));
|
||||
|
||||
expect(await screen.findByText("claude-3-opus")).toBeInTheDocument();
|
||||
expect(discoverProviderModelsCall).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("skips a row already created under this credential on a re-run", async () => {
|
||||
discoverProviderModelsCall.mockResolvedValue({ models: ["claude-3-opus"] });
|
||||
listAllModelsCall.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
model_name: "claude-3-opus",
|
||||
litellm_params: { model: "anthropic/claude-3-opus", litellm_credential_name: "anthropic-prod" },
|
||||
model_info: { id: "existing-id" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const { user } = await setup();
|
||||
|
||||
await chooseProvider(user, "Anthropic");
|
||||
await user.type(screen.getByLabelText("Credential name"), "anthropic-prod");
|
||||
await user.click(screen.getByRole("button", { name: /Next/ }));
|
||||
await user.type(await screen.findByLabelText("API Key"), "sk-ant-test");
|
||||
await user.click(screen.getByRole("button", { name: "Save credential" }));
|
||||
|
||||
await screen.findByText("claude-3-opus");
|
||||
await user.click(screen.getByRole("button", { name: "Create 1 model" }));
|
||||
|
||||
expect(await screen.findByText(/claude-3-opus: skipped/)).toBeInTheDocument();
|
||||
expect(createProviderModelCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not persist an alias for a row whose model creation failed", async () => {
|
||||
discoverProviderModelsCall.mockResolvedValue({ models: ["claude-3-opus", "claude-3-haiku"] });
|
||||
createProviderModelCall.mockImplementation(async (_token: string, payload: { model_name: string }) => {
|
||||
if (payload.model_name === "claude-3-haiku") {
|
||||
throw new Error("upstream rejected");
|
||||
}
|
||||
return { model_id: "new-id" };
|
||||
});
|
||||
const { user } = await setup();
|
||||
|
||||
await chooseProvider(user, "Anthropic");
|
||||
await user.type(screen.getByLabelText("Credential name"), "anthropic-prod");
|
||||
await user.click(screen.getByRole("button", { name: /Next/ }));
|
||||
await user.type(await screen.findByLabelText("API Key"), "sk-ant-test");
|
||||
await user.click(screen.getByRole("button", { name: "Save credential" }));
|
||||
|
||||
expect(await screen.findByText("claude-3-opus")).toBeInTheDocument();
|
||||
|
||||
const opusAltNames = rowFor("claude-3-opus").getByRole("combobox");
|
||||
await user.type(opusAltNames, "gpt-4o");
|
||||
await user.click(await screen.findByText('Create "gpt-4o"'));
|
||||
|
||||
const haikuAltNames = rowFor("claude-3-haiku").getByRole("combobox");
|
||||
await user.type(haikuAltNames, "gpt-4o-mini");
|
||||
await user.click(await screen.findByText('Create "gpt-4o-mini"'));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Create 2 models" }));
|
||||
|
||||
expect(await screen.findByText(/claude-3-opus: created/)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/claude-3-haiku: failed/)).toBeInTheDocument();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(setCallbacksCall).toHaveBeenCalledWith("test-access-token", {
|
||||
router_settings: { model_group_alias: { "gpt-4o": "claude-3-opus" } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("saves a LiteLLM-signed credential before any Anthropic id exists, then collects them all on the JWKS step", async () => {
|
||||
discoverProviderModelsCall.mockResolvedValue({ models: ["claude-3-opus"] });
|
||||
const { user } = await setup();
|
||||
|
||||
await saveInternalIssuerCredential(user, "anthropic-wif");
|
||||
|
||||
// Every id comes off the Anthropic Console only once the JWKS below is registered, and the
|
||||
// JWKS only exists once the credential is saved, so saving must not demand any of them first.
|
||||
expect(credentialCreateCall).toHaveBeenCalledWith("test-access-token", {
|
||||
credential_name: "anthropic-wif",
|
||||
credential_values: INTERNAL_ISSUER_CREATE_VALUES,
|
||||
credential_info: { custom_llm_provider: "anthropic" },
|
||||
});
|
||||
expect(getCredentialJwksCall).toHaveBeenCalledWith("test-access-token", "anthropic-wif");
|
||||
|
||||
expect(screen.getByText("Still needed before discovery: Organization ID, Federation Rule ID.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Next/ })).toBeDisabled();
|
||||
|
||||
fillFederationIds({ "Organization ID": "org-1" });
|
||||
expect(screen.getByText("Still needed before discovery: Federation Rule ID.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /Next/ })).toBeDisabled();
|
||||
|
||||
fillFederationIds({ "Federation Rule ID": " fdrl_abc ", "Service Account ID": "svac_1" });
|
||||
expect(screen.queryByText(/Still needed before discovery/)).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: /Next/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(credentialUpdateCall).toHaveBeenCalledWith("test-access-token", "anthropic-wif", {
|
||||
credential_name: "anthropic-wif",
|
||||
credential_values: {
|
||||
anthropic_organization_id: "org-1",
|
||||
anthropic_federation_rule_id: "fdrl_abc",
|
||||
anthropic_service_account_id: "svac_1",
|
||||
},
|
||||
credential_info: { custom_llm_provider: "anthropic" },
|
||||
}),
|
||||
);
|
||||
expect(credentialUpdateCall).toHaveBeenCalledTimes(1);
|
||||
expect(await screen.findByText("claude-3-opus")).toBeInTheDocument();
|
||||
expect(discoverProviderModelsCall).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps the ids from the JWKS step across Back, a credential re-save and the return trip", async () => {
|
||||
discoverProviderModelsCall.mockRejectedValueOnce(new Error("Authentication failed"));
|
||||
discoverProviderModelsCall.mockResolvedValueOnce({ models: ["claude-3-opus"] });
|
||||
const { user } = await setup();
|
||||
|
||||
await saveInternalIssuerCredential(user, "anthropic-wif");
|
||||
fillFederationIds({ "Organization ID": "org-1", "Federation Rule ID": "fdrl_abc", "Service Account ID": "svac_1" });
|
||||
await user.click(screen.getByRole("button", { name: /Next/ }));
|
||||
expect(await screen.findByText("Discovery failed")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Back/ }));
|
||||
expect(await screen.findByText("Register this JWKS with Anthropic")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Federation Rule ID")).toHaveValue("fdrl_abc");
|
||||
expect(screen.getByLabelText("Service Account ID")).toHaveValue("svac_1");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Back/ }));
|
||||
expect(await screen.findByLabelText("Organization ID")).toHaveValue("org-1");
|
||||
expect(screen.getByLabelText("Federation Rule ID")).toHaveValue("fdrl_abc");
|
||||
expect(screen.getByLabelText("Service Account ID")).toHaveValue("svac_1");
|
||||
expect(screen.getByLabelText("Workspace ID")).toHaveValue("");
|
||||
|
||||
credentialUpdateCall.mockClear();
|
||||
await user.click(screen.getByRole("button", { name: "Save changes" }));
|
||||
expect(await screen.findByText("Register this JWKS with Anthropic")).toBeInTheDocument();
|
||||
expect(credentialUpdateCall).toHaveBeenCalledWith("test-access-token", "anthropic-wif", {
|
||||
credential_name: "anthropic-wif",
|
||||
credential_values: {
|
||||
...INTERNAL_ISSUER_CREATE_VALUES,
|
||||
anthropic_organization_id: "org-1",
|
||||
anthropic_federation_rule_id: "fdrl_abc",
|
||||
anthropic_service_account_id: "svac_1",
|
||||
},
|
||||
credential_info: { custom_llm_provider: "anthropic" },
|
||||
});
|
||||
expect(screen.getByLabelText("Federation Rule ID")).toHaveValue("fdrl_abc");
|
||||
expect(screen.queryByText(/Still needed before discovery/)).not.toBeInTheDocument();
|
||||
|
||||
credentialUpdateCall.mockClear();
|
||||
await user.click(screen.getByRole("button", { name: /Next/ }));
|
||||
expect(await screen.findByText("claude-3-opus")).toBeInTheDocument();
|
||||
expect(credentialUpdateCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("lets a failed discovery be fixed by adding the Workspace ID on the JWKS step, PATCHing only that id", async () => {
|
||||
discoverProviderModelsCall.mockRejectedValueOnce(new Error("Model discovery failed: HTTP 401"));
|
||||
discoverProviderModelsCall.mockResolvedValueOnce({ models: ["claude-3-opus"] });
|
||||
const { user } = await setup();
|
||||
|
||||
await saveInternalIssuerCredential(user, "anthropic-wif");
|
||||
fillFederationIds({ "Organization ID": "org-1", "Federation Rule ID": "fdrl_abc" });
|
||||
await user.click(screen.getByRole("button", { name: /Next/ }));
|
||||
expect(await screen.findByText("Model discovery failed: HTTP 401")).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Back/ }));
|
||||
fillFederationIds({ "Workspace ID": "wrkspc_1" });
|
||||
credentialUpdateCall.mockClear();
|
||||
await user.click(screen.getByRole("button", { name: /Next/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(credentialUpdateCall).toHaveBeenCalledWith("test-access-token", "anthropic-wif", {
|
||||
credential_name: "anthropic-wif",
|
||||
credential_values: { anthropic_workspace_id: "wrkspc_1" },
|
||||
credential_info: { custom_llm_provider: "anthropic" },
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByText("claude-3-opus")).toBeInTheDocument();
|
||||
expect(discoverProviderModelsCall).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("deletes an id cleared on the JWKS step instead of leaving the saved value in place", async () => {
|
||||
discoverProviderModelsCall.mockResolvedValue({ models: ["claude-3-opus"] });
|
||||
const { user } = await setup();
|
||||
|
||||
await chooseProvider(user, "Anthropic");
|
||||
await user.type(screen.getByLabelText("Credential name"), "anthropic-wif");
|
||||
await user.click(screen.getByRole("button", { name: /Next/ }));
|
||||
await chooseSelectOption(
|
||||
user,
|
||||
await screen.findByRole("combobox", { name: "Authentication method" }),
|
||||
"Workload Identity Federation (LiteLLM-signed)",
|
||||
);
|
||||
fireEvent.change(await screen.findByLabelText("Issuer URL"), { target: { value: "https://proxy.example.com" } });
|
||||
fireEvent.change(screen.getByLabelText("Issuer Subject"), { target: { value: "litellm-proxy" } });
|
||||
fireEvent.change(screen.getByLabelText("Signing Key Reference"), { target: { value: "os.environ/SIGNING_KEY" } });
|
||||
fireEvent.change(screen.getByLabelText("Organization ID"), { target: { value: "org-1" } });
|
||||
fireEvent.change(screen.getByLabelText("Federation Rule ID"), { target: { value: "fdrl_abc" } });
|
||||
fireEvent.change(screen.getByLabelText("Workspace ID"), { target: { value: "wrkspc_stale" } });
|
||||
await user.click(screen.getByRole("button", { name: "Save credential" }));
|
||||
await screen.findByText("Register this JWKS with Anthropic");
|
||||
expect(screen.getByLabelText("Workspace ID")).toHaveValue("wrkspc_stale");
|
||||
|
||||
fillFederationIds({ "Workspace ID": "" });
|
||||
await user.click(screen.getByRole("button", { name: /Next/ }));
|
||||
|
||||
const workspaceDeletion = {
|
||||
credential_name: "anthropic-wif",
|
||||
credential_values: {},
|
||||
credential_info: { custom_llm_provider: "anthropic" },
|
||||
credential_values_to_delete: ["anthropic_workspace_id"],
|
||||
};
|
||||
await waitFor(() =>
|
||||
expect(credentialUpdateCall).toHaveBeenCalledWith("test-access-token", "anthropic-wif", workspaceDeletion),
|
||||
);
|
||||
expect(await screen.findByText("claude-3-opus")).toBeInTheDocument();
|
||||
});
|
||||
it("refuses to create when the deployment lookup fails, rather than duplicating saved rows", async () => {
|
||||
discoverProviderModelsCall.mockResolvedValue({ models: ["claude-3-opus"] });
|
||||
listAllModelsCall.mockRejectedValue(new Error("proxy unreachable"));
|
||||
const { user } = await setup();
|
||||
|
||||
await chooseProvider(user, "Anthropic");
|
||||
await user.type(screen.getByLabelText("Credential name"), "anthropic-prod");
|
||||
await user.click(screen.getByRole("button", { name: /Next/ }));
|
||||
await user.type(await screen.findByLabelText("API Key"), "sk-ant-test");
|
||||
await user.click(screen.getByRole("button", { name: "Save credential" }));
|
||||
|
||||
await screen.findByText("claude-3-opus");
|
||||
await user.click(screen.getByRole("button", { name: "Create 1 model" }));
|
||||
|
||||
expect(await screen.findByText(/could duplicate ones already saved/i)).toBeInTheDocument();
|
||||
expect(createProviderModelCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates rather than PATCHes after the credential name changes following a save", async () => {
|
||||
discoverProviderModelsCall.mockResolvedValue({ models: ["claude-3-opus"] });
|
||||
const { user } = await setup();
|
||||
|
||||
await chooseProvider(user, "Anthropic");
|
||||
await user.type(screen.getByLabelText("Credential name"), "anthropic-first");
|
||||
await user.click(screen.getByRole("button", { name: /Next/ }));
|
||||
|
||||
await chooseSelectOption(
|
||||
user,
|
||||
await screen.findByRole("combobox", { name: "Authentication method" }),
|
||||
"Workload Identity Federation (LiteLLM-signed)",
|
||||
);
|
||||
fireEvent.change(await screen.findByLabelText("Issuer URL"), { target: { value: "https://proxy.example.com" } });
|
||||
fireEvent.change(screen.getByLabelText("Issuer Subject"), { target: { value: "litellm-proxy" } });
|
||||
fireEvent.change(screen.getByLabelText("Signing Key Reference"), { target: { value: "os.environ/SIGNING_KEY" } });
|
||||
await user.click(screen.getByRole("button", { name: "Save credential" }));
|
||||
|
||||
// The JWKS step is the one place the wizard pauses after a save, so it is the route back to
|
||||
// the name field. Renaming there must create the new credential, never PATCH the old name.
|
||||
await screen.findByText("Register this JWKS with Anthropic");
|
||||
await user.click(screen.getByRole("button", { name: /Back/ }));
|
||||
await user.click(await screen.findByRole("button", { name: /Back/ }));
|
||||
|
||||
const nameInput = await screen.findByLabelText("Credential name");
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, "anthropic-second");
|
||||
await user.click(screen.getByRole("button", { name: /Next/ }));
|
||||
|
||||
credentialCreateCall.mockClear();
|
||||
credentialUpdateCall.mockClear();
|
||||
await user.click(await screen.findByRole("button", { name: /Save credential/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(credentialCreateCall).toHaveBeenCalledWith(
|
||||
"test-access-token",
|
||||
expect.objectContaining({ credential_name: "anthropic-second" }),
|
||||
),
|
||||
);
|
||||
expect(credentialUpdateCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates rather than PATCHes when the provider changes under the same credential name", async () => {
|
||||
discoverProviderModelsCall.mockResolvedValue({ models: ["claude-3-opus"] });
|
||||
const { user } = await setup();
|
||||
|
||||
await chooseProvider(user, "Anthropic");
|
||||
await user.type(screen.getByLabelText("Credential name"), "shared-name");
|
||||
await user.click(screen.getByRole("button", { name: /Next/ }));
|
||||
|
||||
await chooseSelectOption(
|
||||
user,
|
||||
await screen.findByRole("combobox", { name: "Authentication method" }),
|
||||
"Workload Identity Federation (LiteLLM-signed)",
|
||||
);
|
||||
fireEvent.change(await screen.findByLabelText("Issuer URL"), { target: { value: "https://proxy.example.com" } });
|
||||
fireEvent.change(screen.getByLabelText("Issuer Subject"), { target: { value: "litellm-proxy" } });
|
||||
fireEvent.change(screen.getByLabelText("Signing Key Reference"), { target: { value: "os.environ/SIGNING_KEY" } });
|
||||
await user.click(screen.getByRole("button", { name: "Save credential" }));
|
||||
|
||||
// A credential is (name, provider). Keeping the name but switching provider must not PATCH the
|
||||
// Anthropic credential into an OpenAI one.
|
||||
await screen.findByText("Register this JWKS with Anthropic");
|
||||
await user.click(screen.getByRole("button", { name: /Back/ }));
|
||||
await user.click(await screen.findByRole("button", { name: /Back/ }));
|
||||
await chooseProvider(user, "OpenAI");
|
||||
await user.click(screen.getByRole("button", { name: /Next/ }));
|
||||
|
||||
credentialCreateCall.mockClear();
|
||||
credentialUpdateCall.mockClear();
|
||||
await user.type(await screen.findByLabelText("API Key"), "sk-openai-test");
|
||||
await user.click(screen.getByRole("button", { name: /Save credential/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(credentialCreateCall).toHaveBeenCalledWith(
|
||||
"test-access-token",
|
||||
expect.objectContaining({
|
||||
credential_name: "shared-name",
|
||||
credential_info: { custom_llm_provider: "openai" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(credentialUpdateCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("drops the previous provider's credential values when the provider changes", async () => {
|
||||
discoverProviderModelsCall.mockResolvedValue({ models: ["gpt-4o"] });
|
||||
const { user } = await setup();
|
||||
|
||||
await chooseProvider(user, "Anthropic");
|
||||
await user.type(screen.getByLabelText("Credential name"), "switching");
|
||||
await user.click(screen.getByRole("button", { name: /Next/ }));
|
||||
await user.type(await screen.findByLabelText("API Key"), "sk-ant-secret");
|
||||
await user.click(screen.getByRole("button", { name: /Back/ }));
|
||||
|
||||
await chooseProvider(user, "OpenAI");
|
||||
await user.click(screen.getByRole("button", { name: /Next/ }));
|
||||
|
||||
const apiKeyField = await screen.findByLabelText("API Key");
|
||||
expect(apiKeyField).toHaveValue("");
|
||||
await user.type(apiKeyField, "sk-openai-test");
|
||||
await user.click(screen.getByRole("button", { name: /Save credential/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(credentialCreateCall).toHaveBeenCalledWith(
|
||||
"test-access-token",
|
||||
expect.objectContaining({
|
||||
credential_values: expect.objectContaining({ api_key: "sk-openai-test" }),
|
||||
credential_info: { custom_llm_provider: "openai" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks creation while any model name is blank", async () => {
|
||||
discoverProviderModelsCall.mockResolvedValue({ models: ["claude-3-opus"] });
|
||||
const { user } = await setup();
|
||||
|
||||
await chooseProvider(user, "Anthropic");
|
||||
await user.type(screen.getByLabelText("Credential name"), "anthropic-prod");
|
||||
await user.click(screen.getByRole("button", { name: /Next/ }));
|
||||
await user.type(await screen.findByLabelText("API Key"), "sk-ant-test");
|
||||
await user.click(screen.getByRole("button", { name: "Save credential" }));
|
||||
|
||||
const nameCell = await screen.findByDisplayValue("claude-3-opus");
|
||||
await user.clear(nameCell);
|
||||
|
||||
expect(screen.getByRole("button", { name: /Create 1 model/ })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,388 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useForm, useWatch, FormProvider } from "react-hook-form";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { useProviderFields } from "@/app/(dashboard)/hooks/providers/useProviderFields";
|
||||
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
|
||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
import {
|
||||
MountedFormProvider,
|
||||
projectMountedValues,
|
||||
useMountRegistry,
|
||||
type MountedFormValues,
|
||||
} from "@/components/common_components/MountedFormField";
|
||||
import { computeCredentialValuesToDelete } from "@/components/model_add/credential_form_helpers";
|
||||
import ProviderSpecificFields from "@/components/add_model/provider_specific_fields";
|
||||
import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
|
||||
import { Providers } from "@/components/provider_info_helpers";
|
||||
import {
|
||||
credentialCreateCall,
|
||||
credentialUpdateCall,
|
||||
discoverProviderModelsCall,
|
||||
getCredentialJwksCall,
|
||||
listAllModelsCall,
|
||||
getCallbacksCall,
|
||||
setCallbacksCall,
|
||||
createProviderModelCall,
|
||||
type AnthropicJwks,
|
||||
type DeploymentInfoRow,
|
||||
type ProviderCreateInfo,
|
||||
} from "@/components/networking";
|
||||
import type { SearchSelectOption } from "@/components/shared/SearchSelect";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { extractProxyErrorMessage } from "@/lib/http/client";
|
||||
import {
|
||||
aliasAdditionsFromRows,
|
||||
buildDiscoveredRows,
|
||||
buildModelCreationPayload,
|
||||
mergeModelGroupAliases,
|
||||
rowsPendingCreation,
|
||||
type CreationResult,
|
||||
type DiscoveredModelRow,
|
||||
type ModelGroupAliasMap,
|
||||
} from "./wizardLogic";
|
||||
import { federationIdsUpdate, readFederationIds, withFederationIds } from "./anthropicFederation";
|
||||
import ReviewModelsStep from "./ReviewModelsStep";
|
||||
import { DiscoverStep, JwksStep, ProviderStep, ResultsStep } from "./WizardSteps";
|
||||
|
||||
type WizardStep = "provider" | "credential" | "jwks" | "discover" | "review" | "creating" | "done";
|
||||
|
||||
const STEP_ORDER: readonly WizardStep[] = ["provider", "credential", "jwks", "discover", "review", "creating", "done"];
|
||||
|
||||
const STEP_LABELS: Record<WizardStep, string> = {
|
||||
provider: "Provider",
|
||||
credential: "Authentication",
|
||||
jwks: "Register issuer",
|
||||
discover: "Discover models",
|
||||
review: "Review models",
|
||||
creating: "Creating",
|
||||
done: "Done",
|
||||
};
|
||||
|
||||
const ANTHROPIC_INTERNAL_ISSUER_DISCRIMINATOR = "internal_issuer";
|
||||
|
||||
const StepIndicator: React.FC<{ step: WizardStep; skipJwks: boolean }> = ({ step, skipJwks }) => {
|
||||
const visibleSteps = STEP_ORDER.filter((s) => s !== "creating" && (!skipJwks || s !== "jwks"));
|
||||
const currentIndex = visibleSteps.indexOf(step === "creating" ? "done" : step);
|
||||
return (
|
||||
<div className="mb-6 flex flex-wrap items-center gap-2 text-sm">
|
||||
{visibleSteps.map((s, index) => (
|
||||
<React.Fragment key={s}>
|
||||
{index > 0 && <span className="text-muted-foreground">{"->"}</span>}
|
||||
<span className={index <= currentIndex ? "font-medium text-foreground" : "text-muted-foreground"}>
|
||||
{STEP_LABELS[s]}
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function AddProviderPanel() {
|
||||
const { accessToken } = useAuthorized();
|
||||
const queryClient = useQueryClient();
|
||||
const { data: providerMetadata } = useProviderFields();
|
||||
const { data: credentialsResponse } = useCredentials();
|
||||
|
||||
const [step, setStep] = React.useState<WizardStep>("provider");
|
||||
const [selectedProvider, setSelectedProvider] = React.useState<Providers | null>(null);
|
||||
const [credentialName, setCredentialName] = React.useState("");
|
||||
const [savedCredential, setSavedCredential] = React.useState<{ name: string; provider: string } | null>(null);
|
||||
const [savedValues, setSavedValues] = React.useState<Record<string, unknown>>({});
|
||||
const [jwks, setJwks] = React.useState<AnthropicJwks | null>(null);
|
||||
const [jwksError, setJwksError] = React.useState<string | null>(null);
|
||||
const [discoveryError, setDiscoveryError] = React.useState<string | null>(null);
|
||||
const [createError, setCreateError] = React.useState<string | null>(null);
|
||||
const [isDiscovering, setIsDiscovering] = React.useState(false);
|
||||
const [rows, setRows] = React.useState<DiscoveredModelRow[]>([]);
|
||||
const [isCreating, setIsCreating] = React.useState(false);
|
||||
const [creationResults, setCreationResults] = React.useState<CreationResult[]>([]);
|
||||
const [aliasCollisions, setAliasCollisions] = React.useState<string[]>([]);
|
||||
|
||||
const form = useForm<MountedFormValues>({ mode: "onChange" });
|
||||
const registry = useMountRegistry();
|
||||
const federationIds = readFederationIds(useWatch({ control: form.control }));
|
||||
|
||||
const providerOptions: SearchSelectOption[] = React.useMemo(
|
||||
() =>
|
||||
(providerMetadata ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.provider_display_name.localeCompare(b.provider_display_name))
|
||||
.map((p) => ({
|
||||
label: p.provider_display_name,
|
||||
value: p.provider_display_name,
|
||||
icon: <ProviderLogo provider={p.provider_display_name} className="w-5 h-5" />,
|
||||
})),
|
||||
[providerMetadata],
|
||||
);
|
||||
|
||||
const selectedProviderInfo: ProviderCreateInfo | undefined = React.useMemo(
|
||||
() => providerMetadata?.find((p) => p.provider_display_name === selectedProvider),
|
||||
[providerMetadata, selectedProvider],
|
||||
);
|
||||
const litellmProvider = selectedProviderInfo?.litellm_provider ?? "";
|
||||
|
||||
// A credential is identified by name AND provider: switching provider under the same name must
|
||||
// create a new one, not PATCH the previous provider's credential into a different provider.
|
||||
const credentialSaved =
|
||||
savedCredential !== null && savedCredential.name === credentialName && savedCredential.provider === litellmProvider;
|
||||
|
||||
const nameCollision =
|
||||
credentialName.length > 0 &&
|
||||
!credentialSaved &&
|
||||
(credentialsResponse?.credentials ?? []).some((c) => c.credential_name === credentialName);
|
||||
|
||||
const goTo = (next: WizardStep) => setStep(next);
|
||||
|
||||
const saveCredential = async () => {
|
||||
if (!accessToken || !selectedProvider) {
|
||||
return;
|
||||
}
|
||||
const isValid = await form.trigger(registry.mountedNames() as string[]);
|
||||
if (!isValid) {
|
||||
return;
|
||||
}
|
||||
const values = projectMountedValues(registry, form.getValues) as Record<string, unknown>;
|
||||
const nonEmptyValues = Object.fromEntries(
|
||||
Object.entries(values).filter(([, v]) => v !== "" && v !== undefined && v !== null),
|
||||
);
|
||||
try {
|
||||
if (!credentialSaved) {
|
||||
await credentialCreateCall(accessToken, {
|
||||
credential_name: credentialName,
|
||||
credential_values: nonEmptyValues,
|
||||
credential_info: { custom_llm_provider: litellmProvider },
|
||||
});
|
||||
} else {
|
||||
const credentialValuesToDelete = computeCredentialValuesToDelete(savedValues, values);
|
||||
const updatePayload = {
|
||||
credential_name: credentialName,
|
||||
credential_values: nonEmptyValues,
|
||||
credential_info: { custom_llm_provider: litellmProvider },
|
||||
...(credentialValuesToDelete.length > 0 ? { credential_values_to_delete: credentialValuesToDelete } : {}),
|
||||
};
|
||||
await credentialUpdateCall(accessToken, credentialName, updatePayload);
|
||||
}
|
||||
setSavedValues(values);
|
||||
setSavedCredential({ name: credentialName, provider: litellmProvider });
|
||||
queryClient.invalidateQueries({ queryKey: ["credentials"] });
|
||||
toast.success(`Credential "${credentialName}" saved`);
|
||||
if (values.anthropic_identity_source === ANTHROPIC_INTERNAL_ISSUER_DISCRIMINATOR) {
|
||||
goTo("jwks");
|
||||
void loadJwks();
|
||||
} else {
|
||||
goTo("discover");
|
||||
void runDiscovery();
|
||||
}
|
||||
} catch (error) {
|
||||
toast.fromError(`Failed to save credential: ${extractProxyErrorMessage(error)}`);
|
||||
}
|
||||
};
|
||||
|
||||
const loadJwks = async () => {
|
||||
if (!accessToken) return;
|
||||
setJwksError(null);
|
||||
try {
|
||||
const result = await getCredentialJwksCall(accessToken, credentialName);
|
||||
setJwks(result);
|
||||
} catch (error) {
|
||||
setJwksError(extractProxyErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const confirmFederationIds = async () => {
|
||||
if (!accessToken) return;
|
||||
const ids = readFederationIds(form.getValues());
|
||||
const update = federationIdsUpdate(savedValues, ids);
|
||||
if (update !== null) {
|
||||
const updatePayload = {
|
||||
credential_name: credentialName,
|
||||
credential_values: update.credential_values,
|
||||
credential_info: { custom_llm_provider: litellmProvider },
|
||||
...(update.credential_values_to_delete.length > 0
|
||||
? { credential_values_to_delete: [...update.credential_values_to_delete] }
|
||||
: {}),
|
||||
};
|
||||
try {
|
||||
await credentialUpdateCall(accessToken, credentialName, updatePayload);
|
||||
setSavedValues((prev) => withFederationIds(prev, ids));
|
||||
} catch (error) {
|
||||
toast.fromError(`Failed to save the federation ids: ${extractProxyErrorMessage(error)}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
goTo("discover");
|
||||
void runDiscovery();
|
||||
};
|
||||
|
||||
const runDiscovery = async () => {
|
||||
if (!accessToken) return;
|
||||
setIsDiscovering(true);
|
||||
setDiscoveryError(null);
|
||||
try {
|
||||
const result = await discoverProviderModelsCall(accessToken, {
|
||||
custom_llm_provider: litellmProvider,
|
||||
litellm_credential_name: credentialName,
|
||||
});
|
||||
setRows(buildDiscoveredRows(result.models));
|
||||
goTo("review");
|
||||
} catch (error) {
|
||||
setDiscoveryError(extractProxyErrorMessage(error));
|
||||
} finally {
|
||||
setIsDiscovering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createModels = async () => {
|
||||
if (!accessToken) return;
|
||||
setIsCreating(true);
|
||||
setCreationResults([]);
|
||||
setAliasCollisions([]);
|
||||
setCreateError(null);
|
||||
goTo("creating");
|
||||
|
||||
let existing: DeploymentInfoRow[];
|
||||
try {
|
||||
existing = (await listAllModelsCall(accessToken)).data;
|
||||
} catch (error) {
|
||||
setIsCreating(false);
|
||||
setCreateError(
|
||||
`Could not read the existing deployments, so creating now could duplicate ones already saved. ${extractProxyErrorMessage(error)}`,
|
||||
);
|
||||
goTo("review");
|
||||
return;
|
||||
}
|
||||
const pending = rowsPendingCreation(rows, litellmProvider, credentialName, existing);
|
||||
const pendingIds = new Set(pending.map((r) => r.id));
|
||||
|
||||
const results: CreationResult[] = [];
|
||||
for (const row of rows) {
|
||||
if (!pendingIds.has(row.id)) {
|
||||
results.push({ row, status: "skipped", detail: "already created" });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await createProviderModelCall(accessToken, buildModelCreationPayload(litellmProvider, credentialName, row));
|
||||
results.push({ row, status: "created" });
|
||||
} catch (error) {
|
||||
results.push({ row, status: "failed", detail: extractProxyErrorMessage(error) });
|
||||
}
|
||||
}
|
||||
setCreationResults(results);
|
||||
|
||||
const failedRowIds = new Set(results.filter((r) => r.status === "failed").map((r) => r.row.id));
|
||||
const additions = aliasAdditionsFromRows(rows.filter((row) => !failedRowIds.has(row.id)));
|
||||
if (additions.length > 0) {
|
||||
try {
|
||||
const config = await getCallbacksCall(accessToken, "", "");
|
||||
const existingAliasMap: ModelGroupAliasMap = config?.router_settings?.model_group_alias ?? {};
|
||||
const { merged, collisions } = mergeModelGroupAliases(existingAliasMap, additions);
|
||||
if (collisions.length > 0) {
|
||||
setAliasCollisions([...collisions]);
|
||||
}
|
||||
await setCallbacksCall(accessToken, { router_settings: { model_group_alias: merged } });
|
||||
} catch (error) {
|
||||
toast.fromError(`Failed to save alternate names: ${extractProxyErrorMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
|
||||
setIsCreating(false);
|
||||
goTo("done");
|
||||
};
|
||||
|
||||
const isInternalIssuer = savedValues.anthropic_identity_source === ANTHROPIC_INTERNAL_ISSUER_DISCRIMINATOR;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<h2 className="mb-4 text-2xl font-semibold text-foreground">Add Provider</h2>
|
||||
<StepIndicator step={step} skipJwks={!isInternalIssuer} />
|
||||
|
||||
{step === "provider" && (
|
||||
<ProviderStep
|
||||
providerOptions={providerOptions}
|
||||
selectedProvider={selectedProvider}
|
||||
onSelectProvider={(provider) => {
|
||||
if (provider !== selectedProvider) form.reset();
|
||||
setSelectedProvider(provider);
|
||||
}}
|
||||
credentialName={credentialName}
|
||||
onCredentialNameChange={setCredentialName}
|
||||
nameCollision={Boolean(nameCollision)}
|
||||
onNext={() => goTo("credential")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "credential" && selectedProvider && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<FormProvider {...form}>
|
||||
<MountedFormProvider value={{ control: form.control, registry }}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void saveCredential();
|
||||
}}
|
||||
>
|
||||
<ProviderSpecificFields selectedProvider={selectedProvider} />
|
||||
<div className="flex justify-between">
|
||||
<Button type="button" variant="outline" onClick={() => goTo("provider")}>
|
||||
<ArrowLeft className="mr-1 size-4" /> Back
|
||||
</Button>
|
||||
<Button type="submit">{credentialSaved ? "Save changes" : "Save credential"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</MountedFormProvider>
|
||||
</FormProvider>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{step === "jwks" && (
|
||||
<JwksStep
|
||||
jwks={jwks}
|
||||
jwksError={jwksError}
|
||||
federationIds={federationIds}
|
||||
onFederationIdChange={(key, value) => form.setValue(key, value, { shouldDirty: true })}
|
||||
onBack={() => goTo("credential")}
|
||||
onNext={() => void confirmFederationIds()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "discover" && (
|
||||
<DiscoverStep
|
||||
isDiscovering={isDiscovering}
|
||||
discoveryError={discoveryError}
|
||||
onBack={() => goTo(isInternalIssuer ? "jwks" : "credential")}
|
||||
onRetry={() => void runDiscovery()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step === "review" && (
|
||||
<ReviewModelsStep
|
||||
rows={rows}
|
||||
setRows={setRows}
|
||||
createError={createError}
|
||||
onBack={() => {
|
||||
goTo("discover");
|
||||
void runDiscovery();
|
||||
}}
|
||||
onCreateModels={() => void createModels()}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(step === "creating" || step === "done") && (
|
||||
<ResultsStep
|
||||
isCreating={isCreating}
|
||||
isDone={step === "done"}
|
||||
creationResults={creationResults}
|
||||
aliasCollisions={aliasCollisions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { MultiSelect } from "@/components/shared/MultiSelect";
|
||||
import { ArrowLeft, Plus, X } from "lucide-react";
|
||||
import { buildManualRow, type DiscoveredModelRow } from "./wizardLogic";
|
||||
|
||||
interface ReviewModelsStepProps {
|
||||
rows: DiscoveredModelRow[];
|
||||
setRows: React.Dispatch<React.SetStateAction<DiscoveredModelRow[]>>;
|
||||
createError: string | null;
|
||||
onBack: () => void;
|
||||
onCreateModels: () => void;
|
||||
}
|
||||
|
||||
const ReviewModelsStep: React.FC<ReviewModelsStepProps> = ({ rows, setRows, createError, onBack, onCreateModels }) => {
|
||||
const [manualId, setManualId] = React.useState("");
|
||||
|
||||
const hasBlankName = rows.some((row) => row.modelName.trim() === "");
|
||||
|
||||
const updateRow = (id: string, patch: Partial<DiscoveredModelRow>) =>
|
||||
setRows((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row)));
|
||||
|
||||
const removeRow = (id: string) => setRows((current) => current.filter((row) => row.id !== id));
|
||||
|
||||
const addManualRow = () => {
|
||||
const trimmed = manualId.trim();
|
||||
if (!trimmed) return;
|
||||
setRows((current) => [...current, buildManualRow(trimmed)]);
|
||||
setManualId("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-4">
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No models discovered. Add one manually below.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Upstream ID</TableHead>
|
||||
<TableHead>Enabled</TableHead>
|
||||
<TableHead>Model name</TableHead>
|
||||
<TableHead>Alternate names</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="whitespace-nowrap font-mono text-xs">{row.upstreamId}</TableCell>
|
||||
<TableCell>
|
||||
<Switch
|
||||
checked={row.enabled}
|
||||
onCheckedChange={(checked) => updateRow(row.id, { enabled: checked })}
|
||||
aria-label={`Enable ${row.upstreamId}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[160px]">
|
||||
<Input
|
||||
value={row.modelName}
|
||||
onChange={(e) => updateRow(row.id, { modelName: e.target.value })}
|
||||
aria-label={`Model name for ${row.upstreamId}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-[200px]">
|
||||
<MultiSelect
|
||||
value={row.alternateNames}
|
||||
onValueChange={(value) => updateRow(row.id, { alternateNames: value })}
|
||||
options={[]}
|
||||
allowCustomValues
|
||||
placeholder="Add alternate names"
|
||||
emptyText="Type to add an alternate name"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.manual && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Remove ${row.upstreamId}`}
|
||||
onClick={() => removeRow(row.id)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="add-provider-manual-model" className="mb-1 block text-xs text-muted-foreground">
|
||||
Add a hidden model manually
|
||||
</label>
|
||||
<Input
|
||||
id="add-provider-manual-model"
|
||||
value={manualId}
|
||||
onChange={(e) => setManualId(e.target.value)}
|
||||
placeholder="upstream model id"
|
||||
/>
|
||||
</div>
|
||||
<Button type="button" variant="outline" onClick={addManualRow} disabled={!manualId.trim()}>
|
||||
<Plus className="mr-1 size-4" /> Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{createError && <p className="text-sm text-destructive">{createError}</p>}
|
||||
|
||||
<div className="flex justify-between">
|
||||
<Button type="button" variant="outline" onClick={onBack}>
|
||||
<ArrowLeft className="mr-1 size-4" /> Back
|
||||
</Button>
|
||||
<Button disabled={rows.length === 0 || hasBlankName} onClick={onCreateModels}>
|
||||
Create {rows.length} model{rows.length === 1 ? "" : "s"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReviewModelsStep;
|
||||
|
|
@ -1,226 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field";
|
||||
import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { AlertTriangle, ArrowLeft, ArrowRight, Loader2 } from "lucide-react";
|
||||
import { Providers } from "@/components/provider_info_helpers";
|
||||
import type { AnthropicJwks } from "@/components/networking";
|
||||
import type { CreationResult } from "./wizardLogic";
|
||||
import {
|
||||
ANTHROPIC_FEDERATION_FIELDS,
|
||||
missingFederationFields,
|
||||
type AnthropicFederationIds,
|
||||
type AnthropicFederationKey,
|
||||
} from "./anthropicFederation";
|
||||
|
||||
const CREATION_RESULT_CLASS_NAME: Record<CreationResult["status"], string> = {
|
||||
failed: "text-destructive",
|
||||
skipped: "text-muted-foreground",
|
||||
created: "text-success",
|
||||
};
|
||||
|
||||
interface ProviderStepProps {
|
||||
providerOptions: SearchSelectOption[];
|
||||
selectedProvider: Providers | null;
|
||||
onSelectProvider: (provider: Providers) => void;
|
||||
credentialName: string;
|
||||
onCredentialNameChange: (name: string) => void;
|
||||
nameCollision: boolean;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export const ProviderStep: React.FC<ProviderStepProps> = ({
|
||||
providerOptions,
|
||||
selectedProvider,
|
||||
onSelectProvider,
|
||||
credentialName,
|
||||
onCredentialNameChange,
|
||||
nameCollision,
|
||||
onNext,
|
||||
}) => (
|
||||
<Card>
|
||||
<CardContent className="space-y-4">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="add-provider-provider">Provider</FieldLabel>
|
||||
<SearchSelect
|
||||
inputId="add-provider-provider"
|
||||
options={providerOptions}
|
||||
placeholder="Select a provider"
|
||||
value={selectedProvider ?? ""}
|
||||
onValueChange={(value) => onSelectProvider(value as Providers)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="add-provider-credential-name">Credential name</FieldLabel>
|
||||
<Input
|
||||
id="add-provider-credential-name"
|
||||
value={credentialName}
|
||||
onChange={(e) => onCredentialNameChange(e.target.value)}
|
||||
placeholder="e.g. anthropic-prod"
|
||||
/>
|
||||
{nameCollision && <p className="text-sm text-destructive">A credential with this name already exists.</p>}
|
||||
</Field>
|
||||
<div className="flex justify-end">
|
||||
<Button disabled={!selectedProvider || !credentialName || nameCollision} onClick={onNext}>
|
||||
Next <ArrowRight className="ml-1 size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
interface JwksStepProps {
|
||||
jwks: AnthropicJwks | null;
|
||||
jwksError: string | null;
|
||||
federationIds: AnthropicFederationIds;
|
||||
onFederationIdChange: (key: AnthropicFederationKey, value: string) => void;
|
||||
onBack: () => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
export const JwksStep: React.FC<JwksStepProps> = ({
|
||||
jwks,
|
||||
jwksError,
|
||||
federationIds,
|
||||
onFederationIdChange,
|
||||
onBack,
|
||||
onNext,
|
||||
}) => {
|
||||
const missing = missingFederationFields(federationIds);
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert variant="info">
|
||||
<AlertTitle>Register this JWKS with Anthropic</AlertTitle>
|
||||
<AlertDescription>
|
||||
In the Claude Console, open Settings {">"} Workload identity, click Connect workload, choose Custom OIDC and
|
||||
paste this JWKS as the inline key set, using the Issuer URL and Subject from the previous step. Once the
|
||||
rule and its service account exist, copy their ids below. Everything entered here is saved to this
|
||||
credential before discovery runs.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{jwksError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="size-4" />
|
||||
<AlertTitle>Could not load JWKS</AlertTitle>
|
||||
<AlertDescription>{jwksError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{jwks && (
|
||||
<div className="relative rounded-md border bg-muted p-3">
|
||||
<CopyButton value={JSON.stringify(jwks, null, 2)} label="Copy JWKS" className="absolute top-2 right-2" />
|
||||
<pre className="overflow-x-auto text-xs">{JSON.stringify(jwks, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
{ANTHROPIC_FEDERATION_FIELDS.map((field) => (
|
||||
<Field key={field.key}>
|
||||
<FieldLabel htmlFor={`add-provider-${field.key}`}>{field.label}</FieldLabel>
|
||||
<Input
|
||||
id={`add-provider-${field.key}`}
|
||||
value={federationIds[field.key]}
|
||||
aria-required={field.required || undefined}
|
||||
aria-describedby={`add-provider-${field.key}-hint`}
|
||||
onChange={(e) => onFederationIdChange(field.key, e.target.value)}
|
||||
/>
|
||||
<FieldDescription id={`add-provider-${field.key}-hint`}>{field.hint}</FieldDescription>
|
||||
</Field>
|
||||
))}
|
||||
{missing.length > 0 && (
|
||||
<p className="text-sm text-muted-foreground">Still needed before discovery: {missing.join(", ")}.</p>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<Button type="button" variant="outline" onClick={onBack}>
|
||||
<ArrowLeft className="mr-1 size-4" /> Back
|
||||
</Button>
|
||||
<Button disabled={missing.length > 0} onClick={onNext}>
|
||||
Next <ArrowRight className="ml-1 size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface DiscoverStepProps {
|
||||
isDiscovering: boolean;
|
||||
discoveryError: string | null;
|
||||
onBack: () => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
export const DiscoverStep: React.FC<DiscoverStepProps> = ({ isDiscovering, discoveryError, onBack, onRetry }) => (
|
||||
<Card>
|
||||
<CardContent className="space-y-4">
|
||||
{isDiscovering && (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" /> Discovering models...
|
||||
</p>
|
||||
)}
|
||||
{discoveryError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="size-4" />
|
||||
<AlertTitle>Discovery failed</AlertTitle>
|
||||
<AlertDescription>{discoveryError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<Button type="button" variant="outline" onClick={onBack}>
|
||||
<ArrowLeft className="mr-1 size-4" /> Back
|
||||
</Button>
|
||||
{discoveryError && (
|
||||
<Button disabled={isDiscovering} onClick={onRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
interface ResultsStepProps {
|
||||
isCreating: boolean;
|
||||
isDone: boolean;
|
||||
creationResults: CreationResult[];
|
||||
aliasCollisions: string[];
|
||||
}
|
||||
|
||||
export const ResultsStep: React.FC<ResultsStepProps> = ({ isCreating, isDone, creationResults, aliasCollisions }) => (
|
||||
<Card>
|
||||
<CardContent className="space-y-4">
|
||||
{isCreating && (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" /> Creating models...
|
||||
</p>
|
||||
)}
|
||||
{isDone && (
|
||||
<>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{creationResults.map((result) => (
|
||||
<li key={result.row.id}>
|
||||
<span className={CREATION_RESULT_CLASS_NAME[result.status]}>
|
||||
{result.row.modelName}: {result.status}
|
||||
{result.detail ? ` (${result.detail})` : ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{aliasCollisions.length > 0 && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="size-4" />
|
||||
<AlertTitle>Some alternate names were not saved</AlertTitle>
|
||||
<AlertDescription>
|
||||
These alias names already exist and were left unchanged: {aliasCollisions.join(", ")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
federationIdsUpdate,
|
||||
missingFederationFields,
|
||||
readFederationIds,
|
||||
withFederationIds,
|
||||
} from "./anthropicFederation";
|
||||
|
||||
const ALL_IDS = {
|
||||
anthropic_organization_id: "org-1",
|
||||
anthropic_federation_rule_id: "fdrl_1",
|
||||
anthropic_service_account_id: "svac_1",
|
||||
anthropic_workspace_id: "wrkspc_1",
|
||||
};
|
||||
|
||||
const ids = (overrides: Partial<Record<string, string>> = {}) => readFederationIds({ ...ALL_IDS, ...overrides });
|
||||
|
||||
describe("readFederationIds", () => {
|
||||
it("reads the four ids, trimming pasted whitespace and treating anything else as blank", () => {
|
||||
const formValues = {
|
||||
anthropic_organization_id: " org-1 ",
|
||||
anthropic_federation_rule_id: undefined,
|
||||
anthropic_service_account_id: 42,
|
||||
api_key: "unrelated",
|
||||
};
|
||||
const onlyOrganization = {
|
||||
anthropic_organization_id: "org-1",
|
||||
anthropic_federation_rule_id: "",
|
||||
anthropic_service_account_id: "",
|
||||
anthropic_workspace_id: "",
|
||||
};
|
||||
expect(readFederationIds(formValues)).toEqual(onlyOrganization);
|
||||
});
|
||||
});
|
||||
|
||||
describe("missingFederationFields", () => {
|
||||
it("names only the organization and federation rule when everything is blank", () => {
|
||||
expect(missingFederationFields(readFederationIds({}))).toEqual(["Organization ID", "Federation Rule ID"]);
|
||||
});
|
||||
|
||||
it("does not require the service account or workspace ids", () => {
|
||||
expect(missingFederationFields(ids({ anthropic_service_account_id: "", anthropic_workspace_id: "" }))).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats whitespace as blank", () => {
|
||||
expect(missingFederationFields(ids({ anthropic_federation_rule_id: " " }))).toEqual(["Federation Rule ID"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("federationIdsUpdate", () => {
|
||||
it("is null when the entered ids match the saved credential", () => {
|
||||
expect(
|
||||
federationIdsUpdate(
|
||||
{ anthropic_organization_id: "org-1", anthropic_issuer_url: "https://proxy.example.com" },
|
||||
ids({ anthropic_federation_rule_id: "", anthropic_service_account_id: "", anthropic_workspace_id: "" }),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("sends only the ids that changed and keeps the untouched saved ones out of the payload", () => {
|
||||
expect(
|
||||
federationIdsUpdate(
|
||||
{ anthropic_organization_id: "org-1" },
|
||||
ids({ anthropic_service_account_id: "", anthropic_workspace_id: "" }),
|
||||
),
|
||||
).toEqual({ credential_values: { anthropic_federation_rule_id: "fdrl_1" }, credential_values_to_delete: [] });
|
||||
});
|
||||
|
||||
it("deletes an id the operator cleared after it was saved instead of merging over it", () => {
|
||||
expect(
|
||||
federationIdsUpdate(
|
||||
{ anthropic_organization_id: "org-1", anthropic_federation_rule_id: "fdrl_1", anthropic_workspace_id: "w" },
|
||||
ids({ anthropic_service_account_id: "", anthropic_workspace_id: "" }),
|
||||
),
|
||||
).toEqual({ credential_values: {}, credential_values_to_delete: ["anthropic_workspace_id"] });
|
||||
});
|
||||
|
||||
it("ignores whitespace-only differences against the saved value", () => {
|
||||
expect(federationIdsUpdate({ anthropic_organization_id: " org-1" }, ids())).toEqual({
|
||||
credential_values: {
|
||||
anthropic_federation_rule_id: "fdrl_1",
|
||||
anthropic_service_account_id: "svac_1",
|
||||
anthropic_workspace_id: "wrkspc_1",
|
||||
},
|
||||
credential_values_to_delete: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("withFederationIds", () => {
|
||||
it("replaces the saved ids with the entered ones, dropping cleared ids and keeping other fields", () => {
|
||||
const saved = { anthropic_organization_id: "old", anthropic_workspace_id: "w", anthropic_issuer_url: "u" };
|
||||
const merged = {
|
||||
anthropic_issuer_url: "u",
|
||||
anthropic_organization_id: "org-1",
|
||||
anthropic_federation_rule_id: "fdrl_1",
|
||||
anthropic_service_account_id: "svac_1",
|
||||
};
|
||||
expect(withFederationIds(saved, ids({ anthropic_workspace_id: "" }))).toEqual(merged);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
export const ANTHROPIC_FEDERATION_FIELDS = [
|
||||
{
|
||||
key: "anthropic_organization_id",
|
||||
label: "Organization ID",
|
||||
required: true,
|
||||
hint: "The UUID under Settings > Organization in the Claude Console.",
|
||||
},
|
||||
{
|
||||
key: "anthropic_federation_rule_id",
|
||||
label: "Federation Rule ID",
|
||||
required: true,
|
||||
hint: "The fdrl_... id on the rule's detail page under Settings > Workload identity.",
|
||||
},
|
||||
{
|
||||
key: "anthropic_service_account_id",
|
||||
label: "Service Account ID",
|
||||
required: false,
|
||||
hint:
|
||||
"The svac_... id of the service account the rule targets, on the same page. Anthropic lists it as required, " +
|
||||
"though the exchange works without it when the rule targets a single service account, so fill it in whenever " +
|
||||
"you have it.",
|
||||
},
|
||||
{
|
||||
key: "anthropic_workspace_id",
|
||||
label: "Workspace ID",
|
||||
required: false,
|
||||
hint:
|
||||
"Required only when the rule is enabled in more than one workspace (for example All workspaces); Anthropic " +
|
||||
"then rejects the exchange with a 401 logged as workspace_id_required. Use the wrkspc_... id under " +
|
||||
"Settings > Workspaces, or the literal default. Leave blank when the rule is enabled in a single workspace.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type AnthropicFederationKey = (typeof ANTHROPIC_FEDERATION_FIELDS)[number]["key"];
|
||||
|
||||
export type AnthropicFederationIds = Readonly<Record<AnthropicFederationKey, string>>;
|
||||
|
||||
export interface FederationIdsUpdate {
|
||||
readonly credential_values: Readonly<Partial<Record<AnthropicFederationKey, string>>>;
|
||||
readonly credential_values_to_delete: readonly AnthropicFederationKey[];
|
||||
}
|
||||
|
||||
const trimmed = (value: unknown): string => (typeof value === "string" ? value.trim() : "");
|
||||
|
||||
export const readFederationIds = (values: Readonly<Record<string, unknown>>): AnthropicFederationIds =>
|
||||
Object.fromEntries(ANTHROPIC_FEDERATION_FIELDS.map((field) => [field.key, trimmed(values[field.key])])) as Record<
|
||||
AnthropicFederationKey,
|
||||
string
|
||||
>;
|
||||
|
||||
export const missingFederationFields = (ids: AnthropicFederationIds): readonly string[] =>
|
||||
ANTHROPIC_FEDERATION_FIELDS.filter((field) => field.required && ids[field.key] === "").map((field) => field.label);
|
||||
|
||||
/**
|
||||
* The PATCH that brings a saved credential in line with the ids entered on the Register issuer
|
||||
* step, or null when every id already matches what was saved. Ids the operator cleared since the
|
||||
* save are deleted rather than merged over, the same rule the Authentication step applies.
|
||||
*/
|
||||
export const federationIdsUpdate = (
|
||||
saved: Readonly<Record<string, unknown>>,
|
||||
ids: AnthropicFederationIds,
|
||||
): FederationIdsUpdate | null => {
|
||||
const changed = ANTHROPIC_FEDERATION_FIELDS.map((field) => field.key).filter(
|
||||
(key) => ids[key] !== trimmed(saved[key]),
|
||||
);
|
||||
if (changed.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
credential_values: Object.fromEntries(changed.filter((key) => ids[key] !== "").map((key) => [key, ids[key]])),
|
||||
credential_values_to_delete: changed.filter((key) => ids[key] === ""),
|
||||
};
|
||||
};
|
||||
|
||||
export const withFederationIds = (
|
||||
saved: Readonly<Record<string, unknown>>,
|
||||
ids: AnthropicFederationIds,
|
||||
): Readonly<Record<string, unknown>> =>
|
||||
Object.fromEntries([
|
||||
...Object.entries(saved).filter(([key]) => !(key in ids)),
|
||||
...Object.entries(ids).filter(([, value]) => value !== ""),
|
||||
]);
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { DeploymentInfoRow } from "@/components/networking";
|
||||
import {
|
||||
aliasAdditionsFromRows,
|
||||
buildDiscoveredRows,
|
||||
buildManualRow,
|
||||
buildModelCreationPayload,
|
||||
litellmModelForUpstreamId,
|
||||
mergeModelGroupAliases,
|
||||
rowsPendingCreation,
|
||||
type DiscoveredModelRow,
|
||||
} from "./wizardLogic";
|
||||
|
||||
describe("litellmModelForUpstreamId", () => {
|
||||
it("prefixes a bare upstream id with the provider", () => {
|
||||
expect(litellmModelForUpstreamId("anthropic", "claude-3-opus")).toBe("anthropic/claude-3-opus");
|
||||
});
|
||||
|
||||
it("does not double-prefix an id that already carries the provider prefix", () => {
|
||||
expect(litellmModelForUpstreamId("anthropic", "anthropic/claude-3-opus")).toBe("anthropic/claude-3-opus");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDiscoveredRows", () => {
|
||||
it("defaults every row to enabled, with model_name equal to the discovered id", () => {
|
||||
const rows = buildDiscoveredRows(["claude-3-opus", "claude-3-haiku"]);
|
||||
expect(rows).toHaveLength(2);
|
||||
const defaultedOpusRow = {
|
||||
upstreamId: "claude-3-opus",
|
||||
modelName: "claude-3-opus",
|
||||
enabled: true,
|
||||
alternateNames: [],
|
||||
manual: false,
|
||||
};
|
||||
expect(rows[0]).toMatchObject(defaultedOpusRow);
|
||||
});
|
||||
|
||||
it("gives every row a unique id even for duplicate upstream ids", () => {
|
||||
const rows = buildDiscoveredRows(["same-id", "same-id"]);
|
||||
expect(rows[0].id).not.toBe(rows[1].id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildManualRow", () => {
|
||||
it("marks the row manual and enabled by default", () => {
|
||||
const row = buildManualRow("hidden-model");
|
||||
const manualEnabledRow = { upstreamId: "hidden-model", modelName: "hidden-model", enabled: true, manual: true };
|
||||
expect(row).toMatchObject(manualEnabledRow);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildModelCreationPayload", () => {
|
||||
const baseRow: DiscoveredModelRow = {
|
||||
id: "row-1",
|
||||
upstreamId: "claude-3-opus",
|
||||
modelName: "my-claude",
|
||||
enabled: true,
|
||||
alternateNames: [],
|
||||
manual: false,
|
||||
};
|
||||
|
||||
it("maps enabled=true to blocked=false", () => {
|
||||
const payload = buildModelCreationPayload("anthropic", "my-cred", baseRow);
|
||||
const unblockedCreation = {
|
||||
model_name: "my-claude",
|
||||
litellm_params: { model: "anthropic/claude-3-opus", litellm_credential_name: "my-cred" },
|
||||
model_info: {},
|
||||
blocked: false,
|
||||
};
|
||||
expect(payload).toEqual(unblockedCreation);
|
||||
});
|
||||
|
||||
it("maps enabled=false to blocked=true", () => {
|
||||
const payload = buildModelCreationPayload("anthropic", "my-cred", { ...baseRow, enabled: false });
|
||||
expect(payload.blocked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rowsPendingCreation", () => {
|
||||
const rows: DiscoveredModelRow[] = [
|
||||
{ id: "1", upstreamId: "claude-3-opus", modelName: "opus", enabled: true, alternateNames: [], manual: false },
|
||||
{ id: "2", upstreamId: "claude-3-haiku", modelName: "haiku", enabled: true, alternateNames: [], manual: false },
|
||||
];
|
||||
|
||||
it("returns every row when nothing exists yet", () => {
|
||||
expect(rowsPendingCreation(rows, "anthropic", "my-cred", [])).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("skips a row already created under the same credential", () => {
|
||||
const existing: DeploymentInfoRow[] = [
|
||||
{
|
||||
model_name: "opus",
|
||||
litellm_params: { model: "anthropic/claude-3-opus", litellm_credential_name: "my-cred" },
|
||||
model_info: { id: "abc" },
|
||||
},
|
||||
];
|
||||
const pending = rowsPendingCreation(rows, "anthropic", "my-cred", existing);
|
||||
expect(pending.map((r) => r.upstreamId)).toEqual(["claude-3-haiku"]);
|
||||
});
|
||||
|
||||
it("does not skip a same-model row that belongs to a different credential", () => {
|
||||
const existing: DeploymentInfoRow[] = [
|
||||
{
|
||||
model_name: "opus",
|
||||
litellm_params: { model: "anthropic/claude-3-opus", litellm_credential_name: "someone-elses-cred" },
|
||||
model_info: { id: "abc" },
|
||||
},
|
||||
];
|
||||
expect(rowsPendingCreation(rows, "anthropic", "my-cred", existing)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeModelGroupAliases", () => {
|
||||
it("adds new aliases to an empty map", () => {
|
||||
const { merged, collisions } = mergeModelGroupAliases({}, [{ alias: "gpt-4o", targetModelGroup: "opus" }]);
|
||||
expect(merged).toEqual({ "gpt-4o": "opus" });
|
||||
expect(collisions).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves an existing object-valued entry untouched", () => {
|
||||
const existing = { "hidden-alias": { model: "some-model", hidden: true } };
|
||||
const { merged } = mergeModelGroupAliases(existing, [{ alias: "new-alias", targetModelGroup: "opus" }]);
|
||||
expect(merged["hidden-alias"]).toEqual({ model: "some-model", hidden: true });
|
||||
expect(merged["new-alias"]).toBe("opus");
|
||||
});
|
||||
|
||||
it("rejects a collision with an existing alias rather than overwriting it", () => {
|
||||
const existing = { "gpt-4o": "some-other-model" };
|
||||
const { merged, collisions } = mergeModelGroupAliases(existing, [{ alias: "gpt-4o", targetModelGroup: "opus" }]);
|
||||
expect(merged["gpt-4o"]).toBe("some-other-model");
|
||||
expect(collisions).toEqual(["gpt-4o"]);
|
||||
});
|
||||
|
||||
it("rejects a collision between two additions in the same batch", () => {
|
||||
const { merged, collisions } = mergeModelGroupAliases({}, [
|
||||
{ alias: "dup", targetModelGroup: "opus" },
|
||||
{ alias: "dup", targetModelGroup: "haiku" },
|
||||
]);
|
||||
expect(merged.dup).toBe("opus");
|
||||
expect(collisions).toEqual(["dup"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("aliasAdditionsFromRows", () => {
|
||||
it("flattens each row's alternate names against its model_name", () => {
|
||||
const rows: DiscoveredModelRow[] = [
|
||||
{ id: "1", upstreamId: "opus", modelName: "my-opus", enabled: true, alternateNames: ["gpt-4o"], manual: false },
|
||||
{ id: "2", upstreamId: "haiku", modelName: "my-haiku", enabled: true, alternateNames: [], manual: false },
|
||||
];
|
||||
expect(aliasAdditionsFromRows(rows)).toEqual([{ alias: "gpt-4o", targetModelGroup: "my-opus" }]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
import type { DeploymentInfoRow } from "@/components/networking";
|
||||
|
||||
export interface DiscoveredModelRow {
|
||||
id: string;
|
||||
upstreamId: string;
|
||||
modelName: string;
|
||||
enabled: boolean;
|
||||
alternateNames: string[];
|
||||
manual: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The litellm_params.model this upstream id resolves to: provider-prefixed, but never
|
||||
* double-prefixed if the discovered/typed id already carries the provider's own prefix.
|
||||
*/
|
||||
export const litellmModelForUpstreamId = (litellmProvider: string, upstreamId: string): string =>
|
||||
upstreamId.startsWith(`${litellmProvider}/`) ? upstreamId : `${litellmProvider}/${upstreamId}`;
|
||||
|
||||
export const buildDiscoveredRows = (upstreamIds: readonly string[]): DiscoveredModelRow[] =>
|
||||
upstreamIds.map((upstreamId, index) => ({
|
||||
id: `discovered-${index}-${upstreamId}`,
|
||||
upstreamId,
|
||||
modelName: upstreamId,
|
||||
enabled: true,
|
||||
alternateNames: [],
|
||||
manual: false,
|
||||
}));
|
||||
|
||||
export const buildManualRow = (upstreamId: string): DiscoveredModelRow => ({
|
||||
id: `manual-${upstreamId}-${Math.random().toString(36).slice(2)}`,
|
||||
upstreamId,
|
||||
modelName: upstreamId,
|
||||
enabled: true,
|
||||
alternateNames: [],
|
||||
manual: true,
|
||||
});
|
||||
|
||||
export interface CreationResult {
|
||||
row: DiscoveredModelRow;
|
||||
status: "created" | "skipped" | "failed";
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface ModelCreationPayload {
|
||||
model_name: string;
|
||||
litellm_params: { model: string; litellm_credential_name: string };
|
||||
model_info: Record<string, never>;
|
||||
blocked: boolean;
|
||||
}
|
||||
|
||||
/** blocked is the inverse of the review table's enabled toggle -- a disabled row is still
|
||||
* created, just paused, so a later enable is a one-click unblock instead of re-discovery. */
|
||||
export const buildModelCreationPayload = (
|
||||
litellmProvider: string,
|
||||
credentialName: string,
|
||||
row: DiscoveredModelRow,
|
||||
): ModelCreationPayload => ({
|
||||
model_name: row.modelName,
|
||||
litellm_params: {
|
||||
model: litellmModelForUpstreamId(litellmProvider, row.upstreamId),
|
||||
litellm_credential_name: credentialName,
|
||||
},
|
||||
model_info: {},
|
||||
blocked: !row.enabled,
|
||||
});
|
||||
|
||||
const deploymentKey = (credentialName: string, litellmModel: string): string => `${credentialName}::${litellmModel}`;
|
||||
|
||||
/**
|
||||
* Rows not yet created for this credential, matched against every existing deployment by
|
||||
* (litellm_credential_name, litellm_params.model) -- the partial-failure-recovery key: re-running
|
||||
* the wizard after some rows already landed must not attempt to recreate them.
|
||||
*/
|
||||
export const rowsPendingCreation = (
|
||||
rows: readonly DiscoveredModelRow[],
|
||||
litellmProvider: string,
|
||||
credentialName: string,
|
||||
existingDeployments: readonly DeploymentInfoRow[],
|
||||
): DiscoveredModelRow[] => {
|
||||
const existingKeys = new Set(
|
||||
existingDeployments
|
||||
.filter((deployment) => deployment.litellm_params.litellm_credential_name === credentialName)
|
||||
.map((deployment) => deploymentKey(credentialName, deployment.litellm_params.model ?? "")),
|
||||
);
|
||||
return rows.filter(
|
||||
(row) =>
|
||||
!existingKeys.has(deploymentKey(credentialName, litellmModelForUpstreamId(litellmProvider, row.upstreamId))),
|
||||
);
|
||||
};
|
||||
|
||||
export type ModelGroupAliasValue = string | { model: string; hidden?: boolean };
|
||||
export type ModelGroupAliasMap = Record<string, ModelGroupAliasValue>;
|
||||
|
||||
export interface AliasAddition {
|
||||
alias: string;
|
||||
targetModelGroup: string;
|
||||
}
|
||||
|
||||
export interface AliasMergeResult {
|
||||
merged: ModelGroupAliasMap;
|
||||
collisions: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges new alias entries into the existing model_group_alias map for a single
|
||||
* /config/update write. Never touches an existing key's value (object-valued {model, hidden}
|
||||
* entries survive untouched) and never overwrites a name collision -- a colliding alias is
|
||||
* reported back rather than silently dropped or silently replacing what was there.
|
||||
*/
|
||||
export const mergeModelGroupAliases = (
|
||||
existing: ModelGroupAliasMap,
|
||||
additions: readonly AliasAddition[],
|
||||
): AliasMergeResult => {
|
||||
const collisions: string[] = [];
|
||||
const added: ModelGroupAliasMap = {};
|
||||
for (const { alias, targetModelGroup } of additions) {
|
||||
if (alias in existing || alias in added) {
|
||||
collisions.push(alias);
|
||||
continue;
|
||||
}
|
||||
added[alias] = targetModelGroup;
|
||||
}
|
||||
return { merged: { ...existing, ...added }, collisions };
|
||||
};
|
||||
|
||||
/** Flattens the review table's per-row alternate names into the alias additions
|
||||
* mergeModelGroupAliases expects, skipping rows the operator removed/left disabled-but-empty. */
|
||||
export const aliasAdditionsFromRows = (rows: readonly DiscoveredModelRow[]): AliasAddition[] =>
|
||||
rows.flatMap((row) => row.alternateNames.map((alias) => ({ alias, targetModelGroup: row.modelName })));
|
||||
|
|
@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||
import { Providers } from "../provider_info_helpers";
|
||||
import { CredentialItem } from "../networking";
|
||||
import CredentialModal from "./CredentialModal";
|
||||
import { chooseSelectOption } from "../../../tests/test-utils";
|
||||
|
||||
vi.mock("../networking", async () => {
|
||||
const actual = await vi.importActual("../networking");
|
||||
|
|
@ -51,6 +52,9 @@ vi.mock("../networking", async () => {
|
|||
{ key: "api_key", label: "Anthropic API Key", field_type: "password" },
|
||||
{ key: "anthropic_federation_rule_id", label: "Federation Rule ID", field_type: "text", required: true },
|
||||
{ key: "anthropic_organization_id", label: "Organization ID", field_type: "text", required: true },
|
||||
{ key: "anthropic_issuer_url", label: "Issuer URL", field_type: "text", required: true },
|
||||
{ key: "anthropic_issuer_subject", label: "Subject", field_type: "text", required: true },
|
||||
{ key: "anthropic_issuer_signing_key_ref", label: "Signing Key Reference", field_type: "text" },
|
||||
{
|
||||
key: "anthropic_identity_token",
|
||||
label: "Identity Token Reference",
|
||||
|
|
@ -66,6 +70,18 @@ vi.mock("../networking", async () => {
|
|||
field_keys: ["anthropic_federation_rule_id", "anthropic_organization_id", "anthropic_identity_token"],
|
||||
fixed_values: {},
|
||||
},
|
||||
{
|
||||
id: "wif_internal_issuer",
|
||||
label: "Workload Identity Federation (LiteLLM-signed)",
|
||||
field_keys: [
|
||||
"anthropic_issuer_url",
|
||||
"anthropic_issuer_subject",
|
||||
"anthropic_issuer_signing_key_ref",
|
||||
"anthropic_organization_id",
|
||||
"anthropic_federation_rule_id",
|
||||
],
|
||||
fixed_values: { anthropic_identity_source: "internal_issuer" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
@ -97,7 +113,15 @@ const mockCredential: CredentialItem = {
|
|||
const renderModal = (props: Partial<React.ComponentProps<typeof CredentialModal>> = {}) =>
|
||||
render(
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<CredentialModal open={true} mode="add" onCancel={vi.fn()} onSubmit={vi.fn()} {...props} />
|
||||
<CredentialModal
|
||||
open={true}
|
||||
mode="add"
|
||||
onCancel={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
testConnection={vi.fn().mockResolvedValue({ models: [] })}
|
||||
loadJwks={vi.fn().mockResolvedValue({ keys: [] })}
|
||||
{...props}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
|
|
@ -151,6 +175,130 @@ describe("CredentialModal", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("footer", () => {
|
||||
it("puts Cancel on the left and Test Connection beside the submit button on the right", () => {
|
||||
renderModal({ mode: "add" });
|
||||
|
||||
const cancel = screen.getByRole("button", { name: "Cancel" });
|
||||
const testConnection = screen.getByRole("button", { name: "Test Connection" });
|
||||
const submit = screen.getByRole("button", { name: "Add Credential" });
|
||||
expect(cancel.compareDocumentPosition(testConnection) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(testConnection.compareDocumentPosition(submit) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes on Cancel without submitting the form", () => {
|
||||
const onCancel = vi.fn();
|
||||
const onSubmit = vi.fn();
|
||||
renderModal({ mode: "add", onCancel, onSubmit });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("test connection", () => {
|
||||
it("tests an entered API key inline before the credential is saved", async () => {
|
||||
const testConnection = vi.fn().mockResolvedValue({ models: ["gpt-5.5", "gpt-5.5-mini"] });
|
||||
renderModal({ mode: "add", testConnection });
|
||||
|
||||
fireEvent.change(await screen.findByLabelText("OpenAI API Key"), { target: { value: "sk-test" } });
|
||||
const button = screen.getByRole("button", { name: "Test Connection" });
|
||||
await waitFor(() => expect(button).toBeEnabled());
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Connection succeeded. 2 models available: gpt-5.5, gpt-5.5-mini.",
|
||||
);
|
||||
expect(testConnection).toHaveBeenCalledWith({ custom_llm_provider: "openai", api_key: "sk-test" });
|
||||
});
|
||||
|
||||
it("keeps workload identity values out of an inline test and points at saving first", async () => {
|
||||
const testConnection = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderModal({ mode: "add", testConnection });
|
||||
|
||||
const providerInput = screen.getByRole("combobox", { name: "Provider:" });
|
||||
await user.click(providerInput);
|
||||
fireEvent.change(providerInput, { target: { value: "Anthropic" } });
|
||||
await user.click(await screen.findByRole("option", { name: (_, option) => option.textContent === "Anthropic" }));
|
||||
await chooseSelectOption(
|
||||
user,
|
||||
await screen.findByRole("combobox", { name: "Authentication method" }),
|
||||
"Workload Identity Federation (LiteLLM-signed)",
|
||||
);
|
||||
fireEvent.change(await screen.findByLabelText("Issuer URL"), {
|
||||
target: { value: "https://litellm.example.com" },
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/^Add the credential first, then test it from Edit/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Test Connection" })).toBeDisabled();
|
||||
expect(testConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tests a saved credential by name and surfaces the proxy error", async () => {
|
||||
const testConnection = vi.fn().mockRejectedValue(new Error("Model discovery failed: invalid x-api-key"));
|
||||
renderModal({ mode: "edit", existingCredential: mockCredential, testConnection });
|
||||
|
||||
await screen.findByLabelText("OpenAI API Key");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Test Connection" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Model discovery failed: invalid x-api-key");
|
||||
expect(testConnection).toHaveBeenCalledWith({
|
||||
custom_llm_provider: "openai",
|
||||
litellm_credential_name: "test-credential",
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for unsaved edits to be saved before testing a saved credential", async () => {
|
||||
const testConnection = vi.fn();
|
||||
renderModal({ mode: "edit", existingCredential: mockCredential, testConnection });
|
||||
|
||||
fireEvent.change(await screen.findByLabelText("OpenAI API Key"), { target: { value: "sk-changed" } });
|
||||
|
||||
expect(
|
||||
await screen.findByText("Update the credential first. Test Connection checks the saved values."),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Test Connection" })).toBeDisabled();
|
||||
expect(testConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("LiteLLM-signed JWKS", () => {
|
||||
const signedCredential: CredentialItem = {
|
||||
credential_name: "anthropic-signed",
|
||||
credential_values: {
|
||||
anthropic_identity_source: "internal_issuer",
|
||||
anthropic_issuer_url: "https://litellm.example.com",
|
||||
anthropic_issuer_subject: "litellm-proxy",
|
||||
anthropic_issuer_signing_key_ref: "os.environ/ISSUER_SIGNING_KEY_PEM",
|
||||
anthropic_organization_id: "",
|
||||
anthropic_federation_rule_id: "",
|
||||
},
|
||||
credential_info: { custom_llm_provider: Providers.Anthropic },
|
||||
};
|
||||
|
||||
it("shows the saved credential's public JWKS with a copy button", async () => {
|
||||
const loadJwks = vi.fn().mockResolvedValue({ keys: [{ kty: "RSA", kid: "k-2026", n: "abc", e: "AQAB" }] });
|
||||
renderModal({ mode: "edit", existingCredential: signedCredential, loadJwks });
|
||||
|
||||
expect(await screen.findByText(/"kid": "k-2026"/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Copy JWKS" })).toBeInTheDocument();
|
||||
expect(loadJwks).toHaveBeenCalledWith("anthropic-signed");
|
||||
});
|
||||
|
||||
it("does not request a JWKS for a credential that is not LiteLLM-signed", async () => {
|
||||
const loadJwks = vi.fn();
|
||||
renderModal({ mode: "edit", existingCredential: mockCredential, loadJwks });
|
||||
|
||||
await screen.findByLabelText("OpenAI API Key");
|
||||
|
||||
expect(screen.queryByText("Public JWKS")).not.toBeInTheDocument();
|
||||
expect(loadJwks).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("credential_values_to_delete", () => {
|
||||
const anthropicWifCredential: CredentialItem = {
|
||||
credential_name: "anthropic-wif-cred",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,20 @@
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { FieldDescription, FieldLegend, FieldSet } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { SimpleTooltip } from "@/components/ui/tooltip";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import CopyButton from "@/components/shared/CopyButton";
|
||||
import { SearchSelect, type SearchSelectOption } from "@/components/shared/SearchSelect";
|
||||
import { labelWithHint } from "@/components/shared/form/LabelWithHint";
|
||||
import { extractProxyErrorMessage } from "@/lib/http/client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CircleCheck, CircleX } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { FormProvider, useForm, useWatch } from "react-hook-form";
|
||||
import ProviderSpecificFields from "../add_model/provider_specific_fields";
|
||||
import { requiredRule } from "../common_components/formRules";
|
||||
import { labelWithHint } from "@/components/shared/form/LabelWithHint";
|
||||
import {
|
||||
MountedFormField,
|
||||
MountedFormProvider,
|
||||
|
|
@ -14,11 +22,19 @@ import {
|
|||
useMountRegistry,
|
||||
type MountedFormValues,
|
||||
} from "../common_components/MountedFormField";
|
||||
import { CredentialItem } from "../networking";
|
||||
import type {
|
||||
AnthropicJwks,
|
||||
CredentialItem,
|
||||
ProviderModelDiscoveryRequest,
|
||||
ProviderModelDiscoveryResponse,
|
||||
} from "../networking";
|
||||
import { Providers } from "../provider_info_helpers";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import { computeCredentialValuesToDelete, resetCredentialFormOnProviderChange } from "./credential_form_helpers";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import {
|
||||
computeCredentialValuesToDelete,
|
||||
planCredentialTest,
|
||||
resetCredentialFormOnProviderChange,
|
||||
summarizeDiscoveredModels,
|
||||
} from "./credential_form_helpers";
|
||||
|
||||
const providerOptions: SearchSelectOption[] = Object.entries(Providers).map(([providerEnum, providerDisplayName]) => ({
|
||||
label: providerDisplayName,
|
||||
|
|
@ -26,12 +42,49 @@ const providerOptions: SearchSelectOption[] = Object.entries(Providers).map(([pr
|
|||
icon: <Logo provider={providerEnum} label={providerDisplayName} className="w-5 h-5" />,
|
||||
}));
|
||||
|
||||
type ConnectionTest =
|
||||
| { readonly kind: "idle" }
|
||||
| { readonly kind: "testing" }
|
||||
| { readonly kind: "success"; readonly message: string }
|
||||
| { readonly kind: "failure"; readonly message: string };
|
||||
|
||||
interface CredentialModalProps {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (values: any, credentialValuesToDelete: string[]) => void;
|
||||
mode: "add" | "edit";
|
||||
existingCredential?: CredentialItem | null;
|
||||
testConnection: (request: ProviderModelDiscoveryRequest) => Promise<ProviderModelDiscoveryResponse>;
|
||||
loadJwks: (credentialName: string) => Promise<AnthropicJwks>;
|
||||
}
|
||||
|
||||
function JwksExport({
|
||||
credentialName,
|
||||
loadJwks,
|
||||
}: {
|
||||
credentialName: string;
|
||||
loadJwks: CredentialModalProps["loadJwks"];
|
||||
}) {
|
||||
const jwks = useQuery({ queryKey: ["credential-jwks", credentialName], queryFn: () => loadJwks(credentialName) });
|
||||
const jwksText = jwks.data ? JSON.stringify(jwks.data, null, 2) : null;
|
||||
return (
|
||||
<FieldSet className="mb-4">
|
||||
<FieldLegend variant="label">Public JWKS</FieldLegend>
|
||||
<FieldDescription>
|
||||
In the Claude Console, open Settings, then Workload identity, click Connect workload, choose Custom OIDC and
|
||||
paste this key set as the inline JWKS together with the Issuer URL and Subject above. Copy the Organization ID
|
||||
and Federation Rule ID the Console shows into the fields above, update the credential, then test the connection.
|
||||
</FieldDescription>
|
||||
{jwks.isPending && <p className="text-sm text-muted-foreground">Loading JWKS...</p>}
|
||||
{jwks.isError && <p className="text-sm text-destructive">{extractProxyErrorMessage(jwks.error)}</p>}
|
||||
{jwksText && (
|
||||
<div className="relative rounded-md border bg-muted p-3">
|
||||
<CopyButton value={jwksText} label="Copy JWKS" className="absolute top-2 right-2" />
|
||||
<pre className="overflow-x-auto text-xs">{jwksText}</pre>
|
||||
</div>
|
||||
)}
|
||||
</FieldSet>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CredentialModal({
|
||||
|
|
@ -40,11 +93,14 @@ export default function CredentialModal({
|
|||
onSubmit,
|
||||
mode,
|
||||
existingCredential = null,
|
||||
testConnection,
|
||||
loadJwks,
|
||||
}: CredentialModalProps) {
|
||||
const isEdit = mode === "edit";
|
||||
const [selectedProvider, setSelectedProvider] = useState<Providers>(
|
||||
(existingCredential?.credential_info.custom_llm_provider as Providers) ?? Providers.OpenAI,
|
||||
);
|
||||
const [connectionTest, setConnectionTest] = useState<ConnectionTest>({ kind: "idle" });
|
||||
|
||||
const initialValues = existingCredential
|
||||
? {
|
||||
|
|
@ -58,6 +114,19 @@ export default function CredentialModal({
|
|||
|
||||
const form = useForm<MountedFormValues>({ mode: "onChange", defaultValues: initialValues });
|
||||
const registry = useMountRegistry();
|
||||
useWatch({ control: form.control });
|
||||
const testInput = {
|
||||
mode,
|
||||
provider: selectedProvider,
|
||||
credentialName: existingCredential?.credential_name ?? "",
|
||||
mountedValues: projectMountedValues(registry, form.getValues),
|
||||
hasUnsavedChanges: form.formState.isDirty,
|
||||
};
|
||||
const testPlan = planCredentialTest(testInput);
|
||||
const jwksCredentialName =
|
||||
isEdit && existingCredential?.credential_values?.anthropic_identity_source === "internal_issuer"
|
||||
? existingCredential.credential_name
|
||||
: null;
|
||||
|
||||
const formAdapter = {
|
||||
getFieldValue: (field: string) => form.getValues(field),
|
||||
|
|
@ -84,6 +153,19 @@ export default function CredentialModal({
|
|||
form.reset();
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (testPlan.kind !== "ready") {
|
||||
return;
|
||||
}
|
||||
setConnectionTest({ kind: "testing" });
|
||||
try {
|
||||
const { models } = await testConnection(testPlan.request);
|
||||
setConnectionTest({ kind: "success", message: summarizeDiscoveredModels(models) });
|
||||
} catch (error) {
|
||||
setConnectionTest({ kind: "failure", message: extractProxyErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const closeAndReset = () => {
|
||||
onCancel();
|
||||
form.reset();
|
||||
|
|
@ -145,17 +227,48 @@ export default function CredentialModal({
|
|||
|
||||
<ProviderSpecificFields selectedProvider={selectedProvider} />
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<SimpleTooltip content="Get help on our github">
|
||||
<a href="https://github.com/BerriAI/litellm/issues" className="text-sm text-primary hover:underline">
|
||||
Need Help?
|
||||
</a>
|
||||
</SimpleTooltip>
|
||||
{jwksCredentialName && <JwksExport credentialName={jwksCredentialName} loadJwks={loadJwks} />}
|
||||
|
||||
<div>
|
||||
<Button variant="outline" className="mr-2.5" onClick={closeAndReset}>
|
||||
{connectionTest.kind === "success" && (
|
||||
<Alert className="mb-4">
|
||||
<CircleCheck />
|
||||
<AlertDescription>{connectionTest.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{connectionTest.kind === "failure" && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<CircleX />
|
||||
<AlertDescription>{connectionTest.message}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{testPlan.kind === "unavailable" && (
|
||||
<p className="mb-2 text-right text-xs text-muted-foreground">{testPlan.reason}</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button type="button" variant="outline" onClick={closeAndReset}>
|
||||
Cancel
|
||||
</Button>
|
||||
<SimpleTooltip content="Get help on our github">
|
||||
<a
|
||||
href="https://github.com/BerriAI/litellm/issues"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Need Help?
|
||||
</a>
|
||||
</SimpleTooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={testPlan.kind !== "ready" || connectionTest.kind === "testing"}
|
||||
onClick={() => void handleTestConnection()}
|
||||
>
|
||||
{connectionTest.kind === "testing" ? "Testing..." : "Test Connection"}
|
||||
</Button>
|
||||
<Button type="submit">{isEdit ? "Update Credential" : "Add Credential"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ import {
|
|||
credentialDeleteCall,
|
||||
CredentialItem,
|
||||
credentialUpdateCall,
|
||||
discoverProviderModelsCall,
|
||||
getCredentialJwksCall,
|
||||
type ProviderModelDiscoveryRequest,
|
||||
} from "@/components/networking";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { stripMaskedSecrets } from "@/utils/maskedSecretUtils";
|
||||
|
|
@ -35,6 +38,10 @@ const withoutRestrictedFields = (values: Record<string, unknown>): Record<string
|
|||
|
||||
export default function CredentialsPanel() {
|
||||
const { accessToken, userRole } = useAuthorized();
|
||||
const testConnection = (request: ProviderModelDiscoveryRequest) =>
|
||||
accessToken ? discoverProviderModelsCall(accessToken, request) : Promise.reject(new Error("Not signed in"));
|
||||
const loadJwks = (credentialName: string) =>
|
||||
accessToken ? getCredentialJwksCall(accessToken, credentialName) : Promise.reject(new Error("Not signed in"));
|
||||
// Admin Viewer follows the read-parity rule: see credentials, do not modify.
|
||||
const canModifyCredentials = isProxyAdminRole(userRole ?? "");
|
||||
const { data: credentialsResponse, isLoading, refetch: refetchCredentials } = useCredentials();
|
||||
|
|
@ -141,6 +148,8 @@ export default function CredentialsPanel() {
|
|||
onSubmit={handleAddCredential}
|
||||
open={isAddModalOpen}
|
||||
onCancel={() => setIsAddModalOpen(false)}
|
||||
testConnection={testConnection}
|
||||
loadJwks={loadJwks}
|
||||
/>
|
||||
)}
|
||||
{isUpdateModalOpen && (
|
||||
|
|
@ -150,6 +159,8 @@ export default function CredentialsPanel() {
|
|||
existingCredential={selectedCredential}
|
||||
onSubmit={handleUpdateCredential}
|
||||
onCancel={() => setIsUpdateModalOpen(false)}
|
||||
testConnection={testConnection}
|
||||
loadJwks={loadJwks}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Providers } from "../provider_info_helpers";
|
||||
import { computeCredentialValuesToDelete, resetCredentialFormOnProviderChange } from "./credential_form_helpers";
|
||||
import {
|
||||
computeCredentialValuesToDelete,
|
||||
type CredentialTestInput,
|
||||
litellmProviderId,
|
||||
planCredentialTest,
|
||||
resetCredentialFormOnProviderChange,
|
||||
summarizeDiscoveredModels,
|
||||
} from "./credential_form_helpers";
|
||||
|
||||
/**
|
||||
* Build a minimal FormInstance stub that records calls. We don't depend
|
||||
|
|
@ -117,3 +124,97 @@ describe("computeCredentialValuesToDelete", () => {
|
|||
expect(computeCredentialValuesToDelete({}, { api_key: "sk-new" })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("litellmProviderId", () => {
|
||||
it("maps a dashboard provider key to the litellm provider id", () => {
|
||||
expect(litellmProviderId("Anthropic")).toBe("anthropic");
|
||||
expect(litellmProviderId("Google_AI_Studio")).toBe("gemini");
|
||||
});
|
||||
|
||||
it("passes a litellm provider id stored by curl or the API through unchanged", () => {
|
||||
expect(litellmProviderId("anthropic")).toBe("anthropic");
|
||||
});
|
||||
});
|
||||
|
||||
describe("planCredentialTest", () => {
|
||||
const planWith = (overrides: Partial<CredentialTestInput>) => {
|
||||
const input: CredentialTestInput = {
|
||||
mode: "add",
|
||||
provider: "OpenAI",
|
||||
credentialName: "",
|
||||
mountedValues: {},
|
||||
hasUnsavedChanges: true,
|
||||
...overrides,
|
||||
};
|
||||
return planCredentialTest(input);
|
||||
};
|
||||
const addWith = (mountedValues: Record<string, unknown>) => planWith({ mountedValues });
|
||||
|
||||
it("sends only the entered api_key and api_base inline before saving", () => {
|
||||
const keyOnlyForm = { credential_name: "prod", custom_llm_provider: "OpenAI", api_key: "sk-x", api_base: "" };
|
||||
expect(addWith(keyOnlyForm)).toEqual({
|
||||
kind: "ready",
|
||||
request: { custom_llm_provider: "openai", api_key: "sk-x" },
|
||||
});
|
||||
expect(addWith({ api_key: "sk-x", api_base: "https://proxy.example.com/v1" })).toEqual({
|
||||
kind: "ready",
|
||||
request: { custom_llm_provider: "openai", api_key: "sk-x", api_base: "https://proxy.example.com/v1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("asks for values before anything has been entered", () => {
|
||||
expect(addWith({ credential_name: "prod", custom_llm_provider: "OpenAI", api_key: null })).toEqual({
|
||||
kind: "unavailable",
|
||||
reason: "Fill in the credential values first.",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to send server-owned values inline and points at saving first", () => {
|
||||
const plan = planWith({
|
||||
provider: "Anthropic",
|
||||
mountedValues: { anthropic_identity_source: "internal_issuer", anthropic_issuer_url: "", api_base: "" },
|
||||
});
|
||||
expect(plan.kind).toBe("unavailable");
|
||||
expect(plan.kind === "unavailable" && plan.reason).toMatch(/^Add the credential first/);
|
||||
});
|
||||
|
||||
it("tests a saved credential by name so its values never leave the proxy", () => {
|
||||
const savedWif: Partial<CredentialTestInput> = {
|
||||
mode: "edit",
|
||||
provider: "Anthropic",
|
||||
credentialName: "anthropic-wif",
|
||||
mountedValues: { anthropic_federation_rule_id: "fdrl_1", anthropic_organization_id: "org-1" },
|
||||
hasUnsavedChanges: false,
|
||||
};
|
||||
expect(planWith(savedWif)).toEqual({
|
||||
kind: "ready",
|
||||
request: { custom_llm_provider: "anthropic", litellm_credential_name: "anthropic-wif" },
|
||||
});
|
||||
});
|
||||
|
||||
it("requires unsaved edits to be saved before a by-name test", () => {
|
||||
const editedWif: Partial<CredentialTestInput> = {
|
||||
mode: "edit",
|
||||
provider: "Anthropic",
|
||||
credentialName: "anthropic-wif",
|
||||
mountedValues: { api_key: "sk-new" },
|
||||
};
|
||||
expect(planWith(editedWif)).toEqual({
|
||||
kind: "unavailable",
|
||||
reason: "Update the credential first. Test Connection checks the saved values.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeDiscoveredModels", () => {
|
||||
it("reports an empty discovery as a success without models", () => {
|
||||
expect(summarizeDiscoveredModels([])).toBe("Connection succeeded, but the provider returned no models.");
|
||||
});
|
||||
|
||||
it("lists up to three models and counts the rest", () => {
|
||||
expect(summarizeDiscoveredModels(["a"])).toBe("Connection succeeded. 1 model available: a.");
|
||||
expect(summarizeDiscoveredModels(["a", "b", "c", "d", "e"])).toBe(
|
||||
"Connection succeeded. 5 models available: a, b, c and 2 more.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Providers } from "../provider_info_helpers";
|
||||
import type { ProviderModelDiscoveryRequest } from "../networking";
|
||||
import { provider_map, Providers } from "../provider_info_helpers";
|
||||
|
||||
interface CredentialFormAdapter {
|
||||
getFieldValue: (field: string) => unknown;
|
||||
|
|
@ -65,3 +66,63 @@ export function computeCredentialValuesToDelete(
|
|||
return value === "" || value === null || value === undefined;
|
||||
});
|
||||
}
|
||||
|
||||
export const litellmProviderId = (provider: string): string => provider_map[provider] ?? provider;
|
||||
|
||||
const INLINE_TESTABLE_KEYS: ReadonlySet<string> = new Set(["api_key", "api_base"]);
|
||||
const FORM_META_KEYS: ReadonlySet<string> = new Set(["credential_name", "custom_llm_provider"]);
|
||||
|
||||
export type CredentialTestPlan =
|
||||
| { readonly kind: "ready"; readonly request: ProviderModelDiscoveryRequest }
|
||||
| { readonly kind: "unavailable"; readonly reason: string };
|
||||
|
||||
export interface CredentialTestInput {
|
||||
readonly mode: "add" | "edit";
|
||||
readonly provider: string;
|
||||
readonly credentialName: string;
|
||||
readonly mountedValues: Record<string, unknown>;
|
||||
readonly hasUnsavedChanges: boolean;
|
||||
}
|
||||
|
||||
const isBlank = (value: unknown): boolean => value === "" || value === null || value === undefined;
|
||||
|
||||
/**
|
||||
* Decide what Test Connection sends to POST /provider/models/discover. A saved credential is tested
|
||||
* by name so its values never leave the proxy. Before saving, only api_key and api_base may travel
|
||||
* inline: every other credential value (federation ids, signing key refs, cloud keys) is server-owned
|
||||
* and the route refuses it in a request body, so those are tested from Edit after saving.
|
||||
*/
|
||||
export function planCredentialTest(input: CredentialTestInput): CredentialTestPlan {
|
||||
const custom_llm_provider = litellmProviderId(input.provider);
|
||||
if (input.mode === "edit") {
|
||||
return input.hasUnsavedChanges
|
||||
? { kind: "unavailable", reason: "Update the credential first. Test Connection checks the saved values." }
|
||||
: { kind: "ready", request: { custom_llm_provider, litellm_credential_name: input.credentialName } };
|
||||
}
|
||||
const entered = Object.entries(input.mountedValues).filter(
|
||||
([key, value]) => !FORM_META_KEYS.has(key) && !isBlank(value),
|
||||
);
|
||||
if (entered.length === 0) {
|
||||
return { kind: "unavailable", reason: "Fill in the credential values first." };
|
||||
}
|
||||
if (!entered.every(([key]) => INLINE_TESTABLE_KEYS.has(key))) {
|
||||
return {
|
||||
kind: "unavailable",
|
||||
reason:
|
||||
"Add the credential first, then test it from Edit. Only an API key and API base can be tested before saving.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "ready",
|
||||
request: { custom_llm_provider, ...Object.fromEntries(entered.map(([key, value]) => [key, String(value)])) },
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeDiscoveredModels(models: readonly string[]): string {
|
||||
if (models.length === 0) {
|
||||
return "Connection succeeded, but the provider returned no models.";
|
||||
}
|
||||
const shown = models.slice(0, 3).join(", ");
|
||||
const rest = models.length > 3 ? ` and ${models.length - 3} more` : "";
|
||||
return `Connection succeeded. ${models.length} model${models.length === 1 ? "" : "s"} available: ${shown}${rest}.`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1858,20 +1858,6 @@ export const modelInfoV1Call = async (accessToken: string, modelId: string) => {
|
|||
}
|
||||
};
|
||||
|
||||
export interface DeploymentInfoRow {
|
||||
model_name: string;
|
||||
litellm_params: { model?: string; litellm_credential_name?: string };
|
||||
model_info: { id: string };
|
||||
}
|
||||
|
||||
export const listAllModelsCall = async (accessToken: string): Promise<{ data: DeploymentInfoRow[] }> => {
|
||||
/**
|
||||
* Every deployment the caller can see, unpaginated. Used to dedupe model creation against
|
||||
* what already exists (e.g. when resuming a partially-failed Add Provider wizard run).
|
||||
*/
|
||||
return await apiClient.get(`/model/info`, { accessToken });
|
||||
};
|
||||
|
||||
export const modelHubPublicModelsCall = async () => {
|
||||
const url = proxyBaseUrl ? `${proxyBaseUrl}/public/model_hub` : `/public/model_hub`;
|
||||
const response = await fetch(url, {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue