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
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ 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.3] - 2026-06-12

### Added

- 🌉 **OpenAI-compatible gateway.** Expose cptr workspaces through `/v1/models` and `/v1/chat/completions`, so Open WebUI and other OpenAI-compatible clients can use each workspace as a model with the full cptr agent loop.
- 🔑 **Gateway API keys.** New Gateway admin settings tab for creating, copying, listing, and deleting API keys. Keys are stored hashed and newly generated keys are only shown once.

### Changed

- 🔄 **Gateway streaming support.** Chat tasks can now stream assistant deltas into an OpenAI-style SSE response while still updating cptr chats and sidebar state.
- 🧭 **Frontend dev proxy.** The Vite dev server now proxies `/v1` gateway requests to the backend during local development.

## [0.3.2] - 2026-06-12

### Added
Expand Down Expand Up @@ -224,4 +236,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 🔐 **Authentication.** Username/password authentication with JWT-based session management.
- 🎨 **Admin settings.** Settings UI for managing AI connections and app configuration.
- 🐳 **Docker support.** Multi-stage Dockerfile and GitHub Actions workflow for building and publishing to GHCR.
- 📦 **PyPI packaging.** Hatchling-based build with frontend assets bundled into the wheel, published via trusted OIDC publishing.
- 📦 **PyPI packaging.** Hatchling-based build with frontend assets bundled into the wheel, published via trusted OIDC publishing.
4 changes: 3 additions & 1 deletion cptr/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
chat_router,
events_router,
files_router,
gateway_router,
git_router,
proxy_router,
search_router,
Expand Down Expand Up @@ -94,7 +95,7 @@ async def auth_middleware(request: Request, call_next):
or path == "/manifest.json"
):
return await call_next(request)
if path.startswith("/_app/") or not path.startswith("/api/"):
if path.startswith("/_app/") or path.startswith("/v1/") or not path.startswith("/api/"):
return await call_next(request)
# GET /api/files/{id} is public (UUID is unguessable, <img src> can't send cookies)
if request.method == "GET" and path.startswith("/api/files/"):
Expand Down Expand Up @@ -218,6 +219,7 @@ async def get_config():
app.include_router(chat_router)
app.include_router(events_router)
app.include_router(files_router)
app.include_router(gateway_router)
app.include_router(git_router)
app.include_router(proxy_router)
app.include_router(search_router)
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.2",
"version": "0.3.3",
"type": "module",
"scripts": {
"dev": "vite dev",
Expand Down
203 changes: 203 additions & 0 deletions cptr/frontend/src/lib/components/Admin/Gateway.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { onMount } from 'svelte';
import { fetchJSON, jsonBody } from '$lib/apis';
import { t } from '$lib/i18n';
import Icon from '$lib/components/Icon.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';

interface ApiKey {
id: string;
name: string;
created_at: number;
}

let keys = $state<ApiKey[]>([]);
let loading = $state(true);
let creating = $state(false);
let newKeyName = $state('');

/** Newly created key, shown once, then hidden */
let revealedKey = $state('');

async function loadKeys() {
try {
keys = await fetchJSON<ApiKey[]>('/v1/keys');
} catch {
toast.error($t('admin.gateway.loadError'));
} finally {
loading = false;
}
}

async function createKey() {
if (!newKeyName.trim()) {
newKeyName = 'default';
}
creating = true;
try {
const result = await fetchJSON<{ key: string; id: string; name: string }>(
'/v1/keys',
jsonBody({ name: newKeyName.trim() })
);
revealedKey = result.key;
newKeyName = '';
await loadKeys();
toast.success($t('admin.gateway.keyCreated'));
} catch {
toast.error($t('admin.gateway.createError'));
} finally {
creating = false;
}
}

async function deleteKey(id: string) {
try {
await fetchJSON(`/v1/keys/${id}`, { method: 'DELETE' });
keys = keys.filter((k) => k.id !== id);
if (keys.length === 0) revealedKey = '';
toast.success($t('admin.gateway.keyDeleted'));
} catch {
toast.error($t('admin.gateway.deleteError'));
}
}

function copyKey() {
navigator.clipboard.writeText(revealedKey);
toast.success($t('admin.gateway.copied'));
}

function formatDate(ts: number) {
return new Date(ts * 1000).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}

onMount(loadKeys);
</script>

<div class="flex flex-col min-h-full">
<h2 class="text-sm font-medium text-gray-900 dark:text-white mb-1">
{$t('admin.gateway.title')}
</h2>
<p class="text-[11px] text-gray-400 dark:text-gray-600 mb-4">
{$t('admin.gateway.description')}
</p>

{#if loading}
<div class="flex justify-center py-8">
<Spinner size={16} />
</div>
{:else}
{#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">
<h3 class="text-xs text-gray-400 dark:text-gray-600">
{$t('admin.gateway.newKey')}
</h3>
<button
class="shrink-0 text-[11px] text-gray-500 hover:text-gray-900 dark:text-gray-500 dark:hover:text-white transition-colors"
onclick={copyKey}
>
Copy
</button>
</div>
<div class="flex items-center gap-2">
<code
class="flex-1 min-w-0 text-[11px] font-mono bg-gray-50 dark:bg-white/4 px-2.5 py-2 rounded-lg border border-gray-100 dark:border-white/5 text-gray-600 dark:text-gray-400 select-all break-all"
>
{revealedKey}
</code>
</div>
<p class="text-[11px] text-gray-400 dark:text-gray-600 mt-1.5">
{$t('admin.gateway.keyWarning')}
</p>
</div>
{/if}

<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2">Keys</h3>
<div class="flex items-center gap-2 mb-4">
<input
type="text"
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"
placeholder={$t('admin.gateway.keyNamePlaceholder')}
bind:value={newKeyName}
onkeydown={(e) => e.key === 'Enter' && createKey()}
disabled={creating}
/>
<button
class="shrink-0 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={createKey}
disabled={creating}
>
{#if creating}
<Spinner size={12} />
{:else}
{$t('admin.gateway.createKey')}
{/if}
</button>
</div>

{#if keys.length === 0}
<div
class="flex flex-col items-center justify-center py-8 text-gray-400 dark:text-gray-600 border-b border-gray-100 dark:border-white/5"
>
<Icon name="shield" size={24} class="mb-2 opacity-40" />
<p class="text-xs">{$t('admin.gateway.noKeys')}</p>
</div>
{:else}
<div
class="divide-y divide-gray-100 dark:divide-white/5 border-b border-gray-100 dark:border-white/5"
>
{#each keys as key (key.id)}
<div class="flex items-center justify-between h-9">
<div class="flex items-center gap-2 min-w-0">
<Icon name="shield" size={12} class="shrink-0 text-gray-400 dark:text-gray-600" />
<span class="text-xs font-medium text-gray-700 dark:text-gray-300 truncate">
{key.name}
</span>
<span class="text-[10px] text-gray-400 dark:text-gray-600 shrink-0">
{formatDate(key.created_at)}
</span>
</div>
<button
class="shrink-0 p-1 text-gray-300 hover:text-gray-600 dark:text-gray-700 dark:hover:text-gray-400 transition-colors"
onclick={() => deleteKey(key.id)}
title={$t('admin.delete')}
>
<Icon name="trash" size={12} />
</button>
</div>
{/each}
</div>
{/if}

<div class="mt-5">
<h3 class="text-xs text-gray-400 dark:text-gray-600 mb-2">
{$t('admin.gateway.howToConnect')}
</h3>
<div class="space-y-1.5 text-[11px] font-mono text-gray-600 dark:text-gray-400">
<div>
<span class="text-gray-400 dark:text-gray-600">Base URL:</span>
<span class="text-gray-700 dark:text-gray-300"
>{`${typeof window !== 'undefined' ? window.location.origin : ''}/v1`}</span
>
</div>
<div>
<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>
</div>
</div>
{/if}
</div>
44 changes: 34 additions & 10 deletions cptr/frontend/src/lib/components/Icon.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,10 @@
<polyline points="7 3 7 8 15 8" />
{:else if name === 'shield'}
<path d="M12 22C12 22 20 18 20 12V5L12 2L4 5V12C4 18 12 22 12 22Z" />
{:else if name === 'gateway'}
<path d="M3 19H12M21 19H12M12 19V13M12 13H18V5H6V13H12Z" />
<path d="M9 9.01L9.01 8.99889" />
<path d="M12 9.01L12.01 8.99889" />
{:else if name === 'quote'}
<path d="M10 12H5C5 7 7.5 4.5 10 3.5" />
<path d="M21 12H16C16 7 18.5 4.5 21 3.5" />
Expand Down Expand Up @@ -306,7 +310,9 @@
<path d="M10 6V2" />
<path d="M14 6V2" />
{:else if name === 'play'}
<path d="M6.90588 4.53682C6.50592 4.2998 6 4.58808 6 5.05299V18.947C6 19.4119 6.50592 19.7002 6.90588 19.4632L18.629 12.5162C19.0211 12.2838 19.0211 11.7162 18.629 11.4838L6.90588 4.53682Z" />
<path
d="M6.90588 4.53682C6.50592 4.2998 6 4.58808 6 5.05299V18.947C6 19.4119 6.50592 19.7002 6.90588 19.4632L18.629 12.5162C19.0211 12.2838 19.0211 11.7162 18.629 11.4838L6.90588 4.53682Z"
/>
{:else if name === 'clock'}
<circle cx="12" cy="12" r="10" />
<path d="M12 6V12H16.5" />
Expand All @@ -318,28 +324,46 @@
<path d="M21 5L2 12.5L9 13.5M21 5L18.5 20L9 13.5M21 5L9 13.5M9 13.5V19L12.2488 15.7229" />
{:else if name === 'discord'}
<path d="M5.5 16C10.5 18.5 13.5 18.5 18.5 16" />
<path d="M15.5 17.5L16.5 19.5C16.5 19.5 20.6713 18.1717 22 16C22 15 22.5301 7.85339 19 5.5C17.5 4.5 15 4 15 4L14 6H12" />
<path d="M8.52832 17.5L7.52832 19.5C7.52832 19.5 3.35699 18.1717 2.02832 16C2.02832 15 1.49823 7.85339 5.02832 5.5C6.52832 4.5 9.02832 4 9.02832 4L10.0283 6H12.0283" />
<path d="M8.5 14C7.67157 14 7 13.1046 7 12C7 10.8954 7.67157 10 8.5 10C9.32843 10 10 10.8954 10 12C10 13.1046 9.32843 14 8.5 14Z" />
<path d="M15.5 14C14.6716 14 14 13.1046 14 12C14 10.8954 14.6716 10 15.5 10C16.3284 10 17 10.8954 17 12C17 13.1046 16.3284 14 15.5 14Z" />
<path
d="M15.5 17.5L16.5 19.5C16.5 19.5 20.6713 18.1717 22 16C22 15 22.5301 7.85339 19 5.5C17.5 4.5 15 4 15 4L14 6H12"
/>
<path
d="M8.52832 17.5L7.52832 19.5C7.52832 19.5 3.35699 18.1717 2.02832 16C2.02832 15 1.49823 7.85339 5.02832 5.5C6.52832 4.5 9.02832 4 9.02832 4L10.0283 6H12.0283"
/>
<path
d="M8.5 14C7.67157 14 7 13.1046 7 12C7 10.8954 7.67157 10 8.5 10C9.32843 10 10 10.8954 10 12C10 13.1046 9.32843 14 8.5 14Z"
/>
<path
d="M15.5 14C14.6716 14 14 13.1046 14 12C14 10.8954 14.6716 10 15.5 10C16.3284 10 17 10.8954 17 12C17 13.1046 16.3284 14 15.5 14Z"
/>
{:else if name === 'slack'}
<path d="M10 3L6 21" />
<path d="M20.5 16H2.5" />
<path d="M22 7H4" />
<path d="M18 3L14 21" />
{:else if name === 'whatsapp'}
<path d="M22 12C22 17.5228 17.5228 22 12 22C10.1786 22 8.47087 21.513 7 20.6622L2 21.5L2.83209 16C2.29689 14.7751 2 13.4222 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z" />
<path d="M12.9604 13.8683L15.0399 13.4624L17 14.2149V16.0385C17 16.6449 16.4783 17.1073 15.8901 16.9783C14.3671 16.6444 11.5997 15.8043 9.67826 13.8683C7.84859 12.0248 7.22267 9.45734 7.01039 8.04128C6.92535 7.47406 7.3737 7 7.94306 7H9.83707L10.572 8.96888L10.1832 11.0701" />
<path
d="M22 12C22 17.5228 17.5228 22 12 22C10.1786 22 8.47087 21.513 7 20.6622L2 21.5L2.83209 16C2.29689 14.7751 2 13.4222 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12Z"
/>
<path
d="M12.9604 13.8683L15.0399 13.4624L17 14.2149V16.0385C17 16.6449 16.4783 17.1073 15.8901 16.9783C14.3671 16.6444 11.5997 15.8043 9.67826 13.8683C7.84859 12.0248 7.22267 9.45734 7.01039 8.04128C6.92535 7.47406 7.3737 7 7.94306 7H9.83707L10.572 8.96888L10.1832 11.0701"
/>
{:else if name === 'signal'}
<path d="M8.5 11.5L11.5 14.5L16.5 9.5" />
<path d="M5 18L3.13036 4.91253C3.05646 4.39524 3.39389 3.91247 3.90398 3.79912L11.5661 2.09641C11.8519 2.03291 12.1481 2.03291 12.4339 2.09641L20.096 3.79912C20.6061 3.91247 20.9435 4.39524 20.8696 4.91252L19 18C18.9293 18.495 18.5 21.5 12 21.5C5.5 21.5 5.07071 18.495 5 18Z" />
<path
d="M5 18L3.13036 4.91253C3.05646 4.39524 3.39389 3.91247 3.90398 3.79912L11.5661 2.09641C11.8519 2.03291 12.1481 2.03291 12.4339 2.09641L20.096 3.79912C20.6061 3.91247 20.9435 4.39524 20.8696 4.91252L19 18C18.9293 18.495 18.5 21.5 12 21.5C5.5 21.5 5.07071 18.495 5 18Z"
/>
{:else if name === 'browser'}
<path d="M22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12Z" />
<path
d="M22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22C17.5228 22 22 17.5228 22 12Z"
/>
<path d="M13 2.04932C13 2.04932 16 6 16 12C16 18 13 21.9507 13 21.9507" />
<path d="M11 21.9507C11 21.9507 8 18 8 12C8 6 11 2.04932 11 2.04932" />
<path d="M2 12H22" />
{:else if name === 'microphone'}
<path d="M12 1C10.3431 1 9 2.34315 9 4V12C9 13.6569 10.3431 15 12 15C13.6569 15 15 13.6569 15 12V4C15 2.34315 13.6569 1 12 1Z" />
<path
d="M12 1C10.3431 1 9 2.34315 9 4V12C9 13.6569 10.3431 15 12 15C13.6569 15 15 13.6569 15 12V4C15 2.34315 13.6569 1 12 1Z"
/>
<path d="M5 10V12C5 15.866 8.13401 19 12 19C15.866 19 19 15.866 19 12V10" />
<line x1="12" y1="19" x2="12" y2="23" />
<line x1="8" y1="23" x2="16" y2="23" />
Expand Down
Loading
Loading