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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions cptr/frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion cptr/frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.3.3",
"version": "0.3.4",
"type": "module",
"scripts": {
"dev": "vite dev",
Expand Down
101 changes: 90 additions & 11 deletions cptr/frontend/src/lib/components/Admin/Gateway.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -12,24 +13,58 @@
created_at: number;
}

interface GatewayModel {
id: string;
name: string;
provider: string;
}

let keys = $state<ApiKey[]>([]);
let models = $state<GatewayModel[]>([]);
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<ApiKey[]>('/v1/keys');
const [loadedKeys, modelConfig, gatewayConfig] = await Promise.all([
fetchJSON<ApiKey[]>('/v1/keys'),
getModelConfig(),
fetchJSON<{ config: Record<string, unknown> }>('/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 {
loading = false;
}
}

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';
Expand All @@ -42,7 +77,8 @@
);
revealedKey = result.key;
newKeyName = '';
await loadKeys();
const loadedKeys = await fetchJSON<ApiKey[]>('/v1/keys');
keys = loadedKeys;
toast.success($t('admin.gateway.keyCreated'));
} catch {
toast.error($t('admin.gateway.createError'));
Expand All @@ -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',
Expand All @@ -75,7 +116,7 @@
});
}

onMount(loadKeys);
onMount(loadSettings);
</script>

<div class="flex flex-col min-h-full">
Expand All @@ -91,6 +132,27 @@
<Spinner size={16} />
</div>
{:else}
<div class="mb-5 border-b border-gray-100 dark:border-white/5 pb-4">
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2">
{$t('admin.gateway.model')}
</h3>
<div class="flex items-center gap-2">
<select
class="flex-1 h-7 px-2 rounded-lg text-xs bg-gray-100 dark:bg-white/6 text-gray-700 dark:text-gray-300 border border-gray-200 dark:border-white/8 outline-none focus:border-gray-400 dark:focus:border-white/20 transition-colors"
bind:value={selectedModel}
disabled={saving}
>
<option value="">{$t('admin.gateway.modelFallback')}</option>
{#each models as model (model.id)}
<option value={model.id}>{model.id}</option>
{/each}
</select>
</div>
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-1.5">
{$t('admin.gateway.modelDescription')}
</p>
</div>

{#if revealedKey}
<div class="mb-5 border-b border-gray-100 dark:border-white/5 pb-4">
<div class="flex items-center justify-between gap-2 mb-2">
Expand Down Expand Up @@ -189,15 +251,32 @@
<span class="text-gray-400 dark:text-gray-600">API Key:</span>
<span class="text-gray-700 dark:text-gray-300">sk-cptr-...</span>
</div>
<div>
<span class="text-gray-400 dark:text-gray-600">Header:</span>
<span class="text-gray-700 dark:text-gray-300">X-OpenWebUI-Chat-Id: {'{{CHAT_ID}}'}</span>
</div>
<div>
<span class="text-gray-400 dark:text-gray-600">Also accepts:</span>
<span class="text-gray-700 dark:text-gray-300">X-Chat-Id</span>
<div class="pt-1">
<div class="flex items-center justify-between gap-2 mb-1">
<span class="text-gray-400 dark:text-gray-600">Headers:</span>
<button
class="shrink-0 text-[11px] font-sans text-gray-500 hover:text-gray-900 dark:text-gray-500 dark:hover:text-white transition-colors"
onclick={copyHeaders}
>
Copy
</button>
</div>
<button
class="w-full text-left whitespace-pre-wrap rounded-lg border border-gray-100 dark:border-white/5 bg-gray-50 dark:bg-white/4 px-2.5 py-2 text-[11px] font-mono text-gray-700 dark:text-gray-300 transition-colors hover:border-gray-200 dark:hover:border-white/10"
onclick={copyHeaders}
>
{openWebUIHeaders}
</button>
</div>
</div>
</div>

<div class="mt-auto pt-6 flex justify-end">
<button
class="text-[13px] text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors duration-100 disabled:opacity-50"
onclick={() => save()}
disabled={saving}>{$t('settings.save')}</button
>
</div>
{/if}
</div>
4 changes: 4 additions & 0 deletions cptr/frontend/src/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
36 changes: 32 additions & 4 deletions cptr/routers/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", "")
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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', '')}")
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading