diff --git a/CHANGELOG.md b/CHANGELOG.md index beb6fc4b..7815a6f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.4.6] - 2026-06-15 + +### Added + +- 🎯 **Default model.** Pick a default model from Settings › Models. New chats and gateway requests will use it automatically instead of whichever model happens to be first in the list. + +### Changed + +- 🔄 **Smarter gateway model selection.** The gateway now respects your default model setting before falling back, and can auto-discover available models from providers that don't have a pre-configured list. + ## [0.4.5] - 2026-06-15 ### Fixed diff --git a/cptr/frontend/src/lib/components/Admin/Models.svelte b/cptr/frontend/src/lib/components/Admin/Models.svelte index 234a8d8a..e7bed0d9 100644 --- a/cptr/frontend/src/lib/components/Admin/Models.svelte +++ b/cptr/frontend/src/lib/components/Admin/Models.svelte @@ -11,6 +11,7 @@ import { t } from '$lib/i18n'; import Icon from '../Icon.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; + import ModelSelector from '$lib/components/common/ModelSelector.svelte'; type ParamRow = { key: string; value: string }; type ModelEntry = { @@ -34,6 +35,9 @@ let globalExpanded = $state(false); let showVariables = $state(false); + // Default model + let defaultModelId = $state(''); + // Context compaction let compactTokenThreshold = $state(80000); let compactDirty = $state(false); @@ -105,10 +109,11 @@ Files: loading = false; } - // Load context compaction threshold from admin config + // Load admin config (default model, context compaction) try { const adminCfg = await getAdminConfig(); compactTokenThreshold = Number(adminCfg['chat.compact_token_threshold']) || 80000; + defaultModelId = typeof adminCfg['chat.default_model'] === 'string' ? adminCfg['chat.default_model'] : ''; } catch {} }); @@ -158,11 +163,12 @@ Files: globalDirty = false; models.forEach((m) => (m.dirty = false)); - // Save context compaction threshold - if (compactDirty) { - await updateConfig({ 'chat.compact_token_threshold': compactTokenThreshold }); - compactDirty = false; - } + // Save default model + await updateConfig({ + 'chat.compact_token_threshold': compactTokenThreshold, + 'chat.default_model': defaultModelId + }); + compactDirty = false; toast.success($t('settings.saved')); } catch { @@ -276,6 +282,17 @@ Files: {$t('admin.models')} + +

+ {$t('models.defaultModel')} +

+
+ +
+

+ {$t('models.defaultModelHint')} +

+

{$t('admin.contextCompaction')}

diff --git a/cptr/frontend/src/lib/components/common/ModelSelector.svelte b/cptr/frontend/src/lib/components/common/ModelSelector.svelte index 4dff8164..ad3211e0 100644 --- a/cptr/frontend/src/lib/components/common/ModelSelector.svelte +++ b/cptr/frontend/src/lib/components/common/ModelSelector.svelte @@ -6,8 +6,10 @@ interface Props { selectedModel: string; + preferAbove?: boolean; + align?: 'start' | 'end'; } - let { selectedModel = $bindable() }: Props = $props(); + let { selectedModel = $bindable(), preferAbove = true, align = 'end' }: Props = $props(); let btnEl: HTMLButtonElement | undefined = $state(); let searchInputEl: HTMLInputElement | undefined = $state(); @@ -76,10 +78,10 @@ items={menuItems} anchor={btnEl} onclose={() => (open = false)} - preferAbove={true} + preferAbove={preferAbove} maxHeight="15rem" className="w-48" - align="end" + align={align} > {#snippet header()}
diff --git a/cptr/frontend/src/lib/i18n/locales/en.json b/cptr/frontend/src/lib/i18n/locales/en.json index 54e3d56a..c12180d7 100644 --- a/cptr/frontend/src/lib/i18n/locales/en.json +++ b/cptr/frontend/src/lib/i18n/locales/en.json @@ -307,6 +307,9 @@ "admin.browserFirecrawlBaseUrlHint": "Change for self-hosted Firecrawl instances", "admin.models": "Models", + "models.defaultModel": "Default model", + "models.defaultModelNone": "None (use first available)", + "models.defaultModelHint": "Pre-selected model for new chats and gateway requests.", "models.defaults": "Defaults", "models.noDefaults": "No global defaults", "models.noParams": "No parameters", diff --git a/cptr/routers/gateway.py b/cptr/routers/gateway.py index b8a438d4..b05958a4 100644 --- a/cptr/routers/gateway.py +++ b/cptr/routers/gateway.py @@ -527,7 +527,7 @@ async def _proxy_to_llm( max_tokens=200, ) except Exception as e: - logger.warning("[gateway] Utility task LLM call failed: %s", e) + logger.warning("[gateway] Utility task LLM call failed: %r", e) result = "" completion_id = f"chatcmpl-{uuid.uuid4().hex[:24]}" @@ -588,7 +588,8 @@ async def _resolve_model(workspace: str) -> tuple[dict, str, str]: Priority: 1. Gateway model selected in Settings > Gateway 2. Workspace-specific model override (.cptr/model) - 3. First enabled connection's first model + 3. Default model from Settings > Models (chat.default_model) + 4. First enabled connection's first model (with auto-discovery) Returns (connection_dict, bare_model, full_model_id). """ @@ -621,12 +622,29 @@ async def _resolve_model(workspace: str) -> tuple[dict, str, str]: model_override, ) + # Try the default model (set in Settings > Models) + default_model = await Config.get("chat.default_model") + if isinstance(default_model, str) and default_model.strip(): + try: + connection, bare = await _resolve_connection(default_model.strip()) + return connection, bare, default_model.strip() + except Exception: + logger.warning( + "[openai-compat] Default model '%s' not found, falling back", + default_model, + ) + # Fall back to the first enabled connection + its first model + from cptr.routers.chat import _fetch_provider_models + connections = await Config.get("chat.connections") or [] for conn in connections: if not conn.get("enabled", True): continue model_ids = conn.get("data", {}).get("models") + if not model_ids: + # Attempt auto-discovery if models aren't pre-stored + model_ids = await _fetch_provider_models(conn) if model_ids: prefix = (conn.get("prefix_id") or "").strip() bare = model_ids[0] diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index 37c470f3..c6917371 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -945,7 +945,12 @@ async def run_chat_task( async def emit(**data): """Stream an output delta to the user.""" - await emit_to_user(user_id, {"chat_id": chat_id, "message_id": message_id, **data}) + try: + await emit_to_user(user_id, {"chat_id": chat_id, "message_id": message_id, **data}) + except Exception: + # Socket failure must not prevent the queue push below, + # otherwise the gateway SSE stream hangs forever. + logger.debug("[task %s] emit_to_user failed", message_id[:8], exc_info=True) # Push to gateway queue if present if output_queue is not None: if "delta" in data: @@ -1491,6 +1496,16 @@ def _sync_state(): _task_state.pop(message_id, None) await emit(done=True, error=error_msg) finally: + # Guarantee the gateway SSE stream terminates. If emit() + # already pushed a done/error event the sentinel is harmless + # (_stream checks for None separately). Without this, a + # crash in emit_to_user or an unexpected exit path leaves + # the SSE generator hanging for up to 5 minutes. + if output_queue is not None: + try: + await output_queue.put(None) + except Exception: + pass _tasks.pop(message_id, None) _task_state.pop(message_id, None) _task_chat.pop(message_id, None) diff --git a/pyproject.toml b/pyproject.toml index 551b42a3..825f3153 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cptr" -version = "0.4.5" +version = "0.4.6" description = "Your computer, from anywhere. Code, manage, and control your machine from the web." license = {file = "LICENSE"} readme = "README.md"