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 +72 -0
- modelrelay/_util.py +95 -0
- modelrelay/auth.py +163 -0
- modelrelay/cli.py +200 -0
- modelrelay/config.py +217 -0
- modelrelay/console.html +588 -0
- modelrelay/console.py +256 -0
- modelrelay/errors.py +90 -0
- modelrelay/messages.py +44 -0
- modelrelay/relay.py +166 -0
- modelrelay/server.py +331 -0
- modelrelay/templates/__init__.py +0 -0
- modelrelay/templates/gateway_adapter.py +91 -0
- modelrelay/testing/__init__.py +1 -0
- modelrelay/testing/mock_adapter.py +45 -0
- modelrelay/testing/mock_server.py +283 -0
- modelrelay/tools.py +108 -0
- modelrelay/transports/__init__.py +20 -0
- modelrelay/transports/base.py +159 -0
- modelrelay/transports/jobs.py +141 -0
- modelrelay/transports/openai_compatible.py +61 -0
- modelrelay/transports/openai_format.py +125 -0
- modelrelay/types.py +95 -0
- modelrelay-0.2.0.dist-info/METADATA +541 -0
- modelrelay-0.2.0.dist-info/RECORD +28 -0
- modelrelay-0.2.0.dist-info/WHEEL +4 -0
- modelrelay-0.2.0.dist-info/entry_points.txt +2 -0
- modelrelay-0.2.0.dist-info/licenses/LICENSE +21 -0
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())
|