remuda 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.
Files changed (73) hide show
  1. remuda/__init__.py +128 -0
  2. remuda/api.py +275 -0
  3. remuda/catalog/__init__.py +52 -0
  4. remuda/catalog/adapters.py +199 -0
  5. remuda/catalog/errors.py +32 -0
  6. remuda/catalog/models.py +70 -0
  7. remuda/catalog/resolve.py +244 -0
  8. remuda/cli/__init__.py +5 -0
  9. remuda/cli/app.py +76 -0
  10. remuda/cli/commands/__init__.py +20 -0
  11. remuda/cli/commands/check.py +69 -0
  12. remuda/cli/commands/init_job.py +59 -0
  13. remuda/cli/commands/inline_run.py +164 -0
  14. remuda/cli/commands/models.py +95 -0
  15. remuda/cli/commands/pools.py +162 -0
  16. remuda/cli/commands/preview.py +81 -0
  17. remuda/cli/commands/render.py +121 -0
  18. remuda/cli/commands/report.py +47 -0
  19. remuda/cli/commands/run.py +295 -0
  20. remuda/cli/commands/stats.py +202 -0
  21. remuda/cli/console.py +72 -0
  22. remuda/cli/options.py +74 -0
  23. remuda/cli/progress.py +11 -0
  24. remuda/cli/reporting.py +82 -0
  25. remuda/cli/scaffold.py +113 -0
  26. remuda/engine/__init__.py +30 -0
  27. remuda/engine/ladder.py +241 -0
  28. remuda/engine/lanes.py +163 -0
  29. remuda/engine/packing.py +131 -0
  30. remuda/engine/plan.py +82 -0
  31. remuda/engine/progress.py +40 -0
  32. remuda/engine/runner.py +406 -0
  33. remuda/errors.py +9 -0
  34. remuda/health.py +102 -0
  35. remuda/inline.py +148 -0
  36. remuda/inspection.py +120 -0
  37. remuda/ledger/__init__.py +55 -0
  38. remuda/ledger/models.py +123 -0
  39. remuda/ledger/render.py +220 -0
  40. remuda/ledger/resume.py +89 -0
  41. remuda/ledger/store.py +298 -0
  42. remuda/preflight.py +53 -0
  43. remuda/py.typed +0 -0
  44. remuda/registry/__init__.py +46 -0
  45. remuda/registry/bootstrap.py +158 -0
  46. remuda/registry/errors.py +25 -0
  47. remuda/registry/loader.py +167 -0
  48. remuda/registry/models.py +200 -0
  49. remuda/registry/registry.py +193 -0
  50. remuda/rows.py +153 -0
  51. remuda/spec/__init__.py +55 -0
  52. remuda/spec/errors.py +30 -0
  53. remuda/spec/lint.py +99 -0
  54. remuda/spec/loader.py +210 -0
  55. remuda/spec/models.py +297 -0
  56. remuda/spec/prompt.py +60 -0
  57. remuda/spec/shapes.py +140 -0
  58. remuda/stats.py +152 -0
  59. remuda/transport/__init__.py +35 -0
  60. remuda/transport/errors.py +33 -0
  61. remuda/transport/models.py +60 -0
  62. remuda/transport/openai_compat.py +234 -0
  63. remuda/transport/opencode.py +191 -0
  64. remuda/validate/__init__.py +13 -0
  65. remuda/validate/answers.py +229 -0
  66. remuda/validate/preconditions.py +31 -0
  67. remuda/validate/verdict.py +30 -0
  68. remuda/version.py +3 -0
  69. remuda-0.1.0.dist-info/METADATA +221 -0
  70. remuda-0.1.0.dist-info/RECORD +73 -0
  71. remuda-0.1.0.dist-info/WHEEL +4 -0
  72. remuda-0.1.0.dist-info/entry_points.txt +3 -0
  73. remuda-0.1.0.dist-info/licenses/LICENSE +21 -0
remuda/__init__.py ADDED
@@ -0,0 +1,128 @@
1
+ """remuda — bulk LLM inference over pools of free/cheap models.
2
+
3
+ The herd of spare horses on a cattle drive: ride one until it tires, swap to
4
+ the next. Public API surface lives here; internal module paths are not a
5
+ contract.
6
+ """
7
+
8
+ from remuda.api import JobRunOutcome, run, run_job_dir, run_sync
9
+ from remuda.engine.plan import EngineError
10
+ from remuda.engine.progress import ProgressEvent
11
+ from remuda.engine.runner import RowResult, Runner
12
+ from remuda.errors import RemudaError
13
+ from remuda.inspection import (
14
+ CheckReport,
15
+ PreviewReport,
16
+ RenderedPrompt,
17
+ check_job_dir,
18
+ preview_job,
19
+ )
20
+ from remuda.ledger import (
21
+ LedgerEntry,
22
+ RenderError,
23
+ RenderRequest,
24
+ Report,
25
+ ResumeRefusedError,
26
+ RunStore,
27
+ SpecLock,
28
+ render,
29
+ )
30
+ from remuda.registry import (
31
+ DiscoverQuery,
32
+ ModelConfig,
33
+ Pool,
34
+ PoolEntry,
35
+ Provider,
36
+ Registry,
37
+ RegistryError,
38
+ RegistryValidationError,
39
+ default_config_dirs,
40
+ load_registry,
41
+ )
42
+ from remuda.rows import RowSourceError, read_columns, read_rows
43
+ from remuda.spec import (
44
+ ExtractSchema,
45
+ FieldSpec,
46
+ GenerateConstraints,
47
+ InputSpec,
48
+ Job,
49
+ MapTable,
50
+ Precondition,
51
+ PromptRenderError,
52
+ PromptSpec,
53
+ ScoreRange,
54
+ SpecValidationError,
55
+ )
56
+ from remuda.transport import (
57
+ CompletionRequest,
58
+ CompletionResult,
59
+ PermanentTransportError,
60
+ RateLimitedError,
61
+ TransientTransportError,
62
+ Transport,
63
+ TransportError,
64
+ Usage,
65
+ )
66
+ from remuda.validate import Outcome, Verdict, validate_answer
67
+ from remuda.version import __version__
68
+
69
+ __all__ = [
70
+ "CheckReport",
71
+ "CompletionRequest",
72
+ "CompletionResult",
73
+ "DiscoverQuery",
74
+ "EngineError",
75
+ "ExtractSchema",
76
+ "FieldSpec",
77
+ "GenerateConstraints",
78
+ "InputSpec",
79
+ "Job",
80
+ "JobRunOutcome",
81
+ "LedgerEntry",
82
+ "MapTable",
83
+ "ModelConfig",
84
+ "Outcome",
85
+ "PermanentTransportError",
86
+ "Pool",
87
+ "PoolEntry",
88
+ "Precondition",
89
+ "PreviewReport",
90
+ "ProgressEvent",
91
+ "PromptRenderError",
92
+ "PromptSpec",
93
+ "Provider",
94
+ "RateLimitedError",
95
+ "Registry",
96
+ "RegistryError",
97
+ "RegistryValidationError",
98
+ "RemudaError",
99
+ "RenderError",
100
+ "RenderRequest",
101
+ "RenderedPrompt",
102
+ "Report",
103
+ "ResumeRefusedError",
104
+ "RowResult",
105
+ "RowSourceError",
106
+ "RunStore",
107
+ "Runner",
108
+ "ScoreRange",
109
+ "SpecLock",
110
+ "SpecValidationError",
111
+ "TransientTransportError",
112
+ "Transport",
113
+ "TransportError",
114
+ "Usage",
115
+ "Verdict",
116
+ "__version__",
117
+ "check_job_dir",
118
+ "default_config_dirs",
119
+ "load_registry",
120
+ "preview_job",
121
+ "read_columns",
122
+ "read_rows",
123
+ "render",
124
+ "run",
125
+ "run_job_dir",
126
+ "run_sync",
127
+ "validate_answer",
128
+ ]
remuda/api.py ADDED
@@ -0,0 +1,275 @@
1
+ """The embedding surface (FR-7).
2
+
3
+ A host application runs the same jobs remuda's CLI runs: rows from any
4
+ source, results delivered per completed row, the same ladder and the same
5
+ report object. No files are required — pass rows in, get a Report back.
6
+ """
7
+
8
+ import asyncio
9
+ from collections.abc import Mapping, Sequence
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ from remuda.catalog.resolve import ResolvedPool, resolve_pool
17
+ from remuda.engine.progress import ProgressCallback
18
+ from remuda.engine.runner import LedgerWriter, RowResult, Runner, Sink, TransportFactory
19
+ from remuda.errors import RemudaError
20
+ from remuda.inline import one_shot_job, one_shot_row
21
+ from remuda.ledger.models import LedgerEntry, Report
22
+ from remuda.ledger.resume import OpenedRun, build_lock, open_run
23
+ from remuda.ledger.store import RUNS_DIRNAME, RunStore, new_run_id
24
+ from remuda.preflight import preflight_opencode
25
+ from remuda.registry.registry import Registry
26
+ from remuda.rows import read_rows
27
+ from remuda.spec.models import Job
28
+ from remuda.transport.openai_compat import build_transport
29
+ from remuda.version import __version__
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class JobRunOutcome:
34
+ """A file-backed run: what happened, and where it was recorded."""
35
+
36
+ report: Report
37
+ store: RunStore
38
+ is_resumed: bool
39
+ announcements: tuple[str, ...] = ()
40
+
41
+
42
+ async def run(
43
+ job: Job,
44
+ rows: Sequence[Mapping[str, Any]],
45
+ registry: Registry,
46
+ *,
47
+ pool: str | None = None,
48
+ only: Sequence[str] | None = None,
49
+ sink: Sink | None = None,
50
+ progress: ProgressCallback | None = None,
51
+ write: LedgerWriter | None = None,
52
+ done: Mapping[tuple[str, str], LedgerEntry] | None = None,
53
+ run_id: str | None = None,
54
+ resolved_pools: Mapping[str, ResolvedPool] | None = None,
55
+ transport_factory: TransportFactory = build_transport,
56
+ ) -> Report:
57
+ """Derive every selected field for every row.
58
+
59
+ Args:
60
+ job: the job definition — from a directory or built in code.
61
+ rows: plain mappings; every column rides through untouched.
62
+ registry: providers, models and pools by name.
63
+ pool: overrides the pool every field names.
64
+ only: field selection; dependencies must be included.
65
+ sink: called once per row, when all its fields are decided.
66
+ progress: called once per chunk (FR-6).
67
+ write: called for every decided result — the ledger's append.
68
+ done: results a resume must not recompute.
69
+ run_id: identifies the run in the report.
70
+ resolved_pools: pools already materialized for this run (FR-3).
71
+ transport_factory: how a provider becomes a transport.
72
+
73
+ Raises:
74
+ EngineError: the run cannot be planned.
75
+ """
76
+ resolved_pools = await _ensure_resolved_pools(job, registry, pool, resolved_pools)
77
+ runner = Runner(
78
+ job=job,
79
+ registry=registry,
80
+ run_id=run_id or new_run_id(),
81
+ version=__version__,
82
+ pool=pool,
83
+ only=only,
84
+ transport_factory=transport_factory,
85
+ progress=progress,
86
+ sink=sink,
87
+ write=write,
88
+ done=done,
89
+ resolved_pools=resolved_pools,
90
+ )
91
+ return await runner.run(rows)
92
+
93
+
94
+ def run_sync(
95
+ job: Job,
96
+ rows: Sequence[Mapping[str, Any]],
97
+ registry: Registry,
98
+ **options: Any,
99
+ ) -> Report:
100
+ """Blocking wrapper around `run()` for callers with no event loop."""
101
+ return asyncio.run(run(job, rows, registry, **options))
102
+
103
+
104
+ async def run_job_dir(
105
+ job_dir: Path,
106
+ registry: Registry,
107
+ *,
108
+ runs_root: Path | None = None,
109
+ fresh: bool = False,
110
+ limit: int | None = None,
111
+ pool: str | None = None,
112
+ only: Sequence[str] | None = None,
113
+ fill_missing: str | None = None,
114
+ sink: Sink | None = None,
115
+ progress: ProgressCallback | None = None,
116
+ transport_factory: TransportFactory = build_transport,
117
+ ) -> JobRunOutcome:
118
+ """Run a job directory, recording everything in a run directory.
119
+
120
+ Raises:
121
+ SpecValidationError: the job directory is misdeclared.
122
+ ResumeRefusedError: the previous run used a different job or input.
123
+ EngineError: the run cannot be planned.
124
+ """
125
+ job = Job.from_dir(job_dir)
126
+ input_path = Path(job_dir) / job.input.path
127
+ rows = read_rows(input_path, job.input.format, limit=limit)
128
+ if fill_missing:
129
+ rows = [row for row in rows if not str(row.get(fill_missing, "")).strip()]
130
+
131
+ opened = open_run(
132
+ runs_root=runs_root or Path(job_dir) / RUNS_DIRNAME,
133
+ lock=build_lock(job, input_path, __version__),
134
+ fresh=fresh,
135
+ )
136
+ opened.store.write_job(job)
137
+ resolved = await _resolved_pools(job, registry, opened, pool)
138
+ announcements = await preflight_opencode(registry, resolved.values())
139
+ report = await _run_into(
140
+ opened,
141
+ job=job,
142
+ rows=rows,
143
+ registry=registry,
144
+ resolved_pools=resolved,
145
+ pool=pool,
146
+ only=only,
147
+ sink=sink,
148
+ progress=progress,
149
+ transport_factory=transport_factory,
150
+ )
151
+ return JobRunOutcome(
152
+ report=report,
153
+ store=opened.store,
154
+ is_resumed=opened.is_resumed,
155
+ announcements=tuple(announcements),
156
+ )
157
+
158
+
159
+ async def _run_into(opened: OpenedRun, **options: Any) -> Report:
160
+ """Run into an opened run directory, always closing and reporting."""
161
+ store = opened.store
162
+ try:
163
+ report = await run(
164
+ run_id=store.run_id,
165
+ write=store.append,
166
+ done=opened.done,
167
+ **options,
168
+ )
169
+ finally:
170
+ store.close()
171
+ store.write_report(report)
172
+ return report
173
+
174
+
175
+ async def _ensure_resolved_pools(
176
+ job: Job,
177
+ registry: Registry,
178
+ pool_override: str | None,
179
+ given: Mapping[str, ResolvedPool] | None,
180
+ ) -> Mapping[str, ResolvedPool] | None:
181
+ """Materialize catalog-backed pools the run needs but was not handed.
182
+
183
+ Statically declared pools resolve from the registry inside the engine;
184
+ only pools with discover queries need a catalog round-trip before
185
+ planning. This makes them work in every run shape — one-shot and inline
186
+ included — not just the job-directory path, which snapshots its own.
187
+ """
188
+ wanted = {pool_override or field.pool for field in job.fields}
189
+ resolved = dict(given or {})
190
+ pending = sorted(
191
+ name
192
+ for name in wanted
193
+ if name is not None
194
+ and name not in resolved
195
+ and registry.pool(name).discover_queries
196
+ )
197
+ if not pending:
198
+ return given
199
+ async with httpx.AsyncClient(timeout=60.0) as client:
200
+ for name in pending:
201
+ resolved[name] = await resolve_pool(registry.pool(name), registry, client)
202
+ return resolved
203
+
204
+
205
+ async def _resolved_pools(
206
+ job: Job,
207
+ registry: Registry,
208
+ opened: OpenedRun,
209
+ pool_override: str | None,
210
+ ) -> dict[str, ResolvedPool]:
211
+ """Materialize every pool the run needs, reusing a resume's snapshot.
212
+
213
+ A resumed run must keep the models it started with: free-tier membership
214
+ churns, and re-resolving would silently change who answered the run.
215
+ """
216
+ snapshot = opened.store.read_resolved_pools()
217
+ wanted = {
218
+ pool_override or field.pool
219
+ for field in job.fields
220
+ if (pool_override or field.pool) is not None
221
+ }
222
+ resolved: dict[str, ResolvedPool] = {}
223
+ pending = [name for name in wanted if name is not None and name not in snapshot]
224
+ if pending:
225
+ async with httpx.AsyncClient(timeout=60.0) as client:
226
+ for name in sorted(pending):
227
+ resolved[name] = await resolve_pool(
228
+ registry.pool(name), registry, client
229
+ )
230
+ resolved.update(snapshot)
231
+ if resolved:
232
+ opened.store.write_resolved_pools(list(resolved.values()))
233
+ return resolved
234
+
235
+
236
+ class OneShotFailedError(RemudaError):
237
+ """A one-shot request produced no valid answer (FR-11)."""
238
+
239
+
240
+ async def one_shot(
241
+ prompt: str,
242
+ registry: Registry,
243
+ *,
244
+ pool: str,
245
+ vocabulary: Sequence[str] | None = None,
246
+ piped: str | None = None,
247
+ progress: ProgressCallback | None = None,
248
+ transport_factory: TransportFactory = build_transport,
249
+ ) -> str:
250
+ """Answer one ad-hoc request through the ordinary ladder (FR-11).
251
+
252
+ Nothing is written: no run directory, no ledger, no report.
253
+
254
+ Raises:
255
+ OneShotFailedError: the pool produced no valid answer. The caller
256
+ must not print anything to stdout in that case.
257
+ EngineError: the request cannot be planned.
258
+ """
259
+ job = one_shot_job(prompt, pool=pool, vocabulary=vocabulary)
260
+ answers: list[RowResult] = []
261
+ report = await run(
262
+ job,
263
+ [one_shot_row(piped)],
264
+ registry,
265
+ sink=answers.append,
266
+ progress=progress,
267
+ transport_factory=transport_factory,
268
+ )
269
+ if not report.is_successful or not answers:
270
+ reason = report.failures[0].reason if report.failures else "no answer"
271
+ raise OneShotFailedError(reason or "the pool produced no valid answer")
272
+ value = answers[0].value(job.fields[0].name)
273
+ if value is None:
274
+ raise OneShotFailedError("the pool produced no valid answer")
275
+ return str(value)
@@ -0,0 +1,52 @@
1
+ """Per-provider catalog discovery behind `discover:` (FR-3)."""
2
+
3
+ from remuda.catalog.adapters import (
4
+ ADAPTERS,
5
+ OLLAMA,
6
+ OPENAI_COMPAT,
7
+ OPENROUTER,
8
+ CatalogAdapter,
9
+ OllamaCatalog,
10
+ OpenAICompatCatalog,
11
+ OpenRouterCatalog,
12
+ adapter_for,
13
+ )
14
+ from remuda.catalog.errors import CatalogError, UnsupportedFilterError
15
+ from remuda.catalog.models import (
16
+ FULL_CAPABILITIES,
17
+ NAME_ONLY_CAPABILITIES,
18
+ CatalogModel,
19
+ )
20
+ from remuda.catalog.resolve import (
21
+ ResolvedMember,
22
+ ResolvedPool,
23
+ apply_query,
24
+ member_to_model,
25
+ requested_filters,
26
+ resolve_pool,
27
+ to_model_configs,
28
+ )
29
+
30
+ __all__ = [
31
+ "ADAPTERS",
32
+ "FULL_CAPABILITIES",
33
+ "NAME_ONLY_CAPABILITIES",
34
+ "OLLAMA",
35
+ "OPENAI_COMPAT",
36
+ "OPENROUTER",
37
+ "CatalogAdapter",
38
+ "CatalogError",
39
+ "CatalogModel",
40
+ "OllamaCatalog",
41
+ "OpenAICompatCatalog",
42
+ "OpenRouterCatalog",
43
+ "ResolvedMember",
44
+ "ResolvedPool",
45
+ "UnsupportedFilterError",
46
+ "adapter_for",
47
+ "apply_query",
48
+ "member_to_model",
49
+ "requested_filters",
50
+ "resolve_pool",
51
+ "to_model_configs",
52
+ ]
@@ -0,0 +1,199 @@
1
+ """One discovery adapter per provider shape (FR-3).
2
+
3
+ Every adapter answers two questions: what models does this provider serve,
4
+ and which filters can I answer about them. The second is what keeps an
5
+ unanswerable filter a refusal instead of a silent no-op.
6
+ """
7
+
8
+ from collections.abc import Mapping, Sequence
9
+ from typing import Any, Protocol, runtime_checkable
10
+
11
+ import httpx
12
+
13
+ from remuda.catalog.errors import CatalogError
14
+ from remuda.catalog.models import (
15
+ FULL_CAPABILITIES,
16
+ NAME_ONLY_CAPABILITIES,
17
+ CatalogModel,
18
+ )
19
+ from remuda.registry.models import Provider
20
+
21
+ OPENROUTER = "openrouter"
22
+ OPENAI_COMPAT = "openai_compat"
23
+ OLLAMA = "ollama"
24
+
25
+
26
+ @runtime_checkable
27
+ class CatalogAdapter(Protocol):
28
+ """Reads a provider's model list."""
29
+
30
+ kind: str
31
+ capabilities: frozenset[str]
32
+
33
+ async def fetch(
34
+ self, provider: Provider, client: httpx.AsyncClient
35
+ ) -> list[CatalogModel]:
36
+ """Return every model the provider currently serves."""
37
+ ...
38
+
39
+
40
+ class OpenRouterCatalog:
41
+ """`GET /api/v1/models` — pricing, context length and throughput."""
42
+
43
+ kind = OPENROUTER
44
+ capabilities = FULL_CAPABILITIES
45
+
46
+ async def fetch(
47
+ self, provider: Provider, client: httpx.AsyncClient
48
+ ) -> list[CatalogModel]:
49
+ """Read OpenRouter's catalog."""
50
+ payload = await _get_json(client, _root(provider) + "/models", provider)
51
+ return [_openrouter_model(entry) for entry in _entries(payload, "data")]
52
+
53
+
54
+ class OpenAICompatCatalog:
55
+ """`GET /v1/models` — a bare list of ids, nothing more."""
56
+
57
+ kind = OPENAI_COMPAT
58
+ capabilities = NAME_ONLY_CAPABILITIES
59
+
60
+ async def fetch(
61
+ self, provider: Provider, client: httpx.AsyncClient
62
+ ) -> list[CatalogModel]:
63
+ """Read an OpenAI-compatible model list."""
64
+ payload = await _get_json(client, _root(provider) + "/models", provider)
65
+ return [
66
+ CatalogModel(id=str(entry["id"]))
67
+ for entry in _entries(payload, "data")
68
+ if entry.get("id")
69
+ ]
70
+
71
+
72
+ class OllamaCatalog:
73
+ """`GET /api/tags` — locally pulled models, names only."""
74
+
75
+ kind = OLLAMA
76
+ capabilities = NAME_ONLY_CAPABILITIES
77
+
78
+ async def fetch(
79
+ self, provider: Provider, client: httpx.AsyncClient
80
+ ) -> list[CatalogModel]:
81
+ """Read the local Ollama model list."""
82
+ base = _root(provider).removesuffix("/v1")
83
+ payload = await _get_json(client, f"{base}/api/tags", provider)
84
+ return [
85
+ CatalogModel(id=str(entry["name"]))
86
+ for entry in _entries(payload, "models")
87
+ if entry.get("name")
88
+ ]
89
+
90
+
91
+ #: Every adapter, by the name a provider declares in `catalog:`.
92
+ ADAPTERS: dict[str, CatalogAdapter] = {
93
+ OPENROUTER: OpenRouterCatalog(),
94
+ OPENAI_COMPAT: OpenAICompatCatalog(),
95
+ OLLAMA: OllamaCatalog(),
96
+ }
97
+
98
+
99
+ def adapter_for(provider: Provider) -> CatalogAdapter:
100
+ """Return the adapter a provider's `catalog` declaration names.
101
+
102
+ Raises:
103
+ CatalogError: the provider names no catalog, or an unknown one.
104
+ """
105
+ declared = provider.catalog
106
+ if declared is None:
107
+ raise CatalogError(
108
+ f"provider '{provider.name}' declares no catalog, so its models "
109
+ "cannot be discovered — set catalog: "
110
+ + " | ".join(sorted(ADAPTERS))
111
+ + ", or name the models explicitly in the pool"
112
+ )
113
+ adapter = ADAPTERS.get(declared)
114
+ if adapter is None:
115
+ raise CatalogError(
116
+ f"provider '{provider.name}' names unknown catalog '{declared}' — "
117
+ "known catalogs: " + ", ".join(sorted(ADAPTERS))
118
+ )
119
+ return adapter
120
+
121
+
122
+ # -- shared HTTP plumbing --------------------------------------------------
123
+
124
+
125
+ def _root(provider: Provider) -> str:
126
+ base = (provider.base_url or "").rstrip("/")
127
+ if not base:
128
+ raise CatalogError(f"provider '{provider.name}' has no base_url to read")
129
+ return base
130
+
131
+
132
+ async def _get_json(client: httpx.AsyncClient, url: str, provider: Provider) -> Any:
133
+ try:
134
+ response = await client.get(url, headers=_headers(provider))
135
+ except httpx.TransportError as error:
136
+ raise CatalogError(
137
+ f"cannot reach the catalog of provider '{provider.name}' at {url}: {error}"
138
+ ) from error
139
+ if response.status_code >= 400:
140
+ raise CatalogError(
141
+ f"the catalog of provider '{provider.name}' returned "
142
+ f"{response.status_code} for {url}"
143
+ )
144
+ try:
145
+ return response.json()
146
+ except ValueError as error:
147
+ raise CatalogError(
148
+ f"the catalog of provider '{provider.name}' returned a non-JSON body"
149
+ ) from error
150
+
151
+
152
+ def _headers(provider: Provider) -> dict[str, str]:
153
+ # Imported here: the transport module owns credential resolution, and
154
+ # importing it at module level would tie the catalog to the transport.
155
+ from remuda.transport.openai_compat import catalog_headers # noqa: PLC0415
156
+
157
+ return catalog_headers(provider)
158
+
159
+
160
+ def _entries(payload: Any, key: str) -> Sequence[Mapping[str, Any]]:
161
+ rows = payload.get(key, []) if isinstance(payload, Mapping) else payload
162
+ if not isinstance(rows, Sequence):
163
+ return []
164
+ return [row for row in rows if isinstance(row, Mapping)]
165
+
166
+
167
+ def _openrouter_model(entry: Mapping[str, Any]) -> CatalogModel:
168
+ pricing = _mapping(entry.get("pricing"))
169
+ stats = _mapping(entry.get("stats"))
170
+ return CatalogModel(
171
+ id=str(entry.get("id", "")),
172
+ name=_optional_str(entry.get("name")),
173
+ context_length=_optional_int(entry.get("context_length")),
174
+ prompt_price_usd=_optional_float(pricing.get("prompt")),
175
+ completion_price_usd=_optional_float(pricing.get("completion")),
176
+ throughput=_optional_float(stats.get("throughput") or entry.get("throughput")),
177
+ )
178
+
179
+
180
+ def _mapping(value: Any) -> Mapping[str, Any]:
181
+ return value if isinstance(value, Mapping) else {}
182
+
183
+
184
+ def _optional_str(value: Any) -> str | None:
185
+ return str(value) if value is not None else None
186
+
187
+
188
+ def _optional_int(value: Any) -> int | None:
189
+ try:
190
+ return int(value) if value is not None else None
191
+ except (TypeError, ValueError):
192
+ return None
193
+
194
+
195
+ def _optional_float(value: Any) -> float | None:
196
+ try:
197
+ return float(value) if value is not None else None
198
+ except (TypeError, ValueError):
199
+ return None
@@ -0,0 +1,32 @@
1
+ """Errors raised while discovering a provider's models."""
2
+
3
+ from remuda.catalog.models import FILTER_DESCRIPTIONS
4
+ from remuda.errors import RemudaError
5
+
6
+
7
+ class CatalogError(RemudaError):
8
+ """A provider's catalog could not be read."""
9
+
10
+
11
+ class UnsupportedFilterError(CatalogError):
12
+ """A `discover:` filter this catalog cannot answer — refused, not ignored.
13
+
14
+ Silently dropping the filter would materialize a pool the operator never
15
+ asked for: "free models over 32k" quietly becoming "every model".
16
+ """
17
+
18
+ def __init__(self, provider: str, catalog: str, filters: list[str]) -> None:
19
+ self.provider = provider
20
+ self.catalog = catalog
21
+ self.filters: tuple[str, ...] = tuple(filters)
22
+ super().__init__(self._describe())
23
+
24
+ def _describe(self) -> str:
25
+ named = ", ".join(
26
+ f"'{name}' ({FILTER_DESCRIPTIONS.get(name, name)})" for name in self.filters
27
+ )
28
+ return (
29
+ f"provider '{self.provider}' has a '{self.catalog}' catalog, which "
30
+ f"cannot answer {named}. Drop the filter, or name the models "
31
+ "explicitly in the pool."
32
+ )