modelrelay 0.2.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.
modelrelay/__init__.py ADDED
@@ -0,0 +1,72 @@
1
+ """modelrelay: one small API to call LLMs anywhere.
2
+
3
+ from modelrelay import llm
4
+ print(llm.chat("Hello!", model="gpt-4o").text)
5
+ """
6
+
7
+ from ._util import enable_logging, require
8
+ from .auth import BearerAuth, ClientCredentials, StaticToken, TokenProvider
9
+ from .config import Config
10
+ from .errors import (
11
+ AuthError,
12
+ ConfigError,
13
+ InvalidToolCall,
14
+ JobFailed,
15
+ JobTimeout,
16
+ ModelRelayError,
17
+ PayloadTooLarge,
18
+ ProviderError,
19
+ StreamInterrupted,
20
+ UnexpectedResponse,
21
+ )
22
+ from .relay import Relay
23
+ from .transports import JobState, JobsTransport, OpenAICompatible, Transport
24
+ from .types import ChatRequest, Event, Image, Response, ToolCall, Usage
25
+
26
+ __version__ = "0.2.0"
27
+
28
+
29
+ class _DefaultRelay:
30
+ """`llm`: a Relay created on first use from the default config."""
31
+
32
+ _relay: Relay | None = None
33
+
34
+ def __getattr__(self, name):
35
+ if _DefaultRelay._relay is None:
36
+ _DefaultRelay._relay = Relay()
37
+ return getattr(_DefaultRelay._relay, name)
38
+
39
+
40
+ llm = _DefaultRelay()
41
+
42
+ __all__ = [
43
+ "AuthError",
44
+ "BearerAuth",
45
+ "ChatRequest",
46
+ "ClientCredentials",
47
+ "Config",
48
+ "ConfigError",
49
+ "Event",
50
+ "Image",
51
+ "InvalidToolCall",
52
+ "JobFailed",
53
+ "JobState",
54
+ "JobTimeout",
55
+ "JobsTransport",
56
+ "ModelRelayError",
57
+ "OpenAICompatible",
58
+ "PayloadTooLarge",
59
+ "ProviderError",
60
+ "Relay",
61
+ "Response",
62
+ "StaticToken",
63
+ "StreamInterrupted",
64
+ "TokenProvider",
65
+ "ToolCall",
66
+ "Transport",
67
+ "UnexpectedResponse",
68
+ "Usage",
69
+ "enable_logging",
70
+ "llm",
71
+ "require",
72
+ ]
modelrelay/_util.py ADDED
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ import logging
5
+ import os
6
+ import sys
7
+ from importlib import import_module
8
+ from importlib.metadata import entry_points
9
+ from pathlib import Path
10
+
11
+ from .errors import ConfigError, UnexpectedResponse
12
+
13
+ log = logging.getLogger("modelrelay")
14
+ log.addHandler(logging.NullHandler()) # silent unless the app or MODELRELAY_LOG turns logs on; errors still raise
15
+ _MISSING = object()
16
+
17
+
18
+ def dig(obj, path: str, default=None):
19
+ """dig({"a": {"b": [1, 2]}}, "a.b.1") -> 2. Returns `default` when the path is missing."""
20
+ for key in path.split("."):
21
+ if isinstance(obj, dict) and key in obj:
22
+ obj = obj[key]
23
+ elif isinstance(obj, list) and key.isdigit() and int(key) < len(obj):
24
+ obj = obj[int(key)]
25
+ else:
26
+ return default
27
+ return obj
28
+
29
+
30
+ def require(obj, path: str, what: str = ""):
31
+ """Like dig(), but raises UnexpectedResponse (with the full payload) when the path is missing."""
32
+ value = dig(obj, path, _MISSING)
33
+ if value is _MISSING or value is None:
34
+ label = f" ({what})" if what else ""
35
+ raise UnexpectedResponse(f"Field '{path}'{label} not found in response", body=obj)
36
+ return value
37
+
38
+
39
+ def adapters_dir() -> Path:
40
+ """~/.modelrelay/adapters: single-file adapters that live on this machine only."""
41
+ from .config import config_dir
42
+ return config_dir() / "adapters"
43
+
44
+
45
+ def load_object(name: str, group: str, builtins: dict):
46
+ """Resolves, in this order:
47
+ - a builtin name ("openai_compatible", "jobs", ...);
48
+ - "module:Class", where module is a file in ~/.modelrelay/adapters/ (module.py);
49
+ - "package.module:Class" importable from the environment;
50
+ - an entry point registered under `group` by an installed package.
51
+ """
52
+ if name in builtins:
53
+ return builtins[name]
54
+ if ":" in name:
55
+ module, attr = name.split(":", 1)
56
+ local = adapters_dir() / f"{module}.py"
57
+ mod = _load_file(local, module) if "." not in module and local.is_file() else import_module(module)
58
+ if not hasattr(mod, attr):
59
+ raise ConfigError(f"'{attr}' not found in {getattr(mod, '__file__', module)}")
60
+ return getattr(mod, attr)
61
+ for ep in entry_points(group=group):
62
+ if ep.name == name:
63
+ return ep.load()
64
+ raise ConfigError(
65
+ f"Unknown '{name}' for {group}. Use one of {sorted(builtins)}, 'file:Class' for a file in "
66
+ f"{adapters_dir()}, a 'package.module:Class' path, or an entry point in the '{group}' group."
67
+ )
68
+
69
+
70
+ def _load_file(path: Path, module: str):
71
+ key = f"modelrelay_adapters.{module}"
72
+ if key in sys.modules:
73
+ return sys.modules[key]
74
+ spec = importlib.util.spec_from_file_location(key, path)
75
+ mod = importlib.util.module_from_spec(spec)
76
+ sys.modules[key] = mod
77
+ try:
78
+ spec.loader.exec_module(mod)
79
+ except Exception:
80
+ del sys.modules[key]
81
+ raise
82
+ log.debug("loaded adapter %s", path)
83
+ return mod
84
+
85
+
86
+ def enable_logging(level: str | int = "DEBUG") -> None:
87
+ """Prints modelrelay's logs to stderr. Same as setting MODELRELAY_LOG=debug."""
88
+ handler = logging.StreamHandler()
89
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
90
+ log.addHandler(handler)
91
+ log.setLevel(level.upper() if isinstance(level, str) else level)
92
+
93
+
94
+ if os.environ.get("MODELRELAY_LOG"):
95
+ enable_logging(os.environ["MODELRELAY_LOG"])
modelrelay/auth.py ADDED
@@ -0,0 +1,163 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import threading
5
+ import time
6
+ from typing import Callable
7
+
8
+ import httpx
9
+
10
+ from ._util import dig, log
11
+ from .errors import AuthError, ConfigError
12
+
13
+
14
+ class TokenProvider:
15
+ """Gives out a valid bearer token. `refreshable` providers get a second chance on 401/403."""
16
+
17
+ refreshable = False
18
+
19
+ def get_token(self) -> str:
20
+ raise NotImplementedError
21
+
22
+ def invalidate(self) -> None:
23
+ pass
24
+
25
+
26
+ class StaticToken(TokenProvider):
27
+ """A fixed API key. It never expires."""
28
+
29
+ def __init__(self, token: str | None, missing_hint: str = ""):
30
+ self._token = token
31
+ self._missing_hint = missing_hint
32
+
33
+ def get_token(self) -> str:
34
+ if not self._token:
35
+ raise ConfigError(f"No API key configured. {self._missing_hint}".strip())
36
+ return self._token
37
+
38
+
39
+ class ClientCredentials(TokenProvider):
40
+ """Exchanges a client id + secret for a token that lasts `ttl_minutes`.
41
+
42
+ The token is renewed `refresh_margin_seconds` before it expires. The endpoint's
43
+ field names are configurable; subclass and override `fetch_token` when the
44
+ exchange is more unusual than that.
45
+ """
46
+
47
+ refreshable = True
48
+
49
+ def __init__(
50
+ self,
51
+ http: httpx.Client,
52
+ *,
53
+ token_url: str,
54
+ client_id: str | None,
55
+ client_secret: str | None,
56
+ ttl_minutes: float = 30,
57
+ refresh_margin_seconds: float = 120,
58
+ request_format: str = "json",
59
+ id_field: str = "client_id",
60
+ secret_field: str = "client_secret",
61
+ token_field: str = "access_token",
62
+ extra_fields: dict | None = None,
63
+ clock: Callable[[], float] = time.monotonic,
64
+ ):
65
+ if request_format not in ("json", "form"):
66
+ raise ConfigError("auth_options.request_format must be 'json' or 'form'")
67
+ self.http = http
68
+ self.token_url = token_url
69
+ self.client_id = client_id
70
+ self.client_secret = client_secret
71
+ self.ttl = ttl_minutes * 60
72
+ self.margin = min(refresh_margin_seconds, self.ttl / 2)
73
+ self.request_format = request_format
74
+ self.id_field = id_field
75
+ self.secret_field = secret_field
76
+ self.token_field = token_field
77
+ self.extra_fields = extra_fields or {}
78
+ self._clock = clock
79
+ self._lock = threading.Lock()
80
+ self._token: str | None = None
81
+ self._expires_at = 0.0
82
+
83
+ def get_token(self) -> str:
84
+ with self._lock:
85
+ if self._token is None or self._clock() >= self._expires_at - self.margin:
86
+ log.info("requesting a new token from %s", self.token_url)
87
+ self._token = self.fetch_token()
88
+ self._expires_at = self._clock() + self.ttl
89
+ return self._token
90
+
91
+ def invalidate(self) -> None:
92
+ with self._lock:
93
+ self._token = None
94
+
95
+ def fetch_token(self) -> str:
96
+ if not self.client_id or not self.client_secret:
97
+ raise ConfigError(
98
+ "client_credentials needs a client id and secret: set auth_options.client_id and "
99
+ "auth_options.client_secret in the config file (or $MODELRELAY_CLIENT_ID / $MODELRELAY_CLIENT_SECRET)."
100
+ )
101
+ body = {self.id_field: self.client_id, self.secret_field: self.client_secret, **self.extra_fields}
102
+ kwargs = {"json": body} if self.request_format == "json" else {"data": body}
103
+ try:
104
+ r = self.http.post(self.token_url, **kwargs)
105
+ except httpx.HTTPError as e:
106
+ raise AuthError(f"Could not reach the token endpoint: {type(e).__name__}: {e}", url=self.token_url) from e
107
+ if r.status_code >= 400:
108
+ raise AuthError(f"Token endpoint answered HTTP {r.status_code}: {r.text[:500]}",
109
+ url=self.token_url, status=r.status_code)
110
+ try:
111
+ data = r.json()
112
+ except ValueError as e:
113
+ raise AuthError(f"Token endpoint did not return JSON: {r.text[:500]!r}", url=self.token_url) from e
114
+ token = dig(data, self.token_field)
115
+ if not token:
116
+ fields = sorted(data) if isinstance(data, dict) else type(data).__name__
117
+ raise AuthError(f"Token endpoint response has no '{self.token_field}' field (top-level fields: {fields})",
118
+ url=self.token_url)
119
+ return token
120
+
121
+
122
+ class BearerAuth(httpx.Auth):
123
+ """Adds the token to every request; on 401/403 renews it once and retries."""
124
+
125
+ def __init__(self, provider: TokenProvider):
126
+ self.provider = provider
127
+
128
+ def auth_flow(self, request):
129
+ request.headers["Authorization"] = f"Bearer {self.provider.get_token()}"
130
+ response = yield request
131
+ if response.status_code in (401, 403) and self.provider.refreshable:
132
+ log.info("HTTP %s on %s %s: renewing the token and retrying once",
133
+ response.status_code, request.method, request.url)
134
+ self.provider.invalidate()
135
+ request.headers["Authorization"] = f"Bearer {self.provider.get_token()}"
136
+ yield request
137
+
138
+
139
+ BUILTIN_AUTH = {"static": StaticToken, "client_credentials": ClientCredentials}
140
+
141
+
142
+ def build_token_provider(config, http: httpx.Client) -> TokenProvider:
143
+ from ._util import load_object
144
+
145
+ options = dict(config.auth_options)
146
+ if config.auth == "static":
147
+ key = config.api_key or os.environ.get(config.api_key_env)
148
+ where = config.source or "built in code"
149
+ return StaticToken(key, missing_hint=f"Set `api_key`, or the {config.api_key_env} environment variable. Config: {where}")
150
+ if config.auth == "client_credentials":
151
+ if "token_url" not in options:
152
+ raise ConfigError("auth_options.token_url is required for client_credentials.")
153
+ # Values in the file win; the environment variables are only a fallback.
154
+ id_env = options.pop("client_id_env", "MODELRELAY_CLIENT_ID")
155
+ secret_env = options.pop("client_secret_env", "MODELRELAY_CLIENT_SECRET")
156
+ return ClientCredentials(
157
+ http,
158
+ client_id=options.pop("client_id", None) or os.environ.get(id_env),
159
+ client_secret=options.pop("client_secret", None) or os.environ.get(secret_env),
160
+ **options,
161
+ )
162
+ cls = load_object(config.auth, "modelrelay.auth", BUILTIN_AUTH)
163
+ return cls(http, **options)
modelrelay/cli.py ADDED
@@ -0,0 +1,200 @@
1
+ """Command line: `modelrelay init` creates a config, `modelrelay show` prints the one in use,
2
+ `modelrelay serve` exposes it as a local OpenAI-compatible endpoint, with a setup screen at /."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import sys
8
+ from importlib.resources import files
9
+
10
+ from ._util import adapters_dir
11
+ from .config import DEFAULT_PROFILE, Config, config_dir
12
+ from .errors import ModelRelayError
13
+
14
+ TEMPLATES = {
15
+ "openrouter": '''\
16
+ # modelrelay config: OpenRouter
17
+ base_url = "https://openrouter.ai/api/v1"
18
+ api_key_env = "OPENROUTER_API_KEY" # or put the key here: api_key = "sk-or-..."
19
+
20
+ # Roles: apps ask for a role, this file picks the model.
21
+ # "text" reads: conversation, and the images the app sends (pick a model with image input if the app sends images)
22
+ # "image" generates images (a model with image output)
23
+ # A role can carry default params, e.g. how hard the model thinks:
24
+ # "text" = { model = "provider/model", reasoning_effort = "medium" } # minimal | low | medium | high
25
+ [models] # name used in code = name the provider expects
26
+ "text" = "google/gemini-2.5-flash" # reads (text + images)
27
+ "image" = "google/gemini-2.5-flash-image" # generates images
28
+ "gpt-4o-mini" = "openai/gpt-4o-mini"
29
+ "gemini-2.5-flash" = "google/gemini-2.5-flash"
30
+ "gemini-2.5-flash-image" = "google/gemini-2.5-flash-image"
31
+
32
+ # Per-app models (optional). Provider and credentials above are shared by every app; an app
33
+ # section only lists the names it wants resolved differently. Apps say who they are with the
34
+ # X-Modelrelay-App header (modelrelay serve) or Relay(app="..."). Check with: modelrelay show --app <app>
35
+ # [apps.wotan.models]
36
+ # "text" = { model = "anthropic/claude-opus-5-5", reasoning_effort = "high" }
37
+ # [apps.sagadeck.models]
38
+ # "image" = "google/gemini-3-pro-image"
39
+ ''',
40
+ "openai": '''\
41
+ # modelrelay config: OpenAI
42
+ base_url = "https://api.openai.com/v1"
43
+ api_key_env = "OPENAI_API_KEY" # or put the key here: api_key = "sk-..."
44
+
45
+ # Roles: apps ask for a role, this file picks the model.
46
+ # "text" reads: conversation, and the images the app sends (pick a model with image input if the app sends images)
47
+ # "image" generates images (a model with image output)
48
+ # A role can carry default params, e.g. how hard the model thinks:
49
+ # "text" = { model = "provider/model", reasoning_effort = "medium" } # minimal | low | medium | high
50
+ [models]
51
+ "text" = "gpt-4o" # reads (text + images)
52
+
53
+ # Per-app models (optional). Provider and credentials above are shared by every app; an app
54
+ # section only lists the names it wants resolved differently. Apps say who they are with the
55
+ # X-Modelrelay-App header (modelrelay serve) or Relay(app="..."). Check with: modelrelay show --app <app>
56
+ # [apps.wotan.models]
57
+ # "text" = { model = "anthropic/claude-opus-5-5", reasoning_effort = "high" }
58
+ # [apps.sagadeck.models]
59
+ # "image" = "google/gemini-3-pro-image"
60
+ ''',
61
+ "gateway": '''\
62
+ # modelrelay config: a gateway with a job API, an OpenAI-compatible proxy and expiring tokens.
63
+ # One file uses both paths with the same token. Fill in the values, then run: modelrelay show
64
+ # The job API adapter is adapters/gateway.py, next to this file.
65
+ base_url = "https://gateway.example.com/v1"
66
+
67
+ transport = "gateway:GatewayJobs" # chat(): the job API (adapters/gateway.py, class GatewayJobs)
68
+ stream_transport = "openai_compatible" # stream(): the proxy, text as it is generated
69
+ # if the proxy goes away: stream_transport = "gateway:GatewayJobs"
70
+ tools_mode = "native" # or "emulated"
71
+ max_payload_mb = 20
72
+ # ca_bundle = "C:/certs/company-ca.pem" # or: verify_ssl = false
73
+
74
+ auth = "client_credentials"
75
+ [auth_options]
76
+ token_url = "https://identity.example.com/token"
77
+ token_field = "access_token" # dotted path in the token response
78
+ ttl_minutes = 30
79
+ client_id = "" # this file stays in your user folder; `modelrelay show` masks these
80
+ client_secret = ""
81
+
82
+ # Roles: apps ask for a role, this file picks the model.
83
+ # "text" reads: conversation, and the images the app sends (pick a model with image input if the app sends images)
84
+ # "image" generates images (a model with image output)
85
+ # A role can carry default params, e.g. how hard the model thinks:
86
+ # "text" = { model = "provider/model", reasoning_effort = "medium" } # minimal | low | medium | high
87
+ [models]
88
+ # "gpt-4o" = "region;gpt-4o"
89
+ # "text" = "region;gpt-4o" # reads (text + images)
90
+
91
+ # Per-app models (optional). Provider and credentials above are shared by every app; an app
92
+ # section only lists the names it wants resolved differently. Apps say who they are with the
93
+ # X-Modelrelay-App header (modelrelay serve) or Relay(app="..."). Check with: modelrelay show --app <app>
94
+ # [apps.wotan.models]
95
+ # "text" = { model = "anthropic/claude-opus-5-5", reasoning_effort = "high" }
96
+ # [apps.sagadeck.models]
97
+ # "image" = "google/gemini-3-pro-image"
98
+
99
+ [transports."gateway:GatewayJobs"]
100
+ base_url = "https://gateway.example.com/jobs-api"
101
+ poll_interval = 0.5
102
+ poll_max_interval = 5
103
+ max_wait_seconds = 900
104
+
105
+ [transports.openai_compatible]
106
+ base_url = "https://proxy.example.com/v1"
107
+ ''',
108
+ }
109
+
110
+ # Extra files created next to a template's config (path relative to ~/.modelrelay).
111
+ TEMPLATE_FILES = {"gateway": {"adapters/gateway.py": "gateway_adapter.py"}}
112
+
113
+ SECRET_KEYS = {"api_key", "client_secret", "client_id"}
114
+
115
+
116
+ def init(profile: str, template: str) -> int:
117
+ path = config_dir() / f"{profile}.toml"
118
+ if path.exists():
119
+ print(f"{path} already exists; nothing changed. Edit it, or delete it and run init again.")
120
+ return 1
121
+ path.parent.mkdir(parents=True, exist_ok=True)
122
+ path.write_text(TEMPLATES[template], encoding="utf-8")
123
+ print(f"Created {path} from the '{template}' template.")
124
+ for relative, resource in TEMPLATE_FILES.get(template, {}).items():
125
+ extra = config_dir() / relative
126
+ if extra.exists():
127
+ print(f"Kept the existing {extra}.")
128
+ continue
129
+ extra.parent.mkdir(parents=True, exist_ok=True)
130
+ extra.write_text((files("modelrelay") / "templates" / resource).read_text(encoding="utf-8"), encoding="utf-8")
131
+ print(f"Created {extra}.")
132
+ print("Edit the file(s), then check with: modelrelay show"
133
+ + (f" --profile {profile}" if profile != DEFAULT_PROFILE else ""))
134
+ return 0
135
+
136
+
137
+ def show(profile: str | None, app: str | None = None) -> int:
138
+ config = Config.load(profile=profile)
139
+ print(f"config file: {config.source}")
140
+ print(f"adapters folder: {adapters_dir()}")
141
+ if app:
142
+ own = (config.apps.get(app) or {}).get("models", {})
143
+ print(f"models for app '{app}'" + ("" if app in config.apps else f" (no [apps.{app}] section: using [models])") + ":")
144
+ for name in config.entries_for(app):
145
+ target, params = config.route(name, app)
146
+ provider = config.provider_for(name, app)
147
+ params = {**({"provider": provider} if provider and config.providers else {}), **params}
148
+ extra = " (" + ", ".join(f"{k}={v}" for k, v in params.items()) + ")" if params else ""
149
+ print(f" {name} = {target}{extra}" + (" <- [apps.%s.models]" % app if name in own else ""))
150
+ return 0
151
+ for name, value in vars(config).items():
152
+ if name == "source":
153
+ continue
154
+ print(f"{name} = {_mask(name, value)}")
155
+ return 0
156
+
157
+
158
+ def _mask(name, value):
159
+ if isinstance(value, dict):
160
+ return {k: _mask(k, v) for k, v in value.items()}
161
+ if name in SECRET_KEYS and value:
162
+ return "***"
163
+ return value
164
+
165
+
166
+ def main(argv: list[str] | None = None) -> int:
167
+ parser = argparse.ArgumentParser(prog="modelrelay")
168
+ sub = parser.add_subparsers(dest="command", required=True)
169
+
170
+ p_init = sub.add_parser("init", help=f"create {config_dir()}/<profile>.toml")
171
+ p_init.add_argument("--profile", default=DEFAULT_PROFILE)
172
+ p_init.add_argument("--template", choices=sorted(TEMPLATES), default="openrouter")
173
+
174
+ p_show = sub.add_parser("show", help="print the config in use (secrets masked)")
175
+ p_show.add_argument("--profile")
176
+ p_show.add_argument("--app", help="only the models this app gets ([models] + [apps.<app>.models])")
177
+
178
+ p_serve = sub.add_parser("serve", help="local OpenAI-compatible endpoint (/v1/chat/completions) using the config")
179
+ p_serve.add_argument("--profile")
180
+ p_serve.add_argument("--host", default="127.0.0.1")
181
+ p_serve.add_argument("--port", type=int, default=8765)
182
+ p_serve.add_argument("--api-key", help="require this bearer token (default: $MODELRELAY_SERVE_KEY, else none)")
183
+ p_serve.add_argument("--public-url", help="address of the setup screen behind a login proxy, e.g. https://example.com/ia/")
184
+
185
+ args = parser.parse_args(argv)
186
+ try:
187
+ if args.command == "init":
188
+ return init(args.profile, args.template)
189
+ if args.command == "serve":
190
+ from .server import serve
191
+ serve(host=args.host, port=args.port, api_key=args.api_key, profile=args.profile, public_url=args.public_url)
192
+ return 0
193
+ return show(args.profile, args.app)
194
+ except ModelRelayError as e:
195
+ print(f"error: {e}", file=sys.stderr)
196
+ return 1
197
+
198
+
199
+ if __name__ == "__main__":
200
+ sys.exit(main())