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
10 changes: 8 additions & 2 deletions raven/cli/deep_research_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ def configure_deep_research(*, non_interactive: bool = False, warnings: Optional
"""
warnings = warnings if warnings is not None else []
from raven.cli._styles import RAVEN_STYLE
from raven.cli.onboard_commands import _QMARK, _prompt_api_key, _require_questionary, _t, console
from raven.cli.onboard_commands import _BACK, _QMARK, _prompt_api_key, _require_questionary, _t, console

if non_interactive:
warnings.append("deep_research: skipped (non-interactive; pass --key to configure)")
Expand Down Expand Up @@ -142,7 +142,13 @@ def configure_deep_research(*, non_interactive: bool = False, warnings: Optional
)

while True:
key = _prompt_api_key("deep_research")
key = _prompt_api_key(
"deep_research",
allow_back=True,
back_label=_t("empty ↵ to cancel", "留空回车取消"),
)
if key is _BACK:
return False # empty submit cancels configuration
res = _validate_key(key, current["api_base"])
if res["ok"]:
break
Expand Down
21 changes: 12 additions & 9 deletions raven/cli/onboard_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -373,16 +373,18 @@ def _validate_provider_name(name: str) -> str:
return candidate


def _back_placeholder(allow_back: bool) -> Any:
"""A faint in-field placeholder telling the user an empty submit rewinds.
def _back_placeholder(allow_back: bool, label: Optional[str] = None) -> Any:
"""A faint in-field placeholder telling the user what an empty submit does.

Rendered greyed inside the input (via prompt_toolkit's ``placeholder``),
it disappears the moment they type and leaves nothing behind once the
prompt is answered. Returns ``None`` when back isn't offered.
prompt is answered. Returns ``None`` when back isn't offered. ``label``
overrides the default "go back" wording for prompts where an empty submit
means something else (e.g. cancelling rather than rewinding a step).
"""
if not allow_back:
return None
return [("fg:#6c6c6c italic", _t("empty ↵ to go back", "留空回车返回上一步"))]
return [("fg:#6c6c6c italic", label or _t("empty ↵ to go back", "留空回车返回上一步"))]


def _field_placeholder(allow_back: bool, required: bool) -> Any:
Expand Down Expand Up @@ -453,18 +455,19 @@ def _select_provider() -> Optional[str]:
return picked # None on Ctrl+C


def _prompt_api_key(provider: str, *, allow_back: bool = False) -> Any:
def _prompt_api_key(provider: str, *, allow_back: bool = False, back_label: Optional[str] = None) -> Any:
"""Ask for an API key (hidden input). Returns ``_BACK`` on empty submit
when ``allow_back`` is set, else the key string."""
when ``allow_back`` is set, else the key string. ``back_label`` overrides
the empty-submit hint for callers where it cancels rather than rewinds."""
questionary = _require_questionary()
from raven.cli._styles import RAVEN_STYLE

def _validate(v: str) -> Any:
if allow_back and v == "":
return True # empty is the back signal, not an error
return True # a truly-empty submit is the back/cancel signal
return (
True
if len(v) >= 8
if len(v.strip()) >= 8
else _t(
"API key looks off (empty or too short) — please re-enter (≥ 8 chars).",
"API Key 看起来不对(过短或为空),请重新输入(至少 8 位)。",
Expand All @@ -474,7 +477,7 @@ def _validate(v: str) -> Any:
key = questionary.password(
_t("Paste your API key:", "粘贴你的 API Key:"),
validate=_validate,
placeholder=_back_placeholder(allow_back),
placeholder=_back_placeholder(allow_back, back_label),
style=RAVEN_STYLE,
qmark=_QMARK,
).ask()
Expand Down
41 changes: 41 additions & 0 deletions tests/test_cli_deep_research_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,47 @@ def test_configure_validation_fail_then_cancel(tmp_path: Path, monkeypatch):
assert configure_deep_research(non_interactive=False, warnings=[]) is False # cancelled, nothing written


def test_configure_key_empty_submit_cancels(tmp_path: Path, monkeypatch):
"""Empty submit at the key prompt (``_prompt_api_key`` returns ``_BACK``)
cancels configuration: returns False and writes nothing -- the missing
pre-key cancel path that used to force a Ctrl+C / whole-process exit."""
import raven.cli.onboard_commands as ob

p = tmp_path / "config.json"
monkeypatch.setattr(ut, "get_config_path", lambda: p)
monkeypatch.setattr(ob, "_require_questionary", lambda: _FakeQuestionary(["configure"]))

captured: dict = {}

def _fake_prompt(*a, **k):
captured.update(k)
return ob._BACK

monkeypatch.setattr(ob, "_prompt_api_key", _fake_prompt)

assert configure_deep_research(non_interactive=False, warnings=[]) is False
assert not p.exists() or "deepResearch" not in json.loads(p.read_text()).get("tools", {})
# the prompt is asked with a cancel-worded hint, not the default "go back"
assert captured.get("allow_back") is True
assert captured.get("back_label")


def test_configure_reconfigure_key_empty_submit_cancels(tmp_path: Path, monkeypatch):
"""Reconfigure path (already configured): empty submit at the key prompt
cancels and leaves the existing key untouched, instead of exiting raven --
the ``raven deep-research enable`` -> Reconfigure repro."""
import raven.cli.onboard_commands as ob

p = tmp_path / "config.json"
monkeypatch.setattr(ut, "get_config_path", lambda: p)
ut.set_deep_research({"api_key": "sk-old", "model": "m"}, config_path=p)
monkeypatch.setattr(ob, "_require_questionary", lambda: _FakeQuestionary(["reconfigure"]))
monkeypatch.setattr(ob, "_prompt_api_key", lambda *a, **k: ob._BACK)

assert configure_deep_research(non_interactive=False, warnings=[]) is False
assert json.loads(p.read_text())["tools"]["deepResearch"]["apiKey"] == "sk-old" # unchanged


def test_configure_validation_retry_then_ok(tmp_path: Path, monkeypatch):
calls = {"n": 0}

Expand Down
49 changes: 49 additions & 0 deletions tests/test_cli_onboard_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,55 @@ def _fake_autocomplete(message, choices, default=None, **kwargs):
assert data["agents"]["defaults"]["model"] == "claude-haiku-4-5"


def _capture_password_validate(monkeypatch: pytest.MonkeyPatch, answer: str) -> dict[str, Any]:
"""Stub ``questionary.password`` to record the ``validate`` callable the
prompt installs and return ``answer`` from ``.ask()``."""
import questionary

captured: dict[str, Any] = {}

class _FakeQuestion:
def ask(self) -> Any:
return answer

def _fake_password(message: Any, *, validate: Any = None, **kwargs: Any) -> Any:
captured["validate"] = validate
return _FakeQuestion()

monkeypatch.setattr(questionary, "password", _fake_password)
return captured


def test_prompt_api_key_validator_rejects_whitespace_only(monkeypatch: pytest.MonkeyPatch) -> None:
"""An all-whitespace (or empty) key must fail the field validator (which
re-prompts) rather than pass the raw length check, strip to empty, and hit
``typer.Exit`` (which quit raven with no message)."""
captured = _capture_password_validate(monkeypatch, "sk-realkey123")

key = onboard_commands._prompt_api_key("deep_research")
assert key == "sk-realkey123"

validate = captured["validate"]
assert validate(" ") is not True
assert validate("") is not True
assert validate("sk-12345678") is True


def test_prompt_api_key_empty_is_back_but_whitespace_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
"""With ``allow_back``, only a truly-empty submit is the back/cancel signal;
a whitespace-only entry is still rejected as an invalid key (re-prompt), not
silently treated as back."""
captured = _capture_password_validate(monkeypatch, "")

result = onboard_commands._prompt_api_key("deep_research", allow_back=True)
assert result is onboard_commands._BACK

validate = captured["validate"]
assert validate("") is True # truly-empty submit rewinds/cancels
assert validate(" ") is not True # whitespace rejected, not back
assert validate("sk-12345678") is True


def test_format_model_for_provider_prefix_rules() -> None:
"""Provider's ``litellm_prefix`` is applied unless model_id already has one."""
from raven.providers.registry import find_by_name
Expand Down
Loading