diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3a7f618..455825e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
@@ -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.
\ No newline at end of file
+- 📦 **PyPI packaging.** Hatchling-based build with frontend assets bundled into the wheel, published via trusted OIDC publishing.
diff --git a/cptr/app.py b/cptr/app.py
index 52bf426..7ee442f 100644
--- a/cptr/app.py
+++ b/cptr/app.py
@@ -15,6 +15,7 @@
chat_router,
events_router,
files_router,
+ gateway_router,
git_router,
proxy_router,
search_router,
@@ -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, can't send cookies)
if request.method == "GET" and path.startswith("/api/files/"):
@@ -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)
diff --git a/cptr/frontend/package-lock.json b/cptr/frontend/package-lock.json
index 8869438..7037e24 100644
--- a/cptr/frontend/package-lock.json
+++ b/cptr/frontend/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "frontend",
- "version": "0.1.4",
+ "version": "0.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
- "version": "0.1.4",
+ "version": "0.3.3",
"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 3b44424..29c5352 100644
--- a/cptr/frontend/package.json
+++ b/cptr/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
- "version": "0.3.2",
+ "version": "0.3.3",
"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
new file mode 100644
index 0000000..34afa22
--- /dev/null
+++ b/cptr/frontend/src/lib/components/Admin/Gateway.svelte
@@ -0,0 +1,203 @@
+
+
+
+
+ {$t('admin.gateway.title')}
+
+
+ {$t('admin.gateway.description')}
+
+
+ {#if loading}
+
+
+
+ {:else}
+ {#if revealedKey}
+
+
+
+ {$t('admin.gateway.newKey')}
+
+
+ Copy
+
+
+
+
+ {revealedKey}
+
+
+
+ {$t('admin.gateway.keyWarning')}
+
+
+ {/if}
+
+
Keys
+
+ e.key === 'Enter' && createKey()}
+ disabled={creating}
+ />
+
+ {#if creating}
+
+ {:else}
+ {$t('admin.gateway.createKey')}
+ {/if}
+
+
+
+ {#if keys.length === 0}
+
+
+
{$t('admin.gateway.noKeys')}
+
+ {:else}
+
+ {#each keys as key (key.id)}
+
+
+
+
+ {key.name}
+
+
+ {formatDate(key.created_at)}
+
+
+
deleteKey(key.id)}
+ title={$t('admin.delete')}
+ >
+
+
+
+ {/each}
+
+ {/if}
+
+
+
+ {$t('admin.gateway.howToConnect')}
+
+
+
+ Base URL:
+ {`${typeof window !== 'undefined' ? window.location.origin : ''}/v1`}
+
+
+ API Key:
+ sk-cptr-...
+
+
+ Header:
+ X-OpenWebUI-Chat-Id: {'{{CHAT_ID}}'}
+
+
+ Also accepts:
+ X-Chat-Id
+
+
+
+ {/if}
+
diff --git a/cptr/frontend/src/lib/components/Icon.svelte b/cptr/frontend/src/lib/components/Icon.svelte
index 9d5f231..c9261a6 100644
--- a/cptr/frontend/src/lib/components/Icon.svelte
+++ b/cptr/frontend/src/lib/components/Icon.svelte
@@ -235,6 +235,10 @@
{:else if name === 'shield'}
+ {:else if name === 'gateway'}
+
+
+
{:else if name === 'quote'}
@@ -306,7 +310,9 @@
{:else if name === 'play'}
-
+
{:else if name === 'clock'}
@@ -318,28 +324,46 @@
{:else if name === 'discord'}
-
-
-
-
+
+
+
+
{:else if name === 'slack'}
{:else if name === 'whatsapp'}
-
-
+
+
{:else if name === 'signal'}
-
+
{:else if name === 'browser'}
-
+
{:else if name === 'microphone'}
-
+
diff --git a/cptr/frontend/src/lib/components/SettingsModal.svelte b/cptr/frontend/src/lib/components/SettingsModal.svelte
index 6b96a6a..936a54b 100644
--- a/cptr/frontend/src/lib/components/SettingsModal.svelte
+++ b/cptr/frontend/src/lib/components/SettingsModal.svelte
@@ -10,6 +10,7 @@
import Connections from './Admin/Connections.svelte';
import Models from './Admin/Models.svelte';
import Messaging from './Admin/Messaging.svelte';
+ import Gateway from './Admin/Gateway.svelte';
import AudioSettings from './Admin/AudioSettings.svelte';
import AdminSettings from './Admin/Settings.svelte';
import { session } from '$lib/session';
@@ -25,6 +26,7 @@
| 'connections'
| 'models'
| 'messaging'
+ | 'gateway'
| 'audio'
| 'admin_settings';
@@ -51,6 +53,7 @@
{ id: 'connections', label: $t('admin.connections'), icon: 'plug' },
{ id: 'models', label: $t('admin.models'), icon: 'cube' },
{ id: 'messaging', label: $t('admin.messaging'), icon: 'chat-bubble' },
+ { id: 'gateway', label: $t('admin.gateway.tab'), icon: 'gateway' },
{ id: 'audio', label: 'Audio', icon: 'microphone' },
{ id: 'browser', label: 'Browser', icon: 'browser' },
{ id: 'admin_settings', label: $t('settings.configuration'), icon: 'shield' }
@@ -89,7 +92,9 @@
{#if isAdmin}
- {$t('sidebar.admin')}
+ {$t('sidebar.admin')}
{#each adminTabs as tab}
{:else if activeTab === 'messaging'}
+ {:else if activeTab === 'gateway'}
+
{:else if activeTab === 'audio'}
{:else if activeTab === 'admin_settings'}
diff --git a/cptr/frontend/src/lib/i18n/locales/en.json b/cptr/frontend/src/lib/i18n/locales/en.json
index cd1c64a..18145a8 100644
--- a/cptr/frontend/src/lib/i18n/locales/en.json
+++ b/cptr/frontend/src/lib/i18n/locales/en.json
@@ -410,6 +410,22 @@
"connections.failedToUpdate": "Failed to update connection",
+ "admin.gateway.tab": "Gateway",
+ "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.createKey": "Create key",
+ "admin.gateway.keyNamePlaceholder": "Key name (e.g. open-webui)",
+ "admin.gateway.keyCreated": "API key created",
+ "admin.gateway.createError": "Failed to create key",
+ "admin.gateway.keyDeleted": "API key deleted",
+ "admin.gateway.deleteError": "Failed to delete key",
+ "admin.gateway.copied": "Copied to clipboard",
+ "admin.gateway.noKeys": "No API keys yet",
+ "admin.gateway.newKey": "New API key created. Copy it now",
+ "admin.gateway.keyWarning": "This key will only be shown once. Store it securely.",
+ "admin.gateway.howToConnect": "Connect from Open WebUI",
+
"admin.messaging": "Messaging",
"messaging.loadError": "Failed to load bots",
diff --git a/cptr/frontend/vite.config.ts b/cptr/frontend/vite.config.ts
index ecf1022..085ff61 100644
--- a/cptr/frontend/vite.config.ts
+++ b/cptr/frontend/vite.config.ts
@@ -11,6 +11,10 @@ export default defineConfig({
changeOrigin: true,
ws: true
},
+ '/v1': {
+ target: 'http://localhost:9741',
+ changeOrigin: true
+ },
'/socket.io': {
target: 'http://localhost:9741',
changeOrigin: true,
diff --git a/cptr/routers/__init__.py b/cptr/routers/__init__.py
index 954a058..0f448bf 100644
--- a/cptr/routers/__init__.py
+++ b/cptr/routers/__init__.py
@@ -7,6 +7,7 @@
from cptr.routers.bridge import router as bridge_router
from cptr.routers.bridge import webhook_router
from cptr.routers.chat import router as chat_router
+from cptr.routers.gateway import router as gateway_router
from cptr.routers.events import router as events_router
from cptr.routers.files import router as files_router
from cptr.routers.git import router as git_router
@@ -27,6 +28,7 @@
"chat_router",
"events_router",
"files_router",
+ "gateway_router",
"git_router",
"proxy_router",
"search_router",
diff --git a/cptr/routers/gateway.py b/cptr/routers/gateway.py
new file mode 100644
index 0000000..9dae5f8
--- /dev/null
+++ b/cptr/routers/gateway.py
@@ -0,0 +1,546 @@
+"""Gateway: expose cptr workspaces as OpenAI-compatible models.
+
+GET /v1/models - list workspaces in OpenAI model-list format
+POST /v1/chat/completions - run the agentic loop on a workspace, stream SSE
+
+Any OpenAI-compatible client (Open WebUI, curl, Python SDK) can connect
+to cptr and use each workspace as a "model" that can read files, edit code,
+run commands, and use skills.
+
+Session mapping: if the caller sends X-Chat-Id or X-OpenWebUI-Chat-Id,
+the same cptr chat is reused across turns. Otherwise each request creates
+a fresh chat.
+
+Auth: Bearer token validated against hashed keys in the Config store.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import json
+import logging
+import secrets
+import time
+import uuid
+from pathlib import Path
+
+from fastapi import APIRouter, HTTPException, Request
+from fastapi.responses import StreamingResponse
+from pydantic import BaseModel
+
+from cptr.models import Chat, ChatMessage, Config
+from cptr.models.workspaces import Workspace
+from cptr.utils.config import now_ms
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/v1", tags=["gateway"])
+
+# Headers that clients can send with the chat ID.
+CHAT_ID_HEADER = "X-Chat-Id"
+OWUI_CHAT_ID_HEADER = "X-OpenWebUI-Chat-Id"
+
+
+# ── API key management ───────────────────────────────────────
+
+
+async def _get_api_keys() -> list[dict]:
+ """Load API keys from Config store."""
+ keys = await Config.get("api_keys")
+ return keys if isinstance(keys, list) else []
+
+
+async def _save_api_keys(keys: list[dict]) -> None:
+ await Config.upsert({"api_keys": keys})
+
+
+def _hash_key(raw: str) -> str:
+ return hashlib.sha256(raw.encode()).hexdigest()
+
+
+async def _validate_bearer(request: Request) -> str:
+ """Validate Bearer token from Authorization header. Returns user_id."""
+ auth = request.headers.get("Authorization", "")
+ if not auth.startswith("Bearer "):
+ raise HTTPException(401, "Missing or invalid Authorization header")
+
+ token = auth[7:].strip()
+ if not token:
+ raise HTTPException(401, "Empty bearer token")
+
+ token_hash = _hash_key(token)
+ keys = await _get_api_keys()
+ for key in keys:
+ if key.get("key_hash") == token_hash:
+ user_id = key.get("user_id")
+ if not user_id:
+ raise HTTPException(500, "API key has no user_id")
+ return user_id
+
+ raise HTTPException(401, "Invalid API key")
+
+
+# ── GET /v1/models ───────────────────────────────────────────
+
+
+@router.get("/models")
+async def list_models(request: Request):
+ """List workspaces as OpenAI-format models."""
+ user_id = await _validate_bearer(request)
+ workspaces = await Workspace.get_by_user(user_id)
+
+ # Disambiguate basenames
+ name_counts: dict[str, int] = {}
+ for ws in workspaces:
+ name = Path(ws.path).name
+ name_counts[name] = name_counts.get(name, 0) + 1
+
+ seen: dict[str, int] = {}
+ models = []
+ for ws in workspaces:
+ basename = Path(ws.path).name
+ if name_counts[basename] > 1:
+ seen[basename] = seen.get(basename, 0) + 1
+ model_id = f"cptr/{basename}-{seen[basename]}"
+ else:
+ model_id = f"cptr/{basename}"
+
+ models.append(
+ {
+ "id": model_id,
+ "object": "model",
+ "created": ws.created_at or int(time.time()),
+ "owned_by": "cptr",
+ "name": f"{ws.name} - {ws.path}",
+ # Extra metadata for cptr
+ "cptr_workspace": ws.path,
+ }
+ )
+
+ return {"object": "list", "data": models}
+
+
+# ── POST /v1/chat/completions ────────────────────────────────
+
+
+class ChatCompletionMessage(BaseModel):
+ role: str
+ content: str = ""
+
+
+class ChatCompletionRequest(BaseModel):
+ model: str
+ messages: list[dict]
+ stream: bool = True
+ # Other OpenAI params are accepted but ignored
+ temperature: float | None = None
+ max_tokens: int | None = None
+ top_p: float | None = None
+
+
+@router.post("/chat/completions")
+async def create_chat_completion(request: Request, body: ChatCompletionRequest):
+ """Run the cptr agentic loop and stream results as OpenAI SSE."""
+ user_id = await _validate_bearer(request)
+
+ # 1. Resolve model → workspace path
+ workspace = await _resolve_workspace(user_id, body.model)
+
+ # 2. Resolve the underlying LLM connection for this workspace
+ connection, bare_model, model_id = await _resolve_model_connection(workspace)
+
+ # 3. Session mapping: find or create a cptr chat
+ client_chat_id = request.headers.get(CHAT_ID_HEADER) or request.headers.get(OWUI_CHAT_ID_HEADER)
+ chat_id = await _find_or_create_chat(
+ user_id, workspace, client_chat_id, body.messages, model_id
+ )
+
+ # 4. Create user + assistant messages
+ user_content = ""
+ if body.messages:
+ last_user = next(
+ (m for m in reversed(body.messages) if m.get("role") == "user"),
+ None,
+ )
+ user_content = (last_user.get("content", "") if last_user else "") or ""
+
+ # Get parent (latest message in the chat)
+ chat = await Chat.get_by_id(chat_id)
+ parent_id = chat.current_message_id if chat else None
+
+ user_msg = await ChatMessage.create(
+ chat_id=chat_id,
+ role="user",
+ content=user_content,
+ parent_id=parent_id,
+ created_at=now_ms(),
+ )
+
+ assistant_msg = await ChatMessage.create(
+ chat_id=chat_id,
+ role="assistant",
+ content="",
+ parent_id=user_msg.id,
+ model=model_id,
+ done=False,
+ created_at=now_ms(),
+ )
+ await Chat.update_current_message(chat_id, assistant_msg.id, now_ms())
+
+ # Export JSON so cptr sidebar sees it immediately
+ from cptr.utils.chat_export import export_chat_to_file
+
+ await export_chat_to_file(chat_id)
+
+ # 5. Create output queue and start the agentic loop
+ output_queue: asyncio.Queue = asyncio.Queue()
+
+ from cptr.utils.chat_task import start_task
+
+ start_task(
+ message_id=assistant_msg.id,
+ chat_id=chat_id,
+ user_id=user_id,
+ connection=connection,
+ workspace=workspace,
+ model=bare_model,
+ output_queue=output_queue,
+ )
+
+ # 6. Stream SSE response
+ completion_id = f"chatcmpl-{assistant_msg.id[:24]}"
+ created = int(time.time())
+
+ if body.stream:
+ return StreamingResponse(
+ _sse_generator(output_queue, completion_id, created, body.model),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "X-Accel-Buffering": "no",
+ },
+ )
+ else:
+ # Non-streaming: collect all text, return as single response
+ full_text = await _collect_response(output_queue)
+ return {
+ "id": completion_id,
+ "object": "chat.completion",
+ "created": created,
+ "model": body.model,
+ "choices": [
+ {
+ "index": 0,
+ "message": {"role": "assistant", "content": full_text},
+ "finish_reason": "stop",
+ }
+ ],
+ "usage": {
+ "prompt_tokens": 0,
+ "completion_tokens": 0,
+ "total_tokens": 0,
+ },
+ }
+
+
+# ── SSE generator ────────────────────────────────────────────
+
+
+async def _sse_generator(
+ queue: asyncio.Queue,
+ completion_id: str,
+ created: int,
+ model: str,
+):
+ """Translate queue events → OpenAI SSE chunks."""
+
+ def _chunk(delta: dict, finish_reason: str | None = None) -> str:
+ data = {
+ "id": completion_id,
+ "object": "chat.completion.chunk",
+ "created": created,
+ "model": model,
+ "choices": [
+ {
+ "index": 0,
+ "delta": delta,
+ "finish_reason": finish_reason,
+ }
+ ],
+ }
+ return f"data: {json.dumps(data)}\n\n"
+
+ # Initial chunk with role
+ yield _chunk({"role": "assistant", "content": ""})
+
+ while True:
+ try:
+ event = await asyncio.wait_for(queue.get(), timeout=300)
+ except asyncio.TimeoutError:
+ # Safety timeout, end the stream
+ yield _chunk({}, "stop")
+ yield "data: [DONE]\n\n"
+ return
+
+ if event is None:
+ # Sentinel: stream complete
+ yield _chunk({}, "stop")
+ yield "data: [DONE]\n\n"
+ return
+
+ event_type = event.get("type")
+
+ if event_type == "delta":
+ content = event.get("content", "")
+ if content:
+ yield _chunk({"content": content})
+
+ elif event_type == "done":
+ finish = event.get("finish_reason", "stop")
+ yield _chunk({}, finish)
+ yield "data: [DONE]\n\n"
+ return
+
+ elif event_type == "error":
+ # Stream the error as content, then stop
+ error_msg = event.get("message", "Internal error")
+ yield _chunk({"content": f"\n\n> **Error:** {error_msg}"})
+ yield _chunk({}, "stop")
+ 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.
+
+
+async def _collect_response(queue: asyncio.Queue) -> str:
+ """Collect all text from the queue for non-streaming mode."""
+ parts = []
+ while True:
+ try:
+ event = await asyncio.wait_for(queue.get(), timeout=300)
+ except asyncio.TimeoutError:
+ break
+ if event is None:
+ break
+ if event.get("type") == "delta":
+ parts.append(event.get("content", ""))
+ elif event.get("type") in ("done", "error"):
+ if event.get("type") == "error":
+ parts.append(f"\n\n> **Error:** {event.get('message', '')}")
+ break
+ return "".join(parts)
+
+
+# ── Workspace resolution ─────────────────────────────────────
+
+
+async def _resolve_workspace(user_id: str, model_id: str) -> str:
+ """Resolve 'cptr/basename' → workspace filesystem path."""
+ if not model_id.startswith("cptr/"):
+ raise HTTPException(400, f"Invalid model ID: {model_id}")
+
+ target = model_id[5:] # strip "cptr/"
+ workspaces = await Workspace.get_by_user(user_id)
+
+ # Exact basename match
+ for ws in workspaces:
+ if Path(ws.path).name == target:
+ return ws.path
+
+ # Disambiguated match (e.g., "my-project-2")
+ name_counts: dict[str, int] = {}
+ for ws in workspaces:
+ name = Path(ws.path).name
+ name_counts[name] = name_counts.get(name, 0) + 1
+
+ seen: dict[str, int] = {}
+ for ws in workspaces:
+ basename = Path(ws.path).name
+ if name_counts[basename] > 1:
+ seen[basename] = seen.get(basename, 0) + 1
+ if f"{basename}-{seen[basename]}" == target:
+ return ws.path
+
+ raise HTTPException(404, f"Workspace not found for model: {model_id}")
+
+
+# ── Model connection resolution ──────────────────────────────
+
+
+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
+
+ Returns (connection_dict, bare_model, full_model_id).
+ """
+ from cptr.routers.chat import _resolve_connection
+
+ # Check for workspace-specific model override
+ model_file = Path(workspace) / ".cptr" / "model"
+ model_override = None
+ if model_file.is_file():
+ model_override = model_file.read_text().strip()
+
+ if model_override:
+ try:
+ connection, bare = await _resolve_connection(model_override)
+ return connection, bare, model_override
+ except Exception:
+ logger.warning(
+ "[openai-compat] Workspace model override '%s' not found, falling back",
+ model_override,
+ )
+
+ # Fall back to the first enabled connection + its first model
+ 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 model_ids:
+ prefix = (conn.get("prefix_id") or "").strip()
+ bare = model_ids[0]
+ full = f"{prefix}/{bare}" if prefix else bare
+ return conn, bare, full
+
+ raise HTTPException(503, "No model connections configured. Add a connection in cptr settings.")
+
+
+# ── Session mapping ──────────────────────────────────────────
+
+
+async def _find_or_create_chat(
+ user_id: str,
+ workspace: str,
+ client_chat_id: str | None,
+ messages: list[dict],
+ model_id: str,
+) -> str:
+ """Find an existing cptr chat for this client conversation, or create one."""
+
+ if client_chat_id:
+ # Search for a chat with this client chat ID in metadata
+ from cptr.utils.db import get_db
+ from sqlalchemy import select
+
+ async with await get_db() as db:
+ result = await db.execute(select(Chat).where(Chat.user_id == user_id))
+ for chat in result.scalars():
+ meta = chat.meta or {}
+ if (
+ meta.get("client_chat_id") == client_chat_id
+ or meta.get("owui_chat_id") == client_chat_id
+ ):
+ return chat.id
+
+ # Create a new chat
+ title = "Open WebUI Chat"
+ if messages:
+ first_user = next(
+ (m.get("content", "")[:50] for m in messages if m.get("role") == "user"),
+ None,
+ )
+ if first_user:
+ title = first_user.strip() or title
+
+ meta = {
+ "workspace": workspace,
+ "params": {"tool_approval_mode": "full"},
+ }
+ if client_chat_id:
+ meta["client_chat_id"] = client_chat_id
+ meta["owui_chat_id"] = client_chat_id
+
+ chat = await Chat.create(
+ user_id=user_id,
+ title=title[:100],
+ meta=meta,
+ created_at=now_ms(),
+ )
+
+ # Ensure .cptr/chats/ dir exists and create marker file
+ chats_dir = Path(workspace) / ".cptr" / "chats"
+ chats_dir.mkdir(parents=True, exist_ok=True)
+ (chats_dir / f"{chat.id}.json").write_text("{}")
+
+ return chat.id
+
+
+# ── API key admin endpoint ───────────────────────────────────
+
+
+class CreateApiKeyRequest(BaseModel):
+ name: str = "default"
+
+
+@router.post("/keys")
+async def create_api_key(request: Request, body: CreateApiKeyRequest):
+ """Create a new API key (requires cookie auth, admin only)."""
+ from cptr.utils.config import check_access
+
+ client_host = request.client.host if request.client else "127.0.0.1"
+ jwt_token = request.cookies.get("cptr_session")
+ auth = check_access(client_host=client_host, jwt_token=jwt_token)
+ if not auth or not auth.user_id:
+ raise HTTPException(401, "Admin authentication required")
+
+ raw = f"sk-cptr-{secrets.token_urlsafe(32)}"
+ entry = {
+ "id": str(uuid.uuid4()),
+ "key_hash": _hash_key(raw),
+ "user_id": auth.user_id,
+ "name": body.name,
+ "created_at": int(time.time()),
+ }
+ keys = await _get_api_keys()
+ keys.append(entry)
+ await _save_api_keys(keys)
+
+ return {"key": raw, "id": entry["id"], "name": body.name}
+
+
+@router.get("/keys")
+async def list_api_keys(request: Request):
+ """List API keys (masked). Requires cookie auth."""
+ from cptr.utils.config import check_access
+
+ client_host = request.client.host if request.client else "127.0.0.1"
+ jwt_token = request.cookies.get("cptr_session")
+ auth = check_access(client_host=client_host, jwt_token=jwt_token)
+ if not auth or not auth.user_id:
+ raise HTTPException(401, "Admin authentication required")
+
+ keys = await _get_api_keys()
+ return [
+ {
+ "id": k.get("id"),
+ "name": k.get("name", ""),
+ "created_at": k.get("created_at"),
+ }
+ for k in keys
+ ]
+
+
+@router.delete("/keys/{key_id}")
+async def delete_api_key(request: Request, key_id: str):
+ """Delete an API key. Requires cookie auth."""
+ from cptr.utils.config import check_access
+
+ client_host = request.client.host if request.client else "127.0.0.1"
+ jwt_token = request.cookies.get("cptr_session")
+ auth = check_access(client_host=client_host, jwt_token=jwt_token)
+ if not auth or not auth.user_id:
+ raise HTTPException(401, "Admin authentication required")
+
+ keys = await _get_api_keys()
+ filtered = [k for k in keys if k.get("id") != key_id]
+ if len(filtered) == len(keys):
+ raise HTTPException(404, "Key not found")
+ await _save_api_keys(filtered)
+ return {"ok": True}
diff --git a/cptr/utils/chat_task.py b/cptr/utils/chat_task.py
index 93fdfe0..667bc98 100644
--- a/cptr/utils/chat_task.py
+++ b/cptr/utils/chat_task.py
@@ -53,11 +53,13 @@ def start_task(
workspace: str,
model: str,
regeneration_prompt: str | None = None,
+ output_queue: asyncio.Queue | None = None,
):
"""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
+ message_id, chat_id, user_id, connection, workspace, model,
+ regeneration_prompt, output_queue,
)
)
_tasks[message_id] = task
@@ -776,12 +778,22 @@ async def run_chat_task(
workspace: str,
model: str,
regeneration_prompt: str | None = None,
+ output_queue: asyncio.Queue | None = None,
):
"""Plain async function. Makes raw API calls in a loop."""
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})
+ # Push to gateway queue if present
+ if output_queue is not None:
+ if "delta" in data:
+ await output_queue.put({"type": "delta", "content": data["delta"]})
+ elif data.get("done"):
+ if "error" in data:
+ await output_queue.put({"type": "error", "message": data["error"]})
+ else:
+ await output_queue.put({"type": "done", "finish_reason": "stop"})
async def _emit_done():
"""Emit done=True enriched with chat title and content preview."""
diff --git a/cptr/utils/config.py b/cptr/utils/config.py
index 0438144..e2858be 100644
--- a/cptr/utils/config.py
+++ b/cptr/utils/config.py
@@ -230,6 +230,7 @@ async def has_any_user() -> bool:
return result.scalar_one_or_none() is not None
+
# ── PAM ──────────────────────────────────────────────────────
diff --git a/pyproject.toml b/pyproject.toml
index 197ccec..f71dd74 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "cptr"
-version = "0.3.2"
+version = "0.3.3"
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 b101198..3bc6536 100644
--- a/uv.lock
+++ b/uv.lock
@@ -255,7 +255,7 @@ wheels = [
[[package]]
name = "cptr"
-version = "0.2.0"
+version = "0.3.3"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },