Merge bf2c8a28c6 into 5bbc1fcdf9
This commit is contained in:
commit
72e84da09b
102
README.md
102
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue