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/config.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import dataclass, field, fields
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
if sys.version_info >= (3, 11):
|
|
9
|
+
import tomllib
|
|
10
|
+
else: # pragma: no cover
|
|
11
|
+
import tomli as tomllib
|
|
12
|
+
|
|
13
|
+
from .errors import ConfigError
|
|
14
|
+
|
|
15
|
+
CONFIG_ENV = "MODELRELAY_CONFIG" # optional override, e.g. for CI
|
|
16
|
+
DEFAULT_PROFILE = "config"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def config_dir() -> Path:
|
|
20
|
+
"""~/.modelrelay, i.e. C:/Users/<user>/.modelrelay on Windows."""
|
|
21
|
+
return Path.home() / ".modelrelay"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Config:
|
|
26
|
+
# Which transport handles chat() and stream(). stream_transport falls back to transport.
|
|
27
|
+
transport: str = "openai_compatible"
|
|
28
|
+
stream_transport: str | None = None
|
|
29
|
+
base_url: str = "https://api.openai.com/v1"
|
|
30
|
+
|
|
31
|
+
# "static" (fixed API key), "client_credentials" (id + secret -> expiring token), or a plugin.
|
|
32
|
+
auth: str = "static"
|
|
33
|
+
api_key: str | None = None
|
|
34
|
+
api_key_env: str = "OPENAI_API_KEY"
|
|
35
|
+
auth_options: dict = field(default_factory=dict)
|
|
36
|
+
|
|
37
|
+
# Model names used in code -> names the provider expects. A value can also be a table with
|
|
38
|
+
# default request params: "text" = { model = "provider/model", reasoning_effort = "high" }
|
|
39
|
+
models: dict = field(default_factory=dict)
|
|
40
|
+
|
|
41
|
+
# Per-app model overrides: [apps.<app>.models]. An app only lists what differs from [models];
|
|
42
|
+
# provider, auth and everything else stay shared. See models_for().
|
|
43
|
+
apps: dict = field(default_factory=dict)
|
|
44
|
+
|
|
45
|
+
# "native" sends `tools` to the API; "emulated" describes them in the prompt and parses the reply.
|
|
46
|
+
tools_mode: str = "native"
|
|
47
|
+
|
|
48
|
+
verify_ssl: bool = True
|
|
49
|
+
ca_bundle: str | None = None
|
|
50
|
+
timeout_seconds: float = 120.0
|
|
51
|
+
max_payload_mb: float | None = None
|
|
52
|
+
headers: dict = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
# Per-transport options, e.g. [transports.jobs] poll_interval = 1.0
|
|
55
|
+
transports: dict = field(default_factory=dict)
|
|
56
|
+
|
|
57
|
+
# More than one provider: [providers.<name>] holds the same connection keys as the top of the
|
|
58
|
+
# file (base_url, auth, api_key, transport...). A model entry picks one with `provider = "<name>"`;
|
|
59
|
+
# entries without it use `provider` below, or the connection at the top of the file when unset.
|
|
60
|
+
providers: dict = field(default_factory=dict)
|
|
61
|
+
provider: str | None = None
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def load(cls, path: str | Path | None = None, profile: str | None = None, **overrides) -> Config:
|
|
65
|
+
"""Loads the config file, in this order:
|
|
66
|
+
|
|
67
|
+
1. `path`, if given
|
|
68
|
+
2. ~/.modelrelay/<profile>.toml, if `profile` is given (must exist)
|
|
69
|
+
3. $MODELRELAY_CONFIG, if set (optional; never required)
|
|
70
|
+
4. ~/.modelrelay/config.toml
|
|
71
|
+
|
|
72
|
+
No file found is an error (run `modelrelay init`). To build a config in code
|
|
73
|
+
instead, use Config.from_dict(). The file used is in `config.source`.
|
|
74
|
+
"""
|
|
75
|
+
source = _locate(path, profile)
|
|
76
|
+
data = _read(source)
|
|
77
|
+
data.update(overrides)
|
|
78
|
+
config = cls.from_dict(data)
|
|
79
|
+
config.source = source
|
|
80
|
+
return config
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_dict(cls, data: dict) -> Config:
|
|
84
|
+
known = {f.name for f in fields(cls) if f.init}
|
|
85
|
+
unknown = set(data) - known
|
|
86
|
+
if unknown:
|
|
87
|
+
raise ConfigError(f"Unknown config keys: {sorted(unknown)}")
|
|
88
|
+
config = cls(**data)
|
|
89
|
+
if config.tools_mode not in ("native", "emulated"):
|
|
90
|
+
raise ConfigError("tools_mode must be 'native' or 'emulated'")
|
|
91
|
+
_check_models(config.models, "[models]")
|
|
92
|
+
_check_apps(config.apps)
|
|
93
|
+
_check_providers(config)
|
|
94
|
+
return config
|
|
95
|
+
|
|
96
|
+
def provider_for(self, name: str, app: str | None = None) -> str | None:
|
|
97
|
+
"""Which [providers.<name>] serves a model name (None: the connection at the top of the file)."""
|
|
98
|
+
entry = self.entries_for(app).get(name)
|
|
99
|
+
own = entry.get("provider") if isinstance(entry, dict) else None
|
|
100
|
+
return own or self.provider
|
|
101
|
+
|
|
102
|
+
def for_provider(self, name: str) -> Config:
|
|
103
|
+
"""The connection settings of one provider, as a standalone Config. Nothing is inherited from
|
|
104
|
+
the top of the file, so one provider's key never reaches another provider's URL."""
|
|
105
|
+
config = Config.from_dict(dict(self.providers[name]))
|
|
106
|
+
config.source = self.source
|
|
107
|
+
return config
|
|
108
|
+
|
|
109
|
+
def entries_for(self, app: str | None = None) -> dict:
|
|
110
|
+
"""Raw entries an app sees: [models], with [apps.<app>.models] on top (a whole entry replaces
|
|
111
|
+
the shared one). Unknown or missing app -> just [models]."""
|
|
112
|
+
own = (self.apps.get(app) or {}).get("models", {}) if app else {}
|
|
113
|
+
return {**self.models, **own}
|
|
114
|
+
|
|
115
|
+
def models_for(self, app: str | None = None) -> dict:
|
|
116
|
+
"""Name -> provider model for an app (params left out; see route())."""
|
|
117
|
+
return {name: _split(entry)[0] for name, entry in self.entries_for(app).items()}
|
|
118
|
+
|
|
119
|
+
def route(self, name: str, app: str | None = None) -> tuple[str, dict]:
|
|
120
|
+
"""(provider model, default request params) for a name. Not an alias -> (name, {})."""
|
|
121
|
+
entry = self.entries_for(app).get(name)
|
|
122
|
+
return _split(entry) if entry is not None else (name, {})
|
|
123
|
+
|
|
124
|
+
source: Path | None = field(default=None, init=False, repr=False, compare=False)
|
|
125
|
+
|
|
126
|
+
def transport_options(self, name: str) -> dict:
|
|
127
|
+
return dict(self.transports.get(name, {}))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _split(entry) -> tuple[str, dict]:
|
|
131
|
+
if isinstance(entry, str):
|
|
132
|
+
return entry, {}
|
|
133
|
+
params = {k: v for k, v in entry.items() if k not in ("model", "provider")}
|
|
134
|
+
return entry["model"], params
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# Keys a [providers.<name>] section can hold: the connection, not the model maps.
|
|
138
|
+
PROVIDER_KEYS = {"transport", "stream_transport", "base_url", "auth", "api_key", "api_key_env", "auth_options",
|
|
139
|
+
"tools_mode", "verify_ssl", "ca_bundle", "timeout_seconds", "max_payload_mb", "headers", "transports"}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _check_providers(config) -> None:
|
|
143
|
+
if not isinstance(config.providers, dict):
|
|
144
|
+
raise ConfigError("providers must be tables: [providers.<name>]")
|
|
145
|
+
for name, section in config.providers.items():
|
|
146
|
+
if not isinstance(section, dict):
|
|
147
|
+
raise ConfigError(f'[providers.{name}] must be a table, e.g. [providers.{name}] base_url = "https://..."')
|
|
148
|
+
unknown = set(section) - PROVIDER_KEYS
|
|
149
|
+
if unknown:
|
|
150
|
+
raise ConfigError(f"Unknown keys in [providers.{name}]: {sorted(unknown)}. A provider only holds the "
|
|
151
|
+
f"connection ({', '.join(sorted(PROVIDER_KEYS))}); models go in [models].")
|
|
152
|
+
used = [("provider", config.provider)] if config.provider else []
|
|
153
|
+
for where, models in [("[models]", config.models)] + [
|
|
154
|
+
(f"[apps.{a}.models]", (s or {}).get("models", {})) for a, s in config.apps.items()]:
|
|
155
|
+
used += [(f'{where} "{n}"', e.get("provider")) for n, e in models.items() if isinstance(e, dict) and e.get("provider")]
|
|
156
|
+
for where, name in used:
|
|
157
|
+
if name not in config.providers:
|
|
158
|
+
known = ", ".join(sorted(config.providers)) or "none yet"
|
|
159
|
+
raise ConfigError(f'{where} uses provider "{name}", but there is no [providers.{name}] (known: {known})')
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _check_models(models, where: str) -> None:
|
|
163
|
+
if not isinstance(models, dict):
|
|
164
|
+
raise ConfigError(f"{where} must map names to models")
|
|
165
|
+
for name, entry in models.items():
|
|
166
|
+
if isinstance(entry, str):
|
|
167
|
+
continue
|
|
168
|
+
if not isinstance(entry, dict):
|
|
169
|
+
raise ConfigError(f'{where} "{name}" must be a model string or a table, e.g. '
|
|
170
|
+
f'"{name}" = {{ model = "provider/model", reasoning_effort = "high" }}')
|
|
171
|
+
if not isinstance(entry.get("model"), str) or not entry["model"]:
|
|
172
|
+
raise ConfigError(f'{where} "{name}" needs a `model`: '
|
|
173
|
+
f'"{name}" = {{ model = "provider/model", reasoning_effort = "high" }}')
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _check_apps(apps) -> None:
|
|
177
|
+
if not isinstance(apps, dict):
|
|
178
|
+
raise ConfigError("apps must be a table: [apps.<app>.models]")
|
|
179
|
+
for name, section in apps.items():
|
|
180
|
+
if not isinstance(section, dict):
|
|
181
|
+
raise ConfigError(f"[apps.{name}] must be a table with a [apps.{name}.models] section")
|
|
182
|
+
unknown = set(section) - {"models"}
|
|
183
|
+
if unknown:
|
|
184
|
+
raise ConfigError(f"Unknown keys in [apps.{name}]: {sorted(unknown)}. Only [apps.{name}.models] is supported: "
|
|
185
|
+
"provider, auth and transports are shared by all apps (use a profile to change those).")
|
|
186
|
+
_check_models(section.get("models", {}), f"[apps.{name}.models]")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _locate(path, profile) -> Path:
|
|
190
|
+
if path:
|
|
191
|
+
return _must_exist(Path(path), "Config file")
|
|
192
|
+
if profile:
|
|
193
|
+
path = config_dir() / f"{profile}.toml"
|
|
194
|
+
if not path.is_file():
|
|
195
|
+
raise ConfigError(f"Profile '{profile}' not found: {path}. "
|
|
196
|
+
f"Run `modelrelay init --profile {profile}` to create it.")
|
|
197
|
+
return path
|
|
198
|
+
if os.environ.get(CONFIG_ENV):
|
|
199
|
+
return _must_exist(Path(os.environ[CONFIG_ENV]), f"${CONFIG_ENV}")
|
|
200
|
+
default = config_dir() / f"{DEFAULT_PROFILE}.toml"
|
|
201
|
+
if not default.is_file():
|
|
202
|
+
raise ConfigError(f"No config found at {default}. Run `modelrelay init` to create one.")
|
|
203
|
+
return default
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _must_exist(path: Path, what: str) -> Path:
|
|
207
|
+
if not path.is_file():
|
|
208
|
+
raise ConfigError(f"{what} not found: {path}")
|
|
209
|
+
return path
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _read(path: Path) -> dict:
|
|
213
|
+
try:
|
|
214
|
+
with path.open("rb") as f:
|
|
215
|
+
return tomllib.load(f)
|
|
216
|
+
except tomllib.TOMLDecodeError as e:
|
|
217
|
+
raise ConfigError(f"Invalid TOML in {path}: {e}") from e
|