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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 53 additions & 26 deletions raven/cli/onboard_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -957,23 +957,6 @@ def _credential_kind(provider: str, spec: Any) -> str:
return CRED_KEY


def _litellm_spelling(provider: str) -> str:
"""How LiteLLM spells this vendor, for use as a route prefix.

Config section names are matched spelling-insensitively, so the name reaching
here may be underscored where LiteLLM hyphenates. Only LiteLLM's own form
works as a prefix.
"""
from raven.providers.litellm_provider_names import LITELLM_PROVIDER_NAMES
from raven.providers.registry import normalize_provider_name

wanted = normalize_provider_name(provider)
for name in LITELLM_PROVIDER_NAMES:
if normalize_provider_name(name) == wanted:
return name
return wanted


def _format_model_for_provider(provider: str, spec: Any, model_id: str) -> str:
"""Apply the provider's route prefix to a raw ``/v1/models`` id when needed.

Expand All @@ -996,7 +979,9 @@ def _format_model_for_provider(provider: str, spec: Any, model_id: str) -> str:
# 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.
prefix = _litellm_spelling(provider)
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}"
if spec.name in {"minimax_global", "minimax_cn"}:
public_prefix = spec.name.replace("_", "-")
Expand All @@ -1020,6 +1005,7 @@ def _pick_model(
*,
current_model: Optional[str],
model_ids: Optional[list[str]],
probe_status: str,
user_provided_model: Optional[str],
non_interactive: bool,
) -> str:
Expand Down Expand Up @@ -1057,8 +1043,18 @@ def _pick_model(

known = [*common_models_for(provider), *litellm_models_for(provider)]
if known:
# openai / anthropic / deepseek / gemini have no /models endpoint to
# pre-check, so there is no list on a perfectly healthy run. Saying
# "couldn't reach the provider" there contradicted the line printed
# just above it and read as a failure to a user for whom nothing
# had failed.
console.print(
_t(
" [dim]This provider has no model list to fetch - offering the ones we know.[/dim]",
" [dim]该服务商没有可拉取的模型列表,先列出已知的。[/dim]",
)
if probe_status == "skipped"
else _t(
" [dim]Couldn't reach the provider for its model list - offering the ones we know.[/dim]",
" [dim]未能向服务商拉取模型列表,先列出已知的。[/dim]",
)
Expand Down Expand Up @@ -1292,6 +1288,20 @@ def _configure_one_provider(
# A provider passed by flag is used once; switching then requires the
# interactive picker (or, in non-interactive mode, is impossible).
flag_provider = provider

def _rewind() -> None:
"""Discard the flag values before the next pass through the picker.

All of them, not just the provider: they were typed for the provider
that just failed. A stale --api-key was written to the newly picked
provider without a prompt, a stale --base-url pointed it at the previous
provider's machine, and picking a local deployment -- which rejects
--api-key by design -- ended the whole wizard on a usage error, losing
the later steps this loop exists to keep.
"""
nonlocal flag_provider, api_key, base_url, model
flag_provider = api_key = base_url = model = None

while True:
if flag_provider:
provider = _validate_provider_name(flag_provider)
Expand All @@ -1310,7 +1320,7 @@ def _configure_one_provider(
provider = _validate_provider_name(picked)
except typer.BadParameter as exc:
console.print(f" [red]x[/red] {exc}")
flag_provider = None
_rewind()
continue

spec = find_by_name(provider)
Expand Down Expand Up @@ -1352,8 +1362,8 @@ def _configure_one_provider(
)
if custom_model is _BACK:
# User backed out of the first credential field — rewind to the
# provider picker (drop any flag so the picker actually shows).
flag_provider = None
# provider picker (drop the flags so the picker actually shows).
_rewind()
continue

chosen_model = _resolve_model_with_test(
Expand All @@ -1367,9 +1377,9 @@ def _configure_one_provider(
skip_test=skip_test,
)
if chosen_model is None:
# "Switch provider" — re-run the picker (drop the flag so the second
# pass prompts rather than reusing the failed flag value), undoing
# what this pass wrote.
# "Switch provider" — re-run the picker (drop the flags so the second
# pass prompts rather than reusing the failed values), undoing what
# this pass wrote.
#
# Put back exactly what was there, read before this pass wrote
# anything. One branch, because "was it configured" is the wrong
Expand All @@ -1385,7 +1395,7 @@ def _configure_one_provider(
# them at all, and doing so turned a failed verification into a
# RuntimeError that took the whole wizard down.
_roll_back_provider_fields(provider, spec, old_key=old_key, old_base=old_base)
flag_provider = None
_rewind()
continue
_persist_default_model(chosen_model)
return {"provider": provider, "model": chosen_model}
Expand Down Expand Up @@ -1613,6 +1623,7 @@ def _resolve_model_with_test(
spec,
current_model=current,
model_ids=model_ids,
probe_status=status,
user_provided_model=user_model_flag,
non_interactive=non_interactive,
)
Expand Down Expand Up @@ -1666,14 +1677,15 @@ def _configure_existing_provider_model(*, non_interactive: bool) -> bool:
if not provider:
raise typer.Exit(1)
spec = find_by_name(provider)
ok, _status, model_ids = _verify_provider(provider)
ok, status, model_ids = _verify_provider(provider)
if not ok:
return False
chosen = _pick_model(
provider,
spec,
current_model=None,
model_ids=model_ids,
probe_status=status,
user_provided_model=None,
non_interactive=False,
)
Expand Down Expand Up @@ -1762,6 +1774,21 @@ def _manage_existing_providers(*, non_interactive: bool) -> None:
if retyped is None:
continue
_write_provider_fields(target, {"api_base": retyped})
elif _credential_kind(target, target_spec) == CRED_ENDPOINT:
# A self-hosted endpoint is a key *and* the address it is sent
# to. Updating only the key left the one field that moves when
# the user redeploys -- the URL -- unreachable from this menu.
from raven.config.update_providers import get_provider_config

try:
stored = get_provider_config(target, redact_secrets=False).get("api_base") or ""
except KeyError:
stored = ""
retyped_key = _prompt_api_key(target)
retyped_url = _prompt_base_url(stored or "https://")
if retyped_url is None:
continue
_write_provider_fields(target, {"api_key": retyped_key, "api_base": retyped_url})
else:
_write_provider_fields(target, {"api_key": _prompt_api_key(target)})
console.print(
Expand Down
26 changes: 6 additions & 20 deletions raven/providers/common_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,23 +190,6 @@ def _litellm_chat_models_by_provider() -> dict[str, tuple[str, ...]]:
return index


def _litellm_spelling_for(slug: str) -> str:
"""How LiteLLM spells this vendor, which is the only form usable as a prefix.

Section names are matched spelling-insensitively, so the name arriving here
may be underscored where LiteLLM hyphenates -- and LiteLLM rejects the
underscored form outright.
"""
from raven.providers.litellm_provider_names import LITELLM_PROVIDER_NAMES
from raven.providers.registry import normalize_provider_name

wanted = normalize_provider_name(slug)
for name in LITELLM_PROVIDER_NAMES:
if normalize_provider_name(name) == wanted:
return name
return wanted


def litellm_models_for(slug: str) -> list[str]:
"""Chat models LiteLLM knows for this provider, as ids that actually route.

Expand All @@ -220,7 +203,7 @@ def litellm_models_for(slug: str) -> list[str]:
do not -- and offering a bare id would route it by keyword rather than to the
provider the user picked.
"""
from raven.providers.registry import find_by_name, normalize_provider_name
from raven.providers.registry import find_by_name, litellm_spelling, normalize_provider_name

spec = find_by_name(slug)
if spec is None:
Expand All @@ -234,10 +217,13 @@ def litellm_models_for(slug: str) -> list[str]:
# Bedrock's do not -- so they are normalized here rather than trusted.
# Offering an unprefixed one would reintroduce the very thing this exists
# to avoid.
# The index is keyed by LiteLLM's own spelling, so it has to be read by
# that spelling too -- looking it up normalized found nothing for any
# vendor LiteLLM hyphenates.
index = _litellm_chat_models_by_provider()
prefix = _litellm_spelling_for(slug)
prefix = litellm_spelling(slug)
out: list[str] = []
for model in index.get(normalize_provider_name(slug), ()):
for model in index.get(prefix, ()):
bare = model[len(prefix) + 1 :] if model.startswith(f"{prefix}/") else model
out.append(f"{prefix}/{bare}")
return out
Expand Down
18 changes: 18 additions & 0 deletions raven/providers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,24 @@ def normalize_provider_name(name: str | None) -> str:
return (name or "").strip().lower().replace("-", "_")


def litellm_spelling(name: str | None) -> str:
"""How LiteLLM spells this vendor, which is the only form usable as a prefix.

Names are matched spelling-insensitively everywhere else, so the one arriving
here may be underscored where LiteLLM hyphenates -- and LiteLLM rejects the
underscored form outright ("nano_gpt/..." comes back as "LLM Provider NOT
provided"), which turns a provider that configured cleanly into one that
cannot be called.
"""
from raven.providers.litellm_provider_names import LITELLM_PROVIDER_NAMES

wanted = normalize_provider_name(name)
for candidate in LITELLM_PROVIDER_NAMES:
if normalize_provider_name(candidate) == wanted:
return candidate
return wanted


def names_same_provider(key: str, name: str) -> bool:
"""Do these two strings name the same provider?

Expand Down
40 changes: 27 additions & 13 deletions raven/tui_rpc/methods/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,7 @@
reset_provider,
set_provider_fields,
)
from raven.providers.common_models import (
_litellm_chat_models_by_provider,
common_models_for,
litellm_models_for,
)
from raven.providers.common_models import common_models_for, litellm_models_for
from raven.providers.registry import canonical_provider_name, find_by_model, find_by_name
from raven.tui_rpc.errors import (
ConfigValidationError,
Expand Down Expand Up @@ -113,6 +109,28 @@ def _build_provider_entry(slug: str, *, current_provider: str | None) -> dict[st
}


async def _entry_off_loop(slug: str, current_provider: str | None) -> dict[str, Any]:
"""Build one picker row without blocking the event loop.

Reading the candidate chain imports LiteLLM the first time, which takes
seconds -- long enough to stall this session's token stream. Every handler
that returns a row goes through here rather than warming the cache in one
and reading it inline in the others: the cache is cold whenever a first read
failed (failures are deliberately not cached), and handler order is up to
the client.
"""
return await asyncio.to_thread(_build_provider_entry, slug, current_provider=current_provider)


async def _entries_off_loop(current_provider: str | None) -> list[dict[str, Any]]:
"""Build every picker row in one thread hop rather than one hop per row."""

def _build() -> list[dict[str, Any]]:
return [_build_provider_entry(p["name"], current_provider=current_provider) for p in list_providers()]

return await asyncio.to_thread(_build)


def _current_selection() -> tuple[str, str | None]:
from raven.cli._helpers import load_runtime_config

Expand All @@ -136,12 +154,8 @@ def _current_selection() -> tuple[str, str | None]:

async def model_options(params: dict) -> dict:
_parse(ModelOptionsParams, params)
# Warm the catalogue off the loop. Reading it imports LiteLLM the first time,
# which takes seconds -- long enough that doing it inline would stall this
# session's token stream while the picker opens.
await asyncio.to_thread(_litellm_chat_models_by_provider)
current_model, current_provider = _current_selection()
entries = [_build_provider_entry(p["name"], current_provider=current_provider) for p in list_providers()]
entries = await _entries_off_loop(current_provider)
return {
"model": current_model,
"provider": current_provider or "",
Expand Down Expand Up @@ -182,7 +196,7 @@ async def model_save_key(params: dict) -> dict:

_, current_provider = _current_selection()
return {
"provider": _build_provider_entry(parsed.slug, current_provider=current_provider),
"provider": await _entry_off_loop(parsed.slug, current_provider),
}


Expand All @@ -203,7 +217,7 @@ async def model_add_model(params: dict) -> dict:
raise ConfigValidationError(str(exc), data={"slug": parsed.slug}) from exc
_, current_provider = _current_selection()
return {
"provider": _build_provider_entry(parsed.slug, current_provider=current_provider),
"provider": await _entry_off_loop(parsed.slug, current_provider),
}


Expand All @@ -215,7 +229,7 @@ async def model_remove_model(params: dict) -> dict:
raise ConfigValidationError(str(exc), data={"slug": parsed.slug}) from exc
_, current_provider = _current_selection()
return {
"provider": _build_provider_entry(parsed.slug, current_provider=current_provider),
"provider": await _entry_off_loop(parsed.slug, current_provider),
}


Expand Down
Loading
Loading