fix(studio): persist connection model selections for remote clients (#7298)
* fix(studio): persist connection model selections server-side Remote Studio clients could see saved connections but not their enabled model lists because models lived only in browser localStorage. Store models and available_models in llm_providers and sync them through the providers API so alternate clients inherit the same catalog state. Fixes #7281 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Hydrate external connections on chat startup (#7281) Extract provider sync logic into sync-external-providers.ts and call it from chat-page on mount so persisted model selections appear in the Connected picker without opening Settings → Connections first. * fix(studio): backfill connection models and preserve local options (#7298) Address Codex P2 on remote connection persistence: - Backfill localStorage model selections to /api/providers when backend rows still have empty models_json (legacy upgrades) - Carry promptCacheTtl and openaiContainerTtlMinutes through startup sync - Await hydratePersistedSettings before syncing on ChatPage mount Contract tests: 7 passed; npm run typecheck passed. * Tighten comments * Tighten comments --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Han <danielhanchen@gmail.com>
This commit is contained in:
parent
e2ccf4d376
commit
b448fb5de0
13 changed files with 530 additions and 184 deletions
|
|
@ -47,6 +47,14 @@ class ProviderCreate(BaseModel):
|
|||
None,
|
||||
description = "Custom base URL (overrides registry default). Omit to use the default.",
|
||||
)
|
||||
models: list[str] = Field(
|
||||
default_factory = list,
|
||||
description = "Enabled model IDs for this connection",
|
||||
)
|
||||
available_models: list[str] = Field(
|
||||
default_factory = list,
|
||||
description = "Discovered catalog model IDs last fetched for this connection",
|
||||
)
|
||||
|
||||
|
||||
class ProviderUpdate(BaseModel):
|
||||
|
|
@ -55,6 +63,11 @@ class ProviderUpdate(BaseModel):
|
|||
display_name: Optional[str] = Field(None, description = "New display name")
|
||||
base_url: Optional[str] = Field(None, description = "New base URL")
|
||||
is_enabled: Optional[bool] = Field(None, description = "Enable or disable this provider")
|
||||
models: Optional[list[str]] = Field(None, description = "Enabled model IDs for this connection")
|
||||
available_models: Optional[list[str]] = Field(
|
||||
None,
|
||||
description = "Discovered catalog model IDs last fetched for this connection",
|
||||
)
|
||||
|
||||
|
||||
class ProviderResponse(BaseModel):
|
||||
|
|
@ -65,6 +78,14 @@ class ProviderResponse(BaseModel):
|
|||
display_name: str = Field(..., description = "User-chosen label")
|
||||
base_url: str = Field(..., description = "API base URL")
|
||||
is_enabled: bool = Field(True, description = "Whether this provider is enabled")
|
||||
models: list[str] = Field(
|
||||
default_factory = list,
|
||||
description = "Enabled model IDs for this connection",
|
||||
)
|
||||
available_models: list[str] = Field(
|
||||
default_factory = list,
|
||||
description = "Discovered catalog model IDs last fetched for this connection",
|
||||
)
|
||||
created_at: str = Field(..., description = "ISO 8601 creation timestamp")
|
||||
updated_at: str = Field(..., description = "ISO 8601 last-update timestamp")
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,20 @@ logger = structlog.get_logger(__name__)
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
def _provider_response(row: dict) -> ProviderResponse:
|
||||
return ProviderResponse(
|
||||
id = row["id"],
|
||||
provider_type = row["provider_type"],
|
||||
display_name = row["display_name"],
|
||||
base_url = row["base_url"],
|
||||
is_enabled = bool(row["is_enabled"]),
|
||||
models = row.get("models") or [],
|
||||
available_models = row.get("available_models") or [],
|
||||
created_at = row["created_at"],
|
||||
updated_at = row["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
# ── Public key for API key encryption ─────────────────────────────
|
||||
|
||||
|
||||
|
|
@ -89,18 +103,7 @@ async def get_pricing_snapshot(current_subject: str = Depends(get_current_subjec
|
|||
async def list_provider_configs(current_subject: str = Depends(get_current_subject)):
|
||||
"""List all saved provider configurations."""
|
||||
rows = providers_db.list_providers()
|
||||
return [
|
||||
ProviderResponse(
|
||||
id = row["id"],
|
||||
provider_type = row["provider_type"],
|
||||
display_name = row["display_name"],
|
||||
base_url = row["base_url"],
|
||||
is_enabled = bool(row["is_enabled"]),
|
||||
created_at = row["created_at"],
|
||||
updated_at = row["updated_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
return [_provider_response(row) for row in rows]
|
||||
|
||||
|
||||
@router.post("/", response_model = ProviderResponse, status_code = 201)
|
||||
|
|
@ -124,18 +127,12 @@ async def create_provider_config(
|
|||
provider_type = payload.provider_type,
|
||||
display_name = payload.display_name,
|
||||
base_url = base_url,
|
||||
models = payload.models,
|
||||
available_models = payload.available_models,
|
||||
)
|
||||
|
||||
row = providers_db.get_provider(provider_id)
|
||||
return ProviderResponse(
|
||||
id = row["id"],
|
||||
provider_type = row["provider_type"],
|
||||
display_name = row["display_name"],
|
||||
base_url = row["base_url"],
|
||||
is_enabled = bool(row["is_enabled"]),
|
||||
created_at = row["created_at"],
|
||||
updated_at = row["updated_at"],
|
||||
)
|
||||
return _provider_response(row)
|
||||
|
||||
|
||||
@router.put("/{provider_id}", response_model = ProviderResponse)
|
||||
|
|
@ -154,20 +151,14 @@ async def update_provider_config(
|
|||
display_name = payload.display_name,
|
||||
base_url = payload.base_url,
|
||||
is_enabled = payload.is_enabled,
|
||||
models = payload.models,
|
||||
available_models = payload.available_models,
|
||||
)
|
||||
if not updated:
|
||||
raise HTTPException(status_code = 400, detail = "No fields to update")
|
||||
|
||||
row = providers_db.get_provider(provider_id)
|
||||
return ProviderResponse(
|
||||
id = row["id"],
|
||||
provider_type = row["provider_type"],
|
||||
display_name = row["display_name"],
|
||||
base_url = row["base_url"],
|
||||
is_enabled = bool(row["is_enabled"]),
|
||||
created_at = row["created_at"],
|
||||
updated_at = row["updated_at"],
|
||||
)
|
||||
return _provider_response(row)
|
||||
|
||||
|
||||
@router.delete("/{provider_id}", status_code = 204)
|
||||
|
|
|
|||
|
|
@ -6,8 +6,12 @@
|
|||
Same pattern as studio_db.py (module-level functions, raw sqlite3, WAL,
|
||||
per-function connections). API keys are NOT stored here: they live only in
|
||||
the browser (localStorage) and are sent encrypted per-request.
|
||||
|
||||
Enabled model selections and discovered catalog IDs are stored server-side so
|
||||
remote Studio clients see the same connection state (#7281).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
|
|
@ -22,6 +26,33 @@ _schema_lock = threading.Lock()
|
|||
_schema_ready = False
|
||||
|
||||
|
||||
def _encode_models_json(models: Optional[list[str]]) -> str:
|
||||
if not models:
|
||||
return "[]"
|
||||
return json.dumps([str(model).strip() for model in models if str(model).strip()])
|
||||
|
||||
|
||||
def _decode_models_json(raw: Optional[str]) -> list[str]:
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
if not isinstance(parsed, list):
|
||||
return []
|
||||
return [str(model).strip() for model in parsed if str(model).strip()]
|
||||
|
||||
|
||||
def _row_models(row: sqlite3.Row) -> tuple[list[str], list[str]]:
|
||||
return (
|
||||
_decode_models_json(row["models_json"] if "models_json" in row.keys() else None),
|
||||
_decode_models_json(
|
||||
row["available_models_json"] if "available_models_json" in row.keys() else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _ensure_schema(conn: sqlite3.Connection) -> None:
|
||||
"""Create the llm_providers table if absent. Called once per process."""
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
|
|
@ -38,6 +69,13 @@ def _ensure_schema(conn: sqlite3.Connection) -> None:
|
|||
)
|
||||
"""
|
||||
)
|
||||
existing_cols = {row[1] for row in conn.execute("PRAGMA table_info(llm_providers)").fetchall()}
|
||||
if "models_json" not in existing_cols:
|
||||
conn.execute("ALTER TABLE llm_providers ADD COLUMN models_json TEXT NOT NULL DEFAULT '[]'")
|
||||
if "available_models_json" not in existing_cols:
|
||||
conn.execute(
|
||||
"ALTER TABLE llm_providers ADD COLUMN available_models_json TEXT NOT NULL DEFAULT '[]'"
|
||||
)
|
||||
|
||||
|
||||
def get_connection() -> sqlite3.Connection:
|
||||
|
|
@ -59,17 +97,37 @@ def get_connection() -> sqlite3.Connection:
|
|||
return conn
|
||||
|
||||
|
||||
def create_provider(id: str, provider_type: str, display_name: str, base_url: str) -> None:
|
||||
def create_provider(
|
||||
id: str,
|
||||
provider_type: str,
|
||||
display_name: str,
|
||||
base_url: str,
|
||||
models: Optional[list[str]] = None,
|
||||
available_models: Optional[list[str]] = None,
|
||||
) -> None:
|
||||
"""Insert a new provider configuration."""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO llm_providers (id, provider_type, display_name, base_url, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO llm_providers (
|
||||
id, provider_type, display_name, base_url,
|
||||
models_json, available_models_json,
|
||||
created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(id, provider_type, display_name, base_url, now, now),
|
||||
(
|
||||
id,
|
||||
provider_type,
|
||||
display_name,
|
||||
base_url,
|
||||
_encode_models_json(models),
|
||||
_encode_models_json(available_models),
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
|
|
@ -81,6 +139,8 @@ def update_provider(
|
|||
display_name: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
is_enabled: Optional[bool] = None,
|
||||
models: Optional[list[str]] = None,
|
||||
available_models: Optional[list[str]] = None,
|
||||
) -> bool:
|
||||
"""Update fields on an existing provider. Returns True if a row was updated."""
|
||||
updates = []
|
||||
|
|
@ -94,6 +154,12 @@ def update_provider(
|
|||
if is_enabled is not None:
|
||||
updates.append("is_enabled = ?")
|
||||
params.append(1 if is_enabled else 0)
|
||||
if models is not None:
|
||||
updates.append("models_json = ?")
|
||||
params.append(_encode_models_json(models))
|
||||
if available_models is not None:
|
||||
updates.append("available_models_json = ?")
|
||||
params.append(_encode_models_json(available_models))
|
||||
if not updates:
|
||||
return False
|
||||
updates.append("updated_at = ?")
|
||||
|
|
@ -128,7 +194,13 @@ def get_provider(id: str) -> Optional[dict]:
|
|||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute("SELECT * FROM llm_providers WHERE id = ?", (id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
if not row:
|
||||
return None
|
||||
data = dict(row)
|
||||
models, available_models = _row_models(row)
|
||||
data["models"] = models
|
||||
data["available_models"] = available_models
|
||||
return data
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
|
@ -138,6 +210,13 @@ def list_providers() -> list[dict]:
|
|||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute("SELECT * FROM llm_providers ORDER BY created_at").fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
providers: list[dict] = []
|
||||
for row in rows:
|
||||
data = dict(row)
|
||||
models, available_models = _row_models(row)
|
||||
data["models"] = models
|
||||
data["available_models"] = available_models
|
||||
providers.append(data)
|
||||
return providers
|
||||
finally:
|
||||
conn.close()
|
||||
|
|
|
|||
70
studio/backend/tests/test_providers_db_models.py
Normal file
70
studio/backend/tests/test_providers_db_models.py
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Unit tests for provider model persistence (unslothai/unsloth#7281)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import storage.providers_db as providers_db
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def isolated_providers_db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
db_path = tmp_path / "studio.db"
|
||||
monkeypatch.setattr(providers_db, "studio_db_path", lambda: db_path)
|
||||
monkeypatch.setattr(providers_db, "ensure_dir", lambda _path: None)
|
||||
providers_db._schema_ready = False
|
||||
yield db_path
|
||||
providers_db._schema_ready = False
|
||||
|
||||
|
||||
def test_create_and_list_provider_models(isolated_providers_db: Path):
|
||||
providers_db.create_provider(
|
||||
id = "ollama1",
|
||||
provider_type = "ollama",
|
||||
display_name = "Home Ollama",
|
||||
base_url = "http://127.0.0.1:11434",
|
||||
models = ["llama3.2", "qwen2.5"],
|
||||
available_models = ["llama3.2", "qwen2.5", "mistral"],
|
||||
)
|
||||
|
||||
row = providers_db.get_provider("ollama1")
|
||||
assert row is not None
|
||||
assert row["models"] == ["llama3.2", "qwen2.5"]
|
||||
assert row["available_models"] == ["llama3.2", "qwen2.5", "mistral"]
|
||||
|
||||
listed = providers_db.list_providers()
|
||||
assert len(listed) == 1
|
||||
assert listed[0]["models"] == ["llama3.2", "qwen2.5"]
|
||||
|
||||
|
||||
def test_update_provider_models(isolated_providers_db: Path):
|
||||
providers_db.create_provider(
|
||||
id = "vllm1",
|
||||
provider_type = "vllm",
|
||||
display_name = "Remote vLLM",
|
||||
base_url = "http://studio-host:8000/v1",
|
||||
models = ["meta-llama/Llama-3.2-1B-Instruct"],
|
||||
available_models = ["meta-llama/Llama-3.2-1B-Instruct"],
|
||||
)
|
||||
|
||||
assert providers_db.update_provider(
|
||||
id = "vllm1",
|
||||
models = ["meta-llama/Llama-3.2-3B-Instruct"],
|
||||
available_models = [
|
||||
"meta-llama/Llama-3.2-1B-Instruct",
|
||||
"meta-llama/Llama-3.2-3B-Instruct",
|
||||
],
|
||||
)
|
||||
|
||||
row = providers_db.get_provider("vllm1")
|
||||
assert row is not None
|
||||
assert row["models"] == ["meta-llama/Llama-3.2-3B-Instruct"]
|
||||
assert row["available_models"] == [
|
||||
"meta-llama/Llama-3.2-1B-Instruct",
|
||||
"meta-llama/Llama-3.2-3B-Instruct",
|
||||
]
|
||||
|
|
@ -83,7 +83,7 @@ function CopyBtn({ text }: { text: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
/** Save the executed script as a .py file via a client-side Blob (no server file serving). */
|
||||
/** Save the script as a .py file via a client-side Blob. */
|
||||
function DownloadBtn({ code, name = "script.py" }: { code: string; name?: string }) {
|
||||
const download = useCallback(() => {
|
||||
if (typeof document === "undefined") {
|
||||
|
|
@ -229,8 +229,8 @@ const PythonToolUIImpl: ToolCallMessagePartComponent = ({
|
|||
const authToken = getAuthToken();
|
||||
|
||||
return (
|
||||
// Run status and output collapse from history, but the script source is
|
||||
// rendered outside ToolFallbackContent so it stays visible on reopen (#7165).
|
||||
// Status/output collapse from history; the script source renders outside
|
||||
// ToolFallbackContent so it stays visible on reopen (#7165).
|
||||
<ToolFallbackRoot defaultOpen={isRunning}>
|
||||
<ToolFallbackTrigger
|
||||
toolName={firstLine ? `Python: ${firstLine}` : "Python"}
|
||||
|
|
|
|||
|
|
@ -92,10 +92,8 @@ export const OPTIMIZER_OPTIONS: ReadonlyArray<{ value: string; label: string }>
|
|||
{ value: "adamw_torch_fused", label: "AdamW (PyTorch Fused)" },
|
||||
];
|
||||
|
||||
// Optimizers the MLX trainer actually supports on Apple Silicon. Values must
|
||||
// match SUPPORTED_MLX_OPTIMIZERS in unsloth-zoo's mlx/trainer.py; on MLX the
|
||||
// bitsandbytes/torch names above have no meaning and are remapped to plain
|
||||
// AdamW, so Studio offers this list instead when running on a Mac.
|
||||
// MLX trainer optimizers (Apple Silicon); must match SUPPORTED_MLX_OPTIMIZERS in
|
||||
// unsloth-zoo's mlx/trainer.py. The CUDA/torch names above are remapped to AdamW on MLX.
|
||||
export const MLX_OPTIMIZER_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
|
||||
{ value: "adamw", label: "AdamW" },
|
||||
{ value: "adam", label: "Adam" },
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ export interface ProviderConfig {
|
|||
display_name: string;
|
||||
base_url: string;
|
||||
is_enabled: boolean;
|
||||
models?: string[];
|
||||
available_models?: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
|
@ -123,6 +125,8 @@ export async function createProviderConfig(payload: {
|
|||
providerType: string;
|
||||
displayName: string;
|
||||
baseUrl?: string | null;
|
||||
models?: string[];
|
||||
availableModels?: string[];
|
||||
}): Promise<ProviderConfig> {
|
||||
const response = await authFetch("/api/providers/", {
|
||||
method: "POST",
|
||||
|
|
@ -131,6 +135,8 @@ export async function createProviderConfig(payload: {
|
|||
provider_type: payload.providerType,
|
||||
display_name: payload.displayName,
|
||||
base_url: payload.baseUrl ?? null,
|
||||
models: payload.models ?? [],
|
||||
available_models: payload.availableModels ?? [],
|
||||
}),
|
||||
});
|
||||
return parseJsonOrThrow<ProviderConfig>(response);
|
||||
|
|
@ -158,6 +164,8 @@ export async function updateProviderConfig(
|
|||
displayName?: string;
|
||||
baseUrl?: string | null;
|
||||
isEnabled?: boolean;
|
||||
models?: string[];
|
||||
availableModels?: string[];
|
||||
},
|
||||
): Promise<ProviderConfig> {
|
||||
const response = await authFetch(`/api/providers/${providerId}`, {
|
||||
|
|
@ -167,6 +175,10 @@ export async function updateProviderConfig(
|
|||
...(payload.displayName === undefined ? {} : { display_name: payload.displayName }),
|
||||
...(payload.baseUrl === undefined ? {} : { base_url: payload.baseUrl }),
|
||||
...(payload.isEnabled === undefined ? {} : { is_enabled: payload.isEnabled }),
|
||||
...(payload.models === undefined ? {} : { models: payload.models }),
|
||||
...(payload.availableModels === undefined
|
||||
? {}
|
||||
: { available_models: payload.availableModels }),
|
||||
}),
|
||||
});
|
||||
return parseJsonOrThrow<ProviderConfig>(response);
|
||||
|
|
|
|||
|
|
@ -175,6 +175,7 @@ import {
|
|||
} from "./stores/chat-runtime-store";
|
||||
import { useChatPreferencesStore } from "./stores/chat-preferences-store";
|
||||
import { useExternalProvidersStore } from "./stores/external-providers-store";
|
||||
import { syncExternalProvidersFromBackend } from "./sync-external-providers";
|
||||
import { buildChatTourSteps } from "./tour";
|
||||
import type { ChatView, MessageRecord } from "./types";
|
||||
import {
|
||||
|
|
@ -1762,8 +1763,18 @@ export function ChatPage({
|
|||
const externalProvidersForChat = connectionsEnabled ? externalProviders : [];
|
||||
|
||||
useEffect(() => {
|
||||
void hydratePersistedSettings();
|
||||
}, [hydratePersistedSettings]);
|
||||
void (async () => {
|
||||
await hydratePersistedSettings();
|
||||
try {
|
||||
const synced = await syncExternalProvidersFromBackend(
|
||||
useExternalProvidersStore.getState().providers,
|
||||
);
|
||||
setExternalProviders(synced);
|
||||
} catch {
|
||||
// Silent on startup; Connections settings still surfaces load errors.
|
||||
}
|
||||
})();
|
||||
}, [hydratePersistedSettings, setExternalProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip while off-route: ChatPage stays mounted, and toast+navigate here would
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ import {
|
|||
type ProviderRegistryEntry,
|
||||
createProviderConfig,
|
||||
deleteProviderConfig,
|
||||
listProviderConfigs,
|
||||
listProviderModels,
|
||||
listProviderRegistry,
|
||||
testProviderConnection,
|
||||
|
|
@ -49,7 +48,6 @@ import {
|
|||
} from "./api/providers-api";
|
||||
import type { ExternalProviderConfig } from "./external-providers";
|
||||
import {
|
||||
CUSTOM_BACKEND_PROVIDER_TYPE,
|
||||
CUSTOM_PROVIDER_PRESETS,
|
||||
allowsManualModelIdsWithCatalog,
|
||||
customProviderBaseUrlPlaceholder,
|
||||
|
|
@ -68,6 +66,10 @@ import {
|
|||
toExternalBackendProviderType,
|
||||
} from "./external-providers";
|
||||
import { useExternalProvidersStore } from "./stores/external-providers-store";
|
||||
import {
|
||||
pruneProviderModelIds,
|
||||
syncExternalProvidersFromBackend,
|
||||
} from "./sync-external-providers";
|
||||
|
||||
/** Matches navbar / thread layout easing (see index.css --ease-out-quart) */
|
||||
const PROVIDER_FORM_EASE: [number, number, number, number] = [
|
||||
|
|
@ -76,58 +78,7 @@ const PROVIDER_FORM_EASE: [number, number, number, number] = [
|
|||
const PROVIDER_FORM_DURATION = 0.2;
|
||||
const CUSTOM_PROVIDER_MISSING_KEY_MESSAGE =
|
||||
"No API key found. Add a valid API key for this connection.";
|
||||
const ANTHROPIC_DATED_SNAPSHOT_SUFFIX = /-\d{8}$/;
|
||||
const OPENAI_DEPRECATED_MODELS = new Set(["gpt-5.3"]);
|
||||
const HIDDEN_PROVIDER_TYPES = new Set(["qwen"]);
|
||||
const OPENROUTER_EXCLUDED_MODELS = new Set([
|
||||
"google/chirp-3",
|
||||
"kwaivgi/kling-v3.0-pro",
|
||||
"openai/whisper-1",
|
||||
"openai/gpt-4o-mini-transcribe",
|
||||
"recraft/recraft-v4-pro",
|
||||
]);
|
||||
|
||||
function normalizeUrl(input: string): string {
|
||||
return input.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function resolveUiProviderTypeFromConfig(
|
||||
configProviderType: string,
|
||||
configDisplayName: string | null | undefined,
|
||||
configBaseUrl: string | null | undefined,
|
||||
registryRows: ProviderRegistryEntry[],
|
||||
existingProviderType: string | undefined,
|
||||
): string {
|
||||
if (existingProviderType && isCustomProviderType(existingProviderType)) {
|
||||
return existingProviderType;
|
||||
}
|
||||
if (configProviderType !== CUSTOM_BACKEND_PROVIDER_TYPE) {
|
||||
return configProviderType;
|
||||
}
|
||||
const displayName = (configDisplayName ?? "").trim().toLowerCase();
|
||||
const matchingCustomPreset = CUSTOM_PROVIDER_PRESETS.find(
|
||||
(preset) => preset.displayName.toLowerCase() === displayName,
|
||||
);
|
||||
if (matchingCustomPreset) {
|
||||
return matchingCustomPreset.providerType;
|
||||
}
|
||||
const openAiRegistry = registryRows.find(
|
||||
(entry) => entry.provider_type === CUSTOM_BACKEND_PROVIDER_TYPE,
|
||||
);
|
||||
if (!openAiRegistry) {
|
||||
return configProviderType;
|
||||
}
|
||||
const openAiDisplayName = openAiRegistry.display_name.trim().toLowerCase();
|
||||
if (displayName.length > 0 && displayName !== openAiDisplayName) {
|
||||
return LEGACY_CUSTOM_PROVIDER_TYPE;
|
||||
}
|
||||
const configUrl = normalizeUrl(configBaseUrl ?? "");
|
||||
const defaultUrl = normalizeUrl(openAiRegistry.base_url ?? "");
|
||||
if (configUrl.length > 0 && configUrl !== defaultUrl) {
|
||||
return LEGACY_CUSTOM_PROVIDER_TYPE;
|
||||
}
|
||||
return configProviderType;
|
||||
}
|
||||
|
||||
function parseManualModelIds(text: string): string[] {
|
||||
const seen = new Set<string>();
|
||||
|
|
@ -182,19 +133,6 @@ function shouldAppendOpenAiVersionPath(providerType: string): boolean {
|
|||
);
|
||||
}
|
||||
|
||||
function pruneProviderModelIds(providerType: string, modelIds: string[]): string[] {
|
||||
if (providerType === "anthropic") {
|
||||
return modelIds.filter((id) => !ANTHROPIC_DATED_SNAPSHOT_SUFFIX.test(id));
|
||||
}
|
||||
if (providerType === "openai") {
|
||||
return modelIds.filter((id) => !OPENAI_DEPRECATED_MODELS.has(id));
|
||||
}
|
||||
if (providerType === "openrouter") {
|
||||
return modelIds.filter((id) => !OPENROUTER_EXCLUDED_MODELS.has(id));
|
||||
}
|
||||
return modelIds;
|
||||
}
|
||||
|
||||
function formatModelSummary(models: string[]): string {
|
||||
if (models.length === 0) {
|
||||
return "No models enabled";
|
||||
|
|
@ -360,9 +298,9 @@ export function ChatProvidersSettings({
|
|||
}
|
||||
let syncSucceeded = false;
|
||||
try {
|
||||
const [registryRows, configRows] = await Promise.all([
|
||||
const [registryRows, syncedProviders] = await Promise.all([
|
||||
listProviderRegistry(),
|
||||
listProviderConfigs(),
|
||||
syncExternalProvidersFromBackend(providersRef.current),
|
||||
]);
|
||||
if (!isMounted) return;
|
||||
syncSucceeded = true;
|
||||
|
|
@ -377,61 +315,6 @@ export function ChatProvidersSettings({
|
|||
}
|
||||
return registryRows[0]?.provider_type ?? "";
|
||||
});
|
||||
const existingById = new Map<string, ExternalProviderConfig>();
|
||||
for (const provider of providersRef.current) {
|
||||
existingById.set(provider.id, provider);
|
||||
}
|
||||
const syncedProviders: ExternalProviderConfig[] = configRows
|
||||
.filter((config) => config.is_enabled)
|
||||
.map((config) => {
|
||||
const existing = existingById.get(config.id);
|
||||
const uiProviderType = resolveUiProviderTypeFromConfig(
|
||||
config.provider_type,
|
||||
config.display_name,
|
||||
config.base_url,
|
||||
registryRows,
|
||||
existing?.providerType,
|
||||
);
|
||||
const createdAt = Number.isFinite(Date.parse(config.created_at))
|
||||
? Date.parse(config.created_at)
|
||||
: Date.now();
|
||||
const updatedAt = Number.isFinite(Date.parse(config.updated_at))
|
||||
? Date.parse(config.updated_at)
|
||||
: Date.now();
|
||||
const registryEntry =
|
||||
registryRows.find((entry) => entry.provider_type === uiProviderType) ??
|
||||
registryRows.find((entry) => entry.provider_type === config.provider_type);
|
||||
const defaultModels = pruneProviderModelIds(
|
||||
uiProviderType,
|
||||
registryEntry?.default_models ?? [],
|
||||
);
|
||||
const savedModels = existing?.models ?? [];
|
||||
const savedAvailableModels = existing?.availableModels ?? [];
|
||||
const existingModels = pruneProviderModelIds(
|
||||
uiProviderType,
|
||||
savedModels.length > 0 ? savedModels : defaultModels,
|
||||
);
|
||||
const existingAvailableModels = pruneProviderModelIds(
|
||||
uiProviderType,
|
||||
savedAvailableModels.length > 0 ? savedAvailableModels : defaultModels,
|
||||
);
|
||||
return {
|
||||
id: config.id,
|
||||
providerType: uiProviderType,
|
||||
name: config.display_name,
|
||||
baseUrl: config.base_url ?? "",
|
||||
models: existingModels,
|
||||
availableModels: existingAvailableModels,
|
||||
enablePromptCaching: supportsProviderPromptCaching(uiProviderType)
|
||||
? (existing?.enablePromptCaching ?? true)
|
||||
: undefined,
|
||||
isReasoningModel: supportsProviderReasoningToggle(uiProviderType)
|
||||
? existing?.isReasoningModel === true
|
||||
: undefined,
|
||||
createdAt: existing?.createdAt ?? createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
});
|
||||
// Trust the backend response. An empty array means every connection was
|
||||
// removed (often from another tab); mirror that locally, else stale
|
||||
// entries become un-removable here until localStorage is cleared.
|
||||
|
|
@ -699,6 +582,10 @@ export function ChatProvidersSettings({
|
|||
providerType: backendProviderType,
|
||||
displayName,
|
||||
baseUrl,
|
||||
models: modelsToSave,
|
||||
availableModels: manualOnly
|
||||
? []
|
||||
: pruneProviderModelIds(providerType, availableModels),
|
||||
});
|
||||
const createdAt = Number.isFinite(Date.parse(created.created_at))
|
||||
? Date.parse(created.created_at)
|
||||
|
|
@ -814,6 +701,10 @@ export function ChatProvidersSettings({
|
|||
customProviderDisplayName(existing.providerType)
|
||||
: existing.name,
|
||||
baseUrl,
|
||||
models: modelsToSave,
|
||||
availableModels: manualOnly
|
||||
? []
|
||||
: pruneProviderModelIds(existing.providerType, availableModels),
|
||||
});
|
||||
if (apiKey.trim()) {
|
||||
setExternalProviderApiKey(editingProviderId, apiKey.trim());
|
||||
|
|
|
|||
221
studio/frontend/src/features/chat/sync-external-providers.ts
Normal file
221
studio/frontend/src/features/chat/sync-external-providers.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
import {
|
||||
type ProviderRegistryEntry,
|
||||
listProviderConfigs,
|
||||
listProviderRegistry,
|
||||
updateProviderConfig,
|
||||
} from "./api/providers-api";
|
||||
import {
|
||||
CUSTOM_BACKEND_PROVIDER_TYPE,
|
||||
CUSTOM_PROVIDER_PRESETS,
|
||||
type ExternalProviderConfig,
|
||||
isCustomProviderType,
|
||||
isPromptCacheTtl,
|
||||
LEGACY_CUSTOM_PROVIDER_TYPE,
|
||||
supportsProviderPromptCaching,
|
||||
supportsProviderPromptCacheTtl,
|
||||
supportsProviderReasoningToggle,
|
||||
} from "./external-providers";
|
||||
|
||||
const ANTHROPIC_DATED_SNAPSHOT_SUFFIX = /-\d{8}$/;
|
||||
const OPENAI_DEPRECATED_MODELS = new Set(["gpt-5.3"]);
|
||||
const OPENROUTER_EXCLUDED_MODELS = new Set([
|
||||
"google/chirp-3",
|
||||
"kwaivgi/kling-v3.0-pro",
|
||||
"openai/whisper-1",
|
||||
"openai/gpt-4o-mini-transcribe",
|
||||
"recraft/recraft-v4-pro",
|
||||
]);
|
||||
|
||||
function normalizeUrl(input: string): string {
|
||||
return input.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
export function resolveUiProviderTypeFromConfig(
|
||||
configProviderType: string,
|
||||
configDisplayName: string | null | undefined,
|
||||
configBaseUrl: string | null | undefined,
|
||||
registryRows: ProviderRegistryEntry[],
|
||||
existingProviderType: string | undefined,
|
||||
): string {
|
||||
if (existingProviderType && isCustomProviderType(existingProviderType)) {
|
||||
return existingProviderType;
|
||||
}
|
||||
if (configProviderType !== CUSTOM_BACKEND_PROVIDER_TYPE) {
|
||||
return configProviderType;
|
||||
}
|
||||
const displayName = (configDisplayName ?? "").trim().toLowerCase();
|
||||
const matchingCustomPreset = CUSTOM_PROVIDER_PRESETS.find(
|
||||
(preset) => preset.displayName.toLowerCase() === displayName,
|
||||
);
|
||||
if (matchingCustomPreset) {
|
||||
return matchingCustomPreset.providerType;
|
||||
}
|
||||
const openAiRegistry = registryRows.find(
|
||||
(entry) => entry.provider_type === CUSTOM_BACKEND_PROVIDER_TYPE,
|
||||
);
|
||||
if (!openAiRegistry) {
|
||||
return configProviderType;
|
||||
}
|
||||
const openAiDisplayName = openAiRegistry.display_name.trim().toLowerCase();
|
||||
if (displayName.length > 0 && displayName !== openAiDisplayName) {
|
||||
return LEGACY_CUSTOM_PROVIDER_TYPE;
|
||||
}
|
||||
const configUrl = normalizeUrl(configBaseUrl ?? "");
|
||||
const defaultUrl = normalizeUrl(openAiRegistry.base_url ?? "");
|
||||
if (configUrl.length > 0 && configUrl !== defaultUrl) {
|
||||
return LEGACY_CUSTOM_PROVIDER_TYPE;
|
||||
}
|
||||
return configProviderType;
|
||||
}
|
||||
|
||||
export function pruneProviderModelIds(
|
||||
providerType: string,
|
||||
modelIds: string[],
|
||||
): string[] {
|
||||
if (providerType === "anthropic") {
|
||||
return modelIds.filter((id) => !ANTHROPIC_DATED_SNAPSHOT_SUFFIX.test(id));
|
||||
}
|
||||
if (providerType === "openai") {
|
||||
return modelIds.filter((id) => !OPENAI_DEPRECATED_MODELS.has(id));
|
||||
}
|
||||
if (providerType === "openrouter") {
|
||||
return modelIds.filter((id) => !OPENROUTER_EXCLUDED_MODELS.has(id));
|
||||
}
|
||||
return modelIds;
|
||||
}
|
||||
|
||||
/** Carry browser-local provider knobs through a backend sync rebuild. */
|
||||
export function mergeLocalProviderOptions(
|
||||
existing: ExternalProviderConfig | undefined,
|
||||
synced: ExternalProviderConfig,
|
||||
): ExternalProviderConfig {
|
||||
if (!existing) {
|
||||
return synced;
|
||||
}
|
||||
const providerType = synced.providerType;
|
||||
return {
|
||||
...synced,
|
||||
enablePromptCaching: supportsProviderPromptCaching(providerType)
|
||||
? (existing.enablePromptCaching ?? synced.enablePromptCaching ?? true)
|
||||
: undefined,
|
||||
promptCacheTtl:
|
||||
supportsProviderPromptCacheTtl(providerType) &&
|
||||
isPromptCacheTtl(existing.promptCacheTtl)
|
||||
? existing.promptCacheTtl
|
||||
: synced.promptCacheTtl,
|
||||
isReasoningModel: supportsProviderReasoningToggle(providerType)
|
||||
? (existing.isReasoningModel ?? synced.isReasoningModel)
|
||||
: undefined,
|
||||
openaiContainerTtlMinutes:
|
||||
providerType === "openai" &&
|
||||
typeof existing.openaiContainerTtlMinutes === "number" &&
|
||||
existing.openaiContainerTtlMinutes >= 1
|
||||
? Math.min(existing.openaiContainerTtlMinutes, 20)
|
||||
: synced.openaiContainerTtlMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
/** Merge enabled backend provider configs with local store state. */
|
||||
export async function syncExternalProvidersFromBackend(
|
||||
existingProviders: ExternalProviderConfig[],
|
||||
): Promise<ExternalProviderConfig[]> {
|
||||
const [registryRows, configRows] = await Promise.all([
|
||||
listProviderRegistry(),
|
||||
listProviderConfigs(),
|
||||
]);
|
||||
|
||||
const existingById = new Map<string, ExternalProviderConfig>();
|
||||
for (const provider of existingProviders) {
|
||||
existingById.set(provider.id, provider);
|
||||
}
|
||||
|
||||
const backfillTasks: Promise<unknown>[] = [];
|
||||
const syncedProviders = configRows
|
||||
.filter((config) => config.is_enabled)
|
||||
.map((config) => {
|
||||
const existing = existingById.get(config.id);
|
||||
const uiProviderType = resolveUiProviderTypeFromConfig(
|
||||
config.provider_type,
|
||||
config.display_name,
|
||||
config.base_url,
|
||||
registryRows,
|
||||
existing?.providerType,
|
||||
);
|
||||
const createdAt = Number.isFinite(Date.parse(config.created_at))
|
||||
? Date.parse(config.created_at)
|
||||
: Date.now();
|
||||
const updatedAt = Number.isFinite(Date.parse(config.updated_at))
|
||||
? Date.parse(config.updated_at)
|
||||
: Date.now();
|
||||
const registryEntry =
|
||||
registryRows.find((entry) => entry.provider_type === uiProviderType) ??
|
||||
registryRows.find((entry) => entry.provider_type === config.provider_type);
|
||||
const defaultModels = pruneProviderModelIds(
|
||||
uiProviderType,
|
||||
registryEntry?.default_models ?? [],
|
||||
);
|
||||
const serverModels = pruneProviderModelIds(
|
||||
uiProviderType,
|
||||
config.models ?? [],
|
||||
);
|
||||
const serverAvailableModels = pruneProviderModelIds(
|
||||
uiProviderType,
|
||||
config.available_models ?? [],
|
||||
);
|
||||
const savedModels = existing?.models ?? [];
|
||||
const savedAvailableModels = existing?.availableModels ?? [];
|
||||
const resolvedModels = pruneProviderModelIds(
|
||||
uiProviderType,
|
||||
serverModels.length > 0
|
||||
? serverModels
|
||||
: savedModels.length > 0
|
||||
? savedModels
|
||||
: defaultModels,
|
||||
);
|
||||
const resolvedAvailableModels = pruneProviderModelIds(
|
||||
uiProviderType,
|
||||
serverAvailableModels.length > 0
|
||||
? serverAvailableModels
|
||||
: savedAvailableModels.length > 0
|
||||
? savedAvailableModels
|
||||
: defaultModels,
|
||||
);
|
||||
const needsModelBackfill =
|
||||
serverModels.length === 0 && savedModels.length > 0;
|
||||
const needsAvailableBackfill =
|
||||
serverAvailableModels.length === 0 && savedAvailableModels.length > 0;
|
||||
if (needsModelBackfill || needsAvailableBackfill) {
|
||||
backfillTasks.push(
|
||||
updateProviderConfig(config.id, {
|
||||
models: resolvedModels,
|
||||
availableModels: resolvedAvailableModels,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const synced: ExternalProviderConfig = {
|
||||
id: config.id,
|
||||
providerType: uiProviderType,
|
||||
name: config.display_name,
|
||||
baseUrl: config.base_url ?? "",
|
||||
models: resolvedModels,
|
||||
availableModels: resolvedAvailableModels,
|
||||
enablePromptCaching: supportsProviderPromptCaching(uiProviderType)
|
||||
? (existing?.enablePromptCaching ?? true)
|
||||
: undefined,
|
||||
isReasoningModel: supportsProviderReasoningToggle(uiProviderType)
|
||||
? existing?.isReasoningModel === true
|
||||
: undefined,
|
||||
createdAt: existing?.createdAt ?? createdAt,
|
||||
updatedAt,
|
||||
};
|
||||
return mergeLocalProviderOptions(existing, synced);
|
||||
});
|
||||
|
||||
if (backfillTasks.length > 0) {
|
||||
await Promise.allSettled(backfillTasks);
|
||||
}
|
||||
return syncedProviders;
|
||||
}
|
||||
|
|
@ -205,26 +205,19 @@ export function ParamsSection(): ReactElement {
|
|||
setCtxInput(String(store.contextLength));
|
||||
}, [store.contextLength]);
|
||||
|
||||
// On Apple Silicon the MLX trainer supports a different optimizer set than
|
||||
// the CUDA/bitsandbytes list, so offer the MLX names there.
|
||||
// Apple Silicon (MLX) supports a different optimizer set than the CUDA list.
|
||||
const isMac = platformDeviceType === "mac";
|
||||
const optimizerOptions = isMac ? MLX_OPTIMIZER_OPTIONS : OPTIMIZER_OPTIONS;
|
||||
|
||||
// On Mac, the MLX backend normalizes every CUDA/bitsandbytes optimizer in
|
||||
// OPTIMIZER_OPTIONS (including the shared default) to plain AdamW, so show
|
||||
// AdamW for those to keep the control truthful and non-blank. Any other
|
||||
// value -- an MLX optimizer the user picked, or an unrecognized/non-canonical
|
||||
// imported one -- is shown as-is rather than mislabeled as AdamW, since the
|
||||
// backend would run or reject it on its own terms. Non-Mac display unchanged.
|
||||
// On Mac the MLX backend remaps CUDA optimizers to AdamW, so label those as
|
||||
// AdamW; other values (MLX or imported) show as-is. Non-Mac unchanged.
|
||||
const isCudaAliasOptimizer = OPTIMIZER_OPTIONS.some(
|
||||
(o) => o.value === store.optimizerType,
|
||||
);
|
||||
const selectedOptimizer =
|
||||
isMac && isCudaAliasOptimizer ? "adamw" : store.optimizerType;
|
||||
|
||||
// LoftQ is not supported on MLX (the backend rejects it), so clear a stale
|
||||
// selection to lora on Apple Silicon -- whether persisted, applied from a
|
||||
// model default, or imported -- so the backend never receives it.
|
||||
// LoftQ is unsupported on MLX; clear a stale selection to lora on Apple Silicon.
|
||||
const setLoraVariant = store.setLoraVariant;
|
||||
useEffect(() => {
|
||||
if (isMac && store.loraVariant === "loftq") {
|
||||
|
|
@ -232,8 +225,7 @@ export function ParamsSection(): ReactElement {
|
|||
}
|
||||
}, [isMac, store.loraVariant, setLoraVariant]);
|
||||
|
||||
// Packing is not supported on MLX (the backend forces it off), so clear it on
|
||||
// Apple Silicon -- the checkbox is disabled and the flag is never sent.
|
||||
// Packing is unsupported on MLX; clear it on Apple Silicon (checkbox disabled).
|
||||
const setPacking = store.setPacking;
|
||||
useEffect(() => {
|
||||
if (isMac && store.packing) {
|
||||
|
|
|
|||
|
|
@ -189,9 +189,8 @@ export function ProgressSection({
|
|||
const cfgLoraDropout = cfg?.loraDropout;
|
||||
const cfgLoraVariant = cfg?.loraVariant;
|
||||
|
||||
// Mirror the training form: on Mac the CUDA/bitsandbytes optimizer names run
|
||||
// as plain AdamW (the MLX backend normalizes them), so label them AdamW here
|
||||
// too rather than by the requested, unnormalized name.
|
||||
// Mirror the training form: on Mac the MLX backend runs CUDA optimizers as
|
||||
// AdamW, so label them AdamW here too.
|
||||
const effectiveOptimizer =
|
||||
platformDeviceType === "mac" &&
|
||||
OPTIMIZER_OPTIONS.some((o) => o.value === cfgOptimizerType)
|
||||
|
|
|
|||
61
tests/studio/test_remote_connection_models_contract.py
Normal file
61
tests/studio/test_remote_connection_models_contract.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Static contracts for remote connection model persistence (#7281)."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
FRONTEND = REPO / "studio/frontend/src"
|
||||
PROVIDERS_API = FRONTEND / "features/chat/api/providers-api.ts"
|
||||
SYNC_PROVIDERS = FRONTEND / "features/chat/sync-external-providers.ts"
|
||||
CHAT_PAGE = FRONTEND / "features/chat/chat-page.tsx"
|
||||
PROVIDERS_DB = REPO / "studio/backend/storage/providers_db.py"
|
||||
PROVIDERS_MODELS = REPO / "studio/backend/models/providers.py"
|
||||
|
||||
|
||||
def test_providers_db_stores_model_json_columns():
|
||||
source = PROVIDERS_DB.read_text(encoding = "utf-8")
|
||||
assert "models_json" in source
|
||||
assert "available_models_json" in source
|
||||
assert "ALTER TABLE llm_providers ADD COLUMN models_json" in source
|
||||
|
||||
|
||||
def test_provider_api_schemas_expose_models():
|
||||
source = PROVIDERS_MODELS.read_text(encoding = "utf-8")
|
||||
assert "models: list[str]" in source
|
||||
assert "available_models: list[str]" in source
|
||||
|
||||
|
||||
def test_frontend_sync_prefers_server_models_on_remote_clients():
|
||||
source = SYNC_PROVIDERS.read_text(encoding = "utf-8")
|
||||
assert "config.models" in source
|
||||
assert "config.available_models" in source
|
||||
assert "serverModels.length > 0" in source
|
||||
|
||||
|
||||
def test_frontend_sync_backfills_local_models_to_backend():
|
||||
source = SYNC_PROVIDERS.read_text(encoding = "utf-8")
|
||||
assert "updateProviderConfig" in source
|
||||
assert "needsModelBackfill" in source
|
||||
assert "Promise.allSettled(backfillTasks)" in source
|
||||
|
||||
|
||||
def test_frontend_sync_preserves_local_provider_options():
|
||||
source = SYNC_PROVIDERS.read_text(encoding = "utf-8")
|
||||
assert "mergeLocalProviderOptions" in source
|
||||
assert "promptCacheTtl" in source
|
||||
assert "openaiContainerTtlMinutes" in source
|
||||
|
||||
|
||||
def test_chat_page_hydrates_connections_on_startup():
|
||||
source = CHAT_PAGE.read_text(encoding = "utf-8")
|
||||
assert "syncExternalProvidersFromBackend" in source
|
||||
assert "await hydratePersistedSettings()" in source
|
||||
|
||||
|
||||
def test_providers_api_sends_models_to_backend():
|
||||
source = PROVIDERS_API.read_text(encoding = "utf-8")
|
||||
assert "available_models: payload.availableModels" in source
|
||||
assert "models: payload.models" in source
|
||||
Loading…
Add table
Add a link
Reference in a new issue