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"