Add the assistant: a chat that drives the MCP tools in-process
Docs / docs (push) Successful in 42s
Playwright Tests / test-playwright (1, 2) (push) Failing after 2m42s
Playwright Tests / test-playwright (2, 2) (push) Failing after 10s
pre-commit / pre-commit (push) Failing after 2m33s
Test Backend / test-backend (push) Failing after 2m48s
Compose Smoke Test / test-compose (push) Failing after 9s
Playwright Tests / merge-reports (push) Failing after 2m17s

A provider set under Settings (OpenAI-compatible; key encrypted in
flow-data/ai.json) turns on an Assistant panel in the sidebar. Each turn runs
the FastMCP tools in-process as the person chatting, streams the rounds over
SSE and keeps one transcript per user. The panel floats on any screen and
shares the editor's lane: a settings panel keeps the top half, the chat the
bottom. The MCP client is now built once, for the endpoint and the chat.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 23:28:23 +02:00
co-authored by Claude Fable 5.1
parent d9630f66f5
commit cc4091166e
25 changed files with 1957 additions and 43 deletions
+7
View File
@@ -0,0 +1,7 @@
"""The assistant: a model driving the MCP tools from inside the dashboard.
``config`` is the provider and the key, ``client`` the one call every
OpenAI-compatible provider answers, ``agent`` the loop between the model and
the tools. The tools are the MCP server's own, so what the assistant can do is
exactly what an external agent can do.
"""
+172
View File
@@ -0,0 +1,172 @@
"""The turn: the model, the tools, and the rounds between them.
The tools are the MCP server's own, called in-process rather than over the
endpoint: same names, same schemas, same REST calls underneath. A tool added
for external agents is there for the assistant the moment it exists, and the
assistant can do nothing an external agent could not.
The transcript is a list of chat-completion messages, mutated in place by
:func:`run_turn` and persisted by the caller. It is also what the browser
renders, so nothing is stored twice.
"""
from __future__ import annotations
import json
import logging
from collections.abc import AsyncIterator
from typing import Any
import httpx
from mcp.types import TextContent
from fluksio.ai import client
from fluksio.ai.config import AIConfig
from fluksio.flow.schemas import FlowDef
from fluksio.mcp.server import caller_authorization, mcp
logger = logging.getLogger(__name__)
_SYSTEM = """You are the assistant inside a Fluksio instance. You read, build, edit and \
operate flows through the tools; they are the same API the editor uses, so \
anything you save appears in the editor at once.
How to work:
- Read before you write: get_flow before save_flow, and pass back the version \
you read. On a version conflict, read again and reapply the change.
- save_flow writes a draft, and validate_flow tells you whether it is sound. \
Publish only when the user asks for the change to go live; otherwise leave \
the draft for review in the editor and say so.
- list_node_types gives the node types and their parameters; get_node_source \
shows how a Python node is written before you write one.
- Keep answers short, in plain text without markdown. Name what you changed.
The flow document `save_flow` takes, as a JSON schema:
"""
# ponytail: a tool's answer is replayed on every later round of the
# conversation, so one big one is paid for again and again; cut it here rather
# than summarise it. A per-tool digest is the upgrade if this bites.
_RESULT_LIMIT = 20_000
def system_prompt(flow: str | None) -> str:
prompt = _SYSTEM + json.dumps(FlowDef.model_json_schema())
if flow:
prompt += f"\n\nThe user is looking at the flow named {flow!r}."
return prompt
async def tool_specs() -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": tool.name,
"description": tool.description or "",
"parameters": tool.inputSchema,
},
}
for tool in await mcp.list_tools()
]
def _arguments(raw: Any) -> dict[str, Any]:
if isinstance(raw, dict):
return raw
try:
parsed = json.loads(raw or "{}")
except ValueError:
return {}
return parsed if isinstance(parsed, dict) else {}
async def _call_tool(name: str, arguments: dict[str, Any]) -> str:
"""Run one tool; whatever goes wrong is text the model can read."""
try:
result = await mcp.call_tool(name, arguments)
except Exception as exc: # noqa: BLE001 - an unknown tool, or refused arguments
return json.dumps({"error": str(exc)})
if isinstance(result, dict):
text = json.dumps(result)
else:
text = "".join(c.text for c in result if isinstance(c, TextContent))
if len(text) > _RESULT_LIMIT:
text = text[:_RESULT_LIMIT] + "… (truncated)"
return text
def summarize(content: str | None) -> dict[str, Any]:
"""What the browser shows for a tool's answer: whether it worked."""
try:
result = json.loads(content or "")
except ValueError:
return {"ok": True}
if isinstance(result, dict) and "error" in result:
return {"ok": False, "error": str(result["error"])[:300]}
return {"ok": True}
async def run_turn(
config: AIConfig,
messages: list[dict[str, Any]],
text: str,
*,
flow: str | None,
authorization: str,
) -> AsyncIterator[dict[str, Any]]:
"""One user turn, yielding progress events as it goes.
A round is appended to *messages* only once every tool in it has answered,
so a turn cut off part-way leaves a transcript the provider still accepts.
"""
messages.append({"role": "user", "content": text})
tools = await tool_specs()
system = {"role": "system", "content": system_prompt(flow)}
token = caller_authorization.set(authorization)
try:
for _ in range(config.max_rounds):
try:
response = await client.chat_completion(
api_base=config.api_base,
api_key=config.api_key,
model=config.model,
messages=[system, *messages],
tools=tools,
)
except (client.ProviderError, httpx.HTTPError) as exc:
yield {"type": "error", "detail": f"The provider answered {exc}"}
return
choice = response["choices"][0]["message"]
content = choice.get("content") or ""
calls = choice.get("tool_calls") or []
assistant: dict[str, Any] = {"role": "assistant", "content": content}
if content:
yield {"type": "message", "content": content}
if not calls:
messages.append(assistant)
return
assistant["tool_calls"] = calls
answers: list[dict[str, Any]] = []
for call in calls:
function = call.get("function") or {}
name = str(function.get("name", ""))
arguments = _arguments(function.get("arguments"))
yield {"type": "tool_call", "name": name, "arguments": arguments}
result = await _call_tool(name, arguments)
answers.append(
{
"role": "tool",
"tool_call_id": call.get("id", ""),
"content": result,
}
)
yield {"type": "tool_result", "name": name, **summarize(result)}
messages.append(assistant)
messages.extend(answers)
yield {
"type": "error",
"detail": f"Stopped after {config.max_rounds} rounds of tool calls.",
}
finally:
caller_authorization.reset(token)
+76
View File
@@ -0,0 +1,76 @@
"""One OpenAI-compatible provider call, and the model listing beside it.
Chat completions with function tools is the dialect every hosted provider and
every local server speaks — OpenAI, Anthropic's compatibility endpoint,
OpenRouter, Ollama, vLLM — so one client covers them without a provider SDK.
The admin types the URL; the model never chooses where a request goes.
"""
from __future__ import annotations
from typing import Any
from urllib.parse import urlparse
import httpx
class ProviderError(Exception):
"""The provider answered, and the answer was a refusal."""
def check_base(api_base: str) -> None:
parsed = urlparse(api_base)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise ValueError("The provider URL must be an http(s) URL")
def _headers(api_key: str) -> dict[str, str]:
return {"Authorization": f"Bearer {api_key}"}
def _detail(response: httpx.Response) -> str:
try:
error = response.json().get("error")
except ValueError:
error = None
if isinstance(error, dict):
error = error.get("message")
text = str(error or response.text or response.reason_phrase)
return f"{response.status_code}: {text[:300]}"
async def chat_completion(
*,
api_base: str,
api_key: str,
model: str,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]],
timeout: float = 120.0,
) -> dict[str, Any]:
"""``POST {api_base}/chat/completions``, the whole answer at once."""
body: dict[str, Any] = {"model": model, "messages": messages}
if tools:
body["tools"] = tools
async with httpx.AsyncClient(timeout=timeout) as http:
response = await http.post(
api_base.rstrip("/") + "/chat/completions",
json=body,
headers=_headers(api_key),
)
if response.is_error:
raise ProviderError(_detail(response))
result: dict[str, Any] = response.json()
return result
async def list_models(api_base: str, api_key: str, timeout: float = 15.0) -> list[str]:
"""The ids ``GET {api_base}/models`` offers, for the settings screen."""
async with httpx.AsyncClient(timeout=timeout) as http:
response = await http.get(
api_base.rstrip("/") + "/models", headers=_headers(api_key)
)
if response.is_error:
raise ProviderError(_detail(response))
data = response.json().get("data") or []
return sorted(str(m["id"]) for m in data if isinstance(m, dict) and "id" in m)
+98
View File
@@ -0,0 +1,98 @@
"""The assistant's provider: where the model is, and the key that opens it.
One file on the data volume beside ``cloud.json``, for the same reasons: it is
a credential, it must survive a rebuild, and it must never be in the flows
repository. The key is encrypted with the cipher the secrets store uses, so
both turn unreadable together when ``SECRET_KEY`` changes. Nothing here exists
until a superuser writes it, and its absence is what keeps every AI surface
off.
"""
from __future__ import annotations
import json
import logging
import os
from dataclasses import asdict, dataclass
from typing import Any
from cryptography.fernet import InvalidToken
from fluksio.core.config import settings
from fluksio.flow.secrets import fernet
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class AIConfig:
#: An OpenAI-compatible base, e.g. ``https://api.openai.com/v1``.
api_base: str
api_key: str
model: str
#: How many model-and-tools rounds one turn may take before it is stopped.
max_rounds: int = 12
@property
def ready(self) -> bool:
return bool(self.api_base and self.api_key and self.model)
def public(self) -> dict[str, Any]:
"""The secret-free view the settings screen gets."""
key = self.api_key
masked = None
if key:
masked = f"{key[:3]}…{key[-4:]}" if len(key) > 8 else "••••"
return {
"api_base": self.api_base,
"model": self.model,
"max_rounds": self.max_rounds,
"api_key_set": bool(key),
"api_key_masked": masked,
}
def load() -> AIConfig | None:
path = settings.AI_CONFIG_FILE
try:
data = json.loads(path.read_text())
except OSError:
return None
except ValueError:
logger.exception("Could not read %s; the assistant stays off", path)
return None
sealed = data.pop("api_key_enc", "")
key = ""
if sealed:
try:
key = fernet(settings.SECRET_KEY).decrypt(sealed.encode()).decode()
except InvalidToken:
logger.error(
"%s was written with a different SECRET_KEY; enter the key again",
path,
)
try:
return AIConfig(api_key=key, **data)
except TypeError:
logger.exception("Could not read %s; the assistant stays off", path)
return None
def save(config: AIConfig) -> None:
path = settings.AI_CONFIG_FILE
path.parent.mkdir(parents=True, exist_ok=True)
data = asdict(config)
key = data.pop("api_key")
data["api_key_enc"] = (
fernet(settings.SECRET_KEY).encrypt(key.encode()).decode() if key else ""
)
# Written through a temporary file so a crash mid-write cannot leave a
# half-parsed credential behind, and never group- or world-readable.
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2))
os.chmod(tmp, 0o600)
tmp.replace(path)
def delete() -> None:
settings.AI_CONFIG_FILE.unlink(missing_ok=True)
+4
View File
@@ -1,6 +1,7 @@
from fastapi import APIRouter
from fluksio.api.routes import (
ai,
alerts,
artifacts,
cloud,
@@ -51,3 +52,6 @@ api_router.include_router(oauth.router)
# Always mounted like oauth, so the generated SDK keeps its shape; the
# endpoints refuse to work unless the private API is explicitly enabled.
api_router.include_router(private.router)
# The assistant. Always mounted for the same reason; without a provider
# configured the status says so and the chat answers 404.
api_router.include_router(ai.router)
+250
View File
@@ -0,0 +1,250 @@
"""The assistant: its provider, and the conversation with it.
The provider is instance-wide and superuser-only to set, because the key it
holds is spent by everyone who chats. The conversation is per person and runs
as them: every tool call carries their own token, so the assistant can do
nothing they could not do in the browser.
"""
from __future__ import annotations
import json
import logging
import uuid
from collections.abc import AsyncIterator
from typing import Any
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from fluksio.ai import agent, client
from fluksio.ai import config as ai_config
from fluksio.api.deps import CurrentUser, TokenDep, get_current_active_superuser
from fluksio.core.config import settings
from fluksio.flow.schemas import NAME_PATTERN
from fluksio.mcp import server as tools
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/ai", tags=["ai"])
#: Whose turn is running: a second message while one is answered is refused
#: rather than interleaved into the same transcript.
_busy: set[uuid.UUID] = set()
class AIConfigPublic(BaseModel):
api_base: str
model: str
max_rounds: int
api_key_set: bool
api_key_masked: str | None
class AIConfigUpdate(BaseModel):
api_base: str = Field(min_length=1, max_length=512)
#: Blank or absent keeps the stored key.
api_key: str | None = Field(default=None, max_length=512)
model: str = Field(default="", max_length=128)
max_rounds: int = Field(default=12, ge=1, le=50)
class AIModelsBody(BaseModel):
api_base: str = Field(min_length=1, max_length=512)
#: Absent tries the stored key, so a saved provider can be re-listed.
api_key: str | None = Field(default=None, max_length=512)
class AIStatus(BaseModel):
available: bool
model: str | None
class ChatItem(BaseModel):
role: str
content: str | None = None
tool_calls: list[dict[str, Any]] | None = None
#: On a tool's answer: whether it worked, and the error if not.
ok: bool | None = None
error: str | None = None
class TurnBody(BaseModel):
message: str = Field(min_length=1, max_length=20_000)
#: The flow open in the editor, when the chat is opened from one.
flow: str | None = Field(default=None, pattern=NAME_PATTERN.pattern)
def _public(config: ai_config.AIConfig | None) -> AIConfigPublic:
if config is None:
return AIConfigPublic(
api_base="",
model="",
max_rounds=12,
api_key_set=False,
api_key_masked=None,
)
return AIConfigPublic(**config.public())
@router.get(
"/config",
dependencies=[Depends(get_current_active_superuser)],
response_model=AIConfigPublic,
)
def read_ai_config() -> Any:
return _public(ai_config.load())
@router.put(
"/config",
dependencies=[Depends(get_current_active_superuser)],
response_model=AIConfigPublic,
)
def update_ai_config(body: AIConfigUpdate) -> Any:
try:
client.check_base(body.api_base)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
current = ai_config.load()
key = body.api_key or (current.api_key if current else "")
config = ai_config.AIConfig(
api_base=body.api_base.rstrip("/"),
api_key=key,
model=body.model.strip(),
max_rounds=body.max_rounds,
)
ai_config.save(config)
return _public(config)
@router.delete("/config", dependencies=[Depends(get_current_active_superuser)])
def delete_ai_config() -> Any:
ai_config.delete()
return _public(None)
@router.post(
"/config/models",
dependencies=[Depends(get_current_active_superuser)],
response_model=list[str],
)
async def list_ai_models(body: AIModelsBody) -> Any:
"""What the provider offers, tried with the key given or the one stored."""
try:
client.check_base(body.api_base)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
current = ai_config.load()
key = body.api_key or (current.api_key if current else "")
try:
return await client.list_models(body.api_base.rstrip("/"), key)
except (client.ProviderError, httpx.HTTPError) as exc:
raise HTTPException(
status_code=400, detail=f"The provider answered {exc}"
) from exc
@router.get("/status", response_model=AIStatus)
def read_ai_status(_: CurrentUser) -> Any:
"""Whether there is an assistant to show. Readable by anyone signed in."""
config = ai_config.load()
if config is None or not config.ready:
return AIStatus(available=False, model=None)
return AIStatus(available=True, model=config.model)
def _chat_file(user_id: uuid.UUID) -> Any:
return settings.CHATS_DIR / f"{user_id}.json"
def _load_chat(user_id: uuid.UUID) -> list[dict[str, Any]]:
try:
messages: list[dict[str, Any]] = json.loads(_chat_file(user_id).read_text())
except (OSError, ValueError):
return []
return messages
def _save_chat(user_id: uuid.UUID, messages: list[dict[str, Any]]) -> None:
path = _chat_file(user_id)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(messages))
@router.get("/chat", response_model=list[ChatItem])
def read_chat(user: CurrentUser) -> Any:
"""The transcript, with each tool's answer reduced to whether it worked."""
items = []
for message in _load_chat(user.id):
if message.get("role") == "tool":
items.append(
ChatItem(role="tool", **agent.summarize(message.get("content")))
)
else:
items.append(
ChatItem(
role=message["role"],
content=message.get("content"),
tool_calls=message.get("tool_calls"),
)
)
return items
@router.delete("/chat", response_model=list[ChatItem])
def clear_chat(user: CurrentUser) -> Any:
_chat_file(user.id).unlink(missing_ok=True)
return []
def _sse(event: dict[str, Any]) -> str:
return f"data: {json.dumps(event)}\n\n"
@router.post("/chat")
def chat(
body: TurnBody, request: Request, user: CurrentUser, token: TokenDep
) -> StreamingResponse:
"""One turn, streamed as server-sent events.
``message``, ``tool_call`` and ``tool_result`` events as the model works,
``error`` when something stops it, and ``done`` last either way.
"""
config = ai_config.load()
if config is None or not config.ready:
# 404 rather than 403: an unconfigured instance says nothing about it.
raise HTTPException(status_code=404, detail="The assistant is not set up")
if user.id in _busy:
raise HTTPException(status_code=409, detail="Still answering the last one")
tools.connect(request.app)
messages = _load_chat(user.id)
async def generate() -> AsyncIterator[str]:
_busy.add(user.id)
try:
yield _sse({"type": "start"})
async for event in agent.run_turn(
config,
messages,
body.message,
flow=body.flow,
authorization=f"Bearer {token}",
):
yield _sse(event)
except Exception as exc: # noqa: BLE001 - the stream is the error channel
logger.exception("The assistant's turn failed")
yield _sse({"type": "error", "detail": f"{type(exc).__name__}: {exc}"})
finally:
# What happened so far, even if the browser left part-way.
_save_chat(user.id, messages)
_busy.discard(user.id)
yield _sse({"type": "done"})
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
+8
View File
@@ -27,6 +27,8 @@ DERIVED_PATHS = {
"OAUTH_PRIVATE_KEY_FILE": "oauth-key.pem",
"CLOUD_CONFIG_FILE": "cloud.json",
"PROVISIONERS_FILE": "provisioners.json",
"AI_CONFIG_FILE": "ai.json",
"CHATS_DIR": "chats",
}
@@ -102,6 +104,12 @@ class Settings(BaseSettings):
# Written only when someone enrols this instance with a portal.
# Its absence is what keeps remote access off.
CLOUD_CONFIG_FILE: Path = Path("flow-data/cloud.json")
# The assistant's provider: written from Settings, key encrypted with the
# app key like the secrets store. Absent until someone configures it, and
# its absence is what keeps every AI surface off.
AI_CONFIG_FILE: Path = Path("flow-data/ai.json")
# One transcript per user, beside the config it talks through.
CHATS_DIR: Path = Path("flow-data/chats")
OAUTH_CODE_EXPIRE_SECONDS: int = 60
# Short, because an agent's token is a bearer secret held by a program
# rather than a person, and it can refresh unattended.
+7 -3
View File
@@ -34,14 +34,18 @@ class SecretNotFound(KeyError):
return f"No secret named '{self.name}' — add it under Secrets."
def fernet(key: str) -> Fernet:
"""The cipher a store keyed on the app key uses, so every one of them
turns unreadable together when ``SECRET_KEY`` changes."""
return Fernet(base64.urlsafe_b64encode(hashlib.sha256(key.encode()).digest()))
class SecretsStore:
"""Named credentials, encrypted at rest with a key derived from the app key."""
def __init__(self, path: Path, key: str) -> None:
self._path = path
self._fernet = Fernet(
base64.urlsafe_b64encode(hashlib.sha256(key.encode()).digest())
)
self._fernet = fernet(key)
def _read(self) -> dict[str, str]:
if not self._path.exists():
+6 -4
View File
@@ -3,6 +3,7 @@ import contextlib
import hashlib
import inspect
import logging
import sys
import tempfile
from collections.abc import AsyncIterator, Callable, Coroutine
from contextlib import AbstractAsyncContextManager, asynccontextmanager
@@ -392,10 +393,11 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
raise
finally:
await _release()
if settings.MCP_ENABLED:
from fluksio.mcp.http import aclose
await aclose()
# Imported by the endpoint when MCP is on and by the assistant on its
# first turn; either way the client it built is closed here.
tools = sys.modules.get("fluksio.mcp.server")
if tools is not None:
await tools.aclose()
# The schema enumerates every endpoint this instance serves, including the
+1 -20
View File
@@ -11,7 +11,6 @@ from __future__ import annotations
import logging
from urllib.parse import urlparse
import httpx
import jwt
from fastapi.concurrency import run_in_threadpool
from mcp.server.auth.provider import AccessToken
@@ -27,9 +26,6 @@ from fluksio.mcp import server
logger = logging.getLogger(__name__)
#: The tool calls never leave the process, so the host is a label, not a route.
_INTERNAL_BASE = "http://fluksio-mcp.internal"
class _JWTVerifier:
"""Accept only tokens minted for the MCP channel, by agents still registered.
@@ -112,20 +108,5 @@ def build_http_app(app: object) -> Starlette:
# module-level so the tools can be declared at import time.
server.mcp._token_verifier = _JWTVerifier() # noqa: SLF001
server.set_client(
httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), # type: ignore[arg-type]
base_url=_INTERNAL_BASE,
# Running a flow is synchronous work on a threadpool, so a slow
# flow must not look like a dead endpoint.
timeout=120.0,
event_hooks={"request": [server.forward_caller_auth]},
)
)
server.connect(app)
return server.mcp.streamable_http_app()
async def aclose() -> None:
if server._client is not None: # noqa: SLF001
await server._client.aclose() # noqa: SLF001
server.set_client(None)
+45 -9
View File
@@ -13,6 +13,7 @@ identity rather than some service account.
from __future__ import annotations
import logging
from contextvars import ContextVar
from typing import Any
import httpx
@@ -23,28 +24,63 @@ logger = logging.getLogger(__name__)
mcp = FastMCP("fluksio")
#: Set by ``http.build_http_app``; the ASGI client that carries tool calls into
#: the REST API without a network hop.
#: The tool calls never leave the process, so the host is a label, not a route.
_INTERNAL_BASE = "http://fluksio-mcp.internal"
#: The ASGI client that carries tool calls into the REST API without a network
#: hop. Built once by :func:`connect`, for the endpoint and the assistant alike.
_client: httpx.AsyncClient | None = None
#: The ``Authorization`` value for tool calls made from inside this process,
#: where no MCP request carries one: the assistant sets it to the token of the
#: person chatting, for the length of their turn.
caller_authorization: ContextVar[str | None] = ContextVar(
"caller_authorization", default=None
)
def set_client(client: httpx.AsyncClient | None) -> None:
global _client
_client = client
async def forward_caller_auth(request: httpx.Request) -> None:
"""Carry the calling agent's bearer token onto the REST call.
def connect(app: object) -> None:
"""Wire the tools to *app*'s API, once."""
if _client is not None:
return
set_client(
httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), # type: ignore[arg-type]
base_url=_INTERNAL_BASE,
# Running a flow is synchronous work on a threadpool, so a slow
# flow must not look like a dead endpoint.
timeout=120.0,
event_hooks={"request": [forward_caller_auth]},
)
)
The transport attaches the originating HTTP request to every JSON-RPC
message, and a tool handler runs inside that request's context, so the
token is read per message rather than per connection.
async def aclose() -> None:
if _client is not None:
await _client.aclose()
set_client(None)
async def forward_caller_auth(request: httpx.Request) -> None:
"""Carry the caller's bearer token onto the REST call.
Over the endpoint, the transport attaches the originating HTTP request to
every JSON-RPC message, and a tool handler runs inside that request's
context, so the token is read per message rather than per connection. A
call from inside the process has no such request and uses what
:data:`caller_authorization` holds.
"""
authorization = caller_authorization.get()
try:
source = request_ctx.get().request
except LookupError:
return
authorization = getattr(source, "headers", {}).get("authorization")
source = None
authorization = getattr(source, "headers", {}).get("authorization") or authorization
if authorization:
request.headers["authorization"] = authorization
+138
View File
@@ -0,0 +1,138 @@
"""The assistant: who may set it up, and that it acts as the person chatting."""
import json
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
from fluksio.ai import agent
from fluksio.core.config import settings
@pytest.fixture
def ai_files(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
monkeypatch.setattr(settings, "AI_CONFIG_FILE", tmp_path / "ai.json")
monkeypatch.setattr(settings, "CHATS_DIR", tmp_path / "chats")
return tmp_path
def _events(response: Any) -> list[dict[str, Any]]:
return [
json.loads(line[5:])
for line in response.iter_lines()
if line.startswith("data:")
]
def test_the_provider_is_a_superuser_setting_and_the_key_stays_sealed(
client: TestClient,
superuser_token_headers: dict[str, str],
normal_user_token_headers: dict[str, str],
ai_files: Path,
) -> None:
assert client.get(
"/api/v1/ai/status", headers=normal_user_token_headers
).json() == {
"available": False,
"model": None,
}
body = {"api_base": "http://llm.test/v1", "api_key": "sk-test-123456", "model": "m"}
refused = client.put(
"/api/v1/ai/config", json=body, headers=normal_user_token_headers
)
assert refused.status_code == 403
saved = client.put("/api/v1/ai/config", json=body, headers=superuser_token_headers)
assert saved.status_code == 200, saved.text
assert saved.json()["api_key_masked"] == "sk-…3456"
assert "sk-test" not in (ai_files / "ai.json").read_text()
assert client.get(
"/api/v1/ai/status", headers=normal_user_token_headers
).json() == {
"available": True,
"model": "m",
}
# A save that names no key keeps the one there is.
kept = client.put(
"/api/v1/ai/config",
json={"api_base": "http://llm.test/v1", "model": "m2"},
headers=superuser_token_headers,
)
assert kept.json()["api_key_set"] is True
assert kept.json()["model"] == "m2"
@pytest.mark.usefixtures("ai_files")
def test_a_turn_runs_the_tools_as_the_person_chatting(
client: TestClient,
superuser_token_headers: dict[str, str],
monkeypatch: pytest.MonkeyPatch,
) -> None:
client.put(
"/api/v1/ai/config",
json={"api_base": "http://llm.test/v1", "api_key": "k", "model": "m"},
headers=superuser_token_headers,
)
seen: list[list[dict[str, Any]]] = []
answers: Iterator[dict[str, Any]] = iter(
[
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"function": {"name": "list_flows", "arguments": "{}"},
}
],
},
{"role": "assistant", "content": "Counted them."},
]
)
async def fake_completion(**kwargs: Any) -> dict[str, Any]:
seen.append(kwargs["messages"])
assert any(t["function"]["name"] == "save_flow" for t in kwargs["tools"])
return {"choices": [{"message": next(answers)}]}
monkeypatch.setattr(agent.client, "chat_completion", fake_completion)
with client.stream(
"POST",
"/api/v1/ai/chat",
json={"message": "How many flows are there?"},
headers=superuser_token_headers,
) as response:
assert response.status_code == 200
events = _events(response)
assert [e["type"] for e in events] == [
"start",
"tool_call",
"tool_result",
"message",
"done",
]
# The tool went through the REST API with the caller's own token: an
# unauthenticated call would have come back as an error the model reads.
assert events[2] == {"type": "tool_result", "name": "list_flows", "ok": True}
answered = seen[-1][-1]
assert answered["role"] == "tool"
assert "count" in json.loads(answered["content"])
assert seen[-1][0]["role"] == "system"
transcript = client.get("/api/v1/ai/chat", headers=superuser_token_headers).json()
assert [m["role"] for m in transcript] == ["user", "assistant", "tool", "assistant"]
assert transcript[2] == {
"role": "tool",
"content": None,
"tool_calls": None,
"ok": True,
"error": None,
}
client.delete("/api/v1/ai/chat", headers=superuser_token_headers)
assert client.get("/api/v1/ai/chat", headers=superuser_token_headers).json() == []