Self-hosted knowledge management for SMEs: a split-screen Markdown editor whose sections an LLM refines while you write, and RAG question answering over the documents that result. FastAPI + Postgres/pgvector on the back, SvelteKit on the front, everything OpenAI-compatible and self-hostable. Squashed into a single commit; the development history stays local. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
152 lines
5.4 KiB
Python
152 lines
5.4 KiB
Python
"""Minimal fake OpenAI-compatible server as an ASGI app (no real LLM).
|
|
|
|
Wire it into the client via httpx.ASGITransport — see the fake_llm fixture.
|
|
Chat behavior is scripted through `chat_responses`; each entry is one of:
|
|
{"content": "..."} plain completion content
|
|
{"chunks": ["a", "b"]} streamed delta pieces
|
|
{"status": 500} induced HTTP error
|
|
An empty script falls back to `default_content`.
|
|
"""
|
|
|
|
import json
|
|
from dataclasses import dataclass, field
|
|
from typing import Any
|
|
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.responses import JSONResponse, StreamingResponse
|
|
|
|
_USAGE = {"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10}
|
|
|
|
|
|
@dataclass
|
|
class FakeOpenAI:
|
|
chat_responses: list[dict[str, Any]] = field(default_factory=list)
|
|
default_content: str = "pong"
|
|
embedding_dim: int = 8
|
|
# What GET /v1/models reports; None means the route is missing (404).
|
|
served_models: list[str] | None = field(
|
|
default_factory=lambda: ["gemma-3-27b", "bge-m3"]
|
|
)
|
|
requests: list[dict[str, Any]] = field(default_factory=list)
|
|
|
|
def __post_init__(self) -> None:
|
|
self.app = self._build_app()
|
|
|
|
def _build_app(self) -> FastAPI:
|
|
app = FastAPI()
|
|
|
|
@app.post("/v1/chat/completions")
|
|
async def chat_completions(request: Request) -> Any:
|
|
body = await request.json()
|
|
self.requests.append(body)
|
|
plan = (
|
|
self.chat_responses.pop(0)
|
|
if self.chat_responses
|
|
else {"content": self.default_content}
|
|
)
|
|
if "status" in plan:
|
|
return JSONResponse(
|
|
{"error": {"message": "induced failure", "type": "server_error"}},
|
|
status_code=plan["status"],
|
|
)
|
|
content = plan.get("content", self.default_content)
|
|
if body.get("stream"):
|
|
pieces = plan.get("chunks", [content])
|
|
include_usage = bool(
|
|
(body.get("stream_options") or {}).get("include_usage")
|
|
)
|
|
return StreamingResponse(
|
|
self._stream(body["model"], pieces, include_usage),
|
|
media_type="text/event-stream",
|
|
)
|
|
return JSONResponse(
|
|
{
|
|
"id": "cmpl-fake",
|
|
"object": "chat.completion",
|
|
"created": 0,
|
|
"model": body["model"],
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"message": {"role": "assistant", "content": content},
|
|
"finish_reason": "stop",
|
|
}
|
|
],
|
|
"usage": _USAGE,
|
|
}
|
|
)
|
|
|
|
@app.post("/v1/embeddings")
|
|
async def embeddings(request: Request) -> Any:
|
|
body = await request.json()
|
|
self.requests.append(body)
|
|
inputs = body["input"]
|
|
if isinstance(inputs, str):
|
|
inputs = [inputs]
|
|
return JSONResponse(
|
|
{
|
|
"object": "list",
|
|
"model": body["model"],
|
|
"data": [
|
|
{
|
|
"object": "embedding",
|
|
"index": i,
|
|
"embedding": [float(i)] * self.embedding_dim,
|
|
}
|
|
for i in range(len(inputs))
|
|
],
|
|
"usage": {
|
|
"prompt_tokens": len(inputs),
|
|
"total_tokens": len(inputs),
|
|
},
|
|
}
|
|
)
|
|
|
|
@app.get("/v1/models")
|
|
async def models() -> Any:
|
|
# `served_models = None` mimics an endpoint without the route,
|
|
# which plenty of OpenAI-compatible servers genuinely lack.
|
|
if self.served_models is None:
|
|
return JSONResponse(
|
|
{"error": {"message": "not found", "type": "invalid_request"}},
|
|
status_code=404,
|
|
)
|
|
return JSONResponse(
|
|
{
|
|
"object": "list",
|
|
"data": [
|
|
{"id": name, "object": "model", "owned_by": "fake"}
|
|
for name in self.served_models
|
|
],
|
|
}
|
|
)
|
|
|
|
return app
|
|
|
|
@staticmethod
|
|
async def _stream(model: str, pieces: list[str], include_usage: bool) -> Any:
|
|
def chunk(delta: dict[str, Any], finish: str | None) -> str:
|
|
payload = {
|
|
"id": "cmpl-fake",
|
|
"object": "chat.completion.chunk",
|
|
"created": 0,
|
|
"model": model,
|
|
"choices": [{"index": 0, "delta": delta, "finish_reason": finish}],
|
|
}
|
|
return f"data: {json.dumps(payload)}\n\n"
|
|
|
|
for piece in pieces:
|
|
yield chunk({"content": piece}, None)
|
|
yield chunk({}, "stop")
|
|
if include_usage:
|
|
final = {
|
|
"id": "cmpl-fake",
|
|
"object": "chat.completion.chunk",
|
|
"created": 0,
|
|
"model": model,
|
|
"choices": [],
|
|
"usage": _USAGE,
|
|
}
|
|
yield f"data: {json.dumps(final)}\n\n"
|
|
yield "data: [DONE]\n\n"
|