main.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. from __future__ import annotations
  2. import asyncio
  3. from contextlib import asynccontextmanager, suppress
  4. from pathlib import Path
  5. from fastapi import FastAPI, HTTPException
  6. from fastapi.responses import HTMLResponse, JSONResponse
  7. from pydantic import BaseModel
  8. from starlette.routing import Router
  9. from .cache import SnapshotCache
  10. from .config import OpenClawConfigRepository
  11. from .providers import ProviderAdapter
  12. from .service import ModelDashboardService
  13. from .settings import AppSettings
  14. from .ui import render_page
  15. def _patch_router_startup_compatibility() -> None:
  16. """Bridge older FastAPI startup arguments to newer Starlette routers.
  17. The environment ships a Starlette build that no longer accepts the legacy
  18. ``on_startup`` and ``on_shutdown`` keyword arguments FastAPI still sends
  19. during app construction. Keep the wrapper narrow: ignore those legacy
  20. hooks because this app uses lifespan management instead.
  21. """
  22. original_init = Router.__init__
  23. if getattr(original_init, "__model_selector_patched__", False):
  24. return
  25. def patched_init(self, *args, on_startup=None, on_shutdown=None, **kwargs):
  26. return original_init(self, *args, **kwargs)
  27. patched_init.__model_selector_patched__ = True # type: ignore[attr-defined]
  28. Router.__init__ = patched_init # type: ignore[assignment]
  29. _patch_router_startup_compatibility()
  30. class AliasUpdate(BaseModel):
  31. full_key: str
  32. alias: str | None = None
  33. class ModelDeleteRequest(BaseModel):
  34. full_key: str
  35. class ModelAddRequest(BaseModel):
  36. provider_key: str
  37. model_id: str
  38. def create_app(
  39. settings: AppSettings | None = None,
  40. config_path: Path | None = None,
  41. cache_dir: Path | None = None,
  42. adapters: dict[str, ProviderAdapter] | None = None,
  43. ) -> FastAPI:
  44. settings = settings or AppSettings.from_env()
  45. config_repo = OpenClawConfigRepository(config_path or settings.config_path)
  46. cache = SnapshotCache(cache_dir or settings.cache_dir)
  47. service = ModelDashboardService(config_repo, cache, adapters=adapters)
  48. @asynccontextmanager
  49. async def lifespan(app: FastAPI):
  50. stop_event = asyncio.Event()
  51. await service.refresh_all()
  52. refresh_task = asyncio.create_task(
  53. service.refresh_periodically(stop_event, settings.refresh_interval_seconds)
  54. )
  55. app.state.service = service
  56. app.state.refresh_stop_event = stop_event
  57. app.state.refresh_task = refresh_task
  58. try:
  59. yield
  60. finally:
  61. stop_event.set()
  62. refresh_task.cancel()
  63. with suppress(asyncio.CancelledError):
  64. await refresh_task
  65. app = FastAPI(title="Model Selector", lifespan=lifespan)
  66. app.state.service = service
  67. app.state.refresh_stop_event = None
  68. app.state.refresh_task = None
  69. @app.get("/", response_class=HTMLResponse)
  70. async def index() -> HTMLResponse:
  71. return HTMLResponse(render_page(service.state_payload()))
  72. @app.get("/api/state")
  73. async def state() -> JSONResponse:
  74. return JSONResponse(service.state_payload())
  75. @app.get("/api/config")
  76. async def config() -> JSONResponse:
  77. return JSONResponse(
  78. {
  79. "config_path": str(config_repo.config_path),
  80. "configured_models": service.configured_models(),
  81. }
  82. )
  83. @app.post("/api/alias")
  84. async def alias(update: AliasUpdate) -> JSONResponse:
  85. if "/" not in update.full_key:
  86. raise HTTPException(status_code=400, detail="full_key must be provider/model")
  87. updated = service.update_alias(update.full_key, update.alias)
  88. return JSONResponse({"ok": True, "config": updated})
  89. @app.post("/api/config/models/delete")
  90. async def delete_model(request: ModelDeleteRequest) -> JSONResponse:
  91. try:
  92. updated = service.delete_configured_model(request.full_key)
  93. except ValueError as exc:
  94. raise HTTPException(status_code=400, detail=str(exc)) from exc
  95. return JSONResponse({"ok": True, "config": updated})
  96. @app.post("/api/config/models/add")
  97. async def add_model(request: ModelAddRequest) -> JSONResponse:
  98. try:
  99. updated = await service.add_provider_model(request.provider_key, request.model_id)
  100. except ValueError as exc:
  101. raise HTTPException(status_code=404, detail=str(exc)) from exc
  102. return JSONResponse({"ok": True, "config": updated})
  103. @app.post("/api/refresh")
  104. async def refresh() -> JSONResponse:
  105. states = await service.refresh_all()
  106. return JSONResponse(
  107. {
  108. "ok": True,
  109. "providers": {key: service._state_to_payload(value) for key, value in states.items()},
  110. }
  111. )
  112. return app
  113. app = create_app()
  114. def main() -> None:
  115. import uvicorn
  116. uvicorn.run("model_selector.main:app", host="0.0.0.0", port=3300, reload=False)
  117. if __name__ == "__main__":
  118. main()