feat(headless): Implement Phase 2 - Core API Routes & Authentication
This commit is contained in:
@@ -1,27 +1,27 @@
|
|||||||
# Implementation Plan: Manual Slop Headless Backend
|
# Implementation Plan: Manual Slop Headless Backend
|
||||||
|
|
||||||
## Phase 1: Project Setup & Headless Scaffold
|
## Phase 1: Project Setup & Headless Scaffold [checkpoint: d5f056c]
|
||||||
- [x] Task: Update dependencies (02fc847)
|
- [x] Task: Update dependencies (02fc847)
|
||||||
- [x] Add `fastapi` and `uvicorn` to `pyproject.toml` (and sync `requirements.txt` via `uv`).
|
- [x] Add `fastapi` and `uvicorn` to `pyproject.toml` (and sync `requirements.txt` via `uv`).
|
||||||
- [x] Task: Implement headless startup
|
- [x] Task: Implement headless startup
|
||||||
- [x] Modify `gui_2.py` (or create `headless.py`) to parse a `--headless` CLI flag.
|
- [x] Modify `gui_2.py` (or create `headless.py`) to parse a `--headless` CLI flag.
|
||||||
- [x] Update config parsing in `config.toml` to support headless configuration sections.
|
- [x] Update config parsing in `config.toml` to support headless configuration sections.
|
||||||
- [x] Bypass Dear PyGui initialization if headless mode is active.
|
- [x] Bypass Dear PyGui initialization if headless mode is active.
|
||||||
- [~] Task: Create foundational API application
|
- [x] Task: Create foundational API application
|
||||||
- [ ] Set up the core FastAPI application instance.
|
- [x] Set up the core FastAPI application instance.
|
||||||
- [ ] Implement `/health` and `/status` endpoints for Docker lifecycle checks.
|
- [x] Implement `/health` and `/status` endpoints for Docker lifecycle checks.
|
||||||
- [ ] Task: Conductor - User Manual Verification 'Project Setup & Headless Scaffold' (Protocol in workflow.md)
|
- [x] Task: Conductor - User Manual Verification 'Project Setup & Headless Scaffold' (Protocol in workflow.md) d5f056c
|
||||||
|
|
||||||
## Phase 2: Core API Routes & Authentication
|
## Phase 2: Core API Routes & Authentication
|
||||||
- [ ] Task: Implement API Key Security
|
- [x] Task: Implement API Key Security
|
||||||
- [ ] Create a dependency/middleware in FastAPI to validate `X-API-KEY`.
|
- [x] Create a dependency/middleware in FastAPI to validate `X-API-KEY`.
|
||||||
- [ ] Configure the API key validator to read from environment variables or `manual_slop.toml` (supporting Unraid template secrets).
|
- [x] Configure the API key validator to read from environment variables or `manual_slop.toml` (supporting Unraid template secrets).
|
||||||
- [ ] Add tests for authorized and unauthorized API access.
|
- [x] Add tests for authorized and unauthorized API access.
|
||||||
- [ ] Task: Implement AI Generation Endpoint
|
- [x] Task: Implement AI Generation Endpoint
|
||||||
- [ ] Create a `/api/v1/generate` POST endpoint.
|
- [x] Create a `/api/v1/generate` POST endpoint.
|
||||||
- [ ] Map request payloads to `ai_client.py` unified wrappers.
|
- [x] Map request payloads to `ai_client.py` unified wrappers.
|
||||||
- [ ] Return standard JSON responses with the generated text and token metrics.
|
- [x] Return standard JSON responses with the generated text and token metrics.
|
||||||
- [ ] Task: Conductor - User Manual Verification 'Core API Routes & Authentication' (Protocol in workflow.md)
|
- [~] Task: Conductor - User Manual Verification 'Core API Routes & Authentication' (Protocol in workflow.md)
|
||||||
|
|
||||||
## Phase 3: Remote Tool Confirmation Mechanism
|
## Phase 3: Remote Tool Confirmation Mechanism
|
||||||
- [ ] Task: Refactor Execution Engine for Async Wait
|
- [ ] Task: Refactor Execution Engine for Async Wait
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ history_trunc_limit = 8000
|
|||||||
system_prompt = ""
|
system_prompt = ""
|
||||||
|
|
||||||
[theme]
|
[theme]
|
||||||
palette = "Gold"
|
palette = "ImGui Dark"
|
||||||
font_size = 14.0
|
font_size = 16.0
|
||||||
scale = 1.2000000476837158
|
scale = 1.0
|
||||||
font_path = ""
|
font_path = ""
|
||||||
|
|
||||||
[projects]
|
[projects]
|
||||||
|
|||||||
101
gui_2.py
101
gui_2.py
@@ -22,7 +22,9 @@ import api_hooks
|
|||||||
import mcp_client
|
import mcp_client
|
||||||
from performance_monitor import PerformanceMonitor
|
from performance_monitor import PerformanceMonitor
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Depends, HTTPException, Security
|
||||||
|
from fastapi.security.api_key import APIKeyHeader
|
||||||
|
from pydantic import BaseModel
|
||||||
from imgui_bundle import imgui, hello_imgui, immapp
|
from imgui_bundle import imgui, hello_imgui, immapp
|
||||||
|
|
||||||
CONFIG_PATH = Path("config.toml")
|
CONFIG_PATH = Path("config.toml")
|
||||||
@@ -307,11 +309,31 @@ class App:
|
|||||||
def create_api(self) -> FastAPI:
|
def create_api(self) -> FastAPI:
|
||||||
api = FastAPI(title="Manual Slop Headless API")
|
api = FastAPI(title="Manual Slop Headless API")
|
||||||
|
|
||||||
|
class GenerateRequest(BaseModel):
|
||||||
|
prompt: str
|
||||||
|
auto_add_history: bool = True
|
||||||
|
temperature: float | None = None
|
||||||
|
max_tokens: int | None = None
|
||||||
|
|
||||||
|
API_KEY_NAME = "X-API-KEY"
|
||||||
|
api_key_header = APIKeyHeader(name=API_KEY_NAME, auto_error=False)
|
||||||
|
|
||||||
|
async def get_api_key(header_key: str = Depends(api_key_header)):
|
||||||
|
headless_cfg = self.config.get("headless", {})
|
||||||
|
config_key = headless_cfg.get("api_key", "").strip()
|
||||||
|
env_key = os.environ.get("SLOP_API_KEY", "").strip()
|
||||||
|
target_key = env_key or config_key
|
||||||
|
if not target_key:
|
||||||
|
return None
|
||||||
|
if header_key == target_key:
|
||||||
|
return header_key
|
||||||
|
raise HTTPException(status_code=403, detail="Could not validate API Key")
|
||||||
|
|
||||||
@api.get("/health")
|
@api.get("/health")
|
||||||
def health():
|
def health():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
@api.get("/status")
|
@api.get("/status", dependencies=[Depends(get_api_key)])
|
||||||
def status():
|
def status():
|
||||||
return {
|
return {
|
||||||
"provider": self.current_provider,
|
"provider": self.current_provider,
|
||||||
@@ -321,6 +343,81 @@ class App:
|
|||||||
"session_usage": self.session_usage
|
"session_usage": self.session_usage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@api.post("/api/v1/generate", dependencies=[Depends(get_api_key)])
|
||||||
|
def generate(req: GenerateRequest):
|
||||||
|
if not req.prompt.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="Prompt cannot be empty")
|
||||||
|
|
||||||
|
with self._send_thread_lock:
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
# Refresh context before sending
|
||||||
|
md, path, file_items, stable_md, disc_text = self._do_generate()
|
||||||
|
self.last_md = md
|
||||||
|
self.last_md_path = path
|
||||||
|
self.last_file_items = file_items
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Context aggregation failure: {e}")
|
||||||
|
|
||||||
|
user_msg = req.prompt
|
||||||
|
base_dir = self.ui_files_base_dir
|
||||||
|
csp = filter(bool, [self.ui_global_system_prompt.strip(), self.ui_project_system_prompt.strip()])
|
||||||
|
ai_client.set_custom_system_prompt("\n\n".join(csp))
|
||||||
|
|
||||||
|
# Override parameters if provided in request, otherwise use GUI defaults
|
||||||
|
temp = req.temperature if req.temperature is not None else self.temperature
|
||||||
|
tokens = req.max_tokens if req.max_tokens is not None else self.max_tokens
|
||||||
|
ai_client.set_model_params(temp, tokens, self.history_trunc_limit)
|
||||||
|
ai_client.set_agent_tools(self.ui_agent_tools)
|
||||||
|
|
||||||
|
if req.auto_add_history:
|
||||||
|
with self._pending_history_adds_lock:
|
||||||
|
self._pending_history_adds.append({
|
||||||
|
"role": "User",
|
||||||
|
"content": user_msg,
|
||||||
|
"collapsed": False,
|
||||||
|
"ts": project_manager.now_ts()
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = ai_client.send(stable_md, user_msg, base_dir, self.last_file_items, disc_text)
|
||||||
|
|
||||||
|
if req.auto_add_history:
|
||||||
|
with self._pending_history_adds_lock:
|
||||||
|
self._pending_history_adds.append({
|
||||||
|
"role": "AI",
|
||||||
|
"content": resp,
|
||||||
|
"collapsed": False,
|
||||||
|
"ts": project_manager.now_ts()
|
||||||
|
})
|
||||||
|
|
||||||
|
# Ensure metrics are updated for the response
|
||||||
|
self._recalculate_session_usage()
|
||||||
|
duration = time.time() - start_time
|
||||||
|
|
||||||
|
return {
|
||||||
|
"text": resp,
|
||||||
|
"metadata": {
|
||||||
|
"provider": self.current_provider,
|
||||||
|
"model": self.current_model,
|
||||||
|
"duration_sec": round(duration, 3),
|
||||||
|
"timestamp": project_manager.now_ts()
|
||||||
|
},
|
||||||
|
"usage": self.session_usage
|
||||||
|
}
|
||||||
|
except ProviderError as e:
|
||||||
|
# Specific error handling for vendor issues (4xx/5xx from Gemini/Anthropic)
|
||||||
|
raise HTTPException(status_code=502, detail=f"AI Provider Error: {e.ui_message()}")
|
||||||
|
except Exception as e:
|
||||||
|
# Generic internal error
|
||||||
|
raise HTTPException(status_code=500, detail=f"In-flight AI request failure: {e}")
|
||||||
|
|
||||||
|
@api.post("/api/v1/stream", dependencies=[Depends(get_api_key)])
|
||||||
|
async def stream(req: GenerateRequest):
|
||||||
|
# Streaming implementation would require ai_client to support yield-based responses.
|
||||||
|
# Currently added as a placeholder to satisfy spec requirements.
|
||||||
|
raise HTTPException(status_code=501, detail="Streaming endpoint (/api/v1/stream) is not yet supported in this version.")
|
||||||
|
|
||||||
return api
|
return api
|
||||||
|
|
||||||
# ---------------------------------------------------------------- project loading
|
# ---------------------------------------------------------------- project loading
|
||||||
|
|||||||
@@ -8,5 +8,5 @@ active = "main"
|
|||||||
|
|
||||||
[discussions.main]
|
[discussions.main]
|
||||||
git_commit = ""
|
git_commit = ""
|
||||||
last_updated = "2026-02-25T13:02:29"
|
last_updated = "2026-02-25T13:08:45"
|
||||||
history = []
|
history = []
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ auto_add = true
|
|||||||
|
|
||||||
[discussions.main]
|
[discussions.main]
|
||||||
git_commit = ""
|
git_commit = ""
|
||||||
last_updated = "2026-02-25T13:02:29"
|
last_updated = "2026-02-25T13:08:45"
|
||||||
history = [
|
history = [
|
||||||
"@1772042443.1159382\nUser:\nStress test entry 0 Stress test entry 0 Stress test entry 0 Stress test entry 0 Stress test entry 0",
|
"@1772042443.1159382\nUser:\nStress test entry 0 Stress test entry 0 Stress test entry 0 Stress test entry 0 Stress test entry 0",
|
||||||
"@1772042443.1159382\nUser:\nStress test entry 1 Stress test entry 1 Stress test entry 1 Stress test entry 1 Stress test entry 1",
|
"@1772042443.1159382\nUser:\nStress test entry 1 Stress test entry 1 Stress test entry 1 Stress test entry 1 Stress test entry 1",
|
||||||
|
|||||||
@@ -23,13 +23,40 @@ class TestHeadlessAPI(unittest.TestCase):
|
|||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertEqual(response.json(), {"status": "ok"})
|
self.assertEqual(response.json(), {"status": "ok"})
|
||||||
|
|
||||||
def test_status_endpoint(self):
|
def test_status_endpoint_unauthorized(self):
|
||||||
|
# Ensure a key is required
|
||||||
|
with patch.dict(self.app_instance.config, {"headless": {"api_key": "some-required-key"}}):
|
||||||
response = self.client.get("/status")
|
response = self.client.get("/status")
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_status_endpoint_authorized(self):
|
||||||
|
# We'll use a test key
|
||||||
|
headers = {"X-API-KEY": "test-secret-key"}
|
||||||
|
with patch.dict(self.app_instance.config, {"headless": {"api_key": "test-secret-key"}}):
|
||||||
|
response = self.client.get("/status", headers=headers)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_generate_endpoint(self):
|
||||||
|
payload = {
|
||||||
|
"prompt": "Hello AI"
|
||||||
|
}
|
||||||
|
# Mock ai_client.send and get_comms_log
|
||||||
|
with patch('gui_2.ai_client.send') as mock_send, \
|
||||||
|
patch('gui_2.ai_client.get_comms_log') as mock_log:
|
||||||
|
mock_send.return_value = "Hello from Mock AI"
|
||||||
|
mock_log.return_value = [{
|
||||||
|
"kind": "response",
|
||||||
|
"payload": {
|
||||||
|
"usage": {"input_tokens": 10, "output_tokens": 5}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
|
||||||
|
response = self.client.post("/api/v1/generate", json=payload)
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
data = response.json()
|
data = response.json()
|
||||||
self.assertIn("provider", data)
|
self.assertEqual(data["text"], "Hello from Mock AI")
|
||||||
self.assertIn("model", data)
|
self.assertIn("metadata", data)
|
||||||
self.assertIn("active_project", data)
|
self.assertEqual(data["usage"]["input_tokens"], 10)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user