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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 23 additions & 6 deletions cptr/frontend/src/lib/components/Admin/Models.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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);
Expand Down Expand Up @@ -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 {}
});

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -276,6 +282,17 @@ Files:
{$t('admin.models')}
</h2>

<!-- Default model -->
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2">
{$t('models.defaultModel')}
</h3>
<div class="mb-1">
<ModelSelector bind:selectedModel={defaultModelId} preferAbove={false} align="start" />
</div>
<p class="text-[11px] text-gray-400 dark:text-gray-600 mb-5">
{$t('models.defaultModelHint')}
</p>

<!-- Context compaction -->
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2">{$t('admin.contextCompaction')}</h3>

Expand Down
8 changes: 5 additions & 3 deletions cptr/frontend/src/lib/components/common/ModelSelector.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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()}
<div class="flex items-center gap-1.5 h-6 px-2 mt-0.5">
Expand Down
3 changes: 3 additions & 0 deletions cptr/frontend/src/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 20 additions & 2 deletions cptr/routers/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]}"
Expand Down Expand Up @@ -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).
"""
Expand Down Expand Up @@ -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]
Expand Down
17 changes: 16 additions & 1 deletion cptr/utils/chat_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading