Skip to content
19 changes: 13 additions & 6 deletions raven/cli/agent_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import asyncio
import signal
import sys
from pathlib import Path

import typer
from prompt_toolkit import PromptSession
Expand Down Expand Up @@ -220,10 +221,17 @@ def agent(
# fail loudly later rather than block on prompts.
from raven.cli.onboard_commands import _is_config_populated

if message is None and _stdout_isatty() and not _is_config_populated():
startup_config_path = Path(config).expanduser().resolve() if config else None
config_target_exists = startup_config_path is None or startup_config_path.exists()
if (
message is None
and _stdout_isatty()
and config_target_exists
and not _is_config_populated(startup_config_path)
):
from raven.cli.onboard_commands import ensure_configured_or_onboard

ensure_configured_or_onboard()
ensure_configured_or_onboard(config_path=startup_config_path)

from loguru import logger

Expand All @@ -240,10 +248,9 @@ def agent(
from raven.proactive_engine.schedulers.cron.service import CronService
from raven.session.manager import SessionManager, new_chat_id

# load_runtime_config must run FIRST: it calls set_config_path() so
# that subsequent load_raven_config() reads from --config, not the
# default ~/.raven/config.json. Otherwise skill_forge / sentinel
# from --config are silently ignored.
# The startup gate above reads the same explicit path without changing
# global loader state. load_runtime_config now sets that state before
# all remaining config consumers run.
config = load_runtime_config(config, workspace)
ec_config = load_raven_config()
sentinel_cfg = ec_config.sentinel
Expand Down
54 changes: 39 additions & 15 deletions raven/cli/onboard_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from __future__ import annotations

import sys
from pathlib import Path
from typing import Any, Callable, Optional

import typer
Expand Down Expand Up @@ -326,7 +327,7 @@ def _check_tty_or_die(non_interactive: bool) -> None:
raise typer.Exit(2)


def _load_raw_config() -> dict[str, Any]:
def _load_raw_config(config_path: Path | None = None) -> dict[str, Any]:
"""Return the parsed on-disk config, or ``{}`` if absent/empty.

A present-but-unparseable config raises ConfigReadError (surfaced cleanly by
Expand All @@ -336,7 +337,7 @@ def _load_raw_config() -> dict[str, Any]:
"""
from raven.config.loader import get_config_path, read_raw_or_raise

return read_raw_or_raise(get_config_path()) or {}
return read_raw_or_raise(config_path or get_config_path()) or {}


def _configured_providers() -> list[str]:
Expand All @@ -346,22 +347,37 @@ def _configured_providers() -> list[str]:
return [row["name"] for row in list_providers() if row["configured"]]


def _is_config_populated() -> bool:
"""True iff at least one provider has a key AND a default model is set.
def _is_config_populated(config_path: Path | None = None) -> bool:
"""True iff the selected provider is configured and a default model is set.

"Populated" for the startup gate means the required step (Step 1) is
satisfied: a provider key plus ``agents.defaults.model``. Either alone is
not enough to talk to a model.
satisfied: provider credentials plus ``agents.defaults.model``. OAuth
providers keep their credentials outside ``config.json``, so use the
provider ops layer's shared configuration check. Credentials for an
unrelated provider must not satisfy the gate.
"""
from raven.providers.registry import split_model_id
from raven.config.schema import Config
from raven.config.update_providers import list_providers

data = _load_raw_config()
data = _load_raw_config(config_path)
model = (data.get("agents", {}) or {}).get("defaults", {}).get("model")
configured = _configured_providers()
model_prefix, _ = split_model_id(str(model or ""))
has_non_minimax_provider = any(name not in {"minimax_global", "minimax_cn"} for name in configured)
has_provider = has_non_minimax_provider or model_prefix in configured
return bool(has_provider and model)
if not model:
return False
routing_config = Config.model_validate(
{
"agents": data.get("agents") or {},
"providers": data.get("providers") or {},
}
)
selected_provider = routing_config.get_provider_name(model)
provider_status = {
provider["name"]: provider["configured"]
for provider in list_providers(
config_path=config_path,
raw_providers=data.get("providers") or {},
)
}
return bool(selected_provider and provider_status.get(selected_provider, False))


def _handle_existing_config(*, reset: bool, yes: bool, non_interactive: bool) -> None:
Expand Down Expand Up @@ -4683,16 +4699,24 @@ def _run_wizard_body(
# ---------------------------------------------------------------------------


def ensure_configured_or_onboard(*, non_interactive: bool = False) -> bool:
def ensure_configured_or_onboard(
*,
non_interactive: bool = False,
config_path: Path | None = None,
) -> bool:
"""Run the wizard when the required config (provider + model) is missing.

Returns ``True`` if config was already complete (caller proceeds straight
to the session), ``False`` if the wizard ran (config is now populated). In
a non-interactive context with missing config, the wizard's TTY check
will raise — callers on non-TTY paths must guard before invoking.
"""
if _is_config_populated():
if _is_config_populated(config_path):
return True
if config_path is not None:
from raven.config.loader import set_config_path

set_config_path(config_path)
run_wizard(non_interactive=non_interactive)
return False

Expand Down
123 changes: 109 additions & 14 deletions raven/config/update_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,12 @@
functions defined here. Direct ``load_config`` / ``save_config`` on the
providers section is forbidden -- see plan rule.

OAuth providers have a separate
auth path via ``provider_commands._LOGIN_HANDLERS`` and store tokens via
``oauth_cli_kit``, not in ``config.json``. ``set_provider_fields`` refuses
to write ``api_key`` for those providers; callers must invoke
``provider login`` for that. ``reset_provider`` handles both cases:
schema-default rewrite for config fields, plus unlinking the
``oauth_cli_kit`` token file when the provider has ``is_oauth=True``.
OAuth providers have separate auth paths via
``provider_commands._LOGIN_HANDLERS`` and keep credentials outside
``config.json``. ``set_provider_fields`` refuses to write ``api_key`` for
those providers; callers must invoke ``provider login`` for that.
``reset_provider`` rewrites the schema defaults and removes the provider's
external credentials.
"""

from __future__ import annotations
Expand Down Expand Up @@ -429,14 +428,102 @@ def _oauth_token_path(provider_name: str) -> Path:
so tests can point at ``tmp_path`` without touching real user data.
"""
override = os.environ.get("OAUTH_CLI_KIT_TOKEN_PATH")
if override:
if provider_name == "openai_codex" and override:
return Path(override)
filename = "codex.json" if provider_name == "openai_codex" else f"{provider_name}.json"
if provider_name == "openai_codex":
try:
from oauth_cli_kit import OPENAI_CODEX_PROVIDER

filename = OPENAI_CODEX_PROVIDER.token_filename
except (ImportError, AttributeError):
pass
try:
from platformdirs import user_data_dir
except ImportError:
return Path.home() / ".local" / "share" / "oauth-cli-kit" / "auth" / f"{provider_name}.json"
return Path.home() / ".local" / "share" / "oauth-cli-kit" / "auth" / filename
base_dir = Path(user_data_dir("oauth-cli-kit", appauthor=False))
return base_dir / "auth" / f"{provider_name}.json"
return base_dir / "auth" / filename


def _github_copilot_token_is_usable() -> bool:
"""Match LiteLLM 1.85's offline GitHub Copilot credential semantics.

An unexpired cached API key is immediately usable. A stored GitHub access
token is refresh-capable, so the normal request path can exchange it for an
API key. The environment variable names and default files mirror
``litellm.llms.github_copilot.authenticator.Authenticator`` without
instantiating it (its constructor creates directories).
"""
import time

token_dir = Path(
os.environ.get(
"GITHUB_COPILOT_TOKEN_DIR",
str(Path.home() / ".config" / "litellm" / "github_copilot"),
)
)
access_path = token_dir / os.environ.get("GITHUB_COPILOT_ACCESS_TOKEN_FILE", "access-token")
api_key_path = token_dir / os.environ.get("GITHUB_COPILOT_API_KEY_FILE", "api-key.json")
try:
if access_path.is_file() and access_path.read_text(encoding="utf-8").strip():
return True
if not api_key_path.is_file():
return False
payload = json.loads(api_key_path.read_text(encoding="utf-8"))
token = payload.get("token") if isinstance(payload, dict) else None
expires_at = payload.get("expires_at") if isinstance(payload, dict) else None
if (
not isinstance(token, str)
or not token.strip()
or isinstance(expires_at, bool)
or not isinstance(expires_at, (int, float))
):
return False
return expires_at > time.time()
except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError):
return False


def _oauth_token_is_usable(provider_name: str) -> bool:
"""Return whether the provider's stored OAuth credentials are usable.

This check never refreshes credentials or makes a network request. Codex
loading delegates to oauth-cli-kit's storage contract, including its
cache-miss import from ``~/.codex/auth.json``. An expired Codex access
token remains usable when it has a refresh token because the real request
path refreshes it; ``account_id`` is mandatory because Raven sends it as a
request header.
"""
if provider_name == "github_copilot":
return _github_copilot_token_is_usable()
if provider_name != "openai_codex":
return False
try:
from oauth_cli_kit import OPENAI_CODEX_PROVIDER
from oauth_cli_kit.storage import FileTokenStorage

token = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).load()
except Exception:
return False
if token is None:
return False
access = token.access
refresh = token.refresh
account_id = token.account_id
expires = token.expires
if not isinstance(access, str) or not access.strip():
return False
if not isinstance(refresh, str) or not refresh.strip():
return False
if not isinstance(account_id, str) or not account_id.strip():
return False
if isinstance(expires, bool):
return False
try:
return int(expires) > 0
except (TypeError, ValueError):
return False


# ---------------------------------------------------------------------------
Expand All @@ -461,7 +548,11 @@ def provider_field_specs(name: str) -> dict[str, dict[str, Any]]:
# ---------------------------------------------------------------------------


def list_providers(*, config_path: Path | None = None) -> list[dict[str, Any]]:
def list_providers(
*,
config_path: Path | None = None,
raw_providers: dict[str, Any] | None = None,
) -> list[dict[str, Any]]:
"""Reflect every provider declared on ``ProvidersConfig`` + current status.

Returns one dict per provider:
Expand All @@ -474,8 +565,12 @@ def list_providers(*, config_path: Path | None = None) -> list[dict[str, Any]]:
- ``api_key_redacted`` ``****set****`` / ``(empty)`` / ``(not needed for local)``
- ``api_base`` current value (or ``None`` if untouched)
"""
path = config_path or get_config_path()
data = read_raw_or_raise(path)
if raw_providers is None:
path = config_path or get_config_path()
data = read_raw_or_raise(path)
raw_providers = data.get("providers") or {}
else:
data = {"providers": raw_providers}

out: list[dict[str, Any]] = []
for fname in _listable_provider_names(data):
Expand Down Expand Up @@ -515,7 +610,7 @@ def list_providers(*, config_path: Path | None = None) -> list[dict[str, Any]]:

configured = load_token("global" if fname == "minimax_global" else "cn") is not None
else:
configured = _oauth_token_path(fname).exists()
configured = _oauth_token_is_usable(fname)
api_key_redacted = "OAuth token" if configured else "(empty)"
elif is_local:
configured = bool(api_base) or bool(api_key)
Expand Down
Loading