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.
Files changed (129) hide show
  1. agent/README.md +22 -0
  2. agent/__init__.py +0 -0
  3. agent/cli/README.md +77 -0
  4. agent/cli/__init__.py +0 -0
  5. agent/cli/art.py +371 -0
  6. agent/cli/chat.py +309 -0
  7. agent/cli/clipboard.py +106 -0
  8. agent/cli/context.py +56 -0
  9. agent/cli/doctor.py +60 -0
  10. agent/cli/errors.py +58 -0
  11. agent/cli/eval.py +130 -0
  12. agent/cli/eval_claw.py +414 -0
  13. agent/cli/eval_compaction.py +106 -0
  14. agent/cli/eval_hle.py +92 -0
  15. agent/cli/eval_memory.py +253 -0
  16. agent/cli/eval_swe.py +172 -0
  17. agent/cli/lessons.py +97 -0
  18. agent/cli/main.py +67 -0
  19. agent/cli/modals.py +570 -0
  20. agent/cli/models.py +64 -0
  21. agent/cli/output.py +54 -0
  22. agent/cli/route.py +78 -0
  23. agent/cli/sessions.py +125 -0
  24. agent/cli/setup_screen.py +562 -0
  25. agent/cli/shell.py +548 -0
  26. agent/cli/tui.py +1807 -0
  27. agent/cli/ui.py +14 -0
  28. agent/cli/usage_panel.py +159 -0
  29. agent/config/README.md +7 -0
  30. agent/config/__init__.py +0 -0
  31. agent/config/envfile.py +76 -0
  32. agent/eval/README.md +76 -0
  33. agent/eval/__init__.py +0 -0
  34. agent/eval/claw_bench.py +1031 -0
  35. agent/eval/compaction_bench.py +229 -0
  36. agent/eval/data/README.md +10 -0
  37. agent/eval/data/claw/README.md +108 -0
  38. agent/eval/data/claw/llm_judge-gemini.patch +57 -0
  39. agent/eval/data/claw/otto.yaml +35 -0
  40. agent/eval/failures.py +276 -0
  41. agent/eval/golden/README.md +33 -0
  42. agent/eval/golden/code_01.json +6 -0
  43. agent/eval/golden/code_02.json +6 -0
  44. agent/eval/golden/code_03.json +6 -0
  45. agent/eval/golden/code_04.json +6 -0
  46. agent/eval/golden/code_05.json +6 -0
  47. agent/eval/golden/code_06.json +6 -0
  48. agent/eval/golden/math_01.json +6 -0
  49. agent/eval/golden/math_02.json +6 -0
  50. agent/eval/golden/math_03.json +6 -0
  51. agent/eval/golden/math_04.json +6 -0
  52. agent/eval/golden/math_05.json +6 -0
  53. agent/eval/golden/math_06.json +6 -0
  54. agent/eval/golden/nphard_gcp_01.json +6 -0
  55. agent/eval/golden/nphard_ksp_01.json +6 -0
  56. agent/eval/golden/nphard_math_binpacking_01.json +6 -0
  57. agent/eval/golden/nphard_math_clique_01.json +6 -0
  58. agent/eval/golden/nphard_math_setcover_01.json +6 -0
  59. agent/eval/golden/nphard_math_subsetsum_01.json +6 -0
  60. agent/eval/golden/nphard_tsp_01.json +6 -0
  61. agent/eval/golden/nphard_tsp_02.json +6 -0
  62. agent/eval/hle_bench.py +273 -0
  63. agent/eval/langfuse_sync.py +172 -0
  64. agent/eval/memory_bench.py +538 -0
  65. agent/eval/runner.py +174 -0
  66. agent/eval/single_agent.py +120 -0
  67. agent/eval/swe_bench.py +604 -0
  68. agent/eval/terminal_bench.py +345 -0
  69. agent/memory/README.md +102 -0
  70. agent/memory/__init__.py +42 -0
  71. agent/memory/embeddings.py +302 -0
  72. agent/memory/hashing.py +15 -0
  73. agent/memory/lessons.py +483 -0
  74. agent/memory/queue.py +531 -0
  75. agent/memory/retrieval.py +493 -0
  76. agent/memory/session.py +60 -0
  77. agent/memory/sessions.py +436 -0
  78. agent/memory/store.py +429 -0
  79. agent/memory/tokens.py +60 -0
  80. agent/memory/wiring.py +146 -0
  81. agent/pipeline/README.md +135 -0
  82. agent/pipeline/__init__.py +0 -0
  83. agent/pipeline/browsing.py +609 -0
  84. agent/pipeline/budget.py +403 -0
  85. agent/pipeline/codemap.py +254 -0
  86. agent/pipeline/evidence.py +325 -0
  87. agent/pipeline/execution.py +67 -0
  88. agent/pipeline/modes.py +137 -0
  89. agent/pipeline/native.py +1137 -0
  90. agent/pipeline/nodes.py +3644 -0
  91. agent/pipeline/pricing.py +209 -0
  92. agent/pipeline/progress.py +139 -0
  93. agent/pipeline/rag.py +139 -0
  94. agent/pipeline/research.py +1325 -0
  95. agent/pipeline/run.py +528 -0
  96. agent/pipeline/screen.py +77 -0
  97. agent/pipeline/state.py +220 -0
  98. agent/pipeline/toolkit.py +328 -0
  99. agent/pipeline/tools.py +1990 -0
  100. agent/pipeline/tracing.py +147 -0
  101. agent/pipeline/usage.py +251 -0
  102. agent/pipeline/vision.py +84 -0
  103. agent/pipeline/walkthrough.py +735 -0
  104. agent/pipeline/workspace.py +229 -0
  105. agent/router/README.md +60 -0
  106. agent/router/__init__.py +0 -0
  107. agent/router/automap.py +114 -0
  108. agent/router/health.py +229 -0
  109. agent/router/llm_provider/README.md +38 -0
  110. agent/router/llm_provider/__init__.py +202 -0
  111. agent/router/llm_provider/anthropic_provider.py +128 -0
  112. agent/router/llm_provider/base.py +507 -0
  113. agent/router/llm_provider/custom.py +152 -0
  114. agent/router/llm_provider/gemini_provider.py +122 -0
  115. agent/router/llm_provider/inception_provider.py +687 -0
  116. agent/router/llm_provider/openai_provider.py +151 -0
  117. agent/router/llm_provider/retired.py +145 -0
  118. agent/router/llm_provider/temperature.py +371 -0
  119. agent/router/mapping.py +579 -0
  120. agent/router/outcomes.py +363 -0
  121. agent/router/overrides.py +389 -0
  122. agent/router/reload.py +28 -0
  123. agent/router/router.py +413 -0
  124. agent/router/setup.py +123 -0
  125. otto_cli_agent-0.1.0.dist-info/METADATA +115 -0
  126. otto_cli_agent-0.1.0.dist-info/RECORD +129 -0
  127. otto_cli_agent-0.1.0.dist-info/WHEEL +4 -0
  128. otto_cli_agent-0.1.0.dist-info/entry_points.txt +2 -0
  129. 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)