| import io |
| import os |
| import json |
| import time |
| import uuid |
| import asyncio |
| import urllib.request |
| import urllib.error |
|
|
| from fastapi import FastAPI, HTTPException, Request, Response, Depends |
| from fastapi.responses import StreamingResponse, FileResponse, PlainTextResponse |
| from fastapi.staticfiles import StaticFiles |
| from pydantic import BaseModel |
| from PIL import Image |
|
|
| from seed_catalog import SeedCatalog |
| from breeder import breed, clone |
| from starter_loader import StarterSeedLoader |
| from name_generator import NameGenerator |
| from icon_loader import generate_seed_image |
| from seed_profile import GrowthStage |
| import lab |
| import deck |
|
|
| |
| |
| os.environ.setdefault("KEY_OS_ROOT", os.path.join(os.path.dirname(__file__), ".observer-bus-runtime")) |
| os.environ.setdefault("KEY_OS_NAME", "WEED-SIM Observer Bus") |
| import keyos_bridge |
|
|
| app = FastAPI(title="WEED-SIM Web API") |
|
|
| |
| try: |
| from key_os.server import app as kos_app |
| app.mount("/bus", kos_app) |
| except Exception as _kos_exc: |
| print(f"[K-os] bus mount skipped: {_kos_exc}") |
|
|
|
|
| def _register_facilities(): |
| """Advertise WEED-SIM's operations as K-os facilities so the bus exposes a |
| callable syscall table that agents can discover at /bus/api/facilities.""" |
| try: |
| from key_os.store import KeyOSStore |
| from key_os.models import ServiceSpec |
| store = KeyOSStore(os.environ["KEY_OS_ROOT"]) |
| existing = {s.key for s in store.load_services(include_kernel=False)} |
| specs = [ |
| ServiceSpec(key="weedsim.inventory", label="Inventory", group="weed-sim", |
| description="List the session's specimens with traits + colors.", |
| route="/api/inventory", method="GET", risk_class="inspect", tags=("weed-sim", "read")), |
| ServiceSpec(key="weedsim.load_starters", label="Load Starters", group="weed-sim", |
| description="Seed the session with the 12 baseline strains.", |
| route="/api/inventory/starter", method="POST", risk_class="mutating", tags=("weed-sim",)), |
| ServiceSpec(key="weedsim.grow", label="Grow", group="weed-sim", |
| description="Advance a specimen's growth stage.", |
| route="/api/seed/{id}/grow", method="POST", risk_class="mutating", tags=("weed-sim",)), |
| ServiceSpec(key="weedsim.breed", label="Breed", group="weed-sim", |
| description="Cross two parents; HSV recombination. Emits a provenance receipt.", |
| route="/api/breed", method="POST", risk_class="mutating", tags=("weed-sim", "genetics")), |
| ServiceSpec(key="weedsim.clone", label="Clone", group="weed-sim", |
| description="Vegetative copy of one specimen.", |
| route="/api/clone", method="POST", risk_class="mutating", tags=("weed-sim", "genetics")), |
| ServiceSpec(key="weedsim.germplasm", label="Germplasm", group="weed-sim", |
| description="BrAPI-style accessions: generation, pedigree, observations, genotype, facets.", |
| route="/api/lab/germplasm", method="GET", risk_class="inspect", tags=("weed-sim", "brapi")), |
| ServiceSpec(key="weedsim.predict_cross", label="Predict Cross", group="weed-sim", |
| description="Monte-Carlo offspring trait + color distributions for two parents.", |
| route="/api/lab/predict-cross", method="POST", risk_class="inspect", tags=("weed-sim", "analysis")), |
| ServiceSpec(key="weedsim.newick", label="Newick Export", group="weed-sim", |
| description="Reticulate pedigree as Extended-Newick.", |
| route="/api/lab/newick", method="GET", risk_class="inspect", tags=("weed-sim", "phylo")), |
| ] |
| for spec in specs: |
| if spec.key not in existing: |
| store.put_service(spec) |
| except Exception as exc: |
| print(f"[K-os] facility registration skipped: {exc}") |
|
|
|
|
| _register_facilities() |
|
|
| |
| name_gen = NameGenerator() |
|
|
| |
| |
| |
| |
| |
| |
| SESSION_COOKIE = "weedsim_sid" |
| SESSION_HEADER = "x-weedsim-session" |
| SESSION_TTL = 60 * 60 * 6 |
| MAX_SESSIONS = 500 |
| _sessions: dict[str, dict] = {} |
|
|
|
|
| def _new_session() -> dict: |
| catalog = SeedCatalog() |
| return { |
| "catalog": catalog, |
| "loader": StarterSeedLoader(catalog, name_gen), |
| "selection": [], |
| "ts": time.time(), |
| } |
|
|
|
|
| def _reap_sessions() -> None: |
| """Drop expired sessions, then evict oldest if still over the cap.""" |
| now = time.time() |
| for sid in [s for s, v in _sessions.items() if now - v["ts"] > SESSION_TTL]: |
| _sessions.pop(sid, None) |
| if len(_sessions) > MAX_SESSIONS: |
| for sid, _ in sorted(_sessions.items(), key=lambda kv: kv[1]["ts"])[ |
| : len(_sessions) - MAX_SESSIONS |
| ]: |
| _sessions.pop(sid, None) |
|
|
|
|
| def get_session(request: Request, response: Response) -> dict: |
| """Resolve (or create) the caller's session. |
| |
| Identity comes from (in order) the X-WeedSim-Session header (set by the |
| frontend from a localStorage token β works in any iframe), a `sid` query |
| param (so <img> sprite URLs carry it), or the cookie (fallback). This makes |
| sessions independent of third-party-cookie policy. |
| """ |
| sid = ( |
| request.headers.get(SESSION_HEADER) |
| or request.query_params.get("sid") |
| or request.cookies.get(SESSION_COOKIE) |
| ) |
| if not sid or sid not in _sessions: |
| _reap_sessions() |
| if not sid: |
| sid = uuid.uuid4().hex |
| _sessions[sid] = _new_session() |
| response.set_cookie( |
| SESSION_COOKIE, sid, httponly=True, samesite="none", secure=True, max_age=SESSION_TTL |
| ) |
| sess = _sessions[sid] |
| sess["ts"] = time.time() |
| return sess |
|
|
|
|
| def lookup_session(request: Request) -> dict | None: |
| """Read-only session resolution for image endpoints (never sets a cookie).""" |
| sid = request.cookies.get(SESSION_COOKIE) |
| sess = _sessions.get(sid) if sid else None |
| if sess: |
| sess["ts"] = time.time() |
| return sess |
|
|
|
|
| |
| app.mount("/static", StaticFiles(directory="static"), name="static") |
|
|
|
|
| @app.get("/") |
| async def root(): |
| return FileResponse("static/landing.html") |
|
|
|
|
| @app.get("/play") |
| async def play_page(): |
| return FileResponse("static/index.html") |
|
|
|
|
| @app.get("/docs-hub") |
| async def docs_page(): |
| return FileResponse("static/docs.html") |
|
|
|
|
| @app.get("/healthz") |
| async def healthz(): |
| return {"status": "ok", "sessions": len(_sessions)} |
|
|
|
|
| |
| |
| ROUTE_MANIFEST = { |
| "schema": "weedsim.route_manifest/v1", |
| "groups": [ |
| {"group": "Surfaces", "routes": [ |
| {"method": "GET", "path": "/", "label": "Landing", "kind": "page", "desc": "Meta entry + live Observer-Bus state."}, |
| {"method": "GET", "path": "/play", "label": "Play Mode", "kind": "page", "desc": "Grow, breed, and clone specimens."}, |
| {"method": "GET", "path": "/lab", "label": "Speciation Lab", "kind": "page", "desc": "Faceted grid, parallel coordinates, gamut wheel, cross predictor."}, |
| {"method": "GET", "path": "/deck", "label": "The Deck", "kind": "page", "desc": "Shared-state hub: gauges, breakdowns, recent crosses, signals."}, |
| {"method": "GET", "path": "/agent", "label": "Agent", "kind": "page", "desc": "Chat agent that drives the whole system via HF Inference Providers + tool calls."}, |
| {"method": "GET", "path": "/docs", "label": "OpenAPI (Swagger)", "kind": "page", "desc": "Auto-generated interactive API explorer."}, |
| {"method": "GET", "path": "/bus", "label": "Observer Bus (embedded)", "kind": "page", "desc": "The full K-os HUD running in-process: signals, receipts, facilities, Resource OS."}, |
| ]}, |
| {"group": "Play API", "routes": [ |
| {"method": "GET", "path": "/api/inventory", "label": "Inventory", "kind": "get", "desc": "Session specimens with bud/leaf colors + traits."}, |
| {"method": "POST", "path": "/api/inventory/starter", "label": "Load starters", "kind": "post", "desc": "Add the 12 baseline strains."}, |
| {"method": "POST", "path": "/api/inventory/clear", "label": "Clear", "kind": "post", "desc": "Empty the session registry."}, |
| {"method": "POST", "path": "/api/seed/{id}/grow", "label": "Grow", "kind": "post", "desc": "Advance a specimen's growth stage."}, |
| {"method": "POST", "path": "/api/breed", "label": "Breed", "kind": "post", "desc": "Cross two parents (HSV recombination); emits a K-os receipt."}, |
| {"method": "POST", "path": "/api/clone", "label": "Clone", "kind": "post", "desc": "Vegetative copy of one specimen."}, |
| {"method": "GET", "path": "/api/image/{id}/{size}", "label": "Sprite", "kind": "get-tmpl", "desc": "Pixel-art sprite, recolored via bud/leaf masks."}, |
| ]}, |
| {"group": "Lab API (BrAPI-style)", "routes": [ |
| {"method": "GET", "path": "/api/lab/germplasm", "label": "Germplasm", "kind": "get", "desc": "Accessions: generation, pedigree, observations, genotype, hue, facets."}, |
| {"method": "POST", "path": "/api/lab/predict-cross", "label": "Predict cross", "kind": "post", "desc": "Monte-Carlo offspring trait + color distributions."}, |
| {"method": "GET", "path": "/api/lab/newick", "label": "Newick export", "kind": "get", "desc": "Reticulate pedigree as Extended-Newick."}, |
| ]}, |
| {"group": "Deck API", "routes": [ |
| {"method": "GET", "path": "/api/deck/summary", "label": "Summary", "kind": "get", "desc": "Unified state: counts, generations, trait averages, gamut, recent crosses."}, |
| {"method": "GET", "path": "/api/deck/signals", "label": "Signals", "kind": "get", "desc": "Observer-Bus signal inbox (fail-open proxy to K-os)."}, |
| {"method": "GET", "path": "/api/selection", "label": "Selection", "kind": "get", "desc": "Shared specimen selection across modes."}, |
| {"method": "GET", "path": "/api/routes", "label": "Route manifest", "kind": "get", "desc": "This manifest."}, |
| {"method": "GET", "path": "/healthz", "label": "Health", "kind": "get", "desc": "Liveness + active session count."}, |
| ]}, |
| {"group": "Observer Bus API (embedded, agent-usable)", "routes": [ |
| {"method": "GET", "path": "/bus/api/signals", "label": "Bus signals", "kind": "get", "desc": "The live signal inbox (crosses appear as provenance receipts)."}, |
| {"method": "GET", "path": "/bus/api/os", "label": "Resource OS", "kind": "get", "desc": "Read-only packet map over facilities, receipts, notepad, boundaries."}, |
| {"method": "GET", "path": "/bus/api/os/search?q=", "label": "Resource search", "kind": "get", "desc": "Search Resource-OS packets."}, |
| {"method": "GET", "path": "/bus/api/facilities", "label": "Facilities", "kind": "get", "desc": "Callable syscall table β WEED-SIM ops registered as facilities."}, |
| {"method": "POST", "path": "/bus/api/receipts", "label": "Publish receipt", "kind": "post", "desc": "Append a provenance receipt (no auth on this build)."}, |
| ]}, |
| {"group": "External / Provenance", "routes": [ |
| {"method": "GET", "path": "https://huggingface.co/spaces/tostido/K-os", "label": "K-os Observer Bus", "kind": "external", "desc": "The OS substrate WEED-SIM publishes receipts to."}, |
| {"method": "GET", "path": "https://brapi.org/specification", "label": "BrAPI spec", "kind": "external", "desc": "Breeding API the germplasm model follows."}, |
| {"method": "GET", "path": "https://icytree.org/", "label": "IcyTree", "kind": "external", "desc": "Open a Newick export to view the pedigree network."}, |
| ]}, |
| ], |
| } |
|
|
|
|
| @app.get("/api/routes") |
| async def route_manifest(): |
| return ROUTE_MANIFEST |
|
|
|
|
| |
| |
| |
| |
| |
| PROVIDER_BASE = { |
| "hf": "https://router.huggingface.co/v1", |
| "openrouter": "https://openrouter.ai/api/v1", |
| } |
|
|
|
|
| def _provider_token(request: Request, provider: str) -> str: |
| tok = request.headers.get("x-hf-token") |
| if tok: |
| return tok |
| return os.environ.get("OPENROUTER_API_KEY", "") if provider == "openrouter" else os.environ.get("HF_TOKEN", "") |
|
|
|
|
| def _extra_headers(provider: str) -> dict: |
| if provider == "openrouter": |
| return {"HTTP-Referer": "https://tostido-weed-sim.hf.space", "X-Title": "WEED-SIM Agent"} |
| return {} |
|
|
|
|
| def _router_get(base: str, path: str, token: str, extra: dict | None = None): |
| headers = {"Authorization": f"Bearer {token}", **(extra or {})} |
| req = urllib.request.Request(base + path, method="GET", headers=headers) |
| with urllib.request.urlopen(req, timeout=30) as r: |
| return json.loads(r.read()) |
|
|
|
|
| def _router_post(base: str, path: str, token: str, payload: dict, extra: dict | None = None): |
| headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json", **(extra or {})} |
| req = urllib.request.Request(base + path, data=json.dumps(payload).encode(), method="POST", headers=headers) |
| with urllib.request.urlopen(req, timeout=120) as r: |
| return json.loads(r.read()) |
|
|
|
|
| def _normalize_models(provider: str, data) -> list: |
| items = data.get("data", data if isinstance(data, list) else []) |
| out = [] |
| for m in items: |
| if not isinstance(m, dict): |
| continue |
| if provider == "openrouter": |
| sp = m.get("supported_parameters") or [] |
| pricing = m.get("pricing") or {} |
| free = str(pricing.get("prompt", "0")) in ("0", "0.0") or ":free" in str(m.get("id", "")) |
| out.append({"id": m.get("id"), "tools": "tools" in sp, "free": free, |
| "context": int(m.get("context_length") or 0), "providers": []}) |
| else: |
| provs = [p for p in (m.get("providers") or []) if isinstance(p, dict)] |
| ctx = max([int(p.get("context_length") or 0) for p in provs] or [0]) |
| out.append({"id": m.get("id"), "tools": any(p.get("supports_tools") for p in provs), |
| "free": any(p.get("is_free") for p in provs), "context": ctx, |
| "providers": [p.get("provider") for p in provs]}) |
| out.sort(key=lambda x: (not x["tools"], -(x["context"] or 0), str(x["id"]).lower())) |
| return out |
|
|
|
|
| @app.get("/agent") |
| async def agent_page(): |
| return FileResponse("static/agent.html") |
|
|
|
|
| @app.get("/api/agent/models") |
| async def agent_models(request: Request, provider: str = "hf"): |
| base = PROVIDER_BASE.get(provider, PROVIDER_BASE["hf"]) |
| token = _provider_token(request, provider) |
| if not token: |
| raise HTTPException(status_code=401, detail="Provider token required.") |
| try: |
| data = await asyncio.to_thread(_router_get, base, "/models", token, _extra_headers(provider)) |
| except urllib.error.HTTPError as e: |
| raise HTTPException(status_code=e.code, detail=e.read().decode()[:300]) |
| except Exception as e: |
| raise HTTPException(status_code=502, detail=str(e)) |
| models = _normalize_models(provider, data) |
| return {"count": len(models), "provider": provider, "models": models} |
|
|
|
|
| class AgentChatRequest(BaseModel): |
| model: str |
| messages: list |
| provider: str = "hf" |
| tools: list | None = None |
| tool_choice: str | None = "auto" |
| temperature: float | None = 0.4 |
| max_tokens: int | None = 1200 |
|
|
|
|
| @app.post("/api/agent/chat") |
| async def agent_chat(req: AgentChatRequest, request: Request): |
| base = PROVIDER_BASE.get(req.provider, PROVIDER_BASE["hf"]) |
| token = _provider_token(request, req.provider) |
| if not token: |
| raise HTTPException(status_code=401, detail="Paste a provider token to use the agent.") |
| payload = {"model": req.model, "messages": req.messages} |
| if req.tools: |
| payload["tools"] = req.tools |
| payload["tool_choice"] = req.tool_choice or "auto" |
| if req.temperature is not None: |
| payload["temperature"] = req.temperature |
| if req.max_tokens: |
| payload["max_tokens"] = req.max_tokens |
| try: |
| return await asyncio.to_thread(_router_post, base, "/chat/completions", token, payload, _extra_headers(req.provider)) |
| except urllib.error.HTTPError as e: |
| raise HTTPException(status_code=e.code, detail=f"{req.provider} router: {e.read().decode()[:400]}") |
| except Exception as e: |
| raise HTTPException(status_code=502, detail=f"{req.provider} router unreachable: {e}") |
|
|
|
|
| @app.get("/lab") |
| async def lab_page(): |
| return FileResponse("static/lab.html") |
|
|
|
|
| @app.get("/deck") |
| async def deck_page(): |
| return FileResponse("static/deck.html") |
|
|
|
|
| |
| @app.get("/api/deck/summary") |
| async def deck_summary(session: dict = Depends(get_session)): |
| return deck.summary(session["catalog"].get_all_seeds()) |
|
|
|
|
| @app.get("/api/deck/signals") |
| async def deck_signals(): |
| return keyos_bridge.signals() |
|
|
|
|
| class SelectionRequest(BaseModel): |
| ids: list[str] = [] |
|
|
|
|
| @app.get("/api/selection") |
| async def get_selection(session: dict = Depends(get_session)): |
| return {"ids": session.get("selection", [])} |
|
|
|
|
| @app.post("/api/selection") |
| async def set_selection(req: SelectionRequest, session: dict = Depends(get_session)): |
| catalog = session["catalog"] |
| session["selection"] = [i for i in req.ids if catalog.get_seed_by_id(i)][:8] |
| return {"ids": session["selection"]} |
|
|
|
|
| |
| @app.get("/api/lab/germplasm") |
| async def lab_germplasm(session: dict = Depends(get_session)): |
| return lab.lab_payload(session["catalog"].get_all_seeds()) |
|
|
|
|
| class CrossRequest(BaseModel): |
| parent1_id: str |
| parent2_id: str |
| n: int = 200 |
|
|
|
|
| @app.post("/api/lab/predict-cross") |
| async def lab_predict_cross(req: CrossRequest, session: dict = Depends(get_session)): |
| catalog = session["catalog"] |
| p1 = catalog.get_seed_by_id(req.parent1_id) |
| p2 = catalog.get_seed_by_id(req.parent2_id) |
| if not p1 or not p2: |
| raise HTTPException(status_code=404, detail="Parents not found") |
| return lab.predict_cross(p1, p2, n=max(20, min(1000, req.n))) |
|
|
|
|
| @app.get("/api/lab/newick") |
| async def lab_newick(session: dict = Depends(get_session)): |
| return PlainTextResponse(lab.to_newick(session["catalog"].get_all_seeds())) |
|
|
|
|
| |
| @app.get("/api/inventory") |
| async def get_inventory(session: dict = Depends(get_session)): |
| out = [] |
| for s in session["catalog"].get_all_seeds(): |
| out.append({ |
| "id": s.seed_id, |
| "name": s.strain_name, |
| "stage": s.growth_stage.name, |
| "type": s.type, |
| "thc": s.thc, |
| "cbd": s.cbd, |
| "yield": s.yield_, |
| "grow_time": s.grow_time, |
| "bud_color": list(s.bud_color), |
| "leaf_color": list(s.leaf_color), |
| "stabilities": s.stabilities, |
| "alleles": s.alleles, |
| "lineage": list(s.lineage), |
| "can_attempt": s.can_attempt(), |
| "attempts_used": s.attempts_used, |
| "max_attempts": s.max_attempts, |
| "is_starter": s.is_starter, |
| "description": s.description, |
| "quantity": s.quantity, |
| }) |
| return {"seeds": out} |
|
|
|
|
| @app.post("/api/inventory/starter") |
| async def load_starters(session: dict = Depends(get_session)): |
| session["loader"].load_starter_seeds() |
| return {"ok": True} |
|
|
|
|
| @app.post("/api/inventory/clear") |
| async def clear_inventory(session: dict = Depends(get_session)): |
| session["catalog"].clear() |
| return {"ok": True} |
|
|
|
|
| @app.post("/api/seed/{seed_id}/grow") |
| async def grow_seed(seed_id: str, session: dict = Depends(get_session)): |
| seed = session["catalog"].get_seed_by_id(seed_id) |
| if not seed: |
| raise HTTPException(status_code=404, detail="Seed not found") |
| seed.advance_growth_stage() |
| return {"ok": True} |
|
|
|
|
| |
| class BreedRequest(BaseModel): |
| parent1_id: str |
| parent2_id: str |
|
|
|
|
| @app.post("/api/breed") |
| async def breed_seeds(req: BreedRequest, session: dict = Depends(get_session)): |
| catalog = session["catalog"] |
| p1 = catalog.get_seed_by_id(req.parent1_id) |
| p2 = catalog.get_seed_by_id(req.parent2_id) |
| if not p1 or not p2: |
| raise HTTPException(status_code=404, detail="Parents not found") |
| try: |
| offspring = breed(p1, p2, name_gen) |
| catalog.add_seed(offspring) |
| keyos_bridge.publish_receipt("cross", offspring.strain_name, { |
| "offspring_id": offspring.seed_id, |
| "parents": [p1.strain_name, p2.strain_name], |
| "bud_color": list(offspring.bud_color), |
| "leaf_color": list(offspring.leaf_color), |
| }) |
| return {"ok": True, "offspring_id": offspring.seed_id} |
| except Exception as e: |
| raise HTTPException(status_code=400, detail=str(e)) |
|
|
|
|
| class CloneRequest(BaseModel): |
| seed_id: str |
|
|
|
|
| @app.post("/api/clone") |
| async def clone_seed(req: CloneRequest, session: dict = Depends(get_session)): |
| catalog = session["catalog"] |
| p = catalog.get_seed_by_id(req.seed_id) |
| if not p: |
| raise HTTPException(status_code=404, detail="Seed not found") |
| try: |
| c = clone(p, name_gen) |
| catalog.add_seed(c) |
| keyos_bridge.publish_receipt("clone", c.strain_name, { |
| "clone_id": c.seed_id, "parent": p.strain_name, |
| }) |
| return {"ok": True, "clone_id": c.seed_id} |
| except Exception as e: |
| raise HTTPException(status_code=400, detail=str(e)) |
|
|
|
|
| |
| |
| |
| |
| _RENDER_CACHE: dict[tuple, bytes] = {} |
| _RENDER_CACHE_MAX = 2000 |
|
|
|
|
| def _render(seed, stage_name: str, size: int) -> StreamingResponse: |
| key = (seed.seed_id, stage_name, size, str(seed.bud_color), str(seed.leaf_color), seed.is_starter) |
| png = _RENDER_CACHE.get(key) |
| if png is None: |
| img = generate_seed_image(seed, stage_name) |
| if size != img.width: |
| img = img.resize((size, size), Image.NEAREST) |
| buf = io.BytesIO() |
| img.save(buf, format="PNG") |
| png = buf.getvalue() |
| if len(_RENDER_CACHE) >= _RENDER_CACHE_MAX: |
| _RENDER_CACHE.pop(next(iter(_RENDER_CACHE))) |
| _RENDER_CACHE[key] = png |
| return StreamingResponse(io.BytesIO(png), media_type="image/png") |
|
|
|
|
| @app.get("/api/image/{seed_id}/{size}") |
| async def get_seed_image(seed_id: str, size: int, session: dict = Depends(get_session)): |
| seed = session["catalog"].get_seed_by_id(seed_id) |
| if not seed: |
| raise HTTPException(status_code=404, detail="Seed not found") |
| return _render(seed, seed.growth_stage.name, size) |
|
|
|
|
| @app.get("/api/image_mature/{seed_id}/{size}") |
| async def get_mature_seed_image(seed_id: str, size: int, session: dict = Depends(get_session)): |
| |
| seed = session["catalog"].get_seed_by_id(seed_id) |
| if not seed: |
| raise HTTPException(status_code=404, detail="Seed not found") |
| return _render(seed, GrowthStage.MATURE.name, size) |
|
|
|
|
| if __name__ == "__main__": |
| import uvicorn |
|
|
| |
| |
| port = int(os.environ.get("PORT", "7860")) |
| uvicorn.run("app:app", host="0.0.0.0", port=port) |
|
|