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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ Optional: add an ordered **Fallback Models** list under **Model Config**. It
applies to every connected client. A failed request may reach and consume usage
from more than one provider before succeeding.

For another API endpoint, open **Providers → Custom providers → Add provider**. Enter its API base URL, optional key, and API format. Leave **Model IDs** empty to discover models automatically, or enter one upstream model ID per line. Save, then select a model in **Model Config**.

<details>
<summary><strong>Provider catalog</strong></summary>

Expand Down
1 change: 1 addition & 0 deletions e2e/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ async def stream_responses(
response_model: str | None = None,
reasoning: ReasoningPolicy = DEFAULT_REASONING_POLICY,
request_headers: Mapping[str, str] | None = None,
model_info: ProviderModelInfo | None = None,
) -> AsyncIterator[str]:
if False:
yield ""
Expand Down
122 changes: 122 additions & 0 deletions e2e/test_custom_providers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Custom endpoint configuration through the real Admin Apply flow."""

import pytest
from playwright.sync_api import Page, expect


@pytest.fixture(autouse=True)
def no_page_errors(page: Page):
errors = []
page.on("pageerror", lambda error: errors.append(str(error)))
yield
assert errors == []


def add_provider(page: Page, name: str) -> str:
page.get_by_role("button", name="Add provider", exact=True).click()
dialog = page.locator("#providerDialog")
dialog.get_by_label("Name", exact=True).fill(name)
dialog.get_by_label("Base URL", exact=True).fill("https://gateway.example/team/v1")
dialog.get_by_label("API key", exact=True).fill("example-custom-key")
dialog.get_by_label("Model IDs", exact=True).fill("org/model-one\nmodel-two")
page.locator("#saveProvider").click()
expect(dialog).not_to_be_visible()
card = page.locator("#providers-custom .provider-card").filter(
has=page.get_by_text(name, exact=True)
)
expect(card).to_be_visible()
expect(page.locator("#addCustomProvider")).to_be_focused()
provider_id = card.get_attribute("data-provider")
assert provider_id is not None
return provider_id


def test_custom_provider_save_rename_and_remove_preserve_unrelated_draft(
page: Page, admin_base_url: str
):
page.goto(admin_base_url + "/admin")
expect(page.locator("#messageArea")).to_have_text("")
page.locator("#field-HTTP_READ_TIMEOUT").fill("181")
provider_id = add_provider(page, "Work gateway")
expect(page.locator("#field-HTTP_READ_TIMEOUT")).to_have_value("181")
card = page.locator(f'[data-provider="{provider_id}"]')
expect(card.locator(".provider-check-result")).to_have_text("2 models available")
card.get_by_role("button", name="Edit", exact=True).click()
dialog = page.locator("#providerDialog")
expect(dialog.get_by_label("API key", exact=True)).to_have_value("")
dialog.get_by_label("Name", exact=True).fill("Renamed gateway")
page.locator("#saveProvider").click()
expect(dialog).not_to_be_visible()
expect(card.locator(".provider-title")).to_have_text("Renamed gateway")
expect(card.get_by_role("button", name="Edit", exact=True)).to_be_focused()
card.get_by_role("button", name="Edit", exact=True).click()
dialog.get_by_role("button", name="Remove provider", exact=True).click()
expect(dialog).not_to_be_visible()
expect(card).to_have_count(0)
expect(page.locator("#addCustomProvider")).to_be_focused()
expect(page.locator("#field-HTTP_READ_TIMEOUT")).to_have_value("181")


def test_custom_provider_sorting_selection_and_blocked_removal(
page: Page, admin_base_url: str
):
page.goto(admin_base_url + "/admin")
add_provider(page, "Zulu gateway")
selected = add_provider(page, "Alpha gateway")
expect(page.locator("#providers-custom .provider-title")).to_have_text(
["Alpha gateway", "Zulu gateway"]
)
page.get_by_role("button", name="Model Config", exact=True).click()
model = page.locator("#field-MODEL_HAIKU")
model.fill("Alpha gateway")
page.get_by_role("option", name="Alpha gateway/org/model-one", exact=True).click()
expect(model).to_have_value(f"{selected}/org/model-one")
page.locator("#applyButton").click()
expect(page.locator("#dirtyState")).to_have_text("No changes")
page.get_by_role("button", name="Providers", exact=True).click()
page.locator(f'[data-provider="{selected}"]').get_by_role(
"button", name="Edit", exact=True
).click()
page.get_by_role("button", name="Remove provider", exact=True).click()
expect(page.locator("#providerMessage")).to_contain_text("MODEL")
expect(page.locator("#providerDialog")).to_be_visible()


def test_custom_dialog_cancel_and_format_changes(page: Page, admin_base_url: str):
page.goto(admin_base_url + "/admin")
page.get_by_role("button", name="Add provider", exact=True).click()
dialog = page.locator("#providerDialog")
dialog.get_by_label("Reasoning format", exact=True).select_option("thinking")
dialog.get_by_label("Reasoning history format", exact=True).select_option(
"reasoning_content"
)
dialog.get_by_label("API format", exact=True).select_option("anthropic_messages")
expect(dialog.get_by_label("Reasoning format", exact=True)).to_have_value(
"provider_default"
)
expect(
dialog.get_by_label("Reasoning history format", exact=True)
).not_to_be_visible()
page.keyboard.press("Escape")
expect(dialog).not_to_be_visible()
expect(page.locator("#providers-custom .provider-card")).to_have_count(0)
expect(page.locator("#addCustomProvider")).to_be_focused()


def test_duplicate_custom_provider_keeps_failed_save_draft(
page: Page, admin_base_url: str
):
page.goto(admin_base_url + "/admin")
add_provider(page, "Gateway")
page.get_by_role("button", name="Add provider", exact=True).click()
dialog = page.locator("#providerDialog")
dialog.get_by_label("Name", exact=True).fill("Gateway")
dialog.get_by_label("Base URL", exact=True).fill("https://another.example/v1")
page.locator("#saveProvider").click()
expect(page.locator("#providerMessage")).to_contain_text("already exists")
expect(dialog).to_be_visible()
expect(dialog.get_by_label("Base URL", exact=True)).to_have_value(
"https://another.example/v1"
)
expect(page.locator("#saveProvider")).to_be_enabled()
expect(page.locator("#providers-custom .provider-card")).to_have_count(1)
1 change: 1 addition & 0 deletions e2e/test_provider_modals.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def test_provider_groups_sort_each_subgroup_and_keep_setup_separate_from_health(
"OAuth providers",
"Cloud providers",
"Local providers",
"Custom providers",
]
for group in ("cloud", "local"):
for subgroup in ("configured", "unconfigured"):
Expand Down
11 changes: 10 additions & 1 deletion src/free_claude_code/api/admin_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from free_claude_code.application.errors import ApplicationError
from free_claude_code.application.model_catalog import read_model_catalog
from free_claude_code.application.model_metadata import ProviderModelRefreshResult
from free_claude_code.config.admin.custom_providers import CustomProviderMutation
from free_claude_code.config.admin.manifest import FIELD_BY_KEY
from free_claude_code.config.provider_catalog import (
PROVIDER_CATALOG,
Expand Down Expand Up @@ -76,6 +77,7 @@ class AdminConfigPayload(BaseModel):
"""Partial config update submitted by the admin UI."""

values: JsonObject = Field(default_factory=dict)
custom_provider: CustomProviderMutation | None = None


class ConnectedAccountLoginPayload(BaseModel):
Expand Down Expand Up @@ -136,6 +138,10 @@ async def apply_admin_config(
services: ApiServices = Depends(get_services),
):
require_loopback_admin(request)
if payload.custom_provider is not None:
return await services.admin.apply_admin_config(
_filtered_values(payload.values), payload.custom_provider
)
result = await services.admin.apply_admin_config(_filtered_values(payload.values))
return result

Expand Down Expand Up @@ -421,13 +427,16 @@ def _model_options(
services: ApiServices,
*,
refresh_result: ProviderModelRefreshResult | None = None,
) -> dict[str, list[str]]:
) -> JsonObject:
catalog = read_model_catalog(services.requests)
failed_provider_ids = (
refresh_result.failed_provider_ids if refresh_result is not None else ()
)
return {
"models": [model.provider_model_ref for model in catalog.models],
"model_labels": {
model.provider_model_ref: model.display_name for model in catalog.models
},
"failed_providers": list(failed_provider_ids),
}

Expand Down
Loading
Loading