diff --git a/CHANGELOG.md b/CHANGELOG.md index 455825e..71041af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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.3.4] - 2026-06-12 + +### Changed + +- 🛠️ **Gateway tool call visibility.** Tool calls now appear concisely in gateway responses instead of being hidden from OpenAI-compatible clients. +- ⚙️ **Gateway response model setting.** Choose the model used to generate gateway responses directly from the Gateway settings tab. + ## [0.3.3] - 2026-06-12 ### Added diff --git a/cptr/frontend/package-lock.json b/cptr/frontend/package-lock.json index 7037e24..e421e4e 100644 --- a/cptr/frontend/package-lock.json +++ b/cptr/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "0.3.3", + "version": "0.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.3.3", + "version": "0.3.4", "dependencies": { "@codemirror/lang-cpp": "^6.0.3", "@codemirror/lang-css": "^6.3.1", diff --git a/cptr/frontend/package.json b/cptr/frontend/package.json index 29c5352..0260b9a 100644 --- a/cptr/frontend/package.json +++ b/cptr/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.3.3", + "version": "0.3.4", "type": "module", "scripts": { "dev": "vite dev", diff --git a/cptr/frontend/src/lib/components/Admin/Gateway.svelte b/cptr/frontend/src/lib/components/Admin/Gateway.svelte index 34afa22..599c718 100644 --- a/cptr/frontend/src/lib/components/Admin/Gateway.svelte +++ b/cptr/frontend/src/lib/components/Admin/Gateway.svelte @@ -2,6 +2,7 @@ import { toast } from 'svelte-sonner'; import { onMount } from 'svelte'; import { fetchJSON, jsonBody } from '$lib/apis'; + import { getModelConfig, updateConfig } from '$lib/apis/admin'; import { t } from '$lib/i18n'; import Icon from '$lib/components/Icon.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; @@ -12,17 +13,39 @@ created_at: number; } + interface GatewayModel { + id: string; + name: string; + provider: string; + } + let keys = $state([]); + let models = $state([]); let loading = $state(true); let creating = $state(false); + let saving = $state(false); let newKeyName = $state(''); + let selectedModel = $state(''); /** Newly created key, shown once, then hidden */ let revealedKey = $state(''); + const openWebUIHeaders = `{ + "X-OpenWebUI-Chat-Id": "{{CHAT_ID}}" +}`; - async function loadKeys() { + async function loadSettings() { try { - keys = await fetchJSON('/v1/keys'); + const [loadedKeys, modelConfig, gatewayConfig] = await Promise.all([ + fetchJSON('/v1/keys'), + getModelConfig(), + fetchJSON<{ config: Record }>('/api/admin/config/gateway') + ]); + keys = loadedKeys; + models = modelConfig.models; + selectedModel = + typeof gatewayConfig.config['gateway.model'] === 'string' + ? gatewayConfig.config['gateway.model'] + : ''; } catch { toast.error($t('admin.gateway.loadError')); } finally { @@ -30,6 +53,18 @@ } } + async function save() { + saving = true; + try { + await updateConfig({ 'gateway.model': selectedModel }); + toast.success($t('settings.saved')); + } catch { + toast.error($t('admin.gateway.modelSaveError')); + } finally { + saving = false; + } + } + async function createKey() { if (!newKeyName.trim()) { newKeyName = 'default'; @@ -42,7 +77,8 @@ ); revealedKey = result.key; newKeyName = ''; - await loadKeys(); + const loadedKeys = await fetchJSON('/v1/keys'); + keys = loadedKeys; toast.success($t('admin.gateway.keyCreated')); } catch { toast.error($t('admin.gateway.createError')); @@ -67,6 +103,11 @@ toast.success($t('admin.gateway.copied')); } + function copyHeaders() { + navigator.clipboard.writeText(openWebUIHeaders); + toast.success($t('admin.gateway.copied')); + } + function formatDate(ts: number) { return new Date(ts * 1000).toLocaleDateString(undefined, { month: 'short', @@ -75,7 +116,7 @@ }); } - onMount(loadKeys); + onMount(loadSettings);
@@ -91,6 +132,27 @@
{:else} +
+

+ {$t('admin.gateway.model')} +

+
+ +
+

+ {$t('admin.gateway.modelDescription')} +

+
+ {#if revealedKey}
@@ -189,15 +251,32 @@ API Key: sk-cptr-...
-
- Header: - X-OpenWebUI-Chat-Id: {'{{CHAT_ID}}'} -
-
- Also accepts: - X-Chat-Id +
+
+ Headers: + +
+
+ +
+ +
{/if} diff --git a/cptr/frontend/src/lib/i18n/locales/en.json b/cptr/frontend/src/lib/i18n/locales/en.json index 18145a8..f0b91e0 100644 --- a/cptr/frontend/src/lib/i18n/locales/en.json +++ b/cptr/frontend/src/lib/i18n/locales/en.json @@ -414,6 +414,10 @@ "admin.gateway.title": "API Gateway", "admin.gateway.description": "Generate API keys to connect Open WebUI or any OpenAI-compatible client to your workspaces.", "admin.gateway.loadError": "Failed to load API keys", + "admin.gateway.model": "Response model", + "admin.gateway.modelDescription": "Gateway clients choose a workspace; cptr uses this model to generate the response.", + "admin.gateway.modelFallback": "Use workspace/default model", + "admin.gateway.modelSaveError": "Failed to save gateway model", "admin.gateway.createKey": "Create key", "admin.gateway.keyNamePlaceholder": "Key name (e.g. open-webui)", "admin.gateway.keyCreated": "API key created", diff --git a/cptr/routers/gateway.py b/cptr/routers/gateway.py index 9dae5f8..0bb0fca 100644 --- a/cptr/routers/gateway.py +++ b/cptr/routers/gateway.py @@ -59,6 +59,14 @@ def _hash_key(raw: str) -> str: return hashlib.sha256(raw.encode()).hexdigest() +def _format_tool_call(item: dict) -> str | None: + """Render a tool call as compact markdown for OpenAI-compatible clients.""" + if item.get("type") != "function_call" or item.get("status") != "in_progress": + return None + + return f"\n\n`{item.get('name', 'tool')}`\n\n" + + async def _validate_bearer(request: Request) -> str: """Validate Bearer token from Authorization header. Returns user_id.""" auth = request.headers.get("Authorization", "") @@ -297,6 +305,11 @@ def _chunk(delta: dict, finish_reason: str | None = None) -> str: if content: yield _chunk({"content": content}) + elif event_type == "output": + content = _format_tool_call(event.get("item") or {}) + if content: + yield _chunk({"content": content}) + elif event_type == "done": finish = event.get("finish_reason", "stop") yield _chunk({}, finish) @@ -311,8 +324,7 @@ def _chunk(delta: dict, finish_reason: str | None = None) -> str: yield "data: [DONE]\n\n" return - # Other event types (tool calls, etc.) are silently consumed, - # they're persisted in cptr's DB and visible in its sidebar. + # Other output types are persisted in cptr's DB and visible in its sidebar. async def _collect_response(queue: asyncio.Queue) -> str: @@ -327,6 +339,10 @@ async def _collect_response(queue: asyncio.Queue) -> str: break if event.get("type") == "delta": parts.append(event.get("content", "")) + elif event.get("type") == "output": + content = _format_tool_call(event.get("item") or {}) + if content: + parts.append(content) elif event.get("type") in ("done", "error"): if event.get("type") == "error": parts.append(f"\n\n> **Error:** {event.get('message', '')}") @@ -374,13 +390,25 @@ async def _resolve_model_connection(workspace: str) -> tuple[dict, str, str]: """Find a model connection to use for the agentic loop. Priority: - 1. Workspace-specific model override (.cptr/model) - 2. First enabled connection's first model + 1. Gateway model selected in Settings > Gateway + 2. Workspace-specific model override (.cptr/model) + 3. First enabled connection's first model Returns (connection_dict, bare_model, full_model_id). """ from cptr.routers.chat import _resolve_connection + gateway_model = await Config.get("gateway.model") + if isinstance(gateway_model, str) and gateway_model.strip(): + try: + connection, bare = await _resolve_connection(gateway_model.strip()) + return connection, bare, gateway_model.strip() + except Exception: + logger.warning( + "[openai-compat] Gateway model '%s' not found, falling back", + gateway_model, + ) + # Check for workspace-specific model override model_file = Path(workspace) / ".cptr" / "model" model_override = None diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py index 667bc98..fa699c7 100644 --- a/cptr/utils/chat_task.py +++ b/cptr/utils/chat_task.py @@ -58,8 +58,14 @@ def start_task( """Launch the agentic loop as a background asyncio.Task.""" task = asyncio.create_task( run_chat_task( - message_id, chat_id, user_id, connection, workspace, model, - regeneration_prompt, output_queue, + message_id, + chat_id, + user_id, + connection, + workspace, + model, + regeneration_prompt, + output_queue, ) ) _tasks[message_id] = task @@ -336,6 +342,7 @@ def _render_template(template: str, variables: dict[str, str]) -> str: - Unrecognized {{...}} tokens are left as-is. - Cleans up excess blank lines left by empty variable substitutions. """ + def _replace(match: re.Match) -> str: key = match.group(1) if key in variables: @@ -406,20 +413,14 @@ async def _load_system_prompt(workspace: str, model: str = "") -> str: # Per-model prompt if model: model_prompt = ( - chat_models_config - .get(model, {}) - .get("params", {}) - .get("system_prompt") + chat_models_config.get(model, {}).get("params", {}).get("system_prompt") ) if model_prompt: template = model_prompt # Global prompt if template is None: global_prompt = ( - chat_models_config - .get("*", {}) - .get("params", {}) - .get("system_prompt") + chat_models_config.get("*", {}).get("params", {}).get("system_prompt") ) if global_prompt: template = global_prompt @@ -498,9 +499,7 @@ async def generate_chat_title( # ── Message history ───────────────────────────────────────── -async def _load_message_history( - chat_id: str, message_id: str -) -> tuple[list[dict], str | None]: +async def _load_message_history(chat_id: str, message_id: str) -> tuple[list[dict], str | None]: """Load the ancestor chain from message_id to root as LLM messages. Walks up via parent_id so only the active branch is included. @@ -548,23 +547,23 @@ async def _load_message_history( if m.role == "user": attached_files = (m.meta or {}).get("files", []) images = [ - f for f in attached_files - if isinstance(f, dict) and (f.get("type") == "image" or (f.get("content_type") or "").startswith("image/")) - ] - non_images = [ - f for f in attached_files - if isinstance(f, dict) and f not in images + f + for f in attached_files + if isinstance(f, dict) + and (f.get("type") == "image" or (f.get("content_type") or "").startswith("image/")) ] - + non_images = [f for f in attached_files if isinstance(f, dict) and f not in images] + if images or non_images: from cptr.utils.storage import get_storage import base64 - + text_content = entry["content"] - + # Append file:// references so the AI can read them with read_file if non_images: from cptr.utils.storage import UPLOADS_DIR + file_refs = [] for f in non_images: file_id = f.get("id") @@ -577,7 +576,7 @@ async def _load_message_history( text_content += "\n\nAttached files:\n" + "\n".join(file_refs) content_blocks = [{"type": "text", "text": text_content}] if text_content else [] - + for img in images: file_id = img.get("id") if not file_id: @@ -586,12 +585,10 @@ async def _load_message_history( if data: b64_str = base64.b64encode(data).decode("utf-8") ctype = img.get("content_type") or "image/png" - content_blocks.append({ - "type": "image", - "media_type": ctype, - "base64": b64_str - }) - + content_blocks.append( + {"type": "image", "media_type": ctype, "base64": b64_str} + ) + if len(content_blocks) > (1 if text_content else 0): entry["content"] = content_blocks elif text_content != entry["content"]: @@ -730,7 +727,6 @@ def _find_safe_split(messages: list[dict], target_keep: int) -> int: return min(split, n - 2) # always keep at least 2 - # ── Connection resolution ─────────────────────────────────── @@ -754,7 +750,9 @@ def build_artifact_item(tool_name: str, arguments: dict, result: str) -> dict | return { "type": "artifact", "artifact_type": artifact_type, - "title": meta.get("title") or arguments.get("title") or artifact_type.replace("_", " ").title(), + "title": meta.get("title") + or arguments.get("title") + or artifact_type.replace("_", " ").title(), "content": arguments.get("content", ""), "path": meta.get("path") or arguments.get("path", ""), } @@ -789,6 +787,8 @@ async def emit(**data): if output_queue is not None: if "delta" in data: await output_queue.put({"type": "delta", "content": data["delta"]}) + elif "output" in data: + await output_queue.put({"type": "output", "item": data["output"]}) elif data.get("done"): if "error" in data: await output_queue.put({"type": "error", "message": data["error"]}) @@ -870,11 +870,15 @@ def _sync_state(): last_user = next((m for m in reversed(messages) if m["role"] == "user"), None) if last_user: import re as _re - mentioned = _re.findall(r'\$([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)', last_user["content"]) + + mentioned = _re.findall( + r"\$([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)", last_user["content"] + ) skill_names = {s.name for s in skills} attached_skill_ids = [m for m in mentioned if m in skill_names] if attached_skill_ids: from cptr.utils.tools import _activated_skills + skill_blocks = [] for sid in attached_skill_ids: skill = load_skill(workspace, sid) @@ -891,7 +895,9 @@ def _sync_state(): # Inject create_artifact (only available in plan mode) tools.append(_fn_to_schema("create_artifact", create_artifact)) messages.append({"role": "user", "content": PLAN_MODE_PROMPT}) - logger.info("[task %s] plan mode active, %d tools available", message_id[:8], len(tools)) + logger.info( + "[task %s] plan mode active, %d tools available", message_id[:8], len(tools) + ) # Tool approval mode: 'ask' | 'auto' | 'full' # ask = require approval for ALL tools (including reads) @@ -928,8 +934,12 @@ def _sync_state(): api_type = connection.get("api_type", "chat_completions") summary = await summarize_messages( - drop_zone, loaded_summary, - provider, base_url, api_key, model, + drop_zone, + loaded_summary, + provider, + base_url, + api_key, + model, api_type=api_type, ) @@ -955,7 +965,10 @@ def _sync_state(): logger.info( "[task %s] compacted: dropped %d msgs, kept %d, summary=%d chars", - message_id[:8], len(drop_zone), len(keep_zone), len(summary), + message_id[:8], + len(drop_zone), + len(keep_zone), + len(summary), ) # Anthropic supports images natively in tool_result content blocks. @@ -977,13 +990,18 @@ def _sync_state(): else: api_messages.append(m) if image_blocks: - api_messages.append({ - "role": "user", - "content": [ - {"type": "text", "text": "Here are the images from the tool results above."}, - *image_blocks, - ], - }) + api_messages.append( + { + "role": "user", + "content": [ + { + "type": "text", + "text": "Here are the images from the tool results above.", + }, + *image_blocks, + ], + } + ) form_data = ChatCompletionForm( model=model, @@ -993,11 +1011,17 @@ def _sync_state(): ) if provider == "anthropic": - stream = stream_anthropic(form_data, base_url, api_key, request_params=request_params) + stream = stream_anthropic( + form_data, base_url, api_key, request_params=request_params + ) elif connection.get("api_type") == "responses": - stream = stream_openai_responses(form_data, base_url, api_key, request_params=request_params) + stream = stream_openai_responses( + form_data, base_url, api_key, request_params=request_params + ) else: - stream = stream_openai_completions(form_data, base_url, api_key, request_params=request_params) + stream = stream_openai_completions( + form_data, base_url, api_key, request_params=request_params + ) restart = False @@ -1037,9 +1061,20 @@ def _sync_state(): _sync_state() if name == "create_artifact": - result = await create_artifact(**event["arguments"], workspace=workspace) + result = await create_artifact( + **event["arguments"], workspace=workspace + ) else: - result = await execute_tool(name, event["arguments"], {"workspace": workspace, "user_id": user_id, "model_id": model, "chat_id": chat_id}) + result = await execute_tool( + name, + event["arguments"], + { + "workspace": workspace, + "user_id": user_id, + "model_id": model, + "chat_id": chat_id, + }, + ) # Update status to completed item["status"] = "completed" @@ -1061,9 +1096,7 @@ def _sync_state(): _sync_state() # Persist intermediate state so content survives crashes/errors - await ChatMessage.update( - message_id, content=content, output=output_items - ) + await ChatMessage.update(message_id, content=content, output=output_items) # Append to messages for next iteration _append_tool_to_messages(messages, event, result, provider) @@ -1092,7 +1125,9 @@ def _sync_state(): _flush_text() usage = {k: v for k, v in event.items() if k != "type"} if "total_tokens" not in usage: - usage["total_tokens"] = usage.get("input_tokens", 0) + usage.get("output_tokens", 0) + usage["total_tokens"] = usage.get("input_tokens", 0) + usage.get( + "output_tokens", 0 + ) last_usage = usage new_messages_since = 0 logger.info( @@ -1159,11 +1194,12 @@ def _sync_state(): _flush_text() error_msg = str(e) # Try to extract API error body for more detail - if hasattr(e, 'response'): + if hasattr(e, "response"): try: body = e.response.text or "" if body: import json as _json + err_data = _json.loads(body) api_msg = err_data.get("error", {}).get("message", "") if api_msg: @@ -1230,9 +1266,7 @@ def _sync_state(): await post_webhook(webhook_url, title, preview) except Exception: - logger.debug( - "[webhook] Error sending webhook for chat %s", chat_id[:8], exc_info=True - ) + logger.debug("[webhook] Error sending webhook for chat %s", chat_id[:8], exc_info=True) # Process any queued follow-up messages try: await _process_queue(chat_id, user_id, workspace) diff --git a/pyproject.toml b/pyproject.toml index f71dd74..ce72a46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cptr" -version = "0.3.3" +version = "0.3.4" description = "Your computer, from anywhere. Code, manage, and control your machine from the web." license = {file = "LICENSE"} readme = "README.md" diff --git a/uv.lock b/uv.lock index 3bc6536..3b806a1 100644 --- a/uv.lock +++ b/uv.lock @@ -255,7 +255,7 @@ wheels = [ [[package]] name = "cptr" -version = "0.3.3" +version = "0.3.4" source = { editable = "." } dependencies = [ { name = "aiosqlite" },