diff --git a/CONTEXT.md b/CONTEXT.md index eb16c695..32bedf0d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -207,6 +207,97 @@ An LLM vendor adapter (`providers/`: Anthropic, OpenAI, Gemini, …), shared by agent loop and the Curator. _Avoid_: conflating provider (vendor) with model (a model name a provider serves) +A Provider is described along four independent axes -- identity, connection, routing, +and what its models can do -- each with its own home. Mixing them in one record is what +left per-model facts nowhere to live and per-provider facts stated in several places at +once. The terms below name the pieces those axes are built from; they are properties of +a model or of a connection, not four synonyms for Provider. + +**Model Ref**: +The canonical way a model is written down: `provider/model`, naming whoever serves it. +Usually that is the section it was configured under; where a Provider declares +`skip_prefixes` it may instead be the gateway already named in the id +(`openrouter/z-ai/glm-4.6` stored under `zai` keeps OpenRouter's name, because +OpenRouter is what serves it). Produced by `providers/wire.py::stored_model_id`, which +every surface that persists a choice goes through. +_Avoid_: "model id" for the stored form when the sent form is also in play — say Model +Ref or Wire Model. + +**Merge Key**: +The identity of a Model Ref for comparison and de-duplication — the provider and the +vendor's own id, spelling-folded. Two refs naming one model share a Merge Key whatever +spelling either was written in. + +**Wire Model**: +The form a Model Ref takes on the request: a LiteLLM route string, an Azure deployment +name, or a Codex slug. Derived, never stored, and derived in one place +(`providers/wire.py::wire_model`). +_Avoid_: treating the stored and sent forms as one string — they differ per provider. + +**Auth Method**: +One way of connecting to a Provider: what credential material it needs (as an AND of +OR-groups), how that material is obtained, where it is kept, and how it is verified. +A Provider may declare several and is usable when any one is satisfied. +`providers/auth.py::credential_status` answers "is this Provider usable", and is the +only place that may: seven surfaces once decided it independently and disagreed with +each other on the two configurations that made the rewrite necessary. +_Avoid_: "credential kind" for the whole shape — that names only the material. + +**Model Row**: +One model as a person reads it: a Model Ref plus a label and a description, tagged with +the source that supplied them. Display only — nothing shaping a request reads a Model +Row (`providers/catalog.py`). +_Avoid_: confusing it with what a model can *do*. Whether a request may carry +`cache_control` blocks is a Prompt Cache Breakpoint question, not a Model Row one. + +**Model Overlay**: +What a user states about a model no catalogue carries — a label and a description for a +self-hosted deployment. Beats the catalogue for the fields it sets. + +**Prompt Cache Breakpoint**: +An Anthropic-shaped `cache_control` marker placed on a request so the prefix before it is +cached. Whether one may be placed is **(wire x model family)**: the wire has to have +somewhere to carry the field (`ProviderSpec.supports_prompt_caching`, a property of the +API being spoken) *and* the model's vendor has to be the one that reads it. A gateway +accepting the field is not the same as its upstream honouring it -- OpenRouter carries it +for every model it fronts and forwards it to vendors that bill the prompt twice. +Decided once, in `providers/prompt_cache.py`, which every marker asks. +_Avoid_: reading LiteLLM's per-model `supports_prompt_caching`, which answers "does this +model cache at all" -- a different question, and the one that produced the doubled bill. + +**Token Rates**: +What a model costs per token, and separately how much context it holds. Both are facts +about a Provider's catalogue, so both are resolved in `providers/rates.py` rather than by +whoever is about to report a number. The two are deliberately sourced differently: rates +price a call after it happened, so the ladder may reach a community-maintained catalogue; +a context window sizes trimming and therefore shapes the *next* request, so only the +tables that also route may answer it. The window walks its own ladder +(`effective_context_window`): an explicitly configured value wins outright, then the +model's real window, then the module's documented fallback -- and a gauge that cannot +resolve the real window reports 0 so the UI shows its empty state rather than a number +that is nobody's. +_Avoid_: "pricing" for the resolution -- that names the arithmetic on top +(`token_wise/pricing.py`), which is a different module for a reason. + +**Provider Pin**: +`agents.defaults.provider`: an explicit override of the Provider a Model Ref names. +Every surface that changes the model rewrites it by one rule +(`providers/pin.py::resolve`), because a pin left behind routes the new model to the old +vendor with the old vendor's key. +_Avoid_: reading it as a provider *signal* -- a pinned name says which section to ask +about, never that the section holds credentials. + +**Provider Endpoint**: +One url/key/headers group a provider section offers, of possibly several +(`ProviderConfig.endpoints`, resolved through `providers/endpoints.py::provider_endpoints` +whichever spelling the section used -- explicit list, Gemini's `api_key_list`, or the +flat fields). Several endpoints on one section mean several accounts on the same vendor; +`EndpointRotorProvider` spreads and fails over across them. +_Avoid_: two same-sounding neighbors. Routing's `ModelEndpoint` (`RoutingConfig.models`) +keys by *model* and picks a backend per request; a Provider Endpoint keys by *account* +under one provider. And a bare `api_base` is one endpoint's address, not the endpoint -- +an endpoint is the whole credential group under a label. + ### TUI-RPC **TUI-RPC**: diff --git a/README.md b/README.md index 75578672..de9dde68 100644 --- a/README.md +++ b/README.md @@ -503,6 +503,9 @@ Raven is early, and useful contributions are welcome across runtime architecture, TUI polish, provider support, memory workflows, proactivity, benchmarks, documentation, and issue reports. +Model vendors interested in a first-party integration or an open-source +partnership (as MiniMax already has) are welcome to open an issue and say so. + Before opening a PR: 1. Read [AGENTS.md](AGENTS.md). diff --git a/benchmarks/clawbench/stream.py b/benchmarks/clawbench/stream.py index 0eed02cb..e8cb876a 100644 --- a/benchmarks/clawbench/stream.py +++ b/benchmarks/clawbench/stream.py @@ -98,9 +98,10 @@ def __init__( restrict_to_workspace: bool, ) -> None: from raven.agent.loop import AgentLoop - from raven.cli.commands import _make_provider + from raven.cli._helpers import make_provider as _make_provider from raven.config.loader import load_config, set_config_path from raven.config.raven import ContextConfig + from raven.providers.rates import effective_context_window from raven.session.manager import SessionManager workspace.mkdir(parents=True, exist_ok=True) @@ -126,7 +127,9 @@ def __init__( self.config.agents.defaults.workspace = str(workspace.resolve()) self.provider = UsageTrackingProvider(_make_provider(self.config)) self.model = self.config.agents.defaults.model - self.context_window = int(context_window or self.config.agents.defaults.context_window_tokens) + self.context_window = effective_context_window( + self.model, context_window or self.config.agents.defaults.context_window_tokens + ) self.curator_model = curator_model or self.model self.session_id = session_id self.previous_totals = dict(self.provider.accumulated) diff --git a/benchmarks/pinchbench/direct/raven_executor.py b/benchmarks/pinchbench/direct/raven_executor.py index 654d21ff..b42405de 100644 --- a/benchmarks/pinchbench/direct/raven_executor.py +++ b/benchmarks/pinchbench/direct/raven_executor.py @@ -330,38 +330,20 @@ def _make_benchmark_provider(model: str, api_key: str, api_base: str, provider_n def _estimate_cost_usd(model: str, prompt_tokens: int, completion_tokens: int) -> float | None: - """Estimate USD cost using LiteLLM's pricing database with manual fallbacks. + """Estimate USD cost for one call, or None when no source prices the model. - Falls back to _fallback_pricing for models not yet in LiteLLM's DB. - Returns None if the model is unknown to both. + A thin wrapper on purpose. This carried its own two-tier copy of the + resolution -- LiteLLM, then a hand-written table -- while the shipped ladder + grew a live gateway table and a per-vendor catalogue between them, so a + benchmark could report a figure the product would not. There is one answer to + "what does this cost" and it lives with the provider. """ - # Manual fallback pricing ($/token) for models absent from LiteLLM's DB. - # Source: OpenRouter model pages (as of 2026-03). - _fallback_pricing: Dict[str, tuple[float, float]] = { - "z-ai/glm-4.5-air": (0.13e-6, 0.85e-6), # $0.13/$0.85 per 1M tokens - } - - try: - import litellm - - # LiteLLM expects "openrouter//" format for OpenRouter models. - or_model = f"openrouter/{model}" if not model.startswith("openrouter/") else model - prompt_cost, completion_cost = litellm.cost_per_token( - model=or_model, - prompt_tokens=prompt_tokens, - completion_tokens=completion_tokens, - ) - return prompt_cost + completion_cost - except Exception: - pass - - # Fallback for models not in LiteLLM DB - base_model = model.removeprefix("openrouter/") - if base_model in _fallback_pricing: - p_per_tok, c_per_tok = _fallback_pricing[base_model] - return p_per_tok * prompt_tokens + c_per_tok * completion_tokens + from raven.token_wise.pricing import estimate_cost_usd - return None + # Benchmarks name OpenRouter models bare; the ladder is keyed by stored ids, + # which name their provider. + stored = model if model.startswith("openrouter/") else f"openrouter/{model}" + return estimate_cost_usd(stored, prompt_tokens, completion_tokens) async def _run_turn_text(agent, message: str, *, session_key: str, chat_id: str) -> str: diff --git a/pyproject.toml b/pyproject.toml index c73b4803..8129685b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -147,6 +147,8 @@ include = [ "raven/templates/**/*.md", "raven/skills/**/*.md", "raven/skills/**/*.sh", + # Bundled model-label snapshot, so a fresh install labels models offline. + "raven/providers/data/*.json", # Tracing dashboard viewer (dependency-free Node server + static client). "raven/tracing/viewer/**/*.js", "raven/tracing/viewer/**/*.css", diff --git a/raven/agent/loop/main.py b/raven/agent/loop/main.py index 49cee9b9..f6d76613 100644 --- a/raven/agent/loop/main.py +++ b/raven/agent/loop/main.py @@ -43,12 +43,13 @@ from raven.agent.tools.web import WebFetchTool, WebSearchTool from raven.memory_engine.base import TokenBudget from raven.memory_engine.consolidate.consolidator import MemoryConsolidator, MemoryStore -from raven.providers.base import LLMProvider, LLMResponse, ToolCallRequest +from raven.providers.base import ErrorClassification, LLMProvider, LLMResponse, ToolCallRequest from raven.providers.capabilities import image_placeholder_text, supports_image_tool_result, vision_verdict +from raven.providers.rates import effective_context_window, resolve_context_window +from raven.providers.reasoning import split_orphan_think from raven.sandbox import SandboxConfig, SandboxExecutor, SandboxInitError, build_executor from raven.session.manager import Session, SessionManager from raven.spine.turn import Origin -from raven.token_wise.pricing import resolve_context_window from raven.tracing import semconv, trace from raven.utils.helpers import estimate_prompt_tokens, is_image_part, is_inline_image @@ -290,7 +291,7 @@ def __init__( workspace: Path, model: str | None = None, max_iterations: int = 40, - context_window_tokens: int = 65_536, + context_window_tokens: int | None = None, brave_api_key: str | None = None, web_proxy: str | None = None, exec_config: ExecToolConfig | None = None, @@ -377,7 +378,30 @@ def __init__( self.max_iterations = max_iterations # Empty-response recovery budgets. None → enabled defaults. self._recovery_limits = empty_recovery if empty_recovery is not None else RecoveryLimits() - self.context_window_tokens = context_window_tokens + # A caller that passed a positive value set the window explicitly; + # None/0 means "figure it out", resolved once here against the model's + # real window. ("Explicit", not "pinned" -- Provider Pin is a different + # registered term, see CONTEXT.md.) + self._context_window_explicit = bool(context_window_tokens) + if context_window_tokens == 65536: + # The retired schema default, which the old bootstrap wrote to + # disk verbatim -- so on upgraded installs this exact value is + # more often a fossil than a choice. The config is deliberately + # not rewritten (a value the user can see in their own file stays + # theirs); this line is what keeps that stance from failing + # silently. + logger.warning( + "contextWindowTokens: 65536 is pinning the context window (the old default, " + "written out by earlier versions); remove the line from config.json to size " + "it from each model's real window" + ) + # allow_fetch=False: construction must not block on a synchronous + # network call for an OpenRouter model's window -- whatever is already + # cached (in-process or on disk, any age) answers instead. See + # rates._fetch_openrouter_models. + self.context_window_tokens = context_window_tokens or effective_context_window( + self.model, None, allow_fetch=False + ) self.brave_api_key = brave_api_key self.jina_api_key = jina_api_key self.web_proxy = web_proxy @@ -464,7 +488,7 @@ def __init__( builder=self.context, provider=provider, model=self.model, - context_window_tokens=context_window_tokens, + context_window_tokens=self.context_window_tokens, get_tool_definitions=self.tools.get_definitions, now_fn=now_fn, # The factory uses these to assemble the unified engine's @@ -546,7 +570,7 @@ def __init__( provider=provider, model=self.model, sessions=self.sessions, - context_window_tokens=context_window_tokens, + context_window_tokens=self.context_window_tokens, build_messages=self.context.build_messages, get_tool_definitions=self.tools.get_definitions, now_fn=now_fn, @@ -609,6 +633,15 @@ def __init__( self._register_default_tools() self._apply_disabled_tools() + # LazyProvider defers the litellm import behind a background prewarm + # thread (see providers.lazy); the window this constructor just + # resolved above was answered with allow_import=False, so it can be + # wrong until that import lands. Wiring the callback fixes it up in + # place once the real provider is built -- a no-op for any other + # provider, which has no ``on_built`` to set. + if hasattr(provider, "on_built"): + provider.on_built = self.refresh_context_window + def _apply_disabled_tools(self) -> None: """Unregister tools whose names appear in ``tools.disabled_tools``. @@ -659,17 +692,27 @@ def set_provider(self, provider: LLMProvider, model: str) -> None: turn that spawned them -- so ``SubagentManager`` snapshots instead. """ if self._turns_in_flight: + # The only trace of the window the docstring describes. + logger.info("model switch to {} parked until {} running turn(s) drain", model, self._turns_in_flight) self._pending_provider = (provider, model) return self._adopt_provider(provider, model) def _adopt_provider(self, provider: LLMProvider, model: str) -> None: """Hand a provider to the loop and every subsystem holding the old one.""" + logger.info("adopting provider switch: model={}", model) self.provider = provider self.model = model + # Cached per model id but computed from the provider, so a swap that + # keeps the model id would keep serving the old transport's verdict. + self._image_tool_result_ok.clear() self.subagents.set_provider(provider, model) self.context_engine.set_provider(provider, model) self.memory_consolidator.set_provider(provider, model) + # Here rather than at the RPC call site: a parked switch adopts long + # after that call returns, and the window must follow the pair that + # was actually adopted, not the model the RPC saw. + self.refresh_context_window() def _adopt_pending_provider(self) -> None: """Apply a parked switch. Callers must check that no turn is running.""" @@ -693,6 +736,35 @@ def configure_personalization(self, enable: bool) -> None: self.enable_personalization = enable logger.info("Personalization flow: {}", "enabled" if enable else "disabled") + def refresh_context_window(self) -> None: + """Re-resolve ``context_window_tokens`` against the current ``self.model``. + + A no-op once the window was set explicitly at construction -- an + explicit value is a deliberate override, and a model switch afterwards + must not quietly discard it. Otherwise the ladder is re-walked so a + ``/model`` switch picks up the new model's real window instead of + keeping the old one's. + + Also the callback ``LazyProvider.on_built`` fires from its prewarm + thread, i.e. off the event loop -- safe because every write this + method triggers, transitively through the consolidator and the + context engine's builders, is a plain ``int`` attribute assignment, + and the GIL makes each one atomic. + """ + if self._context_window_explicit: + return + # allow_fetch=False: a /model switch runs inside the running event + # loop, so this must not block it on a synchronous network call. See + # rates._fetch_openrouter_models. + self.context_window_tokens = effective_context_window(self.model, None, allow_fetch=False) + # Cascade into the builders that sized themselves against the window + # at construction (the Curator's trimmer) and the consolidator -- + # both would otherwise keep budgeting against the pre-switch model's + # window for the rest of the session. The consolidator's window is a + # plain attribute (no setter of its own), set directly here. + self.context_engine.set_context_window(self.context_window_tokens) + self.memory_consolidator.context_window_tokens = self.context_window_tokens + def _register_default_tools(self) -> None: """Register the default set of tools.""" allowed_dir = self.workspace if self.restrict_to_workspace else None @@ -1544,6 +1616,9 @@ async def _llm_call_stream( reasoning_buf: list[str] = [] tool_call_slots: list[dict[str, Any]] = [] final_usage: dict[str, Any] | None = None + had_error = False + error_content: str | None = None + error_classification: ErrorClassification | None = None # aclosing() guarantees the async generator (and its underlying stream) # is closed when a TimeoutError from the per-chunk idle cap unwinds the @@ -1553,6 +1628,18 @@ async def _llm_call_stream( try: async with aclosing(self.provider.chat_stream(messages=messages, tools=tools, model=model)) as stream: async for delta in stream: + if delta.finish_reason == "error": + # A non-streaming provider's chat() error, replayed + # through the fallback as its single terminal delta. + # Its content is the error text, not a token to render + # or accumulate -- surface it via error_classification + # instead of the normal success collation below. + had_error = True + error_content = delta.content + error_classification = delta.error_classification + if delta.usage is not None: + final_usage = delta.usage + continue reasoning_delta = getattr(delta, "reasoning_content", None) if reasoning_delta: reasoning_buf.append(reasoning_delta) @@ -1576,15 +1663,33 @@ async def _llm_call_stream( error_classification=self.provider.classify_error(TimeoutError()), ) + if had_error: + return LLMResponse( + content=error_content, + finish_reason="error", + error_classification=error_classification, + usage=final_usage or {}, + ) + tool_calls = _finalize_tool_calls(tool_call_slots) finish_reason = "tool_calls" if tool_calls else "stop" + content = "".join(content_buf) + reasoning_content = "".join(reasoning_buf) or None + # getattr because the loop accepts duck-typed providers (test stubs and + # thin adapters implement just chat/chat_stream); absent means the + # LLMProvider default, False. + emits_unparsed = getattr(self.provider, "emits_unparsed_reasoning", None) + if reasoning_content is None and emits_unparsed is not None and emits_unparsed(): + split_reasoning, content = split_orphan_think(content) + reasoning_content = split_reasoning + return LLMResponse( - content="".join(content_buf), + content=content, tool_calls=tool_calls, finish_reason=finish_reason, usage=final_usage or {}, - reasoning_content="".join(reasoning_buf) or None, + reasoning_content=reasoning_content, ) @classmethod @@ -1886,9 +1991,19 @@ async def _run_agent_loop( if usage_sink is not None and response.usage: prompt_tokens = int(response.usage.get("prompt_tokens", 0) or 0) completion_tokens = int(response.usage.get("completion_tokens", 0) or 0) - # Real window from the model's provider table when LiteLLM lags - # (e.g. OpenRouter); otherwise the configured default. - context_max = resolve_context_window(call_model) or self.context_window_tokens + # An explicitly configured window always wins over the live + # table -- that is what setting it means. Otherwise the live + # window from the model's provider table (e.g. OpenRouter, + # when LiteLLM lags) answers instead; unknown to that table + # too, 0 tells the UI to show its empty state rather than a + # number that isn't this model's. + if self._context_window_explicit: + context_max = self.context_window_tokens + else: + # Off the event loop: allow_fetch=True here can hit the + # network for up to 10s on an OpenRouter model with both + # caches expired. See rates._fetch_openrouter_models. + context_max = await asyncio.to_thread(resolve_context_window, call_model) or 0 context_used = prompt_tokens + completion_tokens usage_sink.clear() usage_sink["prompt_tokens"] = prompt_tokens diff --git a/raven/cli/_helpers.py b/raven/cli/_helpers.py index cd719878..17f8f328 100644 --- a/raven/cli/_helpers.py +++ b/raven/cli/_helpers.py @@ -65,36 +65,45 @@ def check_provider_credentials(config: Config) -> None: """Fail-fast when the configured provider is missing required credentials. Cheap (no litellm import), so it can run at startup even when the real - provider is built lazily. It branches on the same ``client`` that decides - which provider gets built: kept in sync by asking the same question, because - the version that compared names drifted the moment the factory stopped. + provider is built lazily. + + Raises ``MissingCredentialsError`` rather than printing and exiting: three entry + points call this, and only one of them is a terminal. Each renders the + failure in its own idiom -- the CLI as a red line and exit 1, the TUI as an + RPC error carrying the same sentence. + + What counts as configured is `providers.auth`, the same declaration routing + and `provider list` consult. Deciding it here as well is what produced three + verdicts on one config: a Gemini section holding only `api_key_list` read as + configured in `provider list` and refused to start, and Azure with a key and + no address was routed and displayed as configured yet rejected here. """ + from raven.providers.auth import MissingCredentialsError, credential_status + from raven.providers.registry import find_by_model, split_model_id + model = config.agents.defaults.model provider_name = config.get_provider_name(model) - p = config.get_provider(model) + if not provider_name: + # Routing found no configured section, so name the provider the model id + # points at rather than reporting on nothing. + spec = find_by_model(model) + provider_name = spec.name if spec else split_model_id(model)[0] + if not provider_name: + raise MissingCredentialsError( + "no provider configured", + # A command, not a config path: the old text pointed at + # ~/.raven/config.json, the layout the CLI exists to hide. + remedy="Run: raven provider set --api-key , then raven provider use /", + ) - from raven.providers.registry import find_by_name - - spec = find_by_name(provider_name) - client = spec.client if spec else "" - - if client == "codex": - return - if client == "azure": - if not p or not p.api_key or not p.api_base: - console.print("[red]Error: Azure OpenAI requires api_key and api_base.[/red]") - console.print("Set them in ~/.raven/config.json under providers.azure_openai section") - console.print("Use the model field to specify the deployment name.") - raise typer.Exit(1) - return - if not model.startswith("bedrock/") and not (p and p.api_key) and not (spec and (spec.is_oauth or spec.is_local)): - console.print("[red]Error: No API key configured.[/red]") - console.print("Set one in ~/.raven/config.json under providers section") - raise typer.Exit(1) + status = credential_status(provider_name, config.providers.get(provider_name), include_external=True) + if not status.ok: + raise MissingCredentialsError(status.summary, provider=provider_name) def make_provider(config: Config): """Create the appropriate LLM provider from config.""" + from raven.providers.auth import MissingCredentialsError from raven.providers.azure_openai_provider import AzureOpenAIProvider from raven.providers.base import GenerationSettings from raven.providers.openai_codex_provider import OpenAICodexProvider @@ -105,11 +114,16 @@ def make_provider(config: Config): provider_name = config.get_provider_name(model) p = config.get_provider(model) - from raven.providers.registry import find_by_name + from raven.providers.registry import endpoints_unsupported_reason, find_by_name spec = find_by_name(provider_name) if provider_name else None client = spec.client if spec else "" + if p and p.endpoints: + reason = endpoints_unsupported_reason(provider_name) + if reason: + raise MissingCredentialsError(reason, provider=provider_name or "") + if client == "codex": provider = OpenAICodexProvider(default_model=model) elif client == "minimax_oauth": @@ -121,31 +135,69 @@ def make_provider(config: Config): ) elif client == "azure": provider = AzureOpenAIProvider( - api_key=p.api_key, + api_key=p.effective_api_key, api_base=p.api_base, default_model=model, + deployment=getattr(p, "deployment", "") or "", + api_version=getattr(p, "api_version", "") or "2024-10-21", ) else: + from raven.providers.capabilities import wire_overrides + from raven.providers.endpoints import provider_endpoints from raven.providers.litellm_provider import LiteLLMProvider - # OpenRouter routes qwen3.x-27B through providers that default to - # reasoning mode (e.g. AtlasCloud): every chat completion emits - # ~800 chain-of-thought tokens and takes ~30s wall — fatal for - # interactive use and for high-volume benchmark runs. The - # ``reasoning.enabled=false`` flag is OpenRouter-specific and - # forwards through LiteLLM's ``extra_body``. - extra_body = None - if provider_name == "openrouter" and "qwen" in (model or "").lower(): - extra_body = {"reasoning": {"enabled": False}} - provider = LiteLLMProvider( - api_key=p.api_key if p else None, - api_base=config.get_api_base(model), - default_model=model, - extra_headers=p.extra_headers if p else None, - provider_name=provider_name, - extra_body=extra_body, - model_overrides=config.agents.defaults.model_overrides, - ) + eps = provider_endpoints(p) if p else [] + if len(eps) > 1: + from raven.providers.endpoint_rotor import EndpointRotorProvider + + def make_inner(ep): + return LiteLLMProvider( + api_key=ep.api_key, + # ``ep.api_base`` already carries the section's flat address + # when the endpoint named none of its own (see + # ``provider_endpoints``); the fallback here is only for a + # gateway/local provider whose *flat* address is also empty, + # where ``get_api_base`` still has the spec's default to + # offer. + api_base=ep.api_base or config.get_api_base(model), + default_model=model, + extra_headers=ep.extra_headers, + provider_name=provider_name, + extra_body=wire_overrides(provider_name, model) or None, + model_overrides=config.agents.defaults.model_overrides, + ) + + provider = EndpointRotorProvider( + eps, + make_inner, + default_model=model, + strategy=p.endpoint_strategy if p else "sticky", + ) + elif eps: + extra_body = wire_overrides(provider_name, model) or None + provider = LiteLLMProvider( + api_key=eps[0].api_key, + # Same fallback as ``make_inner`` above: only reached when the + # flat address is empty too, for a gateway/local provider's + # spec default. + api_base=eps[0].api_base or config.get_api_base(model), + default_model=model, + extra_headers=eps[0].extra_headers, + provider_name=provider_name, + extra_body=extra_body, + model_overrides=config.agents.defaults.model_overrides, + ) + else: + extra_body = wire_overrides(provider_name, model) or None + provider = LiteLLMProvider( + api_key=p.effective_api_key if p else None, + api_base=config.get_api_base(model), + default_model=model, + extra_headers=p.extra_headers if p else None, + provider_name=provider_name, + extra_body=extra_body, + model_overrides=config.agents.defaults.model_overrides, + ) defaults = config.agents.defaults provider.generation = GenerationSettings( @@ -162,10 +214,16 @@ def make_lazy_provider(config: Config): call, so AgentLoop construction stays fast. Credentials are checked now (fail-fast preserved) and the real provider is pre-warmed in the background.""" from raven.providers.base import GenerationSettings + from raven.providers.endpoints import provider_endpoints from raven.providers.lazy import LazyProvider check_provider_credentials(config) defaults = config.agents.defaults + + p = config.get_provider(defaults.model) + eps = provider_endpoints(p) if p else [] + initial_endpoint_label = eps[0].label if len(eps) > 1 else None + provider = LazyProvider( factory=lambda: make_provider(config), default_model=defaults.model, @@ -175,6 +233,7 @@ def make_lazy_provider(config: Config): reasoning_effort=defaults.reasoning_effort, timeout=defaults.llm_call_timeout, ), + initial_endpoint_label=initial_endpoint_label, ) provider.prewarm() return provider diff --git a/raven/cli/commands.py b/raven/cli/commands.py index 0c453c29..472efd92 100644 --- a/raven/cli/commands.py +++ b/raven/cli/commands.py @@ -150,9 +150,20 @@ def main( def run() -> None: """Console-script entry point.""" from raven.config.loader import ConfigReadError + from raven.providers.auth import MissingCredentialsError try: app() + except MissingCredentialsError as exc: + # The gate is decided in `providers.auth` because three entry points ask + # it; printing and exiting is this one's idiom, so it happens here rather + # than there. Rendered once for every command, like ConfigReadError. + from raven.cli._helpers import console + + console.print(f"[red]Error: {exc.summary}.[/red]") + if exc.remedy: + console.print(exc.remedy) + raise SystemExit(1) from exc except ConfigReadError as exc: # A config-write command (channels/provider/deep-research/onboard) hit an # unparseable config. The write layer already refused (file untouched); diff --git a/raven/cli/doctor_commands.py b/raven/cli/doctor_commands.py index 57d9c887..7a637ae9 100644 --- a/raven/cli/doctor_commands.py +++ b/raven/cli/doctor_commands.py @@ -42,7 +42,7 @@ class RoutingInfo: model: str provider: Optional[str] max_tokens: int - context_window_tokens: int + context_window_tokens: Optional[int] @dataclass @@ -328,7 +328,7 @@ def _render_human_output(report: DoctorReport) -> None: else: console.print(" Routes to: [red][/red]") console.print(f" Max tokens: {routing.max_tokens}") - console.print(f" Context win: {routing.context_window_tokens}") + console.print(f" Context win: {routing.context_window_tokens if routing.context_window_tokens else 'auto'}") features = report.features if features is not None: diff --git a/raven/cli/onboard_channels.py b/raven/cli/onboard_channels.py new file mode 100644 index 00000000..81df3153 --- /dev/null +++ b/raven/cli/onboard_channels.py @@ -0,0 +1,582 @@ +"""Chat channel cluster of the onboard wizard (Step 3). + +Split out of ``onboard_commands`` because that module had grown past 5000 +lines; this file owns channel selection, credential prompting, scancode +login, and channel management end to end. Shared wizard UI state +(``console``, ``_t``, ``_BACK``, ``_QMARK``, questionary helpers, ...) still +lives in ``onboard_commands`` -- this module reaches it via the ``oc`` module +reference (not a value import) so that test monkeypatches on +``onboard_commands`` attributes keep working whichever module a caller +patches through. +""" + +from __future__ import annotations + +from typing import Any, Optional + +import typer + +from raven.cli import onboard_commands as oc + + +def _enabled_channels() -> list[str]: + """Names of channels currently enabled on disk.""" + data = oc._load_raw_config() + channels = data.get("channels") or {} + return [name for name, c in channels.items() if isinstance(c, dict) and c.get("enabled")] + + +# Curated channel order: China-domestic first, then overseas. Channels not +# listed (e.g. a newly added adapter) fall to the end in alphabetical order so +# the picker never silently hides one. +# Display order: US/global-common → China-common → US/global-uncommon → +# China-uncommon. (Email is a universal but less-common-as-IM channel, so it +# sits in the uncommon tail.) +_CHANNEL_ORDER = ( + # US / global, common + "telegram", + "discord", + "slack", + "whatsapp", + # China, common + "weixin", + "wecom", + "feishu", + "dingtalk", + "qq", + # US / global, less common + "matrix", + "email", + # China, niche + "mochat", +) + + +# Where to obtain each channel's credentials — shown (dim) before the field +# prompts so the user knows where to fetch the token / keys. +_CHANNEL_CRED_HELP: dict[str, tuple[str, str]] = { + "telegram": ( + "Create a bot with @BotFather in Telegram (send /newbot) — it replies with the token.", + "在 Telegram 里找 @BotFather 发 /newbot 创建机器人,它会回复 token。", + ), + "discord": ( + "Discord Developer Portal → your app → Bot → Reset Token to copy it.", + "Discord 开发者门户 → 你的应用 → Bot → Reset Token 复制。", + ), + "slack": ( + "api.slack.com/apps → OAuth & Permissions gives bot_token (xoxb-…); " + "Basic Information → App-Level Tokens gives app_token (xapp-…).", + "api.slack.com/apps → OAuth & Permissions 拿 bot_token(xoxb-…);" + "Basic Information → App-Level Tokens 拿 app_token(xapp-…)。", + ), + "feishu": ( + "Feishu / Lark Open Platform → your app → Credentials for App ID & App Secret.", + "飞书开放平台 → 你的应用 → 凭证与基础信息 拿 App ID / App Secret。", + ), + "wecom": ( + "WeCom admin console → your bot / app for its ID and secret.", + "企业微信管理后台 → 机器人 / 应用 拿 ID 和 secret。", + ), + "dingtalk": ( + "DingTalk Open Platform → your app for Client ID & Client Secret.", + "钉钉开放平台 → 你的应用 拿 Client ID / Client Secret。", + ), + "qq": ( + "QQ Open Platform → your bot for App ID & secret.", + "QQ 开放平台 → 你的机器人 拿 App ID 和 secret。", + ), + "email": ( + "Use your mail provider's IMAP / SMTP settings; for Gmail / Outlook create an app password.", + "用你邮箱服务商的 IMAP / SMTP 设置;Gmail / Outlook 需创建应用专用密码。", + ), + "matrix": ( + "From your Matrix account: an access token and your full user id (@you:server).", + "从你的 Matrix 账号获取 access token 和完整用户 id(@you:server)。", + ), + "mochat": ( + "Get the claw token and agent user id from your Mochat workspace.", + "从你的 Mochat 工作区获取 claw token 和 agent user id。", + ), +} + + +def _ordered_channel_names() -> list[str]: + from raven.channels.registry import discover_channel_names + + rank = {name: i for i, name in enumerate(_CHANNEL_ORDER)} + return sorted(discover_channel_names(), key=lambda n: (rank.get(n, len(rank)), n)) + + +def _select_channel() -> Optional[str]: + """List available channels via the registry and let the user pick one.""" + questionary = oc._require_questionary() + from raven.cli._styles import RAVEN_STYLE + + names = _ordered_channel_names() + choices = [questionary.Choice(n, value=n) for n in names] + choices.append(questionary.Choice(oc._t("Back", "返回"), value=oc._BACK)) + picked = questionary.select(oc._t("Channel:", "渠道:"), choices=choices, style=RAVEN_STYLE, qmark=oc._QMARK).ask() + return picked + + +def _prompt_channel_fields(channel: str) -> Any: + """Reflect a channel's Pydantic schema and prompt for credential-like fields.""" + questionary = oc._require_questionary() + from raven.cli._styles import RAVEN_STYLE + from raven.config.update_channels import channel_field_specs + + try: + specs = channel_field_specs(channel) + except KeyError as exc: + oc.console.print(f" [red]✗[/red] {exc}") + raise typer.Exit(1) + + # Pre-scan which credential fields we'll ask for, so we can tell the user + # up front what's being configured (and handle the zero-field case). + promptable = [ + (path, spec) + for path, spec in specs.items() + if path != "enabled" and spec.get("type", "") == "str" and spec.get("default") in ("", None) + ] + if promptable: + names = ", ".join(path for path, _ in promptable) + oc.console.print( + oc._t( + f" [dim]Configuring {channel} — fill in:[/dim] {names}", + f" [dim]正在配置 {channel} — 请填写:[/dim] {names}", + ) + ) + help_text = _CHANNEL_CRED_HELP.get(channel) + if help_text: + oc.console.print( + oc._t( + f" [dim]Where to get it: {help_text[0]}[/dim]", + f" [dim]去哪拿:{help_text[1]}[/dim]", + ) + ) + else: + oc.console.print( + oc._t( + f" [dim]{channel} needs no credentials; enabling.[/dim]", + f" [dim]{channel} 无需填写凭证,正在启用。[/dim]", + ) + ) + + fields: dict[str, Any] = {} + for idx, (path, spec) in enumerate(promptable): + required = bool(spec.get("required")) + description = spec.get("description", "") + opt_tag = "" if required else oc._t(" (optional)", " (可选)") + prompt_label = f"{path}{opt_tag}" + (f" — {description}" if description else "") + ":" + # First field's empty submit rewinds to the channel picker; a later + # optional field's empty submit skips it; a later required field re-prompts + # (empty was previously accepted silently, enabling a half-configured + # channel — the write layer treats "required" as a UX marker only). + allow_back = idx == 0 + placeholder = oc._field_placeholder(allow_back, required) + while True: + if spec.get("is_secret"): + value = questionary.password( + prompt_label, placeholder=placeholder, style=RAVEN_STYLE, qmark=oc._QMARK + ).ask() + else: + value = questionary.text( + prompt_label, placeholder=placeholder, style=RAVEN_STYLE, qmark=oc._QMARK + ).ask() + if value is None: + raise typer.Exit(1) + value = value.strip() + if value: + fields[path] = value + break + if allow_back: + return oc._BACK # first field empty → back to the channel picker + if required: + oc.console.print( + oc._t(f" [yellow]{path} is required.[/yellow]", f" [yellow]{path} 为必填项。[/yellow]") + ) + continue # re-prompt instead of enabling a channel missing a credential + break # optional field: empty submit skips it + return fields + + +def _enable_channel(channel: str, fields: dict[str, Any]) -> None: + """Thin wrapper for ``enable_channel`` that surfaces ops errors with hints.""" + from pydantic import ValidationError + + from raven.config.update_channels import enable_channel + + try: + enable_channel(channel, fields) + except KeyError as exc: + oc.console.print(f" [red]✗[/red] {exc}") + raise typer.Exit(1) + except ValidationError as exc: + oc.console.print(oc._t(f" [red]✗ Validation failed:[/red]\n{exc}", f" [red]✗ 校验失败:[/red]\n{exc}")) + raise typer.Exit(1) + + +def _channel_uses_interactive_login(channel: str) -> bool: + """True for scancode/QR channels (WeChat / WhatsApp) that pair via a live + login flow rather than reflected credential fields.""" + try: + from raven.channels.registry import discover_specs + + spec = discover_specs().get(channel) + return bool(spec and spec.capabilities.interactive_login) + except Exception: + return False + + +# Scancode channels whose QR login is served by a Node.js bridge — these need +# Node/npm present before login can even start. The whatsapp adapter's +# ``login`` checks ``shutil.which("npm")`` and merely logs+returns False when +# it's absent, so we detect the missing-runtime case up front to show a +# meaningful "install Node / skip" menu rather than a pointless "re-show QR". +_NODE_BRIDGE_CHANNELS = {"whatsapp"} + + +def _node_runtime_missing(channel: str) -> bool: + """True iff ``channel`` needs a Node bridge and ``npm`` isn't on PATH.""" + if channel not in _NODE_BRIDGE_CHANNELS: + return False + import shutil + + return shutil.which("npm") is None + + +def _handle_missing_node(channel: str, *, non_interactive: bool) -> str: + """Show the Node-missing submenu (install-then-retry / skip). + + Returns ``"retry"`` (re-check after install) or ``"skip"`` (leave the + channel enabled-but-unauthenticated). A pointless "re-show QR" is + intentionally absent — there's no bridge to render a QR without Node. + """ + oc.console.print( + oc._t( + f" [yellow]✗ Node.js / npm not found (the {channel} bridge needs it). " + "Install Node.js, then retry.[/yellow]", + f" [yellow]✗ 未找到 Node.js / npm({channel} 的桥接需要它)。请先安装 Node.js,再重试。[/yellow]", + ) + ) + choice = oc._failure_choice( + [ + (oc._t("Retry after install", "安装后重试"), "retry"), + (oc._t("Skip", "跳过"), "skip"), + ], + non_interactive=non_interactive, + ) + return choice + + +def _scancode_login(channel: str, *, non_interactive: bool = False) -> None: + """Run a scancode channel's real QR login (reuses ``channel.login``). + + Mirrors ``raven channels login``: enable the channel so its config section + persists, build the adapter via its spec factory, then drive + ``await channel.login()`` (which for WhatsApp builds the bridge, displays + the QR, and waits). A failed / timed-out login drops into a numbered + submenu (retry / skip). Node-bridge channels missing Node/npm get a + dedicated install-then-retry menu instead. + """ + import asyncio + + from raven.channels.registry import discover_specs + from raven.config.update_channels import disable_channel + + # Enable first so the config section exists for the factory to read while we + # attempt login. We REVERT this (disable) on any path that doesn't complete + # login, so a cancelled / skipped scan never shows up as "connected". + _enable_channel(channel, {}) + + specs = discover_specs() + spec = specs.get(channel) + if spec is None: + disable_channel(channel) + oc.console.print(oc._t(f" [red]✗ Unknown channel: {channel}[/red]", f" [red]✗ 未知渠道:{channel}[/red]")) + return + + # Enabled above so the factory can read the config section during login. ANY + # path that doesn't finish login must revert the enable — including Ctrl+C in + # a submenu (raises typer.Exit) or mid-scan (KeyboardInterrupt), neither an + # ``Exception`` subclass — so wrap the whole flow and disable in ``finally`` + # unless we actually logged in. + logged_in = False + try: + while True: + # Node-bridge channels: gate on the runtime up front so a missing + # Node/npm shows a useful install menu, not a "re-show QR" no-op. + if _node_runtime_missing(channel): + if _handle_missing_node(channel, non_interactive=non_interactive) == "retry": + continue + oc.console.print( + oc._t( + f" [dim]Skipped {channel}; install Node.js then run raven channels login {channel}.[/dim]", + f" [dim]已跳过 {channel};装好 Node.js 后运行 raven channels login {channel}。[/dim]", + ) + ) + return + + from raven.config.loader import load_config + + channel_cfg = getattr(load_config().channels, channel, None) + if channel_cfg is None: + oc.console.print( + oc._t( + f" [red]✗ No config section for channel: {channel}[/red]", + f" [red]✗ 渠道 {channel} 没有配置段。[/red]", + ) + ) + return + adapter = spec.factory(channel_cfg) + if channel == "whatsapp": + oc.console.print( + oc._t( + " [dim]Building the WhatsApp bridge — the first run can take 30–120s…[/dim]", + " [dim]正在构建 WhatsApp 桥接,首次约需 30–120 秒…[/dim]", + ) + ) + oc.console.print( + oc._t( + f" [dim]Starting {spec.display_name} QR login…[/dim]", + f" [dim]正在启动 {spec.display_name} 扫码登录…[/dim]", + ) + ) + oc.console.print( + oc._t( + f" [dim]A login link / QR code will appear below — scan it with " + f"{spec.display_name} (or open the link on a phone signed in to " + f"{spec.display_name}) to connect. This waits until you finish.[/dim]", + f" [dim]下方会出现登录链接 / 二维码 — 用 {spec.display_name} 扫码" + f"(或在已登录 {spec.display_name} 的手机上打开该链接)即可接入;" + f"这里会一直等到你完成。[/dim]", + ) + ) + from loguru import logger as _wiz_logger + + # The wizard silences raven logs for a clean UI, but a scancode login + # emits its QR / link / progress / failure reason through loguru. Re- + # enable ONLY this channel's adapter subtree for the login attempt (not + # all of raven, which would dump unrelated noise), then restore quiet. + _login_log_scope = f"raven.channels.adapters.{channel}" + try: + _wiz_logger.enable(_login_log_scope) + ok = asyncio.run(adapter.login(force=True)) + except Exception as exc: + oc.console.print( + oc._t( + f" [yellow]✗ Login failed: {exc}[/yellow]", + f" [yellow]✗ 登录失败:{exc}[/yellow]", + ) + ) + ok = False + finally: + _wiz_logger.disable(_login_log_scope) + if ok: + oc.console.print( + oc._t( + f" [green]✓ Logged in; {channel} connected.[/green]", + f" [green]✓ 已登录;{channel} 已接入。[/green]", + ) + ) + logged_in = True + return + choice = oc._failure_choice( + [ + (oc._t("Retry", "重试"), "retry"), + (oc._t("Skip this channel", "跳过此渠道"), "skip"), + ], + non_interactive=non_interactive, + ) + if choice == "retry": + continue + oc.console.print( + oc._t( + f" [dim]{channel} not connected — finish later with raven channels login {channel}.[/dim]", + f" [dim]{channel} 未接入 — 之后用 raven channels login {channel} 完成。[/dim]", + ) + ) + return + finally: + if not logged_in: + # Any non-login exit (skip, no-config, submenu Ctrl+C, mid-scan + # interrupt) reverts the enable so a cancelled scan never persists as + # "connected". The config section is kept for `raven channels login`. + disable_channel(channel) + + +def _add_one_channel(*, non_interactive: bool = False) -> None: + """Pick + (scancode login | reflect-prompt) + enable one channel.""" + while True: + channel = _select_channel() + if channel is None or channel is oc._BACK: + return + if _channel_uses_interactive_login(channel): + _scancode_login(channel, non_interactive=non_interactive) + return + fields = _prompt_channel_fields(channel) + if fields is oc._BACK: + continue # backed out of the first field — re-pick a channel + _enable_channel(channel, fields) + oc.console.print(oc._t(f" [green]✓ {channel} enabled.[/green]", f" [green]✓ {channel} 已启用。[/green]")) + return + + +def _manage_existing_channels() -> None: + """Edit/disable submenu for already-enabled channels.""" + questionary = oc._require_questionary() + from raven.cli._styles import RAVEN_STYLE + from raven.config.update_channels import disable_channel, set_channel_fields + + while True: + enabled = _enabled_channels() + if not enabled: + return + choices = [questionary.Choice(n, value=n) for n in enabled] + choices.append(questionary.Choice(oc._t("Back", "返回"), value=oc._BACK)) + target = questionary.select( + oc._t("Pick a channel to manage:", "选择要管理的渠道:"), + choices=choices, + style=RAVEN_STYLE, + qmark=oc._QMARK, + ).ask() + if target is None or target is oc._BACK: + return + action = questionary.select( + oc._t(f"What would you like to do with {target}?", f"对 {target} 想做什么?"), + choices=[ + questionary.Choice(oc._t("Edit config (re-enter fields)", "编辑配置(重填字段)"), value="edit"), + questionary.Choice(oc._t("Disable (keep credentials)", "停用(保留凭证)"), value="disable"), + questionary.Choice(oc._t("Back", "返回"), value=oc._BACK), + ], + style=RAVEN_STYLE, + qmark=oc._QMARK, + ).ask() + if action is None or action is oc._BACK: + continue + if action == "edit": + fields = _prompt_channel_fields(target) + if fields is oc._BACK: + continue # backed out — return to the manage menu + if fields: + set_channel_fields(target, fields) + oc.console.print( + oc._t( + f" [green]✓ {target} config updated.[/green]", + f" [green]✓ {target} 配置已更新。[/green]", + ) + ) + elif action == "disable": + disable_channel(target) + oc.console.print( + oc._t( + f" [green]✓ Disabled {target} (credentials kept; re-enable later " + f"with raven channels enable {target}).[/green]", + f" [green]✓ 已停用 {target}(凭证保留;之后用 raven channels enable {target} 重新启用)。[/green]", + ) + ) + + +def _step3_channel(*, channel: Optional[str], skip: bool, non_interactive: bool) -> object: + """Step 3 — optionally enable chat channel(s).""" + oc._step_header( + 3, + oc._t( + "(Optional) Connect a messaging app so you can chat with Raven there", + "(可选)接入即时通讯软件,直接在里面和 Raven 聊天", + ), + ) + + if skip: + oc.console.print( + oc._t( + " [dim]Skipped via --skip-channel.[/dim]", + " [dim]已通过 --skip-channel 跳过。[/dim]", + ) + ) + return None + + if non_interactive: + if channel: + oc.console.print( + f"[red]--channel {channel} given but non-interactive mode can't " + "prompt for credential fields.[/red]\n" + f"Run [accent]raven channels enable {channel} -- ...[/accent] " + "after onboard finishes." + ) + raise typer.Exit(2) + oc.console.print( + oc._t( + " [dim]Skipped (non-interactive, --channel not given).[/dim]", + " [dim]已跳过(非交互且未提供 --channel)。[/dim]", + ) + ) + return None + + questionary = oc._require_questionary() + from raven.cli._styles import RAVEN_STYLE + + if channel: + if _channel_uses_interactive_login(channel): + _scancode_login(channel, non_interactive=non_interactive) + else: + fields = _prompt_channel_fields(channel) + if fields is oc._BACK: + oc.console.print(oc._t(" [dim]Skipped.[/dim]", " [dim]已跳过。[/dim]")) + return None + _enable_channel(channel, fields) + oc.console.print( + oc._t( + f" [green]✓ {channel} enabled.[/green]", + f" [green]✓ {channel} 已启用。[/green]", + ) + ) + return None + + while True: + enabled = _enabled_channels() + if not enabled: + action = questionary.select( + oc._t("Connect a chat channel?", "接入一个聊天渠道吗?"), + choices=[ + questionary.Choice(oc._t("Add a channel", "新增一个渠道"), value="add"), + questionary.Choice( + oc._t( + "Skip (add later with raven channels enable)", + "跳过(之后用 raven channels enable 添加)", + ), + value="skip", + ), + ], + style=RAVEN_STYLE, + qmark=oc._QMARK, + ).ask() + if action is None: + raise typer.Exit(1) + if action == "skip": + oc.console.print(oc._t(" [dim]Skipped.[/dim]", " [dim]已跳过。[/dim]")) + return None + _add_one_channel(non_interactive=non_interactive) + continue + + action = questionary.select( + oc._t( + f"Chat channel already connected: {', '.join(enabled)}. What would you like to do?", + f"聊天渠道已接入:{', '.join(enabled)}。想做什么?", + ), + choices=[ + questionary.Choice(oc._t("Done, next step", "完成,下一步"), value="done"), + questionary.Choice(oc._t("Add a channel", "新增一个渠道"), value="add"), + questionary.Choice(oc._t("Edit / remove a channel", "编辑 / 移除渠道"), value="edit"), + ], + style=RAVEN_STYLE, + qmark=oc._QMARK, + ).ask() + if action is None: + raise typer.Exit(1) + if action == "done": + return None + if action == "add": + _add_one_channel(non_interactive=non_interactive) + elif action == "edit": + _manage_existing_channels() diff --git a/raven/cli/onboard_commands.py b/raven/cli/onboard_commands.py index b00f94ac..9a50ee5c 100644 --- a/raven/cli/onboard_commands.py +++ b/raven/cli/onboard_commands.py @@ -36,6 +36,7 @@ from rich.console import Console from rich.panel import Panel +from raven.cli import onboard_channels, onboard_everos from raven.cli._helpers import ( DEFAULT_PROBE_MESSAGE, print_probe_troubleshooting, @@ -47,9 +48,8 @@ CRED_LOCAL, CRED_OAUTH, credential_kind, - needs_public_model_prefix, - public_model_prefix, ) +from raven.providers.wire import stored_model_id class _ThemedConsole(Console): @@ -441,7 +441,7 @@ def _bootstrap_empty_config() -> None: path = get_config_path() if not path.exists(): save_config(load_config()) # writes default Config() to disk - _init_extension_block_defaults() + onboard_everos._init_extension_block_defaults() workspace = get_workspace_path() workspace.mkdir(parents=True, exist_ok=True) sync_workspace_templates(workspace) @@ -890,11 +890,16 @@ def _verify_provider(provider: str, *, skip_test: bool = False) -> tuple[bool, s status = result.get("status", "unknown") # Some direct providers (openai / anthropic / deepseek / gemini) ship no # base URL and rely on the SDK's built-in endpoint, so there's nothing to - # hit for a GET /v1/models pre-check — the probe reports "not_configured" - # because api_base is empty. That's NOT a real auth failure: skip the pre- - # check (the test message sent later exercises real connectivity via + # hit for a GET /v1/models pre-check. That's NOT a real auth failure: skip + # the pre-check (the test message sent later exercises real connectivity via # litellm) instead of dumping the user into the failure submenu. - if status == "not_configured" and "api_base" in (result.get("error") or ""): + # + # `no_probe_endpoint` is the probe saying exactly this. It used to say + # `not_configured` with "api_base" in the text, which is why the old + # condition read that way -- and a rename this caller does not follow puts + # every one of those providers into the failure submenu on the first step + # of onboarding. + if status == "no_probe_endpoint" or (status == "not_configured" and "api_base" in (result.get("error") or "")): if skip_test: console.print( _t( @@ -971,45 +976,10 @@ def _format_model_for_provider(provider: str, spec: Any, model_id: str) -> str: Mistral alongside OpenAI produced "mistral-large-latest", which resolves to OpenAI and spends OpenAI's key. - Its section name is LiteLLM's own name for the vendor, which is exactly the - prefix LiteLLM routes on, so that is what goes in front. + The rule itself is ``providers.wire.stored_model_id``; deciding it here as + well is what made the wizard and the TUI write one model two ways. """ - - if not model_id: - return model_id - if spec is None: - # LiteLLM's own spelling, not the normalized one. A config section may be - # written either way and both resolve, but the wire prefix has to be the - # name LiteLLM routes on -- it hyphenates three vendors, and handing it - # "nano_gpt/..." is rejected with "LLM Provider NOT provided", so the - # provider configured fine and then could not be called. - from raven.providers.registry import litellm_spelling - - prefix = litellm_spelling(provider) - return model_id if model_id.startswith(f"{prefix}/") else f"{prefix}/{model_id}" - # These providers are named by a prefix their own client strips back off - # (``minimax_oauth`` before signing, ``_strip_model_prefix`` for codex), so the - # id can carry it -- and has to: written bare, "gpt-5.6-sol" is claimed by - # OpenAI's keywords and the request goes somewhere it does not exist. - # - # Azure is not in the list and does not reach this function either: an endpoint - # provider is locked in as ``is_custom`` and its model is persisted directly, - # so nothing here decides its spelling. It would need the opposite treatment - # anyway -- the id is used verbatim as a deployment name in a URL path. - if needs_public_model_prefix(spec): - public_prefix = public_model_prefix(spec) - if model_id.startswith(f"{public_prefix}/"): - return model_id - return f"{public_prefix}/{model_id.split('/')[-1]}" - prefix = getattr(spec, "model_prefix", "") or "" - if not prefix: - return model_id - if model_id.startswith(f"{prefix}/"): - return model_id - for skip in getattr(spec, "skip_prefixes", ()) or (): - if model_id.startswith(skip): - return model_id - return f"{prefix}/{model_id}" + return stored_model_id(provider, model_id) def _pick_model( @@ -1186,13 +1156,26 @@ def _write_provider_fields(provider: str, fields: dict[str, Any]) -> None: raise typer.Exit(1) -def _persist_default_model(model: Optional[str]) -> None: - """Patch ``agents.defaults.model`` if we picked one.""" +def _persist_default_model(model: Optional[str], provider: str) -> None: + """Patch ``agents.defaults.model`` and the pin that overrides it. + + Both, always. ``agents.defaults.provider`` wins over whatever a model id + names, so writing the model alone leaves the wizard's own choice routed to + whichever provider was pinned before -- with that provider's key. The rule + for what to pin is ``providers.pin``, the same one the picker and + ``raven provider use`` ask. + """ if not model: return + from raven.config.loader import load_config from raven.config.update import set_default_model + from raven.providers import pin - set_default_model(model) + try: + pinned = load_config().agents.defaults.provider or "" + except Exception: + pinned = "" + set_default_model(model, provider=pin.resolve(model, provider=provider, pinned=pinned)) # --------------------------------------------------------------------------- @@ -1426,7 +1409,7 @@ def _rewind() -> None: _roll_back_provider_fields(provider, spec, old_key=old_key, old_base=old_base) _rewind() continue - _persist_default_model(chosen_model) + _persist_default_model(chosen_model, provider) return {"provider": provider, "model": chosen_model} @@ -1444,7 +1427,17 @@ def _collect_credentials( """Auth setup: OAuth browser flow or api_key write. Returns the custom model id when the provider is ``custom`` (locked in here), ``None`` for a non-custom provider, or ``_BACK`` if the user backed out of the first - interactive credential field (caller should rewind to the picker).""" + interactive credential field, or if the vendor cannot be configured by a + bare key at all (caller should rewind to the picker either way).""" + from raven.providers.auth import key_refusal + + refusal = key_refusal(provider) + if refusal is not None: + console.print(f" [red]x[/red] {refusal}") + if non_interactive: + raise typer.Exit(2) + return _BACK + if is_oauth: if non_interactive: console.print( @@ -1501,6 +1494,19 @@ def _collect_credentials( _write_provider_fields(provider, {"api_base": base_url}) return None + if not api_key: + from raven.providers.registry import normalize_provider_name + + # GigaChat's key is not a typical API key -- it is base64(client_id: + # client_secret) -- and the generic prompt below gives no room to say + # so, so the wizard would otherwise send someone looking for a plain + # key straight into a 401. + if normalize_provider_name(provider) == "gigachat": + console.print( + " [dim]GigaChat's key is base64(client_id:client_secret) from the " + "GigaChat API console, not a typical API key.[/dim]" + ) + # Pure interactive path (no creds came from flags): prompt field-by-field # with empty-submit = back; backing out of the first field rewinds to the # provider picker. @@ -1631,7 +1637,7 @@ def _resolve_model_with_test( # Custom endpoints were previously trusted without a test message — the # highest-typo-risk case. Send the real probe (it builds from the stored # config, so a wrong base_url / model id fails here, not at first chat). - _persist_default_model(custom_model) + _persist_default_model(custom_model, provider) if skip_test: return custom_model while True: @@ -1654,7 +1660,7 @@ def _resolve_model_with_test( user_provided_model=user_model_flag, non_interactive=non_interactive, ) - _persist_default_model(chosen) + _persist_default_model(chosen, provider) if skip_test: return chosen result = _run_test_probe( @@ -1716,7 +1722,7 @@ def _configure_existing_provider_model(*, non_interactive: bool) -> bool: user_provided_model=None, non_interactive=False, ) - _persist_default_model(chosen) + _persist_default_model(chosen, provider) result = _run_test_probe( provider, non_interactive=False, @@ -1869,7 +1875,9 @@ def _manage_existing_providers(*, non_interactive: bool) -> None: # re-pick instead of leaving a model whose provider has no key. from raven.config.update import set_default_model - set_default_model("") + # The pin goes with it: left behind it would route the next model + # the user picks to the provider whose key was just removed. + set_default_model("", provider="auto") console.print( _t( f" [green]✓ Removed {_provider_label(target)}'s configuration.[/green]", @@ -2121,2118 +2129,47 @@ def _step2_sandbox(*, skip: bool, non_interactive: bool) -> object: # --------------------------------------------------------------------------- -# Step 3 — chat channel (stackable) +# Final summary # --------------------------------------------------------------------------- -def _enabled_channels() -> list[str]: - """Names of channels currently enabled on disk.""" - data = _load_raw_config() - channels = data.get("channels") or {} - return [name for name, c in channels.items() if isinstance(c, dict) and c.get("enabled")] - - -# Curated channel order: China-domestic first, then overseas. Channels not -# listed (e.g. a newly added adapter) fall to the end in alphabetical order so -# the picker never silently hides one. -# Display order: US/global-common → China-common → US/global-uncommon → -# China-uncommon. (Email is a universal but less-common-as-IM channel, so it -# sits in the uncommon tail.) -_CHANNEL_ORDER = ( - # US / global, common - "telegram", - "discord", - "slack", - "whatsapp", - # China, common - "weixin", - "wecom", - "feishu", - "dingtalk", - "qq", - # US / global, less common - "matrix", - "email", - # China, niche - "mochat", -) - - -# Where to obtain each channel's credentials — shown (dim) before the field -# prompts so the user knows where to fetch the token / keys. -_CHANNEL_CRED_HELP: dict[str, tuple[str, str]] = { - "telegram": ( - "Create a bot with @BotFather in Telegram (send /newbot) — it replies with the token.", - "在 Telegram 里找 @BotFather 发 /newbot 创建机器人,它会回复 token。", - ), - "discord": ( - "Discord Developer Portal → your app → Bot → Reset Token to copy it.", - "Discord 开发者门户 → 你的应用 → Bot → Reset Token 复制。", - ), - "slack": ( - "api.slack.com/apps → OAuth & Permissions gives bot_token (xoxb-…); " - "Basic Information → App-Level Tokens gives app_token (xapp-…).", - "api.slack.com/apps → OAuth & Permissions 拿 bot_token(xoxb-…);" - "Basic Information → App-Level Tokens 拿 app_token(xapp-…)。", - ), - "feishu": ( - "Feishu / Lark Open Platform → your app → Credentials for App ID & App Secret.", - "飞书开放平台 → 你的应用 → 凭证与基础信息 拿 App ID / App Secret。", - ), - "wecom": ( - "WeCom admin console → your bot / app for its ID and secret.", - "企业微信管理后台 → 机器人 / 应用 拿 ID 和 secret。", - ), - "dingtalk": ( - "DingTalk Open Platform → your app for Client ID & Client Secret.", - "钉钉开放平台 → 你的应用 拿 Client ID / Client Secret。", - ), - "qq": ( - "QQ Open Platform → your bot for App ID & secret.", - "QQ 开放平台 → 你的机器人 拿 App ID 和 secret。", - ), - "email": ( - "Use your mail provider's IMAP / SMTP settings; for Gmail / Outlook create an app password.", - "用你邮箱服务商的 IMAP / SMTP 设置;Gmail / Outlook 需创建应用专用密码。", - ), - "matrix": ( - "From your Matrix account: an access token and your full user id (@you:server).", - "从你的 Matrix 账号获取 access token 和完整用户 id(@you:server)。", - ), - "mochat": ( - "Get the claw token and agent user id from your Mochat workspace.", - "从你的 Mochat 工作区获取 claw token 和 agent user id。", - ), -} - - -def _ordered_channel_names() -> list[str]: - from raven.channels.registry import discover_channel_names - - rank = {name: i for i, name in enumerate(_CHANNEL_ORDER)} - return sorted(discover_channel_names(), key=lambda n: (rank.get(n, len(rank)), n)) - - -def _select_channel() -> Optional[str]: - """List available channels via the registry and let the user pick one.""" - questionary = _require_questionary() - from raven.cli._styles import RAVEN_STYLE - - names = _ordered_channel_names() - choices = [questionary.Choice(n, value=n) for n in names] - choices.append(questionary.Choice(_t("Back", "返回"), value=_BACK)) - picked = questionary.select(_t("Channel:", "渠道:"), choices=choices, style=RAVEN_STYLE, qmark=_QMARK).ask() - return picked - - -def _prompt_channel_fields(channel: str) -> Any: - """Reflect a channel's Pydantic schema and prompt for credential-like fields.""" - questionary = _require_questionary() - from raven.cli._styles import RAVEN_STYLE - from raven.config.update_channels import channel_field_specs - - try: - specs = channel_field_specs(channel) - except KeyError as exc: - console.print(f" [red]✗[/red] {exc}") - raise typer.Exit(1) +def _print_next_steps(*, warnings: list[str]) -> None: + from rich.table import Table - # Pre-scan which credential fields we'll ask for, so we can tell the user - # up front what's being configured (and handle the zero-field case). - promptable = [ - (path, spec) - for path, spec in specs.items() - if path != "enabled" and spec.get("type", "") == "str" and spec.get("default") in ("", None) - ] - if promptable: - names = ", ".join(path for path, _ in promptable) - console.print( - _t( - f" [dim]Configuring {channel} — fill in:[/dim] {names}", - f" [dim]正在配置 {channel} — 请填写:[/dim] {names}", - ) - ) - help_text = _CHANNEL_CRED_HELP.get(channel) - if help_text: - console.print( - _t( - f" [dim]Where to get it: {help_text[0]}[/dim]", - f" [dim]去哪拿:{help_text[1]}[/dim]", - ) - ) - else: + console.print() + if warnings: console.print( - _t( - f" [dim]{channel} needs no credentials; enabling.[/dim]", - f" [dim]{channel} 无需填写凭证,正在启用。[/dim]", - ) - ) - - fields: dict[str, Any] = {} - for idx, (path, spec) in enumerate(promptable): - required = bool(spec.get("required")) - description = spec.get("description", "") - opt_tag = "" if required else _t(" (optional)", " (可选)") - prompt_label = f"{path}{opt_tag}" + (f" — {description}" if description else "") + ":" - # First field's empty submit rewinds to the channel picker; a later - # optional field's empty submit skips it; a later required field re-prompts - # (empty was previously accepted silently, enabling a half-configured - # channel — the write layer treats "required" as a UX marker only). - allow_back = idx == 0 - placeholder = _field_placeholder(allow_back, required) - while True: - if spec.get("is_secret"): - value = questionary.password( - prompt_label, placeholder=placeholder, style=RAVEN_STYLE, qmark=_QMARK - ).ask() - else: - value = questionary.text(prompt_label, placeholder=placeholder, style=RAVEN_STYLE, qmark=_QMARK).ask() - if value is None: - raise typer.Exit(1) - value = value.strip() - if value: - fields[path] = value - break - if allow_back: - return _BACK # first field empty → back to the channel picker - if required: - console.print(_t(f" [yellow]{path} is required.[/yellow]", f" [yellow]{path} 为必填项。[/yellow]")) - continue # re-prompt instead of enabling a channel missing a credential - break # optional field: empty submit skips it - return fields - - -def _enable_channel(channel: str, fields: dict[str, Any]) -> None: - """Thin wrapper for ``enable_channel`` that surfaces ops errors with hints.""" - from pydantic import ValidationError - - from raven.config.update_channels import enable_channel - - try: - enable_channel(channel, fields) - except KeyError as exc: - console.print(f" [red]✗[/red] {exc}") - raise typer.Exit(1) - except ValidationError as exc: - console.print(_t(f" [red]✗ Validation failed:[/red]\n{exc}", f" [red]✗ 校验失败:[/red]\n{exc}")) - raise typer.Exit(1) - - -def _channel_uses_interactive_login(channel: str) -> bool: - """True for scancode/QR channels (WeChat / WhatsApp) that pair via a live - login flow rather than reflected credential fields.""" - try: - from raven.channels.registry import discover_specs - - spec = discover_specs().get(channel) - return bool(spec and spec.capabilities.interactive_login) - except Exception: - return False - - -# Scancode channels whose QR login is served by a Node.js bridge — these need -# Node/npm present before login can even start. The whatsapp adapter's -# ``login`` checks ``shutil.which("npm")`` and merely logs+returns False when -# it's absent, so we detect the missing-runtime case up front to show a -# meaningful "install Node / skip" menu rather than a pointless "re-show QR". -_NODE_BRIDGE_CHANNELS = {"whatsapp"} - - -def _node_runtime_missing(channel: str) -> bool: - """True iff ``channel`` needs a Node bridge and ``npm`` isn't on PATH.""" - if channel not in _NODE_BRIDGE_CHANNELS: - return False - import shutil - - return shutil.which("npm") is None - - -def _handle_missing_node(channel: str, *, non_interactive: bool) -> str: - """Show the Node-missing submenu (install-then-retry / skip). - - Returns ``"retry"`` (re-check after install) or ``"skip"`` (leave the - channel enabled-but-unauthenticated). A pointless "re-show QR" is - intentionally absent — there's no bridge to render a QR without Node. - """ - console.print( - _t( - f" [yellow]✗ Node.js / npm not found (the {channel} bridge needs it). " - "Install Node.js, then retry.[/yellow]", - f" [yellow]✗ 未找到 Node.js / npm({channel} 的桥接需要它)。请先安装 Node.js,再重试。[/yellow]", - ) - ) - choice = _failure_choice( - [ - (_t("Retry after install", "安装后重试"), "retry"), - (_t("Skip", "跳过"), "skip"), - ], - non_interactive=non_interactive, - ) - return choice - - -def _scancode_login(channel: str, *, non_interactive: bool = False) -> None: - """Run a scancode channel's real QR login (reuses ``channel.login``). - - Mirrors ``raven channels login``: enable the channel so its config section - persists, build the adapter via its spec factory, then drive - ``await channel.login()`` (which for WhatsApp builds the bridge, displays - the QR, and waits). A failed / timed-out login drops into a numbered - submenu (retry / skip). Node-bridge channels missing Node/npm get a - dedicated install-then-retry menu instead. - """ - import asyncio - - from raven.channels.registry import discover_specs - from raven.config.update_channels import disable_channel - - # Enable first so the config section exists for the factory to read while we - # attempt login. We REVERT this (disable) on any path that doesn't complete - # login, so a cancelled / skipped scan never shows up as "connected". - _enable_channel(channel, {}) - - specs = discover_specs() - spec = specs.get(channel) - if spec is None: - disable_channel(channel) - console.print(_t(f" [red]✗ Unknown channel: {channel}[/red]", f" [red]✗ 未知渠道:{channel}[/red]")) - return - - # Enabled above so the factory can read the config section during login. ANY - # path that doesn't finish login must revert the enable — including Ctrl+C in - # a submenu (raises typer.Exit) or mid-scan (KeyboardInterrupt), neither an - # ``Exception`` subclass — so wrap the whole flow and disable in ``finally`` - # unless we actually logged in. - logged_in = False - try: - while True: - # Node-bridge channels: gate on the runtime up front so a missing - # Node/npm shows a useful install menu, not a "re-show QR" no-op. - if _node_runtime_missing(channel): - if _handle_missing_node(channel, non_interactive=non_interactive) == "retry": - continue - console.print( - _t( - f" [dim]Skipped {channel}; install Node.js then run raven channels login {channel}.[/dim]", - f" [dim]已跳过 {channel};装好 Node.js 后运行 raven channels login {channel}。[/dim]", - ) - ) - return - - from raven.config.loader import load_config - - channel_cfg = getattr(load_config().channels, channel, None) - if channel_cfg is None: - console.print( - _t( - f" [red]✗ No config section for channel: {channel}[/red]", - f" [red]✗ 渠道 {channel} 没有配置段。[/red]", - ) - ) - return - adapter = spec.factory(channel_cfg) - if channel == "whatsapp": - console.print( - _t( - " [dim]Building the WhatsApp bridge — the first run can take 30–120s…[/dim]", - " [dim]正在构建 WhatsApp 桥接,首次约需 30–120 秒…[/dim]", - ) - ) - console.print( - _t( - f" [dim]Starting {spec.display_name} QR login…[/dim]", - f" [dim]正在启动 {spec.display_name} 扫码登录…[/dim]", - ) - ) - console.print( - _t( - f" [dim]A login link / QR code will appear below — scan it with " - f"{spec.display_name} (or open the link on a phone signed in to " - f"{spec.display_name}) to connect. This waits until you finish.[/dim]", - f" [dim]下方会出现登录链接 / 二维码 — 用 {spec.display_name} 扫码" - f"(或在已登录 {spec.display_name} 的手机上打开该链接)即可接入;" - f"这里会一直等到你完成。[/dim]", - ) - ) - from loguru import logger as _wiz_logger - - # The wizard silences raven logs for a clean UI, but a scancode login - # emits its QR / link / progress / failure reason through loguru. Re- - # enable ONLY this channel's adapter subtree for the login attempt (not - # all of raven, which would dump unrelated noise), then restore quiet. - _login_log_scope = f"raven.channels.adapters.{channel}" - try: - _wiz_logger.enable(_login_log_scope) - ok = asyncio.run(adapter.login(force=True)) - except Exception as exc: - console.print( - _t( - f" [yellow]✗ Login failed: {exc}[/yellow]", - f" [yellow]✗ 登录失败:{exc}[/yellow]", - ) - ) - ok = False - finally: - _wiz_logger.disable(_login_log_scope) - if ok: - console.print( - _t( - f" [green]✓ Logged in; {channel} connected.[/green]", - f" [green]✓ 已登录;{channel} 已接入。[/green]", - ) - ) - logged_in = True - return - choice = _failure_choice( - [ - (_t("Retry", "重试"), "retry"), - (_t("Skip this channel", "跳过此渠道"), "skip"), - ], - non_interactive=non_interactive, - ) - if choice == "retry": - continue - console.print( - _t( - f" [dim]{channel} not connected — finish later with raven channels login {channel}.[/dim]", - f" [dim]{channel} 未接入 — 之后用 raven channels login {channel} 完成。[/dim]", - ) - ) - return - finally: - if not logged_in: - # Any non-login exit (skip, no-config, submenu Ctrl+C, mid-scan - # interrupt) reverts the enable so a cancelled scan never persists as - # "connected". The config section is kept for `raven channels login`. - disable_channel(channel) - - -def _add_one_channel(*, non_interactive: bool = False) -> None: - """Pick + (scancode login | reflect-prompt) + enable one channel.""" - while True: - channel = _select_channel() - if channel is None or channel is _BACK: - return - if _channel_uses_interactive_login(channel): - _scancode_login(channel, non_interactive=non_interactive) - return - fields = _prompt_channel_fields(channel) - if fields is _BACK: - continue # backed out of the first field — re-pick a channel - _enable_channel(channel, fields) - console.print(_t(f" [green]✓ {channel} enabled.[/green]", f" [green]✓ {channel} 已启用。[/green]")) - return - - -def _manage_existing_channels() -> None: - """Edit/disable submenu for already-enabled channels.""" - questionary = _require_questionary() - from raven.cli._styles import RAVEN_STYLE - from raven.config.update_channels import disable_channel, set_channel_fields - - while True: - enabled = _enabled_channels() - if not enabled: - return - choices = [questionary.Choice(n, value=n) for n in enabled] - choices.append(questionary.Choice(_t("Back", "返回"), value=_BACK)) - target = questionary.select( - _t("Pick a channel to manage:", "选择要管理的渠道:"), - choices=choices, - style=RAVEN_STYLE, - qmark=_QMARK, - ).ask() - if target is None or target is _BACK: - return - action = questionary.select( - _t(f"What would you like to do with {target}?", f"对 {target} 想做什么?"), - choices=[ - questionary.Choice(_t("Edit config (re-enter fields)", "编辑配置(重填字段)"), value="edit"), - questionary.Choice(_t("Disable (keep credentials)", "停用(保留凭证)"), value="disable"), - questionary.Choice(_t("Back", "返回"), value=_BACK), - ], - style=RAVEN_STYLE, - qmark=_QMARK, - ).ask() - if action is None or action is _BACK: - continue - if action == "edit": - fields = _prompt_channel_fields(target) - if fields is _BACK: - continue # backed out — return to the manage menu - if fields: - set_channel_fields(target, fields) - console.print( + Panel( _t( - f" [green]✓ {target} config updated.[/green]", - f" [green]✓ {target} 配置已更新。[/green]", + "[bold yellow]⚠ Setup finished with warnings[/bold yellow]", + "[bold yellow]⚠ 配置完成,但有警告[/bold yellow]", ) - ) - elif action == "disable": - disable_channel(target) - console.print( - _t( - f" [green]✓ Disabled {target} (credentials kept; re-enable later " - f"with raven channels enable {target}).[/green]", - f" [green]✓ 已停用 {target}(凭证保留;之后用 raven channels enable {target} 重新启用)。[/green]", + + "\n\n" + + _t( + "[dim]These items didn't pass a connectivity test:[/dim] ", + "[dim]以下项目未通过连通测试:[/dim] ", ) - ) - - -def _step3_channel(*, channel: Optional[str], skip: bool, non_interactive: bool) -> object: - """Step 3 — optionally enable chat channel(s).""" - _step_header( - 3, - _t( - "(Optional) Connect a messaging app so you can chat with Raven there", - "(可选)接入即时通讯软件,直接在里面和 Raven 聊天", - ), - ) - - if skip: - console.print( - _t( - " [dim]Skipped via --skip-channel.[/dim]", - " [dim]已通过 --skip-channel 跳过。[/dim]", + + f"{', '.join(warnings)}\n" + + _t( + "[dim]Fix them before relying on the related features " + "(re-run [/dim][accent]raven onboard[/accent][dim] to reconfigure).[/dim]", + "[dim]在依赖相关功能前请先修复(重新运行 [/dim][accent]raven onboard[/accent][dim] 重新配置)。[/dim]", + ), + border_style="yellow", + padding=(1, 2), ) ) - return None - - if non_interactive: - if channel: - console.print( - f"[red]--channel {channel} given but non-interactive mode can't " - "prompt for credential fields.[/red]\n" - f"Run [accent]raven channels enable {channel} -- ...[/accent] " - "after onboard finishes." - ) - raise typer.Exit(2) + else: console.print( - _t( - " [dim]Skipped (non-interactive, --channel not given).[/dim]", - " [dim]已跳过(非交互且未提供 --channel)。[/dim]", - ) - ) - return None - - questionary = _require_questionary() - from raven.cli._styles import RAVEN_STYLE - - if channel: - if _channel_uses_interactive_login(channel): - _scancode_login(channel, non_interactive=non_interactive) - else: - fields = _prompt_channel_fields(channel) - if fields is _BACK: - console.print(_t(" [dim]Skipped.[/dim]", " [dim]已跳过。[/dim]")) - return None - _enable_channel(channel, fields) - console.print( + Panel( _t( - f" [green]✓ {channel} enabled.[/green]", - f" [green]✓ {channel} 已启用。[/green]", - ) + "[bold green]🎉 Setup complete![/bold green]", + "[bold green]🎉 配置完成![/bold green]", + ), + border_style="green", + padding=(0, 2), ) - return None - - while True: - enabled = _enabled_channels() - if not enabled: - action = questionary.select( - _t("Connect a chat channel?", "接入一个聊天渠道吗?"), - choices=[ - questionary.Choice(_t("Add a channel", "新增一个渠道"), value="add"), - questionary.Choice( - _t( - "Skip (add later with raven channels enable)", - "跳过(之后用 raven channels enable 添加)", - ), - value="skip", - ), - ], - style=RAVEN_STYLE, - qmark=_QMARK, - ).ask() - if action is None: - raise typer.Exit(1) - if action == "skip": - console.print(_t(" [dim]Skipped.[/dim]", " [dim]已跳过。[/dim]")) - return None - _add_one_channel(non_interactive=non_interactive) - continue - - action = questionary.select( - _t( - f"Chat channel already connected: {', '.join(enabled)}. What would you like to do?", - f"聊天渠道已接入:{', '.join(enabled)}。想做什么?", - ), - choices=[ - questionary.Choice(_t("Done, next step", "完成,下一步"), value="done"), - questionary.Choice(_t("Add a channel", "新增一个渠道"), value="add"), - questionary.Choice(_t("Edit / remove a channel", "编辑 / 移除渠道"), value="edit"), - ], - style=RAVEN_STYLE, - qmark=_QMARK, - ).ask() - if action is None: - raise typer.Exit(1) - if action == "done": - return None - if action == "add": - _add_one_channel(non_interactive=non_interactive) - elif action == "edit": - _manage_existing_channels() - - -# --------------------------------------------------------------------------- -# Step 4 — EverOS long-term memory -# --------------------------------------------------------------------------- - - -def _set_memory_backend(backend: Optional[str]) -> None: - """Set ``memory.backend`` (``"everos"`` / ``None``) via the ops layer.""" - from raven.config.update import set_memory_backend - - set_memory_backend(backend) - - -def _init_extension_block_defaults() -> None: - """Seed the memory / plugins / skillForge extension defaults via the ops layer.""" - from raven.config.update import init_extension_block_defaults - - init_extension_block_defaults() - - -def _everos_section(section: str) -> dict[str, Any]: - from raven.config.update_everos import everos_section - - return everos_section(section) - - -def _everos_role_configured(section: str) -> bool: - from raven.config.update_everos import everos_role_configured - - return everos_role_configured(section) - - -def _memory_enabled() -> bool: - """True iff EverOS memory is both selected AND usable on disk. - - "Usable" means the llm role is configured -- that is the whole requirement. - embedding is advised but optional: without it the adapter searches lexically - instead of semantically, which is weaker memory rather than none, and gating - on it here would tell a user who skipped it that memory is off (and skip the - import step along with it). - """ - data = _load_raw_config() - if (data.get("memory") or {}).get("backend") != "everos": - return False - return _everos_role_configured("llm") - - -# Providers whose main model can be reused as the EverOS memory LLM: they -# speak the OpenAI chat-completions protocol that EverOS's bare OpenAI client -# requires. OAuth providers (github_copilot / openai_codex) and non-OpenAI -# wire protocols (anthropic / gemini) are excluded. -_OPENAI_COMPATIBLE_PROVIDERS = {"openrouter", "openai", "deepseek", "custom"} - -# Fallback OpenAI-compatible base URLs for providers whose registry -# ``default_api_base`` is empty (they rely on the SDK's built-in default, -# which EverOS's bare client doesn't know). EverOS needs an explicit base_url. -_PROVIDER_BASE_URL_FALLBACK = { - "openai": "https://api.openai.com/v1", - "deepseek": "https://api.deepseek.com/v1", - "openrouter": "https://openrouter.ai/api/v1", -} - - -def _resolve_model_provider(model: str) -> Optional[str]: - """Best-effort: which configured provider does ``model`` belong to? - - Prefixed models (``openrouter/...`` / ``openai/gpt-4o``) read off the head. - A custom endpoint stores its model as a BARE id (e.g. ``qwen-max``) with no - prefix, so an unrecognized head falls back to ``"custom"`` when a custom - provider is actually configured with a key. Returns ``None`` when no match. - """ - from raven.providers.registry import split_model_id - - if not model: - return None - head, _ = split_model_id(model) - if head: - from raven.config.update_providers import provider_field_specs - - try: - provider_field_specs(head) - return head - except KeyError: - pass - # No usable prefix → could be a bare custom-endpoint model. - custom = (_load_raw_config().get("providers") or {}).get("custom") or {} - if custom.get("apiKey"): - return "custom" - # A bare id that still matches a known provider head (rare; e.g. a direct - # provider's bare default before prefixing) — accept the head if known. - return head if head in _OPENAI_COMPATIBLE_PROVIDERS else None - - -def _model_is_openai_compatible(model: Optional[str]) -> bool: - """Heuristic: can the main chat model's provider be reused for memory LLM? - - EverOS's memory LLM uses a bare OpenAI client, so the main model is - reusable only when its provider speaks the OpenAI chat protocol. Custom - endpoints are OpenAI-compatible by definition (the wizard only offers - ``custom`` for OpenAI-compatible endpoints). - """ - if not model: - return False - return _resolve_model_provider(model) in _OPENAI_COMPATIBLE_PROVIDERS - - -def _resolve_reuse_llm_creds(main_model: str) -> dict[str, Optional[str]]: - """Map a litellm-style main model to bare EverOS LLM settings. - - EverOS sends ``EVEROS_LLM__MODEL`` to ``base_url`` via a bare OpenAI - client, so: - - strip the provider's litellm prefix to the bare model id the upstream - endpoint expects (``openrouter/anthropic/claude-x`` → ``anthropic/claude-x``; - a custom endpoint's bare id is used as-is); - - resolve the provider's real ``base_url`` (configured ``apiBase`` → - registry ``default_api_base`` → a known fallback); - - carry the provider's stored api_key. - """ - from raven.providers.registry import find_by_name, normalize_provider_name, split_model_id - - provider = _resolve_model_provider(main_model) or split_model_id(main_model)[0] - spec = find_by_name(provider) - # Through the ops library, so a section still stored under the provider's - # pre-rename name is found -- a raw lookup by the resolved name is not. - from raven.config.update_providers import get_provider_config - - # No `if spec` gate: LiteLLM-only vendors have no spec of ours yet their - # section holds real credentials, and gating on the spec silently handed the - # probe an empty api_key while the main model was working fine. - try: - _resolved = get_provider_config(provider, redact_secrets=False) - except KeyError: - _resolved = {} - prov_cfg = {"apiKey": _resolved.get("api_key"), "apiBase": _resolved.get("api_base")} if _resolved else {} - - # Strip the routing prefix to the bare model id the upstream endpoint - # expects: litellm consumes it, the raw OpenAI client must not see it. Only - # a prefix naming this provider is stripped -- a custom endpoint stores a - # bare id already, and anything else is part of the vendor's own model id. - bare_model = main_model - head, rest = split_model_id(main_model) - known_prefixes = set(spec.route_names) if spec else {normalize_provider_name(provider)} - if spec: - known_prefixes.add(normalize_provider_name(spec.model_prefix)) - if head and head in known_prefixes: - bare_model = rest - - base_url = ( - prov_cfg.get("apiBase") - or (getattr(spec, "default_api_base", "") if spec else "") - or _PROVIDER_BASE_URL_FALLBACK.get(provider) - ) - return { - "model": bare_model, - "api_key": prov_cfg.get("apiKey"), - "base_url": base_url, - } - - -def _prompt_text(label: str, *, secret: bool = False, default: str = "", allow_back: bool = False) -> Any: - """Prompt for free text. With ``allow_back``, an empty submit returns - ``_BACK`` (and a hint is shown); otherwise returns the stripped string.""" - questionary = _require_questionary() - from raven.cli._styles import RAVEN_STYLE - - placeholder = _back_placeholder(allow_back) - if secret: - value = questionary.password(label, placeholder=placeholder, style=RAVEN_STYLE, qmark=_QMARK).ask() - else: - value = questionary.text(label, default=default, placeholder=placeholder, style=RAVEN_STYLE, qmark=_QMARK).ask() - if value is None: - raise typer.Exit(1) - value = value.strip() - if allow_back and value == "": - return _BACK - return value - - -def _probe_everos_chat(model: Optional[str], *, api_key: Optional[str], base_url: Optional[str]) -> tuple[bool, str]: - """Real capability probe for a memory-LLM endpoint: ``POST - {base_url}/chat/completions`` once and confirm a choice comes back. Unlike a - bare ``GET /models`` connectivity check, this exercises the picked model, so - an endpoint that lists models but doesn't serve the chosen id fails here - instead of reporting a false green. Provider-agnostic; never raises.""" - import httpx - - if not base_url: - return False, "no base_url configured" - url = base_url.rstrip("/") + ("/chat/completions" if "/v1" in base_url else "/v1/chat/completions") - headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} - body = {"model": model, "messages": [{"role": "user", "content": "ping"}], "max_tokens": 1} - try: - with httpx.Client(timeout=15) as client: - resp = client.post(url, headers=headers, json=body) - if resp.status_code != 200: - return False, f"HTTP {resp.status_code}: {resp.text[:200]}" - data = resp.json() - except (httpx.HTTPError, httpx.InvalidURL, ValueError) as exc: - return False, f"probe failed: {exc}" - choices = data.get("choices") if isinstance(data, dict) else None - if isinstance(choices, list) and choices and isinstance(choices[0], dict): - return True, "ok" - return False, "endpoint returned no completion" - - -def _verify_everos_llm( - label: str, - *, - model: Optional[str], - api_key: Optional[str], - base_url: Optional[str], - non_interactive: bool, - warnings: list[str], - continue_hint: Optional[tuple[str, str]] = None, -) -> bool: - """Probe the memory LLM with a real chat completion, offering retry/continue on failure.""" - console.print(_t(f" [dim]⏳ Verifying {label}…[/dim]", f" [dim]⏳ 正在验证 {label}…[/dim]")) - ok, detail = _probe_everos_chat(model, api_key=api_key, base_url=base_url) - if ok: - console.print(_t(f" [green]✓ {label} connected.[/green]", f" [green]✓ {label} 连接成功。[/green]")) - return True - console.print( - _t( - f" [yellow]✗ Couldn't verify {label}: {detail}[/yellow]", - f" [yellow]✗ 验证失败 {label}:{detail}[/yellow]", - ) - ) - if continue_hint: - cont_label = _t(f"Continue anyway ({continue_hint[0]})", f"仍然继续({continue_hint[1]})") - else: - cont_label = _t("Continue anyway", "仍然继续") - choice = _failure_choice( - [ - (_t("Re-enter", "重新填写"), "rekey"), - (cont_label, "continue"), - ], - non_interactive=non_interactive, - ) - if choice == "rekey": - return False - warnings.append(label) - return True - - -def _verify_rerank( - label: str, - *, - model: Optional[str], - api_key: Optional[str], - base_url: Optional[str], - rerank_provider: Optional[str], - non_interactive: bool, - warnings: list[str], - continue_hint: Optional[tuple[str, str]] = None, -) -> bool: - """Probe a rerank endpoint with a provider-specific request, offering retry/continue on failure.""" - console.print(_t(f" [dim]⏳ Verifying {label}…[/dim]", f" [dim]⏳ 正在验证 {label}…[/dim]")) - ok, detail = _probe_rerank(model, api_key=api_key, base_url=base_url, rerank_provider=rerank_provider) - if ok: - console.print(_t(f" [green]✓ {label} connected.[/green]", f" [green]✓ {label} 连接成功。[/green]")) - return True - console.print( - _t( - f" [yellow]✗ Couldn't verify {label}: {detail}[/yellow]", - f" [yellow]✗ 验证失败 {label}:{detail}[/yellow]", - ) - ) - if continue_hint: - cont_label = _t(f"Continue anyway ({continue_hint[0]})", f"仍然继续({continue_hint[1]})") - else: - cont_label = _t("Continue anyway", "仍然继续") - choice = _failure_choice( - [ - (_t("Re-enter", "重新填写"), "rekey"), - (cont_label, "continue"), - ], - non_interactive=non_interactive, - ) - if choice == "rekey": - return False - warnings.append(label) - return True - - -def _probe_rerank( - model: Optional[str], - *, - api_key: Optional[str], - base_url: Optional[str], - rerank_provider: Optional[str], -) -> tuple[bool, str]: - """Real capability probe for a rerank endpoint. Dispatches by provider - protocol (vllm / deepinfra / dashscope). Never raises.""" - import httpx - - if not base_url or not model: - return False, "no base_url or model configured" - headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} - headers["Content-Type"] = "application/json" - - try: - if rerank_provider == "deepinfra": - url = f"{base_url.rstrip('/')}/{model}" - body: dict = {"queries": ["ping"], "documents": ["pong"]} - elif rerank_provider == "dashscope": - url = f"{base_url.rstrip('/')}/api/v1/services/rerank/text-rerank/text-rerank" - body = { - "model": model, - "input": {"query": "ping", "documents": ["pong"]}, - "parameters": {"return_documents": False, "top_n": 1}, - } - else: # vllm / OpenAI-compat - url = f"{base_url.rstrip('/')}/rerank" - body = {"model": model, "query": "ping", "documents": ["pong"]} - - with httpx.Client(timeout=15) as client: - resp = client.post(url, json=body, headers=headers) - if resp.status_code != 200: - return False, f"HTTP {resp.status_code}: {resp.text[:200]}" - data = resp.json() - except (httpx.HTTPError, httpx.InvalidURL, ValueError) as exc: - return False, f"probe failed: {exc}" - - if rerank_provider == "deepinfra": - scores = data.get("scores") - if isinstance(scores, list) and scores: - return True, "ok" - return False, "endpoint returned no scores" - if rerank_provider == "dashscope": - output = data.get("output") - results = output.get("results") if isinstance(output, dict) else None - if isinstance(results, list) and results: - return True, "ok" - return False, "endpoint returned no results" - # vllm - results = data.get("results") - if isinstance(results, list) and results: - return True, "ok" - return False, "endpoint returned no results" - - -_REQUIRED_EMBEDDING_DIM = 1024 - - -def _probe_embedding_dim(url: str, headers: dict, model: str) -> int | str: - """Try embedding with ``dimensions=1024``; fall back to native dim. - - Returns the effective dimension (int) on success, or an error - description (str) on failure. - """ - import httpx - - def _try_embed(client: httpx.Client, body: dict) -> int | str: - try: - resp = client.post(url, json=body, headers=headers) - if resp.status_code != 200: - return f"HTTP {resp.status_code}" - items = resp.json().get("data", []) - if not items: - return "empty response" - first = items[0] - if not isinstance(first, dict): - return "unexpected response format" - return len(first.get("embedding", [])) - except (httpx.HTTPError, httpx.InvalidURL, ValueError) as exc: - return str(exc) - - with httpx.Client(timeout=15) as client: - result = _try_embed( - client, {"model": model, "input": ["dimension check"], "dimensions": _REQUIRED_EMBEDDING_DIM} - ) - if result == _REQUIRED_EMBEDDING_DIM: - return result - return _try_embed(client, {"model": model, "input": ["dimension check"]}) - - -def _verify_embedding_dim( - *, - model: Optional[str], - api_key: Optional[str], - base_url: Optional[str], - non_interactive: bool, -) -> bool: - """Send a test embedding request and verify the vector dimension is 1024. - - Returns True to proceed, False to re-prompt. - """ - if not base_url or not model: - return True - - url = base_url.rstrip("/") + "/embeddings" - headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} - - while True: - console.print( - _t( - " [dim]⏳ Checking embedding dimension…[/dim]", - " [dim]⏳ 正在检测 embedding 维度…[/dim]", - ) - ) - result = _probe_embedding_dim(url, headers, model) - - if result == _REQUIRED_EMBEDDING_DIM: - console.print( - _t( - f" [green]✓ Supports {result}-dim.[/green]", - f" [green]✓ 支持 {result} 维。[/green]", - ) - ) - return True - - if isinstance(result, int) and result < _REQUIRED_EMBEDDING_DIM: - console.print( - _t( - f" [red]✗ Dimension too small: model outputs {result}-dim, " - f"EverOS requires >= {_REQUIRED_EMBEDDING_DIM}. Please pick another model.[/red]", - f" [red]✗ 维度不足:模型输出 {result} 维," - f"EverOS 要求 >= {_REQUIRED_EMBEDDING_DIM} 维,请重新选择。[/red]", - ) - ) - return False - - if isinstance(result, int) and result > _REQUIRED_EMBEDDING_DIM: - console.print( - _t( - f" [red]✗ Model outputs {result}-dim and does not support the " - f"dimensions parameter to truncate to {_REQUIRED_EMBEDDING_DIM}. " - "Please pick another model.[/red]", - f" [red]✗ 模型输出 {result} 维,且不支持 dimensions 参数" - f"截断到 {_REQUIRED_EMBEDDING_DIM} 维,请重新选择。[/red]", - ) - ) - return False - - console.print( - _t( - f" [yellow]✗ Couldn't verify dimension: {result}[/yellow]", - f" [yellow]✗ 无法验证维度:{result}[/yellow]", - ) - ) - if non_interactive: - return False - choice = _failure_choice( - [ - (_t("Retry", "重试"), "retry"), - (_t("Re-enter", "重新选择"), "rekey"), - ], - non_interactive=False, - ) - if choice == "rekey": - return False - - -# Curated OpenAI-compatible endpoints for EverOS memory models. Picking one -# pre-fills its base_url (mirrors the main provider step); everything else is -# reachable via "reuse an existing endpoint" or "custom" (type a base_url). -# These are the providers' documented OpenAI-compatible /v1 endpoints. -_EVEROS_PROVIDERS: list[dict[str, Any]] = [ - { - "name": "openai", - "label": "OpenAI", - "label_zh": "OpenAI", - "base_url": "https://api.openai.com/v1", - "supports": {"llm", "embedding", "multimodal"}, - }, - { - "name": "openrouter", - "label": "OpenRouter", - "label_zh": "OpenRouter", - "base_url": "https://openrouter.ai/api/v1", - "supports": {"llm", "embedding", "rerank", "multimodal"}, - "rerank_provider": "vllm", - }, - { - "name": "deepseek", - "label": "DeepSeek", - "label_zh": "DeepSeek", - "base_url": "https://api.deepseek.com/v1", - "supports": {"llm"}, - }, - { - "name": "deepinfra", - "label": "DeepInfra", - "label_zh": "DeepInfra", - "base_url": "https://api.deepinfra.com/v1/openai", - "supports": {"llm", "embedding", "rerank"}, - "rerank_provider": "deepinfra", - "rerank_base_url": "https://api.deepinfra.com/v1/inference", - }, - { - "name": "siliconflow", - "label": "SiliconFlow", - "label_zh": "硅基流动 SiliconFlow", - "base_url": "https://api.siliconflow.cn/v1", - "supports": {"llm", "embedding", "rerank"}, - "rerank_provider": "vllm", - }, - { - "name": "dashscope", - "label": "DashScope (Alibaba)", - "label_zh": "阿里百炼 DashScope", - "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", - "supports": {"llm", "embedding", "rerank"}, - "rerank_provider": "dashscope", - "rerank_base_url": "https://dashscope.aliyuncs.com", - }, -] - - -def _match_provider_by_url(base_url: Optional[str]) -> Optional[str]: - """Reverse-lookup a curated provider name from its base_url.""" - if not base_url: - return None - normalized = base_url.rstrip("/") - for prov in _EVEROS_PROVIDERS: - if prov["base_url"].rstrip("/") == normalized: - return prov["name"] - return None - - -# Per-role config: menu/verify label, model-id example, whether optional, and -# whether to run a connectivity probe after configuring (rerank/multimodal use -# non-chat endpoints whose /models probe isn't a reliable health check). -_EVEROS_ROLES: dict[str, dict[str, Any]] = { - "llm": { - "label": ("Memory LLM", "记忆 LLM"), - "example": "gpt-4.1-mini", - "optional": False, - "verify": True, - "purpose": ( - "Reads each conversation to judge what matters and extract the key points.", - "从对话中判断信息边界、抽取要点。", - ), - # Worded as a floor rather than a default: the field is pre-filled with - # the user's own main model, because a recommended id is only reachable - # if their key carries it. This tells them how to judge their own. - "recommendation": ( - "Capability floor: [bold]gpt-4.1-mini[/bold] -- weaker models degrade extraction", - "能力下限参考 [bold]gpt-4.1-mini[/bold]:低于这个水平会明显影响提取质量", - ), - "continue_hint": ("memory extraction may fail", "记忆抽取可能失败"), - }, - "embedding": { - "label": ("Memory embedding", "记忆 embedding"), - "example": "Qwen/Qwen3-Embedding-4B", - # Optional in the sense that memory still functions without it: the - # adapter drops to KEYWORD search, which needs no vectors. Strongly - # advised all the same -- lexical recall misses a memory the moment the - # user phrases the question differently. - "optional": True, - "verify": True, - "purpose": ( - "Turns text into vectors so memories are found by meaning, not just keywords.", - "把文字转成向量,让记忆能按「意思」检索,而不只是按关键词。", - ), - "tag": ( - "[accent](optional, strongly advised)[/accent]", - "[accent](可选,强烈建议配置)[/accent]", - ), - "cost": ( - "Without it: rephrase a question and it may miss a memory you have;\n recall can only match keywords.", - "不配置:换个说法提问就可能找不到已有记忆,记忆召回时只能使用关键词检索。", - ), - "recommendation": ( - "Recommended: [bold]Qwen/Qwen3-Embedding-4B[/bold] -- must be [bold yellow]1024-dim[/bold yellow],\n" - " Chinese + English", - "推荐 [bold]Qwen/Qwen3-Embedding-4B[/bold],需 [bold yellow]1024 维[/bold yellow]且支持中英文的模型", - ), - "continue_hint": ("semantic recall will be unavailable", "语义召回将不可用"), - "skip_note": ( - " [yellow]! Skipped: recall will match keywords, not meaning.[/yellow]\n" - " [dim]Phrase a question differently and it may miss a memory you have.\n" - " Configure it later, then run `everos cascade backfill`.[/dim]", - " [yellow]⚠ 已跳过:召回将按关键词匹配,而非按语义。[/yellow]\n" - " [dim]换一种说法提问,就可能找不到已有的记忆。\n" - " 日后配好后运行 everos cascade backfill 可为已存记忆补上向量。[/dim]", - ), - }, - "rerank": { - "label": ("Memory rerank", "记忆 rerank"), - "example": "Qwen/Qwen3-Reranker-4B", - "optional": True, - "verify": True, - "purpose": ( - "Re-ranks what semantic search found so the best match comes first, at a small\n latency cost.", - "在语义召回一批候选后再精排一遍,让最相关的排在最前,会略增延迟。", - ), - "tag": ( - "[accent](optional, advised)[/accent]", - "[accent](可选,建议配置)[/accent]", - ), - "recommendation": ( - "Recommended: [bold]Qwen/Qwen3-Reranker-4B[/bold]", - "推荐 [bold]Qwen/Qwen3-Reranker-4B[/bold]", - ), - "continue_hint": ("rerank quality may degrade", "rerank 精度可能下降"), - "skip_note": ( - " [dim]Skipped rerank; memory retrieval still works.[/dim]", - " [dim]已跳过 rerank,记忆检索仍可用。[/dim]", - ), - }, - "multimodal": { - "label": ("Memory multimodal", "记忆多模态"), - "example": "google/gemini-3-flash-preview", - "optional": True, - "verify": True, - "purpose": ( - "Lets Raven understand and recall images / PDFs / audio as memory.", - "让 Raven 把图片 / PDF / 音频也作为记忆来理解和检索。", - ), - "cost": ( - "Without it: those files stay out of memory. Having such files is not the same\n" - " as needing them remembered -- configure it when you do.", - "不配置:这类文件不进入记忆;有这类文件并不等于需要,确有此需求时再配即可。", - ), - "recommendation": ( - "Recommended: [bold]google/gemini-3-flash-preview[/bold]", - "推荐 [bold]google/gemini-3-flash-preview[/bold]", - ), - "skip_note": ( - " [dim]Skipped; nothing else is affected -- configure it if you come to need\n multimodal memory.[/dim]", - " [dim]已跳过;其余功能不受影响,日后确有把多模态内容纳入记忆的需求时再配即可。[/dim]", - ), - }, -} - - -_EMBEDDING_MODEL_PATTERNS = ("embed", "bge", "e5-", "gte-") -_MULTIMODAL_MODEL_PATTERNS = ("vision", "4o", "gemini", "pixtral", "qwen-vl", "qwen2-vl", "qwen2.5-vl") - - -def _fetch_everos_models( - base_url: Optional[str], - api_key: Optional[str], - *, - section: str = "llm", - provider_name: Optional[str] = None, -) -> Optional[list[str]]: - """Fetch available model ids from a provider endpoint. Never raises. - - For ``section="embedding"``, delegates to per-provider logic because - each provider exposes embedding models differently. - """ - if not base_url: - return None - if section == "embedding": - return _fetch_embedding_models(base_url, api_key, provider_name) - if section == "rerank": - return _fetch_rerank_models(base_url, api_key, provider_name) - if section == "multimodal": - return _fetch_multimodal_models(base_url, api_key, provider_name) - return _fetch_openai_models(base_url, api_key) - - -def _fetch_openai_models( - base_url: str, - api_key: Optional[str], - *, - params: Optional[dict[str, str]] = None, -) -> Optional[list[str]]: - """``GET {base_url}/models`` with OpenAI-style response parsing.""" - import httpx - - url = base_url.rstrip("/") + "/models" - headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} - try: - with httpx.Client(timeout=10) as client: - resp = client.get(url, headers=headers, params=params) - if resp.status_code != 200: - return None - data = resp.json() - except (httpx.HTTPError, httpx.InvalidURL, ValueError): - return None - items = data.get("data") if isinstance(data, dict) else None - if not isinstance(items, list): - return None - ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")] - return sorted(ids) or None - - -def _fetch_deepinfra_models( - api_key: Optional[str], - reported_type: str, - *, - name_contains: Optional[str] = None, -) -> Optional[list[str]]: - """Fetch DeepInfra models filtered by ``reported_type`` and optional name substring.""" - import httpx - - headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} - try: - with httpx.Client(timeout=10) as client: - resp = client.get("https://api.deepinfra.com/models/list", headers=headers) - if resp.status_code != 200: - return None - data = resp.json() - except (httpx.HTTPError, ValueError): - return None - items = data if isinstance(data, list) else [] - ids = [ - m.get("model_name") - for m in items - if isinstance(m, dict) - and m.get("reported_type") == reported_type - and m.get("model_name") - and (name_contains is None or name_contains in m.get("model_name", "")) - ] - return sorted(ids) or None - - -def _fetch_embedding_models( - base_url: str, - api_key: Optional[str], - provider_name: Optional[str], -) -> Optional[list[str]]: - """Provider-specific embedding model listing.""" - if provider_name == "openrouter": - return _fetch_openai_models(base_url.rstrip("/") + "/embeddings", api_key) - - if provider_name == "siliconflow": - return _fetch_openai_models(base_url, api_key, params={"type": "text", "sub_type": "embedding"}) - - if provider_name == "deepinfra": - return _fetch_deepinfra_models(api_key, "embeddings") - - # OpenAI, DashScope, custom — GET /models + name-based filter. - ids = _fetch_openai_models(base_url, api_key) - if ids is None: - return None - filtered = [i for i in ids if any(p in i.lower() for p in _EMBEDDING_MODEL_PATTERNS)] - return filtered or None - - -def _fetch_rerank_models( - base_url: str, - api_key: Optional[str], - provider_name: Optional[str], -) -> Optional[list[str]]: - """Provider-specific rerank model listing.""" - if provider_name == "deepinfra": - # The deepinfra provider hardcodes a Qwen3-Reranker chat template, - # so only Qwen3-Reranker models are compatible. - return _fetch_deepinfra_models(api_key, "reranker", name_contains="Qwen3-Reranker") - - if provider_name == "siliconflow": - return _fetch_openai_models(base_url, api_key, params={"sub_type": "reranker"}) - - if provider_name == "dashscope": - return ["gte-rerank-v2"] - - if provider_name == "openrouter": - return _fetch_openai_models(base_url, api_key, params={"output_modalities": "rerank"}) - - # vllm / custom — no standard rerank listing. - return None - - -def _fetch_multimodal_models( - base_url: str, - api_key: Optional[str], - provider_name: Optional[str], -) -> Optional[list[str]]: - """Provider-specific multimodal (vision) model listing.""" - if provider_name == "openrouter": - return _fetch_openai_models(base_url, api_key, params={"input_modalities": "image"}) - - # OpenAI, custom — GET /models + name-based filter. - ids = _fetch_openai_models(base_url, api_key) - if ids is None: - return None - filtered = [i for i in ids if any(p in i.lower() for p in _MULTIMODAL_MODEL_PATTERNS)] - return filtered or None - - -def _match_everos_default(example: str, models: list[str]) -> str: - """Find the best match for ``example`` in the fetched model list. - - The example (e.g. ``gpt-4.1-mini``) is a bare model name, while - ``models`` may carry provider prefixes (``openai/gpt-4.1-mini``). - Returns the first model whose id ends with ``/example`` or equals - ``example`` exactly; falls back to the bare example string so the - autocomplete input is pre-filled even if no exact match exists. - """ - lower = example.lower() - suffix = f"/{lower}" - for mid in models: - if mid.lower() == lower or mid.lower().endswith(suffix): - return mid - return example - - -def _preferred_memory_model(section: str, main_model: Optional[str], chosen_provider: Optional[str]) -> Optional[str]: - """The main chat model, when it is a sensible pre-fill for this role. - - Only the llm role -- an embedding / rerank / multimodal endpoint does not - serve a chat model. Only when the picked provider is the main model's own: no - other provider carries that id, and pre-filling one it cannot serve turns - Enter into a verification failure. A custom endpoint has no resolved provider - and is left alone for the same reason. - """ - if section != "llm" or not main_model or chosen_provider is None: - return None - if chosen_provider != _resolve_model_provider(main_model): - return None - return _resolve_reuse_llm_creds(main_model).get("model") - - -def _everos_pick_model( - *, - base_url: Optional[str], - api_key: Optional[str], - example: str, - allow_back: bool, - section: str = "llm", - provider_name: Optional[str] = None, - recommendation: Optional[tuple[str, str]] = None, - preferred: Optional[str] = None, -) -> Any: - """Pick a model id for an EverOS endpoint: fetch ``/models`` for a - fuzzy-searchable list, else fall back to free text. Empty submit = back. - - ``preferred`` pre-fills a model the user is already known to have access to - -- their main chat model. It wins over ``example`` because a recommended - model is only a recommendation if the user's key can reach it, and many keys - cannot; ``example`` then reads as the capability floor rather than the - default (see ``recommendation``). - """ - questionary = _require_questionary() - from raven.cli._styles import RAVEN_STYLE - - console.print(_t(" [dim]⏳ Loading models…[/dim]", " [dim]⏳ 正在拉取模型列表…[/dim]")) - models = _fetch_everos_models(base_url, api_key, section=section, provider_name=provider_name) - if preferred: - console.print( - _t( - f" [dim]Pre-filled with your main model [bold]{preferred}[/bold] -- press Enter to accept.[/dim]", - f" [dim]已填入你的主模型 [bold]{preferred}[/bold],直接回车即可。[/dim]", - ) - ) - if recommendation: - console.print(f" [dim]{_t(*recommendation)}[/dim]") - if models: - default_model = preferred or _match_everos_default(example, models) - question = questionary.autocomplete( - _t( - f"Model ({len(models)} available — type to filter):", - f"模型(共 {len(models)} 个 — 输入可筛选):", - ), - choices=models, - default=default_model, - ignore_case=True, - match_middle=True, - placeholder=_back_placeholder(allow_back), - style=RAVEN_STYLE, - qmark=_QMARK, - ) - # Trigger the completion popup immediately so the user sees - # all available models without typing first. - app = question.application - - def _show_completions() -> None: - buf = app.current_buffer - buf.start_completion() - - app.pre_run_callables.append(_show_completions) - chosen = question.ask() - else: - console.print( - _t( - " [dim]Couldn't list models from this endpoint — type the id manually.[/dim]", - " [dim]该端点拉不到模型列表 — 请手动输入模型 id。[/dim]", - ) - ) - chosen = questionary.text( - _t(f"Model id (e.g. {example}):", f"模型 id(如 {example}):"), - default=preferred or "", - placeholder=_back_placeholder(allow_back), - style=RAVEN_STYLE, - qmark=_QMARK, - ).ask() - if chosen is None: - raise typer.Exit(1) - chosen = chosen.strip() - if allow_back and chosen == "": - return _BACK - if not chosen: - raise typer.Exit(1) - return chosen - - -def _everos_pick_creds_and_model( - *, - section: str, - example: str, - main_model: Optional[str], - non_interactive: bool, - recommendation: Optional[tuple[str, str]] = None, -) -> Any: - """Mirror the main provider step for one EverOS model: pick a source - (curated provider / custom) → API key → model. Returns a dict with - ``model`` / ``api_key`` / ``base_url`` (plus ``provider`` for rerank), or - ``_BACK`` when the user backs out of the source picker. Empty submit on any - field rewinds one step.""" - questionary = _require_questionary() - from raven.cli._styles import RAVEN_STYLE - - llm_section = _everos_section("llm") - - # For the LLM role, default to the main chat model's provider. - # For other roles (embedding/rerank/multimodal), default to whichever - # provider the LLM step just configured — the user likely has the - # same API key and only needs to pick a different model. - if section == "llm": - default_provider = _resolve_model_provider(main_model or "") - reuse_source = "main" - else: - default_provider = _match_provider_by_url(llm_section.get("base_url")) - reuse_source = "llm" - - while True: # source picker — a field-level back rewinds here - choices: list[Any] = [] - default_choice = None - for prov in _EVEROS_PROVIDERS: - if section not in prov.get("supports", set()): - continue - is_default = default_provider is not None and prov["name"] == default_provider - if is_default: - if reuse_source == "main": - label = _t( - f"{prov['label']} (main model provider, reuse Key)", - f"{prov['label_zh']}(主模型服务商,复用 Key)", - ) - else: - label = _t( - f"{prov['label']} (memory LLM provider, reuse Key)", - f"{prov['label_zh']}(记忆 LLM 服务商,复用 Key)", - ) - else: - label = _t(prov["label"], prov["label_zh"]) - choice = questionary.Choice(label, value=("provider", prov)) - choices.append(choice) - if is_default: - default_choice = choice.value - choices.append( - questionary.Choice( - _t("Other (custom OpenAI-compatible endpoint)", "其他(自定义 OpenAI 兼容端点)"), - value=("custom",), - ) - ) - choices.append(questionary.Separator()) - choices.append(questionary.Choice(_t("Back", "返回"), value=_BACK)) - - src = questionary.select( - _t("Pick a provider (or reuse / custom):", "选择服务商(或复用 / 自定义):"), - choices=choices, - default=default_choice, - style=RAVEN_STYLE, - qmark=_QMARK, - ).ask() - if src is None: - raise typer.Exit(1) - if src is _BACK: - return _BACK - kind = src[0] - - # Resolve (api_key, base_url) from the chosen source. - chosen_provider: Optional[str] = None - if kind == "provider": - chosen_provider = src[1]["name"] - base_url = src[1]["base_url"] - prefilled_key: Optional[str] = None - if default_provider == src[1]["name"]: - if reuse_source == "main": - prefilled_key = _resolve_reuse_llm_creds(main_model or "").get("api_key") - else: - prefilled_key = llm_section.get("api_key") - if prefilled_key: - if reuse_source == "main": - console.print( - _t( - " [dim]API key reused from main chat model.[/dim]", - " [dim]已复用主对话模型的 API Key。[/dim]", - ) - ) - else: - console.print( - _t( - " [dim]API key reused from memory LLM.[/dim]", - " [dim]已复用记忆 LLM 的 API Key。[/dim]", - ) - ) - api_key = prefilled_key - else: - api_key = _prompt_api_key(src[1]["name"], allow_back=True) - if api_key is _BACK: - continue - else: # custom - base_url = _prompt_text(_t("Base URL (must include /v1):", "Base URL(需包含 /v1):"), allow_back=True) - if base_url is _BACK: - continue - api_key = _prompt_text(_t("API key (hidden):", "API Key(隐藏输入):"), secret=True, allow_back=True) - if api_key is _BACK: - continue - - # Guard against a source that resolved to an empty key / endpoint — - # set_everos_section drops None values, which would otherwise persist a - # section with a model but no usable endpoint. - if not (api_key and base_url): - console.print( - _t( - " [yellow]✗ Missing API key or Base URL for this source — pick another.[/yellow]", - " [yellow]✗ 该来源缺少 API Key 或 Base URL — 请换一个。[/yellow]", - ) - ) - continue - - # rerank: resolve service type + override base_url when needed. - rerank_provider: Optional[str] = None - if section == "rerank": - chosen_prov_dict = src[1] if kind == "provider" else None - if chosen_prov_dict and chosen_prov_dict.get("rerank_provider"): - rerank_provider = chosen_prov_dict["rerank_provider"] - if chosen_prov_dict.get("rerank_base_url"): - base_url = chosen_prov_dict["rerank_base_url"] - else: - rerank_provider = questionary.select( - _t("Rerank service type:", "rerank 服务类型:"), - choices=[ - questionary.Choice("deepinfra", value="deepinfra"), - questionary.Choice("vllm", value="vllm"), - questionary.Choice("dashscope", value="dashscope"), - questionary.Choice(_t("Back", "返回"), value=_BACK), - ], - style=RAVEN_STYLE, - qmark=_QMARK, - ).ask() - if rerank_provider is None: - raise typer.Exit(1) - if rerank_provider is _BACK: - continue - - model = _everos_pick_model( - base_url=base_url, - api_key=api_key, - example=example, - allow_back=True, - section=section, - provider_name=chosen_provider, - recommendation=recommendation, - preferred=_preferred_memory_model(section, main_model, chosen_provider), - ) - if model is _BACK: - continue - - result: dict[str, Any] = {"model": model, "api_key": api_key, "base_url": base_url} - if rerank_provider: - result["provider"] = rerank_provider - return result - - -def _config_everos_role( - *, section: str, main_model: Optional[str], non_interactive: bool, warnings: list[str], skip_test: bool = False -) -> Any: - """Configure one EverOS memory role (llm / embedding / rerank / multimodal) - with the unified provider→key→model flow, reuse shortcuts, and a back loop. - - Returns ``None`` normally; returns ``_ABORT_EVEROS`` when the user gives up a - required role (the caller then disables EverOS, leaving no long-term memory).""" - questionary = _require_questionary() - from raven.cli._styles import RAVEN_STYLE - from raven.config.update_everos import clear_everos_section, set_everos_section - - role = _EVEROS_ROLES[section] - label_en, label_zh = role["label"] - purpose_en, purpose_zh = role["purpose"] - optional = role["optional"] - verify_label = _t(label_en, label_zh) - - # Tell the user what this model is for, and what skipping it costs, before - # asking them to configure it. Header sits on the 2-space info column (bold - # accent); purpose and cost nest under it, matching the layout used - # everywhere else. - # - # The cost line is dim rather than a warning colour on purpose: this is - # pre-decision information, and colouring it would cry wolf before the user - # has chosen anything. The warning comes after, from ``skip_note``. - # - # Roles that want to be configured say so in their own ``tag`` -- calling all - # three merely "optional" flattens the difference between losing semantic - # recall entirely and losing a little ranking accuracy. - tag_markup = _t(*role["tag"]) if role.get("tag") else _t("[dim](optional)[/dim]", "[dim](可选)[/dim]") - lines = [f" [bold][accent]{_t(label_en, label_zh)}[/accent][/bold]" + (f" {tag_markup}" if optional else "")] - lines.append(f" [dim]{_t(purpose_en, purpose_zh)}[/dim]") - if role.get("cost"): - lines.append(f" [dim]{_t(*role['cost'])}[/dim]") - console.print() - # highlight=False so Rich's default highlighter doesn't tint the dim prose - # (parens/numbers/words) and make an informational hint read like an error. - console.print("\n".join(lines), highlight=False) - - while True: # role-menu loop — a back-out of the source picker returns here - current = _everos_section(section).get("model") if _everos_role_configured(section) else None - if current: - choices = [ - questionary.Choice(_t(f"Keep current: {current}", f"沿用当前:{current}"), value="keep"), - questionary.Choice(_t("Reconfigure", "重新配置"), value="redo"), - ] - if optional: - choices.append(questionary.Choice(_t("Skip", "跳过"), value="off")) - action = questionary.select( - _t("Already configured — what now?", "已配置,怎么处理?"), - choices=choices, - style=RAVEN_STYLE, - qmark=_QMARK, - ).ask() - if action is None: - raise typer.Exit(1) - if action == "keep": - return - if action == "off": - clear_everos_section(section) - console.print(_t(f" [dim]{label_en} skipped.[/dim]", f" [dim]已跳过 {label_zh}。[/dim]")) - return - elif optional: - action = questionary.select( - _t("Configure it?", "要配置吗?"), - choices=[ - questionary.Choice(_t("Configure", "配置"), value="redo"), - questionary.Choice(_t("Skip", "跳过"), value="skip"), - ], - style=RAVEN_STYLE, - qmark=_QMARK, - ).ask() - if action is None: - raise typer.Exit(1) - if action == "skip": - # Printed verbatim rather than wrapped in [dim]: skipping rerank - # costs ordering, skipping embedding costs semantic recall - # entirely, and one of those deserves to be seen. - note_en, note_zh = role.get( - "skip_note", (f" [dim]Skipped {label_en}.[/dim]", f" [dim]已跳过 {label_zh}。[/dim]") - ) - console.print(_t(note_en, note_zh), highlight=False) - return - # A required role with nothing configured falls straight into the picker. - - result = _everos_pick_creds_and_model( - section=section, - example=role["example"], - main_model=main_model, - non_interactive=non_interactive, - recommendation=role.get("recommendation"), - ) - if result is _BACK: - if optional or _everos_role_configured(section): - # Optional roles offer Skip; a required role already configured - # falls back to its keep/reconfigure menu. Either way, re-show - # the role menu rather than forcing the give-up exit. - continue - # A required role with nothing configured has no Skip, so backing out - # of the picker would loop forever. Offer a bounded exit -- keep - # trying, or leave without long-term memory. Stated in full and in - # colour: this is the only place the wizard can lose memory - # altogether, and "no cross-session memory" is a consequence a user - # should not discover weeks later by noticing the agent forgets - # everything. - console.print() - console.print( - _t( - f" [yellow]⚠ {label_en} is required for long-term memory.[/yellow]\n" - " [dim]Without it Raven has no memory across sessions: every conversation starts\n" - " from nothing, with no recollection of your preferences or of what was done before.[/dim]", - f" [yellow]⚠ {label_zh} 是长期记忆的必需项。[/yellow]\n" - " [dim]放弃后 Raven 没有任何跨会话记忆:每次对话都从零开始,不记得你的偏好,\n" - " 也不记得之前做过什么。[/dim]", - ), - highlight=False, - ) - action = questionary.select( - _t("What would you like to do?", "想做什么?"), - choices=[ - questionary.Choice(_t("Pick a provider / model", "选择服务商 / 模型"), value="retry"), - questionary.Choice( - _t("Give up (no long-term memory)", "放弃(不启用长期记忆)"), - value="abort", - ), - ], - style=RAVEN_STYLE, - qmark=_QMARK, - ).ask() - if action is None: - raise typer.Exit(1) - if action == "retry": - continue - return _ABORT_EVEROS - - if role["verify"] and skip_test: - console.print( - _t( - f" [dim]Skipping the {verify_label} test call (--skip-test).[/dim]", - f" [dim]已跳过 {verify_label} 的测试调用(--skip-test)。[/dim]", - ) - ) - ok = True - elif section == "llm": - ok = _verify_everos_llm( - verify_label, - model=result["model"], - api_key=result["api_key"], - base_url=result["base_url"], - non_interactive=non_interactive, - warnings=warnings, - continue_hint=role.get("continue_hint"), - ) - elif section == "embedding": - ok = _verify_embedding_dim( - model=result["model"], - api_key=result["api_key"], - base_url=result["base_url"], - non_interactive=non_interactive, - ) - elif section == "rerank": - ok = _verify_rerank( - verify_label, - model=result["model"], - api_key=result["api_key"], - base_url=result["base_url"], - rerank_provider=result.get("provider"), - non_interactive=non_interactive, - warnings=warnings, - continue_hint=role.get("continue_hint"), - ) - elif section == "multimodal": - ok = _verify_everos_llm( - verify_label, - model=result["model"], - api_key=result["api_key"], - base_url=result["base_url"], - non_interactive=non_interactive, - warnings=warnings, - continue_hint=role.get("continue_hint"), - ) - else: - ok = True - if not ok: - continue - - set_everos_section(section, result) - console.print( - _t( - f" [green]✓ {label_en} configured.[/green]", - f" [green]✓ 已配置 {label_zh}。[/green]", - ) - ) - return - - -def _step4_memory( - *, skip: bool, non_interactive: bool, main_model: Optional[str], warnings: list[str], skip_test: bool = False -) -> object: - """Step 4 -- EverOS long-term memory (model sub-screens). - - The bootstrap seeds ``memory.backend="everos"`` (schema default) and everos - is the only memory backend, so this step does not ask whether to enable it: - it either confirms the seed by configuring the llm role, or resolves it back - to ``None`` on skip / non-interactive / give-up. ``None`` means no long-term - memory at all, not a fallback to something simpler. - - ``_memory_enabled`` gates on the llm role alone, so a fresh modelless seed - reads as "not configured yet" and the keep/reconfigure menu only appears once - that model is actually on disk. embedding and rerank are offered here but - never gate: skipping them costs recall quality, not memory itself. - """ - _step_header(4, _t("EverOS long-term memory", "EverOS 长期记忆")) - - import sys - - if sys.platform == "win32": - console.print( - _t( - " [yellow]⚠ EverOS memory engine does not support native Windows.[/yellow]\n" - " [dim]Run Raven inside WSL for full memory support.[/dim]\n" - " [dim]Skipping memory configuration.[/dim]", - " [yellow]⚠ EverOS 记忆引擎暂不支持 Windows 原生环境。[/yellow]\n" - " [dim]在 WSL 中运行 Raven 可获得完整记忆支持。[/dim]\n" - " [dim]已跳过记忆配置。[/dim]", - ) - ) - _set_memory_backend(None) - return None - - if skip or non_interactive: - # Never configured the required models here → disable backend-driven - # memory so runtime doesn't activate EverOS without an llm/embedding. - # (``_memory_enabled`` already gates on both required models, so an - # already-enabled+configured setup is preserved.) - if not _memory_enabled(): - _set_memory_backend(None) - console.print( - _t( - " [dim]Long-term memory stays off.[/dim]", - " [dim]长期记忆保持关闭。[/dim]", - ) - ) - return None - - questionary = _require_questionary() - from raven.cli._styles import RAVEN_STYLE - - if _memory_enabled(): - action = questionary.select( - _t( - "EverOS long-term memory is already enabled. What would you like to do?", - "EverOS 长期记忆已启用。想做什么?", - ), - choices=[ - questionary.Choice(_t("Keep it enabled", "保持启用"), value="keep"), - questionary.Choice(_t("Reconfigure", "重新配置"), value="redo"), - ], - style=RAVEN_STYLE, - qmark=_QMARK, - ).ask() - if action is None: - raise typer.Exit(1) - if action == "keep": - return None # backend already "everos" + models on disk; leave as-is - else: - # No enable/decline question: everos is the only memory backend, so the - # step goes straight into configuring it. Leaving is still possible -- - # backing out of the required roles reaches the give-up prompt, which - # spells out what is lost. - # Wrapped by hand: rich re-wraps at the terminal width and drops the - # two-space indent on continuation lines, which reads as a stray - # left-flush sentence under an indented block. - console.print( - _t( - " [dim]Raven's long-term memory comes from EverOS. What it can do grows with\n" - " what you configure:[/dim]\n" - " [dim] memory LLM only conversations become memories; recall matches keywords[/dim]\n" - " [dim] + memory embedding recall matches meaning, not wording (strongly advised)[/dim]\n" - " [dim] + memory rerank recall ordering gets sharper[/dim]", - " [dim]Raven 拥有 EverOS 提供的强大长期记忆能力,能力随配置递进:[/dim]\n" - " [dim] 仅记忆 LLM 对话会被提炼成记忆存下来,召回按关键词匹配[/dim]\n" - " [dim] + 记忆 embedding 召回按语义匹配,换个问法也能找到(强烈建议配)[/dim]\n" - " [dim] + 记忆 rerank 召回结果排序更准[/dim]", - ), - highlight=False, - ) - - # Ensure the EverOS home directory has its config templates (everos.toml - # + ome.toml) BEFORE writing model sections — set_everos_section merges - # into the template so default sections (memory/sqlite/lancedb/api) are - # preserved. Also creates ome.toml which the runtime requires. - from raven.config.update_everos import configure_everos_env, ensure_everos_home - - configure_everos_env() - ensure_everos_home() - - # Configure required models FIRST, then flip the backend on — so a Ctrl+C - # mid-configuration leaves backend at its prior (disabled) value rather - # than an enabled-but-modelless state. - for _role in ("llm", "embedding", "rerank", "multimodal"): - # Each role prints one leading blank before its own header, so no extra - # separator here — avoids the double blank line between roles. - outcome = _config_everos_role( - section=_role, - main_model=main_model, - non_interactive=non_interactive, - warnings=warnings, - skip_test=skip_test, - ) - if outcome is _ABORT_EVEROS: - _set_memory_backend(None) - console.print( - _t( - " [yellow]⚠ Gave up long-term memory: Raven will not remember anything " - "between sessions.[/yellow]\n" - " [dim]Run `raven onboard` again whenever you want to configure it.[/dim]", - " [yellow]⚠ 已放弃长期记忆,Raven 不会记住任何跨会话内容。[/yellow]\n" - " [dim]随时可以重新运行 raven onboard 配置。[/dim]", - ) - ) - return None - - # Verify EverOS server is reachable (auto-starts if needed) - import asyncio - - from raven.plugin.memory.everos._server import ensure_everos_server - - console.print() - console.print( - _t( - " [dim]Starting EverOS service...[/dim]", - " [dim]正在启动 EverOS 服务...[/dim]", - ) - ) - try: - asyncio.run(ensure_everos_server()) - console.print( - _t( - " [green]✓ EverOS service is running.[/green]", - " [green]✓ EverOS 服务已启动。[/green]", - ) - ) - except RuntimeError as exc: - console.print( - _t( - f" [red]✗ EverOS service failed to start: {exc}[/red]\n" - " [dim]Check: everos installed? Port 18791 free? " - "See ~/.raven/logs/everos-server.log[/dim]", - f" [red]✗ EverOS 服务启动失败:{exc}[/red]\n" - " [dim]请检查:everos 是否安装?端口 18791 是否被占用?" - "查看 ~/.raven/logs/everos-server.log[/dim]", - ) - ) - retry = questionary.select( - _t("What to do?", "怎么办?"), - choices=[ - questionary.Choice(_t("Retry", "重试"), value="retry"), - questionary.Choice(_t("Skip (memory disabled)", "跳过(记忆禁用)"), value="skip"), - ], - style=RAVEN_STYLE, - qmark=_QMARK, - ).ask() - if retry == "retry": - # Recurse once — the loop in _step4_memory handles further retries - try: - asyncio.run(ensure_everos_server()) - console.print( - _t( - " [green]✓ EverOS service is running.[/green]", - " [green]✓ EverOS 服务已启动。[/green]", - ) - ) - except RuntimeError: - console.print( - _t( - " [red]✗ Still failed. Disabling memory.[/red]", - " [red]✗ 仍然失败。禁用记忆功能。[/red]", - ) - ) - _set_memory_backend(None) - return None - else: - _set_memory_backend(None) - return None - _report_everos_capabilities() - _set_memory_backend("everos") - return None - - -def _report_everos_capabilities() -> None: - """Say what the running server can actually do, not just that it answers. - - ``ensure_everos_server`` proves the process is up and nothing more. Since - everos 1.2.1 a server whose embedding provider failed to build still answers - 200 and degrades to keyword-only search, so stopping at "running" would - print a tick over an install that cannot recall anything. The roles were - each verified against their provider earlier in this step; what is new here - is whether everos itself could build them from what got written to - ``everos.toml``. - - Silent on a server too old to report capabilities -- reading that silence as - "unavailable" would condemn a working install. - """ - from raven.config.raven import load_raven_config - from raven.plugin.memory.everos._health import ( - DEGRADING_SECTIONS, - REQUIRED_SECTIONS, - configured_base_url, - probe_capabilities, - ) - - # The configured address, not the default: probing the wrong port reports on - # a server nobody is using, and reads as "not running". - report = probe_capabilities(configured_base_url(load_raven_config())) - if not report.reports_capabilities: - return - configured = [s for s in (*REQUIRED_SECTIONS, *DEGRADING_SECTIONS) if _everos_role_configured(s)] - broken = [s for s in configured if report.available(s) is False] - if not broken: - names = " and ".join(configured) - console.print( - _t( - f" [green]✓ {names} {'is' if len(configured) == 1 else 'are'} available.[/green]", - f" [green]✓ {names} 均可用。[/green]", - ) - ) - return - names = " and ".join(broken) - console.print( - _t( - f" [yellow]⚠ {names} is configured but EverOS could not build it.[/yellow]\n" - " [dim]Memory runs degraded until this is fixed.[/dim]\n" - f" [dim]Check: {_everos_server_log_hint()}[/dim]", - f" [yellow]⚠ {names} 已配置,但 EverOS 未能构建成功。[/yellow]\n" - " [dim]在此修复前,记忆能力将处于降级状态。[/dim]\n" - f" [dim]请查看:{_everos_server_log_hint()}[/dim]", - ) - ) - - -def _everos_server_log_hint() -> str: - from raven.plugin.memory.everos._server import server_log_path - - return str(server_log_path()) - - -# --------------------------------------------------------------------------- -# Final summary -# --------------------------------------------------------------------------- - - -def _print_next_steps(*, warnings: list[str]) -> None: - from rich.table import Table - - console.print() - if warnings: - console.print( - Panel( - _t( - "[bold yellow]⚠ Setup finished with warnings[/bold yellow]", - "[bold yellow]⚠ 配置完成,但有警告[/bold yellow]", - ) - + "\n\n" - + _t( - "[dim]These items didn't pass a connectivity test:[/dim] ", - "[dim]以下项目未通过连通测试:[/dim] ", - ) - + f"{', '.join(warnings)}\n" - + _t( - "[dim]Fix them before relying on the related features " - "(re-run [/dim][accent]raven onboard[/accent][dim] to reconfigure).[/dim]", - "[dim]在依赖相关功能前请先修复(重新运行 [/dim][accent]raven onboard[/accent][dim] 重新配置)。[/dim]", - ), - border_style="yellow", - padding=(1, 2), - ) - ) - else: - console.print( - Panel( - _t( - "[bold green]🎉 Setup complete![/bold green]", - "[bold green]🎉 配置完成![/bold green]", - ), - border_style="green", - padding=(0, 2), - ) - ) + ) # Recap what was configured (read from disk) so the user has closure. provs = ", ".join(_provider_label(n).split(" (")[0] for n in _configured_providers()) or "—" @@ -4241,8 +2178,12 @@ def _print_next_steps(*, warnings: list[str]) -> None: if _current_sandbox_backend() == "none" else _t("Sandbox (boxlite)", "沙箱(boxlite)") ) - chans = ", ".join(_enabled_channels()) or _t("none", "无") - mem = _t("EverOS", "EverOS") if _memory_enabled() else _t("[yellow]off[/yellow]", "[yellow]未启用[/yellow]") + chans = ", ".join(onboard_channels._enabled_channels()) or _t("none", "无") + mem = ( + _t("EverOS", "EverOS") + if onboard_everos._memory_enabled() + else _t("[yellow]off[/yellow]", "[yellow]未启用[/yellow]") + ) recap = Table(show_header=False, box=None, padding=(0, 2, 0, 0)) recap.add_column(style="dim", no_wrap=True) recap.add_column() @@ -4306,7 +2247,7 @@ def _tier_choice_label(name: str, width: int, contents: str, cost: str) -> str: return f"{name}{' ' * (width - _cell_len(name))} · {contents} · {cost}" -def _step5_import(*, skip: bool, non_interactive: bool) -> object: +def _step6_import(*, skip: bool, non_interactive: bool) -> object: """Step 5 — optionally import conversation history from other AI tools.""" _step_header(6, _t("Import history from other AI tools", "从其他 AI 工具导入历史")) @@ -4328,7 +2269,7 @@ def _step5_import(*, skip: bool, non_interactive: bool) -> object: ) return None - if not _memory_enabled(): + if not onboard_everos._memory_enabled(): console.print( _t( " [dim]Skipped — EverOS long-term memory is required for history import.[/dim]", @@ -4912,8 +2853,8 @@ def _run_wizard_body( skip_test=skip_test, ), lambda: _step2_sandbox(skip=skip_sandbox, non_interactive=non_interactive), - lambda: _step3_channel(channel=channel, skip=skip_channel, non_interactive=non_interactive), - lambda: _step4_memory( + lambda: onboard_channels._step3_channel(channel=channel, skip=skip_channel, non_interactive=non_interactive), + lambda: onboard_everos._step4_memory( skip=skip_memory, non_interactive=non_interactive, main_model=_load_current_default_model(), @@ -4925,7 +2866,7 @@ def _run_wizard_body( non_interactive=non_interactive, warnings=warnings, ), - lambda: _step5_import(skip=skip_import, non_interactive=non_interactive), + lambda: _step6_import(skip=skip_import, non_interactive=non_interactive), ] index = 0 diff --git a/raven/cli/onboard_everos.py b/raven/cli/onboard_everos.py new file mode 100644 index 00000000..13fa6607 --- /dev/null +++ b/raven/cli/onboard_everos.py @@ -0,0 +1,1525 @@ +"""EverOS long-term memory cluster of the onboard wizard (Step 4). + +Split out of ``onboard_commands`` because that module had grown past 5000 +lines; this file owns EverOS role configuration (llm / embedding / rerank / +multimodal) end to end. Shared wizard UI state (``console``, ``_t``, ``_BACK``, +``_QMARK``, questionary helpers, ...) still lives in ``onboard_commands`` -- +this module reaches it via the ``oc`` module reference (not a value import) so +that test monkeypatches on ``onboard_commands`` attributes keep working +whichever module a caller patches through. +""" + +from __future__ import annotations + +from typing import Any, Optional + +import typer + +from raven.cli import onboard_commands as oc + + +def _set_memory_backend(backend: Optional[str]) -> None: + """Set ``memory.backend`` (``"everos"`` / ``None``) via the ops layer.""" + from raven.config.update import set_memory_backend + + set_memory_backend(backend) + + +def _init_extension_block_defaults() -> None: + """Seed the memory / plugins / skillForge extension defaults via the ops layer.""" + from raven.config.update import init_extension_block_defaults + + init_extension_block_defaults() + + +def _everos_section(section: str) -> dict[str, Any]: + from raven.config.update_everos import everos_section + + return everos_section(section) + + +def _everos_role_configured(section: str) -> bool: + from raven.config.update_everos import everos_role_configured + + return everos_role_configured(section) + + +def _memory_enabled() -> bool: + """True iff EverOS memory is both selected AND usable on disk. + + "Usable" means the llm role is configured -- that is the whole requirement. + embedding is advised but optional: without it the adapter searches lexically + instead of semantically, which is weaker memory rather than none, and gating + on it here would tell a user who skipped it that memory is off (and skip the + import step along with it). + """ + data = oc._load_raw_config() + if (data.get("memory") or {}).get("backend") != "everos": + return False + return _everos_role_configured("llm") + + +# Providers whose main model can be reused as the EverOS memory LLM: they +# speak the OpenAI chat-completions protocol that EverOS's bare OpenAI client +# requires. OAuth providers (github_copilot / openai_codex) and non-OpenAI +# wire protocols (anthropic / gemini) are excluded. +_OPENAI_COMPATIBLE_PROVIDERS = {"openrouter", "openai", "deepseek", "custom"} + +# Fallback OpenAI-compatible base URLs for providers whose registry +# ``default_api_base`` is empty (they rely on the SDK's built-in default, +# which EverOS's bare client doesn't know). EverOS needs an explicit base_url. +_PROVIDER_BASE_URL_FALLBACK = { + "openai": "https://api.openai.com/v1", + "deepseek": "https://api.deepseek.com/v1", + "openrouter": "https://openrouter.ai/api/v1", +} + + +def _resolve_model_provider(model: str) -> Optional[str]: + """Best-effort: which configured provider does ``model`` belong to? + + Prefixed models (``openrouter/...`` / ``openai/gpt-4o``) read off the head. + A custom endpoint stores its model as a BARE id (e.g. ``qwen-max``) with no + prefix, so an unrecognized head falls back to ``"custom"`` when a custom + provider is actually configured with a key. Returns ``None`` when no match. + """ + from raven.providers.registry import split_model_id + + if not model: + return None + head, _ = split_model_id(model) + if head: + from raven.config.update_providers import provider_field_specs + + try: + provider_field_specs(head) + return head + except KeyError: + pass + # No usable prefix → could be a bare custom-endpoint model. + from raven.config.schema import ProviderConfig + from raven.providers.auth import credential_status + + custom = (oc._load_raw_config().get("providers") or {}).get("custom") or {} + if credential_status("custom", ProviderConfig.model_validate(custom)).ok: + return "custom" + # A bare id that still matches a known provider head (rare; e.g. a direct + # provider's bare default before prefixing) — accept the head if known. + return head if head in _OPENAI_COMPATIBLE_PROVIDERS else None + + +def _model_is_openai_compatible(model: Optional[str]) -> bool: + """Heuristic: can the main chat model's provider be reused for memory LLM? + + EverOS's memory LLM uses a bare OpenAI client, so the main model is + reusable only when its provider speaks the OpenAI chat protocol. Custom + endpoints are OpenAI-compatible by definition (the wizard only offers + ``custom`` for OpenAI-compatible endpoints). + """ + if not model: + return False + return _resolve_model_provider(model) in _OPENAI_COMPATIBLE_PROVIDERS + + +def _resolve_reuse_llm_creds(main_model: str) -> dict[str, Optional[str]]: + """Map a litellm-style main model to bare EverOS LLM settings. + + EverOS sends ``EVEROS_LLM__MODEL`` to ``base_url`` via a bare OpenAI + client, so: + - strip the provider's litellm prefix to the bare model id the upstream + endpoint expects (``openrouter/anthropic/claude-x`` → ``anthropic/claude-x``; + a custom endpoint's bare id is used as-is); + - resolve the provider's real ``base_url`` (configured ``apiBase`` → + registry ``default_api_base`` → a known fallback); + - carry the provider's stored api_key. + """ + from raven.providers.registry import find_by_name, normalize_provider_name, split_model_id + + provider = _resolve_model_provider(main_model) or split_model_id(main_model)[0] + spec = find_by_name(provider) + # Through the ops library, so a section still stored under the provider's + # pre-rename name is found -- a raw lookup by the resolved name is not. + from raven.config.update_providers import get_provider_config + + # No `if spec` gate: LiteLLM-only vendors have no spec of ours yet their + # section holds real credentials, and gating on the spec silently handed the + # probe an empty api_key while the main model was working fine. + try: + _resolved = get_provider_config(provider, redact_secrets=False) + except KeyError: + _resolved = {} + prov_cfg = {"apiKey": _resolved.get("api_key"), "apiBase": _resolved.get("api_base")} if _resolved else {} + + # Strip the routing prefix to the bare model id the upstream endpoint + # expects: litellm consumes it, the raw OpenAI client must not see it. Only + # a prefix naming this provider is stripped -- a custom endpoint stores a + # bare id already, and anything else is part of the vendor's own model id. + bare_model = main_model + head, rest = split_model_id(main_model) + known_prefixes = set(spec.route_names) if spec else {normalize_provider_name(provider)} + if spec: + known_prefixes.add(normalize_provider_name(spec.model_prefix)) + if head and head in known_prefixes: + bare_model = rest + + base_url = ( + prov_cfg.get("apiBase") + or (getattr(spec, "default_api_base", "") if spec else "") + or _PROVIDER_BASE_URL_FALLBACK.get(provider) + ) + return { + "model": bare_model, + "api_key": prov_cfg.get("apiKey"), + "base_url": base_url, + } + + +def _prompt_text(label: str, *, secret: bool = False, default: str = "", allow_back: bool = False) -> Any: + """Prompt for free text. With ``allow_back``, an empty submit returns + ``oc._BACK`` (and a hint is shown); otherwise returns the stripped string.""" + questionary = oc._require_questionary() + from raven.cli._styles import RAVEN_STYLE + + placeholder = oc._back_placeholder(allow_back) + if secret: + value = questionary.password(label, placeholder=placeholder, style=RAVEN_STYLE, qmark=oc._QMARK).ask() + else: + value = questionary.text( + label, default=default, placeholder=placeholder, style=RAVEN_STYLE, qmark=oc._QMARK + ).ask() + if value is None: + raise typer.Exit(1) + value = value.strip() + if allow_back and value == "": + return oc._BACK + return value + + +def _probe_everos_chat(model: Optional[str], *, api_key: Optional[str], base_url: Optional[str]) -> tuple[bool, str]: + """Real capability probe for a memory-LLM endpoint: ``POST + {base_url}/chat/completions`` once and confirm a choice comes back. Unlike a + bare ``GET /models`` connectivity check, this exercises the picked model, so + an endpoint that lists models but doesn't serve the chosen id fails here + instead of reporting a false green. Provider-agnostic; never raises.""" + import httpx + + if not base_url: + return False, "no base_url configured" + url = base_url.rstrip("/") + ("/chat/completions" if "/v1" in base_url else "/v1/chat/completions") + headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + body = {"model": model, "messages": [{"role": "user", "content": "ping"}], "max_tokens": 1} + try: + with httpx.Client(timeout=15) as client: + resp = client.post(url, headers=headers, json=body) + if resp.status_code != 200: + return False, f"HTTP {resp.status_code}: {resp.text[:200]}" + data = resp.json() + except (httpx.HTTPError, httpx.InvalidURL, ValueError) as exc: + return False, f"probe failed: {exc}" + choices = data.get("choices") if isinstance(data, dict) else None + if isinstance(choices, list) and choices and isinstance(choices[0], dict): + return True, "ok" + return False, "endpoint returned no completion" + + +def _verify_everos_llm( + label: str, + *, + model: Optional[str], + api_key: Optional[str], + base_url: Optional[str], + non_interactive: bool, + warnings: list[str], + continue_hint: Optional[tuple[str, str]] = None, +) -> bool: + """Probe the memory LLM with a real chat completion, offering retry/continue on failure.""" + oc.console.print(oc._t(f" [dim]⏳ Verifying {label}…[/dim]", f" [dim]⏳ 正在验证 {label}…[/dim]")) + ok, detail = _probe_everos_chat(model, api_key=api_key, base_url=base_url) + if ok: + oc.console.print(oc._t(f" [green]✓ {label} connected.[/green]", f" [green]✓ {label} 连接成功。[/green]")) + return True + oc.console.print( + oc._t( + f" [yellow]✗ Couldn't verify {label}: {detail}[/yellow]", + f" [yellow]✗ 验证失败 {label}:{detail}[/yellow]", + ) + ) + if continue_hint: + cont_label = oc._t(f"Continue anyway ({continue_hint[0]})", f"仍然继续({continue_hint[1]})") + else: + cont_label = oc._t("Continue anyway", "仍然继续") + choice = oc._failure_choice( + [ + (oc._t("Re-enter", "重新填写"), "rekey"), + (cont_label, "continue"), + ], + non_interactive=non_interactive, + ) + if choice == "rekey": + return False + warnings.append(label) + return True + + +def _verify_rerank( + label: str, + *, + model: Optional[str], + api_key: Optional[str], + base_url: Optional[str], + rerank_provider: Optional[str], + non_interactive: bool, + warnings: list[str], + continue_hint: Optional[tuple[str, str]] = None, +) -> bool: + """Probe a rerank endpoint with a provider-specific request, offering retry/continue on failure.""" + oc.console.print(oc._t(f" [dim]⏳ Verifying {label}…[/dim]", f" [dim]⏳ 正在验证 {label}…[/dim]")) + ok, detail = _probe_rerank(model, api_key=api_key, base_url=base_url, rerank_provider=rerank_provider) + if ok: + oc.console.print(oc._t(f" [green]✓ {label} connected.[/green]", f" [green]✓ {label} 连接成功。[/green]")) + return True + oc.console.print( + oc._t( + f" [yellow]✗ Couldn't verify {label}: {detail}[/yellow]", + f" [yellow]✗ 验证失败 {label}:{detail}[/yellow]", + ) + ) + if continue_hint: + cont_label = oc._t(f"Continue anyway ({continue_hint[0]})", f"仍然继续({continue_hint[1]})") + else: + cont_label = oc._t("Continue anyway", "仍然继续") + choice = oc._failure_choice( + [ + (oc._t("Re-enter", "重新填写"), "rekey"), + (cont_label, "continue"), + ], + non_interactive=non_interactive, + ) + if choice == "rekey": + return False + warnings.append(label) + return True + + +def _probe_rerank( + model: Optional[str], + *, + api_key: Optional[str], + base_url: Optional[str], + rerank_provider: Optional[str], +) -> tuple[bool, str]: + """Real capability probe for a rerank endpoint. Dispatches by provider + protocol (vllm / deepinfra / dashscope). Never raises.""" + import httpx + + if not base_url or not model: + return False, "no base_url or model configured" + headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + headers["Content-Type"] = "application/json" + + try: + if rerank_provider == "deepinfra": + url = f"{base_url.rstrip('/')}/{model}" + body: dict = {"queries": ["ping"], "documents": ["pong"]} + elif rerank_provider == "dashscope": + url = f"{base_url.rstrip('/')}/api/v1/services/rerank/text-rerank/text-rerank" + body = { + "model": model, + "input": {"query": "ping", "documents": ["pong"]}, + "parameters": {"return_documents": False, "top_n": 1}, + } + else: # vllm / OpenAI-compat + url = f"{base_url.rstrip('/')}/rerank" + body = {"model": model, "query": "ping", "documents": ["pong"]} + + with httpx.Client(timeout=15) as client: + resp = client.post(url, json=body, headers=headers) + if resp.status_code != 200: + return False, f"HTTP {resp.status_code}: {resp.text[:200]}" + data = resp.json() + except (httpx.HTTPError, httpx.InvalidURL, ValueError) as exc: + return False, f"probe failed: {exc}" + + if rerank_provider == "deepinfra": + scores = data.get("scores") + if isinstance(scores, list) and scores: + return True, "ok" + return False, "endpoint returned no scores" + if rerank_provider == "dashscope": + output = data.get("output") + results = output.get("results") if isinstance(output, dict) else None + if isinstance(results, list) and results: + return True, "ok" + return False, "endpoint returned no results" + # vllm + results = data.get("results") + if isinstance(results, list) and results: + return True, "ok" + return False, "endpoint returned no results" + + +_REQUIRED_EMBEDDING_DIM = 1024 + + +def _probe_embedding_dim(url: str, headers: dict, model: str) -> int | str: + """Try embedding with ``dimensions=1024``; fall back to native dim. + + Returns the effective dimension (int) on success, or an error + description (str) on failure. + """ + import httpx + + def _try_embed(client: httpx.Client, body: dict) -> int | str: + try: + resp = client.post(url, json=body, headers=headers) + if resp.status_code != 200: + return f"HTTP {resp.status_code}" + items = resp.json().get("data", []) + if not items: + return "empty response" + first = items[0] + if not isinstance(first, dict): + return "unexpected response format" + return len(first.get("embedding", [])) + except (httpx.HTTPError, httpx.InvalidURL, ValueError) as exc: + return str(exc) + + with httpx.Client(timeout=15) as client: + result = _try_embed( + client, {"model": model, "input": ["dimension check"], "dimensions": _REQUIRED_EMBEDDING_DIM} + ) + if result == _REQUIRED_EMBEDDING_DIM: + return result + return _try_embed(client, {"model": model, "input": ["dimension check"]}) + + +def _verify_embedding_dim( + *, + model: Optional[str], + api_key: Optional[str], + base_url: Optional[str], + non_interactive: bool, +) -> bool: + """Send a test embedding request and verify the vector dimension is 1024. + + Returns True to proceed, False to re-prompt. + """ + if not base_url or not model: + return True + + url = base_url.rstrip("/") + "/embeddings" + headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + + while True: + oc.console.print( + oc._t( + " [dim]⏳ Checking embedding dimension…[/dim]", + " [dim]⏳ 正在检测 embedding 维度…[/dim]", + ) + ) + result = _probe_embedding_dim(url, headers, model) + + if result == _REQUIRED_EMBEDDING_DIM: + oc.console.print( + oc._t( + f" [green]✓ Supports {result}-dim.[/green]", + f" [green]✓ 支持 {result} 维。[/green]", + ) + ) + return True + + if isinstance(result, int) and result < _REQUIRED_EMBEDDING_DIM: + oc.console.print( + oc._t( + f" [red]✗ Dimension too small: model outputs {result}-dim, " + f"EverOS requires >= {_REQUIRED_EMBEDDING_DIM}. Please pick another model.[/red]", + f" [red]✗ 维度不足:模型输出 {result} 维," + f"EverOS 要求 >= {_REQUIRED_EMBEDDING_DIM} 维,请重新选择。[/red]", + ) + ) + return False + + if isinstance(result, int) and result > _REQUIRED_EMBEDDING_DIM: + oc.console.print( + oc._t( + f" [red]✗ Model outputs {result}-dim and does not support the " + f"dimensions parameter to truncate to {_REQUIRED_EMBEDDING_DIM}. " + "Please pick another model.[/red]", + f" [red]✗ 模型输出 {result} 维,且不支持 dimensions 参数" + f"截断到 {_REQUIRED_EMBEDDING_DIM} 维,请重新选择。[/red]", + ) + ) + return False + + oc.console.print( + oc._t( + f" [yellow]✗ Couldn't verify dimension: {result}[/yellow]", + f" [yellow]✗ 无法验证维度:{result}[/yellow]", + ) + ) + if non_interactive: + return False + choice = oc._failure_choice( + [ + (oc._t("Retry", "重试"), "retry"), + (oc._t("Re-enter", "重新选择"), "rekey"), + ], + non_interactive=False, + ) + if choice == "rekey": + return False + + +# Curated OpenAI-compatible endpoints for EverOS memory models. Picking one +# pre-fills its base_url (mirrors the main provider step); everything else is +# reachable via "reuse an existing endpoint" or "custom" (type a base_url). +# These are the providers' documented OpenAI-compatible /v1 endpoints. +_EVEROS_PROVIDERS: list[dict[str, Any]] = [ + { + "name": "openai", + "label": "OpenAI", + "label_zh": "OpenAI", + "base_url": "https://api.openai.com/v1", + "supports": {"llm", "embedding", "multimodal"}, + }, + { + "name": "openrouter", + "label": "OpenRouter", + "label_zh": "OpenRouter", + "base_url": "https://openrouter.ai/api/v1", + "supports": {"llm", "embedding", "rerank", "multimodal"}, + "rerank_provider": "vllm", + }, + { + "name": "deepseek", + "label": "DeepSeek", + "label_zh": "DeepSeek", + "base_url": "https://api.deepseek.com/v1", + "supports": {"llm"}, + }, + { + "name": "deepinfra", + "label": "DeepInfra", + "label_zh": "DeepInfra", + "base_url": "https://api.deepinfra.com/v1/openai", + "supports": {"llm", "embedding", "rerank"}, + "rerank_provider": "deepinfra", + "rerank_base_url": "https://api.deepinfra.com/v1/inference", + }, + { + "name": "siliconflow", + "label": "SiliconFlow", + "label_zh": "硅基流动 SiliconFlow", + "base_url": "https://api.siliconflow.cn/v1", + "supports": {"llm", "embedding", "rerank"}, + "rerank_provider": "vllm", + }, + { + "name": "dashscope", + "label": "DashScope (Alibaba)", + "label_zh": "阿里百炼 DashScope", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "supports": {"llm", "embedding", "rerank"}, + "rerank_provider": "dashscope", + "rerank_base_url": "https://dashscope.aliyuncs.com", + }, +] + + +def _match_provider_by_url(base_url: Optional[str]) -> Optional[str]: + """Reverse-lookup a curated provider name from its base_url.""" + if not base_url: + return None + normalized = base_url.rstrip("/") + for prov in _EVEROS_PROVIDERS: + if prov["base_url"].rstrip("/") == normalized: + return prov["name"] + return None + + +# Per-role config: menu/verify label, model-id example, whether optional, and +# whether to run a connectivity probe after configuring (rerank/multimodal use +# non-chat endpoints whose /models probe isn't a reliable health check). +_EVEROS_ROLES: dict[str, dict[str, Any]] = { + "llm": { + "label": ("Memory LLM", "记忆 LLM"), + "example": "gpt-4.1-mini", + "optional": False, + "verify": True, + "purpose": ( + "Reads each conversation to judge what matters and extract the key points.", + "从对话中判断信息边界、抽取要点。", + ), + # Worded as a floor rather than a default: the field is pre-filled with + # the user's own main model, because a recommended id is only reachable + # if their key carries it. This tells them how to judge their own. + "recommendation": ( + "Capability floor: [bold]gpt-4.1-mini[/bold] -- weaker models degrade extraction", + "能力下限参考 [bold]gpt-4.1-mini[/bold]:低于这个水平会明显影响提取质量", + ), + "continue_hint": ("memory extraction may fail", "记忆抽取可能失败"), + }, + "embedding": { + "label": ("Memory embedding", "记忆 embedding"), + "example": "Qwen/Qwen3-Embedding-4B", + # Optional in the sense that memory still functions without it: the + # adapter drops to KEYWORD search, which needs no vectors. Strongly + # advised all the same -- lexical recall misses a memory the moment the + # user phrases the question differently. + "optional": True, + "verify": True, + "purpose": ( + "Turns text into vectors so memories are found by meaning, not just keywords.", + "把文字转成向量,让记忆能按「意思」检索,而不只是按关键词。", + ), + "tag": ( + "[accent](optional, strongly advised)[/accent]", + "[accent](可选,强烈建议配置)[/accent]", + ), + "cost": ( + "Without it: rephrase a question and it may miss a memory you have;\n recall can only match keywords.", + "不配置:换个说法提问就可能找不到已有记忆,记忆召回时只能使用关键词检索。", + ), + "recommendation": ( + "Recommended: [bold]Qwen/Qwen3-Embedding-4B[/bold] -- must be [bold yellow]1024-dim[/bold yellow],\n" + " Chinese + English", + "推荐 [bold]Qwen/Qwen3-Embedding-4B[/bold],需 [bold yellow]1024 维[/bold yellow]且支持中英文的模型", + ), + "continue_hint": ("semantic recall will be unavailable", "语义召回将不可用"), + "skip_note": ( + " [yellow]! Skipped: recall will match keywords, not meaning.[/yellow]\n" + " [dim]Phrase a question differently and it may miss a memory you have.\n" + " Configure it later, then run `everos cascade backfill`.[/dim]", + " [yellow]⚠ 已跳过:召回将按关键词匹配,而非按语义。[/yellow]\n" + " [dim]换一种说法提问,就可能找不到已有的记忆。\n" + " 日后配好后运行 everos cascade backfill 可为已存记忆补上向量。[/dim]", + ), + }, + "rerank": { + "label": ("Memory rerank", "记忆 rerank"), + "example": "Qwen/Qwen3-Reranker-4B", + "optional": True, + "verify": True, + "purpose": ( + "Re-ranks what semantic search found so the best match comes first, at a small\n latency cost.", + "在语义召回一批候选后再精排一遍,让最相关的排在最前,会略增延迟。", + ), + "tag": ( + "[accent](optional, advised)[/accent]", + "[accent](可选,建议配置)[/accent]", + ), + "recommendation": ( + "Recommended: [bold]Qwen/Qwen3-Reranker-4B[/bold]", + "推荐 [bold]Qwen/Qwen3-Reranker-4B[/bold]", + ), + "continue_hint": ("rerank quality may degrade", "rerank 精度可能下降"), + "skip_note": ( + " [dim]Skipped rerank; memory retrieval still works.[/dim]", + " [dim]已跳过 rerank,记忆检索仍可用。[/dim]", + ), + }, + "multimodal": { + "label": ("Memory multimodal", "记忆多模态"), + "example": "google/gemini-3-flash-preview", + "optional": True, + "verify": True, + "purpose": ( + "Lets Raven understand and recall images / PDFs / audio as memory.", + "让 Raven 把图片 / PDF / 音频也作为记忆来理解和检索。", + ), + "cost": ( + "Without it: those files stay out of memory. Having such files is not the same\n" + " as needing them remembered -- configure it when you do.", + "不配置:这类文件不进入记忆;有这类文件并不等于需要,确有此需求时再配即可。", + ), + "recommendation": ( + "Recommended: [bold]google/gemini-3-flash-preview[/bold]", + "推荐 [bold]google/gemini-3-flash-preview[/bold]", + ), + "skip_note": ( + " [dim]Skipped; nothing else is affected -- configure it if you come to need\n multimodal memory.[/dim]", + " [dim]已跳过;其余功能不受影响,日后确有把多模态内容纳入记忆的需求时再配即可。[/dim]", + ), + }, +} + + +_EMBEDDING_MODEL_PATTERNS = ("embed", "bge", "e5-", "gte-") +_MULTIMODAL_MODEL_PATTERNS = ("vision", "4o", "gemini", "pixtral", "qwen-vl", "qwen2-vl", "qwen2.5-vl") + + +def _fetch_everos_models( + base_url: Optional[str], + api_key: Optional[str], + *, + section: str = "llm", + provider_name: Optional[str] = None, +) -> Optional[list[str]]: + """Fetch available model ids from a provider endpoint. Never raises. + + For ``section="embedding"``, delegates to per-provider logic because + each provider exposes embedding models differently. + """ + if not base_url: + return None + if section == "embedding": + return _fetch_embedding_models(base_url, api_key, provider_name) + if section == "rerank": + return _fetch_rerank_models(base_url, api_key, provider_name) + if section == "multimodal": + return _fetch_multimodal_models(base_url, api_key, provider_name) + return _fetch_openai_models(base_url, api_key) + + +def _fetch_openai_models( + base_url: str, + api_key: Optional[str], + *, + params: Optional[dict[str, str]] = None, +) -> Optional[list[str]]: + """``GET {base_url}/models`` with OpenAI-style response parsing.""" + import httpx + + url = base_url.rstrip("/") + "/models" + headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + try: + with httpx.Client(timeout=10) as client: + resp = client.get(url, headers=headers, params=params) + if resp.status_code != 200: + return None + data = resp.json() + except (httpx.HTTPError, httpx.InvalidURL, ValueError): + return None + items = data.get("data") if isinstance(data, dict) else None + if not isinstance(items, list): + return None + ids = [m.get("id") for m in items if isinstance(m, dict) and m.get("id")] + return sorted(ids) or None + + +def _fetch_deepinfra_models( + api_key: Optional[str], + reported_type: str, + *, + name_contains: Optional[str] = None, +) -> Optional[list[str]]: + """Fetch DeepInfra models filtered by ``reported_type`` and optional name substring.""" + import httpx + + headers = {"Authorization": f"Bearer {api_key}"} if api_key else {} + try: + with httpx.Client(timeout=10) as client: + resp = client.get("https://api.deepinfra.com/models/list", headers=headers) + if resp.status_code != 200: + return None + data = resp.json() + except (httpx.HTTPError, ValueError): + return None + items = data if isinstance(data, list) else [] + ids = [ + m.get("model_name") + for m in items + if isinstance(m, dict) + and m.get("reported_type") == reported_type + and m.get("model_name") + and (name_contains is None or name_contains in m.get("model_name", "")) + ] + return sorted(ids) or None + + +def _fetch_embedding_models( + base_url: str, + api_key: Optional[str], + provider_name: Optional[str], +) -> Optional[list[str]]: + """Provider-specific embedding model listing.""" + if provider_name == "openrouter": + return _fetch_openai_models(base_url.rstrip("/") + "/embeddings", api_key) + + if provider_name == "siliconflow": + return _fetch_openai_models(base_url, api_key, params={"type": "text", "sub_type": "embedding"}) + + if provider_name == "deepinfra": + return _fetch_deepinfra_models(api_key, "embeddings") + + # OpenAI, DashScope, custom — GET /models + name-based filter. + ids = _fetch_openai_models(base_url, api_key) + if ids is None: + return None + filtered = [i for i in ids if any(p in i.lower() for p in _EMBEDDING_MODEL_PATTERNS)] + return filtered or None + + +def _fetch_rerank_models( + base_url: str, + api_key: Optional[str], + provider_name: Optional[str], +) -> Optional[list[str]]: + """Provider-specific rerank model listing.""" + if provider_name == "deepinfra": + # The deepinfra provider hardcodes a Qwen3-Reranker chat template, + # so only Qwen3-Reranker models are compatible. + return _fetch_deepinfra_models(api_key, "reranker", name_contains="Qwen3-Reranker") + + if provider_name == "siliconflow": + return _fetch_openai_models(base_url, api_key, params={"sub_type": "reranker"}) + + if provider_name == "dashscope": + return ["gte-rerank-v2"] + + if provider_name == "openrouter": + return _fetch_openai_models(base_url, api_key, params={"output_modalities": "rerank"}) + + # vllm / custom — no standard rerank listing. + return None + + +def _fetch_multimodal_models( + base_url: str, + api_key: Optional[str], + provider_name: Optional[str], +) -> Optional[list[str]]: + """Provider-specific multimodal (vision) model listing.""" + if provider_name == "openrouter": + return _fetch_openai_models(base_url, api_key, params={"input_modalities": "image"}) + + # OpenAI, custom — GET /models + name-based filter. + ids = _fetch_openai_models(base_url, api_key) + if ids is None: + return None + filtered = [i for i in ids if any(p in i.lower() for p in _MULTIMODAL_MODEL_PATTERNS)] + return filtered or None + + +def _match_everos_default(example: str, models: list[str]) -> str: + """Find the best match for ``example`` in the fetched model list. + + The example (e.g. ``gpt-4.1-mini``) is a bare model name, while + ``models`` may carry provider prefixes (``openai/gpt-4.1-mini``). + Returns the first model whose id ends with ``/example`` or equals + ``example`` exactly; falls back to the bare example string so the + autocomplete input is pre-filled even if no exact match exists. + """ + lower = example.lower() + suffix = f"/{lower}" + for mid in models: + if mid.lower() == lower or mid.lower().endswith(suffix): + return mid + return example + + +def _preferred_memory_model(section: str, main_model: Optional[str], chosen_provider: Optional[str]) -> Optional[str]: + """The main chat model, when it is a sensible pre-fill for this role. + + Only the llm role -- an embedding / rerank / multimodal endpoint does not + serve a chat model. Only when the picked provider is the main model's own: no + other provider carries that id, and pre-filling one it cannot serve turns + Enter into a verification failure. A custom endpoint has no resolved provider + and is left alone for the same reason. + """ + if section != "llm" or not main_model or chosen_provider is None: + return None + if chosen_provider != _resolve_model_provider(main_model): + return None + return _resolve_reuse_llm_creds(main_model).get("model") + + +def _everos_pick_model( + *, + base_url: Optional[str], + api_key: Optional[str], + example: str, + allow_back: bool, + section: str = "llm", + provider_name: Optional[str] = None, + recommendation: Optional[tuple[str, str]] = None, + preferred: Optional[str] = None, +) -> Any: + """Pick a model id for an EverOS endpoint: fetch ``/models`` for a + fuzzy-searchable list, else fall back to free text. Empty submit = back. + + ``preferred`` pre-fills a model the user is already known to have access to + -- their main chat model. It wins over ``example`` because a recommended + model is only a recommendation if the user's key can reach it, and many keys + cannot; ``example`` then reads as the capability floor rather than the + default (see ``recommendation``). + """ + questionary = oc._require_questionary() + from raven.cli._styles import RAVEN_STYLE + + oc.console.print(oc._t(" [dim]⏳ Loading models…[/dim]", " [dim]⏳ 正在拉取模型列表…[/dim]")) + models = _fetch_everos_models(base_url, api_key, section=section, provider_name=provider_name) + if preferred: + oc.console.print( + oc._t( + f" [dim]Pre-filled with your main model [bold]{preferred}[/bold] -- press Enter to accept.[/dim]", + f" [dim]已填入你的主模型 [bold]{preferred}[/bold],直接回车即可。[/dim]", + ) + ) + if recommendation: + oc.console.print(f" [dim]{oc._t(*recommendation)}[/dim]") + if models: + default_model = preferred or _match_everos_default(example, models) + question = questionary.autocomplete( + oc._t( + f"Model ({len(models)} available — type to filter):", + f"模型(共 {len(models)} 个 — 输入可筛选):", + ), + choices=models, + default=default_model, + ignore_case=True, + match_middle=True, + placeholder=oc._back_placeholder(allow_back), + style=RAVEN_STYLE, + qmark=oc._QMARK, + ) + # Trigger the completion popup immediately so the user sees + # all available models without typing first. + app = question.application + + def _show_completions() -> None: + buf = app.current_buffer + buf.start_completion() + + app.pre_run_callables.append(_show_completions) + chosen = question.ask() + else: + oc.console.print( + oc._t( + " [dim]Couldn't list models from this endpoint — type the id manually.[/dim]", + " [dim]该端点拉不到模型列表 — 请手动输入模型 id。[/dim]", + ) + ) + chosen = questionary.text( + oc._t(f"Model id (e.g. {example}):", f"模型 id(如 {example}):"), + default=preferred or "", + placeholder=oc._back_placeholder(allow_back), + style=RAVEN_STYLE, + qmark=oc._QMARK, + ).ask() + if chosen is None: + raise typer.Exit(1) + chosen = chosen.strip() + if allow_back and chosen == "": + return oc._BACK + if not chosen: + raise typer.Exit(1) + return chosen + + +def _everos_pick_creds_and_model( + *, + section: str, + example: str, + main_model: Optional[str], + non_interactive: bool, + recommendation: Optional[tuple[str, str]] = None, +) -> Any: + """Mirror the main provider step for one EverOS model: pick a source + (curated provider / custom) → API key → model. Returns a dict with + ``model`` / ``api_key`` / ``base_url`` (plus ``provider`` for rerank), or + ``oc._BACK`` when the user backs out of the source picker. Empty submit on any + field rewinds one step.""" + questionary = oc._require_questionary() + from raven.cli._styles import RAVEN_STYLE + + llm_section = _everos_section("llm") + + # For the LLM role, default to the main chat model's provider. + # For other roles (embedding/rerank/multimodal), default to whichever + # provider the LLM step just configured — the user likely has the + # same API key and only needs to pick a different model. + if section == "llm": + default_provider = _resolve_model_provider(main_model or "") + reuse_source = "main" + else: + default_provider = _match_provider_by_url(llm_section.get("base_url")) + reuse_source = "llm" + + while True: # source picker — a field-level back rewinds here + choices: list[Any] = [] + default_choice = None + for prov in _EVEROS_PROVIDERS: + if section not in prov.get("supports", set()): + continue + is_default = default_provider is not None and prov["name"] == default_provider + if is_default: + if reuse_source == "main": + label = oc._t( + f"{prov['label']} (main model provider, reuse Key)", + f"{prov['label_zh']}(主模型服务商,复用 Key)", + ) + else: + label = oc._t( + f"{prov['label']} (memory LLM provider, reuse Key)", + f"{prov['label_zh']}(记忆 LLM 服务商,复用 Key)", + ) + else: + label = oc._t(prov["label"], prov["label_zh"]) + choice = questionary.Choice(label, value=("provider", prov)) + choices.append(choice) + if is_default: + default_choice = choice.value + choices.append( + questionary.Choice( + oc._t("Other (custom OpenAI-compatible endpoint)", "其他(自定义 OpenAI 兼容端点)"), + value=("custom",), + ) + ) + choices.append(questionary.Separator()) + choices.append(questionary.Choice(oc._t("Back", "返回"), value=oc._BACK)) + + src = questionary.select( + oc._t("Pick a provider (or reuse / custom):", "选择服务商(或复用 / 自定义):"), + choices=choices, + default=default_choice, + style=RAVEN_STYLE, + qmark=oc._QMARK, + ).ask() + if src is None: + raise typer.Exit(1) + if src is oc._BACK: + return oc._BACK + kind = src[0] + + # Resolve (api_key, base_url) from the chosen source. + chosen_provider: Optional[str] = None + if kind == "provider": + chosen_provider = src[1]["name"] + base_url = src[1]["base_url"] + prefilled_key: Optional[str] = None + if default_provider == src[1]["name"]: + if reuse_source == "main": + prefilled_key = _resolve_reuse_llm_creds(main_model or "").get("api_key") + else: + prefilled_key = llm_section.get("api_key") + if prefilled_key: + if reuse_source == "main": + oc.console.print( + oc._t( + " [dim]API key reused from main chat model.[/dim]", + " [dim]已复用主对话模型的 API Key。[/dim]", + ) + ) + else: + oc.console.print( + oc._t( + " [dim]API key reused from memory LLM.[/dim]", + " [dim]已复用记忆 LLM 的 API Key。[/dim]", + ) + ) + api_key = prefilled_key + else: + api_key = oc._prompt_api_key(src[1]["name"], allow_back=True) + if api_key is oc._BACK: + continue + else: # custom + base_url = _prompt_text(oc._t("Base URL (must include /v1):", "Base URL(需包含 /v1):"), allow_back=True) + if base_url is oc._BACK: + continue + api_key = _prompt_text(oc._t("API key (hidden):", "API Key(隐藏输入):"), secret=True, allow_back=True) + if api_key is oc._BACK: + continue + + # Guard against a source that resolved to an empty key / endpoint — + # set_everos_section drops None values, which would otherwise persist a + # section with a model but no usable endpoint. + if not (api_key and base_url): + oc.console.print( + oc._t( + " [yellow]✗ Missing API key or Base URL for this source — pick another.[/yellow]", + " [yellow]✗ 该来源缺少 API Key 或 Base URL — 请换一个。[/yellow]", + ) + ) + continue + + # rerank: resolve service type + override base_url when needed. + rerank_provider: Optional[str] = None + if section == "rerank": + chosen_prov_dict = src[1] if kind == "provider" else None + if chosen_prov_dict and chosen_prov_dict.get("rerank_provider"): + rerank_provider = chosen_prov_dict["rerank_provider"] + if chosen_prov_dict.get("rerank_base_url"): + base_url = chosen_prov_dict["rerank_base_url"] + else: + rerank_provider = questionary.select( + oc._t("Rerank service type:", "rerank 服务类型:"), + choices=[ + questionary.Choice("deepinfra", value="deepinfra"), + questionary.Choice("vllm", value="vllm"), + questionary.Choice("dashscope", value="dashscope"), + questionary.Choice(oc._t("Back", "返回"), value=oc._BACK), + ], + style=RAVEN_STYLE, + qmark=oc._QMARK, + ).ask() + if rerank_provider is None: + raise typer.Exit(1) + if rerank_provider is oc._BACK: + continue + + model = _everos_pick_model( + base_url=base_url, + api_key=api_key, + example=example, + allow_back=True, + section=section, + provider_name=chosen_provider, + recommendation=recommendation, + preferred=_preferred_memory_model(section, main_model, chosen_provider), + ) + if model is oc._BACK: + continue + + result: dict[str, Any] = {"model": model, "api_key": api_key, "base_url": base_url} + if rerank_provider: + result["provider"] = rerank_provider + return result + + +def _config_everos_role( + *, section: str, main_model: Optional[str], non_interactive: bool, warnings: list[str], skip_test: bool = False +) -> Any: + """Configure one EverOS memory role (llm / embedding / rerank / multimodal) + with the unified provider→key→model flow, reuse shortcuts, and a back loop. + + Returns ``None`` normally; returns ``oc._ABORT_EVEROS`` when the user gives up a + required role (the caller then disables EverOS, leaving no long-term memory).""" + questionary = oc._require_questionary() + from raven.cli._styles import RAVEN_STYLE + from raven.config.update_everos import clear_everos_section, set_everos_section + + role = _EVEROS_ROLES[section] + label_en, label_zh = role["label"] + purpose_en, purpose_zh = role["purpose"] + optional = role["optional"] + verify_label = oc._t(label_en, label_zh) + + # Tell the user what this model is for, and what skipping it costs, before + # asking them to configure it. Header sits on the 2-space info column (bold + # accent); purpose and cost nest under it, matching the layout used + # everywhere else. + # + # The cost line is dim rather than a warning colour on purpose: this is + # pre-decision information, and colouring it would cry wolf before the user + # has chosen anything. The warning comes after, from ``skip_note``. + # + # Roles that want to be configured say so in their own ``tag`` -- calling all + # three merely "optional" flattens the difference between losing semantic + # recall entirely and losing a little ranking accuracy. + tag_markup = oc._t(*role["tag"]) if role.get("tag") else oc._t("[dim](optional)[/dim]", "[dim](可选)[/dim]") + lines = [f" [bold][accent]{oc._t(label_en, label_zh)}[/accent][/bold]" + (f" {tag_markup}" if optional else "")] + lines.append(f" [dim]{oc._t(purpose_en, purpose_zh)}[/dim]") + if role.get("cost"): + lines.append(f" [dim]{oc._t(*role['cost'])}[/dim]") + oc.console.print() + # highlight=False so Rich's default highlighter doesn't tint the dim prose + # (parens/numbers/words) and make an informational hint read like an error. + oc.console.print("\n".join(lines), highlight=False) + + while True: # role-menu loop — a back-out of the source picker returns here + current = _everos_section(section).get("model") if _everos_role_configured(section) else None + if current: + choices = [ + questionary.Choice(oc._t(f"Keep current: {current}", f"沿用当前:{current}"), value="keep"), + questionary.Choice(oc._t("Reconfigure", "重新配置"), value="redo"), + ] + if optional: + choices.append(questionary.Choice(oc._t("Skip", "跳过"), value="off")) + action = questionary.select( + oc._t("Already configured — what now?", "已配置,怎么处理?"), + choices=choices, + style=RAVEN_STYLE, + qmark=oc._QMARK, + ).ask() + if action is None: + raise typer.Exit(1) + if action == "keep": + return + if action == "off": + clear_everos_section(section) + oc.console.print(oc._t(f" [dim]{label_en} skipped.[/dim]", f" [dim]已跳过 {label_zh}。[/dim]")) + return + elif optional: + action = questionary.select( + oc._t("Configure it?", "要配置吗?"), + choices=[ + questionary.Choice(oc._t("Configure", "配置"), value="redo"), + questionary.Choice(oc._t("Skip", "跳过"), value="skip"), + ], + style=RAVEN_STYLE, + qmark=oc._QMARK, + ).ask() + if action is None: + raise typer.Exit(1) + if action == "skip": + # Printed verbatim rather than wrapped in [dim]: skipping rerank + # costs ordering, skipping embedding costs semantic recall + # entirely, and one of those deserves to be seen. + note_en, note_zh = role.get( + "skip_note", (f" [dim]Skipped {label_en}.[/dim]", f" [dim]已跳过 {label_zh}。[/dim]") + ) + oc.console.print(oc._t(note_en, note_zh), highlight=False) + return + # A required role with nothing configured falls straight into the picker. + + result = _everos_pick_creds_and_model( + section=section, + example=role["example"], + main_model=main_model, + non_interactive=non_interactive, + recommendation=role.get("recommendation"), + ) + if result is oc._BACK: + if optional or _everos_role_configured(section): + # Optional roles offer Skip; a required role already configured + # falls back to its keep/reconfigure menu. Either way, re-show + # the role menu rather than forcing the give-up exit. + continue + # A required role with nothing configured has no Skip, so backing out + # of the picker would loop forever. Offer a bounded exit -- keep + # trying, or leave without long-term memory. Stated in full and in + # colour: this is the only place the wizard can lose memory + # altogether, and "no cross-session memory" is a consequence a user + # should not discover weeks later by noticing the agent forgets + # everything. + oc.console.print() + oc.console.print( + oc._t( + f" [yellow]⚠ {label_en} is required for long-term memory.[/yellow]\n" + " [dim]Without it Raven has no memory across sessions: every conversation starts\n" + " from nothing, with no recollection of your preferences or of what was done before.[/dim]", + f" [yellow]⚠ {label_zh} 是长期记忆的必需项。[/yellow]\n" + " [dim]放弃后 Raven 没有任何跨会话记忆:每次对话都从零开始,不记得你的偏好,\n" + " 也不记得之前做过什么。[/dim]", + ), + highlight=False, + ) + action = questionary.select( + oc._t("What would you like to do?", "想做什么?"), + choices=[ + questionary.Choice(oc._t("Pick a provider / model", "选择服务商 / 模型"), value="retry"), + questionary.Choice( + oc._t("Give up (no long-term memory)", "放弃(不启用长期记忆)"), + value="abort", + ), + ], + style=RAVEN_STYLE, + qmark=oc._QMARK, + ).ask() + if action is None: + raise typer.Exit(1) + if action == "retry": + continue + return oc._ABORT_EVEROS + + if role["verify"] and skip_test: + oc.console.print( + oc._t( + f" [dim]Skipping the {verify_label} test call (--skip-test).[/dim]", + f" [dim]已跳过 {verify_label} 的测试调用(--skip-test)。[/dim]", + ) + ) + ok = True + elif section == "llm": + ok = _verify_everos_llm( + verify_label, + model=result["model"], + api_key=result["api_key"], + base_url=result["base_url"], + non_interactive=non_interactive, + warnings=warnings, + continue_hint=role.get("continue_hint"), + ) + elif section == "embedding": + ok = _verify_embedding_dim( + model=result["model"], + api_key=result["api_key"], + base_url=result["base_url"], + non_interactive=non_interactive, + ) + elif section == "rerank": + ok = _verify_rerank( + verify_label, + model=result["model"], + api_key=result["api_key"], + base_url=result["base_url"], + rerank_provider=result.get("provider"), + non_interactive=non_interactive, + warnings=warnings, + continue_hint=role.get("continue_hint"), + ) + elif section == "multimodal": + ok = _verify_everos_llm( + verify_label, + model=result["model"], + api_key=result["api_key"], + base_url=result["base_url"], + non_interactive=non_interactive, + warnings=warnings, + continue_hint=role.get("continue_hint"), + ) + else: + ok = True + if not ok: + continue + + set_everos_section(section, result) + oc.console.print( + oc._t( + f" [green]✓ {label_en} configured.[/green]", + f" [green]✓ 已配置 {label_zh}。[/green]", + ) + ) + return + + +def _step4_memory( + *, skip: bool, non_interactive: bool, main_model: Optional[str], warnings: list[str], skip_test: bool = False +) -> object: + """Step 4 -- EverOS long-term memory (model sub-screens). + + The bootstrap seeds ``memory.backend="everos"`` (schema default) and everos + is the only memory backend, so this step does not ask whether to enable it: + it either confirms the seed by configuring the llm role, or resolves it back + to ``None`` on skip / non-interactive / give-up. ``None`` means no long-term + memory at all, not a fallback to something simpler. + + ``_memory_enabled`` gates on the llm role alone, so a fresh modelless seed + reads as "not configured yet" and the keep/reconfigure menu only appears once + that model is actually on disk. embedding and rerank are offered here but + never gate: skipping them costs recall quality, not memory itself. + """ + oc._step_header(4, oc._t("EverOS long-term memory", "EverOS 长期记忆")) + + import sys + + if sys.platform == "win32": + oc.console.print( + oc._t( + " [yellow]⚠ EverOS memory engine does not support native Windows.[/yellow]\n" + " [dim]Run Raven inside WSL for full memory support.[/dim]\n" + " [dim]Skipping memory configuration.[/dim]", + " [yellow]⚠ EverOS 记忆引擎暂不支持 Windows 原生环境。[/yellow]\n" + " [dim]在 WSL 中运行 Raven 可获得完整记忆支持。[/dim]\n" + " [dim]已跳过记忆配置。[/dim]", + ) + ) + _set_memory_backend(None) + return None + + if skip or non_interactive: + # Never configured the required models here → disable backend-driven + # memory so runtime doesn't activate EverOS without an llm/embedding. + # (``_memory_enabled`` already gates on both required models, so an + # already-enabled+configured setup is preserved.) + if not _memory_enabled(): + _set_memory_backend(None) + oc.console.print( + oc._t( + " [dim]Long-term memory stays off.[/dim]", + " [dim]长期记忆保持关闭。[/dim]", + ) + ) + return None + + questionary = oc._require_questionary() + from raven.cli._styles import RAVEN_STYLE + + if _memory_enabled(): + action = questionary.select( + oc._t( + "EverOS long-term memory is already enabled. What would you like to do?", + "EverOS 长期记忆已启用。想做什么?", + ), + choices=[ + questionary.Choice(oc._t("Keep it enabled", "保持启用"), value="keep"), + questionary.Choice(oc._t("Reconfigure", "重新配置"), value="redo"), + ], + style=RAVEN_STYLE, + qmark=oc._QMARK, + ).ask() + if action is None: + raise typer.Exit(1) + if action == "keep": + return None # backend already "everos" + models on disk; leave as-is + else: + # No enable/decline question: everos is the only memory backend, so the + # step goes straight into configuring it. Leaving is still possible -- + # backing out of the required roles reaches the give-up prompt, which + # spells out what is lost. + # Wrapped by hand: rich re-wraps at the terminal width and drops the + # two-space indent on continuation lines, which reads as a stray + # left-flush sentence under an indented block. + oc.console.print( + oc._t( + " [dim]Raven's long-term memory comes from EverOS. What it can do grows with\n" + " what you configure:[/dim]\n" + " [dim] memory LLM only conversations become memories; recall matches keywords[/dim]\n" + " [dim] + memory embedding recall matches meaning, not wording (strongly advised)[/dim]\n" + " [dim] + memory rerank recall ordering gets sharper[/dim]", + " [dim]Raven 拥有 EverOS 提供的强大长期记忆能力,能力随配置递进:[/dim]\n" + " [dim] 仅记忆 LLM 对话会被提炼成记忆存下来,召回按关键词匹配[/dim]\n" + " [dim] + 记忆 embedding 召回按语义匹配,换个问法也能找到(强烈建议配)[/dim]\n" + " [dim] + 记忆 rerank 召回结果排序更准[/dim]", + ), + highlight=False, + ) + + # Ensure the EverOS home directory has its config templates (everos.toml + # + ome.toml) BEFORE writing model sections — set_everos_section merges + # into the template so default sections (memory/sqlite/lancedb/api) are + # preserved. Also creates ome.toml which the runtime requires. + from raven.config.update_everos import configure_everos_env, ensure_everos_home + + configure_everos_env() + ensure_everos_home() + + # Configure required models FIRST, then flip the backend on — so a Ctrl+C + # mid-configuration leaves backend at its prior (disabled) value rather + # than an enabled-but-modelless state. + for _role in ("llm", "embedding", "rerank", "multimodal"): + # Each role prints one leading blank before its own header, so no extra + # separator here — avoids the double blank line between roles. + outcome = _config_everos_role( + section=_role, + main_model=main_model, + non_interactive=non_interactive, + warnings=warnings, + skip_test=skip_test, + ) + if outcome is oc._ABORT_EVEROS: + _set_memory_backend(None) + oc.console.print( + oc._t( + " [yellow]⚠ Gave up long-term memory: Raven will not remember anything " + "between sessions.[/yellow]\n" + " [dim]Run `raven onboard` again whenever you want to configure it.[/dim]", + " [yellow]⚠ 已放弃长期记忆,Raven 不会记住任何跨会话内容。[/yellow]\n" + " [dim]随时可以重新运行 raven onboard 配置。[/dim]", + ) + ) + return None + + # Verify EverOS server is reachable (auto-starts if needed) + import asyncio + + from raven.plugin.memory.everos._server import ensure_everos_server + + oc.console.print() + oc.console.print( + oc._t( + " [dim]Starting EverOS service...[/dim]", + " [dim]正在启动 EverOS 服务...[/dim]", + ) + ) + try: + asyncio.run(ensure_everos_server()) + oc.console.print( + oc._t( + " [green]✓ EverOS service is running.[/green]", + " [green]✓ EverOS 服务已启动。[/green]", + ) + ) + except RuntimeError as exc: + oc.console.print( + oc._t( + f" [red]✗ EverOS service failed to start: {exc}[/red]\n" + " [dim]Check: everos installed? Port 18791 free? " + "See ~/.raven/logs/everos-server.log[/dim]", + f" [red]✗ EverOS 服务启动失败:{exc}[/red]\n" + " [dim]请检查:everos 是否安装?端口 18791 是否被占用?" + "查看 ~/.raven/logs/everos-server.log[/dim]", + ) + ) + retry = questionary.select( + oc._t("What to do?", "怎么办?"), + choices=[ + questionary.Choice(oc._t("Retry", "重试"), value="retry"), + questionary.Choice(oc._t("Skip (memory disabled)", "跳过(记忆禁用)"), value="skip"), + ], + style=RAVEN_STYLE, + qmark=oc._QMARK, + ).ask() + if retry == "retry": + # Recurse once — the loop in _step4_memory handles further retries + try: + asyncio.run(ensure_everos_server()) + oc.console.print( + oc._t( + " [green]✓ EverOS service is running.[/green]", + " [green]✓ EverOS 服务已启动。[/green]", + ) + ) + except RuntimeError: + oc.console.print( + oc._t( + " [red]✗ Still failed. Disabling memory.[/red]", + " [red]✗ 仍然失败。禁用记忆功能。[/red]", + ) + ) + _set_memory_backend(None) + return None + else: + _set_memory_backend(None) + return None + _report_everos_capabilities() + _set_memory_backend("everos") + return None + + +def _report_everos_capabilities() -> None: + """Say what the running server can actually do, not just that it answers. + + ``ensure_everos_server`` proves the process is up and nothing more. Since + everos 1.2.1 a server whose embedding provider failed to build still answers + 200 and degrades to keyword-only search, so stopping at "running" would + print a tick over an install that cannot recall anything. The roles were + each verified against their provider earlier in this step; what is new here + is whether everos itself could build them from what got written to + ``everos.toml``. + + Silent on a server too old to report capabilities -- reading that silence as + "unavailable" would condemn a working install. + """ + from raven.config.raven import load_raven_config + from raven.plugin.memory.everos._health import ( + DEGRADING_SECTIONS, + REQUIRED_SECTIONS, + configured_base_url, + probe_capabilities, + ) + + # The configured address, not the default: probing the wrong port reports on + # a server nobody is using, and reads as "not running". + report = probe_capabilities(configured_base_url(load_raven_config())) + if not report.reports_capabilities: + return + configured = [s for s in (*REQUIRED_SECTIONS, *DEGRADING_SECTIONS) if _everos_role_configured(s)] + broken = [s for s in configured if report.available(s) is False] + if not broken: + names = " and ".join(configured) + oc.console.print( + oc._t( + f" [green]✓ {names} {'is' if len(configured) == 1 else 'are'} available.[/green]", + f" [green]✓ {names} 均可用。[/green]", + ) + ) + return + names = " and ".join(broken) + oc.console.print( + oc._t( + f" [yellow]⚠ {names} is configured but EverOS could not build it.[/yellow]\n" + " [dim]Memory runs degraded until this is fixed.[/dim]\n" + f" [dim]Check: {_everos_server_log_hint()}[/dim]", + f" [yellow]⚠ {names} 已配置,但 EverOS 未能构建成功。[/yellow]\n" + " [dim]在此修复前,记忆能力将处于降级状态。[/dim]\n" + f" [dim]请查看:{_everos_server_log_hint()}[/dim]", + ) + ) + + +def _everos_server_log_hint() -> str: + from raven.plugin.memory.everos._server import server_log_path + + return str(server_log_path()) diff --git a/raven/cli/provider_commands.py b/raven/cli/provider_commands.py index 76b1cda4..e869cea4 100644 --- a/raven/cli/provider_commands.py +++ b/raven/cli/provider_commands.py @@ -15,6 +15,16 @@ also lose their token file - ``provider show `` — reflect available ``--flag`` fields +Endpoint subcommands (``provider endpoint ...``) manage a plain API-key +provider's ``endpoints`` list -- several full key/base/header groups under one +section, for a vendor reachable by more than one account or region. OAuth +providers and Azure OpenAI / OpenAI Codex reject this at startup; only vendors +reached through the plain LiteLLM client accept it: + +- ``provider endpoint add --label X --api-key ... [--api-base ...]`` +- ``provider endpoint remove --label X`` +- ``provider endpoint list `` + Architecture: write operations go ONLY through :mod:`raven.config.update_providers`. Command bodies do not import ``load_config`` / ``save_config`` / provider Pydantic classes. @@ -25,6 +35,7 @@ from __future__ import annotations +import json import os import sys from typing import Any @@ -270,9 +281,16 @@ def _parse_provider_flags(extra_args: list[str], provider_name: str) -> dict[str - ``--api-key abc`` -> ``{"api_key": "abc"}`` - ``--api-key=abc`` -> ``{"api_key": "abc"}`` - ``--api-base X`` -> ``{"api_base": "X"}`` (kebab -> snake) - - ``--vertex true`` -> ``{"vertex": True}`` (bool string coerced) - - ``--no-vertex`` -> ``{"vertex": False}`` (bool negative) - - ``--vertex`` alone -> ``{"vertex": True}`` (bool positive) + - ``-- true`` -> ``{"": "true"}`` (string; the schema coerces) + - ``--no-`` -> ``{"": False}`` (bool negative) + - ``--`` alone -> ``{"": True}`` (bool positive) + + Values come back as written; only the two valueless forms produce a bool + here, and the schema coerces the rest on validation. The bool forms are + named generically because no provider declares a bool field today -- the one + that did (Gemini's ``vertex``) described a mechanism that never existed and + was removed. They stay, matching ``_parse_channel_flags``, so a provider + gaining one needs no parser change. Unknown fields raise ``typer.BadParameter`` pointing at ``provider show``. """ @@ -333,6 +351,42 @@ def _normalize(flag: str) -> str: return out +def _load_section(name: str) -> dict | None: + """This provider's stored fields, or None when it has no section yet.""" + from raven.config.update_providers import get_provider_config + + try: + return get_provider_config(name, redact_secrets=False) + except KeyError: + return None + + +def _ineffective_because(provider: str, model: str) -> list[str]: + """Reasons this model will not actually be used, despite being written. + + A stale ``agents.defaults.provider`` used to be the other one, and the note + for it told the user to edit a field no command wrote. Both surfaces that + change a model now write it by the same rule, so the note would be advice + about a state neither of them produces. + """ + from raven.config.loader import load_config + + try: + config = load_config() + except Exception: + return [] + + notes: list[str] = [] + section = config.providers.get(provider) + deployment = getattr(section, "deployment", "") if section else "" + if deployment: + notes.append( + f"providers.{provider}.deployment is set to {deployment!r}, which decides the " + f"deployment regardless of the model id." + ) + return notes + + def _register_config_commands(app: typer.Typer) -> None: """Attach config subcommands to ``provider_app``.""" app.info.no_args_is_help = True @@ -413,7 +467,7 @@ def provider_set_cmd( raven provider set openrouter --api-key sk-or-v1-... raven provider set azure-openai --api-key X --api-base https://... - raven provider set gemini --api-key K --vertex true + raven provider set gemini --api-key-list k1,k2 """ if _help_requested(ctx.args): _print_schema_table(name) @@ -480,6 +534,14 @@ def provider_test_cmd( "oauth_token_missing": (f"Run: raven provider login {name.replace('_', '-')}"), "network_error": "Check network / firewall / VPN settings", } + if result["status"] == "no_probe_endpoint": + # Not a failure: this probe pings `/models`, and these vendors do not + # publish one at an address we hold. Saying "failed" here told seven + # correctly configured providers they were broken. + console.print(f"[yellow]?[/yellow] {name} not probed: {result['error']}") + console.print(" [dim]Credentials are set; run a turn to exercise them.[/dim]") + return + hint = hints.get(result["status"], "") console.print(f"[red]✗[/red] {name} failed: {result['status']}") if hint: @@ -488,6 +550,76 @@ def provider_test_cmd( console.print(f" [dim]Detail: {result['error']}[/dim]") raise typer.Exit(1) + @app.command("use") + def provider_use_cmd( + model: str = typer.Argument(..., help="Model id, e.g. anthropic/claude-sonnet-5"), + provider: str = typer.Option("", "--provider", "-p", help="Provider serving it, when the id does not say"), + ): + """Make this the model the agent runs on. + + Changing it used to mean re-running the whole wizard: the TUI picker and + onboarding could both switch models and the CLI could not, so a user on a + headless box had six setup steps to walk to change one field. + + The id is stored the way every other surface stores it -- naming its + provider -- so the three cannot disagree about what was chosen. + """ + from raven.config.loader import load_config + from raven.config.update import set_default_model + from raven.providers import pin + from raven.providers.auth import credential_status + from raven.providers.catalog import describe + from raven.providers.wire import stored_model_id + + try: + pinned = load_config().agents.defaults.provider or "" + except Exception: + pinned = "" + # One rule for both entry points: the picker writes this field, and the + # CLI used to tell the user to hand-edit it instead. + resolved = pin.resolve(model, provider=provider, pinned=pinned) + if resolved is None: + console.print(f"[red]✗[/red] cannot tell which provider serves {model!r}.") + console.print(f" [dim]Write it as /{model}, or pass --provider.[/dim]") + raise typer.Exit(1) + + name = provider or (resolved if resolved != pin.AUTO else "") + if not name: + from raven.providers.registry import split_model_id + + name = split_model_id(model)[0] + + stored = stored_model_id(name, model) if name else model + previous = set_default_model(stored, provider=resolved) + + row = describe(name, stored) + label = f"{row.label} ([dim]{stored}[/dim])" if row.described else stored + console.print(f"[green]✓[/green] default model: {label}") + if previous and previous != stored: + console.print(f" [dim]was {previous}[/dim]") + + # Reported rather than refused: choosing a model before configuring its + # provider is a normal order to do things in, and the startup gate says + # the same thing again if it is still missing then. + status = credential_status(name, _load_section(name), include_external=True) + if not status.ok: + console.print(f" [yellow]![/yellow] {status.summary}") + if resolved == pin.AUTO: + # "buy a key from this vendor" is the wrong advice for someone + # already paying a gateway to serve that vendor's models. Routing + # is left on auto precisely so the gateway can answer. + console.print( + f" [dim]Routing stays on auto, so a configured gateway can serve it. " + f"To pin the gateway instead, write it as /{stored}.[/dim]" + ) + + # An Azure deployment can still make this write have no effect, and it + # fails silently otherwise: the command reports success, the file + # changes, and requests keep going where they went before. The stale-pin + # case used to be the other one; this command now writes the pin itself. + for note in _ineffective_because(name, stored): + console.print(f" [yellow]![/yellow] {note}") + @app.command("reset") def provider_reset_cmd( name: str = typer.Argument(..., help="Provider name"), @@ -570,4 +702,125 @@ def provider_show_cmd( _register_config_commands(provider_app) +endpoint_app = typer.Typer( + help=( + "Manage a provider's endpoints -- several full key/base/header groups " + "under one section, for a vendor reachable by more than one account or " + "region. Only plain API-key providers accept this: a provider using " + "OAuth, or Azure OpenAI / OpenAI Codex, is rejected at startup if it has " + "any configured." + ) +) + + +def _parse_extra_headers(value: str) -> dict[str, str] | None: + """Parse ``--extra-headers`` JSON into a dict, or None when unset.""" + if not value: + return None + try: + parsed = json.loads(value) + except json.JSONDecodeError: + raise typer.BadParameter('--extra-headers must be a JSON object, e.g. \'{"X-Foo": "bar"}\'') + if not isinstance(parsed, dict): + raise typer.BadParameter("--extra-headers must be a JSON object") + return parsed + + +@endpoint_app.command("add") +def endpoint_add_cmd( + name: str = typer.Argument(..., help="Provider name (e.g. openrouter)"), + label: str = typer.Option(..., "--label", help="Idempotency key: an existing label is replaced, not merged"), + api_key: str = typer.Option( + "", "--api-key", help="API key for this endpoint (omit only for a local, keyless deployment)" + ), + api_base: str = typer.Option("", "--api-base", help="Base URL for this endpoint"), + extra_headers: str = typer.Option("", "--extra-headers", help='Extra headers as JSON, e.g. {"X-Foo": "bar"}'), +): + """Add or replace one endpoint on a provider, keyed by ``--label``. + + Only meaningful for plain API-key providers reached through the LiteLLM + client -- a provider using OAuth, or Azure OpenAI / OpenAI Codex, refuses + to start with any endpoints configured. + """ + from pydantic import ValidationError + + from raven.config.update_providers import add_provider_endpoint + + headers = _parse_extra_headers(extra_headers) + try: + endpoints = add_provider_endpoint( + name, + label=label, + api_key=api_key, + api_base=api_base or None, + extra_headers=headers, + ) + except (KeyError, RuntimeError) as exc: + console.print(f"[red]✗[/red] {exc}") + raise typer.Exit(1) + except ValidationError as exc: + console.print(f"[red]✗ Validation failed:[/red]\n{exc}") + raise typer.Exit(1) + + console.print(f"[green]✓[/green] {name} endpoint {label!r} saved ({len(endpoints)} total)") + + +@endpoint_app.command("remove") +def endpoint_remove_cmd( + name: str = typer.Argument(..., help="Provider name"), + label: str = typer.Option(..., "--label", help="Label of the endpoint to remove"), +): + """Remove one endpoint by ``--label`` (no-op if the label is not present).""" + from pydantic import ValidationError + + from raven.config.update_providers import remove_provider_endpoint + + try: + endpoints = remove_provider_endpoint(name, label) + except KeyError as exc: + console.print(f"[red]✗[/red] {exc}") + raise typer.Exit(1) + except ValidationError as exc: + console.print(f"[red]✗ Validation failed:[/red]\n{exc}") + raise typer.Exit(1) + + console.print(f"[green]✓[/green] {name} endpoint {label!r} removed ({len(endpoints)} remaining)") + + +@endpoint_app.command("list") +def endpoint_list_cmd( + name: str = typer.Argument(..., help="Provider name"), +): + """List a provider's endpoints. API keys redacted.""" + from pydantic import ValidationError + + from raven.config.update_providers import list_provider_endpoints + + try: + endpoints = list_provider_endpoints(name) + except KeyError as exc: + console.print(f"[red]✗[/red] {exc}") + raise typer.Exit(1) + except ValidationError as exc: + console.print(f"[red]✗ Validation failed:[/red]\n{exc}") + raise typer.Exit(1) + + table = Table(title=f"Provider endpoints: {name}") + table.add_column("Label", style="cyan", no_wrap=True) + table.add_column("API Key") + table.add_column("API Base", overflow="fold") + table.add_column("Extra Headers", overflow="fold") + for ep in endpoints: + table.add_row( + ep["label"], + ep["api_key"], + ep["api_base"] or "", + str(ep["extra_headers"]) if ep["extra_headers"] else "", + ) + console.print(table) + + +provider_app.add_typer(endpoint_app, name="endpoint") + + __all__ = ["provider_app"] diff --git a/raven/cli/sentinel_commands.py b/raven/cli/sentinel_commands.py index e0c0efa0..3f8f8ed1 100644 --- a/raven/cli/sentinel_commands.py +++ b/raven/cli/sentinel_commands.py @@ -179,9 +179,6 @@ def sentinel_tick( base_config = ec_config.base ws = Path(workspace) if workspace else get_workspace_path() provider = make_provider(base_config) - # make_provider returns a tuple in some versions; normalise. - if isinstance(provider, tuple): - provider = provider[0] model = provider.get_default_model() # Frozen clock for eval. ``_kwargs`` is unpacked into every constructor @@ -1139,8 +1136,7 @@ def sentinel_behaviors_rebuild( "[yellow]behaviors_extract.enabled is False in config — " "rebuild will run but no future ticks will refresh.[/yellow]", ) - provider_pair = make_provider(cfg.base) - provider = provider_pair[0] if isinstance(provider_pair, tuple) else provider_pair + provider = make_provider(cfg.base) model = cfg.sentinel.behaviors_extract.model or cfg.sentinel.evaluator_model or provider.get_default_model() store = MemoryStore(ws) session_manager = SessionManager(ws) diff --git a/raven/cli/status_commands.py b/raven/cli/status_commands.py index a85b0705..6d3beba2 100644 --- a/raven/cli/status_commands.py +++ b/raven/cli/status_commands.py @@ -49,8 +49,14 @@ def status(): api_base = (section.api_base if section else None) or info["api_base"] state = f"[green]✓ {api_base}[/green]" if api_base else "[dim]not set[/dim]" else: - configured = bool(section and section.api_key) or info["configured"] - state = "[green]✓[/green]" if configured else "[dim]not set[/dim]" + # `providers.auth`, like every other gate. Reading `api_key` + # here made this the one place that called Azure configured + # with a key and no address, and missed a Gemini section + # holding only `api_key_list`. + from raven.providers.auth import credential_status + + status = credential_status(info["name"], section, include_external=True) + state = "[green]✓[/green]" if status.ok else "[dim]not set[/dim]" console.print(f"{label}: {state}") diff --git a/raven/cli/tui_commands.py b/raven/cli/tui_commands.py index caf78ee0..3c53367e 100644 --- a/raven/cli/tui_commands.py +++ b/raven/cli/tui_commands.py @@ -397,6 +397,7 @@ def _build_tui_agent_loop(): """ from pydantic import ValidationError + from raven.providers.auth import MissingCredentialsError from raven.tui_rpc.errors import InternalError try: @@ -479,6 +480,18 @@ def _build_tui_agent_loop(): # scheduler and its reply is fanned out as a cron.delivered event. return agent_loop + except MissingCredentialsError as e: + # Not a crash: the install simply is not finished. Surfaced as the + # sentence that says which provider needs what, where the generic + # handler below reported `exception_message: "1"` -- `typer.Exit` + # stringified -- and put the real one in a log file. + from loguru import logger as _logger + + _logger.warning("tui: provider not usable: {}", e.summary) + raise InternalError( + e.summary, + data={"reason": "missing_credentials", "provider": e.provider, "remedy": e.remedy}, + ) from e except (*_TUI_INIT_CRASH_TYPES, ValidationError) as e: from loguru import logger as _logger diff --git a/raven/config/schema.py b/raven/config/schema.py index b4cefb30..5c53414e 100644 --- a/raven/config/schema.py +++ b/raven/config/schema.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Any, Literal -from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator, model_validator from pydantic.alias_generators import to_camel from pydantic_settings import BaseSettings @@ -249,7 +249,10 @@ class AgentDefaults(Base): model: str = "anthropic/claude-opus-4-5" provider: str = "auto" # Provider name (e.g. "anthropic", "openrouter") or "auto" for auto-detection max_tokens: int = 8192 - context_window_tokens: int = 65_536 + # None (or 0) means "figure it out" -- resolved against the model's real + # window at construction time. A positive value pins the window, taking + # priority over whatever the model's own catalogue reports. + context_window_tokens: int | None = None temperature: float = 0.1 # Per-call wall-clock cap (seconds) for every LLM request (main loop and # sub-agents). Bounds a stalled backend that trickles bytes without ever @@ -280,6 +283,10 @@ class AgentDefaults(Base): # name: {"kimi-k2.5": {"temperature": 1.0}}. Some models reject the usual # defaults, and hard-coding those quirks in the registry left users unable to # adjust them. Entries here win over the registry's built-in defaults. + # This is also the direct channel for arbitrary sampling/serving params: an + # unknown top-level key is auto-forwarded into extra_body by LiteLLM for + # OpenAI-compatible backends (e.g. sglang's repetition_penalty); a nested + # structure can be written directly as extra_body: {...}. model_overrides: dict[str, dict[str, Any]] = Field(default_factory=dict) enable_personalization: bool = False # 4-step PAHF-inspired personalization flow (classify → ask → execute → learn) @@ -314,47 +321,139 @@ class CronConfig(Base): """Default IANA timezone for cron expressions without explicit ``--tz``.""" +class ModelOverlay(Base): + """A name for a model no catalogue carries. + + A self-hosted deployment serves whatever was put there, and a model released + since the bundled snapshot is in no table yet, so the picker falls back to + showing the id. That is usually fine -- the id is the name the user gave + their own deployment -- but it leaves no way to label several of them. + + Only what a person states about presentation. Token accounting is not in + scope here -- `agents.defaults.contextWindowTokens` / `maxTokens` already + hold it. What has no knob at all is a *price* for an endpoint no catalogue + prices; such a deployment reports unknown spend rather than borrowing a + hosted model's rate. Adding one is a separate ask. + """ + + label: str = "" + description: str = "" + + +class ProviderEndpoint(Base): + """One named URL/key group under a provider section. + + ``label`` is not decoration: it is the idempotency key a later stage + (rotation, failover, per-endpoint health) uses to address one entry across + edits, so two endpoints in the same list must not share one. + """ + + label: str = Field(min_length=1) + api_key: str = "" + api_base: str | None = None + extra_headers: dict[str, str] | None = None + + class ProviderConfig(Base): """LLM provider configuration.""" api_key: str = "" api_base: str | None = None - extra_headers: dict[str, str] | None = None # Custom headers (e.g. APP-Code for AiHubMix) + # Custom headers (e.g. APP-Code for AiHubMix) -- can carry a secret, so + # display faces redact the values (keys stay visible). + extra_headers: dict[str, str] | None = Field(default=None, json_schema_extra={"secret": True}) models: list[str] = Field(default_factory=list) # User-curated model names for the picker + # Several full url/key/header groups under one provider section, for a + # vendor reachable by more than one account or region. Meaningful only for + # a plain API-key provider reached through the litellm client -- a section + # whose auth is OAuth, or that needs more than a key and an address (Azure + # OpenAI, Codex), gets this rejected at `make_provider` construction time + # (wired in a later stage; this field exists regardless). Set and non-empty, + # it replaces the flat `api_key` outright rather than merging with it; an + # entry inherits the flat `api_base`/`extra_headers` for whichever it does + # not name itself -- see `raven.providers.endpoints.provider_endpoints` for the + # one place that resolves which of the two shapes (or Gemini's + # `api_key_list`) is in effect. + endpoints: list[ProviderEndpoint] = Field(default_factory=list) + + @field_validator("endpoints") + @classmethod + def _unique_endpoint_labels(cls, value: list[ProviderEndpoint]) -> list[ProviderEndpoint]: + """Reject a duplicate label -- see the class docstring for why one must be unique.""" + seen: set[str] = set() + for ep in value: + if ep.label in seen: + raise ValueError(f"duplicate endpoint label {ep.label!r}: labels must be unique within a provider") + seen.add(ep.label) + return value + + # How requests spread across `endpoints` when there is more than one: + # "sticky" keeps using the first healthy entry until it fails, "round_robin" + # cycles through all of them. Meaningless with zero or one endpoint. + endpoint_strategy: Literal["sticky", "round_robin"] = "sticky" + # Keyed by model id, in any spelling: what the user knows about a model that + # the catalogues do not. Deliberately additive rather than a change to + # `models` -- that list already lets a model be added, and what was missing + # was a way to describe one, so no config has to be rewritten to get it. + model_overlay: dict[str, ModelOverlay] = Field(default_factory=dict) + + @property + def effective_api_key(self) -> str: + """The key to send, which is not always the ``api_key`` field. + + Declared on the base so every call site can ask without knowing which + providers keep their key somewhere else. Gemini accepts a list, and a + section holding only that list handed LiteLLM an empty string: the + request left with no credential and failed at the API, having passed + every check that only asked whether credentials existed. + """ + return self.api_key + + +class AzureProviderConfig(ProviderConfig): + """Azure OpenAI, whose connection needs more than a key and an address. + + A deployment is a name the tenant gives one model, and it goes into the + request URL's path. It used to be read off ``agents.defaults.model``, which + made a model id double as a connection parameter: the id could carry no + prefix without the prefix landing in the path, so Azure was the one provider + whose ids had to be spelled differently from everyone else's. Declared here, + the model id is free to be a model id. + + ``api_version`` was hardcoded in the client, so a tenant on a different one + had no way to say so. + """ + + deployment: str = "" # falls back to the model id, for configs written before this field + api_version: str = "2024-10-21" class GeminiProviderConfig(ProviderConfig): - """Gemini provider configuration with Vertex AI and multi-key support. + """Gemini, which accepts several keys under one section. - Example YAML: + Example: gemini: - vertex: true - api_key_list: + apiKeyList: - "key1" - "key2" - """ - vertex: bool = False # When true, sets GOOGLE_GENAI_USE_VERTEXAI=True for Vertex AI - api_key_list: list[str] = Field(default_factory=list) # Multiple API keys for rotation - - def next_api_key(self) -> str: - """Return the next API key using round-robin rotation. - - Falls back to single api_key if api_key_list is empty. - """ - import itertools + A ``vertex`` flag used to sit here, documented as setting + ``GOOGLE_GENAI_USE_VERTEXAI``. Nothing read it, and it could not have worked: + that variable belongs to the google-genai SDK, while requests go through + LiteLLM, which does not read it and reaches Vertex as a separate provider + (``vertex_ai``) needing ``VERTEXAI_PROJECT`` and ``VERTEXAI_LOCATION``. It was + settable from the CLI and covered by tests, so it read as a supported feature + while doing nothing at all. Reaching Vertex is a change to how a request is + routed, not a boolean on a key. + """ - if not hasattr(self, "_key_cycle"): - keys = self.api_key_list if self.api_key_list else ([self.api_key] if self.api_key else []) - object.__setattr__(self, "_key_cycle", itertools.cycle(keys) if keys else None) - cycle = getattr(self, "_key_cycle", None) - if cycle is None: - return self.api_key or "" - return next(cycle) + #: Several keys may be listed; the first is used. Round-robin rotation was + #: declared here once and never called -- listing keys and silently using one + #: is the honest description of what happens. + api_key_list: list[str] = Field(default_factory=list) @property def effective_api_key(self) -> str: - """Get the current effective API key (first from list, or single key).""" if self.api_key_list: return self.api_key_list[0] return self.api_key @@ -380,7 +479,7 @@ def _prefer_set_values(base: dict[str, Any], winner: dict[str, Any]) -> dict[str return merged -def _has_credentials(config: "ProviderConfig", spec: Any) -> bool: +def _has_credentials(config: "ProviderConfig", spec: Any, name: str = "") -> bool: """Is this section actually usable, or just a placeholder? Every declared provider exists as an empty section whether or not the user @@ -388,12 +487,18 @@ def _has_credentials(config: "ProviderConfig", spec: Any) -> bool: stand in for evidence either: `is_local` used to answer with no api_base at all, and an empty declared section then beat the credentials the user had really written under one of that provider's other names. + + The rule itself lives in `providers.auth`, because deciding it here as well + is what made a Gemini section holding only `api_key_list` invisible to + routing while `provider list` showed it as configured. + + A vendor Raven carries no spec for reaches this too -- the passthrough route, + where the section name is all there is -- so the name is passed separately + rather than read off a spec that may not exist. """ - if spec.is_oauth: - return True # credentials live in a token file, not the config section - if spec.is_local: - return bool(config.api_base) - return bool(config.api_key) + from raven.providers.auth import credential_status + + return credential_status(name or (spec.name if spec else ""), config, spec=spec).ok class ProvidersConfig(Base): @@ -452,7 +557,7 @@ def _merge_renamed_sections(cls, data: Any) -> Any: return merged custom: ProviderConfig = Field(default_factory=ProviderConfig) # Any OpenAI-compatible endpoint - azure_openai: ProviderConfig = Field(default_factory=ProviderConfig) # Azure OpenAI (model = deployment name) + azure_openai: AzureProviderConfig = Field(default_factory=AzureProviderConfig) # Azure OpenAI anthropic: ProviderConfig = Field(default_factory=ProviderConfig) openai: ProviderConfig = Field(default_factory=ProviderConfig) openrouter: ProviderConfig = Field(default_factory=ProviderConfig) @@ -763,7 +868,13 @@ def effective_media_config(self) -> MediaGenConfig: def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | None", str | None]: """Match provider config and its registry name. Returns (config, spec_name).""" - from raven.providers.registry import PROVIDERS, canonical_provider_name, find_by_keywords, split_model_id + from raven.providers.registry import ( + PROVIDERS, + canonical_provider_name, + find_by_keywords, + find_by_name, + split_model_id, + ) forced = self.agents.defaults.provider if forced != "auto": @@ -789,9 +900,15 @@ def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | N # Explicit prefix naming a provider Raven has no spec for: LiteLLM knows # the vendor, so credentials under that name are enough to reach it. - if prefix: + # + # Only where there is genuinely no spec. A provider that has one has + # already been offered above and turned down for want of credentials -- + # letting it back in here on `api_key` alone reinstated exactly the + # material this rejected it for missing: Azure with a key and no address + # routed here, while display and startup both called it unconfigured. + if prefix and find_by_name(prefix) is None: passthrough = self.providers.get(prefix) - if passthrough and passthrough.api_key: + if passthrough and _has_credentials(passthrough, None, prefix): return passthrough, canonical_provider_name(prefix) # Fallback: configured local providers can route models without @@ -821,7 +938,7 @@ def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | N if spec.is_oauth: continue p = self.providers.get(spec.name) - if p and p.api_key: + if p and _has_credentials(p, spec): return p, spec.name return None, None @@ -838,7 +955,7 @@ def get_provider_name(self, model: str | None = None) -> str | None: def get_api_key(self, model: str | None = None) -> str | None: """Get API key for the given model. Falls back to first available key.""" p = self.get_provider(model) - return p.api_key if p else None + return p.effective_api_key if p else None def get_api_base(self, model: str | None = None) -> str | None: """Get API base URL for the given model. Applies default URLs for gateway/local providers.""" @@ -851,8 +968,8 @@ def get_api_base(self, model: str | None = None) -> str | None: # (like Moonshot) set their base URL via env vars in _setup_env. if name: spec = find_by_name(name) - if spec and (spec.is_gateway or spec.is_local) and spec.default_api_base: - return spec.default_api_base + if spec and spec.usable_default_api_base: + return spec.usable_default_api_base return None @property diff --git a/raven/config/update.py b/raven/config/update.py index 4b952789..9d0a2b33 100644 --- a/raven/config/update.py +++ b/raven/config/update.py @@ -185,6 +185,7 @@ def set_language( def set_default_model( model: str, *, + provider: str | None = None, config_path: Path | None = None, ) -> str | None: """Patch ``agents.defaults.model`` on the on-disk config. Returns previous value. @@ -193,14 +194,22 @@ def set_default_model( needs to swap the default model to one that matches the chosen provider (otherwise ``raven agent`` would still route to whatever the freshly created ``Config()`` baked in, which is typically a different vendor). + + ``provider`` writes ``agents.defaults.provider`` in the same patch. That field + overrides what a model id says, so leaving it behind lets a stale pin route + the new model to the old vendor -- with the old vendor's key -- while the + write that was just reported as successful changes nothing. Callers that do + not know which provider serves the model pass None and leave it alone. """ path = config_path or get_config_path() data = read_raw_or_raise(path) defaults = data.setdefault("agents", {}).setdefault("defaults", {}) prev = defaults.get("model") defaults["model"] = model + if provider is not None: + defaults["provider"] = provider _write_atomic(path, data) - logger.info("config/update: default model set to {} (was {})", model, prev) + logger.info("config/update: default model set to {} (was {}), provider={}", model, prev, provider) return prev diff --git a/raven/config/update_providers.py b/raven/config/update_providers.py index 95ba9ea1..60a3fbae 100644 --- a/raven/config/update_providers.py +++ b/raven/config/update_providers.py @@ -29,10 +29,14 @@ from pydantic_core import PydanticUndefined from raven.config.loader import get_config_path, read_raw_or_raise -from raven.config.schema import ProviderConfig, ProvidersConfig +from raven.config.schema import ProviderConfig, ProviderEndpoint, ProvidersConfig +from raven.providers.endpoints import provider_endpoints from raven.providers.registry import ( + CRED_LOCAL, ProviderSpec, canonical_provider_name, + credential_kind, + endpoints_unsupported_reason, find_by_name, names_same_provider, normalize_provider_name, @@ -414,14 +418,52 @@ def _set_nested(dotted_key: str, value: Any, target: dict[str, Any]) -> Any: def _redact(value: Any) -> Any: - """Redact a single value or list of values.""" + """Redact a single value, list of values, or dict of values (per value, + keys left visible -- see ``_redact_headers``).""" if value in (None, "", [], {}): return "(empty)" if isinstance(value, list): return ["****set****" for _ in value] + if isinstance(value, dict): + return _redact_headers(value) return "****set****" +def _redact_headers(headers: dict[str, str] | None) -> dict[str, str] | None: + """Redact each header's value, keeping the key names visible. + + ``extra_headers`` can carry a secret (an auth header some gateways need + alongside the key) -- masking the whole dict as one ``****set****`` string + would also hide which headers are configured, so each value is redacted on + its own, the same rule every other secret field follows. + """ + if headers is None: + return None + return {key: _redact(value) for key, value in headers.items()} + + +def _redact_nested_model(instance: BaseModel) -> BaseModel: + """Redact this model's own secret fields, by the same rule as the flat ones. + + ``_flatten_instance`` only recurses into ``BaseModel`` fields, not into a + ``list[BaseModel]`` field like ``ProviderConfig.endpoints`` -- so a caller + that walks ``specs`` (field-name keyed) never sees a per-endpoint field and + can't redact it. This is applied to each list element instead. + + ``extra_headers`` gets its own rule rather than ``_is_secret_field``'s: it + is a dict of values, not one, and masking the whole thing would also hide + which headers are configured -- see ``_redact_headers``. + """ + updates = { + fname: _redact(getattr(instance, fname)) + for fname, finfo in type(instance).model_fields.items() + if _is_secret_field(fname, finfo) + } + if "extra_headers" in type(instance).model_fields: + updates["extra_headers"] = _redact_headers(getattr(instance, "extra_headers", None)) + return instance.model_copy(update=updates) if updates else instance + + #: Copilot's credentials are two files LiteLLM owns, not one: the device-flow #: access token and the short-lived API key it is exchanged for. _COPILOT_TOKEN_FILES = ("access-token", "api-key.json") @@ -436,22 +478,9 @@ def _oauth_token_path(provider_name: str) -> Path: has a way to override its directory, so a second derivation is wrong exactly when a user has taken one. """ - from raven.config.paths import get_oauth_dir + from raven.providers.auth import credential_files - if provider_name == "github_copilot": - return _copilot_token_dir() / _COPILOT_TOKEN_FILES[0] - - if provider_name == "openai_codex": - from raven.providers.chatgpt_token import auth_file - - return auth_file() - - if provider_name in {"minimax_global", "minimax_cn"}: - from raven.providers.minimax_oauth import token_path - - return token_path("global" if provider_name == "minimax_global" else "cn") - - return get_oauth_dir() / f"{provider_name}.json" + return credential_files(provider_name)[0] def _copilot_token_dir() -> Path: @@ -506,11 +535,9 @@ def oauth_credential_files(provider_name: str) -> list[Path]: working credential -- and a sign-in has to restrict all of them, for the same reason in the other direction. """ - if provider_name == "github_copilot": - token_dir = _copilot_token_dir() - return [token_dir / filename for filename in _COPILOT_TOKEN_FILES] + from raven.providers.auth import credential_files - return [_oauth_token_path(provider_name)] + return credential_files(provider_name) # --------------------------------------------------------------------------- @@ -582,16 +609,38 @@ def list_providers(*, config_path: Path | None = None) -> list[dict[str, Any]]: api_key = getattr(instance, "api_key", "") or "" api_base = getattr(instance, "api_base", None) api_key_list = list(getattr(instance, "api_key_list", []) or []) + endpoints = list(getattr(instance, "endpoints", []) or []) + # One rule for every gate: this used to accept a Gemini section holding + # only `api_key_list` that routing then skipped and startup refused. + from raven.providers.auth import credential_status + + configured = credential_status(fname, instance, spec=spec, include_external=True).ok if is_oauth: - configured = _oauth_credentials_present(fname) api_key_redacted = "OAuth token" if configured else "(empty)" + # Mirrors the reader's own precedence (endpoints > api_key_list > flat, + # see `provider_endpoints`): a stale flat key left behind by an + # `endpoints` migration must not display as "****set****" while + # `configured` -- decided the same way `credential_status` decides it, + # off the endpoints list -- says otherwise. Checked before the flat/list + # branch below, which used to run first and made this branch, and the + # "(N endpoints)" it reports, unreachable whenever a flat key lingered. + # Local deployments reach here too: keyless endpoints are their normal + # shape, so the count must not read as a misconfiguration. + elif endpoints: + suffix = f"({len(endpoints)} endpoints)" + if is_local: + api_key_redacted = f"(not needed for local) {suffix}" + elif any(ep.api_key for ep in endpoints): + api_key_redacted = f"****set**** {suffix}" + else: + api_key_redacted = f"(empty) {suffix}" elif is_local: - configured = bool(api_base) or bool(api_key) api_key_redacted = "(not needed for local)" if not api_key else "****set****" + elif api_key or api_key_list: + api_key_redacted = "****set****" else: - configured = bool(api_key) or bool(api_key_list) - api_key_redacted = "****set****" if configured else "(empty)" + api_key_redacted = "(empty)" out.append( { @@ -638,6 +687,8 @@ def get_provider_config( val = flat.get(path_key) if redact_secrets and spec["is_secret"]: out[path_key] = _redact(val) + elif redact_secrets and isinstance(val, list) and val and isinstance(val[0], BaseModel): + out[path_key] = [_redact_nested_model(item) for item in val] else: out[path_key] = val return out @@ -677,7 +728,10 @@ def set_provider_fields( ) if spec and spec.is_oauth: - forbidden = [k for k in fields if field_specs[k]["is_secret"]] + # Credential fields only, per the docstring above -- not everything the + # display faces redact: extra_headers is secret to *show* but is no + # credential, and `provider login` would not write it anyway. + forbidden = [k for k in fields if k in ("api_key", "api_key_list")] if forbidden: raise RuntimeError( f"Provider '{name}' uses OAuth — cannot set credential fields " @@ -701,6 +755,16 @@ def set_provider_fields( leaf_cls, leaf_field = _walk_nested_path(cls, path_key) leaf_info = leaf_cls.model_fields[leaf_field] coerced = _coerce_value(raw_val, leaf_info.annotation) + if path_key == "models" and isinstance(coerced, list): + # The third way a model id gets written down, and the one that used + # to skip the contract: `provider set --models x` stored a bare id + # while the picker and the wizard stored a qualified one. Identity + # still matched, so nothing broke -- which is exactly how the two + # spellings coexisted last time, until a delete silently matched + # neither. + from raven.providers.wire import stored_model_id + + coerced = [stored_model_id(name, str(m)) for m in coerced] prev[path_key] = _set_nested(path_key, coerced, working) validated = cls.model_validate(working) @@ -740,8 +804,8 @@ def reset_provider( Two cleanup paths run automatically, dispatched on ``ProviderSpec.is_oauth``: 1. **Config fields** — always rewritten to whatever a fresh Pydantic - instance produces (``api_key=""``, ``api_base=None``, ``vertex=False`` - for Gemini, ``api_key_list=[]`` etc.). For OAuth providers those are + instance produces (``api_key=""``, ``api_base=None``, + ``api_key_list=[]`` for Gemini, etc.). For OAuth providers those are already at defaults, so the write is a no-op for them but harmless. 2. **OAuth credential files** (``is_oauth=True``) — unlinked from disk so the @@ -800,11 +864,16 @@ def add_provider_model( Returns the new model list. Raises KeyError for an unknown provider. """ + from raven.providers.wire import merge_key + name = canonical_provider_name(name) path = config_path or get_config_path() data = read_raw_or_raise(path) cls, models = _load_provider_models(name, data) - if model not in models: + # By identity, not by string: the same model written two ways used to land + # in the list twice, and neither entry could then be removed by the other's + # spelling. + if merge_key(name, model) not in {merge_key(name, m) for m in models}: models.append(model) section = _raw_section(data, name) section["models"] = models @@ -824,12 +893,17 @@ def remove_provider_model( Returns the new model list. Raises KeyError for an unknown provider. """ + from raven.providers.wire import merge_key + name = canonical_provider_name(name) path = config_path or get_config_path() data = read_raw_or_raise(path) cls, models = _load_provider_models(name, data) - if model in models: - models = [m for m in models if m != model] + # Whatever spelling the caller holds removes every spelling of that model: + # the write paths used to disagree, so a list could hold one model twice. + target = merge_key(name, model) + if target in {merge_key(name, m) for m in models}: + models = [m for m in models if merge_key(name, m) != target] section = _raw_section(data, name) section["models"] = models validated = cls.model_validate(section) @@ -838,6 +912,128 @@ def remove_provider_model( return models +def _load_provider_endpoints(name: str, data: dict[str, Any]) -> tuple[type, list[ProviderEndpoint]]: + """Deliberately lets ValidationError out instead of falling back to ``cls()``. + + A section that no longer validates (say, a hand-edited duplicate label) + already stops ``Config.model_validate`` -- Raven will not start on it. The + endpoint commands are the user's likeliest self-rescue there, and a swallow + here made them see an empty list and then *write it back*, wiping every + real endpoint in the section. A loud error names the problem instead. + """ + cls = _provider_schema_cls(name) + section = _raw_section(data, name) + instance = cls.model_validate(section) + return cls, list(getattr(instance, "endpoints", []) or []) + + +def add_provider_endpoint( + name: str, + *, + label: str, + api_key: str = "", + api_base: str | None = None, + extra_headers: dict[str, str] | None = None, + config_path: Path | None = None, +) -> list[ProviderEndpoint]: + """Add or replace one entry in a provider's ``endpoints`` list, keyed by ``label``. + + ``label`` is the idempotency key ``ProviderEndpoint`` declares it as: an + existing entry with that label is replaced wholesale, not merged field by + field, so re-running this with a rotated ``api_key`` is how the rotation + gets written. A new label appends. + + Returns the new endpoint list. Raises KeyError for an unknown provider, + RuntimeError for one that ``endpoints_unsupported_reason`` rejects (Codex, + MiniMax OAuth, Azure, or any OAuth section) -- the same rejection + ``make_provider`` applies at build time, applied here before the write + rather than left for that later failure to catch. Also RuntimeError for an + empty ``api_key`` on a provider whose credential shape needs one -- + derived from the registry (``credential_kind``), not a hardcoded vendor + list, so a local/keyless deployment (``hosted_vllm``, ``ollama_chat``, ...) + keeps writing a keyless endpoint while every key-based provider gets the + same rejection the CLI and the TUI picker both need. + """ + name = canonical_provider_name(name) + reason = endpoints_unsupported_reason(name) + if reason: + raise RuntimeError(reason) + if not api_key and credential_kind(name) != CRED_LOCAL: + raise RuntimeError( + f"{name} needs an api_key -- only a local, keyless deployment can add an endpoint without one" + ) + path = config_path or get_config_path() + data = read_raw_or_raise(path) + cls, endpoints = _load_provider_endpoints(name, data) + + new_endpoint = ProviderEndpoint(label=label, api_key=api_key, api_base=api_base, extra_headers=extra_headers) + updated = [new_endpoint if ep.label == label else ep for ep in endpoints] + if not any(ep.label == label for ep in endpoints): + updated.append(new_endpoint) + + section = _raw_section(data, name) + section["endpoints"] = [ep.model_dump(by_alias=True) for ep in updated] + validated = cls.model_validate(section) + _write_raw_section(data, name, validated.model_dump(by_alias=True)) + _write_atomic(path, data) + return updated + + +def remove_provider_endpoint( + name: str, + label: str, + *, + config_path: Path | None = None, +) -> list[ProviderEndpoint]: + """Remove one endpoint by ``label`` (no-op if absent, mirrors ``remove_provider_model``). + + Returns the new endpoint list. Raises KeyError for an unknown provider. + """ + name = canonical_provider_name(name) + path = config_path or get_config_path() + data = read_raw_or_raise(path) + cls, endpoints = _load_provider_endpoints(name, data) + + remaining = [ep for ep in endpoints if ep.label != label] + if len(remaining) != len(endpoints): + section = _raw_section(data, name) + section["endpoints"] = [ep.model_dump(by_alias=True) for ep in remaining] + validated = cls.model_validate(section) + _write_raw_section(data, name, validated.model_dump(by_alias=True)) + _write_atomic(path, data) + return remaining + + +def list_provider_endpoints(name: str, *, config_path: Path | None = None) -> list[dict[str, Any]]: + """List a provider's ``endpoints``, secrets redacted for display. + + Returns one dict per endpoint: ``label``, ``api_key`` (``****set****`` / + ``(empty)``, same rule as every other secret field), ``api_base``, + ``extra_headers`` (values redacted the same way, keys left visible -- see + ``_redact_headers``). ``api_base``/``extra_headers`` are the resolved + values each request would actually use -- an entry that names none of its + own shows the section's flat value it inherits (see + ``provider_endpoints``), not an empty field the user reads as "did not + take". Raises KeyError for an unknown provider. + """ + name = canonical_provider_name(name) + path = config_path or get_config_path() + data = read_raw_or_raise(path) + cls, endpoints = _load_provider_endpoints(name, data) + if not endpoints: + return [] + section = cls.model_validate(_raw_section(data, name)) + return [ + { + "label": ep.label, + "api_key": _redact(ep.api_key), + "api_base": ep.api_base, + "extra_headers": _redact_headers(ep.extra_headers), + } + for ep in provider_endpoints(section) + ] + + # --------------------------------------------------------------------------- # Public API: credential health check # --------------------------------------------------------------------------- @@ -887,7 +1083,7 @@ def test_provider( try: spec = _provider_spec(name) - cfg = get_provider_config(name, redact_secrets=False, config_path=config_path) + cls = _provider_schema_cls(name) except KeyError as exc: return { "ok": False, @@ -899,8 +1095,27 @@ def test_provider( "error": str(exc), } - api_key = cfg.get("api_key") or "" - api_base = cfg.get("api_base") or (spec.default_api_base if spec else "") or "" + path = config_path or get_config_path() + data = read_raw_or_raise(path) + raw_section = _raw_section(data, name) + try: + instance = cls.model_validate(raw_section) + except ValidationError: + instance = cls() + + # Same source as the request path (`provider_endpoints`), not a second + # read of the flat fields -- an endpoints-only or `api_key_list` section + # has no usable flat `api_key`, and reading that field here reported + # `not_configured` on a section the runtime could already serve. The first + # endpoint that actually holds a key is the one a request would use; none + # holding one falls back to the first endpoint's address, matching what a + # section with no endpoints at all (a single resolved entry echoing the + # flat fields) already gave local/keyless providers. + endpoints = provider_endpoints(instance) + endpoint = next((ep for ep in endpoints if ep.api_key), endpoints[0] if endpoints else None) + api_key = endpoint.api_key if endpoint else "" + api_base = (endpoint.api_base if endpoint else None) or (spec.default_api_base if spec else "") or "" + derived_api_base = False # Before the token fetch below, which asks a question this backend does not # answer: its catalogue is the credential check. @@ -966,14 +1181,37 @@ def test_provider( } if not api_base: + # Asked here and not above: the branches in between return for the + # families whose credential check is not an HTTP ping, and one of them + # is Copilot -- whose driver starts a GitHub device flow when LiteLLM is + # asked to resolve it. Deriving eagerly put that flow before the branch + # that avoids it and hung `provider test github-copilot` on that login. + # Derived rather than declared, and tracked as such: a 404 from an + # address we guessed says the vendor has no models route there, while a + # 404 from one the user typed is a typo they need to see. + api_base = _litellm_api_base(spec) + derived_api_base = bool(api_base) + + if not api_base: + # No address, and for most of these there is nothing the user could have + # supplied: the endpoint is compiled into the vendor's SDK, so there is + # no `/models` to ping. Reporting `not_configured` told seven correctly + # configured providers they were not set up, and pointed at a key they + # had already set. Say what is true instead -- the credential is present + # and this probe cannot reach the vendor. + needs_user_address = bool(spec and (spec.is_local or spec.name == "azure_openai")) return { "ok": False, - "status": "not_configured", + "status": "not_configured" if needs_user_address else "no_probe_endpoint", "elapsed_ms": 0, "http_status": None, "models_count": None, "model_ids": None, - "error": "api_base is empty and provider has no default", + "error": ( + "api_base is empty and provider has no default" + if needs_user_address + else "credential present; this vendor publishes no models endpoint to ping" + ), } url = api_base.rstrip("/") + "/models" @@ -984,7 +1222,57 @@ def test_provider( if spec and spec.name in {"minimax_global", "minimax_cn"} and api_key: headers["x-api-key"] = api_key - return _probe_models_endpoint(url, headers, timeout_s=timeout_s, transport=transport) + result = _probe_models_endpoint(url, headers, timeout_s=timeout_s, transport=transport) + if derived_api_base and result.get("status") == "http_404": + # The address LiteLLM sends completions to is not always where the + # catalogue lives -- DeepSeek's is `/beta`, which has no `/models`. A 404 + # never says anything about the credential, so reporting a failure here + # would be the same lie in a new spelling. + return { + **result, + "ok": False, + "status": "no_probe_endpoint", + "error": "credential present; this vendor publishes no models endpoint to ping", + } + return result + + +def _litellm_api_base(spec: Any) -> str: + """The endpoint LiteLLM would send this vendor's request to, or "". + + Asked rather than tabulated: LiteLLM already knows, because it is the thing + that does the sending, and a second copy of these addresses is a second thing + to keep current. It answers for four of the ten providers that ship no + default; the rest compile the address into the vendor SDK and there is + nothing to return. + """ + if spec is None: + return "" + if spec.is_oauth: + # Asked before the id is built, because building it can hide the answer: + # `wire_model` strips the provider name entirely for the codex and azure + # shapes, so the guard below would be handed a bare "probe-model" and see + # nothing to object to. An OAuth provider's credential check is its own + # flow, never a models ping, so there is nothing here for it either way. + return "" + + from raven.providers.rates import _may_prompt + from raven.providers.wire import stored_model_id, wire_model + + # Asked of the stored form, not the wire form, for the same reason. + stored = stored_model_id(spec.name, "probe-model") + if _may_prompt(stored): + # Resolving one of these resolves its credentials on the way, and with no + # token file that prints a device code and blocks. One answer to "can this + # be handed to LiteLLM" for every caller -- see providers.rates. + return "" + try: + from raven.providers.litellm_setup import import_litellm + + _, _, _, base = import_litellm().get_llm_provider(model=wire_model(stored, spec=spec)) + except Exception: + return "" + return base or "" def _probe_models_endpoint( @@ -1235,5 +1523,8 @@ def _probe_codex_catalog(*, timeout_s: float) -> dict[str, Any]: "get_provider_config", "set_provider_fields", "reset_provider", + "add_provider_endpoint", + "remove_provider_endpoint", + "list_provider_endpoints", "test_provider", ] diff --git a/raven/context_engine/assembler.py b/raven/context_engine/assembler.py index a19bf7f1..f5c3004e 100644 --- a/raven/context_engine/assembler.py +++ b/raven/context_engine/assembler.py @@ -153,6 +153,14 @@ async def after_turn( if hook is not None: await hook(session_key, outcome, usage) + def set_context_window(self, tokens: int) -> None: + # Delegate to any builder that sized itself against the window at + # construction (only the Curator does; seg1-5 carry no budget). + for builder in self._builders: + setter = getattr(builder, "set_context_window", None) + if setter is not None: + setter(tokens) + def _build_user(self, ctx: AssemblyContext) -> dict[str, Any]: """The single structural user message: runtime context + content.""" runtime_ctx = render.build_runtime_context(self._now_fn, ctx.channel, ctx.chat_id) diff --git a/raven/context_engine/base.py b/raven/context_engine/base.py index 1d4bb1f4..fe1c8944 100644 --- a/raven/context_engine/base.py +++ b/raven/context_engine/base.py @@ -184,6 +184,13 @@ async def after_turn( """ return None + def set_context_window(self, tokens: int) -> None: + """Follow a ``/model`` switch: re-budget whichever builders sized + themselves against the window at construction. Default is no-op so + an engine with no such builder need not override it. + """ + return None + __all__ = [ "AssembledPrefix", diff --git a/raven/context_engine/curator.py b/raven/context_engine/curator.py index 44e4f290..a607c7a5 100644 --- a/raven/context_engine/curator.py +++ b/raven/context_engine/curator.py @@ -367,6 +367,12 @@ def set_provider(self, provider: LLMProvider, model: str) -> None: self.model = model self.trimmer.set_provider(provider, model) + def set_context_window(self, tokens: int) -> None: + """Follow a ``/model`` switch: the trimmer must budget against the + new model's window, not the one it was built with.""" + self.context_window_tokens = tokens + self.trimmer.context_window_tokens = tokens + @staticmethod def working_state_segment(working_state: str | None) -> str: """Render segment 6 text (``# Curator Working State``) or ``""``.""" diff --git a/raven/context_engine/factory.py b/raven/context_engine/factory.py index 11f522d3..7151324f 100644 --- a/raven/context_engine/factory.py +++ b/raven/context_engine/factory.py @@ -108,6 +108,7 @@ def build_context_engine( rewriter, gate = _build_rewriter_and_gate( provider=provider, + model=model, skill_forge_config=skill_forge_config, skill_forge_router_config=skill_forge_router_config, ) @@ -216,6 +217,7 @@ def _build_router( def _build_rewriter_and_gate( *, provider: LLMProvider, + model: str, skill_forge_config: "SkillForgeConfig | None", skill_forge_router_config: "SkillForgeRouterConfig", ) -> "tuple[QueryRewriter | None, LLMGateFilter | None]": @@ -236,6 +238,7 @@ def _build_rewriter_and_gate( if bool(getattr(skill_forge_config, "rewrite_enabled", False)): rewriter = QueryRewriter( provider, + model=model, max_tokens=int(getattr(skill_forge_config, "rewrite_max_tokens", 8192) or 8192), ) @@ -248,7 +251,7 @@ def _build_rewriter_and_gate( provider, max_select=int(getattr(skill_forge_config, "llm_gate_max_select", 2) or 2), legacy_top_k=int(skill_forge_router_config.top_k or 5), - model=getattr(skill_forge_config, "llm_gate_model", None) or None, + model=getattr(skill_forge_config, "llm_gate_model", None) or model or None, temperature=float(getattr(skill_forge_config, "llm_gate_temperature", 0.0)), max_tokens=int(getattr(skill_forge_config, "llm_gate_max_tokens", 8192) or 8192), ) diff --git a/raven/context_engine/segments/curator.py b/raven/context_engine/segments/curator.py index 66e1ac2b..e78c50c1 100644 --- a/raven/context_engine/segments/curator.py +++ b/raven/context_engine/segments/curator.py @@ -96,6 +96,11 @@ def set_provider(self, provider: LLMProvider, model: str) -> None: self.curator_model = self.config.curator_model or model self.assembler.set_provider(provider, model) + def set_context_window(self, tokens: int) -> None: + """Follow a ``/model`` switch down into the assembler it owns.""" + self.context_window_tokens = tokens + self.assembler.set_context_window(tokens) + async def build(self, ctx: AssemblyContext) -> Segment | None: if ctx.prefix is None: raise RuntimeError("CuratorSegmentBuilder requires ctx.prefix (phase B)") diff --git a/raven/memory_engine/skill_forge/rewriter.py b/raven/memory_engine/skill_forge/rewriter.py index 5c3dd3b6..e0018aec 100644 --- a/raven/memory_engine/skill_forge/rewriter.py +++ b/raven/memory_engine/skill_forge/rewriter.py @@ -67,10 +67,12 @@ def __init__( self, provider: "LLMProvider", *, + model: str | None = None, max_tokens: int = 8192, temperature: float = 0.3, ) -> None: self._provider = provider + self._model = model self._max_tokens = max_tokens self._temperature = temperature @@ -96,6 +98,7 @@ async def analyze(self, query: str) -> RewriteResult: resp = await asyncio.wait_for( self._provider.chat_with_retry( messages=[{"role": "user", "content": prompt}], + model=self._model or None, max_tokens=self._max_tokens, temperature=self._temperature, ), diff --git a/raven/providers/auth.py b/raven/providers/auth.py new file mode 100644 index 00000000..53d8a1c1 --- /dev/null +++ b/raven/providers/auth.py @@ -0,0 +1,398 @@ +"""How a provider is connected to: what material it needs, and whether it is there. + +Authentication used to be described by one boolean (``ProviderSpec.is_oauth``) +and one string (``env_key``). Underneath sit shapes those two cannot express: +Azure needs a key *and* an address; Gemini takes a key *or* a list of them; +Bedrock needs neither because the environment already holds AWS credentials; +four providers hold a token in a file, written by three unrelated flows. + +Because the shape was not stated, "is this provider usable" was answered +independently wherever it was needed, and the answers diverged. Routing skipped +a Gemini section configured with ``api_key_list``; ``provider list`` showed the +same section as ready; startup refused to run on it. Azure with a key and no +address was accepted by routing and display and rejected at startup. + +So the requirement is declared here, once, as **an AND of OR-groups**: every +group must be satisfied, and any member satisfies its group. That is the whole +grammar, and it is enough for all eight shapes -- "key and address" is two +groups, "key or key list" is one group with two members. + +Callers ask :func:`credential_status`. What they must not do is re-derive the +answer from spec flags, which is what produced the divergence. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from raven.providers.endpoints import provider_endpoints + +if TYPE_CHECKING: + from raven.providers.registry import ProviderSpec + +#: A credential arrives as a token file rather than a config field. +KIND_DEVICE_FLOW = "device_flow" +#: The environment already holds it (AWS credential chain, Google ADC). +KIND_AMBIENT = "ambient" +#: An address is all that is needed; there is no credential. +KIND_NONE = "none" +#: A key held in the config. Which fields count is the method's ``requires``. +KIND_API_KEY = "api_key" + + +@dataclass(frozen=True) +class Requirement: + """One thing that must be present, satisfied by any of ``fields``. + + ``fields`` names config fields; an empty tuple means the material is not in + the config at all and ``token_file`` or ``ambient`` decides instead. + """ + + fields: tuple[str, ...] + label: str + hint: str = "" + #: A ``ProviderSpec`` attribute that also satisfies this requirement when + #: truthy, even though the config carries nothing for it -- e.g. custom's + #: shipped localhost address, a working default the user may still + #: override. Empty for every requirement but ``_ADDRESS_OR_SPEC_DEFAULT``. + spec_fallback: str = "" + + def satisfied_by(self, section: Any, spec: "ProviderSpec | None" = None) -> bool: + if any(_present(section, name) for name in self.fields): + return True + return bool(self.spec_fallback and spec is not None and getattr(spec, self.spec_fallback, None)) + + +@dataclass(frozen=True) +class AuthMethod: + """One way of connecting to a provider. A provider may offer several.""" + + kind: str + requires: tuple[Requirement, ...] = () + #: Checked instead of ``requires`` when the credential is not in the config. + checks_token_file: bool = False + label: str = "" + + def missing( + self, + section: Any, + provider: str, + *, + spec: "ProviderSpec | None" = None, + include_external: bool, + ) -> list[Requirement]: + """What this method still needs. + + ``include_external`` decides whether material held outside the config is + looked at. Routing asks without it: it is choosing which section serves a + model id, it runs on every call, and an OAuth provider's section is + legitimately empty -- a token file read there would put disk I/O on the + hot path and make the choice depend on a sign-in that startup is the + right place to require. Display and startup ask with it, because both + report on what is true right now. + """ + if self.checks_token_file: + if not include_external: + return [] + return [] if _token_present(provider) else [_SIGN_IN(provider)] + return [req for req in self.requires if not req.satisfied_by(section, spec)] + + +class MissingCredentialsError(Exception): + """A provider cannot be used because its credentials are absent. + + Raised where the gate is decided, not where it is reported. The check runs + behind three entry points -- the CLI, the gateway, and the TUI -- and used to + end in ``console.print`` plus ``typer.Exit``, which is one of them speaking. + Through the other two the message went to a log nobody was reading and the + user got ``internal_error`` with ``exception_message: "1"``: the exit code, + stringified. + """ + + def __init__(self, summary: str, *, provider: str = "", remedy: str = ""): + super().__init__(summary) + self.summary = summary + self.provider = provider + #: The command that fixes it, when there is one to name. + self.remedy = remedy + + +@dataclass(frozen=True) +class CredentialStatus: + """The single answer to "can this provider be used right now".""" + + provider: str + ok: bool + kind: str + missing: tuple[Requirement, ...] = () + #: Which declared method is satisfied, or the first one when none is. + method: AuthMethod | None = field(default=None, compare=False) + + @property + def summary(self) -> str: + """What to tell the user, naming the field rather than "No API key".""" + if self.ok: + return f"{self.provider} is configured" + if not self.missing: + return f"{self.provider} is not configured" + parts = [m.hint or m.label for m in self.missing] + return f"{self.provider} needs {', '.join(parts)}" + + +def _present(section: Any, name: str) -> bool: + """Is this field set to something usable, on a model or a plain dict? + + Sections reach here as both: the schema object on the routing path, a raw + mapping on the display path. + + Consumes ``provider_endpoints`` rather than re-deriving its precedence: + ``endpoints`` set means only the resolved list counts -- flat fields + included, api_key never inherited -- so a flat key alongside a keyless + endpoint must not count as present, and an endpoint missing only its own + address still counts once the section's flat address fills it in. Only + ``api_key_list`` has no counterpart on ``ResolvedEndpoint`` (it collapses + into several per-key entries there), so it is read off the section + directly, and is unsatisfiable once ``endpoints`` is set -- ignored + outright, same as the flat key. + """ + if section is None: + return False + endpoints = section.get("endpoints") if isinstance(section, dict) else getattr(section, "endpoints", None) + # isinstance, not truthiness: sections reach here as raw mappings and as + # arbitrary duck-typed objects (test doubles included), and only a real + # list is the endpoints shape provider_endpoints reads. + if isinstance(endpoints, (list, tuple)) and endpoints: + return any(bool(getattr(ep, name, None)) for ep in provider_endpoints(section)) + value = section.get(name) if isinstance(section, dict) else getattr(section, name, None) + if isinstance(value, (list, tuple)): + return any(bool(v) for v in value) + return bool(value) + + +def _token_present(provider: str) -> bool: + from raven.config.update_providers import _oauth_credentials_present + + return _oauth_credentials_present(provider) + + +def credential_files(provider: str) -> list[Path]: + """Every file a sign-in for this provider can leave behind. + + Asked of the module that writes each family rather than derived a second + time: each has its own override for where it puts things, so a second + derivation is wrong exactly when a user has taken one -- that is how + ``openai_codex`` came to be written under one name and read under another. + + Returns a list because a sign-in is not always one file: Copilot exchanges + its token for an API key with a longer life, and clearing the token alone + leaves a working credential behind. The first entry is the one that stands + for the credential when a single path is needed. + """ + from raven.config.paths import get_oauth_dir + + if provider == "github_copilot": + from raven.config.update_providers import _COPILOT_TOKEN_FILES, _copilot_token_dir + + return [_copilot_token_dir() / name for name in _COPILOT_TOKEN_FILES] + + if provider == "openai_codex": + from raven.providers.chatgpt_token import auth_file + + return [auth_file()] + + if provider in {"minimax_global", "minimax_cn"}: + from raven.providers.minimax_oauth import token_path + + return [token_path("global" if provider == "minimax_global" else "cn")] + + return [get_oauth_dir() / f"{provider}.json"] + + +def _SIGN_IN(provider: str) -> Requirement: # noqa: N802 - a constructor, named for the constant it stands in for + public = provider.replace("_", "-") + return Requirement((), "a sign-in", f"a sign-in -- run `raven provider login {public}`") + + +_KEY = Requirement(("api_key",), "an API key", "an API key -- run `raven provider set {public} --api-key `") +_KEY_OR_LIST = Requirement( + ("api_key", "api_key_list"), + "an API key", + "an API key -- run `raven provider set {public} --api-key ` (or --api-key-list k1,k2)", +) +_ADDRESS = Requirement(("api_base",), "an address", "an address -- run `raven provider set {public} --api-base `") +#: Same requirement, plus the spec's own working default -- read through +#: `usable_default_api_base`, the same property `Config.get_api_base` serves +#: from, so the gate can never accept a default the reader then refuses to +#: hand out. Only for `requires_api_base`: that flag means the *user's* +#: address is mandatory (Azure, a bespoke endpoint) -- unless the spec ships +#: one anyway (`custom`'s localhost gateway). `is_local` keeps the plain +#: `_ADDRESS`: a local deployment's spec default (Ollama's standard port) +#: must not make it look configured before the user has pointed it anywhere, +#: which is the bug `_has_credentials`'s docstring already names. +_ADDRESS_OR_SPEC_DEFAULT = Requirement( + ("api_base",), + "an address", + "an address -- run `raven provider set {public} --api-base `", + spec_fallback="usable_default_api_base", +) + + +#: Declarations for the providers whose shape the spec flags cannot express. +#: Everyone else is derived by :func:`auth_methods` from the flags themselves, +#: so adding an ordinary key-based vendor still needs no entry here. +_DECLARED: dict[str, tuple[AuthMethod, ...]] = { + # A key or a list of them. The plural field is the one that shipped + # unreadable to the router while displaying as configured. + "gemini": (AuthMethod(KIND_API_KEY, (_KEY_OR_LIST,), label="API key"),), + # No entry for Azure or the generic endpoint: `requires_api_base` already + # derives exactly this, and a declaration identical to what the flags produce + # is a second statement of one fact -- the thing this module exists to end. + # The AWS credential chain already holds these; asking for a key would be + # asking for something the user does not have in this form. + "bedrock": (AuthMethod(KIND_AMBIENT, (), label="AWS credentials"),), +} + + +def auth_methods(spec: "ProviderSpec | None", name: str = "") -> tuple[AuthMethod, ...]: + """Every way this provider can be connected to, most preferred first.""" + provider = name or (spec.name if spec else "") + declared = _DECLARED.get(provider) + if declared: + return declared + if spec is None: + # A vendor Raven carries no spec for is reached with a key, like most. + return (AuthMethod(KIND_API_KEY, (_KEY,), label="API key"),) + if spec.is_oauth: + return (AuthMethod(KIND_DEVICE_FLOW, checks_token_file=True, label="sign-in"),) + if spec.is_local: + return (AuthMethod(KIND_NONE, (_ADDRESS,), label="address"),) + if spec.requires_api_base: + return (AuthMethod(KIND_API_KEY, (_KEY, _ADDRESS_OR_SPEC_DEFAULT), label="key and endpoint"),) + return (AuthMethod(KIND_API_KEY, (_KEY,), label="API key"),) + + +def credential_status( + name: str, + section: Any, + *, + spec: "ProviderSpec | None" = None, + include_external: bool = False, +) -> CredentialStatus: + """Can this provider be used, and if not, what exactly is missing? + + ``section`` is the provider's config -- the schema object or the raw mapping, + either way. A provider offering several methods is usable when any one of + them is satisfied, which is what lets a vendor be reached by an ambient + credential when no key is set. + + ``include_external`` extends the question to material held outside the + config, which today means an OAuth token file. See ``AuthMethod.missing`` + for why routing deliberately asks without it. + """ + from raven.providers.registry import canonical_provider_name, find_by_name + + name = canonical_provider_name(name) + spec = spec if spec is not None else find_by_name(name) + methods = auth_methods(spec, name) + + unsatisfied: list[tuple[AuthMethod, tuple[Requirement, ...]]] = [] + for method in methods: + gap = method.missing(section, name, spec=spec, include_external=include_external) + if not gap: + return CredentialStatus(name, True, method.kind, (), method) + unsatisfied.append((method, tuple(_localize(req, name, section=section) for req in gap))) + + # Report against the first declared method: it is the preferred one, so its + # gap is the shortest path to a working provider. + method, gap = unsatisfied[0] + return CredentialStatus(name, False, method.kind, gap, method) + + +#: Vendors LiteLLM reaches by API key, that Raven carries no spec for, where a +#: bare key still cannot configure them -- the credential shape needs more than +#: the wizard's generic "paste a key" branch offers. Every other unspecced +#: vendor is in fact reached by a single key; this only lists the ones that +#: are not, so ``key_refusal`` can say what actually gets each one working +#: instead of the wizard writing down a key that 401s (or, for chatgpt, is +#: silently ignored) at the first call. +#: +#: Deliberately not registry entries: a spec exists to drive routing (default +#: model, client selection, the connectivity probe), and none of these six get +#: any of that from Raven -- adding a spec just to hold a rejection message +#: would build the scaffolding this module exists to avoid. +_KEY_CANNOT_CONFIGURE: dict[str, str] = { + "chatgpt": ( + "ChatGPT is reached through its own OAuth device flow -- LiteLLM's " + "chatgpt transformation ignores any api_key and authenticates through a " + "stored browser session instead. Raven already has this path: run " + '`raven provider login openai-codex` (or pick "OpenAI Codex (OAuth)" ' + "from this menu)." + ), + "bedrock": ( + "Bedrock is reached through the AWS credential chain -- an access key " + "and secret plus a region, an AWS profile, or ambient credentials from " + "the environment or instance role -- not a single api_key field. Set " + "AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION (or an AWS " + "profile) in the environment instead." + ), + "sagemaker": ( + "SageMaker is reached through the same AWS credential chain as " + "Bedrock -- an access key and secret plus a region, an AWS profile, or " + "ambient credentials -- not a single api_key field. Set " + "AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION (or an AWS " + "profile) in the environment instead." + ), + "vertex_ai": ( + "Vertex AI needs a project and a location, plus either a service " + "account credentials JSON or ambient Application Default Credentials -- " + "not a single api_key field. Set VERTEXAI_PROJECT / VERTEXAI_LOCATION " + "and either GOOGLE_APPLICATION_CREDENTIALS or run `gcloud auth " + "application-default login`." + ), + "azure": ( + "This is LiteLLM's native Azure vendor, which needs an api_base and an " + "api_version plus either an api_key or Entra ID auth -- a bare key is " + "not enough. Raven's own Azure path (`azure_openai`) already asks for " + "the base URL and version; pick that instead." + ), + "cloudflare": ( + "Cloudflare Workers AI needs an api_key plus either an api_base or an " + "account_id -- a bare key alone is not enough. Configure --base-url " + "(or an account id) alongside the key." + ), +} + + +def key_refusal(vendor: str) -> str | None: + """Why a bare API key cannot configure this vendor, or ``None`` if a key works. + + Checked before the onboarding wizard's generic key-only branch writes a + config section for a vendor Raven carries no spec for, so it can refuse + with the reason rather than persist a key that will never authenticate. + """ + from raven.providers.registry import normalize_provider_name + + return _KEY_CANNOT_CONFIGURE.get(normalize_provider_name(vendor)) + + +def _localize(req: Requirement, name: str, section: Any = None) -> Requirement: + """Put the provider's own name into the hint, so it can be pasted. + + The hint names a command, not a config path: telling someone to hand-edit + `~/.raven/config.json` asks them to know a file layout the CLI exists to + hide, and the OAuth hint already gave a command. + + When the section carries ``endpoints``, a key hint must name + ``endpoint add``: the flat field the generic hint writes to is ignored the + moment endpoints exist (see ``_present``), so following that hint changes + nothing and the gate repeats itself. + """ + hint = req.hint + endpoints = section.get("endpoints") if isinstance(section, dict) else getattr(section, "endpoints", None) + if "api_key" in req.fields and isinstance(endpoints, (list, tuple)) and endpoints: + hint = "an API key on an endpoint -- run `raven provider endpoint add {public} --label