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/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