rosetta-llm 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.
- rosetta/__init__.py +3 -0
- rosetta/__main__.py +78 -0
- rosetta/app.py +74 -0
- rosetta/asgi.py +18 -0
- rosetta/auth.py +88 -0
- rosetta/codecs/__init__.py +1 -0
- rosetta/codecs/anthropic.py +474 -0
- rosetta/codecs/openai_chat.py +494 -0
- rosetta/codecs/openai_responses.py +615 -0
- rosetta/config.py +106 -0
- rosetta/errors.py +35 -0
- rosetta/ir/__init__.py +1 -0
- rosetta/ir/events.py +73 -0
- rosetta/ir/helpers.py +13 -0
- rosetta/ir/request.py +123 -0
- rosetta/ir/response.py +32 -0
- rosetta/observability.py +82 -0
- rosetta/pipeline.py +371 -0
- rosetta/routes/__init__.py +1 -0
- rosetta/routes/chat_completions.py +17 -0
- rosetta/routes/deps.py +28 -0
- rosetta/routes/health.py +48 -0
- rosetta/routes/messages.py +23 -0
- rosetta/routes/models.py +368 -0
- rosetta/routes/responses.py +17 -0
- rosetta/routes/schemas.py +51 -0
- rosetta/stop_reasons.py +30 -0
- rosetta/stream_codecs/__init__.py +1 -0
- rosetta/stream_codecs/anthropic.py +291 -0
- rosetta/stream_codecs/openai_chat.py +294 -0
- rosetta/stream_codecs/openai_responses.py +323 -0
- rosetta/tokens.py +81 -0
- rosetta/upstream.py +91 -0
- rosetta_llm-0.1.0.dist-info/METADATA +197 -0
- rosetta_llm-0.1.0.dist-info/RECORD +37 -0
- rosetta_llm-0.1.0.dist-info/WHEEL +4 -0
- rosetta_llm-0.1.0.dist-info/entry_points.txt +2 -0
rosetta/__init__.py
ADDED
rosetta/__main__.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Entrypoint for `python -m rosetta` and the `rosetta-llm` CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import uvicorn
|
|
10
|
+
|
|
11
|
+
from rosetta.app import create_app
|
|
12
|
+
from rosetta.config import load_config
|
|
13
|
+
from rosetta.observability import setup_logging
|
|
14
|
+
|
|
15
|
+
DEFAULT_CONFIG = str(Path.home() / ".rosetta-llm" / "config.json")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def main() -> None:
|
|
19
|
+
parser = argparse.ArgumentParser(
|
|
20
|
+
prog="rosetta-llm",
|
|
21
|
+
description="Multi-format bidirectional LLM proxy",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"--config",
|
|
25
|
+
"-c",
|
|
26
|
+
default=os.environ.get("ROSETTA_CONFIG", DEFAULT_CONFIG),
|
|
27
|
+
help=f"Path to config.json (default: {DEFAULT_CONFIG})",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--host",
|
|
31
|
+
default=None,
|
|
32
|
+
help="Bind address (overrides config)",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--port",
|
|
36
|
+
"-p",
|
|
37
|
+
type=int,
|
|
38
|
+
default=None,
|
|
39
|
+
help="Bind port (overrides config)",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--proxy-headers",
|
|
43
|
+
action=argparse.BooleanOptionalAction,
|
|
44
|
+
default=True,
|
|
45
|
+
help="Trust X-Forwarded-* headers from proxies (default: on)",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--forwarded-allow-ips",
|
|
49
|
+
default="*",
|
|
50
|
+
help="Allowed forwarded IPs (default: *)",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
args = parser.parse_args()
|
|
54
|
+
|
|
55
|
+
config = load_config(args.config)
|
|
56
|
+
setup_logging(config.log_level)
|
|
57
|
+
|
|
58
|
+
if args.host is not None:
|
|
59
|
+
config.host = args.host
|
|
60
|
+
if args.port is not None:
|
|
61
|
+
config.port = args.port
|
|
62
|
+
|
|
63
|
+
app = create_app(config)
|
|
64
|
+
|
|
65
|
+
uvicorn.run(
|
|
66
|
+
app,
|
|
67
|
+
host=config.host,
|
|
68
|
+
port=config.port,
|
|
69
|
+
log_level=config.log_level,
|
|
70
|
+
access_log=False,
|
|
71
|
+
log_config=None,
|
|
72
|
+
proxy_headers=args.proxy_headers,
|
|
73
|
+
forwarded_allow_ips=args.forwarded_allow_ips,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
main()
|
rosetta/app.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""FastAPI app factory with lifespan management."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import contextlib
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI
|
|
9
|
+
|
|
10
|
+
from rosetta.auth import AuthMiddleware
|
|
11
|
+
from rosetta.config import Config
|
|
12
|
+
from rosetta.observability import RequestContextMiddleware
|
|
13
|
+
from rosetta.routes.chat_completions import router as chat_router
|
|
14
|
+
from rosetta.routes.health import router as health_router
|
|
15
|
+
from rosetta.routes.messages import router as messages_router
|
|
16
|
+
from rosetta.routes.models import refresh_models
|
|
17
|
+
from rosetta.routes.models import router as models_router
|
|
18
|
+
from rosetta.routes.responses import router as responses_router
|
|
19
|
+
from rosetta.upstream import UpstreamClient
|
|
20
|
+
|
|
21
|
+
_REFRESH_INTERVAL = 300 # seconds
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def _models_refresh_loop(app: FastAPI, interval: float) -> None:
|
|
25
|
+
while True:
|
|
26
|
+
try:
|
|
27
|
+
await asyncio.sleep(interval)
|
|
28
|
+
await refresh_models(app.state.config, app.state.upstream, app.state.model_snapshot)
|
|
29
|
+
except asyncio.CancelledError:
|
|
30
|
+
break
|
|
31
|
+
except Exception:
|
|
32
|
+
from rosetta.observability import get_logger
|
|
33
|
+
|
|
34
|
+
get_logger().exception("models_refresh_loop_error")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@asynccontextmanager
|
|
38
|
+
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
39
|
+
app.state.upstream = UpstreamClient(app.state.config.providers)
|
|
40
|
+
await app.state.upstream.start()
|
|
41
|
+
|
|
42
|
+
refresh_task = asyncio.create_task(
|
|
43
|
+
refresh_models(app.state.config, app.state.upstream, app.state.model_snapshot)
|
|
44
|
+
)
|
|
45
|
+
loop_task = asyncio.create_task(_models_refresh_loop(app, _REFRESH_INTERVAL))
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
yield
|
|
49
|
+
finally:
|
|
50
|
+
loop_task.cancel()
|
|
51
|
+
refresh_task.cancel()
|
|
52
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
53
|
+
await loop_task
|
|
54
|
+
await refresh_task
|
|
55
|
+
await app.state.upstream.close()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def create_app(config: Config) -> FastAPI:
|
|
59
|
+
app = FastAPI(lifespan=lifespan)
|
|
60
|
+
app.state.config = config
|
|
61
|
+
app.state.provider_status = {}
|
|
62
|
+
app.state.model_snapshot = {}
|
|
63
|
+
app.state.model_refresh_lock = asyncio.Lock()
|
|
64
|
+
|
|
65
|
+
app.add_middleware(AuthMiddleware)
|
|
66
|
+
app.add_middleware(RequestContextMiddleware)
|
|
67
|
+
|
|
68
|
+
app.include_router(health_router)
|
|
69
|
+
app.include_router(messages_router)
|
|
70
|
+
app.include_router(chat_router)
|
|
71
|
+
app.include_router(responses_router)
|
|
72
|
+
app.include_router(models_router)
|
|
73
|
+
|
|
74
|
+
return app
|
rosetta/asgi.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""ASGI entrypoint — creates the app at module level so uvicorn can import it.
|
|
2
|
+
|
|
3
|
+
uvicorn rosetta.asgi:app --host 0.0.0.0 --port 7860
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from rosetta.app import create_app
|
|
12
|
+
from rosetta.config import load_config
|
|
13
|
+
from rosetta.observability import setup_logging
|
|
14
|
+
|
|
15
|
+
config_path = os.environ.get("ROSETTA_CONFIG", str(Path.home() / ".rosetta-llm" / "config.json"))
|
|
16
|
+
config = load_config(config_path)
|
|
17
|
+
setup_logging(config.log_level)
|
|
18
|
+
app = create_app(config)
|
rosetta/auth.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Bearer-token authentication as a pure ASGI middleware.
|
|
2
|
+
|
|
3
|
+
BaseHTTPMiddleware buffers streaming responses, which would break SSE.
|
|
4
|
+
Implementing as raw ASGI keeps streams flowing chunk-by-chunk.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Awaitable, Callable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import orjson
|
|
13
|
+
|
|
14
|
+
ASGIApp = Callable[
|
|
15
|
+
[
|
|
16
|
+
dict[str, Any],
|
|
17
|
+
Callable[[], Awaitable[dict[str, Any]]],
|
|
18
|
+
Callable[[dict[str, Any]], Awaitable[None]],
|
|
19
|
+
],
|
|
20
|
+
Awaitable[None],
|
|
21
|
+
]
|
|
22
|
+
Receive = Callable[[], Awaitable[dict[str, Any]]]
|
|
23
|
+
Send = Callable[[dict[str, Any]], Awaitable[None]]
|
|
24
|
+
|
|
25
|
+
_PUBLIC_PATHS = frozenset({"/health", "/providers"})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AuthMiddleware:
|
|
29
|
+
"""Strict ASGI middleware that rejects requests lacking a valid bearer token."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, app: ASGIApp) -> None:
|
|
32
|
+
self.app = app
|
|
33
|
+
|
|
34
|
+
async def __call__(self, scope: dict[str, Any], receive: Receive, send: Send) -> None:
|
|
35
|
+
if scope["type"] != "http":
|
|
36
|
+
await self.app(scope, receive, send)
|
|
37
|
+
return
|
|
38
|
+
|
|
39
|
+
config = scope["app"].state.config
|
|
40
|
+
keys = set(config.proxy.api_keys)
|
|
41
|
+
if not keys or scope["path"] in _PUBLIC_PATHS:
|
|
42
|
+
await self.app(scope, receive, send)
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
headers = {k.decode().lower(): v.decode() for k, v in scope.get("headers", [])}
|
|
46
|
+
token = ""
|
|
47
|
+
auth_header = headers.get("authorization", "")
|
|
48
|
+
if auth_header[:7].lower() == "bearer ":
|
|
49
|
+
token = auth_header[7:].strip()
|
|
50
|
+
if not token:
|
|
51
|
+
token = headers.get("x-api-key", "").strip()
|
|
52
|
+
|
|
53
|
+
if token in keys:
|
|
54
|
+
await self.app(scope, receive, send)
|
|
55
|
+
return
|
|
56
|
+
|
|
57
|
+
body = _error_body(scope["path"])
|
|
58
|
+
await send(
|
|
59
|
+
{
|
|
60
|
+
"type": "http.response.start",
|
|
61
|
+
"status": 401,
|
|
62
|
+
"headers": [
|
|
63
|
+
(b"content-type", b"application/json"),
|
|
64
|
+
(b"content-length", str(len(body)).encode()),
|
|
65
|
+
],
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
await send({"type": "http.response.body", "body": body})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _error_body(path: str) -> bytes:
|
|
72
|
+
if path.startswith("/v1/messages"):
|
|
73
|
+
return orjson.dumps(
|
|
74
|
+
{
|
|
75
|
+
"type": "error",
|
|
76
|
+
"error": {"type": "authentication_error", "message": "Invalid or missing API key"},
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
return orjson.dumps(
|
|
80
|
+
{
|
|
81
|
+
"error": {
|
|
82
|
+
"message": "Invalid or missing API key",
|
|
83
|
+
"type": "authentication_error",
|
|
84
|
+
"code": None,
|
|
85
|
+
"param": None,
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Non-streaming parse/render codecs for each API format."""
|