otto-cli-agent 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.
- agent/README.md +22 -0
- agent/__init__.py +0 -0
- agent/cli/README.md +77 -0
- agent/cli/__init__.py +0 -0
- agent/cli/art.py +371 -0
- agent/cli/chat.py +309 -0
- agent/cli/clipboard.py +106 -0
- agent/cli/context.py +56 -0
- agent/cli/doctor.py +60 -0
- agent/cli/errors.py +58 -0
- agent/cli/eval.py +130 -0
- agent/cli/eval_claw.py +414 -0
- agent/cli/eval_compaction.py +106 -0
- agent/cli/eval_hle.py +92 -0
- agent/cli/eval_memory.py +253 -0
- agent/cli/eval_swe.py +172 -0
- agent/cli/lessons.py +97 -0
- agent/cli/main.py +67 -0
- agent/cli/modals.py +570 -0
- agent/cli/models.py +64 -0
- agent/cli/output.py +54 -0
- agent/cli/route.py +78 -0
- agent/cli/sessions.py +125 -0
- agent/cli/setup_screen.py +562 -0
- agent/cli/shell.py +548 -0
- agent/cli/tui.py +1807 -0
- agent/cli/ui.py +14 -0
- agent/cli/usage_panel.py +159 -0
- agent/config/README.md +7 -0
- agent/config/__init__.py +0 -0
- agent/config/envfile.py +76 -0
- agent/eval/README.md +76 -0
- agent/eval/__init__.py +0 -0
- agent/eval/claw_bench.py +1031 -0
- agent/eval/compaction_bench.py +229 -0
- agent/eval/data/README.md +10 -0
- agent/eval/data/claw/README.md +108 -0
- agent/eval/data/claw/llm_judge-gemini.patch +57 -0
- agent/eval/data/claw/otto.yaml +35 -0
- agent/eval/failures.py +276 -0
- agent/eval/golden/README.md +33 -0
- agent/eval/golden/code_01.json +6 -0
- agent/eval/golden/code_02.json +6 -0
- agent/eval/golden/code_03.json +6 -0
- agent/eval/golden/code_04.json +6 -0
- agent/eval/golden/code_05.json +6 -0
- agent/eval/golden/code_06.json +6 -0
- agent/eval/golden/math_01.json +6 -0
- agent/eval/golden/math_02.json +6 -0
- agent/eval/golden/math_03.json +6 -0
- agent/eval/golden/math_04.json +6 -0
- agent/eval/golden/math_05.json +6 -0
- agent/eval/golden/math_06.json +6 -0
- agent/eval/golden/nphard_gcp_01.json +6 -0
- agent/eval/golden/nphard_ksp_01.json +6 -0
- agent/eval/golden/nphard_math_binpacking_01.json +6 -0
- agent/eval/golden/nphard_math_clique_01.json +6 -0
- agent/eval/golden/nphard_math_setcover_01.json +6 -0
- agent/eval/golden/nphard_math_subsetsum_01.json +6 -0
- agent/eval/golden/nphard_tsp_01.json +6 -0
- agent/eval/golden/nphard_tsp_02.json +6 -0
- agent/eval/hle_bench.py +273 -0
- agent/eval/langfuse_sync.py +172 -0
- agent/eval/memory_bench.py +538 -0
- agent/eval/runner.py +174 -0
- agent/eval/single_agent.py +120 -0
- agent/eval/swe_bench.py +604 -0
- agent/eval/terminal_bench.py +345 -0
- agent/memory/README.md +102 -0
- agent/memory/__init__.py +42 -0
- agent/memory/embeddings.py +302 -0
- agent/memory/hashing.py +15 -0
- agent/memory/lessons.py +483 -0
- agent/memory/queue.py +531 -0
- agent/memory/retrieval.py +493 -0
- agent/memory/session.py +60 -0
- agent/memory/sessions.py +436 -0
- agent/memory/store.py +429 -0
- agent/memory/tokens.py +60 -0
- agent/memory/wiring.py +146 -0
- agent/pipeline/README.md +135 -0
- agent/pipeline/__init__.py +0 -0
- agent/pipeline/browsing.py +609 -0
- agent/pipeline/budget.py +403 -0
- agent/pipeline/codemap.py +254 -0
- agent/pipeline/evidence.py +325 -0
- agent/pipeline/execution.py +67 -0
- agent/pipeline/modes.py +137 -0
- agent/pipeline/native.py +1137 -0
- agent/pipeline/nodes.py +3644 -0
- agent/pipeline/pricing.py +209 -0
- agent/pipeline/progress.py +139 -0
- agent/pipeline/rag.py +139 -0
- agent/pipeline/research.py +1325 -0
- agent/pipeline/run.py +528 -0
- agent/pipeline/screen.py +77 -0
- agent/pipeline/state.py +220 -0
- agent/pipeline/toolkit.py +328 -0
- agent/pipeline/tools.py +1990 -0
- agent/pipeline/tracing.py +147 -0
- agent/pipeline/usage.py +251 -0
- agent/pipeline/vision.py +84 -0
- agent/pipeline/walkthrough.py +735 -0
- agent/pipeline/workspace.py +229 -0
- agent/router/README.md +60 -0
- agent/router/__init__.py +0 -0
- agent/router/automap.py +114 -0
- agent/router/health.py +229 -0
- agent/router/llm_provider/README.md +38 -0
- agent/router/llm_provider/__init__.py +202 -0
- agent/router/llm_provider/anthropic_provider.py +128 -0
- agent/router/llm_provider/base.py +507 -0
- agent/router/llm_provider/custom.py +152 -0
- agent/router/llm_provider/gemini_provider.py +122 -0
- agent/router/llm_provider/inception_provider.py +687 -0
- agent/router/llm_provider/openai_provider.py +151 -0
- agent/router/llm_provider/retired.py +145 -0
- agent/router/llm_provider/temperature.py +371 -0
- agent/router/mapping.py +579 -0
- agent/router/outcomes.py +363 -0
- agent/router/overrides.py +389 -0
- agent/router/reload.py +28 -0
- agent/router/router.py +413 -0
- agent/router/setup.py +123 -0
- otto_cli_agent-0.1.0.dist-info/METADATA +115 -0
- otto_cli_agent-0.1.0.dist-info/RECORD +129 -0
- otto_cli_agent-0.1.0.dist-info/WHEEL +4 -0
- otto_cli_agent-0.1.0.dist-info/entry_points.txt +2 -0
- otto_cli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
"""Provider abstraction for Otto.
|
|
2
|
+
|
|
3
|
+
One rule governs this module: the base class holds only what *every* vendor
|
|
4
|
+
genuinely does. Anything a subset of vendors offer is a `Capability` plus an
|
|
5
|
+
optional `Protocol` -- never an inherited method that raises.
|
|
6
|
+
|
|
7
|
+
Subclasses implement exactly three things:
|
|
8
|
+
|
|
9
|
+
_build_client() construct the vendor SDK client
|
|
10
|
+
_fetch_models() one network call, normalised into `ModelInfo`
|
|
11
|
+
chat_model() return a configured LangChain `BaseChatModel`
|
|
12
|
+
|
|
13
|
+
Everything else is inherited. `chat_model` returning a LangChain model rather
|
|
14
|
+
than a bespoke `chat()` method is deliberate: streaming, tool binding, message
|
|
15
|
+
history, Langfuse callbacks and LangGraph node compatibility then come for
|
|
16
|
+
free and behave identically across vendors.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from abc import ABC, abstractmethod
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from enum import StrEnum
|
|
25
|
+
from collections.abc import Mapping
|
|
26
|
+
from typing import Any, Protocol, runtime_checkable
|
|
27
|
+
|
|
28
|
+
from langchain_core.language_models import BaseChatModel
|
|
29
|
+
|
|
30
|
+
from agent.router.llm_provider.temperature import looks_like_temperature_rejection
|
|
31
|
+
from agent.router.llm_provider.retired import (
|
|
32
|
+
is_serviceable,
|
|
33
|
+
looks_retired,
|
|
34
|
+
note_retired,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"Capability",
|
|
39
|
+
"ModelInfo",
|
|
40
|
+
"ProviderStatus",
|
|
41
|
+
"HealthReport",
|
|
42
|
+
"ProviderError",
|
|
43
|
+
"AuthError",
|
|
44
|
+
"ModelNotFound",
|
|
45
|
+
"CapabilityNotSupported",
|
|
46
|
+
"ProviderUnavailable",
|
|
47
|
+
"Completion",
|
|
48
|
+
"BaseProvider",
|
|
49
|
+
"SupportsFIM",
|
|
50
|
+
"SupportsEdit",
|
|
51
|
+
"SupportsEmbeddings",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --------------------------------------------------------------------------
|
|
56
|
+
# Capabilities
|
|
57
|
+
# --------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Capability(StrEnum):
|
|
61
|
+
"""A thing a provider or a specific model can do.
|
|
62
|
+
|
|
63
|
+
Used in two distinct places, and they mean different things:
|
|
64
|
+
|
|
65
|
+
* `BaseProvider.capabilities` -- what the *vendor* offers.
|
|
66
|
+
* `ModelInfo.capabilities` -- what *that model* offers.
|
|
67
|
+
|
|
68
|
+
Inception supports FIM; a given Inception model may not. Route on the
|
|
69
|
+
per-model set, gate the CLI on the per-provider one.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
CHAT = "chat"
|
|
73
|
+
TOOLS = "tools"
|
|
74
|
+
VISION = "vision"
|
|
75
|
+
REASONING = "reasoning"
|
|
76
|
+
STRUCTURED_OUTPUT = "structured_output"
|
|
77
|
+
FIM = "fim"
|
|
78
|
+
EDIT = "edit"
|
|
79
|
+
EMBEDDINGS = "embeddings"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# --------------------------------------------------------------------------
|
|
83
|
+
# Value types
|
|
84
|
+
# --------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class ModelInfo:
|
|
89
|
+
"""One model, normalised across vendors.
|
|
90
|
+
|
|
91
|
+
`raw` keeps the untouched vendor payload so provider-specific code can
|
|
92
|
+
reach fields the common shape drops, without another network call. It is
|
|
93
|
+
excluded from `repr` because vendor payloads are large.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
id: str
|
|
97
|
+
provider: str
|
|
98
|
+
display_name: str | None = None
|
|
99
|
+
capabilities: frozenset[Capability] = frozenset()
|
|
100
|
+
context_window: int | None = None
|
|
101
|
+
max_output_tokens: int | None = None
|
|
102
|
+
raw: dict[str, Any] = field(default_factory=dict, repr=False, compare=False)
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def spec(self) -> str:
|
|
106
|
+
"""Fully-qualified reference, e.g. ``inception:mercury-2``."""
|
|
107
|
+
return f"{self.provider}:{self.id}"
|
|
108
|
+
|
|
109
|
+
def supports(self, capability: Capability) -> bool:
|
|
110
|
+
return capability in self.capabilities
|
|
111
|
+
|
|
112
|
+
def __str__(self) -> str:
|
|
113
|
+
return self.spec
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True, slots=True)
|
|
117
|
+
class Completion:
|
|
118
|
+
"""A raw-SDK completion: the text, plus the usage the endpoint reported.
|
|
119
|
+
|
|
120
|
+
FIM and edit bypass LangChain, so nothing collects their token counts for
|
|
121
|
+
us. Returning a bare `str` threw them away -- and a call with no usage has
|
|
122
|
+
no cost, which silently defeats the whole point of a cost-driven router.
|
|
123
|
+
|
|
124
|
+
`usage` uses Langfuse's key names (input / output / total, plus optional
|
|
125
|
+
cached_input and reasoning) so it can be handed to `usage_details` without
|
|
126
|
+
translation.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
text: str
|
|
130
|
+
usage: Mapping[str, int] | None = None
|
|
131
|
+
|
|
132
|
+
def __str__(self) -> str:
|
|
133
|
+
return self.text
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class ProviderStatus(StrEnum):
|
|
137
|
+
"""Outcome of `BaseProvider.health_check`."""
|
|
138
|
+
|
|
139
|
+
OK = "ok"
|
|
140
|
+
NO_KEY = "no key"
|
|
141
|
+
AUTH_FAILED = "auth failed"
|
|
142
|
+
UNREACHABLE = "unreachable"
|
|
143
|
+
ERROR = "error"
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True, slots=True)
|
|
147
|
+
class HealthReport:
|
|
148
|
+
"""Per-provider result for the `doctor` / `models` commands.
|
|
149
|
+
|
|
150
|
+
Deliberately a value, not an exception: checking four providers should
|
|
151
|
+
report four outcomes, never abort on the first failure.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
provider: str
|
|
155
|
+
status: ProviderStatus
|
|
156
|
+
model_count: int = 0
|
|
157
|
+
detail: str = ""
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def ok(self) -> bool:
|
|
161
|
+
return self.status is ProviderStatus.OK
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# --------------------------------------------------------------------------
|
|
165
|
+
# Errors
|
|
166
|
+
# --------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class ProviderError(Exception):
|
|
170
|
+
"""Base for every provider failure.
|
|
171
|
+
|
|
172
|
+
Subclasses MUST translate vendor SDK exceptions into these before they
|
|
173
|
+
escape `_fetch_models`. That translation is the whole reason `health_check`
|
|
174
|
+
can stay vendor-agnostic -- it is the one piece of per-provider work that
|
|
175
|
+
is easy to forget.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class AuthError(ProviderError):
|
|
180
|
+
"""Key missing, malformed, or rejected by the vendor."""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class ProviderUnavailable(ProviderError):
|
|
184
|
+
"""Network failure, timeout, or vendor-side outage."""
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class ModelNotFound(ProviderError):
|
|
188
|
+
"""Requested model id is not offered by this provider."""
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class CapabilityNotSupported(ProviderError):
|
|
192
|
+
"""Provider or model cannot do the requested thing."""
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# --------------------------------------------------------------------------
|
|
196
|
+
# Base provider
|
|
197
|
+
# --------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
#: Vendor SDK root modules whose exceptions can reach Otto unwrapped.
|
|
201
|
+
#:
|
|
202
|
+
#: A provider subclass translates its own SDK's errors (the invariant a few
|
|
203
|
+
#: lines above), but the CHAT path does not go through the subclass at all --
|
|
204
|
+
#: it goes through a LangChain class Otto does not own. `ChatOpenAI`,
|
|
205
|
+
#: `ChatAnthropic` and `ChatGoogleGenerativeAI` raise raw `openai.RateLimitError`,
|
|
206
|
+
#: `anthropic.APIStatusError`, google-genai errors and bare `httpx` errors
|
|
207
|
+
#: straight out of `.stream()`.
|
|
208
|
+
#:
|
|
209
|
+
#: That is load-bearing because agent/pipeline/nodes.py's `_run_role` and
|
|
210
|
+
#: `evaluator` catch only ProviderError around their tool loop. Before this,
|
|
211
|
+
#: every chat model was Inception's own `ChatInception`, which translates; the
|
|
212
|
+
#: moment a route points at another vendor, a 429 mid-solver stops being a
|
|
213
|
+
#: clean edge back to the overseer and unwinds the entire graph instead.
|
|
214
|
+
_VENDOR_SDK_ROOTS = frozenset({"openai", "anthropic", "google", "httpx", "httpx2"})
|
|
215
|
+
|
|
216
|
+
#: Exception class-name fragments that mean "the vendor was unreachable", as
|
|
217
|
+
#: opposed to "the vendor said no". Matched on the class name because the four
|
|
218
|
+
#: SDKs above share no base class to check against.
|
|
219
|
+
_UNAVAILABLE_MARKERS = ("Connection", "Timeout", "APIStatus", "ServiceUnavailable")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def translate_unknown(exc: Exception, *, provider: str = "", model_id: str = "") -> ProviderError:
|
|
223
|
+
"""Last-resort translation for an exception raised by a chat model Otto
|
|
224
|
+
does not own.
|
|
225
|
+
|
|
226
|
+
Deliberately duck-typed rather than importing the vendor SDKs: this module
|
|
227
|
+
is imported by agent/router/mapping.py's validation path, which a test runs
|
|
228
|
+
in a subprocess with every API key stripped, and which must stay free of
|
|
229
|
+
heavy vendor imports.
|
|
230
|
+
"""
|
|
231
|
+
status = getattr(exc, "status_code", None) or getattr(exc, "code", None)
|
|
232
|
+
name = type(exc).__name__
|
|
233
|
+
detail = f"{name}: {exc}"
|
|
234
|
+
|
|
235
|
+
# Before the retirement check, because Anthropic phrases a refused
|
|
236
|
+
# temperature as "`temperature` is deprecated for this model" -- which
|
|
237
|
+
# reads as a retirement to the marker list below. The model is alive; it
|
|
238
|
+
# just takes no temperature, and nodes.py's _call has already learned
|
|
239
|
+
# that and retried. Treating it as retired would hide a live model for
|
|
240
|
+
# the rest of the run.
|
|
241
|
+
if looks_like_temperature_rejection(exc):
|
|
242
|
+
return ProviderError(detail)
|
|
243
|
+
|
|
244
|
+
# A vendor saying a model is permanently gone is worth remembering: the
|
|
245
|
+
# catalogue still lists it, so without this the next node in the same run
|
|
246
|
+
# resolves the same dead id and fails the same way.
|
|
247
|
+
if provider and model_id and looks_retired(exc):
|
|
248
|
+
note_retired(provider, model_id, detail)
|
|
249
|
+
return ModelNotFound(detail)
|
|
250
|
+
|
|
251
|
+
if status in (401, 403):
|
|
252
|
+
return AuthError(detail)
|
|
253
|
+
if status == 404:
|
|
254
|
+
return ModelNotFound(detail)
|
|
255
|
+
if status == 429 or (isinstance(status, int) and status >= 500):
|
|
256
|
+
return ProviderUnavailable(detail)
|
|
257
|
+
if any(marker in name for marker in _UNAVAILABLE_MARKERS):
|
|
258
|
+
return ProviderUnavailable(detail)
|
|
259
|
+
return ProviderError(detail)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
class BaseProvider(ABC):
|
|
263
|
+
"""A single LLM vendor.
|
|
264
|
+
|
|
265
|
+
Subclasses set the class attributes below and implement the three
|
|
266
|
+
abstract methods. Nothing here performs I/O at construction time except
|
|
267
|
+
building the SDK client -- model discovery is lazy, so constructing a
|
|
268
|
+
provider is cheap and safe.
|
|
269
|
+
"""
|
|
270
|
+
|
|
271
|
+
#: Short lowercase identifier used in model specs, e.g. ``"inception"``.
|
|
272
|
+
name: str = ""
|
|
273
|
+
#: Environment variable holding this vendor's API key.
|
|
274
|
+
env_var: str = ""
|
|
275
|
+
#: Optional default endpoint, for OpenAI-compatible vendors.
|
|
276
|
+
default_base_url: str | None = None
|
|
277
|
+
#: What the vendor offers. Narrow this per subclass.
|
|
278
|
+
capabilities: frozenset[Capability] = frozenset({Capability.CHAT})
|
|
279
|
+
|
|
280
|
+
def __init__(
|
|
281
|
+
self,
|
|
282
|
+
api_key: str | None = None,
|
|
283
|
+
*,
|
|
284
|
+
base_url: str | None = None,
|
|
285
|
+
) -> None:
|
|
286
|
+
if not self.name or not self.env_var:
|
|
287
|
+
raise TypeError(
|
|
288
|
+
f"{type(self).__name__} must set both 'name' and 'env_var'"
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
key = api_key or os.environ.get(self.env_var)
|
|
292
|
+
if not key:
|
|
293
|
+
raise AuthError(f"{self.name}: {self.env_var} is not set")
|
|
294
|
+
|
|
295
|
+
self._api_key: str = key
|
|
296
|
+
self._base_url: str | None = base_url or self.default_base_url
|
|
297
|
+
self._models: list[ModelInfo] | None = None
|
|
298
|
+
self._client: Any = self._build_client()
|
|
299
|
+
|
|
300
|
+
# -- subclass responsibilities ----------------------------------------
|
|
301
|
+
|
|
302
|
+
@abstractmethod
|
|
303
|
+
def _build_client(self) -> Any:
|
|
304
|
+
"""Construct and return the vendor SDK client.
|
|
305
|
+
|
|
306
|
+
Use `self._api_key` and `self._base_url` explicitly rather than
|
|
307
|
+
letting the SDK read the environment on its own -- an explicitly
|
|
308
|
+
passed key is testable and cannot be shadowed by a stale export.
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
@abstractmethod
|
|
312
|
+
def _fetch_models(self) -> list[ModelInfo]:
|
|
313
|
+
"""Fetch every model this provider offers, as `ModelInfo`.
|
|
314
|
+
|
|
315
|
+
Called at most once per instance unless `refresh=True`. Set each
|
|
316
|
+
model's `capabilities` here -- it is the only place with enough
|
|
317
|
+
vendor context to know them.
|
|
318
|
+
|
|
319
|
+
Must raise `AuthError` on a rejected key, `ProviderUnavailable` on a
|
|
320
|
+
network or vendor fault, and `ProviderError` for anything else.
|
|
321
|
+
Letting a raw SDK exception escape breaks `health_check`.
|
|
322
|
+
"""
|
|
323
|
+
|
|
324
|
+
@abstractmethod
|
|
325
|
+
def chat_model(self, model_id: str, **kwargs: Any) -> BaseChatModel:
|
|
326
|
+
"""Return a LangChain chat model bound to `model_id`.
|
|
327
|
+
|
|
328
|
+
Do not implement chat by hand. Returning a `BaseChatModel` is what
|
|
329
|
+
keeps every provider interchangeable inside a LangGraph node and
|
|
330
|
+
visible to Langfuse.
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
# -- configuration ----------------------------------------------------
|
|
334
|
+
|
|
335
|
+
@classmethod
|
|
336
|
+
def is_configured(cls) -> bool:
|
|
337
|
+
"""True if the key is present, without constructing the provider.
|
|
338
|
+
|
|
339
|
+
`__init__` raises on a missing key, so a `doctor` command that wants
|
|
340
|
+
to report rather than crash needs this cheap check first.
|
|
341
|
+
"""
|
|
342
|
+
return bool(os.environ.get(cls.env_var))
|
|
343
|
+
|
|
344
|
+
@property
|
|
345
|
+
def masked_key(self) -> str:
|
|
346
|
+
"""Key rendered for display. Never log or print `_api_key` itself."""
|
|
347
|
+
tail = self._api_key[-4:] if len(self._api_key) >= 4 else ""
|
|
348
|
+
return f"{'*' * 8}{tail}"
|
|
349
|
+
|
|
350
|
+
# -- model discovery --------------------------------------------------
|
|
351
|
+
|
|
352
|
+
def list_models(
|
|
353
|
+
self,
|
|
354
|
+
capability: Capability | None = None,
|
|
355
|
+
*,
|
|
356
|
+
refresh: bool = False,
|
|
357
|
+
) -> list[ModelInfo]:
|
|
358
|
+
"""Models offered, optionally narrowed to one capability.
|
|
359
|
+
|
|
360
|
+
Cached after the first call; pass `refresh=True` to re-fetch.
|
|
361
|
+
"""
|
|
362
|
+
if self._models is None or refresh:
|
|
363
|
+
# Filtered here rather than at the routing layer so EVERY consumer
|
|
364
|
+
# -- routing, `otto models`, the doctor, health reports -- sees the
|
|
365
|
+
# same catalogue. A retired id that reaches Router._select resolves
|
|
366
|
+
# cleanly and then fails mid-run, which is the failure this exists
|
|
367
|
+
# to prevent (agent/router/llm_provider/retired.py).
|
|
368
|
+
self._models = [
|
|
369
|
+
m for m in self._fetch_models() if is_serviceable(self.name, m.id)
|
|
370
|
+
]
|
|
371
|
+
if capability is None:
|
|
372
|
+
return list(self._models)
|
|
373
|
+
return [m for m in self._models if capability in m.capabilities]
|
|
374
|
+
|
|
375
|
+
def model_ids(self, capability: Capability | None = None) -> list[str]:
|
|
376
|
+
return [m.id for m in self.list_models(capability)]
|
|
377
|
+
|
|
378
|
+
def resolve_model(self, model_id: str) -> ModelInfo:
|
|
379
|
+
"""Look up a model by id, raising `ModelNotFound` on a typo.
|
|
380
|
+
|
|
381
|
+
Call this before `chat_model` so a bad id fails with a useful list
|
|
382
|
+
instead of a vendor 404 halfway through a graph run.
|
|
383
|
+
"""
|
|
384
|
+
for model in self.list_models():
|
|
385
|
+
if model.id == model_id:
|
|
386
|
+
return model
|
|
387
|
+
raise ModelNotFound(
|
|
388
|
+
f"{model_id!r} is not offered by {self.name}. "
|
|
389
|
+
f"Available: {', '.join(self.model_ids()) or '<none>'}"
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
# -- capability gating ------------------------------------------------
|
|
393
|
+
|
|
394
|
+
def supports(self, capability: Capability) -> bool:
|
|
395
|
+
return capability in self.capabilities
|
|
396
|
+
|
|
397
|
+
def require(self, capability: Capability) -> None:
|
|
398
|
+
"""Guard clause for optional capabilities.
|
|
399
|
+
|
|
400
|
+
Prefer this over `isinstance(p, SupportsFIM)` at call sites -- it
|
|
401
|
+
yields an error naming the provider and the capability.
|
|
402
|
+
"""
|
|
403
|
+
if not self.supports(capability):
|
|
404
|
+
raise CapabilityNotSupported(
|
|
405
|
+
f"{self.name} does not support {capability}"
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# -- health -----------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
def health_check(self) -> HealthReport:
|
|
411
|
+
"""Verify the key actually works by listing models.
|
|
412
|
+
|
|
413
|
+
Env-var presence proves nothing -- only a real call distinguishes a
|
|
414
|
+
valid key from a revoked one. Never raises.
|
|
415
|
+
"""
|
|
416
|
+
try:
|
|
417
|
+
models = self.list_models(refresh=True)
|
|
418
|
+
except AuthError as exc:
|
|
419
|
+
return HealthReport(self.name, ProviderStatus.AUTH_FAILED, detail=str(exc))
|
|
420
|
+
except ProviderUnavailable as exc:
|
|
421
|
+
return HealthReport(self.name, ProviderStatus.UNREACHABLE, detail=str(exc))
|
|
422
|
+
except ProviderError as exc:
|
|
423
|
+
return HealthReport(self.name, ProviderStatus.ERROR, detail=str(exc))
|
|
424
|
+
except Exception as exc: # a subclass forgot to translate
|
|
425
|
+
return HealthReport(
|
|
426
|
+
self.name,
|
|
427
|
+
ProviderStatus.ERROR,
|
|
428
|
+
detail=f"untranslated {type(exc).__name__}: {exc}",
|
|
429
|
+
)
|
|
430
|
+
return HealthReport(self.name, ProviderStatus.OK, model_count=len(models))
|
|
431
|
+
|
|
432
|
+
@classmethod
|
|
433
|
+
def check(cls) -> HealthReport:
|
|
434
|
+
"""Health check that tolerates a missing key and never raises."""
|
|
435
|
+
if not cls.is_configured():
|
|
436
|
+
return HealthReport(
|
|
437
|
+
cls.name or cls.__name__,
|
|
438
|
+
ProviderStatus.NO_KEY,
|
|
439
|
+
detail=f"{cls.env_var} is not set",
|
|
440
|
+
)
|
|
441
|
+
try:
|
|
442
|
+
return cls().health_check()
|
|
443
|
+
except ProviderError as exc:
|
|
444
|
+
return HealthReport(cls.name, ProviderStatus.ERROR, detail=str(exc))
|
|
445
|
+
|
|
446
|
+
# -- misc -------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
def __repr__(self) -> str:
|
|
449
|
+
loaded = "?" if self._models is None else str(len(self._models))
|
|
450
|
+
return (
|
|
451
|
+
f"<{type(self).__name__} name={self.name!r} "
|
|
452
|
+
f"models={loaded} caps={sorted(c.value for c in self.capabilities)}>"
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
# --------------------------------------------------------------------------
|
|
457
|
+
# Optional capabilities
|
|
458
|
+
# --------------------------------------------------------------------------
|
|
459
|
+
#
|
|
460
|
+
# These are Protocols, not base-class methods, on purpose. A base method that
|
|
461
|
+
# raises NotImplementedError lies in its own signature: the caller cannot tell
|
|
462
|
+
# whether it will work without calling it. A Protocol lets both a static type
|
|
463
|
+
# checker and `isinstance` answer the question up front.
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
@runtime_checkable
|
|
467
|
+
class SupportsFIM(Protocol):
|
|
468
|
+
"""Fill-in-the-middle completion. Not an OpenAI-standard endpoint."""
|
|
469
|
+
|
|
470
|
+
def fim(
|
|
471
|
+
self,
|
|
472
|
+
model_id: str,
|
|
473
|
+
prefix: str,
|
|
474
|
+
suffix: str = "",
|
|
475
|
+
**kwargs: Any,
|
|
476
|
+
) -> Completion: ...
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
@runtime_checkable
|
|
480
|
+
class SupportsEdit(Protocol):
|
|
481
|
+
"""Next-edit prediction over a code region.
|
|
482
|
+
|
|
483
|
+
Note the absence of an `instruction` argument. Edit endpoints of this kind
|
|
484
|
+
infer the change from surrounding context -- the file, recently viewed
|
|
485
|
+
snippets, recent diffs, and cursor position -- rather than from a natural
|
|
486
|
+
language instruction. Providers that want an instruction should pass it
|
|
487
|
+
through `**kwargs` or expose a separate method.
|
|
488
|
+
"""
|
|
489
|
+
|
|
490
|
+
def code_edit(
|
|
491
|
+
self,
|
|
492
|
+
model_id: str,
|
|
493
|
+
code_to_edit: str,
|
|
494
|
+
*,
|
|
495
|
+
current_file: str = "",
|
|
496
|
+
**kwargs: Any,
|
|
497
|
+
) -> Completion: ...
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@runtime_checkable
|
|
501
|
+
class SupportsEmbeddings(Protocol):
|
|
502
|
+
def embed(
|
|
503
|
+
self,
|
|
504
|
+
model_id: str,
|
|
505
|
+
texts: list[str],
|
|
506
|
+
**kwargs: Any,
|
|
507
|
+
) -> list[list[float]]: ...
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Custom OpenAI-compatible endpoints: OpenRouter, a remote vLLM, Ollama, LM
|
|
2
|
+
Studio -- anything that answers `GET /v1/models` and `POST /v1/chat/completions`.
|
|
3
|
+
|
|
4
|
+
One factory, `openai_compatible(name)`, turns a NAME into a provider class
|
|
5
|
+
that reads `<NAME>_API_KEY` and `<NAME>_BASE_URL` from the environment and
|
|
6
|
+
otherwise behaves exactly like `OpenAIProvider` -- because it IS one, a
|
|
7
|
+
subclass built with `type()`. The names live in ~/.otto/routes.json
|
|
8
|
+
(agent/router/overrides.py registers each at startup); the key and URL rows
|
|
9
|
+
live in the repo `.env` beside the built-in vendors' keys.
|
|
10
|
+
|
|
11
|
+
Capability classification is a heuristic and says so, the same way
|
|
12
|
+
`openai_provider._classify` does: a generic `/v1/models` returns ids and
|
|
13
|
+
nothing else. The floor is CHAT+TOOLS; REASONING and VISION are guessed from
|
|
14
|
+
well-known family names; a person can pin the truth per model in routes.json
|
|
15
|
+
(`endpoints.<name>.capabilities`), which wins over the guess.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
from collections.abc import Iterable, Mapping
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
from agent.router.llm_provider.base import (
|
|
25
|
+
AuthError,
|
|
26
|
+
BaseProvider,
|
|
27
|
+
Capability,
|
|
28
|
+
HealthReport,
|
|
29
|
+
ProviderStatus,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
33
|
+
from agent.router.llm_provider.openai_provider import OpenAIProvider
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"NAME_RE", "BUILTIN", "validate_name", "env_prefix", "key_var", "url_var",
|
|
37
|
+
"classify_generic", "openai_compatible",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
#: Lower-case, starts with a letter, up to 32 characters. The name becomes an
|
|
41
|
+
#: environment-variable prefix (upper-cased, hyphens to underscores) and the
|
|
42
|
+
#: provider half of a `provider:model` spec, so it has to be safe in both.
|
|
43
|
+
NAME_RE = re.compile(r"^[a-z][a-z0-9_-]{1,31}$")
|
|
44
|
+
BUILTIN = ("inception", "anthropic", "openai", "gemini")
|
|
45
|
+
_RESERVED = BUILTIN + ("otto", "custom", "any", "none")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def validate_name(name: str) -> None:
|
|
49
|
+
if not isinstance(name, str) or not NAME_RE.match(name):
|
|
50
|
+
raise ValueError(
|
|
51
|
+
f"{name!r} is not a usable endpoint name: lower-case letters, digits, "
|
|
52
|
+
f"'-' or '_', starting with a letter, at most 32 characters"
|
|
53
|
+
)
|
|
54
|
+
if name in _RESERVED:
|
|
55
|
+
raise ValueError(f"{name!r} is reserved")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def env_prefix(name: str) -> str:
|
|
59
|
+
return name.upper().replace("-", "_")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def key_var(name: str) -> str:
|
|
63
|
+
return f"{env_prefix(name)}_API_KEY"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def url_var(name: str) -> str:
|
|
67
|
+
return f"{env_prefix(name)}_BASE_URL"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_NON_CHAT = ("whisper", "tts", "dall-e", "moderation", "sora", "davinci", "babbage")
|
|
71
|
+
_REASONING = re.compile(r"^(o\d|gpt-5|deepseek-r|qwq|.*-r1|.*think|.*reason)")
|
|
72
|
+
_VISION_MARKERS = ("vision", "-vl", "llava", "gpt-4o", "gpt-4.1", "gpt-5",
|
|
73
|
+
"claude", "gemini", "pixtral", "gemma-3")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def classify_generic(model_id: str) -> frozenset[Capability]:
|
|
77
|
+
"""Best-effort capabilities for an id from an unknown OpenAI-compatible
|
|
78
|
+
server. Heuristic -- see the module docstring."""
|
|
79
|
+
lowered = (model_id or "").lower()
|
|
80
|
+
vendor, _, tail = lowered.rpartition("/") # OpenRouter's "vendor/model"
|
|
81
|
+
if "embed" in tail:
|
|
82
|
+
return frozenset({Capability.EMBEDDINGS})
|
|
83
|
+
if any(marker in tail for marker in _NON_CHAT):
|
|
84
|
+
return frozenset()
|
|
85
|
+
caps = {Capability.CHAT, Capability.TOOLS}
|
|
86
|
+
if _REASONING.match(tail) or vendor in ("anthropic", "google"):
|
|
87
|
+
caps.add(Capability.REASONING)
|
|
88
|
+
if any(marker in tail for marker in _VISION_MARKERS):
|
|
89
|
+
caps.add(Capability.VISION)
|
|
90
|
+
return frozenset(caps)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def openai_compatible(
|
|
94
|
+
name: str,
|
|
95
|
+
*,
|
|
96
|
+
capability_overrides: Mapping[str, Iterable[str]] | None = None,
|
|
97
|
+
) -> "type[OpenAIProvider]":
|
|
98
|
+
"""A provider class for the endpoint called `name`.
|
|
99
|
+
|
|
100
|
+
The `openai` SDK import is inside the function on purpose: this module is
|
|
101
|
+
imported at startup by agent/router/overrides.py, and a routes.json that
|
|
102
|
+
names an endpoint must not make a machine without the SDK fail to start.
|
|
103
|
+
"""
|
|
104
|
+
validate_name(name)
|
|
105
|
+
from agent.router.llm_provider.openai_provider import OpenAIProvider
|
|
106
|
+
|
|
107
|
+
overrides: dict[str, frozenset[Capability]] = {
|
|
108
|
+
model_id: frozenset(Capability(c) for c in caps)
|
|
109
|
+
for model_id, caps in (capability_overrides or {}).items()
|
|
110
|
+
}
|
|
111
|
+
key_name, url_name = key_var(name), url_var(name)
|
|
112
|
+
|
|
113
|
+
def __init__(self, api_key: str | None = None, *, base_url: str | None = None) -> None:
|
|
114
|
+
url = base_url or os.environ.get(url_name)
|
|
115
|
+
if not url:
|
|
116
|
+
# Without this an endpoint with a key but no URL would quietly
|
|
117
|
+
# send that key to api.openai.com.
|
|
118
|
+
raise AuthError(f"{name}: {url_name} is not set")
|
|
119
|
+
OpenAIProvider.__init__(self, api_key, base_url=url.rstrip("/"))
|
|
120
|
+
|
|
121
|
+
@classmethod
|
|
122
|
+
def is_configured(cls) -> bool:
|
|
123
|
+
# Servers that ignore keys (Ollama, a local vLLM) still need SOME
|
|
124
|
+
# value here -- the setup screen says so; "x" is fine.
|
|
125
|
+
return bool(os.environ.get(key_name)) and bool(os.environ.get(url_name))
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def check(cls) -> HealthReport:
|
|
129
|
+
missing = [v for v in (key_name, url_name) if not os.environ.get(v)]
|
|
130
|
+
if missing:
|
|
131
|
+
return HealthReport(name, ProviderStatus.NO_KEY,
|
|
132
|
+
detail=f"{' and '.join(missing)} not set")
|
|
133
|
+
return BaseProvider.check.__func__(cls)
|
|
134
|
+
|
|
135
|
+
def _classify(self, model_id: str) -> frozenset[Capability]:
|
|
136
|
+
return overrides.get(model_id) or classify_generic(model_id)
|
|
137
|
+
|
|
138
|
+
namespace = {
|
|
139
|
+
"__module__": __name__,
|
|
140
|
+
"__doc__": f"OpenAI-compatible endpoint {name!r} ({url_name}).",
|
|
141
|
+
"name": name,
|
|
142
|
+
"env_var": key_name,
|
|
143
|
+
"base_url_var": url_name,
|
|
144
|
+
"default_base_url": None,
|
|
145
|
+
"capabilities": frozenset({Capability.CHAT, Capability.TOOLS,
|
|
146
|
+
Capability.VISION, Capability.REASONING}),
|
|
147
|
+
"__init__": __init__,
|
|
148
|
+
"is_configured": is_configured,
|
|
149
|
+
"check": check,
|
|
150
|
+
"_classify": _classify,
|
|
151
|
+
}
|
|
152
|
+
return type(f"CustomProvider_{env_prefix(name)}", (OpenAIProvider,), namespace)
|