feat: rebuild as multi-user web agent
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _last_user_text(messages: list[dict[str, Any]]) -> str:
|
||||
for message in reversed(messages):
|
||||
if message.get("role") != "user":
|
||||
continue
|
||||
content = message.get("content", "")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return " ".join(str(part.get("text", "")) for part in content if isinstance(part, dict))
|
||||
return ""
|
||||
|
||||
|
||||
def _completion(model: str, message: dict[str, Any], finish_reason: str) -> dict[str, Any]:
|
||||
return {
|
||||
"id": f"chatcmpl-stub-{uuid.uuid4().hex}",
|
||||
"object": "chat.completion",
|
||||
"created": int(time.time()),
|
||||
"model": model,
|
||||
"choices": [{"index": 0, "message": message, "finish_reason": finish_reason}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
||||
}
|
||||
|
||||
|
||||
class StubHandler(BaseHTTPRequestHandler):
|
||||
server_version = "K1412E2EStub/1"
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None:
|
||||
return
|
||||
|
||||
def _json(self, status: int, payload: dict[str, Any]) -> None:
|
||||
encoded = json.dumps(payload, ensure_ascii=False).encode()
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(encoded)))
|
||||
self.end_headers()
|
||||
self.wfile.write(encoded)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
if self.path == "/health":
|
||||
self._json(200, {"status": "ok"})
|
||||
return
|
||||
self._json(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self) -> None:
|
||||
if self.path != "/v1/chat/completions":
|
||||
self._json(404, {"error": "not found"})
|
||||
return
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
payload = json.loads(self.rfile.read(length) or b"{}")
|
||||
model = str(payload.get("model", "stub"))
|
||||
messages = payload.get("messages") or []
|
||||
tools = payload.get("tools") or []
|
||||
has_tool_result = any(message.get("role") == "tool" for message in messages)
|
||||
|
||||
if tools and not has_tool_result:
|
||||
names = {str((tool.get("function") or {}).get("name", "")) for tool in tools if isinstance(tool, dict)}
|
||||
is_work = "update_plan" in names
|
||||
path = "work-proof.txt" if is_work else "chat-proof.txt"
|
||||
arguments = json.dumps(
|
||||
{"path": path, "content": _last_user_text(messages) or "e2e"},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
message = {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_e2e_write",
|
||||
"type": "function",
|
||||
"function": {"name": "write_file", "arguments": arguments},
|
||||
}
|
||||
],
|
||||
}
|
||||
self._respond(payload, _completion(model, message, "tool_calls"))
|
||||
return
|
||||
|
||||
text = "E2E provider completed after tool execution." if has_tool_result else "E2E provider response."
|
||||
self._respond(payload, _completion(model, {"role": "assistant", "content": text}, "stop"))
|
||||
|
||||
def _respond(self, request: dict[str, Any], completion: dict[str, Any]) -> None:
|
||||
if not request.get("stream"):
|
||||
self._json(200, completion)
|
||||
return
|
||||
|
||||
message = completion["choices"][0]["message"]
|
||||
finish_reason = completion["choices"][0]["finish_reason"]
|
||||
chunks: list[dict[str, Any]] = []
|
||||
if message.get("tool_calls"):
|
||||
call = message["tool_calls"][0]
|
||||
chunks.append(
|
||||
{
|
||||
"id": completion["id"],
|
||||
"object": "chat.completion.chunk",
|
||||
"created": completion["created"],
|
||||
"model": completion["model"],
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"role": "assistant",
|
||||
"tool_calls": [{"index": 0, **call}],
|
||||
},
|
||||
"finish_reason": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
else:
|
||||
chunks.append(
|
||||
{
|
||||
"id": completion["id"],
|
||||
"object": "chat.completion.chunk",
|
||||
"created": completion["created"],
|
||||
"model": completion["model"],
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": {"role": "assistant", "content": message.get("content", "")},
|
||||
"finish_reason": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
chunks.append(
|
||||
{
|
||||
"id": completion["id"],
|
||||
"object": "chat.completion.chunk",
|
||||
"created": completion["created"],
|
||||
"model": completion["model"],
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": finish_reason}],
|
||||
}
|
||||
)
|
||||
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
for chunk in chunks:
|
||||
self.wfile.write(f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n".encode())
|
||||
self.wfile.write(b"data: [DONE]\n\n")
|
||||
self.wfile.flush()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ThreadingHTTPServer(("0.0.0.0", 9000), StubHandler).serve_forever() # noqa: S104
|
||||
@@ -0,0 +1,261 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
|
||||
import docker
|
||||
from agent_platform.gateway.provider import workspace_ref
|
||||
|
||||
BASE_URL = os.getenv("E2E_BASE_URL", "http://127.0.0.1:3000").rstrip("/")
|
||||
ADMIN_EMAIL = os.getenv("E2E_ADMIN_EMAIL", "admin-e2e@example.invalid")
|
||||
ADMIN_PASSWORD = os.getenv("E2E_ADMIN_PASSWORD", "e2e-admin-password")
|
||||
FORWARD_JWT_SECRET = os.getenv(
|
||||
"E2E_FORWARD_JWT_SECRET",
|
||||
"e2e-forward-jwt-secret-at-least-32-bytes",
|
||||
)
|
||||
INTERNAL_PROVIDER_KEY = os.getenv(
|
||||
"E2E_INTERNAL_PROVIDER_KEY",
|
||||
"e2e-provider-secret-at-least-32-bytes",
|
||||
)
|
||||
EXPECTED_MODELS = {
|
||||
"chat-light",
|
||||
"chat-medium",
|
||||
"chat-high",
|
||||
"work-light",
|
||||
"work-medium",
|
||||
"work-high",
|
||||
}
|
||||
|
||||
|
||||
def _auth(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _signin(client: httpx.Client, email: str, password: str) -> dict[str, Any]:
|
||||
response = client.post("/api/v1/auths/signin", json={"email": email, "password": password})
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
assert data.get("token")
|
||||
return data
|
||||
|
||||
|
||||
def _stream_chat(
|
||||
client: httpx.Client,
|
||||
token: str,
|
||||
*,
|
||||
model: str,
|
||||
chat_id: str,
|
||||
prompt: str,
|
||||
include_workspace_tools: bool,
|
||||
) -> tuple[int, str]:
|
||||
user_message_id = uuid.uuid4().hex
|
||||
assistant_message_id = uuid.uuid4().hex
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"stream": True,
|
||||
"chat_id": chat_id,
|
||||
"id": assistant_message_id,
|
||||
"assistant_message_id": assistant_message_id,
|
||||
"user_message": {
|
||||
"id": user_message_id,
|
||||
"parentId": None,
|
||||
"childrenIds": [assistant_message_id],
|
||||
"role": "user",
|
||||
"content": prompt,
|
||||
"timestamp": int(time.time()),
|
||||
},
|
||||
"params": {"function_calling": "native"},
|
||||
"features": {},
|
||||
}
|
||||
if include_workspace_tools:
|
||||
payload["tool_ids"] = ["server:workspace"]
|
||||
with client.stream(
|
||||
"POST",
|
||||
"/api/chat/completions",
|
||||
headers=_auth(token),
|
||||
json=payload,
|
||||
timeout=90,
|
||||
) as response:
|
||||
return response.status_code, "".join(response.iter_text())
|
||||
|
||||
|
||||
def _workspace_file(client: docker.DockerClient, user_id: str, path: str) -> str:
|
||||
container = client.containers.get(workspace_ref(user_id).container_name)
|
||||
result = container.exec_run(["cat", f"/workspace/{path}"], user="1000:1000")
|
||||
assert result.exit_code == 0, result.output.decode(errors="replace")
|
||||
return result.output.decode()
|
||||
|
||||
|
||||
def _cleanup_workspaces(users: list[dict[str, Any]]) -> None:
|
||||
client = docker.from_env()
|
||||
for user in users:
|
||||
ref = workspace_ref(user["id"])
|
||||
try:
|
||||
client.containers.get(ref.container_name).remove(force=True)
|
||||
except docker.errors.NotFound:
|
||||
pass
|
||||
try:
|
||||
client.volumes.get(ref.volume_name).remove(force=True)
|
||||
except docker.errors.NotFound:
|
||||
pass
|
||||
try:
|
||||
client.networks.get(ref.network_name).remove()
|
||||
except docker.errors.NotFound:
|
||||
pass
|
||||
client.close()
|
||||
|
||||
|
||||
def _runtime_url(client: docker.DockerClient) -> str:
|
||||
container = client.containers.get("k1412-agent-e2e-runtime-1")
|
||||
networks = container.attrs["NetworkSettings"]["Networks"]
|
||||
internal = next(value for name, value in networks.items() if name.endswith("_internal"))
|
||||
return f"http://{internal['IPAddress']}:8000"
|
||||
|
||||
|
||||
def _runtime_headers(user: dict[str, Any], chat_id: str) -> dict[str, str]:
|
||||
now = int(time.time())
|
||||
identity = jwt.encode(
|
||||
{
|
||||
"sub": user["id"],
|
||||
"email": user["email"],
|
||||
"name": user["name"],
|
||||
"role": "user",
|
||||
"iss": "open-webui",
|
||||
"iat": now,
|
||||
"exp": now + 60,
|
||||
},
|
||||
FORWARD_JWT_SECRET,
|
||||
algorithm="HS256",
|
||||
)
|
||||
return {
|
||||
"Authorization": f"Bearer {INTERNAL_PROVIDER_KEY}",
|
||||
"X-OpenWebUI-User-Jwt": identity,
|
||||
"X-OpenWebUI-Chat-Id": chat_id,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
suffix = uuid.uuid4().hex[:10]
|
||||
user_specs = [
|
||||
(f"E2E User A {suffix}", f"e2e-a-{suffix}@example.invalid", "e2e-user-password-a"),
|
||||
(f"E2E User B {suffix}", f"e2e-b-{suffix}@example.invalid", "e2e-user-password-b"),
|
||||
]
|
||||
with httpx.Client(base_url=BASE_URL, timeout=30) as client:
|
||||
health = client.get("/health")
|
||||
health.raise_for_status()
|
||||
assert health.json().get("status") is True
|
||||
|
||||
admin = _signin(client, ADMIN_EMAIL, ADMIN_PASSWORD)
|
||||
admin_token = admin["token"]
|
||||
|
||||
config = client.get("/api/v1/auths/admin/config", headers=_auth(admin_token))
|
||||
config.raise_for_status()
|
||||
admin_config = config.json()
|
||||
assert admin_config["ENABLE_SIGNUP"] is True
|
||||
assert admin_config["DEFAULT_USER_ROLE"] == "pending"
|
||||
assert admin_config["ENABLE_API_KEYS"] is False
|
||||
assert admin_config["ENABLE_AUTOMATIONS"] is False
|
||||
|
||||
model_response = client.get("/api/models?refresh=true", headers=_auth(admin_token))
|
||||
model_response.raise_for_status()
|
||||
model_payload = model_response.json()
|
||||
model_ids = {item["id"] for item in model_payload["data"]}
|
||||
assert model_ids == EXPECTED_MODELS, model_ids
|
||||
assert "ChatGPT-5.6" not in model_response.text
|
||||
|
||||
users: list[dict[str, Any]] = []
|
||||
atexit.register(_cleanup_workspaces, users)
|
||||
for name, email, password in user_specs:
|
||||
signup = client.post(
|
||||
"/api/v1/auths/signup",
|
||||
json={"name": name, "email": email, "password": password},
|
||||
)
|
||||
signup.raise_for_status()
|
||||
pending = signup.json()
|
||||
assert pending["role"] == "pending"
|
||||
|
||||
denied = client.get("/api/models", headers=_auth(pending["token"]))
|
||||
assert denied.status_code in {401, 403}
|
||||
|
||||
approved = client.post(
|
||||
f"/api/v1/users/{pending['id']}/update",
|
||||
headers=_auth(admin_token),
|
||||
json={"role": "user"},
|
||||
)
|
||||
approved.raise_for_status()
|
||||
assert approved.json()["role"] == "user"
|
||||
users.append(_signin(client, email, password))
|
||||
|
||||
for user in users:
|
||||
models = client.get("/api/models", headers=_auth(user["token"]))
|
||||
models.raise_for_status()
|
||||
user_model_ids = {item["id"] for item in models.json()["data"]}
|
||||
assert user_model_ids == EXPECTED_MODELS, user_model_ids
|
||||
assert "ChatGPT-5.6" not in models.text
|
||||
forbidden = client.get("/api/v1/auths/admin/config", headers=_auth(user["token"]))
|
||||
assert forbidden.status_code in {401, 403}
|
||||
|
||||
chat_ids = [f"local:e2e-{suffix}-a", f"local:e2e-{suffix}-b"]
|
||||
for index, user in enumerate(users):
|
||||
marker = f"E2E_CHAT_USER_{index}_{suffix}"
|
||||
status_code, body = _stream_chat(
|
||||
client,
|
||||
user["token"],
|
||||
model="chat-light",
|
||||
chat_id=chat_ids[index],
|
||||
prompt=marker,
|
||||
include_workspace_tools=True,
|
||||
)
|
||||
assert status_code == 200, body
|
||||
|
||||
docker_client = docker.from_env()
|
||||
assert _workspace_file(docker_client, users[0]["id"], "chat-proof.txt") == f"E2E_CHAT_USER_0_{suffix}"
|
||||
assert _workspace_file(docker_client, users[1]["id"], "chat-proof.txt") == f"E2E_CHAT_USER_1_{suffix}"
|
||||
for user in users:
|
||||
ref = workspace_ref(user["id"])
|
||||
workspace = docker_client.containers.get(ref.container_name)
|
||||
workspace.reload()
|
||||
assert set(workspace.attrs["NetworkSettings"]["Networks"]) == {ref.network_name}
|
||||
|
||||
work_marker = f"E2E_WORK_USER_0_{suffix}"
|
||||
status_code, work_body = _stream_chat(
|
||||
client,
|
||||
users[0]["token"],
|
||||
model="work-medium",
|
||||
chat_id=chat_ids[0],
|
||||
prompt=work_marker,
|
||||
include_workspace_tools=False,
|
||||
)
|
||||
assert status_code == 200, work_body
|
||||
assert _workspace_file(docker_client, users[0]["id"], "work-proof.txt") == work_marker
|
||||
|
||||
with httpx.Client(base_url=_runtime_url(docker_client), timeout=30, trust_env=False) as runtime:
|
||||
runtime_headers = _runtime_headers(users[0], chat_ids[0])
|
||||
events = runtime.get(f"/v1/runs/{chat_ids[0]}", headers=runtime_headers)
|
||||
events.raise_for_status()
|
||||
event_types = {event["type"] for event in events.json()["items"]}
|
||||
assert {"run.created", "tool.completed", "run.completed"} <= event_types
|
||||
|
||||
downgrade = runtime.post(
|
||||
"/v1/chat/completions",
|
||||
headers=runtime_headers,
|
||||
json={
|
||||
"model": "chat-high",
|
||||
"messages": [{"role": "user", "content": "THIS_DOWNGRADE_MUST_FAIL"}],
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
assert downgrade.status_code == 409, downgrade.text
|
||||
|
||||
print("E2E passed: auth approval, six models, Chat tools, Work loop, isolation, and downgrade lock.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user