| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154 |
- from __future__ import annotations
- import asyncio
- from contextlib import asynccontextmanager, suppress
- from pathlib import Path
- from fastapi import FastAPI, HTTPException
- from fastapi.responses import HTMLResponse, JSONResponse
- from pydantic import BaseModel
- from starlette.routing import Router
- from .cache import SnapshotCache
- from .config import OpenClawConfigRepository
- from .providers import ProviderAdapter
- from .service import ModelDashboardService
- from .settings import AppSettings
- from .ui import render_page
- def _patch_router_startup_compatibility() -> None:
- """Bridge older FastAPI startup arguments to newer Starlette routers.
- The environment ships a Starlette build that no longer accepts the legacy
- ``on_startup`` and ``on_shutdown`` keyword arguments FastAPI still sends
- during app construction. Keep the wrapper narrow: ignore those legacy
- hooks because this app uses lifespan management instead.
- """
- original_init = Router.__init__
- if getattr(original_init, "__model_selector_patched__", False):
- return
- def patched_init(self, *args, on_startup=None, on_shutdown=None, **kwargs):
- return original_init(self, *args, **kwargs)
- patched_init.__model_selector_patched__ = True # type: ignore[attr-defined]
- Router.__init__ = patched_init # type: ignore[assignment]
- _patch_router_startup_compatibility()
- class AliasUpdate(BaseModel):
- full_key: str
- alias: str | None = None
- class ModelDeleteRequest(BaseModel):
- full_key: str
- class ModelAddRequest(BaseModel):
- provider_key: str
- model_id: str
- def create_app(
- settings: AppSettings | None = None,
- config_path: Path | None = None,
- cache_dir: Path | None = None,
- adapters: dict[str, ProviderAdapter] | None = None,
- ) -> FastAPI:
- settings = settings or AppSettings.from_env()
- config_repo = OpenClawConfigRepository(config_path or settings.config_path)
- cache = SnapshotCache(cache_dir or settings.cache_dir)
- service = ModelDashboardService(config_repo, cache, adapters=adapters)
- @asynccontextmanager
- async def lifespan(app: FastAPI):
- stop_event = asyncio.Event()
- await service.refresh_all()
- refresh_task = asyncio.create_task(
- service.refresh_periodically(stop_event, settings.refresh_interval_seconds)
- )
- app.state.service = service
- app.state.refresh_stop_event = stop_event
- app.state.refresh_task = refresh_task
- try:
- yield
- finally:
- stop_event.set()
- refresh_task.cancel()
- with suppress(asyncio.CancelledError):
- await refresh_task
- app = FastAPI(title="Model Selector", lifespan=lifespan)
- app.state.service = service
- app.state.refresh_stop_event = None
- app.state.refresh_task = None
- @app.get("/", response_class=HTMLResponse)
- async def index() -> HTMLResponse:
- return HTMLResponse(render_page(service.state_payload()))
- @app.get("/api/state")
- async def state() -> JSONResponse:
- return JSONResponse(service.state_payload())
- @app.get("/api/config")
- async def config() -> JSONResponse:
- return JSONResponse(
- {
- "config_path": str(config_repo.config_path),
- "configured_models": service.configured_models(),
- }
- )
- @app.post("/api/alias")
- async def alias(update: AliasUpdate) -> JSONResponse:
- if "/" not in update.full_key:
- raise HTTPException(status_code=400, detail="full_key must be provider/model")
- updated = service.update_alias(update.full_key, update.alias)
- return JSONResponse({"ok": True, "config": updated})
- @app.post("/api/config/models/delete")
- async def delete_model(request: ModelDeleteRequest) -> JSONResponse:
- try:
- updated = service.delete_configured_model(request.full_key)
- except ValueError as exc:
- raise HTTPException(status_code=400, detail=str(exc)) from exc
- return JSONResponse({"ok": True, "config": updated})
- @app.post("/api/config/models/add")
- async def add_model(request: ModelAddRequest) -> JSONResponse:
- try:
- updated = await service.add_provider_model(request.provider_key, request.model_id)
- except ValueError as exc:
- raise HTTPException(status_code=404, detail=str(exc)) from exc
- return JSONResponse({"ok": True, "config": updated})
- @app.post("/api/refresh")
- async def refresh() -> JSONResponse:
- states = await service.refresh_all()
- return JSONResponse(
- {
- "ok": True,
- "providers": {key: service._state_to_payload(value) for key, value in states.items()},
- }
- )
- return app
- app = create_app()
- def main() -> None:
- import uvicorn
- uvicorn.run("model_selector.main:app", host="0.0.0.0", port=3300, reload=False)
- if __name__ == "__main__":
- main()
|