Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,395 @@
|
||||
"""Ramp Router (router.com) provider plugin for Hermes Agent.
|
||||
|
||||
Provider profile for `Ramp Router <https://docs.router.com>`_, Ramp's LLM
|
||||
gateway: one OpenAI Responses-compatible endpoint at
|
||||
``https://api.router.com/v1`` that routes each request across upstream
|
||||
providers (OpenAI, Anthropic, xAI, Fireworks, ...) and handles fallbacks and
|
||||
spend controls server-side.
|
||||
|
||||
Wire notes (verified live against api.router.com, Aug 2026):
|
||||
|
||||
* **Responses API is the native wire.** Router serves ``GET /v1/models``
|
||||
and ``POST /v1/responses``; ``POST /v1/chat/completions`` is only a
|
||||
minimal compatibility shim (added Aug 2026) that translates onto
|
||||
Responses. Per-model reasoning-effort validation, reasoning summaries,
|
||||
and prompt caching are Responses-surface features, so
|
||||
``api_mode="codex_responses"`` plus the ``api.router.com`` host mandate
|
||||
in ``hermes_cli/providers.py`` keep every path on the native wire —
|
||||
the same shape as the ``api.openai.com`` mandate.
|
||||
* **Account-scoped catalog.** Valid model IDs are whatever the key's
|
||||
``GET /v1/models`` returns (BYOK accounts see extra entries), so this
|
||||
profile ships **no** ``fallback_models`` — the picker relies on the live
|
||||
fetch, per Router's own guidance to never hardcode model names.
|
||||
* **Strict reasoning-effort validation.** Router validates
|
||||
``reasoning.effort`` against each model's catalog-declared vocabulary and
|
||||
returns HTTP 400 ``invalid-argument`` on a level the model does not accept
|
||||
(e.g. ``max`` on grok-4.6), and 400 ``unsupported_parameter`` when a
|
||||
non-reasoning model (gpt-4.1 family, gpt-4o, ...) receives any reasoning
|
||||
field. The catalog publishes the vocabulary per model
|
||||
(``router.capabilities.reasoning``), so ``supported_reasoning_efforts``
|
||||
below feeds the codex transport's clamp from a cached copy of it.
|
||||
* **Everything else passes through.** ``store: false``, ``prompt_cache_key``,
|
||||
``include: ["reasoning.encrypted_content"]``, and ``reasoning.summary`` are
|
||||
accepted on all models (ignored where a backend cannot honor them), tools /
|
||||
``parallel_tool_calls`` / streaming SSE work across backends, and encrypted
|
||||
reasoning replay round-trips on OpenAI-served models — so the generic
|
||||
Responses transport path needs no Router-specific request surgery.
|
||||
|
||||
The capability cache mirrors the OpenRouter reasoning-caps design in
|
||||
``hermes_cli/models.py``: cache-only lookups on the per-request hot path
|
||||
(never HTTP), seeded for free whenever ``fetch_models()`` runs (picker,
|
||||
setup, doctor), hydrated from a disk mirror across processes, and refreshed
|
||||
by a background warmer when cold or stale.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from hermes_cli import __version__ as _HERMES_VERSION
|
||||
from providers import register_provider
|
||||
from providers.base import ProviderProfile, _profile_user_agent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ROUTER_DEFAULT_BASE_URL = "https://api.router.com/v1"
|
||||
|
||||
#: Efforts-by-model cache: ``model id -> list of accepted effort levels``.
|
||||
#: ``[]`` means the catalog says the model accepts NO reasoning parameters
|
||||
#: (``reasoning.supported: false``) — the transport must omit reasoning
|
||||
#: entirely. A model absent from the dict is unknown (custom/BYOK route or
|
||||
#: vocabulary not published) and callers fall back to their defaults.
|
||||
_efforts_cache: Optional[dict[str, list[str]]] = None
|
||||
_efforts_lock = threading.Lock()
|
||||
_warm_started = False
|
||||
_disk_checked = False
|
||||
|
||||
#: Disk-mirror staleness bound. Vocabularies change rarely; a stale verdict
|
||||
#: beats no verdict, so a past-TTL mirror is still served while a background
|
||||
#: refresh runs (same policy as the OpenRouter caps mirror).
|
||||
_DISK_TTL_SECONDS = 24 * 60 * 60
|
||||
|
||||
|
||||
def _base_url() -> str:
|
||||
"""Allow a base-URL override via ``RAMP_ROUTER_BASE_URL``."""
|
||||
return os.getenv("RAMP_ROUTER_BASE_URL", "").strip().rstrip("/") or ROUTER_DEFAULT_BASE_URL
|
||||
|
||||
|
||||
def _resolve_api_key() -> str:
|
||||
"""Resolve the Router key from .env / environment, preferring dotenv.
|
||||
|
||||
``RAMP_ROUTER_API_KEY`` is Router's documented variable;
|
||||
``ROUTER_API_KEY`` is accepted as a convenience alias. Falls back to the
|
||||
raw environment when the hermes_cli helper is unavailable (e.g. stripped
|
||||
test environments).
|
||||
"""
|
||||
resolvers = []
|
||||
try:
|
||||
from hermes_cli.config import get_env_value_prefer_dotenv
|
||||
|
||||
resolvers.append(get_env_value_prefer_dotenv)
|
||||
except Exception:
|
||||
pass
|
||||
resolvers.append(lambda var: os.environ.get(var, ""))
|
||||
for resolve in resolvers:
|
||||
for var in ("RAMP_ROUTER_API_KEY", "ROUTER_API_KEY"):
|
||||
try:
|
||||
value = str(resolve(var) or "").strip()
|
||||
except Exception:
|
||||
value = ""
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _parse_efforts(items: Any) -> Optional[dict[str, list[str]]]:
|
||||
"""Parse a Router ``/v1/models`` ``data`` array into the efforts map.
|
||||
|
||||
Returns None when the array has no usable entries, which callers treat
|
||||
as a failed fetch rather than caching an empty verdict.
|
||||
"""
|
||||
if not isinstance(items, list):
|
||||
return None
|
||||
try:
|
||||
from agent.reasoning_effort import EFFORT_LADDER
|
||||
|
||||
known_levels = set(EFFORT_LADDER)
|
||||
except Exception:
|
||||
known_levels = None
|
||||
efforts_by_id: dict[str, list[str]] = {}
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
mid = str(item.get("id") or "").strip()
|
||||
if not mid:
|
||||
continue
|
||||
router_meta = item.get("router")
|
||||
reasoning = None
|
||||
if isinstance(router_meta, dict):
|
||||
capabilities = router_meta.get("capabilities")
|
||||
if isinstance(capabilities, dict):
|
||||
reasoning = capabilities.get("reasoning")
|
||||
if not isinstance(reasoning, dict):
|
||||
continue
|
||||
if reasoning.get("supported") is False:
|
||||
# Definitive negative: any reasoning field 400s on this model.
|
||||
efforts_by_id[mid] = []
|
||||
continue
|
||||
levels = [
|
||||
str(entry.get("value") or "").strip()
|
||||
for entry in reasoning.get("efforts") or []
|
||||
if isinstance(entry, dict) and str(entry.get("value") or "").strip()
|
||||
]
|
||||
if known_levels is not None:
|
||||
# clamp_effort silently ignores ladder-unknown levels, and an
|
||||
# all-unknown vocabulary would pass the requested effort through
|
||||
# unclamped straight to a Router 400 — so a new vendor tier is
|
||||
# dropped at ingest and fails loudly here instead.
|
||||
unknown = [level for level in levels if level not in known_levels]
|
||||
if unknown:
|
||||
logger.info(
|
||||
"router: model %s publishes unrecognized reasoning effort "
|
||||
"level(s) %s; ignoring them (update agent/reasoning_effort "
|
||||
"EFFORT_LADDER to adopt new vendor tiers)",
|
||||
mid,
|
||||
unknown,
|
||||
)
|
||||
levels = [level for level in levels if level in known_levels]
|
||||
if levels:
|
||||
efforts_by_id[mid] = levels
|
||||
# supported=True with no (recognized) vocabulary -> leave the model
|
||||
# out (unknown), so the transport keeps its default clamp behavior.
|
||||
return efforts_by_id or None
|
||||
|
||||
|
||||
def _disk_path() -> Optional[Path]:
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
return get_hermes_home() / "cache" / "router_catalog.json"
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _save_disk(efforts_by_id: dict[str, list[str]]) -> None:
|
||||
path = _disk_path()
|
||||
if path is None:
|
||||
return
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(".tmp")
|
||||
tmp.write_text(
|
||||
json.dumps({"ts": time.time(), "efforts": efforts_by_id}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
tmp.replace(path)
|
||||
except Exception as exc:
|
||||
logger.debug("router: caps disk mirror write failed: %s", exc)
|
||||
|
||||
|
||||
def _load_disk() -> tuple[Optional[dict[str, list[str]]], float]:
|
||||
path = _disk_path()
|
||||
if path is None:
|
||||
return None, 0.0
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
efforts = data.get("efforts")
|
||||
if not isinstance(efforts, dict) or not efforts:
|
||||
return None, 0.0
|
||||
parsed = {
|
||||
str(mid): [str(level) for level in levels]
|
||||
for mid, levels in efforts.items()
|
||||
if isinstance(levels, list)
|
||||
}
|
||||
try:
|
||||
age = max(0.0, time.time() - float(data.get("ts") or 0))
|
||||
except (TypeError, ValueError):
|
||||
age = float(_DISK_TTL_SECONDS)
|
||||
return (parsed or None), age
|
||||
except Exception:
|
||||
return None, 0.0
|
||||
|
||||
|
||||
def _seed_efforts(items: Any) -> Optional[dict[str, list[str]]]:
|
||||
"""Seed memory + disk caches from a ``/v1/models`` payload."""
|
||||
global _efforts_cache
|
||||
parsed = _parse_efforts(items)
|
||||
if parsed is None:
|
||||
return None
|
||||
with _efforts_lock:
|
||||
_efforts_cache = parsed
|
||||
_save_disk(parsed)
|
||||
return parsed
|
||||
|
||||
|
||||
def _fetch_catalog_items(
|
||||
*, api_key: str = "", base_url: str = "", timeout: float = 8.0
|
||||
) -> Optional[list]:
|
||||
"""Fetch the raw ``/v1/models`` ``data`` array. None on any failure."""
|
||||
url = (base_url or _base_url()).rstrip("/") + "/models"
|
||||
import urllib.request
|
||||
|
||||
from hermes_cli.urllib_security import open_credentialed_url
|
||||
|
||||
req = urllib.request.Request(url)
|
||||
key = api_key or _resolve_api_key()
|
||||
if key:
|
||||
req.add_header("Authorization", f"Bearer {key}")
|
||||
req.add_header("Accept", "application/json")
|
||||
# Router sits behind a WAF that rejects the default Python-urllib UA.
|
||||
req.add_header("User-Agent", _profile_user_agent())
|
||||
try:
|
||||
with open_credentialed_url(req, timeout=timeout) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
except Exception as exc:
|
||||
logger.debug("router: catalog fetch failed: %s", exc)
|
||||
return None
|
||||
items = data if isinstance(data, list) else data.get("data", [])
|
||||
return items if isinstance(items, list) else None
|
||||
|
||||
|
||||
def _efforts_cache_only() -> Optional[dict[str, list[str]]]:
|
||||
"""Memory, else the disk mirror. Never HTTP (hot-path safe)."""
|
||||
global _efforts_cache, _disk_checked
|
||||
with _efforts_lock:
|
||||
cached = _efforts_cache
|
||||
if cached is not None:
|
||||
return cached
|
||||
if _disk_checked:
|
||||
return None
|
||||
_disk_checked = True
|
||||
parsed, age = _load_disk()
|
||||
if parsed is None:
|
||||
return None
|
||||
with _efforts_lock:
|
||||
if _efforts_cache is None:
|
||||
_efforts_cache = parsed
|
||||
cached = _efforts_cache
|
||||
if age >= _DISK_TTL_SECONDS:
|
||||
_warm_efforts_async()
|
||||
return cached
|
||||
|
||||
|
||||
def _warm_efforts_async() -> None:
|
||||
"""Refresh the efforts cache in the background, at most once per process."""
|
||||
global _warm_started
|
||||
if os.environ.get("PYTEST_CURRENT_TEST"):
|
||||
# Match the canonical caps warmer (hermes_cli/models.py): a mid-suite
|
||||
# background fetch would make cache state timing-dependent in tests.
|
||||
return
|
||||
with _efforts_lock:
|
||||
if _warm_started:
|
||||
return
|
||||
_warm_started = True
|
||||
if not _resolve_api_key():
|
||||
# Without a key the fetch would 401; the first authenticated
|
||||
# fetch_models() (picker/setup/doctor) seeds the cache instead.
|
||||
return
|
||||
|
||||
def _refresh() -> None:
|
||||
items = _fetch_catalog_items()
|
||||
if items is not None:
|
||||
_seed_efforts(items)
|
||||
|
||||
try:
|
||||
threading.Thread(
|
||||
target=_refresh, name="router-caps-warm", daemon=True
|
||||
).start()
|
||||
except Exception as exc:
|
||||
logger.debug("router: caps warmer failed to start: %s", exc)
|
||||
|
||||
|
||||
class RouterProfile(ProviderProfile):
|
||||
"""Ramp Router — Responses-only gateway with catalog-declared efforts."""
|
||||
|
||||
def fetch_models(
|
||||
self,
|
||||
*,
|
||||
api_key: Optional[str] = None,
|
||||
base_url: Optional[str] = None,
|
||||
timeout: float = 8.0,
|
||||
) -> Optional[list[str]]:
|
||||
"""Fetch the live, key-scoped catalog and seed the caps cache.
|
||||
|
||||
One request serves both consumers: the picker gets the model IDs and
|
||||
the reasoning-vocabulary mirror is left warm at no extra network
|
||||
cost (the same document carries both).
|
||||
"""
|
||||
items = _fetch_catalog_items(
|
||||
api_key=api_key or "", base_url=base_url or "", timeout=timeout
|
||||
)
|
||||
if items is None:
|
||||
return None
|
||||
_seed_efforts(items)
|
||||
# Deduped but not sorted: Router's listing order is deliberate
|
||||
# presentation (featured/current models first), so the picker keeps it.
|
||||
ids = list(
|
||||
dict.fromkeys(
|
||||
str(item["id"])
|
||||
for item in items
|
||||
if isinstance(item, dict) and item.get("id")
|
||||
)
|
||||
)
|
||||
return ids or None
|
||||
|
||||
def supported_reasoning_efforts(
|
||||
self, model: Optional[str]
|
||||
) -> Optional[tuple[str, ...]]:
|
||||
"""Catalog-declared effort vocabulary for *model* (cache-only).
|
||||
|
||||
Router 400s on efforts outside a model's published set and on any
|
||||
reasoning field for non-reasoning models, so the codex transport
|
||||
clamps (or suppresses) from this verdict. Cold cache returns None —
|
||||
the transport keeps its defaults — and kicks a background warmer so
|
||||
the next turn is covered.
|
||||
"""
|
||||
mid = str(model or "").strip()
|
||||
if not mid:
|
||||
return None
|
||||
efforts_by_id = _efforts_cache_only()
|
||||
if efforts_by_id is None:
|
||||
_warm_efforts_async()
|
||||
return None
|
||||
levels = efforts_by_id.get(mid)
|
||||
if levels is None:
|
||||
return None
|
||||
return tuple(levels)
|
||||
|
||||
|
||||
router = RouterProfile(
|
||||
name="router",
|
||||
aliases=("ramp-router", "ramp", "router.com"),
|
||||
api_mode="codex_responses",
|
||||
display_name="Ramp Router",
|
||||
description="Ramp Router (router.com) — routes each request to the cheapest model that clears your quality bar",
|
||||
signup_url="https://app.router.com/keys",
|
||||
# RAMP_ROUTER_API_KEY is Router's documented variable; ROUTER_API_KEY is
|
||||
# a convenience alias. RAMP_ROUTER_BASE_URL overrides the endpoint
|
||||
# (auth.py picks it up as the registry's base_url_env_var).
|
||||
env_vars=("RAMP_ROUTER_API_KEY", "ROUTER_API_KEY", "RAMP_ROUTER_BASE_URL"),
|
||||
base_url=_base_url(),
|
||||
auth_type="api_key",
|
||||
# Identify Hermes traffic to the gateway (Router attributes coding-agent
|
||||
# clients by User-Agent prefix, the way it already recognizes OpenCode's
|
||||
# versioned UA) — and Router's WAF rejects blank/default client UAs.
|
||||
default_headers={"User-Agent": f"Hermes-Agent/{_HERMES_VERSION}"},
|
||||
# Most of the catalog's frontier routes accept image input; capability is
|
||||
# still model-dependent and governed by the live catalog.
|
||||
supports_vision=True,
|
||||
# Cheap, reasoning-capable, and vision-capable — safe for auxiliary tasks
|
||||
# (compaction, titles, vision) when Router is the main provider. Also the
|
||||
# model Router's own docs use as their example.
|
||||
default_aux_model="gpt-5.4-mini",
|
||||
# Deliberately empty: model IDs are account-scoped (BYOK accounts see
|
||||
# extra entries) and Router's docs say to read the catalog at runtime
|
||||
# rather than hardcode names. The picker uses fetch_models() above.
|
||||
fallback_models=(),
|
||||
)
|
||||
|
||||
register_provider(router)
|
||||
@@ -0,0 +1,5 @@
|
||||
name: router-provider
|
||||
kind: model-provider
|
||||
version: 1.0.0
|
||||
description: Ramp Router (router.com) — OpenAI Responses-compatible LLM gateway
|
||||
author: Neel Patel (Ramp)
|
||||
Reference in New Issue
Block a user