This commit is contained in:
Speedliner 2026-08-13 00:53:17 -03:00 committed by GitHub
commit 72e84da09b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 418 additions and 71 deletions

102
README.md
View File

@ -10,7 +10,7 @@ This is an Open WebUI **Pipe** that exposes Claude Code as a selectable model. E
- **Per-chat workspaces** — each `chat_id` gets a sandboxed working directory that persists across turns
- **Dual auth** — bring your own Anthropic **API key** (pay-per-token) *or* a **Claude Pro/Max OAuth token** (bills against your subscription)
- **Streaming UI** — tool calls render inline with previews; generated images/PDFs/CSVs surface as artifacts in the chat
- **Configurable valves** — model, permission mode, tool allowlist, max turns, workspace root, setting sources (`CLAUDE.md`)
- **Configurable valves** — model, permission mode, tool allowlist, max turns, workspace root
## Requirements
@ -23,73 +23,24 @@ This is an Open WebUI **Pipe** that exposes Claude Code as a selectable model. E
## Installation
1. In Open WebUI, go to **Workspace → Functions → +** (or **Admin Panel → Functions**).
2. Paste the contents of [`claude_agent_pipe.py`](./claude_agent_pipe.py) into the editor.
2. Paste the contents of [`claude_agent_pipe.py`](https://github.com/tfriedel/openwebui-claude-code/blob/main/claude_agent_pipe.py) into the editor.
3. Save and enable the function.
4. Open the function's **Valves** and configure auth (one of):
- `ANTHROPIC_API_KEY` — standard pay-per-token billing
- `CLAUDE_CODE_OAUTH_TOKEN` — generate on a machine with a browser via `claude setup-token`; bills against your Pro/Max/Team subscription
- `CLAUDE_CODE_OAUTH_TOKEN` — generate via `claude setup-token`; bills against your Pro/Max/Team subscription. **Known issue:** `setup-token` has occasionally issued tokens with a restricted scope (see [anthropics/claude-code#23703](https://github.com/anthropics/claude-code/issues/23703)), causing `401 Invalid bearer token` on every request even though the token looks valid. If this happens, log in interactively instead (run `claude` with no arguments, complete the full browser OAuth flow) and persist the resulting credentials — see [Docker / persistence notes](#docker--persistence-notes) below.
5. A new model named **Claude Code** will appear in the model picker.
## Configuration (Valves)
| Valve | Default | Description |
| --- | --- | --- |
| `ANTHROPIC_API_KEY` | *(env)* | Anthropic API key. Falls back to the backend's env var. |
| `CLAUDE_CODE_OAUTH_TOKEN` | *(empty)* | Claude subscription OAuth token. Takes priority over the API key when set. |
| `MODEL` | `claude-haiku-4-5` | Claude model ID (e.g. `claude-haiku-4-5`, `claude-sonnet-4-6`, `claude-opus-4-7`). |
| `PERMISSION_MODE` | `bypassPermissions` | `default`, `acceptEdits`, `bypassPermissions`, `plan`, or `dontAsk`. |
| `ALLOWED_TOOLS` | `Read,Write,Edit,Bash,Glob,Grep,WebSearch,WebFetch` | Comma-separated tools auto-approved without prompting. |
| `WORKDIR_ROOT` | `/tmp/claude-agent-pipe` | Root directory for per-chat workspaces. |
| `MAX_TURNS` | `30` | Max agent turns per user message. `0` disables the cap. |
| `SETTING_SOURCES` | *(empty)* | Comma-separated filesystem setting sources to load: `user`, `project`, `local`. Empty = none (isolated baseline). See below. |
## Persistent context via `CLAUDE.md` (`SETTING_SOURCES`)
By default the pipe passes `setting_sources=[]` to the SDK, so **no** filesystem
settings are loaded: each chat starts from a clean baseline and does **not**
inherit the backend user's `~/.claude/` or the workdir's `.claude/`. This is the
safe default for shared deployments.
If you run a single-user/homelab instance and want persistent environmental
context (e.g. a host inventory or standing instructions in
`~/.claude/CLAUDE.md`) without re-explaining it every chat, set the valve:
| Value | Loads |
| --- | --- |
| *(empty)* | Nothing — isolated baseline (default). |
| `user` | `~/.claude/CLAUDE.md` **and** `~/.claude/settings.json`. |
| `user,project,local` | Above plus the workdir's `.claude/settings.json` and `.claude/settings.local.json`. |
Each token maps to one source — `user``~/.claude/`, `project`
`<workdir>/.claude/settings.json`, `local``<workdir>/.claude/settings.local.json`.
Unknown tokens are dropped.
> [!WARNING]
> **Settings sources load more than `CLAUDE.md`.** A loaded `settings.json` can
> define **hooks that execute shell commands**, permission grants, env vars, and
> MCP servers — for *every chat*, under the backend user's identity, with the
> pipe's default `bypassPermissions` mode. Only enable `SETTING_SOURCES` on an
> instance you fully trust and control. **Do not enable it on multi-user or
> public deployments** — it breaks per-chat isolation and lets host config
> influence (or run code in) every user's session. There is no way to load
> `CLAUDE.md` *without* also loading `settings.json` from the same source; that
> coupling is in Claude Code, not this pipe.
### Does this apply to the sandboxed pipe?
Not directly. [`claude_agent_pipe_sandboxed.py`](./claude_agent_pipe_sandboxed.py)
doesn't use `setting_sources` at all — it shells the `claude` CLI inside an
open-terminal sandbox with a **per-chat `CLAUDE_CONFIG_DIR`** that's created
fresh each chat, so there's no host `~/.claude/` to inherit and nothing to
disable. Persistent context there is meant to come from mechanisms already built
for isolation:
- **Workspace Model system prompt** — appended on every turn (`--append-system-prompt`); the natural place for standing instructions.
- **Baking into the image** — skills are already vendored into the sandbox image at build time; a `CLAUDE.md` or settings can be baked the same way (under the per-chat `CLAUDE_CONFIG_DIR` layout) if you want file-based context.
Because the sandbox isolates the agent and a proxy holds the credentials, the
security tradeoff is far milder there — but the `setting_sources` valve itself
has nothing to act on, so it's intentionally **not** added to the sandboxed pipe.
| Valve | Default | Description |
| ------------------------- | ---------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `ANTHROPIC_API_KEY` | *(env)* | Anthropic API key. Falls back to the backend's env var. |
| `CLAUDE_CODE_OAUTH_TOKEN` | *(empty)* | Claude subscription OAuth token. Takes priority over the API key when set. |
| `MODEL` | `claude-haiku-4-5` | Claude model ID (e.g. `claude-haiku-4-5`, `claude-sonnet-4-6`, `claude-opus-4-7`). |
| `PERMISSION_MODE` | `bypassPermissions` | `default`, `acceptEdits`, `bypassPermissions`, `plan`, or `dontAsk`. |
| `ALLOWED_TOOLS` | `Read,Write,Edit,Bash,Glob,Grep,WebSearch,WebFetch` | Comma-separated tools auto-approved without prompting. |
| `WORKDIR_ROOT` | `/tmp/claude-agent-pipe` | Root directory for per-chat workspaces. |
| `MAX_TURNS` | `30` | Max agent turns per user message. `0` disables the cap. |
## Auth notes
@ -97,6 +48,35 @@ When both auth methods are present, the OAuth token wins and the API key is unse
Per Anthropic's terms: a Claude subscription is for personal use — **don't re-offer subscription auth to other end users** through a shared Open WebUI deployment. For multi-user setups, use API keys.
## Docker / persistence notes
If Open WebUI's backend runs in a container without a volume for `~/.claude` (and `~/.claude.json`), credentials from an interactive `claude` login are lost the next time the container is recreated (rebuild, `docker compose down`, etc.) — even though the `CLAUDE_CODE_OAUTH_TOKEN` **Valve** itself survives fine, since Valves live in Open WebUI's own database, not the container filesystem.
To make an interactive login durable, mount both paths:
```yaml
services:
openwebui:
volumes:
- open-webui:/app/backend/data
- claude-code-config:/root/.claude
- ./claude-code-auth/.claude.json:/root/.claude.json # pre-create as an empty file on the host
volumes:
open-webui:
claude-code-config:
```
Then log in once inside the running container:
```bash
docker exec -it <container> /usr/local/lib/python3.11/site-packages/claude_agent_sdk/_bundled/claude
```
(path depends on your Python/SDK install — check with `pip show claude-agent-sdk` if it's elsewhere)
With credentials persisted this way, leave the `CLAUDE_CODE_OAUTH_TOKEN` valve empty — the pipe falls back to whatever the backend environment / `~/.claude` already provides.
## License
MIT

View File

@ -2,7 +2,8 @@
title: Claude Code
description: Run Claude Code's agent loop from inside OpenWebUI chats via the Claude Agent SDK.
author: Thomas Friedel
version: 0.1
contributors: Speedliner
version: 0.3
license: MIT
requirements: claude-agent-sdk>=0.1.60, anthropic>=0.40.0
"""
@ -20,6 +21,166 @@ from typing import Any, AsyncGenerator, Callable, Dict, List, Optional, Set
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Inlined from session_store.py / session_marker.py.
#
# OpenWebUI Functions are loaded as a single in-memory module via exec() (see
# open_webui/utils/plugin.py: load_function_module_by_id) — there is no
# mechanism to ship additional .py files alongside a Function, so importing
# sibling modules (`from session_store import ...`) fails with
# ModuleNotFoundError at install time. Both modules are therefore inlined
# here verbatim. Keep the original files in the repo in sync if you edit
# this logic — they are the canonical, independently testable source; this
# inlined copy is what actually ships to OpenWebUI.
# ---------------------------------------------------------------------------
import sqlite3
import threading
_SESSION_STORE_SCHEMA = """
CREATE TABLE IF NOT EXISTS claude_code_sessions (
chat_id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
updated_at REAL NOT NULL
);
"""
class SessionStore:
"""Thread-safe, file-backed chat_id -> session_id store.
Persists the chat_id -> Claude Code session_id mapping in a small SQLite
database next to the per-chat workdir (WORKDIR_ROOT/.session_store.sqlite3).
This replaces a plain in-process dict, which loses all mappings on every
backend restart and is inconsistent across multiple worker processes.
Deliberately NOT integrated with OpenWebUI's own database: OpenWebUI's
internal SQLAlchemy engine/schema is not a stable, documented plugin API,
and writing into OpenWebUI's own chat/message tables risks racing with
OpenWebUI's own read-modify-write cycle on those rows. A separate SQLite
file has no such overlap and needs no coordination with OpenWebUI.
"""
def __init__(self, root_dir, filename: str = ".session_store.sqlite3") -> None:
self._path = Path(root_dir) / filename
self._path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
self._conn = sqlite3.connect(str(self._path), check_same_thread=False, timeout=10)
self._conn.execute("PRAGMA journal_mode=WAL;")
self._conn.execute("PRAGMA busy_timeout=5000;")
with self._lock:
self._conn.execute(_SESSION_STORE_SCHEMA)
self._conn.commit()
def get(self, chat_id: str) -> Optional[str]:
try:
with self._lock:
cur = self._conn.execute(
"SELECT session_id FROM claude_code_sessions WHERE chat_id = ?",
(chat_id,),
)
row = cur.fetchone()
return row[0] if row else None
except sqlite3.Error:
log.exception("SessionStore.get failed for chat_id=%s", chat_id)
return None
def set(self, chat_id: str, session_id: str) -> None:
try:
with self._lock:
self._conn.execute(
"""
INSERT INTO claude_code_sessions (chat_id, session_id, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(chat_id) DO UPDATE SET
session_id = excluded.session_id,
updated_at = excluded.updated_at
""",
(chat_id, session_id, time.time()),
)
self._conn.commit()
except sqlite3.Error:
log.exception("SessionStore.set failed for chat_id=%s", chat_id)
def close(self) -> None:
with self._lock:
self._conn.close()
_session_stores: Dict[str, "SessionStore"] = {}
_session_stores_lock = threading.Lock()
def get_store(root_dir) -> "SessionStore":
"""Return a process-wide singleton SessionStore per WORKDIR_ROOT."""
key = str(root_dir)
with _session_stores_lock:
store = _session_stores.get(key)
if store is None:
store = SessionStore(root_dir)
_session_stores[key] = store
return store
_SESSION_MARKER_RE = re.compile(r"\[claude-session:([A-Za-z0-9_-]{1,128})\]:\s*#")
def make_marker(session_id: str) -> str:
"""Invisible in-text marker carrying the session_id, appended to replies.
Fallback for when the SQLite store is unreachable/wiped independently of
OpenWebUI's own chat history (e.g. different volume lifecycle across a
container redeploy). This is a Markdown link reference definition
(`[label]: #`), which CommonMark-compliant renderers register but never
render as visible output. Rides inside the ordinary assistant message
text that OpenWebUI already persists no extra write path into
OpenWebUI's own data model, hence no extra race-condition surface.
"""
return f"\n\n[claude-session:{session_id}]: #\n"
def extract_session_id_from_messages(messages: List[Dict[str, Any]]) -> Optional[str]:
"""Scan chat history (most recent first) for the last embedded marker.
Only works if OpenWebUI resends prior assistant messages in
body["messages"] (the normal case for Chat-Completions-shaped pipes).
Fallback only, not a replacement for the SQLite store.
"""
for message in reversed(messages or []):
if message.get("role") != "assistant":
continue
content = message.get("content")
text = _flatten_marker_content(content)
if not text:
continue
match = _SESSION_MARKER_RE.search(text)
if match:
return match.group(1)
return None
def strip_marker(text: str) -> str:
"""Remove marker lines from text before showing/logging it elsewhere."""
return _SESSION_MARKER_RE.sub("", text)
def _flatten_marker_content(content: Any) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [
part.get("text", "")
for part in content
if isinstance(part, dict) and part.get("type") == "text"
]
return "\n".join(parts)
return ""
# ---------------------------------------------------------------------------
# End of inlined session_store.py / session_marker.py
# ---------------------------------------------------------------------------
_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp"}
_DOWNLOAD_EXTENSIONS = {
".pdf",
@ -47,6 +208,7 @@ from claude_agent_sdk import (
AssistantMessage,
ClaudeAgentOptions,
ClaudeSDKClient,
RateLimitEvent,
ResultMessage,
StreamEvent,
SystemMessage,
@ -59,8 +221,15 @@ from claude_agent_sdk import (
log = logging.getLogger(__name__)
# OpenWebUI calls pipe() fresh for each chat turn. We keep a chat_id -> session_id
# map in-process so follow-up turns resume the same Claude Code session.
# OpenWebUI calls pipe() fresh for each chat turn. This in-process dict is a
# fast path so a hot worker doesn't have to hit SQLite on every turn. It is
# NOT the source of truth anymore: it starts empty on every process restart
# and is per-worker (stale/absent on any other worker), which is exactly why
# Claude Code used to "forget" chat history across restarts / multi-worker
# deployments. The source of truth is session_store.SessionStore (SQLite,
# survives restarts, shared across workers via the shared filesystem), with
# session_marker as a secondary fallback embedded in the chat text itself.
# See session_store.py / session_marker.py for the full rationale.
_chat_sessions: Dict[str, str] = {}
@ -76,6 +245,26 @@ _TOOL_PREVIEW_FIELDS = {
"Task": "description",
}
def _format_rate_limit(info) -> str:
"""Human-readable line for a RateLimitEvent.rate_limit_info."""
import datetime
parts = []
if info.status == "rejected":
parts.append("🛑 API-Limit erreicht")
elif info.status == "allowed_warning":
parts.append("⚠️ API-Limit fast erreicht")
else:
parts.append(f" Rate-Limit-Status: {info.status}")
if info.rate_limit_type:
parts.append(f"({info.rate_limit_type})")
if info.utilization is not None:
parts.append(f"· {info.utilization * 100:.0f}% genutzt")
if info.resets_at:
reset_str = datetime.datetime.fromtimestamp(info.resets_at).strftime("%H:%M:%S")
parts.append(f"· verfügbar wieder ab {reset_str}")
return " ".join(parts)
def _tool_preview(name: str, tool_input: Dict[str, Any]) -> str:
key = _TOOL_PREVIEW_FIELDS.get(name)
@ -379,7 +568,7 @@ def _build_kb_mcp_server(
that OpenWebUI's middleware already filtered by the user's grants.
"""
if not knowledge:
return None, []
return None, [], {}
collection_names = [k["id"] for k in knowledge]
display = ", ".join(k["name"] for k in knowledge)
@ -737,6 +926,82 @@ def _build_kb_mcp_server(
return server, tool_names, tools_by_name
# ---------------------------------------------------------------------------
# OpenWebUI Tools/MCP passthrough — wraps whatever Tools or external tool
# servers (incl. MCP, via mcpo) are attached to the Workspace Model as an
# in-process MCP server, so Claude Code gets the same toolbox OpenWebUI's
# native tool-calling would use. Whatever's attached in
# Workspace -> Models -> Tools shows up here automatically at request time —
# no hardcoded server URL/config needed, so it stays in sync if the
# attached tools/connections change later.
# ---------------------------------------------------------------------------
_JSON_SCHEMA_TYPE_MAP = {
"string": str,
"integer": int,
"number": float,
"boolean": bool,
"array": list,
"object": dict,
}
def _build_owui_tools_mcp_server(tools: Optional[Dict[str, Any]]):
"""Wrap OpenWebUI's __tools__ (Tools + external/MCP tool servers attached
to the Workspace Model) as an in-process MCP server. Each entry already
carries an OpenAI-style JSON-schema `spec` and an async-wrapped
`callable` OpenWebUI has already injected any special
__user__/__event_emitter__ params via apply_extra_params_to_tool_function,
so we just need to call it.
Note: OpenWebUI's built-in tools (web_search, image_generation,
execute_code) are currently NOT included in __tools__ only
user-defined Tools and external/MCP tool servers are. Claude Code
already ships its own WebSearch/WebFetch tools, so this mainly matters
for custom/internal tool servers.
"""
if not tools:
return None, []
sdk_tools = []
tool_names: List[str] = []
for name, entry in tools.items():
spec = entry.get("spec") or {}
params = spec.get("parameters", {}).get("properties", {}) or {}
# Flat type mapping — covers the vast majority of OpenWebUI tool
# specs. Nested/array-item schemas aren't modeled; extend here if a
# specific tool needs richer typing.
input_schema = {
pname: _JSON_SCHEMA_TYPE_MAP.get(pinfo.get("type"), str)
for pname, pinfo in params.items()
}
callable_fn = entry.get("callable")
description = spec.get("description") or f"OpenWebUI tool: {name}"
def _make_handler(fn: Callable) -> Callable:
async def _handler(args: Dict[str, Any]) -> Dict[str, Any]:
try:
result = fn(**(args or {}))
if asyncio.iscoroutine(result):
result = await result
except Exception as exc:
log.exception("OpenWebUI tool failed")
return {
"content": [{"type": "text", "text": f"Tool failed: {exc}"}]
}
return {"content": [{"type": "text", "text": str(result)}]}
return _handler
sdk_tools.append(
tool(name, description, input_schema)(_make_handler(callable_fn))
)
tool_names.append(f"mcp__owui-tools__{name}")
server = create_sdk_mcp_server("owui-tools", "0.1", tools=sdk_tools)
return server, tool_names
def _anthropic_kb_tool_defs(
knowledge: List[Dict[str, str]], has_kb_ids: bool
) -> List[Dict[str, Any]]:
@ -1124,9 +1389,7 @@ class Pipe:
final = await stream.get_final_message()
except Exception as exc:
log.exception("Fast path failed")
yield (
f"\n\n**Fast-path error:** `{type(exc).__name__}: {exc}`\n"
)
yield (f"\n\n**Fast-path error:** `{type(exc).__name__}: {exc}`\n")
return
if final.stop_reason != "tool_use":
@ -1370,6 +1633,7 @@ class Pipe:
__files__: Optional[List[Dict[str, Any]]] = None,
__user__: Optional[Dict[str, Any]] = None,
__metadata__: Optional[Dict[str, Any]] = None,
__tools__: Optional[Dict[str, Any]] = None,
) -> AsyncGenerator[str, None]:
# Auth selection:
# 1. If CLAUDE_CODE_OAUTH_TOKEN valve is set → use subscription.
@ -1398,13 +1662,49 @@ class Pipe:
prompt = _strip_mode_prefix(prompt)
chat_id = __chat_id__ or "default"
if not __chat_id__:
log.warning(
"pipe() called without __chat_id__ — falling back to a shared "
"'default' workdir/session. Session resume across turns will "
"not work correctly for this call (see OpenWebUI issue about "
"metadata.chat_id being unset for certain internal calls)."
)
workdir = Path(self.valves.WORKDIR_ROOT) / chat_id
workdir.mkdir(parents=True, exist_ok=True)
allowed_tools = [
t.strip() for t in self.valves.ALLOWED_TOOLS.split(",") if t.strip()
]
# Resolve the session to resume, in order of preference:
# 1. In-process cache (_chat_sessions) — fastest, but empty after a
# restart or on any worker that didn't handle the previous turn.
# 2. SQLite-backed SessionStore — survives restarts and is shared
# across worker processes via the shared filesystem under
# WORKDIR_ROOT. This is the source of truth.
# 3. Marker embedded in the previous assistant message, extracted
# from OpenWebUI's own chat history in `body`. Fallback only,
# for the case where the SQLite file itself became unavailable
# (e.g. WORKDIR_ROOT lives on ephemeral storage that was wiped
# independently of OpenWebUI's own chat database).
session_store = get_store(self.valves.WORKDIR_ROOT)
resume_id = _chat_sessions.get(chat_id)
resume_source = "memory" if resume_id else None
if not resume_id:
resume_id = session_store.get(chat_id)
if resume_id:
resume_source = "sqlite"
if not resume_id:
resume_id = extract_session_id_from_messages(body.get("messages") or [])
if resume_id:
resume_source = "marker"
if resume_id:
log.debug(
"Resuming Claude Code session %s for chat_id=%s (source=%s)",
resume_id,
chat_id,
resume_source,
)
# Knowledge base attached via Workspace Model → expose as an MCP tool
# Claude can call agentically. OpenWebUI's middleware already added one
@ -1415,7 +1715,12 @@ class Pipe:
user_dict=__user__,
event_emitter=__event_emitter__,
)
allowed_tools = allowed_tools + kb_tool_names
# OpenWebUI Tools / external tool servers (incl. MCP via mcpo)
# attached to the Workspace Model → same passthrough treatment.
owui_server, owui_tool_names = _build_owui_tools_mcp_server(__tools__)
allowed_tools = allowed_tools + kb_tool_names + owui_tool_names
options_kwargs: Dict[str, Any] = {
"cwd": str(workdir),
@ -1435,8 +1740,14 @@ class Pipe:
options_kwargs["resume"] = resume_id
if self.valves.MAX_TURNS:
options_kwargs["max_turns"] = self.valves.MAX_TURNS
mcp_servers: Dict[str, Any] = {}
if kb_server is not None:
options_kwargs["mcp_servers"] = {"helm-kb": kb_server}
mcp_servers["helm-kb"] = kb_server
if owui_server is not None:
mcp_servers["owui-tools"] = owui_server
if mcp_servers:
options_kwargs["mcp_servers"] = mcp_servers
# Extend Claude Code's default agent-loop system prompt with whatever
# the Workspace Model configured. `append` keeps the agentic prompt
@ -1513,6 +1824,11 @@ class Pipe:
session_id = message.data.get("session_id")
if session_id:
_chat_sessions[chat_id] = session_id
# Persist beyond this process's lifetime/worker.
# Cheap (single upsert) and done on every init
# message, i.e. once per turn — not hot-path
# sensitive.
session_store.set(chat_id, session_id)
continue
if isinstance(message, StreamEvent):
@ -1609,6 +1925,14 @@ class Pipe:
)
continue
if isinstance(message, RateLimitEvent):
info = message.rate_limit_info
line = _format_rate_limit(info)
await emit_status(line, done=(info.status == "rejected"))
if info.status in ("allowed_warning", "rejected"):
yield f"\n\n_{line}_\n"
continue
if isinstance(message, ResultMessage):
await emit_status("Done.", done=True)
for chunk in _inline_new_artifacts(
@ -1619,8 +1943,20 @@ class Pipe:
yield chunk
if message.subtype != "success":
yield f"\n\n_Agent stopped: {message.subtype}_\n"
if message.api_error_status:
yield f"\n\n**API-Fehler:** `{message.api_error_status}`\n"
if message.total_cost_usd is not None:
yield f"\n\n_Cost: ${message.total_cost_usd:.4f} · {message.duration_ms}ms_\n"
# Fallback-path marker (see session_marker.py): embed
# the session_id invisibly in the reply itself so it
# can be recovered from OpenWebUI's own chat history
# even if both the in-memory cache and the SQLite
# store are unavailable on a later turn. This does
# NOT write into any OpenWebUI-owned data — it's part
# of the ordinary streamed assistant text.
current_session_id = _chat_sessions.get(chat_id)
if current_session_id:
yield make_marker(current_session_id)
return
except Exception as exc:

View File

@ -4,7 +4,8 @@ description: Run Claude Code inside an open-webui/open-terminal sandbox, one
Linux account per OpenWebUI user. Isolates the agent's file/process reach
from the Open WebUI backend host.
author: Thomas Friedel
version: 0.1
contributors: Speedliner
version: 0.2
license: MIT
requirements: httpx>=0.27
"""
@ -994,10 +995,40 @@ async def _handle_event(
)
return
if etype == "rate_limit_event":
info = event.get("rate_limit_info") or {}
status = info.get("status")
line_parts = []
if status == "rejected":
line_parts.append("🛑 API-Limit erreicht")
elif status == "allowed_warning":
line_parts.append("⚠️ API-Limit fast erreicht")
else:
line_parts.append(f" Rate-Limit-Status: {status}")
rl_type = info.get("rateLimitType")
if rl_type:
line_parts.append(f"({rl_type})")
utilization = info.get("utilization")
if utilization is not None:
line_parts.append(f"· {utilization * 100:.0f}% genutzt")
resets_at = info.get("resetsAt")
if resets_at:
import datetime
reset_str = datetime.datetime.fromtimestamp(resets_at).strftime("%H:%M:%S")
line_parts.append(f"· verfügbar wieder ab {reset_str}")
line = " ".join(line_parts)
await emit_status(line, done=(status == "rejected"))
if status in ("allowed_warning", "rejected"):
yield f"\n\n_{line}_\n"
return
if etype == "result":
subtype = event.get("subtype")
if subtype and subtype != "success":
yield f"\n\n_Agent stopped: {subtype}_\n"
api_error_status = event.get("api_error_status")
if api_error_status:
yield f"\n\n**API-Fehler:** `{api_error_status}`\n"
cost = event.get("total_cost_usd")
dur = event.get("duration_ms")
if cost is not None and dur is not None: