Skip to content
Open
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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,19 @@ All notable changes to Raven are documented here.

### Fixed

- A key the vendor refuses is no longer called connected. The settings page
and the first-run wizard ask the vendor about a key before storing it
(`model.check_key`): a key answered with 401, or named invalid, is refused
with the reason in the form and nothing is written, where before it was
stored, marked **Connected** and reported as rejected on the same row. A
stored key the vendor refuses when it is checked again (the row's Check or
Retry, or a connect) reads **Key rejected** on its row.
`raven onboard` and `raven provider test` confirm the key itself at the
vendor's key endpoint where the model list answers anyone (OpenRouter),
instead of reporting "Connected! (468 models available)" for a key the
vendor has never issued; a list that cannot confirm a key says so instead
of claiming it valid.

- On native Windows a DAG node can read files by reference: `{{ ref:... }}`,
`{{ ref_path:... }}` and a `{"file": ...}` input naming a file in the
session's working directory or its sub-agent history. They were resolved by
Expand Down
8 changes: 8 additions & 0 deletions i18n/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -8205,6 +8205,14 @@
"en": "This doesn't look like an API key: it has characters a key never contains. Check what was pasted",
"zh": "这不像是 API key:里面有 Key 不会包含的字符,请检查粘贴的内容"
},
"gui.settings.providers.key_refused": {
"en": "{vendor} rejected this key, so it was not saved: check that it is complete and is this vendor's key, then submit it again",
"zh": "{vendor} 不认这个 Key,没有保存:检查有没有复制全、是不是这家的 Key,改好再提交一次"
},
"gui.settings.providers.key_rejected": {
"en": "Key rejected",
"zh": "Key 被拒"
},
"gui.settings.providers.base": {
"en": "Service address",
"zh": "服务地址"
Expand Down
30 changes: 27 additions & 3 deletions raven/cli/onboard_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -702,9 +702,15 @@ def _run_oauth_login(provider: str) -> bool:
def _verify_provider(provider: str, *, skip_test: bool = False) -> tuple[bool, str, Optional[list[str]]]:
"""Hit ``GET /v1/models`` to verify the credentials we just stored.

With the key itself confirmed where the catalogue says nothing about it
(``check_credential``): OpenRouter's list answers anyone, and reading that
200 as "connected" told a person with a mistyped key that it worked, with
468 models to choose from, until the test message failed a step later.

Returns ``(ok, status, model_ids)``. ``status`` is one of the ops-library
failure codes (``invalid_key`` / ``no_credits`` / ``rate_limited`` /
``network_error`` / …) and drives the failure submenu's wording.
``network_error`` / …) and drives the failure submenu's wording; ``valid``,
``skipped`` and ``unchecked`` are the three ways through.
"""
from raven.config.update_providers import test_provider as probe

Expand All @@ -715,7 +721,7 @@ def _verify_provider(provider: str, *, skip_test: bool = False) -> tuple[bool, s
console.print(t(" [dim]⏳ Reaching the server…[/dim]"))
else:
console.print(t(" [dim]⏳ Verifying your API key…[/dim]"))
result = probe(provider)
result = probe(provider, check_credential=True)
if result["ok"]:
models = result.get("models_count")
suffix = t(" ({models} models available)", models=models) if models else ""
Expand Down Expand Up @@ -748,6 +754,24 @@ def _verify_provider(provider: str, *, skip_test: bool = False) -> tuple[bool, s
)
)
return True, "skipped", None
if status == "key_unchecked":
# The catalogue answered without looking at the key, and this vendor
# has no path that would. Not a failure -- the list it returned is
# real -- and not a confirmation either: the test message is the
# check that remains, and the line says so rather than "Connected!".
if skip_test:
console.print(
t(
" [dim]Reached the provider; its model list answers without checking the key, so the key is not confirmed; connectivity is not tested (--skip-test).[/dim]"
)
)
else:
console.print(
t(
" [dim]Reached the provider; its model list answers without checking the key, so the test message below is what confirms it.[/dim]"
)
)
return True, "unchecked", result.get("model_ids")
hint_map = {
"invalid_key": t("Auth failed: the API key is invalid — check for typos / stray spaces."),
"no_credits": t("Account out of credits or not provisioned — top up and retry."),
Expand Down Expand Up @@ -864,7 +888,7 @@ def _pick_model(
# had failed.
console.print(
t(" [dim]This provider has no model list to fetch - offering the ones we know.[/dim]")
if probe_status == "skipped"
if probe_status in ("skipped", "unchecked")
else t(" [dim]Couldn't reach the provider for its model list - offering the ones we know.[/dim]")
)
model_ids = known
Expand Down
12 changes: 10 additions & 2 deletions raven/cli/provider_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -491,13 +491,15 @@ def provider_test_cmd(

Does NOT consume inference quota — hits the provider's models metadata
endpoint, which is free, fast, and tells you whether the key is valid,
has credit, and isn't rate-limited.
has credit, and isn't rate-limited. A vendor whose list answers anyone
(OpenRouter) is asked at its own key endpoint as well, so a 200 from
the list alone is never read as a valid key.
"""
from raven.config.update_providers import test_provider as probe

console.print(f"[dim]Pinging {name}/v1/models ...[/dim]")
try:
result = probe(name, timeout_s=timeout)
result = probe(name, timeout_s=timeout, check_credential=True)
except KeyError as exc:
console.print(f"[red]✗[/red] {exc}")
raise typer.Exit(1)
Expand All @@ -524,6 +526,12 @@ def provider_test_cmd(
"for another host, is the server's to fix"
),
}
if result["status"] == "key_unchecked":
# Reached, but the list says nothing about the key and the vendor
# has no path that would: reported as that, not as valid.
console.print(f"[yellow]?[/yellow] {name} reached, key not confirmed: {result['error']}")
console.print(" [dim]Credentials are set; run a turn to exercise them.[/dim]")
return
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
Expand Down
45 changes: 45 additions & 0 deletions raven/config/update_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import json
import os
import typing
from collections.abc import Mapping
from pathlib import Path
from typing import Any, Union
from urllib.parse import urlparse
Expand Down Expand Up @@ -1167,6 +1168,41 @@ def list_provider_endpoints(name: str, *, config_path: Path | None = None) -> li
# ---------------------------------------------------------------------------


def _with_override(raw_section: dict[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
"""``raw_section`` with ``override``'s fields in place of the stored ones.

The stored spelling is dropped as well as the schema's: the section model
reads both, and when both are present the serialized one (``apiKey``) wins
over the field name (``api_key``) -- so laying ``api_key`` over a section
that already holds ``apiKey`` would have judged the stored key, not the one
being tried. A ``None`` leaves the stored value alone, the way a form that
draws no field for it does.
"""
merged = dict(raw_section)
for field, value in override.items():
if value is None:
continue
merged.pop(field, None)
merged.pop(to_camel(field), None)
merged[field] = value
return merged


def credential_refused(result: Mapping[str, Any]) -> bool:
"""Whether the vendor itself refused the credential this probe carried.

The one verdict that may hold a key back from being stored. A check can be
wrong in every other way -- a public catalogue answers anyone, a proxy or a
vendor that is down answers nothing -- and none of those is the key's
fault, so none of them refuses it. 401 is the vendor's own word for "not
this credential", and the 400 that `_refuses_the_key` reads is the vendor
naming the key. 403 is left out on purpose: vendors answer it for a
region, a plan or a permission as well, and a proxy answers it for
anything, so a key refused on 403 is reported and stored, not withheld.
"""
return result.get("status") == "invalid_key" and result.get("http_status") in (400, 401)


# Maps HTTP status → user-facing status keyword used by the CLI hint table.
_HTTP_STATUS_MAP: dict[int, str] = {
200: "valid",
Expand Down Expand Up @@ -1326,9 +1362,16 @@ def test_provider(
transport: httpx.BaseTransport | None = None,
full_catalogue: bool = False,
check_credential: bool = False,
section_override: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Verify a provider's credentials via a free GET request to ``/v1/models``.

``section_override`` lays the given fields (schema spellings: ``api_key``,
``api_base``) over the stored section before anything is read off it, so a
credential a form holds and has not saved yet is judged exactly as the
stored one would be -- same address, same relay headers. A read either
way: nothing is written.

``full_catalogue`` asks for everything the provider serves rather than just
enough to check the credential: some file their embedding and image models
at sibling endpoints (``_CATALOGUE_EXTRAS``), and a list that omits them is
Expand Down Expand Up @@ -1387,6 +1430,8 @@ def test_provider(
path = config_path or get_config_path()
data = read_raw_or_raise(path)
raw_section = _raw_section(data, name)
if section_override:
raw_section = _with_override(raw_section, section_override)
try:
instance = cls.model_validate(raw_section)
except ValidationError:
Expand Down
2 changes: 2 additions & 0 deletions raven/i18n/zh.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,8 @@
"Importing...": "导入中...",
" [dim]This provider has no model list to fetch - offering the ones we know.[/dim]": " [dim]该服务商没有可拉取的模型列表,先列出已知的。[/dim]",
" [dim]Couldn't reach the provider for its model list - offering the ones we know.[/dim]": " [dim]未能向服务商拉取模型列表,先列出已知的。[/dim]",
" [dim]Reached the provider; its model list answers without checking the key, so the key is not confirmed; connectivity is not tested (--skip-test).[/dim]": " [dim]已连上服务商;它的模型列表不校验 Key,所以 Key 未经确认;连通性未测试(--skip-test)。[/dim]",
" [dim]Reached the provider; its model list answers without checking the key, so the test message below is what confirms it.[/dim]": " [dim]已连上服务商;它的模型列表不校验 Key,下面的测试消息才是确认它的一步。[/dim]",
"Switch provider": "更换服务商",
"Continue anyway": "仍然继续",
"Pick a provider to manage:": "选择要管理的服务商:",
Expand Down
Loading
Loading