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.
- hoshimi/__init__.py +15 -0
- hoshimi/__main__.py +6 -0
- hoshimi/_exports.py +22 -0
- hoshimi/api/__init__.py +0 -0
- hoshimi/api/app.py +98 -0
- hoshimi/api/deps.py +14 -0
- hoshimi/api/errors.py +115 -0
- hoshimi/api/inference.py +48 -0
- hoshimi/api/paths.py +33 -0
- hoshimi/api/routers/__init__.py +0 -0
- hoshimi/api/routers/app_info.py +64 -0
- hoshimi/api/routers/catalog.py +197 -0
- hoshimi/api/routers/chat.py +126 -0
- hoshimi/api/routers/classification.py +42 -0
- hoshimi/api/routers/credits.py +167 -0
- hoshimi/api/routers/health.py +10 -0
- hoshimi/api/routers/login.py +92 -0
- hoshimi/api/routers/messages.py +122 -0
- hoshimi/api/routers/models.py +44 -0
- hoshimi/api/routers/playground.py +10 -0
- hoshimi/api/routers/probe.py +81 -0
- hoshimi/api/routers/providers.py +318 -0
- hoshimi/api/routers/refresh.py +68 -0
- hoshimi/api/routers/settings.py +121 -0
- hoshimi/api/routers/stats.py +75 -0
- hoshimi/api/security.py +124 -0
- hoshimi/api/static.py +49 -0
- hoshimi/cli/__init__.py +0 -0
- hoshimi/cli/app.py +69 -0
- hoshimi/cli/commands/__init__.py +0 -0
- hoshimi/cli/commands/check.py +58 -0
- hoshimi/cli/commands/config.py +156 -0
- hoshimi/cli/commands/login.py +83 -0
- hoshimi/cli/commands/models.py +29 -0
- hoshimi/cli/commands/providers.py +45 -0
- hoshimi/cli/commands/serve.py +92 -0
- hoshimi/cli/commands/stations.py +46 -0
- hoshimi/cli/commands/system.py +55 -0
- hoshimi/cli/factory.py +91 -0
- hoshimi/cli/output.py +67 -0
- hoshimi/core/__init__.py +1 -0
- hoshimi/core/catalog.py +1334 -0
- hoshimi/core/chat_catalog.py +274 -0
- hoshimi/core/chunks.py +571 -0
- hoshimi/core/config.py +262 -0
- hoshimi/core/context.py +159 -0
- hoshimi/core/credentials.py +47 -0
- hoshimi/core/credits.py +232 -0
- hoshimi/core/discovery.py +60 -0
- hoshimi/core/eac_relay.py +45 -0
- hoshimi/core/eac_seal.py +51 -0
- hoshimi/core/eac_user.py +132 -0
- hoshimi/core/effort.py +328 -0
- hoshimi/core/errors.py +67 -0
- hoshimi/core/expiry.py +96 -0
- hoshimi/core/failure.py +603 -0
- hoshimi/core/free_marks.py +330 -0
- hoshimi/core/login/__init__.py +31 -0
- hoshimi/core/login/buddy.py +231 -0
- hoshimi/core/login/cline.py +187 -0
- hoshimi/core/login/codearts.py +222 -0
- hoshimi/core/login/codex.py +153 -0
- hoshimi/core/login/common.py +215 -0
- hoshimi/core/login/eac.py +220 -0
- hoshimi/core/login/gemini.py +143 -0
- hoshimi/core/login/kilo.py +49 -0
- hoshimi/core/login/lobsterai.py +132 -0
- hoshimi/core/login/loomy.py +326 -0
- hoshimi/core/login/manager.py +297 -0
- hoshimi/core/login/minimax.py +165 -0
- hoshimi/core/login/provider_oauth.py +281 -0
- hoshimi/core/login/qoder.py +133 -0
- hoshimi/core/login/raccoon.py +101 -0
- hoshimi/core/login/trae.py +196 -0
- hoshimi/core/login/zcode.py +135 -0
- hoshimi/core/message_chunks.py +199 -0
- hoshimi/core/modelsdev.py +226 -0
- hoshimi/core/net/__init__.py +1 -0
- hoshimi/core/net/ports.py +66 -0
- hoshimi/core/net/runtime_file.py +51 -0
- hoshimi/core/paths.py +22 -0
- hoshimi/core/presets.py +103 -0
- hoshimi/core/probe.py +313 -0
- hoshimi/core/provider_definitions.py +103 -0
- hoshimi/core/provider_forms.py +66 -0
- hoshimi/core/provider_sources.py +170 -0
- hoshimi/core/providers/__init__.py +79 -0
- hoshimi/core/providers/agg.py +342 -0
- hoshimi/core/providers/base.py +587 -0
- hoshimi/core/providers/bedrock.py +188 -0
- hoshimi/core/providers/buddy.py +299 -0
- hoshimi/core/providers/catalog_profiles.py +156 -0
- hoshimi/core/providers/cline.py +140 -0
- hoshimi/core/providers/cloud.py +306 -0
- hoshimi/core/providers/codearts.py +390 -0
- hoshimi/core/providers/compat.py +44 -0
- hoshimi/core/providers/configured.py +147 -0
- hoshimi/core/providers/eac.py +205 -0
- hoshimi/core/providers/factory.py +148 -0
- hoshimi/core/providers/gemini.py +191 -0
- hoshimi/core/providers/keyed.py +144 -0
- hoshimi/core/providers/kilo.py +106 -0
- hoshimi/core/providers/lobsterai.py +226 -0
- hoshimi/core/providers/local.py +127 -0
- hoshimi/core/providers/loomy.py +140 -0
- hoshimi/core/providers/minimax.py +228 -0
- hoshimi/core/providers/nim_tools.py +120 -0
- hoshimi/core/providers/nvidia.py +54 -0
- hoshimi/core/providers/openai_compat.py +399 -0
- hoshimi/core/providers/opencode.py +278 -0
- hoshimi/core/providers/opencode_fingerprint.py +189 -0
- hoshimi/core/providers/qoder.py +279 -0
- hoshimi/core/providers/raccoon.py +122 -0
- hoshimi/core/providers/radius.py +138 -0
- hoshimi/core/providers/request_profiles.py +115 -0
- hoshimi/core/providers/station_catalogs.py +133 -0
- hoshimi/core/providers/trae.py +314 -0
- hoshimi/core/providers/transports.py +52 -0
- hoshimi/core/providers/typesafe.py +75 -0
- hoshimi/core/providers/zcode.py +191 -0
- hoshimi/core/providers/zhipu.py +86 -0
- hoshimi/core/quota.py +260 -0
- hoshimi/core/record.py +108 -0
- hoshimi/core/refresh.py +215 -0
- hoshimi/core/registry.py +285 -0
- hoshimi/core/security.py +171 -0
- hoshimi/core/sse.py +47 -0
- hoshimi/core/stats.py +379 -0
- hoshimi/core/translate/__init__.py +1 -0
- hoshimi/core/translate/anthropic.py +411 -0
- hoshimi/core/translate/chat.py +123 -0
- hoshimi/core/translate/gemini.py +394 -0
- hoshimi/core/translate/messages.py +235 -0
- hoshimi/core/translate/responses.py +494 -0
- hoshimi/core/translate/solo.py +214 -0
- hoshimi/logger.py +24 -0
- hoshimi/version.py +3 -0
- hoshimi/webui/__init__.py +1 -0
- hoshimi/webui/dist/assets/CatalogStatus-DK53lfUj.js +1 -0
- hoshimi/webui/dist/assets/CatalogStatus-DoMGFxBl.css +1 -0
- hoshimi/webui/dist/assets/ChannelsView-DAA40MJQ.css +1 -0
- hoshimi/webui/dist/assets/ChannelsView-VHHiYbFT.js +1 -0
- hoshimi/webui/dist/assets/ModelsView-Dzkv4gsI.css +1 -0
- hoshimi/webui/dist/assets/ModelsView-Njln1vzT.js +1 -0
- hoshimi/webui/dist/assets/OverviewView-BYFKlKNF.css +1 -0
- hoshimi/webui/dist/assets/OverviewView-Cvv0hBNT.js +1 -0
- hoshimi/webui/dist/assets/PlaygroundView-CjPN6bYp.js +2 -0
- hoshimi/webui/dist/assets/PlaygroundView-DhkBVqGM.css +1 -0
- hoshimi/webui/dist/assets/SettingsView-CqhSedwu.css +1 -0
- hoshimi/webui/dist/assets/SettingsView-DX_OQgUT.js +1 -0
- hoshimi/webui/dist/assets/UsageView-BR0j3K0v.js +1 -0
- hoshimi/webui/dist/assets/UsageView-DuKrmgwU.css +1 -0
- hoshimi/webui/dist/assets/app-CmEs1pbN.js +2 -0
- hoshimi/webui/dist/assets/format-CoK4W4Zn.js +1 -0
- hoshimi/webui/dist/assets/index-CZXOwalx.css +1 -0
- hoshimi/webui/dist/assets/index-DCJqkKVJ.js +650 -0
- hoshimi/webui/dist/assets/roboto-flex-cyrillic-ext-wght-normal-CVcCQn0y.woff2 +0 -0
- hoshimi/webui/dist/assets/roboto-flex-cyrillic-wght-normal-BJ-nQEdz.woff2 +0 -0
- hoshimi/webui/dist/assets/roboto-flex-greek-wght-normal-DeN4rzfy.woff2 +0 -0
- hoshimi/webui/dist/assets/roboto-flex-latin-ext-wght-normal-gysdOMcS.woff2 +0 -0
- hoshimi/webui/dist/assets/roboto-flex-latin-wght-normal-n-dP9qr2.woff2 +0 -0
- hoshimi/webui/dist/assets/roboto-flex-vietnamese-wght-normal-AVFfiTln.woff2 +0 -0
- hoshimi/webui/dist/favicon.svg +18 -0
- hoshimi/webui/dist/index.html +28 -0
- hoshimi-0.1.0.dist-info/METADATA +933 -0
- hoshimi-0.1.0.dist-info/RECORD +170 -0
- hoshimi-0.1.0.dist-info/WHEEL +5 -0
- hoshimi-0.1.0.dist-info/entry_points.txt +2 -0
- hoshimi-0.1.0.dist-info/licenses/LICENSE +674 -0
- 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
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__)
|
hoshimi/api/__init__.py
ADDED
|
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
|
+
}
|
hoshimi/api/inference.py
ADDED
|
@@ -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())
|