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:
Souravrajvi0 2026-07-24 07:41:50 +05:30 committed by GitHub
commit b448fb5de0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 530 additions and 184 deletions

View file

@ -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")

View file

@ -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)

View file

@ -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()

View 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",
]

View file

@ -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"}

View file

@ -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" },

View file

@ -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);

View file

@ -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

View file

@ -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());

View 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;
}

View file

@ -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) {

View file

@ -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)

View 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