hoshimi 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (170) hide show
  1. hoshimi/__init__.py +15 -0
  2. hoshimi/__main__.py +6 -0
  3. hoshimi/_exports.py +22 -0
  4. hoshimi/api/__init__.py +0 -0
  5. hoshimi/api/app.py +98 -0
  6. hoshimi/api/deps.py +14 -0
  7. hoshimi/api/errors.py +115 -0
  8. hoshimi/api/inference.py +48 -0
  9. hoshimi/api/paths.py +33 -0
  10. hoshimi/api/routers/__init__.py +0 -0
  11. hoshimi/api/routers/app_info.py +64 -0
  12. hoshimi/api/routers/catalog.py +197 -0
  13. hoshimi/api/routers/chat.py +126 -0
  14. hoshimi/api/routers/classification.py +42 -0
  15. hoshimi/api/routers/credits.py +167 -0
  16. hoshimi/api/routers/health.py +10 -0
  17. hoshimi/api/routers/login.py +92 -0
  18. hoshimi/api/routers/messages.py +122 -0
  19. hoshimi/api/routers/models.py +44 -0
  20. hoshimi/api/routers/playground.py +10 -0
  21. hoshimi/api/routers/probe.py +81 -0
  22. hoshimi/api/routers/providers.py +318 -0
  23. hoshimi/api/routers/refresh.py +68 -0
  24. hoshimi/api/routers/settings.py +121 -0
  25. hoshimi/api/routers/stats.py +75 -0
  26. hoshimi/api/security.py +124 -0
  27. hoshimi/api/static.py +49 -0
  28. hoshimi/cli/__init__.py +0 -0
  29. hoshimi/cli/app.py +69 -0
  30. hoshimi/cli/commands/__init__.py +0 -0
  31. hoshimi/cli/commands/check.py +58 -0
  32. hoshimi/cli/commands/config.py +156 -0
  33. hoshimi/cli/commands/login.py +83 -0
  34. hoshimi/cli/commands/models.py +29 -0
  35. hoshimi/cli/commands/providers.py +45 -0
  36. hoshimi/cli/commands/serve.py +92 -0
  37. hoshimi/cli/commands/stations.py +46 -0
  38. hoshimi/cli/commands/system.py +55 -0
  39. hoshimi/cli/factory.py +91 -0
  40. hoshimi/cli/output.py +67 -0
  41. hoshimi/core/__init__.py +1 -0
  42. hoshimi/core/catalog.py +1334 -0
  43. hoshimi/core/chat_catalog.py +274 -0
  44. hoshimi/core/chunks.py +571 -0
  45. hoshimi/core/config.py +262 -0
  46. hoshimi/core/context.py +159 -0
  47. hoshimi/core/credentials.py +47 -0
  48. hoshimi/core/credits.py +232 -0
  49. hoshimi/core/discovery.py +60 -0
  50. hoshimi/core/eac_relay.py +45 -0
  51. hoshimi/core/eac_seal.py +51 -0
  52. hoshimi/core/eac_user.py +132 -0
  53. hoshimi/core/effort.py +328 -0
  54. hoshimi/core/errors.py +67 -0
  55. hoshimi/core/expiry.py +96 -0
  56. hoshimi/core/failure.py +603 -0
  57. hoshimi/core/free_marks.py +330 -0
  58. hoshimi/core/login/__init__.py +31 -0
  59. hoshimi/core/login/buddy.py +231 -0
  60. hoshimi/core/login/cline.py +187 -0
  61. hoshimi/core/login/codearts.py +222 -0
  62. hoshimi/core/login/codex.py +153 -0
  63. hoshimi/core/login/common.py +215 -0
  64. hoshimi/core/login/eac.py +220 -0
  65. hoshimi/core/login/gemini.py +143 -0
  66. hoshimi/core/login/kilo.py +49 -0
  67. hoshimi/core/login/lobsterai.py +132 -0
  68. hoshimi/core/login/loomy.py +326 -0
  69. hoshimi/core/login/manager.py +297 -0
  70. hoshimi/core/login/minimax.py +165 -0
  71. hoshimi/core/login/provider_oauth.py +281 -0
  72. hoshimi/core/login/qoder.py +133 -0
  73. hoshimi/core/login/raccoon.py +101 -0
  74. hoshimi/core/login/trae.py +196 -0
  75. hoshimi/core/login/zcode.py +135 -0
  76. hoshimi/core/message_chunks.py +199 -0
  77. hoshimi/core/modelsdev.py +226 -0
  78. hoshimi/core/net/__init__.py +1 -0
  79. hoshimi/core/net/ports.py +66 -0
  80. hoshimi/core/net/runtime_file.py +51 -0
  81. hoshimi/core/paths.py +22 -0
  82. hoshimi/core/presets.py +103 -0
  83. hoshimi/core/probe.py +313 -0
  84. hoshimi/core/provider_definitions.py +103 -0
  85. hoshimi/core/provider_forms.py +66 -0
  86. hoshimi/core/provider_sources.py +170 -0
  87. hoshimi/core/providers/__init__.py +79 -0
  88. hoshimi/core/providers/agg.py +342 -0
  89. hoshimi/core/providers/base.py +587 -0
  90. hoshimi/core/providers/bedrock.py +188 -0
  91. hoshimi/core/providers/buddy.py +299 -0
  92. hoshimi/core/providers/catalog_profiles.py +156 -0
  93. hoshimi/core/providers/cline.py +140 -0
  94. hoshimi/core/providers/cloud.py +306 -0
  95. hoshimi/core/providers/codearts.py +390 -0
  96. hoshimi/core/providers/compat.py +44 -0
  97. hoshimi/core/providers/configured.py +147 -0
  98. hoshimi/core/providers/eac.py +205 -0
  99. hoshimi/core/providers/factory.py +148 -0
  100. hoshimi/core/providers/gemini.py +191 -0
  101. hoshimi/core/providers/keyed.py +144 -0
  102. hoshimi/core/providers/kilo.py +106 -0
  103. hoshimi/core/providers/lobsterai.py +226 -0
  104. hoshimi/core/providers/local.py +127 -0
  105. hoshimi/core/providers/loomy.py +140 -0
  106. hoshimi/core/providers/minimax.py +228 -0
  107. hoshimi/core/providers/nim_tools.py +120 -0
  108. hoshimi/core/providers/nvidia.py +54 -0
  109. hoshimi/core/providers/openai_compat.py +399 -0
  110. hoshimi/core/providers/opencode.py +278 -0
  111. hoshimi/core/providers/opencode_fingerprint.py +189 -0
  112. hoshimi/core/providers/qoder.py +279 -0
  113. hoshimi/core/providers/raccoon.py +122 -0
  114. hoshimi/core/providers/radius.py +138 -0
  115. hoshimi/core/providers/request_profiles.py +115 -0
  116. hoshimi/core/providers/station_catalogs.py +133 -0
  117. hoshimi/core/providers/trae.py +314 -0
  118. hoshimi/core/providers/transports.py +52 -0
  119. hoshimi/core/providers/typesafe.py +75 -0
  120. hoshimi/core/providers/zcode.py +191 -0
  121. hoshimi/core/providers/zhipu.py +86 -0
  122. hoshimi/core/quota.py +260 -0
  123. hoshimi/core/record.py +108 -0
  124. hoshimi/core/refresh.py +215 -0
  125. hoshimi/core/registry.py +285 -0
  126. hoshimi/core/security.py +171 -0
  127. hoshimi/core/sse.py +47 -0
  128. hoshimi/core/stats.py +379 -0
  129. hoshimi/core/translate/__init__.py +1 -0
  130. hoshimi/core/translate/anthropic.py +411 -0
  131. hoshimi/core/translate/chat.py +123 -0
  132. hoshimi/core/translate/gemini.py +394 -0
  133. hoshimi/core/translate/messages.py +235 -0
  134. hoshimi/core/translate/responses.py +494 -0
  135. hoshimi/core/translate/solo.py +214 -0
  136. hoshimi/logger.py +24 -0
  137. hoshimi/version.py +3 -0
  138. hoshimi/webui/__init__.py +1 -0
  139. hoshimi/webui/dist/assets/CatalogStatus-DK53lfUj.js +1 -0
  140. hoshimi/webui/dist/assets/CatalogStatus-DoMGFxBl.css +1 -0
  141. hoshimi/webui/dist/assets/ChannelsView-DAA40MJQ.css +1 -0
  142. hoshimi/webui/dist/assets/ChannelsView-VHHiYbFT.js +1 -0
  143. hoshimi/webui/dist/assets/ModelsView-Dzkv4gsI.css +1 -0
  144. hoshimi/webui/dist/assets/ModelsView-Njln1vzT.js +1 -0
  145. hoshimi/webui/dist/assets/OverviewView-BYFKlKNF.css +1 -0
  146. hoshimi/webui/dist/assets/OverviewView-Cvv0hBNT.js +1 -0
  147. hoshimi/webui/dist/assets/PlaygroundView-CjPN6bYp.js +2 -0
  148. hoshimi/webui/dist/assets/PlaygroundView-DhkBVqGM.css +1 -0
  149. hoshimi/webui/dist/assets/SettingsView-CqhSedwu.css +1 -0
  150. hoshimi/webui/dist/assets/SettingsView-DX_OQgUT.js +1 -0
  151. hoshimi/webui/dist/assets/UsageView-BR0j3K0v.js +1 -0
  152. hoshimi/webui/dist/assets/UsageView-DuKrmgwU.css +1 -0
  153. hoshimi/webui/dist/assets/app-CmEs1pbN.js +2 -0
  154. hoshimi/webui/dist/assets/format-CoK4W4Zn.js +1 -0
  155. hoshimi/webui/dist/assets/index-CZXOwalx.css +1 -0
  156. hoshimi/webui/dist/assets/index-DCJqkKVJ.js +650 -0
  157. hoshimi/webui/dist/assets/roboto-flex-cyrillic-ext-wght-normal-CVcCQn0y.woff2 +0 -0
  158. hoshimi/webui/dist/assets/roboto-flex-cyrillic-wght-normal-BJ-nQEdz.woff2 +0 -0
  159. hoshimi/webui/dist/assets/roboto-flex-greek-wght-normal-DeN4rzfy.woff2 +0 -0
  160. hoshimi/webui/dist/assets/roboto-flex-latin-ext-wght-normal-gysdOMcS.woff2 +0 -0
  161. hoshimi/webui/dist/assets/roboto-flex-latin-wght-normal-n-dP9qr2.woff2 +0 -0
  162. hoshimi/webui/dist/assets/roboto-flex-vietnamese-wght-normal-AVFfiTln.woff2 +0 -0
  163. hoshimi/webui/dist/favicon.svg +18 -0
  164. hoshimi/webui/dist/index.html +28 -0
  165. hoshimi-0.1.0.dist-info/METADATA +933 -0
  166. hoshimi-0.1.0.dist-info/RECORD +170 -0
  167. hoshimi-0.1.0.dist-info/WHEEL +5 -0
  168. hoshimi-0.1.0.dist-info/entry_points.txt +2 -0
  169. hoshimi-0.1.0.dist-info/licenses/LICENSE +674 -0
  170. hoshimi-0.1.0.dist-info/top_level.txt +1 -0
hoshimi/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """Hoshimi: an OpenAI-compatible gateway over free model stations (Cline, TRAE, ZCode, ...).
2
+
3
+ To embed the API and the web UI in another application:
4
+
5
+ from hoshimi.api.app import create_app
6
+ from hoshimi.core.context import build_services
7
+
8
+ app = create_app(build_services())
9
+ """
10
+
11
+ from hoshimi._exports import __dir__ as __dir__
12
+ from hoshimi._exports import __getattr__ as __getattr__
13
+ from hoshimi.version import VERSION as VERSION
14
+
15
+ __all__ = ["VERSION", "Services", "build_services", "create_app"]
hoshimi/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Allow ``python -m hoshimi``."""
2
+
3
+ from hoshimi.cli.app import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
hoshimi/_exports.py ADDED
@@ -0,0 +1,22 @@
1
+ """Resolve the embedding API lazily so basic commands avoid importing FastAPI."""
2
+
3
+ from typing import Any
4
+
5
+
6
+ def __getattr__(name: str) -> Any:
7
+ """Load an embedding API export only when the caller requests it."""
8
+ if name in ("Services", "build_services"):
9
+ from hoshimi.core import context
10
+
11
+ return getattr(context, name)
12
+ if name == "create_app":
13
+ from hoshimi.api.app import create_app
14
+
15
+ return create_app
16
+ raise AttributeError(f"module 'hoshimi' has no attribute {name!r}")
17
+
18
+
19
+ def __dir__() -> list[str]:
20
+ from hoshimi import __all__
21
+
22
+ return sorted(__all__)
File without changes
hoshimi/api/app.py ADDED
@@ -0,0 +1,98 @@
1
+ """``create_app(services)``: the FastAPI app with routers, the security layer and the web UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import AsyncGenerator
7
+ from contextlib import asynccontextmanager
8
+
9
+ from fastapi import FastAPI
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from starlette.middleware.gzip import GZipMiddleware
12
+
13
+ from hoshimi.api.errors import install_error_handlers
14
+ from hoshimi.api.routers import app_info, catalog, chat, classification, credits, health, login, messages, models, playground, probe, providers, refresh, settings, stats
15
+ from hoshimi.api.security import SecurityMiddleware
16
+ from hoshimi.api.static import SPAStaticFiles, web_dist_dir
17
+ from hoshimi.core.context import Services
18
+ from hoshimi.core.errors import ValidationError
19
+ from hoshimi.core.security import build_policy, validate_security_settings
20
+ from hoshimi.version import VERSION
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ INFERENCE_ROUTES = (chat, models, classification, messages)
25
+
26
+
27
+ def normalize_prefix(prefix: str | None) -> str:
28
+ """``/gateway/`` and ``gateway`` both become ``/gateway``; nothing becomes ``""``."""
29
+ cleaned = (prefix or "").strip().strip("/")
30
+ return f"/{cleaned}" if cleaned else ""
31
+
32
+
33
+ def create_app(
34
+ services: Services,
35
+ bound_host: str | None = None,
36
+ bound_port: int | None = None,
37
+ extra_hosts: set[str] | None = None,
38
+ serve_ui: bool = True,
39
+ api_prefix: str | None = None,
40
+ ) -> FastAPI:
41
+ """Build the application.
42
+
43
+ ``api_prefix`` moves everything this package serves — the API and the web UI — under one
44
+ path, so a host application can mount it beside its own routes without a clash. The web UI
45
+ needs no change: it derives its base URL from the URL its own script was loaded from.
46
+ """
47
+ problem = validate_security_settings(services.settings)
48
+ if problem:
49
+ raise ValidationError(problem)
50
+
51
+ prefix = normalize_prefix(api_prefix)
52
+
53
+ @asynccontextmanager
54
+ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
55
+ # The renewal timer is off when the operator set server.auto_refresh=false
56
+ # or built Services without a refresher (tests, embedders).
57
+ if services.refresher is not None and services.settings.auto_refresh:
58
+ services.refresher.start()
59
+ if services.credits is not None and services.settings.auto_checkin:
60
+ services.credits.start()
61
+ try:
62
+ yield
63
+ finally:
64
+ services.close()
65
+
66
+ app = FastAPI(title="Hoshimi", version=VERSION, lifespan=lifespan, docs_url=f"{prefix}/docs", redoc_url=None, openapi_url=f"{prefix}/openapi.json")
67
+ app.state.services = services
68
+ app.state.bound_port = bound_port
69
+ app.state.api_prefix = prefix
70
+ install_error_handlers(app)
71
+
72
+ # Management API under /api; inference protocols share /v1.
73
+ app.include_router(health.router, prefix=prefix)
74
+ for module in (app_info, settings, providers, catalog, probe, stats, refresh, credits, login, playground):
75
+ app.include_router(module.router, prefix=f"{prefix}/api")
76
+ for module in INFERENCE_ROUTES:
77
+ app.include_router(module.router, prefix=prefix)
78
+
79
+ origins = services.settings.allow_origins
80
+ if origins:
81
+ app.add_middleware(CORSMiddleware, allow_origins=origins, allow_methods=["*"], allow_headers=["*"], allow_credentials=True)
82
+ # Chat deltas are small; long JSON payloads compress well.
83
+ app.add_middleware(GZipMiddleware, minimum_size=4096)
84
+ app.add_middleware(
85
+ SecurityMiddleware,
86
+ policy=lambda: build_policy(services.settings),
87
+ bound_host=lambda: bound_host or services.settings.host,
88
+ extra_hosts=extra_hosts,
89
+ prefix=prefix,
90
+ )
91
+
92
+ # Registered last, so it only receives paths nothing else matched.
93
+ dist = web_dist_dir()
94
+ if serve_ui and (dist / "index.html").is_file():
95
+ app.mount(f"{prefix}/", SPAStaticFiles(directory=dist, html=True), name="ui")
96
+ elif serve_ui:
97
+ logger.warning("No web UI build found at %s; serving the API only", dist)
98
+ return app
hoshimi/api/deps.py ADDED
@@ -0,0 +1,14 @@
1
+ """FastAPI dependencies. Services come from ``app.state`` rather than a global locator."""
2
+
3
+ from typing import Annotated
4
+
5
+ from fastapi import Depends, Request
6
+
7
+ from hoshimi.core.context import Services
8
+
9
+
10
+ def get_services(request: Request) -> Services:
11
+ return request.app.state.services
12
+
13
+
14
+ ServicesDep = Annotated[Services, Depends(get_services)]
hoshimi/api/errors.py ADDED
@@ -0,0 +1,115 @@
1
+ """Map domain exceptions onto the endpoint's JSON error shape.
2
+
3
+ ``/v1/*`` speaks OpenAI's error envelope (``{"error": {"message", "type", "code"}}``) because
4
+ that is what OpenAI clients parse. Messages uses Anthropic's envelope;
5
+ management routes answer Hoshimi's ``{code, message, detail}``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from fastapi import FastAPI, Request
13
+ from fastapi.exception_handlers import request_validation_exception_handler
14
+ from fastapi.exceptions import RequestValidationError
15
+ from fastapi.responses import JSONResponse
16
+ from pydantic import Field
17
+
18
+ from hoshimi.api.paths import route_path
19
+ from hoshimi.core.errors import HoshimiError, ModelNotFoundError, UpstreamError, ValidationError
20
+ from hoshimi.core.record import Record
21
+
22
+ # Domain code -> the ``type`` OpenAI clients expect.
23
+ OPENAI_ERROR_TYPES = {
24
+ "not_found": "invalid_request_error",
25
+ "model_not_found": "invalid_request_error",
26
+ "invalid_input": "invalid_request_error",
27
+ "conflict": "invalid_request_error",
28
+ "upstream_error": "upstream_error",
29
+ "quota_exceeded": "upstream_error",
30
+ "auth_failed": "upstream_error",
31
+ "internal_error": "server_error",
32
+ }
33
+
34
+
35
+ class ErrorResponse(Record):
36
+ code: str
37
+ message: str
38
+ detail: dict[str, Any] = Field(default_factory=dict)
39
+
40
+
41
+ class AnthropicErrorDetail(Record):
42
+ type: str
43
+ message: str
44
+
45
+
46
+ class AnthropicErrorResponse(Record):
47
+ type: str
48
+ error: AnthropicErrorDetail
49
+
50
+
51
+ ANTHROPIC_ERROR_RESPONSES: dict[int | str, dict[str, Any]] = {status: {"model": AnthropicErrorResponse} for status in (400, 401, 403, 404, 429, 500, 502, 503, "default")}
52
+
53
+
54
+ def openai_error(message: str, error_type: str, code: str | None = None) -> dict[str, Any]:
55
+ return {"error": {"message": message, "type": error_type, "code": code}}
56
+
57
+
58
+ def is_messages_path(path: str) -> bool:
59
+ return path.rstrip("/").endswith(("/v1/messages", "/v1/messages/count_tokens"))
60
+
61
+
62
+ def anthropic_error(message: str, status: int) -> dict[str, Any]:
63
+ kind = {
64
+ 400: "invalid_request_error",
65
+ 401: "authentication_error",
66
+ 403: "permission_error",
67
+ 404: "not_found_error",
68
+ 413: "request_too_large",
69
+ 429: "rate_limit_error",
70
+ 529: "overloaded_error",
71
+ }.get(status, "api_error")
72
+ return {"type": "error", "error": {"type": kind, "message": message}}
73
+
74
+
75
+ def error_type_for(exc: HoshimiError) -> str:
76
+ if isinstance(exc, UpstreamError):
77
+ return "upstream_error"
78
+ if isinstance(exc, (ModelNotFoundError, ValidationError)):
79
+ return "invalid_request_error"
80
+ return OPENAI_ERROR_TYPES.get(exc.code, "server_error")
81
+
82
+
83
+ def error_response(status: int, code: str, message: str, detail: dict[str, Any] | None = None, headers: dict[str, str] | None = None) -> JSONResponse:
84
+ return JSONResponse(status_code=status, content={"code": code, "message": message, "detail": detail or {}}, headers=headers)
85
+
86
+
87
+ async def _domain_error(request: Request, exc: Exception) -> JSONResponse:
88
+ assert isinstance(exc, HoshimiError)
89
+ if is_messages_path(route_path(request.scope)):
90
+ return JSONResponse(status_code=exc.http_status, content=anthropic_error(exc.message, exc.http_status))
91
+ if route_path(request.scope).startswith("/v1/"):
92
+ payload = openai_error(exc.message, error_type_for(exc), code=exc.code if exc.code != "internal_error" else None)
93
+ return JSONResponse(status_code=exc.http_status, content=payload)
94
+ return error_response(exc.http_status, exc.code, exc.message, exc.detail)
95
+
96
+
97
+ def install_error_handlers(app: FastAPI) -> None:
98
+ app.add_exception_handler(HoshimiError, _domain_error)
99
+ app.add_exception_handler(RequestValidationError, _validation_error)
100
+
101
+
102
+ async def _validation_error(request: Request, exc: Exception) -> JSONResponse:
103
+ assert isinstance(exc, RequestValidationError)
104
+ if is_messages_path(route_path(request.scope)):
105
+ return JSONResponse(status_code=400, content=anthropic_error("Invalid Messages request body.", 400))
106
+ return await request_validation_exception_handler(request, exc)
107
+
108
+
109
+ # Declared on routes so the generated client knows the error shape.
110
+ ERROR_RESPONSES: dict[int | str, dict[str, Any]] = {
111
+ 400: {"model": ErrorResponse},
112
+ 404: {"model": ErrorResponse},
113
+ 403: {"model": ErrorResponse},
114
+ 409: {"model": ErrorResponse},
115
+ }
@@ -0,0 +1,48 @@
1
+ """Shared model selection and accounting for the public inference protocols."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from collections.abc import Iterator
7
+ from typing import Any
8
+
9
+ from hoshimi.core.context import Services
10
+ from hoshimi.core.effort import requested_level, resolve_level
11
+ from hoshimi.core.errors import ValidationError
12
+
13
+
14
+ def prepare_request(services: Services, body: dict[str, Any]) -> tuple[str, dict[str, Any]]:
15
+ model = body.get("model")
16
+ if not isinstance(model, str) or not model.strip():
17
+ model = services.settings.default_model or services.registry.default_model or ""
18
+ if not model:
19
+ raise ValidationError("Missing 'model' field.")
20
+ body = dict(body)
21
+ body["model"] = model
22
+ body.setdefault("prompt_cache_key", f"hoshimi-{int(time.time())}")
23
+ return model, body
24
+
25
+
26
+ def tracked_chunks(
27
+ services: Services,
28
+ model: str,
29
+ body: dict[str, Any],
30
+ *,
31
+ origin: str,
32
+ ) -> Iterator[dict[str, Any]]:
33
+ """The upstream stream with this call's usage, latency and outcome recorded.
34
+
35
+ The turn starts here so a routing failure still counts: a request the
36
+ gateway refused is a request it answered. Effort is the rung the wire
37
+ carries: a caller who named none sends none, and the sample must not
38
+ claim a level the body never had.
39
+ """
40
+ level = requested_level(body)
41
+ effort = (resolve_level(level, services.registry.spec(model)) or "") if level else ""
42
+ turn = services.stats.turn(model, effort=effort, origin=origin)
43
+ try:
44
+ upstream = services.registry.stream(model, body)
45
+ except BaseException:
46
+ turn.fail()
47
+ raise
48
+ return turn.wrap(upstream)
hoshimi/api/paths.py ADDED
@@ -0,0 +1,33 @@
1
+ """Deployment paths shared by mounted apps, authentication and Socket.IO."""
2
+
3
+ from urllib.parse import urlsplit
4
+
5
+ from starlette import _utils
6
+ from starlette.requests import Request
7
+ from starlette.types import Scope
8
+
9
+
10
+ def route_path(scope: Scope) -> str:
11
+ """Older Starlette strips mount prefixes; newer versions leave that to the router."""
12
+ resolve = getattr(_utils, "get_route_path", None)
13
+ return resolve(scope) if resolve is not None else scope.get("path", "")
14
+
15
+
16
+ def public_prefix(request: Request) -> str:
17
+ base = getattr(request.app.state, "public_base_url", None)
18
+ if base:
19
+ return urlsplit(base).path.rstrip("/")
20
+ root = request.scope.get("root_path", "").rstrip("/")
21
+ return root + (getattr(request.app.state, "api_prefix", "") or "")
22
+
23
+
24
+ def validate_public_base_url(value: str | None) -> str | None:
25
+ """Only the host application may supply this URL; never derive it from forwarded headers."""
26
+ if value is None:
27
+ return None
28
+ url = urlsplit(value)
29
+ if url.scheme not in ("http", "https") or not url.hostname or url.username is not None or url.password is not None or url.query or url.fragment:
30
+ raise ValueError("public_base_url must be an absolute HTTP(S) URL without credentials, query or fragment")
31
+ if any(c.isspace() for c in value) or "\\" in value:
32
+ raise ValueError("public_base_url contains invalid characters")
33
+ return value.rstrip("/")
File without changes
@@ -0,0 +1,64 @@
1
+ """App version, health and what the interface needs to know about this server."""
2
+
3
+ from fastapi import APIRouter, Request
4
+
5
+ from hoshimi.api.deps import ServicesDep
6
+ from hoshimi.core.net.ports import is_loopback
7
+ from hoshimi.core.providers import real_account_count
8
+ from hoshimi.core.record import Record
9
+ from hoshimi.version import VERSION
10
+
11
+ router = APIRouter(prefix="/v1/app", tags=["app"])
12
+
13
+
14
+ class AppVersion(Record):
15
+ version: str
16
+
17
+
18
+ class Health(Record):
19
+ status: str
20
+ auth_required: bool
21
+
22
+
23
+ class AppMeta(Record):
24
+ version: str
25
+ auth_required: bool
26
+ model_api_key_configured: bool
27
+ local: bool
28
+ """True when this request comes from the server's own machine."""
29
+ api_prefix: str
30
+ data_dir: str
31
+ config_path: str | None
32
+ default_model: str | None
33
+ providers: int
34
+ accounts: int
35
+
36
+
37
+ @router.get("/version", operation_id="get_app_version")
38
+ def get_version() -> AppVersion:
39
+ return AppVersion(version=VERSION)
40
+
41
+
42
+ @router.get("/health", operation_id="get_health")
43
+ def get_health(services: ServicesDep) -> Health:
44
+ return Health(status="ok", auth_required=bool(services.settings.access_token))
45
+
46
+
47
+ @router.get("/meta", operation_id="get_app_meta")
48
+ def get_meta(request: Request, services: ServicesDep) -> AppMeta:
49
+ client = request.client.host if request.client else ""
50
+ accounts = sum(
51
+ real_account_count(provider) for provider in services.registry.providers.values()
52
+ )
53
+ return AppMeta(
54
+ version=VERSION,
55
+ auth_required=bool(services.settings.access_token),
56
+ model_api_key_configured=bool(services.settings.model_api_key),
57
+ local=bool(client) and (is_loopback(client) or client == "testclient"),
58
+ api_prefix=getattr(request.app.state, "api_prefix", "") or "",
59
+ data_dir=str(services.data_dir),
60
+ config_path=str(services.config_path) if services.config_path else None,
61
+ default_model=services.settings.default_model,
62
+ providers=len(services.registry.providers),
63
+ accounts=accounts,
64
+ )
@@ -0,0 +1,197 @@
1
+ """Model capability catalogue for the web UI: what each id can do."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from collections.abc import AsyncIterator, Iterable
8
+ from typing import Literal
9
+
10
+ from fastapi import APIRouter
11
+ from fastapi.responses import StreamingResponse
12
+ from pydantic import Field
13
+
14
+ from hoshimi.api.deps import ServicesDep
15
+ from hoshimi.api.routers.probe import ProbeState
16
+ from hoshimi.core.catalog import ModelSpec
17
+ from hoshimi.core.effort import LEVELS, efforts_for
18
+ from hoshimi.core.record import Record
19
+
20
+ router = APIRouter(prefix="/v1", tags=["models"])
21
+ CATALOG_TIMEOUT_SECONDS = 20.0
22
+
23
+
24
+ class EffortOption(Record):
25
+ id: str
26
+ name: str
27
+ description: str = ""
28
+ is_default: bool = False
29
+ tokens: int | None = None
30
+
31
+
32
+ class ModelSpecObject(Record):
33
+ id: str
34
+ provider: str
35
+ name: str
36
+ wire: str = "chat"
37
+ vision: bool = False
38
+ reasoning: bool = False
39
+ tools: bool | None = None
40
+ context_window: int = 0
41
+ max_output: int = 0
42
+ can_disable_thinking: bool = True
43
+ region_sensitive: bool = False
44
+ # Whether calling this id costs the account anything, per the station that
45
+ # serves it. None is "the station did not say", which is most ids at most
46
+ # stations — a price list is not something every gateway publishes.
47
+ free: bool | None = None
48
+ efforts: list[EffortOption]
49
+ effort_default: str | None = None
50
+ # The last availability verdict for this id, when a round has asked about it.
51
+ probe: ProbeState | None = None
52
+ # The gateway's own verdict: the station answered 404/410 for every
53
+ # account, so the id is banned for a while. Unlike `probe` this was not
54
+ # asked for — it is what serving this model taught the gateway.
55
+ unavailable: bool = False
56
+
57
+
58
+ class ModelSpecList(Record):
59
+ levels: list[EffortOption]
60
+ data: list[ModelSpecObject]
61
+
62
+
63
+ class CatalogProvider(Record):
64
+ id: str
65
+ status: Literal["loading", "ready", "failed", "timeout"]
66
+ data: list[ModelSpecObject]
67
+
68
+
69
+ class CatalogEvent(Record):
70
+ type: Literal["snapshot", "provider", "done"]
71
+ levels: list[EffortOption] = Field(default_factory=list)
72
+ providers: list[CatalogProvider] = Field(default_factory=list)
73
+ provider: CatalogProvider | None = None
74
+
75
+
76
+ class CatalogStreamResponse(StreamingResponse):
77
+ """Describe an individual event under the actual SSE media type in OpenAPI."""
78
+
79
+ media_type = "text/event-stream"
80
+
81
+
82
+ def _probe_state(services, model: str) -> ProbeState | None:
83
+ row = services.probe.state_for(model)
84
+ return ProbeState(**row) if row else None
85
+
86
+
87
+ @router.get("/models", operation_id="list_model_specs")
88
+ def list_model_specs(services: ServicesDep) -> ModelSpecList:
89
+ """Every advertised model with its capability spec and effort menu.
90
+
91
+ `levels` is the free lane's token ladder (what a budget-driven model
92
+ offers); each model carries its own `efforts` when it has a menu.
93
+ """
94
+ return ModelSpecList(levels=_levels(), data=_model_objects(services, services.registry.catalog()))
95
+
96
+
97
+ def _levels() -> list[EffortOption]:
98
+ return [
99
+ EffortOption(
100
+ id=level.id,
101
+ name=level.name,
102
+ description=level.hint,
103
+ is_default=level.id == "balanced",
104
+ tokens=None,
105
+ )
106
+ for level in LEVELS
107
+ ]
108
+
109
+
110
+ def _model_objects(services, specs: Iterable[ModelSpec]) -> list[ModelSpecObject]:
111
+ data: list[ModelSpecObject] = []
112
+ for spec in specs:
113
+ options = efforts_for(spec) or []
114
+ data.append(
115
+ ModelSpecObject(
116
+ id=spec.id,
117
+ provider=spec.provider,
118
+ name=spec.name,
119
+ wire=spec.wire,
120
+ vision=spec.vision,
121
+ reasoning=spec.reasoning,
122
+ tools=spec.tools,
123
+ context_window=spec.context_window,
124
+ max_output=spec.max_output,
125
+ can_disable_thinking=spec.can_disable_thinking,
126
+ region_sensitive=spec.region_sensitive,
127
+ free=spec.free,
128
+ efforts=[EffortOption(**option) for option in options],
129
+ effort_default=next((option["id"] for option in options if option.get("is_default")), None),
130
+ probe=_probe_state(services, spec.id),
131
+ unavailable=services.registry.dead_models.is_dead(spec.provider, spec.id),
132
+ )
133
+ )
134
+ return data
135
+
136
+
137
+ @router.get("/catalog/stream", operation_id="stream_catalog", response_class=CatalogStreamResponse,
138
+ responses={200: {"model": CatalogEvent}})
139
+ def stream_catalog(services: ServicesDep) -> StreamingResponse:
140
+ """Send cached rows first, then each provider independently in completion order."""
141
+ return CatalogStreamResponse(catalog_events(services), headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
142
+
143
+
144
+ async def catalog_events(services) -> AsyncIterator[str]:
145
+ discovery = services.registry.discovery
146
+ # Snapshot the actual objects: account edits must not mix old and new pools.
147
+ providers = services.registry.ordered_providers()
148
+
149
+ def row(pid, provider, status, specs=None):
150
+ try:
151
+ data = _model_objects(services, (specs if specs is not None else discovery.cached(provider).specs).values())
152
+ except Exception: # noqa: BLE001 - malformed capabilities are isolated to their provider
153
+ return CatalogProvider(id=pid, status="failed", data=[])
154
+ return CatalogProvider(id=pid, status=status, data=data)
155
+
156
+ def event(value: CatalogEvent) -> str:
157
+ return f"data: {value.model_dump_json()}\n\n"
158
+
159
+ yield event(CatalogEvent(type="snapshot", levels=_levels(), providers=[row(pid, provider, "loading") for pid, provider in providers]))
160
+ pending = {asyncio.shield(asyncio.wrap_future(discovery.submit(provider))): (pid, provider) for pid, provider in providers}
161
+ deadline = time.monotonic() + CATALOG_TIMEOUT_SECONDS
162
+ try:
163
+ while pending:
164
+ done, _ = await asyncio.wait(pending, timeout=min(1.0, max(0, deadline - time.monotonic())), return_when=asyncio.FIRST_COMPLETED)
165
+ for future in done:
166
+ pid, provider = pending.pop(future)
167
+ result = future.result()
168
+ yield event(CatalogEvent(type="provider", provider=row(pid, provider, "failed" if result.failed else "ready", result.specs)))
169
+ if pending and time.monotonic() >= deadline:
170
+ for pid, provider in pending.values():
171
+ yield event(CatalogEvent(type="provider", provider=row(pid, provider, "timeout")))
172
+ break
173
+ if pending and not done:
174
+ yield ": keep-alive\n\n"
175
+ yield event(CatalogEvent(type="done"))
176
+ finally:
177
+ # Shielded futures detach this subscriber without cancelling another
178
+ # browser's request or releasing a still-running synchronous worker.
179
+ for future in pending:
180
+ future.cancel()
181
+
182
+
183
+ class UnavailableReset(Record):
184
+ cleared: int
185
+
186
+
187
+ @router.post("/models/unavailable/reset", operation_id="reset_unavailable_models")
188
+ def reset_unavailable_models(services: ServicesDep) -> UnavailableReset:
189
+ """Let every banned id back in.
190
+
191
+ The ban is the gateway remembering a refusal; this is the operator saying
192
+ the station has been fixed. A turn that gets an answer clears its own entry
193
+ anyway, so what this is really for is the entries nothing will retry: an id
194
+ no client asks for any more, or one that will 404 again the moment it is
195
+ tried and so can never be the request that clears it.
196
+ """
197
+ return UnavailableReset(cleared=services.registry.dead_models.clear_all())