Skip to content
Merged
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
9 changes: 0 additions & 9 deletions raven/cli/_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,6 @@ def make_provider(config: Config):
# OpenAI Codex (OAuth)
if provider_name == "openai_codex" or model.startswith("openai-codex/"):
provider = OpenAICodexProvider(default_model=model)
# Custom: direct OpenAI-compatible endpoint, bypasses LiteLLM
elif provider_name == "custom":
from raven.providers.custom_provider import CustomProvider

provider = CustomProvider(
api_key=p.api_key if p else "no-key",
api_base=config.get_api_base(model) or "http://localhost:8000/v1",
default_model=model,
)
# Azure OpenAI: direct Azure OpenAI endpoint with deployment name
elif provider_name == "azure_openai":
if not p or not p.api_key or not p.api_base:
Expand Down
1 change: 1 addition & 0 deletions raven/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,7 @@ class ProviderConfig(Base):
api_key: str = ""
api_base: str | None = None
extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix)
models: list[str] = Field(default_factory=list) # User-curated model names for the picker


class GeminiProviderConfig(ProviderConfig):
Expand Down
58 changes: 58 additions & 0 deletions raven/config/update_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,64 @@ def reset_provider(
logger.info("update_providers: {} reset to defaults", name)


def _load_provider_models(name: str, data: dict[str, Any]) -> tuple[type, list[str]]:
cls = _provider_schema_cls(name)
section = (data.get("providers") or {}).get(name) or {}
try:
instance = cls.model_validate(section)
except ValidationError:
instance = cls()
return cls, list(getattr(instance, "models", []) or [])


def add_provider_model(
name: str,
model: str,
*,
config_path: Path | None = None,
) -> list[str]:
"""Append ``model`` to a provider's curated ``models`` list (idempotent).

Returns the new model list. Raises KeyError for an unknown provider.
"""
path = config_path or get_config_path()
data = _load_raw(path)
cls, models = _load_provider_models(name, data)
if model not in models:
models.append(model)
section = (data.get("providers") or {}).get(name) or {}
section["models"] = models
validated = cls.model_validate(section)
data.setdefault("providers", {})
data["providers"][name] = validated.model_dump(by_alias=True)
_write_atomic(path, data)
return models


def remove_provider_model(
name: str,
model: str,
*,
config_path: Path | None = None,
) -> list[str]:
"""Remove ``model`` from a provider's curated ``models`` list (no-op if absent).

Returns the new model list. Raises KeyError for an unknown provider.
"""
path = config_path or get_config_path()
data = _load_raw(path)
cls, models = _load_provider_models(name, data)
if model in models:
models = [m for m in models if m != model]
section = (data.get("providers") or {}).get(name) or {}
section["models"] = models
validated = cls.model_validate(section)
data.setdefault("providers", {})
data["providers"][name] = validated.model_dump(by_alias=True)
_write_atomic(path, data)
return models


# ---------------------------------------------------------------------------
# Public API: credential health check
# ---------------------------------------------------------------------------
Expand Down
51 changes: 51 additions & 0 deletions raven/providers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import json
import random
from abc import ABC, abstractmethod
from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from typing import Any

Expand Down Expand Up @@ -207,6 +208,56 @@ async def chat(
"""
pass

async def chat_stream(
self,
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
model: str | None = None,
max_tokens: int = 4096,
temperature: float = 0.7,
reasoning_effort: str | None = None,
tool_choice: str | dict[str, Any] | None = None,
) -> AsyncIterator[StreamDelta]:
"""Non-streaming fallback: emit the full ``chat()`` response as a single
terminal delta.

The TUI agent loop drives turns via ``chat_stream``; providers without a
real streaming implementation (custom-bespoke / azure / codex) would
otherwise ``AttributeError`` there. This default makes any provider that
implements ``chat`` usable in the streaming path — without token-level
streaming. ``LiteLLMProvider`` overrides this with true streaming.
"""
response = await self.chat(
messages=messages,
tools=tools,
model=model,
max_tokens=max_tokens,
temperature=temperature,
reasoning_effort=reasoning_effort,
tool_choice=tool_choice,
)
tool_call_delta: dict[str, Any] | None = None
if response.tool_calls:
tool_call_delta = {
"tool_calls": [
{
"index": i,
"id": tc.id,
"function": {
"name": tc.name,
"arguments": json.dumps(tc.arguments, ensure_ascii=False),
},
}
for i, tc in enumerate(response.tool_calls)
]
}
yield StreamDelta(
content=response.content,
tool_call_delta=tool_call_delta,
usage=response.usage or None,
reasoning_content=response.reasoning_content,
)

@staticmethod
def _extract_status_code(exc: BaseException | None) -> int | None:
"""Walk the exception's cause/context chain for an HTTP status code."""
Expand Down
10 changes: 8 additions & 2 deletions raven/providers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,14 @@ def label(self) -> str:
keywords=(),
env_key="",
display_name="Custom",
litellm_prefix="",
is_direct=True,
# Route through LiteLLM as a generic OpenAI-compatible gateway: the
# `openai/` prefix + configured api_base reach any OpenAI-compatible
# endpoint, and LiteLLM gives streaming / retry / tool-calling that the
# old direct CustomProvider lacked. Matched only via an explicit
# `provider: custom` selection (keywords empty).
litellm_prefix="openai",
is_gateway=True,
default_api_base="http://localhost:8000/v1",
),

# === Azure OpenAI (direct API calls with API version 2024-10-21) =====
Expand Down
8 changes: 7 additions & 1 deletion raven/tui_rpc/methods/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from raven.tui_rpc.methods.config import register_config_methods
from raven.tui_rpc.methods.confirm import register_confirm_methods
from raven.tui_rpc.methods.question import register_question_methods
from raven.tui_rpc.methods.model import register_model_methods
from raven.tui_rpc.methods.reload import register_reload_methods
from raven.tui_rpc.methods.session import register_session_methods
from raven.tui_rpc.methods.setup import register_setup_methods
Expand Down Expand Up @@ -114,10 +115,14 @@ def register_aligned_methods_except_system(
register_cli_methods(dispatcher, confirm_broker=confirm_broker)
register_setup_methods(dispatcher)
register_reload_methods(dispatcher)
register_config_methods(dispatcher)
register_config_methods(dispatcher, agent_loop_factory=agent_loop_factory)
register_session_methods(dispatcher, agent_loop_factory=agent_loop_factory)
register_terminal_methods(dispatcher)
register_stub_methods(dispatcher)
# model.{options,save_key,disconnect,add_model,remove_model}: real handlers
# must come AFTER register_stub_methods (Dispatcher.register raises on
# duplicate; the stub group no longer owns these names).
register_model_methods(dispatcher)
# harness-command-catalog-dynamic: real ``commands.catalog`` handler;
# MUST come after ``register_stub_methods`` because the stub list dropped
# its ``commands.catalog`` entry, and ``Dispatcher.register`` raises on
Expand Down Expand Up @@ -167,6 +172,7 @@ def register_aligned_methods_except_system(
"register_session_methods",
"register_terminal_methods",
"register_stub_methods",
"register_model_methods",
"register_slash_routing_methods",
"register_turn_methods",
"register_confirm_methods",
Expand Down
96 changes: 93 additions & 3 deletions raven/tui_rpc/methods/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,19 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable

from raven.cli._helpers import load_runtime_config, make_provider
from raven.providers.registry import find_by_model
from raven.tui_rpc.errors import (
ConfigFieldReadonlyError,
ConfigValidationError,
ModelNotAvailableError,
ModelSwitchInTurnError,
)
from raven.tui_rpc.methods.turn import is_turn_active

if TYPE_CHECKING:
from raven.tui_rpc.dispatcher import Dispatcher
from raven.tui_rpc.methods.session import AgentLoopFactory


_CONFIG_DIR_NAME = ".raven"
Expand Down Expand Up @@ -214,9 +220,16 @@ async def config_get(params: dict) -> dict:
return {"config": out}


async def config_set(params: dict) -> dict:
async def config_set(
params: dict,
*,
agent_loop_factory: "AgentLoopFactory | None" = None,
) -> dict:
"""Write a single whitelisted key. Returns ``{applied, previous}``.

The special key ``"model"`` switches the live agent loop's provider/model
(returns ``{applied, previous, value}``); see :func:`_set_model`.

Raises:
ConfigValidationError (-32011): params shape or value invalid.
ConfigFieldReadonlyError (-32010): key not in writable whitelist.
Expand All @@ -240,6 +253,9 @@ async def config_set(params: dict) -> dict:
)
raw_value = params["value"]

if key == "model":
return _set_model(params, raw_value, agent_loop_factory)

if key not in _VALIDATORS:
raise ConfigFieldReadonlyError(
f"key '{key}' is not in the v0.1 hot-changeable whitelist",
Expand All @@ -256,10 +272,84 @@ async def config_set(params: dict) -> dict:
return {"applied": True, "previous": previous}


def register_config_methods(dispatcher: "Dispatcher") -> None:
def _set_model(
params: dict,
raw_value: Any,
agent_loop_factory: "AgentLoopFactory | None",
) -> dict:
"""Switch the global model (and provider) and reassign the live loop.

Build the provider from the prospective config BEFORE persisting, so a
rebuild failure aborts cleanly with the on-disk model untouched.
"""
if not isinstance(raw_value, str) or not raw_value:
raise ConfigValidationError(
"config.set model value must be a non-empty string",
data={"field": "value", "got": repr(raw_value)},
)
new_provider = params.get("provider")
if new_provider is not None and not isinstance(new_provider, str):
raise ConfigValidationError(
"config.set model provider must be a string",
data={"field": "provider", "got": repr(new_provider)},
)
# Bare `/model <name>` carries no provider; derive it from the model so a
# previously-forced provider does not silently mis-route the new model.
# Gateway/local models (no keyword match) leave the forced provider intact.
if new_provider is None:
spec = find_by_model(raw_value)
if spec is not None:
new_provider = spec.name

session_id = params.get("session_id")
if isinstance(session_id, str) and session_id and is_turn_active(session_id):
raise ModelSwitchInTurnError(
f"cannot switch model while session {session_id!r} has an active turn",
data={"session_id": session_id},
)

payload = _load_config()
previous = _get_nested(payload, "agents.defaults.model")

loop = agent_loop_factory() if agent_loop_factory is not None else None
built_provider = None
if loop is not None:
runtime = load_runtime_config(None, None)
runtime.agents.defaults.model = raw_value
if new_provider is not None:
runtime.agents.defaults.provider = new_provider
try:
built_provider = make_provider(runtime)
except (SystemExit, RuntimeError, ValueError) as exc:
raise ModelNotAvailableError(
f"cannot build provider for model {raw_value!r}",
data={"model": raw_value, "error": str(exc)},
) from exc

_set_nested(payload, "agents.defaults.model", raw_value)
if new_provider is not None:
_set_nested(payload, "agents.defaults.provider", new_provider)
_save_config(payload)

if loop is not None:
loop.provider = built_provider
loop.model = raw_value

return {"applied": True, "previous": previous, "value": raw_value}


def register_config_methods(
dispatcher: "Dispatcher",
*,
agent_loop_factory: "AgentLoopFactory | None" = None,
) -> None:
"""Register ``config.get`` / ``config.set`` on a dispatcher instance."""

async def _set(params: dict) -> dict:
return await config_set(params, agent_loop_factory=agent_loop_factory)

dispatcher.register("config.get", config_get)
dispatcher.register("config.set", config_set)
dispatcher.register("config.set", _set)


__all__ = [
Expand Down
Loading