Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions raven/cli/provider_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
- ``provider list`` — overview of every provider's status
- ``provider get <name>`` — current config (secrets redacted)
- ``provider set <name> [...]`` — patch fields (--api-key, --api-base, ...)
- ``provider test <name>`` — verify creds via free ``GET /v1/models``
- ``provider test <name>`` — verify creds via a free model catalog request
- ``provider reset <name>`` — restore schema defaults; OAuth providers
also lose their token file
- ``provider show <name>`` — reflect available ``--flag`` fields
Expand Down Expand Up @@ -383,15 +383,14 @@ def provider_test_cmd(
name: str = typer.Argument(..., help="Provider name"),
timeout: int = typer.Option(10, "--timeout", "-t", help="Timeout seconds"),
):
"""Verify a provider's credentials via a free ``GET /v1/models`` call.
"""Verify a provider's credentials via a free model catalog request.

Does NOT consume inference quota — hits the provider's models metadata
endpoint, which is free, fast, and tells you whether the key is valid,
has credit, and isn't rate-limited.
endpoint to verify that the configured credentials can list models.
"""
from raven.config.update_providers import test_provider as probe

console.print(f"[dim]Pinging {name}/v1/models ...[/dim]")
console.print(f"[dim]Pinging {name} model catalog ...[/dim]")
try:
result = probe(name, timeout_s=timeout)
except KeyError as exc:
Expand All @@ -406,12 +405,21 @@ def provider_test_cmd(
)
return

from raven.providers.registry import CRED_OAUTH, credential_kind

oauth_login_hint = f"Run: raven provider login {name.replace('_', '-')}"
invalid_credential_hint = (
oauth_login_hint
if credential_kind(name) == CRED_OAUTH
else f"Run: raven provider set {name} --api-key <NEW-KEY>"
)
hints = {
"not_configured": f"Run: raven provider set {name} --api-key <KEY>",
"invalid_key": f"Run: raven provider set {name} --api-key <NEW-KEY>",
"invalid_key": invalid_credential_hint,
"no_credits": "Fund your account at the provider's billing page",
"rate_limited": "Wait a few minutes and retry, or switch provider",
"oauth_token_missing": (f"Run: raven provider login {name.replace('_', '-')}"),
"oauth_token_missing": oauth_login_hint,
"no_models": "No visible models are available for this account",
"network_error": "Check network / firewall / VPN settings",
}
hint = hints.get(result["status"], "")
Expand Down
63 changes: 57 additions & 6 deletions raven/config/update_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -771,23 +771,25 @@ def test_provider(
config_path: Path | None = None,
transport: httpx.BaseTransport | None = None,
) -> dict[str, Any]:
"""Verify a provider's credentials via a free GET request to ``/v1/models``.
"""Verify credentials with the provider's free model metadata endpoint.

Why ``/v1/models`` rather than a chat completion (same rationale as
hermes-agent's ``doctor._probe_apikey_provider``):
OpenAI-compatible providers use ``/v1/models``. OpenAI Codex uses its
account-scoped catalog because its OAuth token is not an API key and the
generic endpoint rejects it.

- Zero token cost — metadata endpoint, not LLM-generated content.
- No charge to the user, doesn't burn inference quota.
- Supported by virtually every OpenAI-compatible provider (the 18 we
ship today).
- Supported by the OpenAI-compatible providers we ship, with a dedicated
adapter for providers whose catalog contract differs.
- No "which test model?" maintenance burden.

Behavior:

1. Look up the provider's ``api_key`` or provider-specific OAuth access
token and ``api_base``
(falling back to ``ProviderSpec.default_api_base`` when unset).
2. ``GET {api_base}/v1/models`` with ``Authorization: Bearer {key}``.
2. Fetch the provider-specific model catalog with its required auth
headers.
3. Map status code → keyword (see ``_HTTP_STATUS_MAP``). Unknown codes
render as ``http_{code}``. Network errors → ``network_error``.

Expand All @@ -813,6 +815,7 @@ def test_provider(

api_key = cfg.get("api_key") or ""
api_base = cfg.get("api_base") or (spec.default_api_base if spec else "") or ""
oauth_token: Any | None = None

if spec and spec.is_oauth:
try:
Expand Down Expand Up @@ -856,6 +859,7 @@ def test_provider(
"error": "no OAuth token stored",
}
api_key = token.access
oauth_token = token

if not api_key and not (spec and spec.is_local):
return {
Expand All @@ -879,6 +883,53 @@ def test_provider(
"error": "api_base is empty and provider has no default",
}

if spec and spec.name == "openai_codex":
from raven.providers.openai_codex_catalog import CodexModelCatalogError, fetch_codex_models

start = time.monotonic()
try:
model_ids = fetch_codex_models(oauth_token, timeout=timeout_s, transport=transport)
except httpx.HTTPStatusError as exc:
status_code = exc.response.status_code
return {
"ok": False,
"status": _HTTP_STATUS_MAP.get(status_code, f"http_{status_code}"),
"elapsed_ms": int((time.monotonic() - start) * 1000),
"http_status": status_code,
"models_count": None,
"model_ids": None,
"error": f"HTTP {status_code}",
}
except CodexModelCatalogError as exc:
return {
"ok": False,
"status": "no_models",
"elapsed_ms": int((time.monotonic() - start) * 1000),
"http_status": 200,
"models_count": 0,
"model_ids": [],
"error": str(exc),
}
except httpx.HTTPError as exc:
return {
"ok": False,
"status": "network_error",
"elapsed_ms": int((time.monotonic() - start) * 1000),
"http_status": None,
"models_count": None,
"model_ids": None,
"error": str(exc),
}
return {
"ok": True,
"status": "valid",
"elapsed_ms": int((time.monotonic() - start) * 1000),
"http_status": 200,
"models_count": len(model_ids),
"model_ids": model_ids,
"error": None,
}

url = api_base.rstrip("/") + "/models"
if "/v1" not in api_base:
url = api_base.rstrip("/") + "/v1/models"
Expand Down
88 changes: 88 additions & 0 deletions raven/providers/openai_codex_catalog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""Account-scoped model catalog for the OpenAI Codex OAuth provider."""

from __future__ import annotations

from typing import Any

import httpx

AUTO_CODEX_MODEL = "openai-codex/auto"
CODEX_CATALOG_URL = "https://chatgpt.com/backend-api/codex/models"
# This version declares the Codex protocol Raven currently implements. It is
# intentionally independent of Raven's package version.
CODEX_CATALOG_CLIENT_VERSION = "0.146.0"
CODEX_CATALOG_TIMEOUT = 5.0
CODEX_CATALOG_CACHE_TTL = 300.0


class CodexModelCatalogError(RuntimeError):
"""The Codex account catalog cannot provide a usable model."""


def is_auto_codex_model(model: str) -> bool:
return model in {"auto", AUTO_CODEX_MODEL, "openai_codex/auto"}


def _catalog_headers(token: Any) -> dict[str, str]:
access = getattr(token, "access", None)
if not isinstance(access, str) or not access:
raise CodexModelCatalogError("Codex OAuth access token is missing")

headers = {
"Authorization": f"Bearer {access}",
"User-Agent": "raven (python)",
"accept": "application/json",
}
account_id = getattr(token, "account_id", None)
if isinstance(account_id, str) and account_id:
headers["chatgpt-account-id"] = account_id
return headers


def _visible_models(payload: Any) -> list[str]:
raw_models = payload.get("models") if isinstance(payload, dict) else None
if not isinstance(raw_models, list):
raise CodexModelCatalogError("Codex model catalog contains no visible models")

candidates: list[tuple[int, int, str]] = []
for index, item in enumerate(raw_models):
if not isinstance(item, dict) or item.get("visibility") != "list":
continue
slug = item.get("slug")
if not isinstance(slug, str) or not slug.strip():
continue
priority = item.get("priority")
if not isinstance(priority, int) or isinstance(priority, bool):
priority = 2**31 - 1
candidates.append((priority, index, slug.strip()))

candidates.sort(key=lambda candidate: (candidate[0], candidate[1]))
models = list(dict.fromkeys(candidate[2] for candidate in candidates))
if not models:
raise CodexModelCatalogError("Codex model catalog contains no visible models")
return models


def fetch_codex_models(
token: Any,
*,
timeout: float = CODEX_CATALOG_TIMEOUT,
transport: httpx.BaseTransport | None = None,
) -> list[str]:
"""Fetch visible account models in the server's priority order."""
client_kwargs: dict[str, Any] = {"timeout": timeout}
if transport is not None:
client_kwargs["transport"] = transport

with httpx.Client(**client_kwargs) as client:
response = client.get(
CODEX_CATALOG_URL,
params={"client_version": CODEX_CATALOG_CLIENT_VERSION},
headers=_catalog_headers(token),
)
response.raise_for_status()
try:
payload = response.json()
except ValueError as exc:
raise CodexModelCatalogError("Codex model catalog returned invalid JSON") from exc
return _visible_models(payload)
46 changes: 45 additions & 1 deletion raven/providers/openai_codex_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,20 @@
import asyncio
import hashlib
import json
import time
from typing import Any, AsyncGenerator

import httpx
from loguru import logger

from raven.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from raven.providers.openai_codex_catalog import (
AUTO_CODEX_MODEL,
CODEX_CATALOG_CACHE_TTL,
CODEX_CATALOG_TIMEOUT,
fetch_codex_models,
is_auto_codex_model,
)

DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses"
DEFAULT_ORIGINATOR = "raven"
Expand All @@ -19,9 +27,12 @@
class OpenAICodexProvider(LLMProvider):
"""Use Codex OAuth to call the Responses API."""

def __init__(self, default_model: str = "openai-codex/gpt-5.1-codex"):
def __init__(self, default_model: str = AUTO_CODEX_MODEL):
super().__init__(api_key=None, api_base=None)
self.default_model = default_model
self._resolved_auto_model: str | None = None
self._resolved_auto_model_for: str | None = None
self._resolved_auto_model_expires_at = 0.0

async def chat(
self,
Expand All @@ -45,6 +56,15 @@ async def chat(
) from e

token = await asyncio.to_thread(get_codex_token)
if is_auto_codex_model(model):
try:
model = await self._resolve_auto_model(token)
except Exception as e:
return LLMResponse(
content=f"Error calling Codex: {str(e)}",
finish_reason="error",
error_classification=self.classify_error(e),
)
headers = _build_headers(token.account_id, token.access)

body: dict[str, Any] = {
Expand Down Expand Up @@ -96,6 +116,30 @@ async def chat(
def get_default_model(self) -> str:
return self.default_model

async def _resolve_auto_model(self, token: Any) -> str:
cache_key = _catalog_cache_key(token)
if (
self._resolved_auto_model is not None
and self._resolved_auto_model_for == cache_key
and time.monotonic() < self._resolved_auto_model_expires_at
):
return self._resolved_auto_model

timeout = min(self.generation.timeout, CODEX_CATALOG_TIMEOUT)
models = await asyncio.to_thread(fetch_codex_models, token, timeout=timeout)
self._resolved_auto_model = models[0]
self._resolved_auto_model_for = cache_key
self._resolved_auto_model_expires_at = time.monotonic() + CODEX_CATALOG_CACHE_TTL
return self._resolved_auto_model


def _catalog_cache_key(token: Any) -> str:
account_id = getattr(token, "account_id", None)
if isinstance(account_id, str) and account_id:
return f"account:{account_id}"
access = getattr(token, "access", "")
return f"token:{hashlib.sha256(str(access).encode()).hexdigest()}"


def _strip_model_prefix(model: str) -> str:
if model.startswith("openai-codex/") or model.startswith("openai_codex/"):
Expand Down
4 changes: 3 additions & 1 deletion raven/providers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
from dataclasses import dataclass
from typing import Any

from raven.providers.openai_codex_catalog import AUTO_CODEX_MODEL


@dataclass(frozen=True)
class ProviderSpec:
Expand Down Expand Up @@ -321,7 +323,7 @@ def claims(self, model: str) -> bool:
strip_model_prefix=False,
model_overrides=(),
is_oauth=True, # OAuth-based authentication
default_model="openai-codex/gpt-5-codex",
default_model=AUTO_CODEX_MODEL,
),
# Github Copilot: uses OAuth, not API key.
ProviderSpec(
Expand Down
36 changes: 36 additions & 0 deletions tests/test_cli_onboard_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -2212,6 +2212,42 @@ def ask(self):
assert any(c.startswith("moonshot/") for c in offered["choices"]), offered["choices"][:3]


def test_codex_picker_preserves_auto_as_default_above_live_catalog(monkeypatch) -> None:
from raven.cli import onboard_commands
from raven.providers.openai_codex_catalog import AUTO_CODEX_MODEL
from raven.providers.registry import find_by_name

offered: dict[str, Any] = {}

class _Prompt:
def __init__(self, _label, **kwargs):
offered["choices"] = list(kwargs["choices"])
offered["default"] = kwargs["default"]

def ask(self):
return offered["default"]

monkeypatch.setattr(
onboard_commands,
"_require_questionary",
lambda: SimpleNamespace(autocomplete=_Prompt),
)

chosen = onboard_commands._pick_model(
"openai_codex",
find_by_name("openai_codex"),
current_model=None,
model_ids=["gpt-new-default", "gpt-secondary"],
probe_status="valid",
user_provided_model=None,
non_interactive=False,
)

assert offered["default"] == AUTO_CODEX_MODEL
assert offered["choices"] == [AUTO_CODEX_MODEL, "gpt-new-default", "gpt-secondary"]
assert chosen == AUTO_CODEX_MODEL


def test_a_spec_less_vendors_model_id_carries_its_route_prefix() -> None:
"""A bare id is routed by keyword and fallback, not to the section configured.

Expand Down
Loading