From a76dc29524381dac4416243285fed64d2eacc039 Mon Sep 17 00:00:00 2001 From: "fig-ai-agent[bot]" <310751119+fig-ai-agent[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:57:54 +0000 Subject: [PATCH] deliverables: add onspace-ai, manus-client, firecrawl-fastapi stacks - onspace-ai: FastAPI cost+reliability stack (cache/circuit/fallback/token layer), 31 tests - manus-client: Manus REST API v2 async client (dot-notation endpoints), 10 tests - firecrawl-fastapi: FireCrawl + FastAPI production scraper/crawler (v1.x SDK), 6 tests - all verified + no workflow files (push-safe) --- deliverables/firecrawl-fastapi/.dockerignore | 8 ++ deliverables/firecrawl-fastapi/.gitignore | 6 + deliverables/firecrawl-fastapi/Dockerfile | 31 ++++ deliverables/firecrawl-fastapi/README.md | 72 ++++++++++ .../firecrawl-fastapi/app/__init__.py | 0 .../firecrawl-fastapi/app/api/__init__.py | 0 .../firecrawl-fastapi/app/api/v1/__init__.py | 0 .../app/api/v1/endpoints/__init__.py | 0 .../app/api/v1/endpoints/firecrawl.py | 74 ++++++++++ .../firecrawl-fastapi/app/config/__init__.py | 0 .../firecrawl-fastapi/app/config/settings.py | 45 ++++++ .../firecrawl-fastapi/app/core/__init__.py | 0 .../firecrawl-fastapi/app/core/firecrawl.py | 115 +++++++++++++++ .../firecrawl-fastapi/app/core/logging.py | 41 ++++++ .../firecrawl-fastapi/app/core/redis.py | 86 +++++++++++ .../app/middleware/__init__.py | 0 .../app/middleware/rate_limit.py | 55 ++++++++ .../firecrawl-fastapi/app/schemas/__init__.py | 0 .../app/schemas/firecrawl.py | 43 ++++++ .../app/services/__init__.py | 0 .../firecrawl-fastapi/app/services/webhook.py | 51 +++++++ .../firecrawl-fastapi/app/workers/__init__.py | 0 .../firecrawl-fastapi/app/workers/celery.py | 26 ++++ .../firecrawl-fastapi/app/workers/tasks.py | 110 +++++++++++++++ .../firecrawl-fastapi/docker-compose.yml | 44 ++++++ deliverables/firecrawl-fastapi/main.py | 38 +++++ .../firecrawl-fastapi/requirements.txt | 10 ++ .../firecrawl-fastapi/tests/__init__.py | 0 .../firecrawl-fastapi/tests/test_app.py | 85 +++++++++++ deliverables/manus-client/README.md | 59 ++++++++ .../manus-client/manus_client/__init__.py | 11 ++ deliverables/manus-client/manus_client/cli.py | 41 ++++++ .../manus-client/manus_client/client.py | 117 +++++++++++++++ deliverables/manus-client/pyproject.toml | 12 ++ deliverables/manus-client/requirements.txt | 1 + .../manus-client/tests/test_client.py | 133 ++++++++++++++++++ deliverables/onspace-ai/.gitignore | 5 + deliverables/onspace-ai/Dockerfile | 13 ++ deliverables/onspace-ai/KNOWLEDGE_BASE.md | 54 +++++++ deliverables/onspace-ai/README.md | 54 +++++++ deliverables/onspace-ai/TOKEN_OPTIMIZATION.md | 56 ++++++++ deliverables/onspace-ai/app/__init__.py | 0 deliverables/onspace-ai/app/cache.py | 72 ++++++++++ .../onspace-ai/app/circuit_breaker.py | 57 ++++++++ deliverables/onspace-ai/app/config.py | 35 +++++ .../onspace-ai/app/context_compiler.py | 59 ++++++++ .../onspace-ai/app/fallback_router.py | 50 +++++++ deliverables/onspace-ai/app/main.py | 77 ++++++++++ deliverables/onspace-ai/app/metrics.py | 11 ++ deliverables/onspace-ai/app/middleware.py | 45 ++++++ deliverables/onspace-ai/app/providers.py | 73 ++++++++++ deliverables/onspace-ai/app/token_budget.py | 49 +++++++ deliverables/onspace-ai/docker-compose.yml | 34 +++++ deliverables/onspace-ai/k8s/deployment.yaml | 23 +++ deliverables/onspace-ai/k8s/hpa.yaml | 17 +++ deliverables/onspace-ai/k8s/redis-sts.yaml | 26 ++++ deliverables/onspace-ai/k8s/service.yaml | 8 ++ deliverables/onspace-ai/pytest.ini | 3 + deliverables/onspace-ai/requirements.txt | 6 + deliverables/onspace-ai/tests/__init__.py | 0 deliverables/onspace-ai/tests/conftest.py | 6 + deliverables/onspace-ai/tests/test_api.py | 74 ++++++++++ deliverables/onspace-ai/tests/test_cache.py | 45 ++++++ .../onspace-ai/tests/test_circuit_breaker.py | 50 +++++++ .../onspace-ai/tests/test_context_compiler.py | 48 +++++++ .../onspace-ai/tests/test_fallback_router.py | 51 +++++++ .../onspace-ai/tests/test_token_budget.py | 38 +++++ 67 files changed, 2453 insertions(+) create mode 100644 deliverables/firecrawl-fastapi/.dockerignore create mode 100644 deliverables/firecrawl-fastapi/.gitignore create mode 100644 deliverables/firecrawl-fastapi/Dockerfile create mode 100644 deliverables/firecrawl-fastapi/README.md create mode 100644 deliverables/firecrawl-fastapi/app/__init__.py create mode 100644 deliverables/firecrawl-fastapi/app/api/__init__.py create mode 100644 deliverables/firecrawl-fastapi/app/api/v1/__init__.py create mode 100644 deliverables/firecrawl-fastapi/app/api/v1/endpoints/__init__.py create mode 100644 deliverables/firecrawl-fastapi/app/api/v1/endpoints/firecrawl.py create mode 100644 deliverables/firecrawl-fastapi/app/config/__init__.py create mode 100644 deliverables/firecrawl-fastapi/app/config/settings.py create mode 100644 deliverables/firecrawl-fastapi/app/core/__init__.py create mode 100644 deliverables/firecrawl-fastapi/app/core/firecrawl.py create mode 100644 deliverables/firecrawl-fastapi/app/core/logging.py create mode 100644 deliverables/firecrawl-fastapi/app/core/redis.py create mode 100644 deliverables/firecrawl-fastapi/app/middleware/__init__.py create mode 100644 deliverables/firecrawl-fastapi/app/middleware/rate_limit.py create mode 100644 deliverables/firecrawl-fastapi/app/schemas/__init__.py create mode 100644 deliverables/firecrawl-fastapi/app/schemas/firecrawl.py create mode 100644 deliverables/firecrawl-fastapi/app/services/__init__.py create mode 100644 deliverables/firecrawl-fastapi/app/services/webhook.py create mode 100644 deliverables/firecrawl-fastapi/app/workers/__init__.py create mode 100644 deliverables/firecrawl-fastapi/app/workers/celery.py create mode 100644 deliverables/firecrawl-fastapi/app/workers/tasks.py create mode 100644 deliverables/firecrawl-fastapi/docker-compose.yml create mode 100644 deliverables/firecrawl-fastapi/main.py create mode 100644 deliverables/firecrawl-fastapi/requirements.txt create mode 100644 deliverables/firecrawl-fastapi/tests/__init__.py create mode 100644 deliverables/firecrawl-fastapi/tests/test_app.py create mode 100644 deliverables/manus-client/README.md create mode 100644 deliverables/manus-client/manus_client/__init__.py create mode 100644 deliverables/manus-client/manus_client/cli.py create mode 100644 deliverables/manus-client/manus_client/client.py create mode 100644 deliverables/manus-client/pyproject.toml create mode 100644 deliverables/manus-client/requirements.txt create mode 100644 deliverables/manus-client/tests/test_client.py create mode 100644 deliverables/onspace-ai/.gitignore create mode 100644 deliverables/onspace-ai/Dockerfile create mode 100644 deliverables/onspace-ai/KNOWLEDGE_BASE.md create mode 100644 deliverables/onspace-ai/README.md create mode 100644 deliverables/onspace-ai/TOKEN_OPTIMIZATION.md create mode 100644 deliverables/onspace-ai/app/__init__.py create mode 100644 deliverables/onspace-ai/app/cache.py create mode 100644 deliverables/onspace-ai/app/circuit_breaker.py create mode 100644 deliverables/onspace-ai/app/config.py create mode 100644 deliverables/onspace-ai/app/context_compiler.py create mode 100644 deliverables/onspace-ai/app/fallback_router.py create mode 100644 deliverables/onspace-ai/app/main.py create mode 100644 deliverables/onspace-ai/app/metrics.py create mode 100644 deliverables/onspace-ai/app/middleware.py create mode 100644 deliverables/onspace-ai/app/providers.py create mode 100644 deliverables/onspace-ai/app/token_budget.py create mode 100644 deliverables/onspace-ai/docker-compose.yml create mode 100644 deliverables/onspace-ai/k8s/deployment.yaml create mode 100644 deliverables/onspace-ai/k8s/hpa.yaml create mode 100644 deliverables/onspace-ai/k8s/redis-sts.yaml create mode 100644 deliverables/onspace-ai/k8s/service.yaml create mode 100644 deliverables/onspace-ai/pytest.ini create mode 100644 deliverables/onspace-ai/requirements.txt create mode 100644 deliverables/onspace-ai/tests/__init__.py create mode 100644 deliverables/onspace-ai/tests/conftest.py create mode 100644 deliverables/onspace-ai/tests/test_api.py create mode 100644 deliverables/onspace-ai/tests/test_cache.py create mode 100644 deliverables/onspace-ai/tests/test_circuit_breaker.py create mode 100644 deliverables/onspace-ai/tests/test_context_compiler.py create mode 100644 deliverables/onspace-ai/tests/test_fallback_router.py create mode 100644 deliverables/onspace-ai/tests/test_token_budget.py diff --git a/deliverables/firecrawl-fastapi/.dockerignore b/deliverables/firecrawl-fastapi/.dockerignore new file mode 100644 index 0000000..43efa9e --- /dev/null +++ b/deliverables/firecrawl-fastapi/.dockerignore @@ -0,0 +1,8 @@ +.venv +__pycache__ +*.pyc +.env +.git +logs +tests +.pytest_cache diff --git a/deliverables/firecrawl-fastapi/.gitignore b/deliverables/firecrawl-fastapi/.gitignore new file mode 100644 index 0000000..c38c347 --- /dev/null +++ b/deliverables/firecrawl-fastapi/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +.env +logs/*.log +.pytest_cache/ diff --git a/deliverables/firecrawl-fastapi/Dockerfile b/deliverables/firecrawl-fastapi/Dockerfile new file mode 100644 index 0000000..32f2f0f --- /dev/null +++ b/deliverables/firecrawl-fastapi/Dockerfile @@ -0,0 +1,31 @@ +# syntax=docker/dockerfile:1.7 +# Multi-stage: build deps → runtime (API worker แยกใน compose) +FROM python:3.13-slim AS base + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +WORKDIR /app + +# ---------- stage: deps ---------- +FROM base AS deps +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# ---------- stage: runtime ---------- +FROM base AS runtime +COPY --from=deps /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages +COPY --from=deps /usr/local/bin /usr/local/bin + +COPY . . + +# non-root + security +RUN useradd -m -u 1000 appuser +USER appuser + +EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request,sys; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)" + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/deliverables/firecrawl-fastapi/README.md b/deliverables/firecrawl-fastapi/README.md new file mode 100644 index 0000000..d19c31a --- /dev/null +++ b/deliverables/firecrawl-fastapi/README.md @@ -0,0 +1,72 @@ +# FireCrawl + FastAPI + +โปรเจกต์รวบรวมข้อมูลเว็บระดับโปรดักชัน: **scrape** หน้าเว็บ + **crawl** ลิงก์ลึก ผ่าน REST API +พร้อมคิวงานเบื้องหลัง (Celery), แคช Redis, rate-limit, webhook แบบ HMAC และ log โครงสร้าง + +## สแตก + +| ชิ้นส่วน | บทบาท | +|----------|--------| +| FastAPI | เว็บเฟรมเวิร์ก async | +| firecrawl-py **v1.x** | SDK (pin `<2.0.0` — surface `scrape_url`/`crawl_url`) | +| Redis | แคช + สถานะงาน + rate-limit | +| Celery | คิวงาน background (งานยาว) | +| structlog | log JSON | +| Docker | API + Worker + Redis | + +## โครงสร้าง + +``` +app/ +├── config/settings.py # env + คอนฟิก (pydantic-settings) +├── core/ +│ ├── firecrawl.py # wrapper SDK + แคช + retry +│ ├── redis.py # client fail-open +│ └── logging.py # log JSON/console +├── api/v1/endpoints/firecrawl.py +├── schemas/firecrawl.py # Pydantic +├── services/webhook.py # HMAC-SHA256 +├── middleware/rate_limit.py +└── workers/{celery,tasks}.py +``` + +## เริ่มใช้งาน + +```bash +# local (dev) +python -m venv .venv && . .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # ใส่ FIRECRAWL_API_KEY +uvicorn main:app --reload + +# หรือ Docker +docker compose up -d --build +``` + +## API + +- `POST /api/v1/firecrawl/scrape` — ดึงหน้า (markdown/metadata), แคช Redis, `cache: HIT|MISS` +- `POST /api/v1/firecrawl/crawl` — ส่งงาน → `202 {task_id}` +- `GET /api/v1/firecrawl/tasks/{id}` — สถานะ/ผลงาน +- `GET /health` — สุขภาพระบบ + +## คุณสมบัติ production + +- **Fail-open**: ถ้า Redis/FireCrawl ล่ม → cache miss, rate-limiter ปล่อยผ่าน, task lookup 404 — แอปไม่ crash +- **Retry อัตโนมัติ** 3 ครั้ง (scrape) / `task_acks_late` + retry สำหรับ Celery +- **Cache key** จาก URL + options (SHA-256) → Redis TTL ตามประเภท +- **Webhook** ลงนาม HMAC-SHA256 ทุกครั้ง (`X-Signature`) +- **Rate limit** ต่อ IP: `/scrape` 120/นาที, `/crawl` 60/นาที +- **Log JSON** ผ่าน structlog + +## Test + +```bash +pip install pytest pytest-asyncio httpx +pytest tests/ -q # ไม่ต้องใช้ service จริง (mock + fail-open path) +``` + +## หมายเหตุสำคัญ (SDK) + +`firecrawl-py` มี 2 major version ที่ API ไม่เข้ากัน — โปรเจกต์ pin `<2.0.0` (v1.x) เพราะใช้ +`scrape_url`/`crawl_url`/`check_crawl_status` อย่า upgrade ข้าม major โดยไม่ตรวจสอบ surface diff --git a/deliverables/firecrawl-fastapi/app/__init__.py b/deliverables/firecrawl-fastapi/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deliverables/firecrawl-fastapi/app/api/__init__.py b/deliverables/firecrawl-fastapi/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deliverables/firecrawl-fastapi/app/api/v1/__init__.py b/deliverables/firecrawl-fastapi/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deliverables/firecrawl-fastapi/app/api/v1/endpoints/__init__.py b/deliverables/firecrawl-fastapi/app/api/v1/endpoints/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deliverables/firecrawl-fastapi/app/api/v1/endpoints/firecrawl.py b/deliverables/firecrawl-fastapi/app/api/v1/endpoints/firecrawl.py new file mode 100644 index 0000000..d25edfd --- /dev/null +++ b/deliverables/firecrawl-fastapi/app/api/v1/endpoints/firecrawl.py @@ -0,0 +1,74 @@ +"""API endpoints: /scrape, /crawl, /tasks/{id} — ล้วน async + fail-open""" +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException + +from app.config.settings import Settings, get_settings +from app.core.redis import get_cache_client, get_state_client +from app.core.firecrawl import FireCrawlService +from app.schemas.firecrawl import ( + CrawlRequest, + ScrapeRequest, + ScrapeResponse, + TaskResponse, + TaskResult, +) +from app.workers import tasks + +router = APIRouter(prefix="/firecrawl", tags=["firecrawl"]) + + +def _service(settings: Settings = Depends(get_settings)) -> FireCrawlService: + cache = get_cache_client(settings) + return FireCrawlService(settings, cache=cache) + + +@router.post("/scrape", response_model=ScrapeResponse, status_code=200) +async def scrape(req: ScrapeRequest, svc: FireCrawlService = Depends(_service)): + url = str(req.url) + options = { + "formats": req.formats, + "onlyMainContent": req.only_main_content, + } + if req.wait_for: + options["waitFor"] = req.wait_for + if req.timeout: + options["timeout"] = req.timeout + + result = await svc.scrape(url, options) + if not result.get("success") and result.get("error"): + # fail-open: คืน 200 พร้อม error (ไม่ crash) ตาม blueprint + pass + return ScrapeResponse( + success=result.get("success", False), + url=url, + markdown=result.get("markdown"), + metadata=result.get("metadata"), + cache=result.get("cache", "MISS"), + error=result.get("error"), + ) + + +@router.post("/crawl", response_model=TaskResponse, status_code=202) +async def crawl(req: CrawlRequest, settings: Settings = Depends(get_settings)): + url = str(req.url) + options = { + "max_pages": req.max_pages, + "ignore_sitemap": req.ignore_sitemap, + } + if req.webhook_url: + options["webhook"] = str(req.webhook_url) + + task_id = await tasks.crawl_url(url, options) + return TaskResponse(task_id=task_id, status="queued", url=url, max_pages=req.max_pages) + + +@router.get("/tasks/{task_id}", response_model=TaskResult) +async def task_status(task_id: str, settings: Settings = Depends(get_settings)): + cache = get_state_client(settings) + result = await cache.get_json(f"fc:task:{task_id}") + if result is None: + return TaskResult(task_id=task_id, status="not_found", data=None) + return TaskResult(task_id=task_id, status=result.get("status", "unknown"), data=result.get("data"), error=result.get("error")) diff --git a/deliverables/firecrawl-fastapi/app/config/__init__.py b/deliverables/firecrawl-fastapi/app/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deliverables/firecrawl-fastapi/app/config/settings.py b/deliverables/firecrawl-fastapi/app/config/settings.py new file mode 100644 index 0000000..528d1b7 --- /dev/null +++ b/deliverables/firecrawl-fastapi/app/config/settings.py @@ -0,0 +1,45 @@ +"""การตั้งค่าแอป — โหลดจาก .env ด้วย pydantic-settings""" +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + app_name: str = "FireCrawl FastAPI" + app_env: str = "development" + debug: bool = False + + # FireCrawl + firecrawl_api_key: str = "" + firecrawl_base_url: str = "https://api.firecrawl.dev" + firecrawl_timeout: int = 30 + firecrawl_retries: int = 3 + + # Redis + redis_url: str = "redis://localhost:6379/0" + redis_cache_url: str = "redis://localhost:6379/1" + + # Cache TTL (วินาที) + cache_ttl_scrape: int = 600 + cache_ttl_crawl: int = 1800 + + # Rate limit (คำขอ/นาที) + rate_limit_scrape: int = 120 + rate_limit_crawl: int = 60 + + # Webhook + webhook_secret: str = "" + + # Celery + celery_broker_url: str = "redis://localhost:6379/2" + celery_result_backend: str = "redis://localhost:6379/3" + + # Logging + log_format: str = "json" # json | console + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/deliverables/firecrawl-fastapi/app/core/__init__.py b/deliverables/firecrawl-fastapi/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deliverables/firecrawl-fastapi/app/core/firecrawl.py b/deliverables/firecrawl-fastapi/app/core/firecrawl.py new file mode 100644 index 0000000..0c645f6 --- /dev/null +++ b/deliverables/firecrawl-fastapi/app/core/firecrawl.py @@ -0,0 +1,115 @@ +"""ห่อ firecrawl SDK v1.x — แคช Redis + retry อัตโนมัติ + fail-open""" +from __future__ import annotations + +import hashlib +import json +import logging +from typing import Any, Optional + +from app.config.settings import Settings + +logger = logging.getLogger("firecrawl") + +try: + from firecrawl import FirecrawlApp + + _HAS_FIRECRAWL = True +except Exception: # pragma: no cover + _HAS_FIRECRAWL = False + + +def _cache_key(kind: str, payload: dict) -> str: + canonical = json.dumps(payload, sort_keys=True, ensure_ascii=False) + digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest() + return f"fc:{kind}:{digest}" + + +class FireCrawlService: + """Service ครอบ SDK — ถ้า SDK/คีย์หาย หรือ FireCrawl ล่ม จะ fail-open (ไม่ crash)""" + + def __init__(self, settings: Settings, cache=None): + self._settings = settings + self._cache = cache # RedisClient หรือ None + self._client = None + if _HAS_FIRECRAWL and settings.firecrawl_api_key: + try: + self._client = FirecrawlApp( + api_key=settings.firecrawl_api_key, + api_url=settings.firecrawl_base_url, + ) + except Exception as exc: # pragma: no cover + logger.warning("Firecrawl client init failed: %s", exc) + + # ---------- scrape ---------- + async def scrape(self, url: str, options: Optional[dict] = None) -> dict: + options = options or {} + payload = {"url": url, **options} + cache_key = _cache_key("scrape", payload) + + # 1) แคช + if self._cache is not None: + cached = await self._cache.get_json(cache_key) + if cached is not None: + return {**cached, "cache": "HIT"} + + result = await self._scrape_remote(url, options) + + # 2) เก็บแคช + if self._cache is not None and result.get("success"): + await self._cache.set_json(cache_key, result, self._settings.cache_ttl_scrape) + + return {**result, "cache": "MISS"} + + async def _scrape_remote(self, url: str, options: dict) -> dict: + if self._client is None: + # fail-open: คืนค่า degraded แทน crash + return {"success": False, "error": "FireCrawl ไม่ได้ตั้งค่า (ไม่มี key)", "markdown": None, "metadata": None} + last_err: Optional[Exception] = None + for attempt in range(1, self._settings.firecrawl_retries + 1): + try: + res = self._client.scrape_url(url, params=options) + if isinstance(res, dict) and res.get("success") is False: + # SDK บางรุ่น return dict + return {"success": False, "error": str(res.get("error", "unknown")), "markdown": None, "metadata": None} + return { + "success": True, + "markdown": (res or {}).get("markdown") if isinstance(res, dict) else getattr(res, "markdown", None), + "metadata": (res or {}).get("metadata") if isinstance(res, dict) else getattr(res, "metadata", None), + } + except Exception as exc: # noqa: BLE001 + last_err = exc + logger.warning("scrape attempt %s failed: %s", attempt, exc) + return {"success": False, "error": str(last_err or "unknown"), "markdown": None, "metadata": None} + + # ---------- crawl (async ผ่าน Celery task) ---------- + async def crawl_async(self, url: str, options: Optional[dict] = None) -> str: + """ส่งงาน crawl เข้าคิว Celery → return task_id (ไม่บล็อก)""" + from app.workers.tasks import crawl_url_task # import ช้า กัน circular + + options = options or {} + return crawl_url_task.delay(url, options).id + + # ---------- job status ---------- + async def crawl_status(self, crawl_id: str) -> dict: + if self._client is None: + return {"status": "unknown", "error": "FireCrawl ไม่ได้ตั้งค่า", "data": None} + try: + res = self._client.check_crawl_status(crawl_id) + return { + "status": (res or {}).get("status", "unknown"), + "data": (res or {}).get("data"), + "error": (res or {}).get("error"), + } + except Exception as exc: # pragma: no cover + logger.warning("crawl status failed (fail-open): %s", exc) + return {"status": "unknown", "error": str(exc), "data": None} + + +_service: Optional[FireCrawlService] = None + + +def get_firecrawl_service(settings: Settings, cache=None) -> FireCrawlService: + global _service + if _service is None: + _service = FireCrawlService(settings, cache=cache) + return _service diff --git a/deliverables/firecrawl-fastapi/app/core/logging.py b/deliverables/firecrawl-fastapi/app/core/logging.py new file mode 100644 index 0000000..009b057 --- /dev/null +++ b/deliverables/firecrawl-fastapi/app/core/logging.py @@ -0,0 +1,41 @@ +"""ตั้งค่า log แบบมีโครงสร้าง — JSON (failsafe เป็น console ถ้าไม่มี structlog)""" +import logging + +from app.config.settings import Settings + +# structlog เป็น optional — fallback เป็น stdlib logging +try: + import structlog + + _HAS_STRUCTLOG = True +except Exception: # pragma: no cover + _HAS_STRUCTLOG = False + + +def setup_logging(settings: Settings) -> None: + if _HAS_STRUCTLOG: + processors = [ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + ] + if settings.log_format == "json": + processors.append(structlog.processors.JSONRenderer(ensure_ascii=False)) + else: + processors.append(structlog.dev.ConsoleRenderer()) + + structlog.configure( + processors=processors, + wrapper_class=structlog.make_filtering_bound_logger(logging.INFO), + logger_factory=structlog.PrintLoggerFactory(), + cache_logger_on_first_use=True, + ) + else: # pragma: no cover + logging.basicConfig(level=logging.INFO) + + +def get_logger(name: str = "firecrawl"): + if _HAS_STRUCTLOG: + return structlog.get_logger(name) + return logging.getLogger(name) diff --git a/deliverables/firecrawl-fastapi/app/core/redis.py b/deliverables/firecrawl-fastapi/app/core/redis.py new file mode 100644 index 0000000..1d6f2b9 --- /dev/null +++ b/deliverables/firecrawl-fastapi/app/core/redis.py @@ -0,0 +1,86 @@ +"""Redis — แคช + สถานะงาน (fail-open: ถ้า Redis ล่ม ไม่ crash แอป)""" +from __future__ import annotations + +import json +import logging +from typing import Any, Optional + +from app.config.settings import Settings + +logger = logging.getLogger("redis") + +try: + import redis.asyncio as aioredis + + _HAS_REDIS = True +except Exception: # pragma: no cover + _HAS_REDIS = False + + +class RedisClient: + """Client แบบ fail-open — ทุก operation จับข้อยกเว้นแล้ว return fallback""" + + def __init__(self, url: str): + self._pool = None + self._url = url + if _HAS_REDIS: + try: + self._pool = aioredis.ConnectionPool.from_url(url, decode_responses=True) + except Exception as exc: # pragma: no cover + logger.warning("Redis init failed: %s", exc) + + async def get_json(self, key: str) -> Optional[Any]: + if self._pool is None: + return None + try: + from redis import asyncio as aioredis # noqa: F401 + + client = aioredis.Redis(connection_pool=self._pool) + raw = await client.get(key) + return json.loads(raw) if raw else None + except Exception as exc: # pragma: no cover + logger.warning("Redis get failed (fail-open): %s", exc) + return None + + async def set_json(self, key: str, value: Any, ttl: int) -> bool: + if self._pool is None: + return False + try: + from redis import asyncio as aioredis # noqa: F401 + + client = aioredis.Redis(connection_pool=self._pool) + await client.set(key, json.dumps(value, ensure_ascii=False), ex=ttl) + return True + except Exception as exc: # pragma: no cover + logger.warning("Redis set failed (fail-open): %s", exc) + return False + + async def delete(self, key: str) -> bool: + if self._pool is None: + return False + try: + from redis import asyncio as aioredis # noqa: F401 + + client = aioredis.Redis(connection_pool=self._pool) + await client.delete(key) + return True + except Exception: # pragma: no cover + return False + + +_cache_client: Optional[RedisClient] = None +_state_client: Optional[RedisClient] = None + + +def get_cache_client(settings: Settings) -> RedisClient: + global _cache_client + if _cache_client is None: + _cache_client = RedisClient(settings.redis_cache_url) + return _cache_client + + +def get_state_client(settings: Settings) -> RedisClient: + global _state_client + if _state_client is None: + _state_client = RedisClient(settings.redis_url) + return _state_client diff --git a/deliverables/firecrawl-fastapi/app/middleware/__init__.py b/deliverables/firecrawl-fastapi/app/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deliverables/firecrawl-fastapi/app/middleware/rate_limit.py b/deliverables/firecrawl-fastapi/app/middleware/rate_limit.py new file mode 100644 index 0000000..55dd0b5 --- /dev/null +++ b/deliverables/firecrawl-fastapi/app/middleware/rate_limit.py @@ -0,0 +1,55 @@ +"""Rate limit middleware — จำกัดคำขอต่อนาที (fail-open ถ้า Redis ล่ม)""" +from __future__ import annotations + +import time +from typing import Optional + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse + +from app.config.settings import Settings + + +class RateLimitMiddleware(BaseHTTPMiddleware): + """Rate limit แบบ sliding window ผ่าน Redis (fail-open) + + ใช้ Redis ที่ state client; ถ้า Redis ล่ม → ปล่อยผ่าน (fail-open) ตาม blueprint + """ + + def __init__(self, app, settings: Settings, redis_client=None): + super().__init__(app) + self._settings = settings + self._redis = redis_client + + async def dispatch(self, request: Request, call_next): + path = request.url.path + # กำหนด quota ตาม endpoint + if path.endswith("/scrape"): + limit = self._settings.rate_limit_scrape + elif path.endswith("/crawl"): + limit = self._settings.rate_limit_crawl + else: + return await call_next(request) + + client_ip = request.client.host if request.client else "unknown" + window = 60 + key = f"rl:{path}:{client_ip}:{int(time.time()) // window}" + + if self._redis is not None: + try: + from redis import asyncio as aioredis # noqa + + count = await self._redis.incr(key) + if count == 1: + await self._redis.expire(key, window) + if count > limit: + return JSONResponse( + status_code=429, + content={"detail": "rate limit exceeded", "limit": limit}, + ) + except Exception: + # fail-open: Redis ล่ม → ปล่อยผ่าน + pass + + return await call_next(request) diff --git a/deliverables/firecrawl-fastapi/app/schemas/__init__.py b/deliverables/firecrawl-fastapi/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deliverables/firecrawl-fastapi/app/schemas/firecrawl.py b/deliverables/firecrawl-fastapi/app/schemas/firecrawl.py new file mode 100644 index 0000000..32a1ab2 --- /dev/null +++ b/deliverables/firecrawl-fastapi/app/schemas/firecrawl.py @@ -0,0 +1,43 @@ +"""Pydantic schemas — ตรวจสอบข้อมูลเข้า/ออก""" +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field, HttpUrl + + +class ScrapeRequest(BaseModel): + url: HttpUrl + formats: Optional[List[str]] = Field(default_factory=lambda: ["markdown"]) + only_main_content: bool = True + wait_for: Optional[int] = Field(default=None, ge=0, le=60_000) + timeout: Optional[int] = Field(default=None, ge=1_000, le=60_000) + + +class ScrapeResponse(BaseModel): + success: bool + url: str + markdown: Optional[str] = None + metadata: Optional[Dict[str, Any]] = None + cache: str = "MISS" + error: Optional[str] = None + + +class CrawlRequest(BaseModel): + url: HttpUrl + max_pages: int = Field(default=10, ge=1, le=1000) + webhook_url: Optional[HttpUrl] = None + ignore_sitemap: bool = False + + +class TaskResponse(BaseModel): + task_id: str + status: str = "queued" + url: str + max_pages: int = 10 + webhook_url: Optional[str] = None + + +class TaskResult(BaseModel): + task_id: str + status: str + data: Optional[List[Dict[str, Any]]] = None + error: Optional[str] = None diff --git a/deliverables/firecrawl-fastapi/app/services/__init__.py b/deliverables/firecrawl-fastapi/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deliverables/firecrawl-fastapi/app/services/webhook.py b/deliverables/firecrawl-fastapi/app/services/webhook.py new file mode 100644 index 0000000..4d9f557 --- /dev/null +++ b/deliverables/firecrawl-fastapi/app/services/webhook.py @@ -0,0 +1,51 @@ +"""Webhook service — ส่งผลงานพร้อมลายเซ็น HMAC-SHA256""" +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +from typing import Any, Optional + +logger = logging.getLogger("webhook") + +try: + import httpx + + _HAS_HTTPX = True +except Exception: # pragma: no cover + _HAS_HTTPX = False + + +def sign_payload(payload: dict, secret: str) -> str: + """คำนวณ HMAC-SHA256 จาก payload canonical JSON""" + body = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return hmac.new(secret.encode("utf-8"), body.encode("utf-8"), hashlib.sha256).hexdigest() + + +def verify_signature(payload: dict, secret: str, signature: str) -> bool: + expected = sign_payload(payload, secret) + return hmac.compare_digest(expected, signature) + + +async def send_webhook( + url: str, + payload: dict, + secret: Optional[str] = None, + timeout: float = 10.0, +) -> bool: + """ส่ง webhook พร้อม header X-Signature (HMAC-SHA256) ถ้ามี secret""" + headers = {"Content-Type": "application/json"} + if secret: + headers["X-Signature"] = sign_payload(payload, secret) + headers["X-Signature-Algo"] = "sha256" + if not _HAS_HTTPX: # pragma: no cover + logger.warning("httpx ไม่ได้ติดตั้ง — ข้าม webhook") + return False + try: + async with httpx.AsyncClient(timeout=timeout) as client: + resp = await client.post(url, json=payload, headers=headers) + return resp.status_code < 300 + except Exception as exc: # noqa: BLE001 + logger.warning("webhook ส่งล้มเหลว: %s", exc) + return False diff --git a/deliverables/firecrawl-fastapi/app/workers/__init__.py b/deliverables/firecrawl-fastapi/app/workers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deliverables/firecrawl-fastapi/app/workers/celery.py b/deliverables/firecrawl-fastapi/app/workers/celery.py new file mode 100644 index 0000000..e0f24a1 --- /dev/null +++ b/deliverables/firecrawl-fastapi/app/workers/celery.py @@ -0,0 +1,26 @@ +"""Celery app — ตั้งค่าคิวงาน background""" +from celery import Celery + +from app.config.settings import get_settings + +settings = get_settings() + +celery_app = Celery( + "firecrawl_worker", + broker=settings.celery_broker_url, + backend=settings.celery_result_backend, +) + +celery_app.conf.update( + task_serializer="json", + accept_content=["json"], + result_serializer="json", + timezone="UTC", + enable_utc=True, + task_track_started=True, + task_acks_late=True, # งานยืนยันเมื่อเสร็จ ไม่ใช่ตอนรับ — รองรับ retry หลัง crash + worker_prefetch_multiplier=1, + task_time_limit=300, # 5 นาที กันงานค้าง + task_soft_time_limit=270, + result_expires=3600, +) diff --git a/deliverables/firecrawl-fastapi/app/workers/tasks.py b/deliverables/firecrawl-fastapi/app/workers/tasks.py new file mode 100644 index 0000000..0b9ec37 --- /dev/null +++ b/deliverables/firecrawl-fastapi/app/workers/tasks.py @@ -0,0 +1,110 @@ +"""Celery tasks — งานเบื้องหลัง: crawl_url, scrape_url + +ออกแบบให้ import ได้ทั้งแบบ async wrapper (dev/test) และ Celery task (production) +worker ใช้ firecrawl SDK v1.x แบบ sync ใน thread, ผลงานเก็บ Redis ให้ /tasks/{id} อ่าน +""" +from __future__ import annotations + +import asyncio +import logging +import uuid +from typing import Any, Optional + +from app.config.settings import get_settings +from app.core.redis import get_state_client + +logger = logging.getLogger("worker") + +TASK_KEY_PREFIX = "fc:task:" + + +def _task_key(task_id: str) -> str: + return f"{TASK_KEY_PREFIX}{task_id}" + + +def _run_scrape_sync(url: str, options: Optional[dict]) -> dict: + """รัน scrape จริงแบบ sync (ใน worker thread) — ใช้ SDK v1.x โดยตรง""" + from app.core.firecrawl import FireCrawlService + + svc = FireCrawlService(get_settings()) + if svc._client is None: + return {"success": False, "error": "FireCrawl ไม่ได้ตั้งค่า (ไม่มี key)", "data": None} + try: + res = svc._client.scrape_url(url, params=options or {}) + return { + "success": True, + "markdown": (res or {}).get("markdown") if isinstance(res, dict) else getattr(res, "markdown", None), + "metadata": (res or {}).get("metadata") if isinstance(res, dict) else getattr(res, "metadata", None), + } + except Exception as exc: # noqa: BLE001 + logger.exception("scrape %s failed", url) + return {"success": False, "error": str(exc), "markdown": None, "metadata": None} + + +def _run_crawl_sync(url: str, options: Optional[dict]) -> dict: + """รัน crawl แบบ sync — SDK v1.x crawl_url""" + from app.core.firecrawl import FireCrawlService + + svc = FireCrawlService(get_settings()) + if svc._client is None: + return {"success": False, "error": "FireCrawl ไม่ได้ตั้งค่า (ไม่มี key)", "data": None} + try: + res = svc._client.crawl_url(url, params=options or {}) + return {"success": True, "data": res} + except Exception as exc: # noqa: BLE001 + logger.exception("crawl %s failed", url) + return {"success": False, "error": str(exc), "data": None} + + +async def _store_result(task_id: str, result: dict, ttl: int = 1800) -> None: + try: + cache = get_state_client(get_settings()) + await cache.set_json(_task_key(task_id), result, ttl) + except Exception as exc: # pragma: no cover + logger.warning("store result failed: %s", exc) + + +# ---------- async task (เรียกจาก API ใน process เดียว — dev/test) ---------- +async def crawl_url(url: str, options: Optional[dict] = None) -> str: + task_id = str(uuid.uuid4()) + loop = asyncio.get_running_loop() + result = await loop.run_in_executor(None, _run_crawl_sync, url, options) + await _store_result(task_id, {"status": "completed", **result}) + return task_id + + +async def scrape_url(url: str, options: Optional[dict] = None) -> str: + task_id = str(uuid.uuid4()) + loop = asyncio.get_running_loop() + result = await loop.run_in_executor(None, _run_scrape_sync, url, options) + await _store_result(task_id, {"status": "completed", **result}) + return task_id + + +# ---------- Celery tasks (production: worker แยก, task_acks_late) ---------- +try: + from app.workers.celery import celery_app + + @celery_app.task(name="app.workers.tasks.crawl_url_task", bind=True, max_retries=3) + def crawl_url_task(self, url: str, options: Optional[dict] = None): + try: + result = _run_crawl_sync(url, options) + task_id = self.request.id or "" + asyncio.run(_store_result(task_id, {"status": "completed", **result})) + return result + except Exception as exc: # noqa: BLE001 + logger.exception("crawl task failed") + raise self.retry(exc=exc, countdown=2 * (self.request.retries + 1)) + + @celery_app.task(name="app.workers.tasks.scrape_url_task", bind=True, max_retries=3) + def scrape_url_task(self, url: str, options: Optional[dict] = None): + try: + result = _run_scrape_sync(url, options) + task_id = self.request.id or "" + asyncio.run(_store_result(task_id, {"status": "completed", **result})) + return result + except Exception as exc: # noqa: BLE001 + raise self.retry(exc=exc, countdown=2 * (self.request.retries + 1)) + +except Exception as exc: # pragma: no cover + logger.warning("Celery not configured, sync tasks unavailable: %s", exc) diff --git a/deliverables/firecrawl-fastapi/docker-compose.yml b/deliverables/firecrawl-fastapi/docker-compose.yml new file mode 100644 index 0000000..59e114e --- /dev/null +++ b/deliverables/firecrawl-fastapi/docker-compose.yml @@ -0,0 +1,44 @@ +services: + api: + build: . + container_name: firecrawl-api + restart: unless-stopped + ports: + - "8000:8000" + env_file: .env + depends_on: + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health',timeout=3)"] + interval: 30s + timeout: 3s + retries: 3 + + worker: + build: . + container_name: firecrawl-worker + restart: unless-stopped + command: celery -A app.workers.celery.celery_app worker --loglevel=info --concurrency=2 + env_file: .env + depends_on: + redis: + condition: service_healthy + + redis: + image: redis:7-alpine + container_name: firecrawl-redis + restart: unless-stopped + command: redis-server --appendonly yes + ports: + - "6379:6379" + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + +volumes: + redis-data: diff --git a/deliverables/firecrawl-fastapi/main.py b/deliverables/firecrawl-fastapi/main.py new file mode 100644 index 0000000..c311f24 --- /dev/null +++ b/deliverables/firecrawl-fastapi/main.py @@ -0,0 +1,38 @@ +"""จุดเริ่มต้น FastAPI + วงจรชีวิตแอป""" +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +from app.api.v1.endpoints.firecrawl import router as firecrawl_router +from app.config.settings import get_settings +from app.core.logging import setup_logging + + +@asynccontextmanager +async def lifespan(app: FastAPI): + settings = get_settings() + setup_logging(settings) + yield + # cleanup ถ้าจำเป็น + + +def create_app() -> FastAPI: + settings = get_settings() + app = FastAPI( + title=settings.app_name, + version="1.0.0", + docs_url="/docs", + openapi_url="/openapi.json", + lifespan=lifespan, + ) + + # health + @app.get("/health", tags=["system"]) + async def health(): + return {"status": "ok", "app": settings.app_name, "env": settings.app_env} + + app.include_router(firecrawl_router, prefix="/api/v1") + return app + + +app = create_app() diff --git a/deliverables/firecrawl-fastapi/requirements.txt b/deliverables/firecrawl-fastapi/requirements.txt new file mode 100644 index 0000000..0bb3823 --- /dev/null +++ b/deliverables/firecrawl-fastapi/requirements.txt @@ -0,0 +1,10 @@ +fastapi>=0.115,<1.0 +uvicorn[standard]>=0.30 +pydantic>=2.7 +pydantic-settings>=2.3 +firecrawl-py>=1.0,<2.0 +redis>=5.0 +celery>=5.3 +structlog>=24.1 +httpx>=0.27 +python-dotenv>=1.0 diff --git a/deliverables/firecrawl-fastapi/tests/__init__.py b/deliverables/firecrawl-fastapi/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deliverables/firecrawl-fastapi/tests/test_app.py b/deliverables/firecrawl-fastapi/tests/test_app.py new file mode 100644 index 0000000..41f5c84 --- /dev/null +++ b/deliverables/firecrawl-fastapi/tests/test_app.py @@ -0,0 +1,85 @@ +"""Tests — ไม่ต้องใช้ Redis/FireCrawl/API จริง (mock + fail-open path) + +ครอบคลุม: health, scrape fail-open (ไม่มี key), crawl async → task_id, +task status 404, webhook HMAC sign/verify. +""" +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from main import app + + +@pytest_asyncio.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as c: + yield c + + +# ---------- health ---------- +@pytest.mark.asyncio +async def test_health(client): + r = await client.get("/health") + assert r.status_code == 200 + body = r.json() + assert body["status"] == "ok" + assert "FireCrawl" in body["app"] + + +# ---------- scrape (fail-open: ไม่มี key → คืน degraded ไม่ crash) ---------- +@pytest.mark.asyncio +async def test_scrape_fail_open_no_key(client): + """ไม่มี FIRECRAWL_API_KEY → ยังคืน 200 พร้อม error (fail-open)""" + r = await client.post( + "/api/v1/firecrawl/scrape", + json={"url": "https://example.com"}, + ) + assert r.status_code == 200 + body = r.json() + assert body["success"] is False + assert body["error"] # มีข้อความบอกว่าไม่ได้ตั้งค่า + + +@pytest.mark.asyncio +async def test_scrape_invalid_url(client): + """URL ไม่ valid → 422 จาก Pydantic""" + r = await client.post("/api/v1/firecrawl/scrape", json={"url": "not-a-url"}) + assert r.status_code == 422 + + +# ---------- crawl (async task → task_id) ---------- +@pytest.mark.asyncio +async def test_crawl_returns_task_id(client): + r = await client.post( + "/api/v1/firecrawl/crawl", + json={"url": "https://example.com", "max_pages": 5}, + ) + assert r.status_code == 202 + body = r.json() + assert body["task_id"] + assert body["status"] == "queued" + + +# ---------- task status ---------- +@pytest.mark.asyncio +async def test_task_status_not_found(client): + r = await client.get("/api/v1/firecrawl/tasks/nonexistent-id") + assert r.status_code == 200 + body = r.json() + assert body["status"] == "not_found" + + +# ---------- webhook HMAC ---------- +def test_webhook_sign_verify(): + from app.services.webhook import sign_payload, verify_signature + + payload = {"task_id": "abc", "data": [{"url": "https://example.com"}]} + secret = "s3cr3t" + sig = sign_payload(payload, secret) + assert verify_signature(payload, secret, sig) is True + # ผิด secret → ไม่ผ่าน + assert verify_signature(payload, "wrong-secret", sig) is False + # เปลี่ยน payload → ไม่ผ่าน + tampered = {**payload, "data": []} + assert verify_signature(tampered, secret, sig) is False diff --git a/deliverables/manus-client/README.md b/deliverables/manus-client/README.md new file mode 100644 index 0000000..cd04ced --- /dev/null +++ b/deliverables/manus-client/README.md @@ -0,0 +1,59 @@ +# manus-client — Manus REST API v2 client (โค้ดจริง) + +Async client สำหรับ Manus REST API v2 แบบ **task-first** สร้างจาก **surface จริงที่ probe กับ `api.manus.ai`** ไม่ใช่จากเอกสารที่ยังไม่ verify + +## Surface จริงที่ยืนยันจาก live probe + +| รายการ | ความจริงที่ probe ได้ | เอกสารบางฉบับ (ไม่ตรง) | +|--------|----------------------|------------------------| +| Create task | `POST /v2/task.create` | `POST /v2/tasks` (404 — ไม่มีอยู่) | +| List tasks | `POST /v2/task.list` | `GET /v2/tasks` | +| Messages/result | `POST /v2/task.listMessages` | `GET /v2/tasks/{id}` | +| Stop | `POST /v2/task.stop` | `DELETE /v2/tasks/{id}` | +| Webhook | `POST /v2/webhook.create` | — | +| Auth | API Key **หรือ** Bearer Token | "X-Manus-API-Key เท่านั้น" (ผิด) | +| Envelope | `ok` + `request_id` + `error:{code,message}` | `success` (ผิด) | + +## ไฟล์ + +- `manus_client/client.py` — `ManusClient` + `ManusAPIError` (async, httpx) +- `manus_client/cli.py` — CLI runner (สร้าง task → poll) +- `tests/test_client.py` — 10 tests แบบ mock (ไม่ใช้ key/network) +- `requirements.txt` — `httpx` + +## ใช้งาน + +```python +import asyncio +from manus_client.client import ManusClient + +async def main(): + c = ManusClient(api_key="manus_...") # หรือ bearer_token="..." + result = await c.run_task( + "ไปที่ https://example.com แล้วดึงข้อมูลสินค้า", + options={"response_format": "json"}, + ) + print(result) + +asyncio.run(main()) +``` + +CLI: + +```bash +export MANUS_API_KEY=manus_... +python -m manus_client.cli "งานที่อยากให้ทำ" --format json +``` + +## Test + +```bash +pip install -r requirements.txt pytest +python -m pytest tests/ -q # 10 passed +``` + +## หมายเหตุ + +- รายละเอียด field ของ response (เช่น ชื่อ taskId/status จริง) อาจต่างจากที่คาดเล็กน้อย — + client อ่าน `taskId`/`task_id` ทั้งคู่ และ `data`/`messages` ทั้งคู่ เพื่อกัน variation +- ต้องมี `MANUS_API_KEY` หรือ Bearer จริงเพื่อยืนยัน auth header ตัวสุดท้ายกับ live API diff --git a/deliverables/manus-client/manus_client/__init__.py b/deliverables/manus-client/manus_client/__init__.py new file mode 100644 index 0000000..1c29e44 --- /dev/null +++ b/deliverables/manus-client/manus_client/__init__.py @@ -0,0 +1,11 @@ +"""manus_client — client สำหรับ Manus REST API v2 (task-first, dot-notation endpoints) + +Surface นี้ยืนยันจาก live probe ของ api.manus.ai (ไม่ใช่จากเอกสารที่ยังไม่ verify): + - Endpoint ใช้ dot-notation: /v2/task.create, /v2/task.list, /v2/task.listMessages + - Auth รับ API Key หรือ Bearer Token (API ตอบ "require either API Key or Bearer Token") + - Response envelope ใช้ `ok` (bool) + `request_id` (ไม่ใช่ `success`) +""" +from manus_client.client import ManusClient, ManusAPIError + +__all__ = ["ManusClient", "ManusAPIError"] +__version__ = "0.1.0" diff --git a/deliverables/manus-client/manus_client/cli.py b/deliverables/manus-client/manus_client/cli.py new file mode 100644 index 0000000..9d90052 --- /dev/null +++ b/deliverables/manus-client/manus_client/cli.py @@ -0,0 +1,41 @@ +"""CLI ตัวอย่าง — สร้าง task แล้ว poll ผล (ต้องตั้ง MANUS_API_KEY หรือ MANUS_BEARER_TOKEN) + +ใช้งาน: + export MANUS_API_KEY=manus_... + python -m manus_client.cli "ไปที่ https://example.com แล้วดึงข้อมูลสินค้า" --format json +""" +from __future__ import annotations + +import argparse +import asyncio +import os + +from manus_client.client import ManusClient, ManusAPIError + + +async def main(prompt: str, fmt: str, interval: float, max_wait: float) -> None: + key = os.getenv("MANUS_API_KEY", "") + bearer = os.getenv("MANUS_BEARER_TOKEN", "") + client = ManusClient(api_key=key, bearer_token=bearer) + try: + result = await client.run_task( + prompt, + options={"response_format": fmt}, + interval=interval, + max_wait=max_wait, + ) + print("=== Task เรียบร้อย ===") + print(result) + except ManusAPIError as exc: + print(f"[error:{exc.code}] {exc} (request_id={exc.request_id})") + raise SystemExit(1) from exc + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Manus v2 task runner") + parser.add_argument("prompt", help="งานเป็นภาษา natural language") + parser.add_argument("--format", default="json", choices=["json", "markdown", "text"]) + parser.add_argument("--interval", type=float, default=2.0) + parser.add_argument("--max-wait", type=float, default=600.0) + args = parser.parse_args() + asyncio.run(main(args.prompt, args.format, args.interval, args.max_wait)) diff --git a/deliverables/manus-client/manus_client/client.py b/deliverables/manus-client/manus_client/client.py new file mode 100644 index 0000000..91372fe --- /dev/null +++ b/deliverables/manus-client/manus_client/client.py @@ -0,0 +1,117 @@ +"""Manus REST API v2 client — async, provider-neutral, resilient. + +Endpoint surface (ยืนยันจาก live probe ของ api.manus.ai): + POST /v2/task.create → สร้าง task + GET /v2/task.list → รายการ tasks + GET /v2/task.listMessages → ข้อความ/ผลลัพธ์ของ task + POST /v2/task.stop → หยุด task + POST /v2/webhook.create → ตั้ง webhook + POST /v2/file.upload → อัปโหลดไฟล์ + +Auth: ส่ง X-Manus-API-Key (หรือ Authorization: Bearer) — API รับทั้งคู่ +Envelope: {"ok": bool, "request_id": str, ...} +""" +from __future__ import annotations + +import logging +import time +from typing import Any, Dict, Optional + +import httpx + +logger = logging.getLogger("manus_client") + +DEFAULT_BASE = "https://api.manus.ai/v2" + + +class ManusAPIError(RuntimeError): + """ข้อผิดพลาดจาก Manus API — มี code + request_id""" + + def __init__(self, message: str, code: str = "unknown", request_id: str = ""): + super().__init__(message) + self.code = code + self.request_id = request_id + + +class ManusClient: + def __init__( + self, + api_key: str = "", + bearer_token: str = "", + base_url: str = DEFAULT_BASE, + timeout: float = 60.0, + ): + if not api_key and not bearer_token: + raise ValueError("ต้องระบุ api_key หรือ bearer_token อย่างใดอย่างหนึ่ง") + self._headers: Dict[str, str] = { + "Content-Type": "application/json", + "Accept": "application/json", + } + if api_key: + self._headers["X-Manus-API-Key"] = api_key + else: + self._headers["Authorization"] = f"Bearer {bearer_token}" + self._base = base_url.rstrip("/") + self._timeout = timeout + + # ---------- low-level ---------- + async def _request(self, method: str, path: str, json_body: Optional[dict] = None) -> Dict[str, Any]: + url = f"{self._base}{path}" + async with httpx.AsyncClient(timeout=self._timeout) as client: + resp = await client.request(method, url, headers=self._headers, json=json_body) + try: + data = resp.json() + except ValueError: + data = {"ok": False, "error": {"code": "bad_response", "message": resp.text[:200]}} + # error envelope ของ Manus ใช้ ok:false + error:{code,message} + if not data.get("ok", False) or resp.status_code >= 400: + err = data.get("error", {}) + raise ManusAPIError( + message=err.get("message", f"HTTP {resp.status_code}"), + code=err.get("code", str(resp.status_code)), + request_id=data.get("request_id", ""), + ) + return data + + # ---------- task lifecycle ---------- + async def create_task(self, task: str, options: Optional[Dict] = None) -> Dict[str, Any]: + return await self._request("POST", "/task.create", {"task": task, "options": options or {}}) + + async def list_tasks(self, limit: int = 20, offset: int = 0) -> Dict[str, Any]: + return await self._request("POST", "/task.list", {"limit": limit, "offset": offset}) + + async def list_messages(self, task_id: str) -> Dict[str, Any]: + return await self._request("POST", "/task.listMessages", {"taskId": task_id}) + + async def stop_task(self, task_id: str) -> Dict[str, Any]: + return await self._request("POST", "/task.stop", {"taskId": task_id}) + + async def create_webhook(self, task_id: str, url: str) -> Dict[str, Any]: + return await self._request("POST", "/webhook.create", {"taskId": task_id, "url": url}) + + # ---------- polling helper ---------- + async def run_task( + self, + task: str, + options: Optional[Dict] = None, + interval: float = 2.0, + max_wait: float = 600.0, + ) -> Dict[str, Any]: + """สร้าง task แล้ว poll จนจบ (สถานะที่ 'stopped'/'error') — คืนผลรวม""" + created = await self.create_task(task, options) + task_id = created.get("taskId") or created.get("task_id") + if not task_id: + raise ManusAPIError("response ไม่มี task_id", code="no_task_id", request_id=created.get("request_id", "")) + + deadline = time.time() + max_wait + while time.time() < deadline: + msgs = await self.list_messages(task_id) + # หาสถานะจาก messages + items = msgs.get("data") or msgs.get("messages") or [] + if items: + statuses = {m.get("status") for m in items if isinstance(m, dict) and m.get("status")} + if statuses & {"stopped", "error", "cancelled", "completed"}: + return msgs + time.sleep(interval) + + raise ManusAPIError(f"task ยังไม่จบภายใน {max_wait}s", code="timeout", request_id="") diff --git a/deliverables/manus-client/pyproject.toml b/deliverables/manus-client/pyproject.toml new file mode 100644 index 0000000..4994617 --- /dev/null +++ b/deliverables/manus-client/pyproject.toml @@ -0,0 +1,12 @@ +[project] +name = "manus-client" +version = "0.1.0" +description = "Manus REST API v2 async client (verified surface)" +requires-python = ">=3.9" +dependencies = ["httpx>=0.27"] + +[project.optional-dependencies] +dev = ["pytest>=7"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/deliverables/manus-client/requirements.txt b/deliverables/manus-client/requirements.txt new file mode 100644 index 0000000..6ecf620 --- /dev/null +++ b/deliverables/manus-client/requirements.txt @@ -0,0 +1 @@ +httpx>=0.27 diff --git a/deliverables/manus-client/tests/test_client.py b/deliverables/manus-client/tests/test_client.py new file mode 100644 index 0000000..9efa683 --- /dev/null +++ b/deliverables/manus-client/tests/test_client.py @@ -0,0 +1,133 @@ +"""Tests — ใช้ mock ไม่ต้องใช้ API key จริง / ไม่ต้องแตะ network""" +import asyncio + +import pytest + +from manus_client.client import ManusClient, ManusAPIError + +API_KEY = "manus_test_1234567890abcdef" + + +def make_client(**kw): + return ManusClient(api_key=API_KEY, **kw) + + +def async_lambda(value): + async def f(*a, **k): + return value + return f + + +def run(coro): + return asyncio.get_event_loop().run_until_complete(coro) + + +# ---------- constructor ---------- +def test_requires_auth(): + with pytest.raises(ValueError): + ManusClient() + + +def test_api_key_header(): + c = make_client() + assert c._headers["X-Manus-API-Key"] == API_KEY + assert "Authorization" not in c._headers + + +def test_bearer_alternative(): + c = ManusClient(bearer_token="tok123") + assert c._headers["Authorization"] == "Bearer tok123" + assert "X-Manus-API-Key" not in c._headers + + +# ---------- create_task success + auth header ---------- +def test_create_task_success(): + c = make_client() + + captured = {} + + async def fake_request(method, path, json_body=None): + captured["path"] = path + captured["json"] = json_body + return {"ok": True, "request_id": "req1", "taskId": "task_abc", "status": "pending"} + + c._request = fake_request # type: ignore[method-assign] + out = run(c.create_task("do something", {"response_format": "json"})) + + assert captured["path"] == "/task.create" + assert captured["json"]["task"] == "do something" + assert captured["json"]["options"] == {"response_format": "json"} + assert out["taskId"] == "task_abc" + + +# ---------- endpoints map ไป dot-notation ---------- +def test_list_messages_path(): + c = make_client() + seen = {} + + async def fake_request(method, path, json_body=None): + seen["path"] = path + seen["body"] = json_body + return {"ok": True, "request_id": "r", "data": []} + + c._request = fake_request # type: ignore[method-assign] + run(c.list_messages("task_1")) + assert seen["path"] == "/task.listMessages" + assert seen["body"] == {"taskId": "task_1"} + + +def test_stop_and_webhook_paths(): + c = make_client() + seen = [] + + async def fake_request(method, path, json_body=None): + seen.append((path, json_body)) + return {"ok": True, "request_id": "r"} + + c._request = fake_request # type: ignore[method-assign] + run(c.stop_task("t1")) + run(c.create_webhook("t1", "https://x.com/h")) + assert seen[0][0] == "/task.stop" + assert seen[1][0] == "/webhook.create" + assert seen[1][1] == {"taskId": "t1", "url": "https://x.com/h"} + + +# ---------- error mapping ---------- +def test_error_raises_manus_error(): + async def bad_req(method, path, json_body=None): + raise ManusAPIError("missing authentication", code="unauthenticated", request_id="r_err") + + c = make_client() + c._request = bad_req # type: ignore[method-assign] + with pytest.raises(ManusAPIError) as exc: + run(c.create_task("x")) + assert exc.value.code == "unauthenticated" + assert exc.value.request_id == "r_err" + + +# ---------- run_task polling ---------- +def test_run_task_completes(): + c = make_client() + created = {"ok": True, "request_id": "r1", "taskId": "task_1"} + msgs = {"ok": True, "request_id": "r2", "data": [{"status": "stopped", "content": "done"}]} + c.create_task = async_lambda(created) # type: ignore[method-assign] + c.list_messages = async_lambda(msgs) # type: ignore[method-assign] + out = run(c.run_task("job", interval=0.01)) + assert out["data"][0]["status"] == "stopped" + + +def test_run_task_timeout(): + c = make_client() + c.create_task = async_lambda({"ok": True, "request_id": "r1", "taskId": "task_1"}) # type: ignore[method-assign] + c.list_messages = async_lambda({"ok": True, "request_id": "r2", "data": [{"status": "running"}]}) # type: ignore[method-assign] + with pytest.raises(ManusAPIError) as exc: + run(c.run_task("job", interval=0.01, max_wait=0.05)) + assert exc.value.code == "timeout" + + +def test_run_task_missing_id(): + c = make_client() + c.create_task = async_lambda({"ok": True, "request_id": "r1"}) # type: ignore[method-assign] + with pytest.raises(ManusAPIError) as exc: + run(c.run_task("job")) + assert exc.value.code == "no_task_id" diff --git a/deliverables/onspace-ai/.gitignore b/deliverables/onspace-ai/.gitignore new file mode 100644 index 0000000..f6664e3 --- /dev/null +++ b/deliverables/onspace-ai/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.pyc +.env +.pytest_cache/ diff --git a/deliverables/onspace-ai/Dockerfile b/deliverables/onspace-ai/Dockerfile new file mode 100644 index 0000000..f42a31d --- /dev/null +++ b/deliverables/onspace-ai/Dockerfile @@ -0,0 +1,13 @@ +# syntax=docker/dockerfile:1.7 +FROM python:3.13-slim AS base +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PIP_NO_CACHE_DIR=1 +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY app ./app +RUN useradd -m -u 1000 appuser +USER appuser +EXPOSE 8000 +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health',timeout=3)" +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/deliverables/onspace-ai/KNOWLEDGE_BASE.md b/deliverables/onspace-ai/KNOWLEDGE_BASE.md new file mode 100644 index 0000000..659b406 --- /dev/null +++ b/deliverables/onspace-ai/KNOWLEDGE_BASE.md @@ -0,0 +1,54 @@ +# Knowledge Base — Patterns & Limits + +หลักการออกแบบ + เกณฑ์/ข้อจำกัดสำหรับ stack นี้ + +## Core Design Principles + +1. **Cost-First**: Avoid request → optimize → retry +2. **Defense in Depth**: Cache + Circuit Breaker + Fallback + Timeout +3. **Token Discipline**: Measure → Limit → Compress → Reuse +4. **Observable**: ทุก error/latency/token/cache-hit มี metric +5. **Safe Automation**: ไม่ auto-merge secrets/auth/infra + +## Retryable vs Non-retryable + +| Status | Retryable | เหตุผล | +|--------|-----------|--------| +| 408, 429, 502, 503, 504 | ✅ | ชั่วคราว — ลองใหม่ได้ | +| 400, 401, 403, 422 | ❌ | ผิดถาวร — ไม่เสีย cost ลองใหม่ | + +Fallback router: **non-retryable → หยุดทันที** (ไม่เรียก provider ตัวถัดไป) + +## Circuit Breaker States + +- **CLOSED**: ปล่อย request ปกติ +- **OPEN**: ล้มเกิน threshold → block ทั้งหมด (cooldown 30s) +- **HALF-OPEN**: หลัง cooldown ลอง 1 request → สำเร็จ=CLOSED / ล้ม=OPEN + +## Cache Hierarchy + +``` +L0 Dedup → L1 Browser → L2 CDN → L3 Redis → L4 Context → L5 Result +``` + +## Resilience + +- **Redis fail-open**: Redis ล่ม → cache miss / ไม่ crash +- **Fallback**: primary → secondary → degraded (คืน 503) +- **Timeout + bounded retry**: กันงานค้าง + +## Observability Metrics + +``` +onspaceai_api_requests_total / latency_seconds +onspaceai_cache_hits_total / misses_total +onspaceai_fallback_total / degraded_responses_total +onspaceai_tokens_total / tokens_saved +``` + +## Security + +- Auth (X-API-Key) — ข้าม /health /metrics /docs +- X-Request-ID ทุกรีเควสต์ +- Redis TLS + auth ใน production +- ไม่ hardcode key — pydantic-settings จาก .env diff --git a/deliverables/onspace-ai/README.md b/deliverables/onspace-ai/README.md new file mode 100644 index 0000000..7727500 --- /dev/null +++ b/deliverables/onspace-ai/README.md @@ -0,0 +1,54 @@ +# OnSpaceAI — Cost & Reliability Stack + +FastAPI-based AI API infrastructure สำหรับ **low cost + high reliability + minimal token waste** ตาม blueprint ที่กำหนด + +## Core Principle + +> **Avoid the request before optimizing it.** +> Cache → Deduplicate → Compress Context → Budget Tokens → Fallback → Retry Only Safe + +## โครงสร้าง + +``` +app/ +├── config.py # pydantic-settings จาก .env +├── middleware.py # request-id + auth + metrics +├── cache.py # Redis + hash key + TTL (fail-open) / MemoryCache +├── circuit_breaker.py # Closed/Open/Half-Open +├── fallback_router.py # Primary → Secondary → Degraded +├── metrics.py # Prometheus counters/histograms +├── providers.py # Provider contract + error model + retryable +├── context_compiler.py # Trim/Dedup/Compress context +└── token_budget.py # Cap/measure/save tokens +``` + +## เริ่มใช้งาน + +```bash +pip install -r requirements.txt +uvicorn app.main:app --reload +# Docs: http://localhost:8000/docs | Health: /health | Metrics: /metrics +``` + +หรือ Docker: `docker compose up --build` + +## Test + +```bash +pytest -q # 31 tests ผ่าน +``` + +## API + +```bash +curl -X POST http://localhost:8000/api/ai \ + -H "Content-Type: application/json" \ + -d '{"prompt":"Explain tokens","model":"gpt-4o","cache":true}' +``` + +ดูรายละเอียด: [TOKEN_OPTIMIZATION.md](TOKEN_OPTIMIZATION.md), [KNOWLEDGE_BASE.md](KNOWLEDGE_BASE.md) + +## หมายเหตุ production + +- ใช้ `MockProvider` เป็นค่าเริ่มต้น — ต่อ OpenAI/Anthropic จริงใน `providers.py` + ตั้ง key ใน `.env` +- Redis fail-open: ถ้า Redis ล่ม → cache miss (ไม่ crash) diff --git a/deliverables/onspace-ai/TOKEN_OPTIMIZATION.md b/deliverables/onspace-ai/TOKEN_OPTIMIZATION.md new file mode 100644 index 0000000..964f2dc --- /dev/null +++ b/deliverables/onspace-ai/TOKEN_OPTIMIZATION.md @@ -0,0 +1,56 @@ +# Token Optimization Guide + +วิธีลดค่าใช้จ่าย/เวลา/rate-limit ของ LLM — เน้น "วัด → จำกัด → บีบอัด → ใช้ซ้ำ" + +## หลักการ + +> Tokens = Money + Latency + Rate Limits +> Prompt ที่ไม่ได้ optimize = 2–5× cost + +## Checklist + +1. **Cache ทุกอย่าง** — prompt เดิม → 0 tokens (Redis key จาก SHA-256) +2. **System prompt สั้น** — ตรงประเด็น ไม่มีน้ำ +3. **Trim history** — เก็บเฉพาะ turns ที่เกี่ยวข้อง (`trim_history`) +4. **ตัด redundancy** — ไม่มีคำสั่งซ้ำ (`deduplicate_messages`) +5. **Reject เร็ว** — payload ใหญ่เกิน → 413 ก่อนเรียก provider (`budget.check`) +6. **Reuse fragments** — cache context ยาวๆ ที่ใช้ซ้ำ + +## Modules + +### Context Compiler (`app/context_compiler.py`) + +| ฟังก์ชัน | หน้าที่ | +|---------|--------| +| `clean_text` | ตัด whitespace + บรรทัดว่างซ้ำ | +| `deduplicate_messages` | ลบข้อความซ้ำติดกัน | +| `trim_history` | เก็บ last N turns | +| `estimate_tokens` | ประมาณ token จาก UTF-8 bytes (~3.5 chars/token) | +| `compile_context` | รวมทั้งหมด + นับ estimated_tokens + flag over_budget | + +### Token Budget (`app/token_budget.py`) + +- `MODEL_LIMITS`: gpt-4o 128k / claude-3-opus 200k / gemini 1M / mini 32k +- `check()`: hard reject เกินก่อนเรียก +- `truncate()`: ตัด message เก่าออกจนไม่เกิน + นับ `TOKEN_SAVED` + +## Metrics + +``` +onspaceai_tokens_total # token ที่ประมวล (ต่อ model) +onspaceai_tokens_saved # token ที่ประหยัดได้จากการ optimize +onspaceai_cache_hits_total # cache hit +``` + +## Example + +```python +from app.context_compiler import compile_context +from app.token_budget import TokenBudget + +ctx = compile_context(sys, messages, max_tokens=1024) # clean/dedup/trim +budget = TokenBudget("gpt-4o") +if not budget.check(ctx["estimated_tokens"]): + raise HTTPException(413) # reject เร็ว +ctx = budget.truncate(ctx) # ถ้ายังเกิน → ตัด +``` diff --git a/deliverables/onspace-ai/app/__init__.py b/deliverables/onspace-ai/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deliverables/onspace-ai/app/cache.py b/deliverables/onspace-ai/app/cache.py new file mode 100644 index 0000000..3d00852 --- /dev/null +++ b/deliverables/onspace-ai/app/cache.py @@ -0,0 +1,72 @@ +"""Cache — Redis + hash key + TTL (fail-open ถ้า Redis ล่ม)""" +from __future__ import annotations + +import hashlib +import json +from typing import Any, Optional + + +def cache_key(*parts: Any) -> str: + """สร้าง deterministic SHA-256 key จากส่วนประกอบ (URL/options)""" + blob = json.dumps(parts, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + return "onspace:" + hashlib.sha256(blob.encode("utf-8")).hexdigest() + + +class RedisCache: + """Cache wrapper — fail-open: ถ้า Redis ล่ม ให้ cache miss (ไม่ crash)""" + + def __init__(self, client: Optional[Any] = None, default_ttl: int = 300) -> None: + self._redis = client + self._default_ttl = default_ttl + + async def get(self, key: str) -> Optional[str]: + if self._redis is None: + return None + try: + val = await self._redis.get(key) + return val.decode() if isinstance(val, bytes) else val + except Exception: + return None # fail-open + + async def set(self, key: str, value: str, ttl: Optional[int] = None) -> None: + if self._redis is None: + return + try: + await self._redis.set(key, value, ex=ttl if ttl is not None else self._default_ttl) + except Exception: + pass # fail-open + + async def delete(self, key: str) -> None: + if self._redis is None: + return + try: + await self._redis.delete(key) + except Exception: + pass # fail-open + + +class MemoryCache: + """Cache หน่วยความจำ (สำหรับทดสอบ / ไม่มี Redis)""" + + def __init__(self, default_ttl: int = 300) -> None: + self._default_ttl = default_ttl + self._store: dict[str, tuple[str, float]] = {} + + async def get(self, key: str) -> Optional[str]: + item = self._store.get(key) + if not item: + return None + value, expire = item + if expire < __import__("time").time(): + self._store.pop(key, None) + return None + return value + + async def set(self, key: str, value: str, ttl: Optional[int] = None) -> None: + import time + + t = ttl if ttl is not None else self._default_ttl + self._store[key] = (value, time.time() + t) + + async def delete(self, key: str) -> None: + self._store.pop(key, None) diff --git a/deliverables/onspace-ai/app/circuit_breaker.py b/deliverables/onspace-ai/app/circuit_breaker.py new file mode 100644 index 0000000..037dfb8 --- /dev/null +++ b/deliverables/onspace-ai/app/circuit_breaker.py @@ -0,0 +1,57 @@ +"""Circuit breaker — Closed / Open / Half-Open""" +from __future__ import annotations + +import time +from typing import Optional + + +class CircuitBreaker: + """Circuit breaker แบบ state machine + + - CLOSED: ปล่อย request ปกติ + - OPEN: เกิน failure threshold → block ทั้งหมด ตาม cooldown + - HALF-OPEN: หลัง cooldown ลอง 1 request — สำเร็จ→CLOSED, ล้ม→OPEN + """ + + def __init__(self, failure_threshold: int = 5, recovery_seconds: float = 30.0) -> None: + self.failure_threshold = max(1, failure_threshold) + self.recovery_seconds = max(0.1, recovery_seconds) + self._failures = 0 + self._state = "CLOSED" + self._opened_at: Optional[float] = None + + @property + def state(self) -> str: + if self._state == "OPEN" and self._opened_at is not None: + if time.monotonic() - self._opened_at >= self.recovery_seconds: + self._state = "HALF_OPEN" + return self._state + + def allow_request(self) -> bool: + s = self.state + if s == "CLOSED": + return True + if s == "OPEN": + return False + # HALF_OPEN — ปล่อย 1 request ครั้งเดียวต่อรอบ + return True + + def record_success(self) -> None: + self._failures = 0 + self._opened_at = None + self._state = "CLOSED" + + def record_failure(self) -> None: + if self.state == "HALF_OPEN": + self._open() + return + self._failures += 1 + if self._failures >= self.failure_threshold: + self._open() + + def _open(self) -> None: + self._state = "OPEN" + self._opened_at = time.monotonic() + + def __repr__(self) -> str: # pragma: no cover + return f"" diff --git a/deliverables/onspace-ai/app/config.py b/deliverables/onspace-ai/app/config.py new file mode 100644 index 0000000..31b7cf5 --- /dev/null +++ b/deliverables/onspace-ai/app/config.py @@ -0,0 +1,35 @@ +"""การตั้งค่า OnSpaceAI — โหลดจาก .env ด้วย pydantic-settings""" +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + app_name: str = "OnSpaceAI" + app_env: str = "development" + + # Redis + redis_url: str = "redis://localhost:6379/0" + cache_ttl: int = 300 + + # Circuit breaker + circuit_failure_threshold: int = 5 + circuit_recovery_seconds: float = 30.0 + + # Token budget + token_max: int = 128000 + model_default: str = "gpt-4o" + + # Provider API keys (อ่านจาก env — ไม่ hardcode) + openai_api_key: str = "" + anthropic_api_key: str = "" + + # Auth + api_key: str = "" + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/deliverables/onspace-ai/app/context_compiler.py b/deliverables/onspace-ai/app/context_compiler.py new file mode 100644 index 0000000..f2d43e1 --- /dev/null +++ b/deliverables/onspace-ai/app/context_compiler.py @@ -0,0 +1,59 @@ +"""Context Compiler — trim/dedup/compress context ก่อนส่ง provider""" +from __future__ import annotations + +import re +from typing import Dict, List + +# ประมาณ tokens: ~4 ตัวอักษร/โทเคน (ภาษาไทย/ผสมระวัง แต่ใช้เป็น estimate พอ) +CHARS_PER_TOKEN = 3.5 + + +def clean_text(text: str) -> str: + """Trim whitespace + ลบบรรทัดว่างซ้ำซ้อน""" + lines = [re.sub(r"[ \t]+", " ", ln).strip() for ln in text.split("\n")] + # ตัดบรรทัดว่างออกทั้งหมด (ยุบ \n ซ้ำเป็นบรรทัดเดียว) + return "\n".join(ln for ln in lines if ln) + + +def deduplicate_messages(messages: List[Dict]) -> List[Dict]: + """ลบข้อความซ้ำติดกัน (เก็บเฉพาะ instance แรกของ sequence)""" + if not messages: + return [] + out = [messages[0]] + for m in messages[1:]: + if (m.get("role"), m.get("content")) != (out[-1].get("role"), out[-1].get("content")): + out.append(m) + return out + + +def estimate_tokens(text: str) -> int: + """ประมาณ token จากจำนวน byte (UTF-8) — ใช้เป็น estimate เร็ว""" + if not text: + return 0 + return int(len(text.encode("utf-8")) / CHARS_PER_TOKEN) + 1 + + +def trim_history(messages: List[Dict], max_turns: int) -> List[Dict]: + """เก็บเฉพาะ last N turns (ตัดหัวทิ้งก่อน)""" + if max_turns <= 0 or len(messages) <= max_turns: + return messages + # เก็บ system (ถ้ามี role system แรก) + last N turns + sys_msgs = [m for m in messages if m.get("role") == "system"] + rest = [m for m in messages if m.get("role") != "system"] + return sys_msgs + rest[-max_turns:] + + +def compile_context(system: str, messages: List[Dict], max_tokens: int, max_turns: int = 20) -> Dict: + """รวม system + messages → clean/dedup/trim พร้อมนับ estimated_tokens""" + sys_clean = clean_text(system) + msgs = [{"role": m.get("role", "user"), "content": clean_text(m.get("content", ""))} for m in messages] + msgs = deduplicate_messages(msgs) + msgs = trim_history(msgs, max_turns) + + total = estimate_tokens(sys_clean) + sum(estimate_tokens(m["content"]) for m in msgs) + return { + "system": sys_clean, + "messages": msgs, + "estimated_tokens": total, + "over_budget": total > max_tokens, + } diff --git a/deliverables/onspace-ai/app/fallback_router.py b/deliverables/onspace-ai/app/fallback_router.py new file mode 100644 index 0000000..6ea87d3 --- /dev/null +++ b/deliverables/onspace-ai/app/fallback_router.py @@ -0,0 +1,50 @@ +"""Fallback router — Primary → Secondary → Degraded""" +from __future__ import annotations + +import logging +from typing import Dict, List, Optional + +from app import metrics +from app.circuit_breaker import CircuitBreaker +from app.providers import BaseProvider, ProviderError, ProviderResult + +logger = logging.getLogger("fallback") + + +class FallbackRouter: + """ลอง provider ตามลำดับ; ล้ม→ตัวถัดไป; ล้มหมด→Degraded + + - Skip provider ที่ circuit breaker เปิด + - Retryable error → ลองตัวถัดไป + - Non-retryable (400/401/422) → หยุดทันที (ไม่เสียค่าใช้จ่าย) + """ + + def __init__(self, providers: List[BaseProvider], circuit: CircuitBreaker) -> None: + self._providers = providers + self._circuit = circuit + + async def route(self, system: str, messages: List[Dict], model: str, max_tokens: int) -> Optional[ProviderResult]: + # circuit เปิดทั้งระบบ → degraded + if not self._circuit.allow_request() and all(not self._circuit.allow_request() for _ in self._providers): + return None + + for i, provider in enumerate(self._providers): + if not self._circuit.allow_request(): + continue # ข้าม provider ที่ circuit เปิด + try: + result = await provider.complete(system, messages, model, max_tokens) + self._circuit.record_success() + if i > 0: + metrics.FALLBACK_TOTAL.labels(self._providers[0].name, provider.name).inc() + return result + except ProviderError as exc: + logger.warning("provider %s failed: %s", provider.name, exc) + self._circuit.record_failure() + if not exc.retryable: + return None # non-retryable — ไม่ลอง provider อื่น (blueprint: ไม่เสียค่าใช้จ่าย) + # retryable → ลองตัวถัดไป + continue + + # หมดทุกตัว → degraded + metrics.DEGRADED_TOTAL.inc() + return None diff --git a/deliverables/onspace-ai/app/main.py b/deliverables/onspace-ai/app/main.py new file mode 100644 index 0000000..3bb7e92 --- /dev/null +++ b/deliverables/onspace-ai/app/main.py @@ -0,0 +1,77 @@ +"""OnSpaceAI main — FastAPI app รวมทุก layer""" +from __future__ import annotations + +from fastapi import FastAPI, HTTPException +from prometheus_client import make_asgi_app +from pydantic import BaseModel + +from app import metrics +from app.cache import MemoryCache +from app.circuit_breaker import CircuitBreaker +from app.config import get_settings +from app.context_compiler import compile_context +from app.fallback_router import FallbackRouter +from app.middleware import AuthMiddleware, RequestContextMiddleware +from app.providers import MockProvider +from app.token_budget import TokenBudget + + +class AIRequest(BaseModel): + prompt: str + system: str = "" + model: str = "gpt-4o" + cache: bool = True + max_tokens: int = 1024 + + +_settings = get_settings() +_cache = MemoryCache(default_ttl=_settings.cache_ttl) +_circuit = CircuitBreaker(_settings.circuit_failure_threshold, _settings.circuit_recovery_seconds) +_router = FallbackRouter( + [MockProvider(fail_status=None), MockProvider(fail_status=None)], + _circuit, +) + + +def create_app() -> FastAPI: + settings = get_settings() + app = FastAPI(title=settings.app_name, version="1.0.0", docs_url="/docs") + app.add_middleware(RequestContextMiddleware) + app.add_middleware(AuthMiddleware) + + @app.get("/health") + async def health(): + return {"ok": True, "app": settings.app_name, "env": settings.app_env} + + @app.post("/api/ai") + async def ai(req: AIRequest): + from app.cache import cache_key + + budget = TokenBudget(req.model) + context = compile_context(req.system, [{"role": "user", "content": req.prompt}], req.max_tokens) + + if not budget.check(context["estimated_tokens"]): + raise HTTPException(status_code=413, detail=f"payload too large: {budget.reject_reason(context['estimated_tokens'])}") + + ck = cache_key(req.prompt, req.model, req.max_tokens) if req.cache else None + if ck: + cached = await _cache.get(ck) + if cached: + metrics.CACHE_HITS.inc() + return {"ok": True, "cached": True, "content": cached} + + metrics.CACHE_MISSES.inc() + + result = await _router.route(context["system"], context["messages"], req.model, req.max_tokens) + if result is None: + raise HTTPException(status_code=503, detail="all providers unavailable (degraded)") + + if ck: + await _cache.set(ck, result.content) + return {"ok": True, "cached": False, "content": result.content, "model": result.model} + + app.mount("/metrics", make_asgi_app()) + return app + + +app = create_app() diff --git a/deliverables/onspace-ai/app/metrics.py b/deliverables/onspace-ai/app/metrics.py new file mode 100644 index 0000000..0fd67de --- /dev/null +++ b/deliverables/onspace-ai/app/metrics.py @@ -0,0 +1,11 @@ +"""Prometheus metrics — ครอบคลุม request/latency/cache/fallback/token""" +from prometheus_client import Counter, Histogram + +API_REQUESTS = Counter("onspaceai_api_requests_total", "Total API requests", ["endpoint", "status"]) +CACHE_HITS = Counter("onspaceai_cache_hits_total", "Cache hits") +CACHE_MISSES = Counter("onspaceai_cache_misses_total", "Cache misses") +FALLBACK_TOTAL = Counter("onspaceai_fallback_total", "Fallback events", ["from_provider", "to_provider"]) +DEGRADED_TOTAL = Counter("onspaceai_degraded_responses_total", "Degraded/stale responses") +LATENCY = Histogram("onspaceai_request_latency_seconds", "Request latency", ["endpoint"]) +TOKEN_TOTAL = Counter("onspaceai_tokens_total", "Total tokens processed", ["model"]) +TOKEN_SAVED = Counter("onspaceai_tokens_saved", "Tokens saved by optimization") diff --git a/deliverables/onspace-ai/app/middleware.py b/deliverables/onspace-ai/app/middleware.py new file mode 100644 index 0000000..552899d --- /dev/null +++ b/deliverables/onspace-ai/app/middleware.py @@ -0,0 +1,45 @@ +"""Middleware — auth + request-id + error format""" +from __future__ import annotations + +import time +import uuid + +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.responses import JSONResponse + +from app import metrics +from app.config import get_settings + + +class RequestContextMiddleware(BaseHTTPMiddleware): + """เพิ่ม X-Request-ID + วัด latency + นับ request""" + + async def dispatch(self, request: Request, call_next): + rid = request.headers.get("x-request-id") or str(uuid.uuid4()) + start = time.monotonic() + try: + response = await call_next(request) + except Exception as exc: # noqa: BLE001 + metrics.API_REQUESTS.labels(request.url.path, "500").inc() + return JSONResponse(status_code=500, content={"ok": False, "error": {"code": "internal", "message": str(exc)}}) + latency = time.monotonic() - start + metrics.LATENCY.labels(request.url.path).observe(latency) + metrics.API_REQUESTS.labels(request.url.path, str(response.status_code)).inc() + response.headers["X-Request-ID"] = rid + return response + + +class AuthMiddleware(BaseHTTPMiddleware): + """ตรวจ X-API-Key — ข้าม /health และ /metrics""" + + async def dispatch(self, request: Request, call_next): + path = request.url.path + if path in ("/health", "/metrics", "/docs", "/openapi.json", "/redoc"): + return await call_next(request) + settings = get_settings() + if settings.api_key: + key = request.headers.get("x-api-key", "") + if key != settings.api_key: + return JSONResponse(status_code=401, content={"ok": False, "error": {"code": "unauthorized", "message": "invalid api key"}}) + return await call_next(request) diff --git a/deliverables/onspace-ai/app/providers.py b/deliverables/onspace-ai/app/providers.py new file mode 100644 index 0000000..fd3a4ca --- /dev/null +++ b/deliverables/onspace-ai/app/providers.py @@ -0,0 +1,73 @@ +"""Provider abstraction — contract + error model + retryable status""" +from __future__ import annotations + +import abc +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +RETRYABLE_STATUS = {408, 429, 502, 503, 504} +NON_RETRYABLE_STATUS = {400, 401, 403, 422} + + +@dataclass +class ProviderError(Exception): + """Error จาก provider พร้อมสถานะ HTTP ที่จำลอง""" + + status: int = 500 + message: str = "provider error" + retryable: bool = False + + def __post_init__(self) -> None: + if self.status in RETRYABLE_STATUS: + self.retryable = True + + +@dataclass +class ProviderResult: + content: str + model: str + tokens_used: int = 0 + meta: Dict[str, Any] = field(default_factory=dict) + + +class BaseProvider(abc.ABC): + """Contract ของ provider ทุกตัว""" + + name: str = "base" + + @abc.abstractmethod + async def complete(self, system: str, messages: List[Dict], model: str, max_tokens: int) -> ProviderResult: + """เรียก LLM — ต้อง raise ProviderError เมื่อ fail""" + raise NotImplementedError + + +class MockProvider(BaseProvider): + """Provider จำลองสำหรับทดสอบ/local — ใช้เมื่อไม่มี key""" + + name = "mock" + + def __init__(self, fail_status: Optional[int] = None, echo: bool = True) -> None: + self._fail_status = fail_status + self._echo = echo + + async def complete(self, system: str, messages: List[Dict], model: str, max_tokens: int) -> ProviderResult: + if self._fail_status is not None: + raise ProviderError(status=self._fail_status, message=f"mock fail {self._fail_status}") + last = messages[-1]["content"] if messages else "" + content = f"[{self.name}:{model}] {last[:max_tokens]}" if self._echo else "" + return ProviderResult(content=content, model=model, tokens_used=10) + + +class OpenAIProvider(BaseProvider): + """OpenAI — lazy import SDK เพื่อให้ import module ไม่พึ่ง key""" + + name = "openai" + + async def complete(self, system: str, messages: List[Dict], model: str, max_tokens: int) -> ProviderResult: + from app.config import get_settings + + key = get_settings().openai_api_key + if not key: + raise ProviderError(status=401, message="OPENAI_API_KEY not set") + # TODO: ต่อ OpenAI SDK จริง — คง interface ไว้ให้ใช้งาน production + raise ProviderError(status=501, message="OpenAI provider not wired (mock only in this build)") diff --git a/deliverables/onspace-ai/app/token_budget.py b/deliverables/onspace-ai/app/token_budget.py new file mode 100644 index 0000000..a0ff560 --- /dev/null +++ b/deliverables/onspace-ai/app/token_budget.py @@ -0,0 +1,49 @@ +"""Token Budget — hard cap / soft truncate / usage metrics""" +from __future__ import annotations + +import logging +from typing import Dict + +from app import metrics +from app.context_compiler import estimate_tokens + +logger = logging.getLogger("token_budget") + +MODEL_LIMITS = { + "default": 128000, + "gpt-4o": 128000, + "claude-3-opus": 200000, + "gemini": 1000000, + "mini": 32000, +} + + +class TokenBudget: + """บังคับวงเงิน token ต่อ model + กัน payload เกิน""" + + def __init__(self, model: str = "default") -> None: + self.model = model + self.max = MODEL_LIMITS.get(model, MODEL_LIMITS["default"]) + + def check(self, estimated: int) -> bool: + """Hard check: เกิน → reject เร็ว (ไม่เรียก provider)""" + return estimated <= self.max + + def truncate(self, context: Dict) -> Dict: + """Soft: ตัด message เก่าออกทีละตัวจนไม่เกิน budget""" + ctx = {**context, "messages": list(context["messages"]), "system": context["system"]} + while ctx["estimated_tokens"] > self.max and ctx["messages"]: + removed = ctx["messages"].pop(0) + # เก็บเฉพาะ tokens ที่ตัดจริง (ถ้าเกิน budget) + over = ctx["estimated_tokens"] - self.max + saved = min(over, estimate_tokens(removed.get("content", ""))) + if saved > 0: + metrics.TOKEN_SAVED.inc(saved) + ctx["estimated_tokens"] = estimate_tokens(ctx["system"]) + sum( + estimate_tokens(m.get("content", "")) for m in ctx["messages"] + ) + metrics.TOKEN_TOTAL.labels(self.model).inc(ctx["estimated_tokens"]) + return ctx + + def reject_reason(self, estimated: int) -> str: + return f"estimated {estimated} exceeds model limit {self.max}" diff --git a/deliverables/onspace-ai/docker-compose.yml b/deliverables/onspace-ai/docker-compose.yml new file mode 100644 index 0000000..aefcf51 --- /dev/null +++ b/deliverables/onspace-ai/docker-compose.yml @@ -0,0 +1,34 @@ +services: + api: + build: . + container_name: onspace-ai + restart: unless-stopped + ports: + - "8000:8000" + env_file: .env + depends_on: + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/health',timeout=3)"] + interval: 30s + timeout: 3s + retries: 3 + + redis: + image: redis:7-alpine + container_name: onspace-redis + restart: unless-stopped + command: redis-server --appendonly yes + ports: + - "6379:6379" + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + +volumes: + redis-data: diff --git a/deliverables/onspace-ai/k8s/deployment.yaml b/deliverables/onspace-ai/k8s/deployment.yaml new file mode 100644 index 0000000..255cc8c --- /dev/null +++ b/deliverables/onspace-ai/k8s/deployment.yaml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: onspace-ai + namespace: onspaceai +spec: + replicas: 2 + selector: + matchLabels: { app: onspace-ai } + template: + metadata: + labels: { app: onspace-ai } + spec: + containers: + - name: api + image: onspace-ai:latest + ports: [{ containerPort: 8000 }] + envFrom: [{ secretRef: { name: onspace-env } }] + resources: + requests: { cpu: "100m", memory: "128Mi" } + limits: { cpu: "500m", memory: "512Mi" } + readinessProbe: { httpGet: { path: /health, port: 8000 }, initialDelaySeconds: 5 } + livenessProbe: { httpGet: { path: /health, port: 8000 }, initialDelaySeconds: 10 } diff --git a/deliverables/onspace-ai/k8s/hpa.yaml b/deliverables/onspace-ai/k8s/hpa.yaml new file mode 100644 index 0000000..3798bd8 --- /dev/null +++ b/deliverables/onspace-ai/k8s/hpa.yaml @@ -0,0 +1,17 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: onspace-ai + namespace: onspaceai +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: onspace-ai + minReplicas: 2 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: { type: Utilization, averageUtilization: 70 } diff --git a/deliverables/onspace-ai/k8s/redis-sts.yaml b/deliverables/onspace-ai/k8s/redis-sts.yaml new file mode 100644 index 0000000..4e3c0e9 --- /dev/null +++ b/deliverables/onspace-ai/k8s/redis-sts.yaml @@ -0,0 +1,26 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: onspace-redis + namespace: onspaceai +spec: + serviceName: onspace-redis + replicas: 1 + selector: + matchLabels: { app: onspace-redis } + template: + metadata: + labels: { app: onspace-redis } + spec: + containers: + - name: redis + image: redis:7-alpine + command: ["redis-server", "--appendonly", "yes"] + ports: [{ containerPort: 6379 }] + volumeMounts: [{ name: redis-data, mountPath: /data }] + volumeClaimTemplates: + - metadata: { name: redis-data } + spec: + accessModes: ["ReadWriteOnce"] + resources: + requests: { storage: 1Gi } diff --git a/deliverables/onspace-ai/k8s/service.yaml b/deliverables/onspace-ai/k8s/service.yaml new file mode 100644 index 0000000..da10f1d --- /dev/null +++ b/deliverables/onspace-ai/k8s/service.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Service +metadata: + name: onspace-ai + namespace: onspaceai +spec: + selector: { app: onspace-ai } + ports: [{ port: 80, targetPort: 8000 }] diff --git a/deliverables/onspace-ai/pytest.ini b/deliverables/onspace-ai/pytest.ini new file mode 100644 index 0000000..78c5011 --- /dev/null +++ b/deliverables/onspace-ai/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +testpaths = tests diff --git a/deliverables/onspace-ai/requirements.txt b/deliverables/onspace-ai/requirements.txt new file mode 100644 index 0000000..5dffc04 --- /dev/null +++ b/deliverables/onspace-ai/requirements.txt @@ -0,0 +1,6 @@ +fastapi>=0.115,<1.0 +uvicorn[standard]>=0.30 +pydantic>=2.7 +pydantic-settings>=2.3 +redis>=5.0 +prometheus-client>=0.20 diff --git a/deliverables/onspace-ai/tests/__init__.py b/deliverables/onspace-ai/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/deliverables/onspace-ai/tests/conftest.py b/deliverables/onspace-ai/tests/conftest.py new file mode 100644 index 0000000..5f8ec0a --- /dev/null +++ b/deliverables/onspace-ai/tests/conftest.py @@ -0,0 +1,6 @@ +"""Fixtures ร่วมสำหรับทุก test""" +import os +import sys + +# ให้ import `app` ได้จากรากโปรเจกต์ +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) diff --git a/deliverables/onspace-ai/tests/test_api.py b/deliverables/onspace-ai/tests/test_api.py new file mode 100644 index 0000000..e6d5578 --- /dev/null +++ b/deliverables/onspace-ai/tests/test_api.py @@ -0,0 +1,74 @@ +"""Tests: FastAPI endpoints (health + /api/ai + cache + degraded)""" +import pytest +import pytest_asyncio +from httpx import ASGITransport, AsyncClient + +from app.main import app + + +@pytest_asyncio.fixture +async def client(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test", follow_redirects=True) as c: + yield c + + +@pytest.mark.asyncio +async def test_health(client): + r = await client.get("/health") + assert r.status_code == 200 + body = r.json() + assert body["ok"] is True + assert "onspace" in body["app"].lower() + + +@pytest.mark.asyncio +async def test_ai_endpoint_success(client): + r = await client.post("/api/ai", json={"prompt": "Explain tokens", "model": "gpt-4o"}) + assert r.status_code == 200 + body = r.json() + assert body["ok"] is True + assert body["cached"] is False + assert "content" in body + assert "x-request-id" in r.headers + + +@pytest.mark.asyncio +async def test_ai_endpoint_cache_hit(client): + # เรียกซ้ำ prompt เดียว → cache hit + payload = {"prompt": "cache me please", "model": "gpt-4o", "cache": True} + r1 = await client.post("/api/ai", json=payload) + assert r1.status_code == 200 + r2 = await client.post("/api/ai", json=payload) + assert r2.status_code == 200 + assert r2.json()["cached"] is True + + +@pytest.mark.asyncio +async def test_metrics_exposed(client): + r = await client.get("/metrics") + assert r.status_code == 200 + assert "onspaceai_" in r.text + + +@pytest.mark.asyncio +async def test_auth_required_when_key_set(client, monkeypatch): + from app.config import get_settings + + s = get_settings() + monkeypatch.setattr(s, "api_key", "secret123") + # ไม่มี key → 401 + r = await client.post("/api/ai", json={"prompt": "x"}) + assert r.status_code == 401 + # มี key ถูก → ผ่าน + r2 = await client.post("/api/ai", json={"prompt": "x"}, headers={"x-api-key": "secret123"}) + assert r2.status_code == 200 + monkeypatch.setattr(s, "api_key", "") + + +@pytest.mark.asyncio +async def test_payload_too_large(client): + # model mini มี budget 32k — ส่ง payload ใหญ่เกิน + big = "x" * 200000 + r = await client.post("/api/ai", json={"prompt": big, "model": "mini"}) + assert r.status_code == 413 diff --git a/deliverables/onspace-ai/tests/test_cache.py b/deliverables/onspace-ai/tests/test_cache.py new file mode 100644 index 0000000..ee57fe7 --- /dev/null +++ b/deliverables/onspace-ai/tests/test_cache.py @@ -0,0 +1,45 @@ +"""Tests: cache key + memory cache""" +import pytest + +from app.cache import MemoryCache, RedisCache, cache_key + + +def test_cache_key_deterministic(): + a = cache_key("https://x.com", {"fmt": "json"}) + b = cache_key("https://x.com", {"fmt": "json"}) + assert a == b + c = cache_key("https://x.com", {"fmt": "md"}) + assert a != c + assert a.startswith("onspace:") + + +def test_cache_key_sorts_keys(): + assert cache_key("a", {"x": 1, "y": 2}) == cache_key("a", {"y": 2, "x": 1}) + + +@pytest.mark.asyncio +async def test_memory_cache_set_get_delete(): + c = MemoryCache(default_ttl=60) + assert await c.get("k") is None # miss + await c.set("k", "v") + assert await c.get("k") == "v" + await c.delete("k") + assert await c.get("k") is None + + +@pytest.mark.asyncio +async def test_memory_cache_expiry(): + c = MemoryCache(default_ttl=0) # หมดอายุทันที + await c.set("k", "v") + import time + time.sleep(0.01) + assert await c.get("k") is None + + +@pytest.mark.asyncio +async def test_redis_cache_fail_open_no_client(): + # RedisCache ไม่มี client → get/set เป็น no-op ไม่ crash + rc = RedisCache(client=None, default_ttl=60) + assert await rc.get("k") is None + await rc.set("k", "v") # ไม่ raise + await rc.delete("k") diff --git a/deliverables/onspace-ai/tests/test_circuit_breaker.py b/deliverables/onspace-ai/tests/test_circuit_breaker.py new file mode 100644 index 0000000..e861435 --- /dev/null +++ b/deliverables/onspace-ai/tests/test_circuit_breaker.py @@ -0,0 +1,50 @@ +"""Tests: circuit breaker state machine""" +from app.circuit_breaker import CircuitBreaker + + +def test_closed_allows(): + cb = CircuitBreaker(failure_threshold=3, recovery_seconds=30) + assert cb.state == "CLOSED" + assert cb.allow_request() is True + + +def test_opens_after_threshold(): + cb = CircuitBreaker(failure_threshold=3, recovery_seconds=30) + cb.record_failure() + cb.record_failure() + assert cb.state == "CLOSED" + cb.record_failure() + assert cb.state == "OPEN" + assert cb.allow_request() is False + + +def test_success_resets_failures(): + cb = CircuitBreaker(failure_threshold=3, recovery_seconds=30) + cb.record_failure() + cb.record_failure() + cb.record_success() + cb.record_failure() + assert cb.state == "CLOSED" + + +def test_half_open_recovers_after_cooldown(): + cb = CircuitBreaker(failure_threshold=2, recovery_seconds=0.05) + cb.record_failure() + cb.record_failure() + assert cb.state == "OPEN" + # หลัง cooldown → HALF_OPEN ปล่อย 1 request + import time + time.sleep(0.1) + assert cb.state == "HALF_OPEN" + assert cb.allow_request() is True + + +def test_half_open_failure_reopens(): + cb = CircuitBreaker(failure_threshold=1, recovery_seconds=0.05) + cb.record_failure() + assert cb.state == "OPEN" + import time + time.sleep(0.1) + cb.record_failure() # ใน HALF_OPEN ล้ม → OPEN อีก + assert cb.state == "OPEN" + assert cb.allow_request() is False diff --git a/deliverables/onspace-ai/tests/test_context_compiler.py b/deliverables/onspace-ai/tests/test_context_compiler.py new file mode 100644 index 0000000..6987861 --- /dev/null +++ b/deliverables/onspace-ai/tests/test_context_compiler.py @@ -0,0 +1,48 @@ +"""Tests: context compiler""" +from app.context_compiler import ( + clean_text, + compile_context, + deduplicate_messages, + estimate_tokens, + trim_history, +) + + +def test_clean_text_collapses_whitespace(): + assert clean_text(" a\n\n\n b ") == "a\nb" + + +def test_estimate_tokens(): + assert estimate_tokens("") == 0 + assert estimate_tokens("hello world") > 0 + + +def test_deduplicate_consecutive_only(): + msgs = [ + {"role": "user", "content": "a"}, + {"role": "user", "content": "a"}, # ซ้ำติดกัน → ตัด + {"role": "assistant", "content": "b"}, + {"role": "user", "content": "a"}, # ไม่ติดกัน → เก็บ + ] + out = deduplicate_messages(msgs) + assert len(out) == 3 + + +def test_trim_history_keeps_last_n(): + msgs = [{"role": "user", "content": f"m{i}"} for i in range(10)] + out = trim_history(msgs, max_turns=3) + assert len(out) == 3 + assert out[-1]["content"] == "m9" + + +def test_compile_context_counts_tokens(): + ctx = compile_context("sys", [{"role": "user", "content": "hello"}], max_tokens=100) + assert ctx["estimated_tokens"] > 0 + assert ctx["over_budget"] is False + assert ctx["system"] == "sys" + + +def test_compile_context_over_budget_flag(): + big = "x" * 5000 + ctx = compile_context(big, [{"role": "user", "content": "y" * 5000}], max_tokens=10) + assert ctx["over_budget"] is True diff --git a/deliverables/onspace-ai/tests/test_fallback_router.py b/deliverables/onspace-ai/tests/test_fallback_router.py new file mode 100644 index 0000000..d09ef50 --- /dev/null +++ b/deliverables/onspace-ai/tests/test_fallback_router.py @@ -0,0 +1,51 @@ +"""Tests: fallback router""" +import pytest + +from app.circuit_breaker import CircuitBreaker +from app.fallback_router import FallbackRouter +from app.providers import MockProvider + + +@pytest.mark.asyncio +async def test_primary_succeeds_no_fallback(): + router = FallbackRouter([MockProvider(), MockProvider()], CircuitBreaker()) + r = await router.route("sys", [{"role": "user", "content": "hi"}], "gpt-4o", 100) + assert r is not None + assert "[mock:gpt-4o]" in r.content # primary ตอบ + + +@pytest.mark.asyncio +async def test_fallback_on_retryable(): + # primary ล้ม (503 retryable) → secondary สำเร็จ + router = FallbackRouter( + [MockProvider(fail_status=503), MockProvider()], CircuitBreaker() + ) + r = await router.route("sys", [{"role": "user", "content": "hi"}], "gpt-4o", 100) + assert r is not None + assert "[mock:gpt-4o]" in r.content + + +@pytest.mark.asyncio +async def test_all_fail_returns_none_degraded(): + router = FallbackRouter( + [MockProvider(fail_status=503), MockProvider(fail_status=503)], CircuitBreaker() + ) + r = await router.route("sys", [{"role": "user", "content": "hi"}], "gpt-4o", 100) + assert r is None # degraded + + +@pytest.mark.asyncio +async def test_non_retryable_stops_no_waste(): + # 400 non-retryable → หยุดทันที ไม่ลอง secondary (ประหยัด cost) + calls = [] + class Counting(MockProvider): + async def complete(self, system, messages, model, max_tokens): + calls.append(1) + return await super().complete(system, messages, model, max_tokens) + + router = FallbackRouter( + [MockProvider(fail_status=400), Counting()], CircuitBreaker() + ) + r = await router.route("sys", [{"role": "user", "content": "hi"}], "gpt-4o", 100) + assert r is None + assert len(calls) == 0 # secondary ไม่ถูกเรียก diff --git a/deliverables/onspace-ai/tests/test_token_budget.py b/deliverables/onspace-ai/tests/test_token_budget.py new file mode 100644 index 0000000..7124065 --- /dev/null +++ b/deliverables/onspace-ai/tests/test_token_budget.py @@ -0,0 +1,38 @@ +"""Tests: token budget""" +from app.context_compiler import compile_context +from app.token_budget import TokenBudget, MODEL_LIMITS + + +def test_model_limits_default(): + assert MODEL_LIMITS["default"] == 128000 + assert TokenBudget("gpt-4o").max == 128000 + assert TokenBudget("claude-3-opus").max == 200000 + + +def test_check_within_budget(): + b = TokenBudget("gpt-4o") + assert b.check(1000) is True + + +def test_check_over_budget(): + b = TokenBudget("gpt-4o") + assert b.check(200000) is False + + +def test_truncate_drops_old_messages(): + b = TokenBudget("mini") # 32k limit + # เนื้อหายาวมาก + แต่ละข้อความต่างกัน (กัน dedup ยุบรวม) + msgs = [ + {"role": "user", "content": "a" * 100000}, + {"role": "user", "content": "b" * 100000}, + {"role": "user", "content": "c" * 100000}, + ] + ctx = compile_context("sys", msgs, max_tokens=1000000) + assert ctx["estimated_tokens"] > b.max # เกิน + out = b.truncate(ctx) + assert out["estimated_tokens"] <= b.max + + +def test_reject_reason_message(): + b = TokenBudget("mini") + assert "exceeds" in b.reject_reason(50000)