mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-14 04:09:09 +00:00
fix(credentials): backport credential edit fixes to rc/1.106.0 (#45890)
Backport #45835 from b2cc5f25f6
Keep RC provider support while adapting credential form callbacks and regression tests
This commit is contained in:
parent
1bdfb67892
commit
96b182ff42
19 changed files with 606 additions and 291 deletions
|
|
@ -2,6 +2,7 @@
|
|||
CRUD endpoints for storing reusable credentials.
|
||||
"""
|
||||
|
||||
import unicodedata
|
||||
from collections.abc import Mapping
|
||||
from typing import (
|
||||
Annotated,
|
||||
|
|
@ -37,6 +38,7 @@ from litellm.proxy.common_utils.credential_hydration import (
|
|||
stored_credential_provider,
|
||||
)
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper
|
||||
from litellm.proxy.db.routing_prisma_wrapper import WriterPinnedClient
|
||||
from litellm.proxy.utils import handle_exception_on_proxy, jsonify_object
|
||||
from litellm.repositories.base_repository import is_unique_violation
|
||||
from litellm.repositories.credentials_repository import CredentialsRepository
|
||||
|
|
@ -46,6 +48,7 @@ from litellm.types.utils import CreateCredentialItem, CredentialItem
|
|||
router: Final = APIRouter()
|
||||
_CREDENTIAL_DICT_ADAPTER: Final = TypeAdapter(dict[str, object])
|
||||
_DISPLAY_NAME_MAX_LENGTH: Final = 255
|
||||
_DISPLAY_NAME_FORBIDDEN_CATEGORIES: Final = frozenset({"Cc", "Cf", "Cs", "Zl", "Zp"})
|
||||
|
||||
|
||||
def _reject_non_admin_wif_fields(
|
||||
|
|
@ -171,9 +174,28 @@ def _normalized_display_name(display_name: str | None) -> str | None:
|
|||
code=status.HTTP_400_BAD_REQUEST,
|
||||
param="display_name",
|
||||
)
|
||||
if any(unicodedata.category(character) in _DISPLAY_NAME_FORBIDDEN_CATEGORIES for character in trimmed):
|
||||
raise ProxyException(
|
||||
message="display_name cannot contain control, formatting, or line separator characters.",
|
||||
type=ProxyErrorTypes.validation_error.value,
|
||||
code=status.HTTP_400_BAD_REQUEST,
|
||||
param="display_name",
|
||||
)
|
||||
return trimmed
|
||||
|
||||
|
||||
def _reject_config_defined_name(credential_name: str) -> None:
|
||||
in_memory: Final = CredentialAccessor.find_credential(credential_name)
|
||||
if in_memory is None or in_memory.source != "config":
|
||||
return
|
||||
raise ProxyException(
|
||||
message=f"Credential '{credential_name}' is defined in config. Edit config.yaml to change it.",
|
||||
type=ProxyErrorTypes.validation_error.value,
|
||||
code=status.HTTP_409_CONFLICT,
|
||||
param="credential_name",
|
||||
)
|
||||
|
||||
|
||||
def _not_found_unless_config_defined(credential_name: str, not_found_detail: str) -> HTTPException | ProxyException:
|
||||
in_memory: Final = CredentialAccessor.find_credential(credential_name)
|
||||
if in_memory is None or in_memory.source != "config":
|
||||
|
|
@ -263,6 +285,7 @@ async def create_credential(
|
|||
_reject_non_admin_wif_fields(
|
||||
await named_credential_wif_fields(credential.credential_name, prisma_client), user_api_key_dict
|
||||
)
|
||||
_reject_config_defined_name(credential.credential_name)
|
||||
processed_credential: Final = CredentialItem(
|
||||
credential_name=credential.credential_name,
|
||||
display_name=_normalized_display_name(credential.display_name),
|
||||
|
|
@ -565,7 +588,7 @@ async def update_credential(
|
|||
status_code=500,
|
||||
detail={"error": CommonProxyErrors.db_not_connected_error.value},
|
||||
)
|
||||
credentials_repository: Final = CredentialsRepository(prisma_client)
|
||||
credentials_repository: Final = CredentialsRepository(WriterPinnedClient(prisma_client.db))
|
||||
db_credential: Final = await credentials_repository.find_by_name(credential_name)
|
||||
if db_credential is None:
|
||||
raise _not_found_unless_config_defined(credential_name, "Credential not found in DB.")
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import json
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -183,6 +184,56 @@ def test_update_credential_still_answers_200_on_a_successful_write(credential_st
|
|||
assert response.json()["success"] is True
|
||||
|
||||
|
||||
def _credential_row(display_name: str, api_key: str, name: str = "replicated") -> dict:
|
||||
return {
|
||||
"credential_name": name,
|
||||
"display_name": display_name,
|
||||
"credential_values": {"api_key": api_key},
|
||||
"credential_info": {"custom_llm_provider": "openai"},
|
||||
}
|
||||
|
||||
|
||||
def _credentials_table(row: dict | None) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
find_many=AsyncMock(),
|
||||
create=AsyncMock(),
|
||||
find_unique=AsyncMock(return_value=row),
|
||||
update=AsyncMock(return_value=None),
|
||||
)
|
||||
|
||||
|
||||
def test_update_credential_merges_onto_the_writer_row_not_a_lagging_replica(restore_credential_list):
|
||||
"""Regression: PATCH read the row through the read replica and wrote the merged row to the
|
||||
writer, so an edit inside the replica lag window wrote back the replica's older label and
|
||||
secrets, undoing a relabel or key rotation that had already committed."""
|
||||
from litellm.proxy.db.prisma_client import PrismaWrapper
|
||||
from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper
|
||||
|
||||
writer_table = _credentials_table(_credential_row("Relabeled", "sk-B"))
|
||||
reader_table = _credentials_table(_credential_row("Original", "sk-A"))
|
||||
writer_inner = SimpleNamespace(litellm_credentialstable=writer_table)
|
||||
reader_inner = SimpleNamespace(litellm_credentialstable=reader_table)
|
||||
prisma_client = MagicMock()
|
||||
prisma_client.db = RoutingPrismaWrapper(
|
||||
writer=PrismaWrapper(original_prisma=writer_inner, iam_token_db_auth=False),
|
||||
reader=PrismaWrapper(original_prisma=reader_inner, iam_token_db_auth=False),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", prisma_client),
|
||||
patch("litellm.proxy.proxy_server.master_key", "sk-test-master"),
|
||||
):
|
||||
response = _patch_credential(
|
||||
"replicated", {"credential_values": {"api_base": "https://example.test/v1"}, "credential_info": {}}
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
written = writer_table.update.await_args.kwargs["data"]
|
||||
assert written["display_name"] == "Relabeled"
|
||||
assert json.loads(written["credential_values"])["api_key"] == "sk-B"
|
||||
reader_table.find_unique.assert_not_awaited()
|
||||
|
||||
|
||||
def _get_jwks(name: str):
|
||||
return _call_as("GET", f"/credentials/{name}/jwks")
|
||||
|
||||
|
|
@ -1027,8 +1078,9 @@ class TestNonAdminCannotTouchAStoredWifCredential:
|
|||
assert litellm.credential_list == [config_credential]
|
||||
assert litellm.credential_list[0].credential_values["api_key"] == "sk-old"
|
||||
|
||||
def test_proxy_admin_can_post_over_a_config_only_wif_credential(self, restore_credential_list, monkeypatch):
|
||||
monkeypatch.setattr(litellm, "credential_list", [_wif_credential("config-wif", source="config")])
|
||||
def test_proxy_admin_cannot_post_over_a_config_only_wif_credential(self, restore_credential_list, monkeypatch):
|
||||
config_credential = _wif_credential("config-wif", source="config")
|
||||
monkeypatch.setattr(litellm, "credential_list", [config_credential])
|
||||
with _repository_holding(None) as repository:
|
||||
response = _post_credential(
|
||||
{
|
||||
|
|
@ -1039,9 +1091,11 @@ class TestNonAdminCannotTouchAStoredWifCredential:
|
|||
auth=_as_admin,
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
repository.create.assert_awaited_once()
|
||||
assert litellm.credential_list[0].credential_values == {"api_key": "sk-rotated"}
|
||||
assert response.status_code == 409, response.text
|
||||
assert response.json()["error"]["param"] == "credential_name"
|
||||
assert "defined in config" in response.json()["error"]["message"]
|
||||
repository.create.assert_not_awaited()
|
||||
assert litellm.credential_list == [config_credential]
|
||||
|
||||
def test_non_admin_cannot_post_a_null_wif_field(self, restore_credential_list):
|
||||
"""Same key-presence rule on the create path: ``{"anthropic_issuer_url": null}`` persists
|
||||
|
|
@ -1471,6 +1525,52 @@ class TestCredentialDisplayName:
|
|||
repository.update_by_name.assert_not_awaited()
|
||||
assert litellm.credential_list == [_labeled_credential()]
|
||||
|
||||
@pytest.mark.parametrize("display_name", ["\u200b", "Prod\u202eAI", "\ufeffProd", "Prod\u0085AI", "Prod\u2028AI"])
|
||||
def test_patch_rejects_invisible_or_control_characters_in_display_name(
|
||||
self, restore_credential_list, monkeypatch, display_name
|
||||
):
|
||||
monkeypatch.setattr(litellm, "credential_list", [_labeled_credential()])
|
||||
with _repository_holding(_labeled_credential()) as repository:
|
||||
response = _patch_credential("openai-prod", {"display_name": display_name, "credential_info": {}})
|
||||
|
||||
assert response.status_code == 400, response.text
|
||||
assert response.json()["error"]["param"] == "display_name"
|
||||
repository.update_by_name.assert_not_awaited()
|
||||
assert litellm.credential_list == [_labeled_credential()]
|
||||
|
||||
@pytest.mark.parametrize("display_name", ["\U0001f600" * 255, "Prod\u00a0OpenAI", "Équipe 東京"])
|
||||
def test_patch_accepts_visible_unicode_up_to_the_limit_in_code_points(
|
||||
self, restore_credential_list, monkeypatch, display_name
|
||||
):
|
||||
monkeypatch.setattr(litellm, "credential_list", [_labeled_credential()])
|
||||
table = _credentials_table(_credential_row("Prod OpenAI", "sk-old", name="openai-prod"))
|
||||
prisma_client = MagicMock()
|
||||
prisma_client.db = SimpleNamespace(litellm_credentialstable=table)
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", prisma_client),
|
||||
patch("litellm.proxy.proxy_server.master_key", "sk-test-master"),
|
||||
):
|
||||
response = _patch_credential("openai-prod", {"display_name": display_name, "credential_info": {}})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert table.update.await_args.kwargs["where"] == {"credential_name": "openai-prod"}
|
||||
assert table.update.await_args.kwargs["data"]["display_name"] == display_name
|
||||
|
||||
def test_create_rejects_a_control_character_in_display_name(self, restore_credential_list):
|
||||
with _repository_holding(None) as repository:
|
||||
response = _post_credential(
|
||||
{
|
||||
"credential_name": "new-cred",
|
||||
"display_name": "Prod\u200bOpenAI",
|
||||
"credential_values": {"api_key": "sk-new"},
|
||||
"credential_info": {"custom_llm_provider": "openai"},
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 400, response.text
|
||||
assert response.json()["error"]["param"] == "display_name"
|
||||
repository.create.assert_not_awaited()
|
||||
|
||||
def test_patch_stores_the_trimmed_display_name(self, restore_credential_list, monkeypatch):
|
||||
monkeypatch.setattr(litellm, "credential_list", [_labeled_credential()])
|
||||
with _repository_holding(_labeled_credential()) as repository:
|
||||
|
|
@ -1534,6 +1634,43 @@ class TestCredentialDisplayName:
|
|||
repository.update_by_name.assert_not_awaited()
|
||||
assert litellm.credential_list == [config_credential]
|
||||
|
||||
def test_post_on_a_config_credential_name_is_rejected_and_does_not_shadow_it(
|
||||
self, restore_credential_list, monkeypatch
|
||||
):
|
||||
config_credential = _labeled_credential(display_name=None).model_copy(update={"source": "config"})
|
||||
monkeypatch.setattr(litellm, "credential_list", [config_credential])
|
||||
with _repository_holding(None) as repository:
|
||||
response = _post_credential(
|
||||
{
|
||||
"credential_name": "openai-prod",
|
||||
"credential_values": {"api_key": "sk-shadow"},
|
||||
"credential_info": {"custom_llm_provider": "openai"},
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 409, response.text
|
||||
assert "defined in config" in response.json()["error"]["message"]
|
||||
repository.create.assert_not_awaited()
|
||||
assert litellm.credential_list == [config_credential]
|
||||
|
||||
def test_post_with_a_display_name_matching_a_config_credential_name_is_allowed(
|
||||
self, restore_credential_list, monkeypatch
|
||||
):
|
||||
config_credential = _labeled_credential(display_name=None).model_copy(update={"source": "config"})
|
||||
monkeypatch.setattr(litellm, "credential_list", [config_credential])
|
||||
with _repository_holding(None) as repository:
|
||||
response = _post_credential(
|
||||
{
|
||||
"credential_name": "openai-staging",
|
||||
"display_name": "openai-prod",
|
||||
"credential_values": {"api_key": "sk-staging"},
|
||||
"credential_info": {"custom_llm_provider": "openai"},
|
||||
}
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
repository.create.assert_awaited_once()
|
||||
|
||||
def test_patch_on_an_unknown_credential_still_answers_404(self, restore_credential_list):
|
||||
with _repository_holding(None):
|
||||
response = _patch_credential("nowhere", {"display_name": "Prod", "credential_info": {}})
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ describe("AllModelsTable", () => {
|
|||
expect(screen.getByText("Manual")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the credential's display name when one is set and keeps the name as the tooltip", () => {
|
||||
it("renders the credential's display name with its credential name beneath", () => {
|
||||
render(
|
||||
<AllModelsTable
|
||||
{...baseProps}
|
||||
|
|
@ -166,8 +166,31 @@ describe("AllModelsTable", () => {
|
|||
/>,
|
||||
);
|
||||
expect(screen.getByText("Prod OpenAI")).toBeInTheDocument();
|
||||
expect(screen.queryByText("openai-prod")).not.toBeInTheDocument();
|
||||
expect(screen.getByTitle("openai-prod")).toBeInTheDocument();
|
||||
expect(screen.getByText("openai-prod")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("tells apart a credential whose label is another credential's name", () => {
|
||||
render(
|
||||
<AllModelsTable
|
||||
{...baseProps}
|
||||
credentialLabels={
|
||||
new Map([
|
||||
["openai-prod", "openai-staging"],
|
||||
["openai-staging", "openai-staging"],
|
||||
])
|
||||
}
|
||||
data={[
|
||||
makeModel({ litellm_params: { model: "openai/gpt-4", litellm_credential_name: "openai-prod" } }),
|
||||
makeModel({
|
||||
model_name: "gpt-4-staging",
|
||||
model_info: { id: "staging-id", db_model: true } as ModelData["model_info"],
|
||||
litellm_params: { model: "openai/gpt-4", litellm_credential_name: "openai-staging" },
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getAllByText("openai-staging")).toHaveLength(2);
|
||||
expect(screen.getByText("openai-prod")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows 'Defined in config' for a config model and the creator for a DB model", () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { CredentialIdentity } from "@/components/shared/CredentialIdentity";
|
||||
import { Copy, Info, Loader2, Pencil, RefreshCw, Trash2 } from "lucide-react";
|
||||
|
||||
import { ProviderLogo } from "@/components/molecules/models/ProviderLogo";
|
||||
|
|
@ -171,10 +172,12 @@ function CredentialsCell({ credentialName, label }: { credentialName: string | u
|
|||
}
|
||||
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-xs font-medium text-info" title={credentialName}>
|
||||
<RefreshCw className="size-3 shrink-0" />
|
||||
<span className="truncate">{label ?? credentialName}</span>
|
||||
</span>
|
||||
<CredentialIdentity
|
||||
credentialName={credentialName}
|
||||
label={label}
|
||||
icon={<RefreshCw className="size-3 shrink-0" />}
|
||||
className="text-xs font-medium text-info"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -96,10 +96,23 @@ describe("ModelInfoEditForm existing-credentials picker", () => {
|
|||
expect(onSubmit.mock.calls[1][0].litellm_credential_name).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the attached credential's display name when not editing", () => {
|
||||
it("shows the attached credential's display name with its credential name beneath when not editing", () => {
|
||||
renderForm({ isEditing: false, litellmParams: { model: "gpt-4o", litellm_credential_name: "openai-main" } });
|
||||
|
||||
expect(screen.getByText("Main OpenAI")).toBeInTheDocument();
|
||||
expect(screen.getByText("openai-main")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the selected credential's name under the picker only when its label differs", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
renderForm({ litellmParams: { model: "gpt-4o", litellm_credential_name: "openai-main" } });
|
||||
|
||||
const picker = await screen.findByPlaceholderText("Select or search for existing credentials");
|
||||
expect(picker).toHaveValue("Main OpenAI");
|
||||
expect(screen.getByText("openai-main")).toBeInTheDocument();
|
||||
|
||||
await user.click(picker);
|
||||
await user.click(await screen.findByRole("option", { name: "None" }));
|
||||
expect(screen.queryByText("openai-main")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -108,7 +121,7 @@ describe("ModelInfoEditForm existing-credentials picker", () => {
|
|||
isEditing: false,
|
||||
litellmParams: { model: "gpt-4o", litellm_credential_name: "not-listed" },
|
||||
});
|
||||
expect(screen.getByText("not-listed")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("not-listed")).toHaveLength(1);
|
||||
unmount();
|
||||
|
||||
renderForm({ isEditing: false });
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { SearchSelect } from "@/components/shared/SearchSelect";
|
||||
import { credentialLabelsByName, credentialOptions } from "@/components/shared/credentialOptions";
|
||||
import { credentialOptions } from "@/components/shared/credentialOptions";
|
||||
import { AttachedCredential, CredentialNameHint } from "@/components/shared/CredentialIdentity";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
|
@ -347,9 +348,6 @@ const ChipList: React.FC<{ values: unknown; emptyLabel: string }> = ({ values, e
|
|||
);
|
||||
};
|
||||
|
||||
const attachedCredentialLabel = (credentialName: string | null | undefined, credentials: CredentialItem[]): string =>
|
||||
credentialName ? credentialLabelsByName(credentials).get(credentialName) ?? credentialName : "Manual";
|
||||
|
||||
const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
||||
localModelData,
|
||||
modelData,
|
||||
|
|
@ -640,21 +638,30 @@ const ModelInfoEditForm: React.FC<ModelInfoEditFormProps> = ({
|
|||
{isEditing ? (
|
||||
<FormField control={form.control} name="litellm_credential_name">
|
||||
{({ id, value, onChange }) => (
|
||||
<SearchSelect
|
||||
inputId={id}
|
||||
placeholder="Select or search for existing credentials"
|
||||
options={credentialOptions(credentialsList)}
|
||||
value={(value as string | null) ?? ""}
|
||||
allowClear={false}
|
||||
onValueChange={(selected) => {
|
||||
if (selected !== null) onChange(selected === "" ? null : selected);
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<SearchSelect
|
||||
inputId={id}
|
||||
placeholder="Select or search for existing credentials"
|
||||
options={credentialOptions(credentialsList)}
|
||||
value={(value as string | null) ?? ""}
|
||||
allowClear={false}
|
||||
onValueChange={(selected) => {
|
||||
if (selected !== null) onChange(selected === "" ? null : selected);
|
||||
}}
|
||||
/>
|
||||
<CredentialNameHint
|
||||
credentialName={value as string | null | undefined}
|
||||
credentials={credentialsList}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</FormField>
|
||||
) : (
|
||||
<Display>
|
||||
{attachedCredentialLabel(localModelData.litellm_params?.litellm_credential_name, credentialsList)}
|
||||
<AttachedCredential
|
||||
credentialName={localModelData.litellm_params?.litellm_credential_name}
|
||||
credentials={credentialsList}
|
||||
/>
|
||||
</Display>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import type { Team } from "../key_team_helpers/key_list";
|
|||
import { type CredentialItem, type ProviderCreateInfo, credentialCreateCall, modelAvailableCall } from "../networking";
|
||||
import CredentialModal from "../model_add/CredentialModal";
|
||||
import { federatedProviderOf } from "../model_add/credential_federation";
|
||||
import { buildCredential, withoutRestrictedFields } from "../model_add/credential_form_helpers";
|
||||
import { buildCredential, type CredentialSubmission } from "../model_add/credential_form_helpers";
|
||||
import { ProviderLogo } from "../molecules/models/ProviderLogo";
|
||||
import AccessGroupTagsCombobox from "./AccessGroupTagsCombobox";
|
||||
import AdvancedSettings from "./advanced_settings";
|
||||
|
|
@ -108,8 +108,8 @@ const AddModelForm: React.FC<AddModelFormProps> = ({
|
|||
const canCreateFederatedCredential =
|
||||
isProxyAdminRole(userRole ?? "") && federatedProviderOf(selectedProvider) !== null;
|
||||
|
||||
const handleCreateFederatedCredential = async (values: Record<string, unknown>) => {
|
||||
const credential = buildCredential(values, withoutRestrictedFields(values));
|
||||
const handleCreateFederatedCredential = async (submission: CredentialSubmission) => {
|
||||
const credential = buildCredential(submission, submission.credential_values);
|
||||
try {
|
||||
await credentialCreateCall(accessToken, credential);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -122,7 +122,10 @@ describe("CredentialModal with Anthropic workload identity federation", () => {
|
|||
expect(await screen.findByLabelText("Upstream API Base")).toHaveValue("");
|
||||
fill("Credential Name:", "anthropic-key");
|
||||
await user.click(screen.getByRole("button", { name: "Add Credential" }));
|
||||
expect(onSubmit).toHaveBeenCalledWith({ credential_name: "anthropic-key", custom_llm_provider: "Anthropic" }, []);
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
{ credential_name: "anthropic-key", custom_llm_provider: "Anthropic", credential_values: {} },
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a federated credential from the federation fields and never sends an API key", async () => {
|
||||
|
|
@ -143,9 +146,11 @@ describe("CredentialModal with Anthropic workload identity federation", () => {
|
|||
const expectedPayload = {
|
||||
credential_name: "anthropic-federated",
|
||||
custom_llm_provider: "Anthropic",
|
||||
anthropic_federation_rule_id: "fdrl_new",
|
||||
anthropic_organization_id: "org-new",
|
||||
anthropic_identity_token_file: "/var/run/secrets/anthropic/token",
|
||||
credential_values: {
|
||||
anthropic_federation_rule_id: "fdrl_new",
|
||||
anthropic_organization_id: "org-new",
|
||||
anthropic_identity_token_file: "/var/run/secrets/anthropic/token",
|
||||
},
|
||||
};
|
||||
expect(onSubmit).toHaveBeenCalledWith(expectedPayload, []);
|
||||
});
|
||||
|
|
@ -168,8 +173,10 @@ describe("CredentialModal with Anthropic workload identity federation", () => {
|
|||
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
anthropic_identity_source: "keycloak",
|
||||
anthropic_keycloak_client_secret_ref: "os.environ/KEYCLOAK_CLIENT_SECRET",
|
||||
credential_values: expect.objectContaining({
|
||||
anthropic_identity_source: "keycloak",
|
||||
anthropic_keycloak_client_secret_ref: "os.environ/KEYCLOAK_CLIENT_SECRET",
|
||||
}),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
|
@ -203,7 +210,7 @@ describe("CredentialModal with Anthropic workload identity federation", () => {
|
|||
await user.click(screen.getByRole("button", { name: "Update Credential" }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
{ credential_name: "anthropic-federated", custom_llm_provider: "anthropic" },
|
||||
{ credential_name: "anthropic-federated", custom_llm_provider: "anthropic", credential_values: {} },
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
|
@ -220,7 +227,7 @@ describe("CredentialModal with Anthropic workload identity federation", () => {
|
|||
{
|
||||
credential_name: "anthropic-federated",
|
||||
custom_llm_provider: "anthropic",
|
||||
anthropic_organization_id: "org-edited",
|
||||
credential_values: { anthropic_organization_id: "org-edited" },
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
|
@ -240,7 +247,7 @@ describe("CredentialModal with Anthropic workload identity federation", () => {
|
|||
expect(values).toEqual({
|
||||
credential_name: "anthropic-federated",
|
||||
custom_llm_provider: "anthropic",
|
||||
api_key: "sk-ant-replacement",
|
||||
credential_values: { api_key: "sk-ant-replacement" },
|
||||
});
|
||||
expect([...valuesToDelete].sort()).toEqual(Object.keys(federatedCredential.credential_values).sort());
|
||||
});
|
||||
|
|
@ -262,9 +269,11 @@ describe("CredentialModal with Anthropic workload identity federation", () => {
|
|||
const expectedValues = {
|
||||
credential_name: "azure-prod",
|
||||
custom_llm_provider: "Anthropic",
|
||||
anthropic_federation_rule_id: "fdrl_new",
|
||||
anthropic_organization_id: "org-new",
|
||||
anthropic_identity_token_file: "/var/run/secrets/anthropic/token",
|
||||
credential_values: {
|
||||
anthropic_federation_rule_id: "fdrl_new",
|
||||
anthropic_organization_id: "org-new",
|
||||
anthropic_identity_token_file: "/var/run/secrets/anthropic/token",
|
||||
},
|
||||
};
|
||||
expect(values).toEqual(expectedValues);
|
||||
expect([...valuesToDelete].sort()).toEqual(["api_base", "api_key", "api_version"]);
|
||||
|
|
@ -281,7 +290,10 @@ describe("CredentialModal with Anthropic workload identity federation", () => {
|
|||
|
||||
expect(await screen.findByLabelText("Azure API Base")).toHaveValue("https://corp.openai.azure.com");
|
||||
await user.click(screen.getByRole("button", { name: "Update Credential" }));
|
||||
expect(onSubmit).toHaveBeenCalledWith({ credential_name: "azure-prod", custom_llm_provider: "Azure" }, []);
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
{ credential_name: "azure-prod", custom_llm_provider: "Azure", credential_values: {} },
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes the stored base URL when the admin clears it on a federated credential", async () => {
|
||||
|
|
@ -297,7 +309,7 @@ describe("CredentialModal with Anthropic workload identity federation", () => {
|
|||
await user.click(screen.getByRole("button", { name: "Update Credential" }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
{ credential_name: "anthropic-federated", custom_llm_provider: "anthropic" },
|
||||
{ credential_name: "anthropic-federated", custom_llm_provider: "anthropic", credential_values: {} },
|
||||
["api_base"],
|
||||
);
|
||||
});
|
||||
|
|
@ -313,7 +325,7 @@ describe("CredentialModal with Anthropic workload identity federation", () => {
|
|||
await user.click(screen.getByRole("button", { name: "Update Credential" }));
|
||||
|
||||
const [values, valuesToDelete] = onSubmit.mock.calls[0];
|
||||
expect(values).toMatchObject({ custom_llm_provider: "OpenAI", api_key: "sk-openai-new" });
|
||||
expect(values).toMatchObject({ custom_llm_provider: "OpenAI", credential_values: { api_key: "sk-openai-new" } });
|
||||
expect([...valuesToDelete].sort()).toEqual(Object.keys(federatedCredential.credential_values).sort());
|
||||
});
|
||||
|
||||
|
|
@ -328,7 +340,7 @@ describe("CredentialModal with Anthropic workload identity federation", () => {
|
|||
await user.click(screen.getByRole("button", { name: "Update Credential" }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
{ credential_name: "anthropic-key-and-federation", custom_llm_provider: "anthropic" },
|
||||
{ credential_name: "anthropic-key-and-federation", custom_llm_provider: "anthropic", credential_values: {} },
|
||||
["api_key"],
|
||||
);
|
||||
});
|
||||
|
|
@ -382,7 +394,7 @@ describe("CredentialModal with Anthropic workload identity federation", () => {
|
|||
{
|
||||
credential_name: "anthropic-unknown-source",
|
||||
custom_llm_provider: "anthropic",
|
||||
anthropic_organization_id: "org-edited",
|
||||
credential_values: { anthropic_organization_id: "org-edited" },
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
|
@ -397,7 +409,7 @@ describe("CredentialModal with Anthropic workload identity federation", () => {
|
|||
await user.click(screen.getByRole("button", { name: "Update Credential" }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
{ credential_name: "anthropic-unknown-source", custom_llm_provider: "anthropic" },
|
||||
{ credential_name: "anthropic-unknown-source", custom_llm_provider: "anthropic", credential_values: {} },
|
||||
["anthropic_identity_source"],
|
||||
);
|
||||
});
|
||||
|
|
@ -418,7 +430,11 @@ describe("CredentialModal with Anthropic workload identity federation", () => {
|
|||
await user.click(screen.getByRole("button", { name: "Add Credential" }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
{ credential_name: "anthropic-env", custom_llm_provider: "Anthropic", anthropic_organization_id: "org-new" },
|
||||
{
|
||||
credential_name: "anthropic-env",
|
||||
custom_llm_provider: "Anthropic",
|
||||
credential_values: { anthropic_organization_id: "org-new" },
|
||||
},
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
|
@ -436,7 +452,7 @@ describe("CredentialModal with Anthropic workload identity federation", () => {
|
|||
{
|
||||
credential_name: "anthropic-env-ids",
|
||||
custom_llm_provider: "Anthropic",
|
||||
anthropic_identity_token_file: "/var/run/secrets/anthropic/token",
|
||||
credential_values: { anthropic_identity_token_file: "/var/run/secrets/anthropic/token" },
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
|
@ -474,9 +490,11 @@ describe("CredentialModal with OpenAI workload identity federation", () => {
|
|||
const expectedPayload = {
|
||||
credential_name: "openai-federated",
|
||||
custom_llm_provider: "OpenAI",
|
||||
openai_identity_provider_id: "idp_new",
|
||||
openai_service_account_id: "svc_new",
|
||||
openai_identity_token_file: "/var/run/secrets/kubernetes.io/serviceaccount/token",
|
||||
credential_values: {
|
||||
openai_identity_provider_id: "idp_new",
|
||||
openai_service_account_id: "svc_new",
|
||||
openai_identity_token_file: "/var/run/secrets/kubernetes.io/serviceaccount/token",
|
||||
},
|
||||
};
|
||||
expect(onSubmit).toHaveBeenCalledWith(expectedPayload, []);
|
||||
});
|
||||
|
|
@ -493,7 +511,10 @@ describe("CredentialModal with OpenAI workload identity federation", () => {
|
|||
|
||||
await user.click(screen.getByRole("button", { name: "Update Credential" }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({ credential_name: "openai-federated", custom_llm_provider: "openai" }, []);
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
{ credential_name: "openai-federated", custom_llm_provider: "openai", credential_values: {} },
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a base URL the proxy would not federate with, and drops that check once the admin picks an API key", async () => {
|
||||
|
|
@ -518,8 +539,7 @@ describe("CredentialModal with OpenAI workload identity federation", () => {
|
|||
const expectedPayload = {
|
||||
credential_name: "openai-gateway",
|
||||
custom_llm_provider: "OpenAI",
|
||||
api_base: "https://gateway.example.com/v1",
|
||||
api_key: "sk-proj-new",
|
||||
credential_values: { api_base: "https://gateway.example.com/v1", api_key: "sk-proj-new" },
|
||||
};
|
||||
expect(onSubmit).toHaveBeenCalledWith(expectedPayload, []);
|
||||
});
|
||||
|
|
@ -537,7 +557,10 @@ describe("CredentialModal with OpenAI workload identity federation", () => {
|
|||
|
||||
await user.click(screen.getByRole("button", { name: "Update Credential" }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith({ credential_name: "openai-federated", custom_llm_provider: "openai" }, []);
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
{ credential_name: "openai-federated", custom_llm_provider: "openai", credential_values: {} },
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes the stored OpenAI federation values when the admin switches the credential to an API key", async () => {
|
||||
|
|
@ -554,7 +577,7 @@ describe("CredentialModal with OpenAI workload identity federation", () => {
|
|||
expect(values).toEqual({
|
||||
credential_name: "openai-federated",
|
||||
custom_llm_provider: "openai",
|
||||
api_key: "sk-proj-replacement",
|
||||
credential_values: { api_key: "sk-proj-replacement" },
|
||||
});
|
||||
expect([...valuesToDelete].sort()).toEqual([
|
||||
"openai_identity_provider_id",
|
||||
|
|
@ -577,7 +600,53 @@ describe("CredentialModal with OpenAI workload identity federation", () => {
|
|||
);
|
||||
await user.click(screen.getByRole("button", { name: "Update Credential" }));
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
{ credential_name: "anthropic-federated", custom_llm_provider: "Anthropic" },
|
||||
{ credential_name: "anthropic-federated", custom_llm_provider: "Anthropic", credential_values: {} },
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CredentialModal display name wiring", () => {
|
||||
it("submits the typed label alongside the credential name and stored values", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = renderModal({ initialProvider: Providers.OpenAI });
|
||||
await screen.findByLabelText("OpenAI API Key");
|
||||
fill("Credential Name:", "openai-prod");
|
||||
fill("Display Name:", "Prod OpenAI");
|
||||
fill("OpenAI API Key", "sk-new");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Add Credential" }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
credential_name: "openai-prod",
|
||||
display_name: "Prod OpenAI",
|
||||
credential_values: expect.objectContaining({ api_key: "sk-new" }),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("edits the opened credential even when its stored values reuse the top-level keys", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = renderModal({
|
||||
mode: "edit",
|
||||
existingCredential: {
|
||||
credential_name: "legacy-a",
|
||||
display_name: "Legacy A",
|
||||
credential_values: { api_key: "sk-a****", credential_name: "other-credential", display_name: "Inner label" },
|
||||
credential_info: { custom_llm_provider: "openai" },
|
||||
},
|
||||
});
|
||||
await screen.findByLabelText("OpenAI API Key");
|
||||
|
||||
expect(screen.getByLabelText("Credential Name:")).toHaveValue("legacy-a");
|
||||
expect(screen.getByLabelText("Credential Name:")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Display Name:")).toHaveValue("Legacy A");
|
||||
await user.click(screen.getByRole("button", { name: "Update Credential" }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
{ credential_name: "legacy-a", custom_llm_provider: "openai", credential_values: {} },
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Providers } from "../provider_info_helpers";
|
||||
import { CredentialItem } from "../networking";
|
||||
|
|
@ -126,142 +125,4 @@ describe("CredentialModal", () => {
|
|||
expect(screen.getByLabelText("Credential Name:")).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("display name", () => {
|
||||
const fillRequiredAddFields = async (user: ReturnType<typeof userEvent.setup>) => {
|
||||
fireEvent.change(screen.getByLabelText("Credential Name:"), { target: { value: "new-cred" } });
|
||||
const providerInput = await screen.findByPlaceholderText("Select a provider");
|
||||
await user.click(providerInput);
|
||||
await user.click(await screen.findByText("OpenAI"));
|
||||
fireEvent.change(await screen.findByLabelText("OpenAI API Key"), { target: { value: "sk-test" } });
|
||||
};
|
||||
|
||||
it("submits the display name with the credential name in add mode", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
const onSubmit = vi.fn();
|
||||
renderModal({ mode: "add", onSubmit });
|
||||
|
||||
await fillRequiredAddFields(user);
|
||||
fireEvent.change(screen.getByLabelText("Display Name:"), { target: { value: "Prod" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add Credential" }));
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
expect(onSubmit.mock.calls[0][0]).toMatchObject({ credential_name: "new-cred", display_name: "Prod" });
|
||||
});
|
||||
|
||||
it("submits no display_name key when the display name is left blank in add mode", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
const onSubmit = vi.fn();
|
||||
renderModal({ mode: "add", onSubmit });
|
||||
|
||||
await fillRequiredAddFields(user);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add Credential" }));
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
expect(onSubmit.mock.calls[0][0]).not.toHaveProperty("display_name");
|
||||
});
|
||||
|
||||
it("prefills the display name and submits an edited one", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderModal({
|
||||
mode: "edit",
|
||||
onSubmit,
|
||||
existingCredential: { ...mockCredential, display_name: "Prod" },
|
||||
});
|
||||
|
||||
const displayNameInput = screen.getByLabelText("Display Name:") as HTMLInputElement;
|
||||
expect(displayNameInput.value).toBe("Prod");
|
||||
|
||||
fireEvent.change(displayNameInput, { target: { value: "Staging" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Update Credential" }));
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
expect(onSubmit.mock.calls[0][0]).toMatchObject({
|
||||
credential_name: "test-credential",
|
||||
display_name: "Staging",
|
||||
});
|
||||
});
|
||||
|
||||
it("submits display_name: null when the display name is cleared in edit mode", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderModal({
|
||||
mode: "edit",
|
||||
onSubmit,
|
||||
existingCredential: { ...mockCredential, display_name: "Prod" },
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Display Name:"), { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Update Credential" }));
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
expect(onSubmit.mock.calls[0][0].display_name).toBeNull();
|
||||
});
|
||||
|
||||
it("trims the display name before submitting in add mode", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
const onSubmit = vi.fn();
|
||||
renderModal({ mode: "add", onSubmit });
|
||||
|
||||
await fillRequiredAddFields(user);
|
||||
fireEvent.change(screen.getByLabelText("Display Name:"), { target: { value: " Prod " } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add Credential" }));
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
expect(onSubmit.mock.calls[0][0]).toMatchObject({ credential_name: "new-cred", display_name: "Prod" });
|
||||
});
|
||||
|
||||
it("submits display_name: null when the display name is whitespace-only in edit mode", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderModal({
|
||||
mode: "edit",
|
||||
onSubmit,
|
||||
existingCredential: { ...mockCredential, display_name: "Prod OpenAI" },
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Display Name:"), { target: { value: " " } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Update Credential" }));
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
expect(onSubmit.mock.calls[0][0].display_name).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves display_name out of the edit when it was not changed", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderModal({
|
||||
mode: "edit",
|
||||
onSubmit,
|
||||
existingCredential: { ...mockCredential, display_name: "Prod" },
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Display Name:"), { target: { value: " Prod " } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Update Credential" }));
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
expect(onSubmit.mock.calls[0][0]).not.toHaveProperty("display_name");
|
||||
});
|
||||
|
||||
it("accepts a 255-character display name and blocks a 256-character one", async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never });
|
||||
const onSubmit = vi.fn();
|
||||
renderModal({ mode: "add", onSubmit });
|
||||
|
||||
await fillRequiredAddFields(user);
|
||||
fireEvent.change(screen.getByLabelText("Display Name:"), { target: { value: "x".repeat(256) } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add Credential" }));
|
||||
expect(await screen.findByText("Display name must be at most 255 characters")).toBeInTheDocument();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Display Name:"), { target: { value: "x".repeat(255) } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add Credential" }));
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
|
||||
expect(onSubmit.mock.calls[0][0].display_name).toBe("x".repeat(255));
|
||||
});
|
||||
|
||||
it("keeps the credential name read-only while the display name stays editable in edit mode", () => {
|
||||
renderModal({ mode: "edit", existingCredential: { ...mockCredential, display_name: "Prod" } });
|
||||
|
||||
expect(screen.getByLabelText("Credential Name:")).toBeDisabled();
|
||||
expect(screen.getByLabelText("Display Name:")).toBeEnabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,7 +17,13 @@ import {
|
|||
import { CredentialItem } from "../networking";
|
||||
import { Providers } from "../provider_info_helpers";
|
||||
import { Logo } from "@/components/molecules/logo/Logo";
|
||||
import { resetCredentialFormOnProviderChange, withoutRestrictedFields } from "./credential_form_helpers";
|
||||
import {
|
||||
displayNameChange,
|
||||
initialFormValues,
|
||||
resetCredentialFormOnProviderChange,
|
||||
withoutRestrictedFields,
|
||||
type CredentialSubmission,
|
||||
} from "./credential_form_helpers";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import FederationFields from "./FederationFields";
|
||||
import { DEFAULT_IDENTITY_SOURCE, inferIdentitySource, type IdentitySourceId } from "./anthropic_federation";
|
||||
|
|
@ -52,7 +58,7 @@ const authMethodItems: { value: AuthMethod; label: string }[] = [
|
|||
interface CredentialModalProps {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: (values: Record<string, unknown>, valuesToDelete: readonly string[]) => void;
|
||||
onSubmit: (submission: CredentialSubmission, valuesToDelete: readonly string[]) => void;
|
||||
mode: "add" | "edit";
|
||||
existingCredential?: CredentialItem | null;
|
||||
initialProvider?: string | null;
|
||||
|
|
@ -63,39 +69,6 @@ interface CredentialModalProps {
|
|||
const sameProvider = (left: string | null | undefined, right: string | null | undefined): boolean =>
|
||||
(left ?? "").toLowerCase() === (right ?? "").toLowerCase();
|
||||
|
||||
const DISPLAY_NAME_MAX_LENGTH = 255;
|
||||
|
||||
const displayNameChange = (
|
||||
value: unknown,
|
||||
existingCredential: CredentialItem | null | undefined,
|
||||
): { display_name?: string | null } => {
|
||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||
if (!existingCredential) {
|
||||
return trimmed ? { display_name: trimmed } : {};
|
||||
}
|
||||
if (trimmed === (existingCredential.display_name ?? "")) {
|
||||
return {};
|
||||
}
|
||||
return { display_name: trimmed || null };
|
||||
};
|
||||
|
||||
const initialFormValues = (
|
||||
existingCredential: CredentialItem | null | undefined,
|
||||
initialProvider: string | null | undefined,
|
||||
): MountedFormValues | undefined => {
|
||||
if (existingCredential) {
|
||||
return {
|
||||
credential_name: existingCredential.credential_name,
|
||||
display_name: existingCredential.display_name ?? "",
|
||||
custom_llm_provider: existingCredential.credential_info.custom_llm_provider,
|
||||
...Object.fromEntries(
|
||||
Object.entries(existingCredential.credential_values || {}).map(([key, value]) => [key, value ?? null]),
|
||||
),
|
||||
};
|
||||
}
|
||||
return initialProvider ? { custom_llm_provider: initialProvider } : undefined;
|
||||
};
|
||||
|
||||
export default function CredentialModal({
|
||||
open,
|
||||
onCancel,
|
||||
|
|
@ -153,18 +126,21 @@ export default function CredentialModal({
|
|||
}
|
||||
const values = projectMountedValues(registry, form.getValues);
|
||||
const meta = {
|
||||
credential_name: values.credential_name,
|
||||
custom_llm_provider: values.custom_llm_provider,
|
||||
credential_name: String(values.credential_name),
|
||||
custom_llm_provider: String(values.custom_llm_provider),
|
||||
...displayNameChange(values.display_name, existingCredential),
|
||||
};
|
||||
if (!isEdit) {
|
||||
onSubmit({ ...meta, ...buildCreateCredentialValues(withoutRestrictedFields(values), selection) }, []);
|
||||
onSubmit(
|
||||
{ ...meta, credential_values: buildCreateCredentialValues(withoutRestrictedFields(values), selection) },
|
||||
[],
|
||||
);
|
||||
return;
|
||||
}
|
||||
const patch = sameProvider(selectedProvider, storedProvider)
|
||||
? buildCredentialPatch(storedValues, withoutRestrictedFields(values), storedSelection, selection)
|
||||
: buildProviderChangePatch(storedValues, withoutRestrictedFields(values), selection);
|
||||
onSubmit({ ...meta, ...patch.credential_values }, patch.credential_values_to_delete);
|
||||
onSubmit({ ...meta, credential_values: patch.credential_values }, patch.credential_values_to_delete);
|
||||
};
|
||||
|
||||
const closeAndReset = () => {
|
||||
|
|
@ -205,19 +181,7 @@ export default function CredentialModal({
|
|||
)}
|
||||
</MountedFormField>
|
||||
|
||||
<MountedFormField
|
||||
label="Display Name:"
|
||||
name="display_name"
|
||||
rules={{
|
||||
validate: {
|
||||
maxLength: (value: unknown) =>
|
||||
typeof value !== "string" ||
|
||||
value.trim().length <= DISPLAY_NAME_MAX_LENGTH ||
|
||||
`Display name must be at most ${DISPLAY_NAME_MAX_LENGTH} characters`,
|
||||
},
|
||||
}}
|
||||
className="mb-4"
|
||||
>
|
||||
<MountedFormField label="Display Name:" name="display_name" className="mb-4">
|
||||
{(control) => (
|
||||
<Input
|
||||
id={control.id}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { CredentialItem, credentialCreateCall, credentialUpdateCall } from "@/co
|
|||
import { toast } from "@/lib/toast";
|
||||
|
||||
import CredentialsPanel from "./CredentialsPanel";
|
||||
import type { CredentialSubmission } from "./credential_form_helpers";
|
||||
|
||||
const mockUseAuthorized = vi.fn();
|
||||
const mockUseCredentials = vi.fn();
|
||||
|
|
@ -39,20 +40,23 @@ vi.mock("./CredentialModal", () => ({
|
|||
}: {
|
||||
mode: "add" | "edit";
|
||||
open: boolean;
|
||||
onSubmit: (values: Record<string, unknown>, valuesToDelete?: readonly string[]) => void;
|
||||
onSubmit: (submission: CredentialSubmission, valuesToDelete?: readonly string[]) => void;
|
||||
}) {
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
const values =
|
||||
const values: CredentialSubmission =
|
||||
mode === "edit"
|
||||
? {
|
||||
credential_name: "openai-key",
|
||||
custom_llm_provider: "openai",
|
||||
api_key: "sk-1****2345",
|
||||
api_base: "https://proxy.e2e.example.com/v1",
|
||||
credential_values: {
|
||||
api_key: "sk-1****2345",
|
||||
api_base: "https://proxy.e2e.example.com/v1",
|
||||
credential_name: "other-credential",
|
||||
},
|
||||
}
|
||||
: { credential_name: "new-cred", custom_llm_provider: "openai" };
|
||||
: { credential_name: "new-cred", custom_llm_provider: "openai", credential_values: {} };
|
||||
return (
|
||||
<>
|
||||
<button data-testid={`credential-modal-${mode}-submit`} onClick={() => onSubmit(values)}>
|
||||
|
|
@ -61,7 +65,17 @@ vi.mock("./CredentialModal", () => ({
|
|||
<button
|
||||
data-testid={`credential-modal-${mode}-switch-to-federation`}
|
||||
onClick={() =>
|
||||
onSubmit({ ...values, api_key: undefined, anthropic_federation_rule_id: "fdrl_new" }, ["api_key"])
|
||||
onSubmit(
|
||||
{
|
||||
...values,
|
||||
credential_values: {
|
||||
...values.credential_values,
|
||||
api_key: undefined,
|
||||
anthropic_federation_rule_id: "fdrl_new",
|
||||
},
|
||||
},
|
||||
["api_key"],
|
||||
)
|
||||
}
|
||||
>
|
||||
switch {mode} to federation
|
||||
|
|
@ -173,7 +187,9 @@ describe("CredentialsPanel", () => {
|
|||
const user = userEvent.setup();
|
||||
mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" });
|
||||
mockUseCredentials.mockReturnValue({ data: { credentials: [] }, isLoading: false, refetch: vi.fn() });
|
||||
vi.mocked(credentialCreateCall).mockRejectedValueOnce(new Error("network down"));
|
||||
vi.mocked(credentialCreateCall).mockRejectedValueOnce(
|
||||
new Error("Credential 'new-cred' is defined in config. Edit config.yaml to change it."),
|
||||
);
|
||||
|
||||
renderPanel();
|
||||
|
||||
|
|
@ -181,7 +197,9 @@ describe("CredentialsPanel", () => {
|
|||
await user.click(screen.getByTestId("credential-modal-add-submit"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith("Failed to add credential");
|
||||
expect(toast.error).toHaveBeenCalledWith(
|
||||
"Credential 'new-cred' is defined in config. Edit config.yaml to change it.",
|
||||
);
|
||||
});
|
||||
// The modal stays open so the user can retry, and no success toast fired.
|
||||
expect(screen.getByTestId("credential-modal-add-submit")).toBeInTheDocument();
|
||||
|
|
@ -205,7 +223,11 @@ describe("CredentialsPanel", () => {
|
|||
});
|
||||
const [, updatedName, payload] = vi.mocked(credentialUpdateCall).mock.calls[0];
|
||||
expect(updatedName).toBe("openai-key");
|
||||
expect(payload.credential_values).toEqual({ api_base: "https://proxy.e2e.example.com/v1" });
|
||||
expect(payload.credential_name).toBe("openai-key");
|
||||
expect(payload.credential_values).toEqual({
|
||||
api_base: "https://proxy.e2e.example.com/v1",
|
||||
credential_name: "other-credential",
|
||||
});
|
||||
});
|
||||
|
||||
it("sends the values to delete with the update and omits the field when there are none", async () => {
|
||||
|
|
@ -225,6 +247,7 @@ describe("CredentialsPanel", () => {
|
|||
const [, , federatedPayload] = vi.mocked(credentialUpdateCall).mock.calls[0];
|
||||
expect(federatedPayload.credential_values).toEqual({
|
||||
api_base: "https://proxy.e2e.example.com/v1",
|
||||
credential_name: "other-credential",
|
||||
anthropic_federation_rule_id: "fdrl_new",
|
||||
});
|
||||
expect(federatedPayload.credential_values_to_delete).toEqual(["api_key"]);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
credentialUpdateCall,
|
||||
} from "@/components/networking";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { extractProxyErrorMessage } from "@/lib/http/client";
|
||||
import { stripMaskedSecrets } from "@/utils/maskedSecretUtils";
|
||||
import { isProxyAdminRole } from "@/utils/roles";
|
||||
|
||||
|
|
@ -19,7 +20,7 @@ import DeleteResourceModal from "../common_components/DeleteResourceModal";
|
|||
import { toast } from "@/lib/toast";
|
||||
import CredentialModal from "./CredentialModal";
|
||||
import CredentialsTable from "./CredentialsTable";
|
||||
import { buildCredential, withoutRestrictedFields } from "./credential_form_helpers";
|
||||
import { buildCredential, type CredentialSubmission } from "./credential_form_helpers";
|
||||
|
||||
export default function CredentialsPanel() {
|
||||
const { accessToken, userRole } = useAuthorized();
|
||||
|
|
@ -35,13 +36,13 @@ export default function CredentialsPanel() {
|
|||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isCredentialDeleting, setIsCredentialDeleting] = useState(false);
|
||||
|
||||
const handleUpdateCredential = async (values: Record<string, unknown>, valuesToDelete: readonly string[] = []) => {
|
||||
const handleUpdateCredential = async (submission: CredentialSubmission, valuesToDelete: readonly string[] = []) => {
|
||||
if (!accessToken) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const newCredential = buildCredential(values, stripMaskedSecrets(withoutRestrictedFields(values)));
|
||||
await credentialUpdateCall(accessToken, values.credential_name as string, {
|
||||
const newCredential = buildCredential(submission, stripMaskedSecrets(submission.credential_values));
|
||||
await credentialUpdateCall(accessToken, submission.credential_name, {
|
||||
...newCredential,
|
||||
...(valuesToDelete.length > 0 ? { credential_values_to_delete: valuesToDelete } : {}),
|
||||
});
|
||||
|
|
@ -49,22 +50,22 @@ export default function CredentialsPanel() {
|
|||
setIsUpdateModalOpen(false);
|
||||
await refetchCredentials();
|
||||
} catch (error) {
|
||||
toast.error("Failed to update credential");
|
||||
toast.error(extractProxyErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCredential = async (values: Record<string, unknown>) => {
|
||||
const handleAddCredential = async (submission: CredentialSubmission) => {
|
||||
if (!accessToken) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const newCredential = buildCredential(values, withoutRestrictedFields(values));
|
||||
const newCredential = buildCredential(submission, submission.credential_values);
|
||||
await credentialCreateCall(accessToken, newCredential);
|
||||
toast.success("Credential added successfully");
|
||||
setIsAddModalOpen(false);
|
||||
await refetchCredentials();
|
||||
} catch (error) {
|
||||
toast.error("Failed to add credential");
|
||||
toast.error(extractProxyErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ describe("CredentialsTable", () => {
|
|||
expect(within(rows[1]).getByText("zulu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should badge a config credential and block editing and deleting it", async () => {
|
||||
it("should show each credential's source in its own column and block editing a config one", async () => {
|
||||
const user = userEvent.setup();
|
||||
const credentials: CredentialItem[] = [
|
||||
{ credential_name: "from-config", source: "config", credential_values: {}, credential_info: {} },
|
||||
|
|
@ -177,8 +177,12 @@ describe("CredentialsTable", () => {
|
|||
];
|
||||
render(<CredentialsTable {...defaultProps} credentials={credentials} />);
|
||||
|
||||
expect(within(screen.getByRole("row", { name: /from-config/ })).getByText("Config")).toBeInTheDocument();
|
||||
expect(within(screen.getByRole("row", { name: /from-db/ })).queryByText("Config")).not.toBeInTheDocument();
|
||||
const sourceColumn = screen.getAllByRole("columnheader").findIndex((header) => header.textContent === "Source");
|
||||
const sourceCell = (name: RegExp) => within(screen.getByRole("row", { name })).getAllByRole("cell")[sourceColumn];
|
||||
expect(sourceColumn).toBeGreaterThan(-1);
|
||||
expect(sourceCell(/from-config/)).toHaveTextContent(/^Config$/);
|
||||
expect(sourceCell(/from-db/)).toHaveTextContent(/^DB$/);
|
||||
expect(within(screen.getByRole("row", { name: /from-config/ })).getAllByText("Config")).toHaveLength(1);
|
||||
|
||||
await user.click(screen.getByTestId("credential-actions-from-config"));
|
||||
expect(await screen.findByTestId("credential-config-owned-hint")).toBeInTheDocument();
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { CredentialItem } from "@/components/networking";
|
|||
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
|
||||
import { DataTableSortHeader } from "@/components/shared/DataTable";
|
||||
import { credentialLabel } from "@/components/shared/credentialOptions";
|
||||
import { IdentityCell } from "@/components/shared/table_cells";
|
||||
import { IdentityCell, StatusBadge } from "@/components/shared/table_cells";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -128,7 +128,6 @@ export const getCredentialsTableColumns = ({
|
|||
<IdentityCell
|
||||
title={credentialLabel(row.original)}
|
||||
subtitle={row.original.display_name ? row.original.credential_name : undefined}
|
||||
badge={row.original.source === "config" ? <Badge variant="outline">Config</Badge> : undefined}
|
||||
className="max-w-72"
|
||||
titleClassName="font-medium"
|
||||
/>
|
||||
|
|
@ -148,6 +147,20 @@ export const getCredentialsTableColumns = ({
|
|||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "source",
|
||||
accessorFn: (row) => row.source ?? "db",
|
||||
meta: { title: "Source" },
|
||||
header: "Source",
|
||||
size: 140,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) =>
|
||||
row.original.source === "config" ? (
|
||||
<StatusBadge tone="neutral" label="Config" />
|
||||
) : (
|
||||
<StatusBadge tone="info" label="DB" />
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (!canModifyCredentials) {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@ import { describe, expect, it, vi } from "vitest";
|
|||
import { Providers } from "../provider_info_helpers";
|
||||
import {
|
||||
buildCredential,
|
||||
displayNameChange,
|
||||
initialFormValues,
|
||||
resetCredentialFormOnProviderChange,
|
||||
withoutRestrictedFields,
|
||||
} from "./credential_form_helpers";
|
||||
import type { CredentialItem } from "../networking";
|
||||
|
||||
/**
|
||||
* Build a minimal FormInstance stub that records calls. We don't depend
|
||||
|
|
@ -95,21 +97,96 @@ describe("resetCredentialFormOnProviderChange", () => {
|
|||
});
|
||||
|
||||
describe("buildCredential", () => {
|
||||
const values = { credential_name: "openai-prod", custom_llm_provider: "openai", api_key: "sk-test" };
|
||||
const submission = {
|
||||
credential_name: "openai-prod",
|
||||
custom_llm_provider: "openai",
|
||||
credential_values: { api_key: "sk-test" },
|
||||
};
|
||||
|
||||
it.each([
|
||||
["a label", "Prod OpenAI"],
|
||||
["a cleared label", null],
|
||||
])("sends %s as a top-level display_name, never as a credential value", (_, displayName) => {
|
||||
const formValues = { ...values, display_name: displayName };
|
||||
|
||||
const credential = buildCredential(formValues, withoutRestrictedFields(formValues));
|
||||
const credential = buildCredential({ ...submission, display_name: displayName }, submission.credential_values);
|
||||
|
||||
expect(credential.display_name).toBe(displayName);
|
||||
expect(credential.credential_values).toEqual({ api_key: "sk-test" });
|
||||
});
|
||||
|
||||
it("leaves display_name out when the form never set it", () => {
|
||||
expect(buildCredential(values, withoutRestrictedFields(values))).not.toHaveProperty("display_name");
|
||||
expect(buildCredential(submission, submission.credential_values)).not.toHaveProperty("display_name");
|
||||
});
|
||||
|
||||
it("keeps secret keys that share a name with top-level fields inside credential_values", () => {
|
||||
const credentialValues = { api_key: "sk-test", credential_name: "legacy-inner", display_name: "Legacy inner" };
|
||||
|
||||
const credential = buildCredential({ ...submission, display_name: "Prod OpenAI" }, credentialValues);
|
||||
|
||||
expect(credential.credential_name).toBe("openai-prod");
|
||||
expect(credential.display_name).toBe("Prod OpenAI");
|
||||
expect(credential.credential_values).toEqual(credentialValues);
|
||||
});
|
||||
});
|
||||
|
||||
describe("displayNameChange", () => {
|
||||
const stored: CredentialItem = {
|
||||
credential_name: "openai-prod",
|
||||
display_name: "Prod",
|
||||
credential_values: {},
|
||||
credential_info: {},
|
||||
};
|
||||
|
||||
it.each([
|
||||
["sends a typed label as is", " Prod OpenAI ", { display_name: " Prod OpenAI " }],
|
||||
[
|
||||
"sends a long label without its own length check",
|
||||
"\u{1F600}".repeat(300),
|
||||
{ display_name: "\u{1F600}".repeat(300) },
|
||||
],
|
||||
["omits a blank label", "", {}],
|
||||
])("in add mode %s", (_, typed, expected) => {
|
||||
expect(displayNameChange(typed, null)).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["omits an unchanged label", "Prod", {}],
|
||||
["sends an edited label", "Staging", { display_name: "Staging" }],
|
||||
["clears an emptied label with null", "", { display_name: null }],
|
||||
["leaves whitespace for the server to reject", " ", { display_name: " " }],
|
||||
])("in edit mode %s", (_, typed, expected) => {
|
||||
expect(displayNameChange(typed, stored)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("initialFormValues", () => {
|
||||
it("prefills the credential's own name, label, and provider over stored values that reuse those keys", () => {
|
||||
const credential: CredentialItem = {
|
||||
credential_name: "legacy-a",
|
||||
display_name: "Legacy A",
|
||||
credential_values: {
|
||||
api_key: "sk-a****",
|
||||
credential_name: "other-credential",
|
||||
display_name: "Inner label",
|
||||
custom_llm_provider: "anthropic",
|
||||
},
|
||||
credential_info: { custom_llm_provider: "openai" },
|
||||
};
|
||||
|
||||
const expectedValues = {
|
||||
api_key: "sk-a****",
|
||||
credential_name: "legacy-a",
|
||||
display_name: "Legacy A",
|
||||
custom_llm_provider: "openai",
|
||||
};
|
||||
|
||||
expect(initialFormValues(credential, null)).toEqual(expectedValues);
|
||||
});
|
||||
|
||||
it("prefills an empty label when the credential has none and only the provider when adding", () => {
|
||||
const credential: CredentialItem = { credential_name: "plain", credential_values: {}, credential_info: {} };
|
||||
|
||||
expect(initialFormValues(credential, null)).toMatchObject({ credential_name: "plain", display_name: "" });
|
||||
expect(initialFormValues(null, "OpenAI")).toEqual({ custom_llm_provider: "OpenAI" });
|
||||
expect(initialFormValues(null, null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import type { MountedFormValues } from "../common_components/MountedFormField";
|
||||
import type { CredentialItem } from "../networking";
|
||||
|
||||
interface CredentialFormAdapter {
|
||||
getFieldValue: (field: string) => unknown;
|
||||
resetFields: () => void;
|
||||
|
|
@ -23,15 +26,53 @@ interface CredentialFormAdapter {
|
|||
*/
|
||||
const restrictedFields: readonly string[] = ["credential_name", "display_name", "custom_llm_provider"];
|
||||
|
||||
export const buildCredential = (values: Record<string, unknown>, credentialValues: Record<string, unknown>) => ({
|
||||
credential_name: values.credential_name as string,
|
||||
...(values.display_name !== undefined ? { display_name: values.display_name as string | null } : {}),
|
||||
export type CredentialSubmission = {
|
||||
readonly credential_name: string;
|
||||
readonly display_name?: string | null;
|
||||
readonly custom_llm_provider: string;
|
||||
readonly credential_values: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const buildCredential = (submission: CredentialSubmission, credentialValues: Record<string, unknown>) => ({
|
||||
credential_name: submission.credential_name,
|
||||
...(submission.display_name !== undefined ? { display_name: submission.display_name } : {}),
|
||||
credential_values: credentialValues,
|
||||
credential_info: {
|
||||
custom_llm_provider: values.custom_llm_provider as string,
|
||||
custom_llm_provider: submission.custom_llm_provider,
|
||||
},
|
||||
});
|
||||
|
||||
export const displayNameChange = (
|
||||
value: unknown,
|
||||
existingCredential: CredentialItem | null | undefined,
|
||||
): { display_name?: string | null } => {
|
||||
const typed = typeof value === "string" ? value : "";
|
||||
if (!existingCredential) {
|
||||
return typed ? { display_name: typed } : {};
|
||||
}
|
||||
if (typed === (existingCredential.display_name ?? "")) {
|
||||
return {};
|
||||
}
|
||||
return { display_name: typed || null };
|
||||
};
|
||||
|
||||
export const initialFormValues = (
|
||||
existingCredential: CredentialItem | null | undefined,
|
||||
initialProvider: string | null | undefined,
|
||||
): MountedFormValues | undefined => {
|
||||
if (existingCredential) {
|
||||
return {
|
||||
...Object.fromEntries(
|
||||
Object.entries(existingCredential.credential_values || {}).map(([key, value]) => [key, value ?? null]),
|
||||
),
|
||||
credential_name: existingCredential.credential_name,
|
||||
display_name: existingCredential.display_name ?? "",
|
||||
custom_llm_provider: existingCredential.credential_info.custom_llm_provider,
|
||||
};
|
||||
}
|
||||
return initialProvider ? { custom_llm_provider: initialProvider } : undefined;
|
||||
};
|
||||
|
||||
export const withoutRestrictedFields = (values: Record<string, unknown>): Record<string, unknown> =>
|
||||
Object.fromEntries(Object.entries(values).filter(([key]) => !restrictedFields.includes(key)));
|
||||
|
||||
|
|
|
|||
|
|
@ -1560,7 +1560,7 @@ describe("ModelInfoView", () => {
|
|||
|
||||
const dialog = await screen.findByRole("dialog", { name: "Using Existing Credential" });
|
||||
await vi.waitFor(() => expect(dialog).toHaveTextContent("Selected Label"));
|
||||
expect(dialog).not.toHaveTextContent("selected-credential");
|
||||
expect(dialog).toHaveTextContent("selected-credential");
|
||||
});
|
||||
|
||||
it.each([["auto_router/adaptive_router"], ["auto_router/quality_router"]])(
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import DeleteResourceModal from "./common_components/DeleteResourceModal";
|
|||
import EditAutoRouterModal from "./edit_auto_router/edit_auto_router_modal";
|
||||
import ReuseCredentialsModal from "./model_add/reuse_credentials";
|
||||
import { credentialLabelsByName } from "./shared/credentialOptions";
|
||||
import { CredentialIdentity } from "./shared/CredentialIdentity";
|
||||
import { toast } from "@/lib/toast";
|
||||
import {
|
||||
CredentialItem,
|
||||
|
|
@ -809,10 +810,13 @@ export default function ModelInfoView({
|
|||
<DialogHeader>
|
||||
<DialogTitle>Using Existing Credential</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm">
|
||||
{credentialLabelsByName(credentialsList).get(modelData.litellm_params.litellm_credential_name ?? "") ??
|
||||
modelData.litellm_params.litellm_credential_name}
|
||||
</p>
|
||||
<CredentialIdentity
|
||||
className="text-sm"
|
||||
credentialName={modelData.litellm_params.litellm_credential_name ?? ""}
|
||||
label={credentialLabelsByName(credentialsList).get(
|
||||
modelData.litellm_params.litellm_credential_name ?? "",
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCredentialModalOpen(false)}>
|
||||
Cancel
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
import type { CredentialItem } from "@/components/networking";
|
||||
import { credentialLabelsByName } from "@/components/shared/credentialOptions";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
interface CredentialIdentityProps {
|
||||
credentialName: string;
|
||||
label: string | undefined;
|
||||
icon?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CredentialIdentity({ credentialName, label, icon, className }: CredentialIdentityProps) {
|
||||
const shown = label ?? credentialName;
|
||||
return (
|
||||
<span className={cn("flex min-w-0 flex-col gap-0.5", className)} title={credentialName}>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
{icon}
|
||||
<span className="truncate">{shown}</span>
|
||||
</span>
|
||||
{shown !== credentialName && (
|
||||
<span className="truncate font-mono text-xs font-normal text-muted-foreground">{credentialName}</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface AttachedCredentialProps {
|
||||
credentialName: string | null | undefined;
|
||||
credentials: CredentialItem[];
|
||||
}
|
||||
|
||||
export function AttachedCredential({ credentialName, credentials }: AttachedCredentialProps) {
|
||||
if (!credentialName) {
|
||||
return <>Manual</>;
|
||||
}
|
||||
return (
|
||||
<CredentialIdentity
|
||||
credentialName={credentialName}
|
||||
label={credentialLabelsByName(credentials).get(credentialName)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function CredentialNameHint({ credentialName, credentials }: AttachedCredentialProps) {
|
||||
const label = credentialName ? credentialLabelsByName(credentials).get(credentialName) : undefined;
|
||||
if (!credentialName || label === undefined || label === credentialName) {
|
||||
return null;
|
||||
}
|
||||
return <p className="mt-1 truncate font-mono text-xs text-muted-foreground">{credentialName}</p>;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue