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.
- remuda/__init__.py +128 -0
- remuda/api.py +275 -0
- remuda/catalog/__init__.py +52 -0
- remuda/catalog/adapters.py +199 -0
- remuda/catalog/errors.py +32 -0
- remuda/catalog/models.py +70 -0
- remuda/catalog/resolve.py +244 -0
- remuda/cli/__init__.py +5 -0
- remuda/cli/app.py +76 -0
- remuda/cli/commands/__init__.py +20 -0
- remuda/cli/commands/check.py +69 -0
- remuda/cli/commands/init_job.py +59 -0
- remuda/cli/commands/inline_run.py +164 -0
- remuda/cli/commands/models.py +95 -0
- remuda/cli/commands/pools.py +162 -0
- remuda/cli/commands/preview.py +81 -0
- remuda/cli/commands/render.py +121 -0
- remuda/cli/commands/report.py +47 -0
- remuda/cli/commands/run.py +295 -0
- remuda/cli/commands/stats.py +202 -0
- remuda/cli/console.py +72 -0
- remuda/cli/options.py +74 -0
- remuda/cli/progress.py +11 -0
- remuda/cli/reporting.py +82 -0
- remuda/cli/scaffold.py +113 -0
- remuda/engine/__init__.py +30 -0
- remuda/engine/ladder.py +241 -0
- remuda/engine/lanes.py +163 -0
- remuda/engine/packing.py +131 -0
- remuda/engine/plan.py +82 -0
- remuda/engine/progress.py +40 -0
- remuda/engine/runner.py +406 -0
- remuda/errors.py +9 -0
- remuda/health.py +102 -0
- remuda/inline.py +148 -0
- remuda/inspection.py +120 -0
- remuda/ledger/__init__.py +55 -0
- remuda/ledger/models.py +123 -0
- remuda/ledger/render.py +220 -0
- remuda/ledger/resume.py +89 -0
- remuda/ledger/store.py +298 -0
- remuda/preflight.py +53 -0
- remuda/py.typed +0 -0
- remuda/registry/__init__.py +46 -0
- remuda/registry/bootstrap.py +158 -0
- remuda/registry/errors.py +25 -0
- remuda/registry/loader.py +167 -0
- remuda/registry/models.py +200 -0
- remuda/registry/registry.py +193 -0
- remuda/rows.py +153 -0
- remuda/spec/__init__.py +55 -0
- remuda/spec/errors.py +30 -0
- remuda/spec/lint.py +99 -0
- remuda/spec/loader.py +210 -0
- remuda/spec/models.py +297 -0
- remuda/spec/prompt.py +60 -0
- remuda/spec/shapes.py +140 -0
- remuda/stats.py +152 -0
- remuda/transport/__init__.py +35 -0
- remuda/transport/errors.py +33 -0
- remuda/transport/models.py +60 -0
- remuda/transport/openai_compat.py +234 -0
- remuda/transport/opencode.py +191 -0
- remuda/validate/__init__.py +13 -0
- remuda/validate/answers.py +229 -0
- remuda/validate/preconditions.py +31 -0
- remuda/validate/verdict.py +30 -0
- remuda/version.py +3 -0
- remuda-0.1.0.dist-info/METADATA +221 -0
- remuda-0.1.0.dist-info/RECORD +73 -0
- remuda-0.1.0.dist-info/WHEEL +4 -0
- remuda-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|
remuda/catalog/errors.py
ADDED
|
@@ -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
|
+
)
|