From 0920b90cc9a96d31c9474fd82a304483f90451c3 Mon Sep 17 00:00:00 2001 From: Handsome-wzw <68996445+Handsome-wzw@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:57:46 +0800 Subject: [PATCH 1/3] fix(*): a key the vendor refuses is not called connected OpenRouter's model list answers anyone, so a mistyped key read as connected on every surface: the settings page and the first-run wizard stored it, marked the row "Connected" and wrote "the provider rejects the saved key" beside it; `raven onboard` and `raven provider test` reported "Connected! (468 models available)" and the first message then failed with an auth error. The probe already knew how to confirm a key where the list says nothing (`check_credential`: a decoy key against the list, then the vendor's own key endpoint); nothing that answers a person used it. The page now asks the vendor before storing (`model.check_key`, a read: the stored section with the form's fields laid over it, through the same probe). The vendor's own refusal -- a 401 to this key, or the key named invalid -- is the one answer that holds a key back: the form says so and nothing is written. Every other answer stores the key and says what is still worth knowing, as before; a check that cannot run stores it and tests on the side. A stored key the vendor later refuses reads "Key rejected" on its row rather than "Connected". The wizard and `provider test` pass `check_credential`, and a list that cannot confirm a key says so instead of claiming it valid. Co-authored-by: Claude (claude-fable-5-1) --- CHANGELOG.md | 11 +++ i18n/messages.json | 8 ++ raven/cli/onboard_commands.py | 30 ++++++- raven/cli/provider_commands.py | 12 ++- raven/config/update_providers.py | 45 ++++++++++ raven/rpc/methods/model.py | 50 +++++++++++ raven/rpc/models.py | 39 +++++++++ rpc-schema/openrpc.json | 63 ++++++++++++++ tests/test_cli_onboard_commands.py | 58 ++++++++++++- tests/test_cli_provider_commands.py | 50 ++++++++++- tests/test_config_update_providers.py | 83 +++++++++++++++++++ tests/test_rpc_model.py | 77 +++++++++++++++++ ui-tui/src/i18n/messages.generated.ts | 6 ++ ui-tui/src/rpc/generated.ts | 27 ++++++ ui-web/scripts/fixture-params.mjs | 2 + .../features/settings/SetupBodies.test.tsx | 79 +++++++++++++----- .../providers/ProviderDetail.test.tsx | 18 ++-- .../settings/providers/ProviderDetail.tsx | 2 +- .../features/settings/providers/Providers.tsx | 14 +++- ui-web/src/features/settings/source.test.ts | 8 ++ ui-web/src/features/settings/source.ts | 3 + ui-web/src/features/settings/store.ts | 57 +++++++++---- ui-web/src/features/settings/types.ts | 5 ++ ui-web/src/rpc/fixtures/model.ts | 13 +++ ui-web/src/rpc/generated.ts | 23 ++++- ui-web/src/test/settingsHarness.ts | 1 + 26 files changed, 728 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 421cf2b11..af351babe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -358,6 +358,17 @@ 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 later refuses 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. - A continued DAG node is judged against what it was told after an attempt fell short, not only against its first prompt. The judge read the task as first given and nothing else, so when the user answered a stuck node by diff --git a/i18n/messages.json b/i18n/messages.json index 6e2ad21c8..22b342012 100644 --- a/i18n/messages.json +++ b/i18n/messages.json @@ -8163,6 +8163,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 connect again", + "zh": "{vendor} 不认这个 Key,没有保存:检查有没有复制全、是不是这家的 Key,改好再点「连接」" + }, + "gui.settings.providers.key_rejected": { + "en": "Key rejected", + "zh": "Key 被拒" + }, "gui.settings.providers.base": { "en": "Service address", "zh": "服务地址" diff --git a/raven/cli/onboard_commands.py b/raven/cli/onboard_commands.py index f7877fc76..bdf547c1a 100644 --- a/raven/cli/onboard_commands.py +++ b/raven/cli/onboard_commands.py @@ -701,9 +701,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 @@ -714,7 +720,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 "" @@ -747,6 +753,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."), @@ -863,7 +887,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 diff --git a/raven/cli/provider_commands.py b/raven/cli/provider_commands.py index 63fffd44e..4542c9367 100644 --- a/raven/cli/provider_commands.py +++ b/raven/cli/provider_commands.py @@ -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) @@ -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 diff --git a/raven/config/update_providers.py b/raven/config/update_providers.py index 00fb963f4..61b2d23fa 100644 --- a/raven/config/update_providers.py +++ b/raven/config/update_providers.py @@ -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 @@ -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", @@ -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 @@ -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: diff --git a/raven/rpc/methods/model.py b/raven/rpc/methods/model.py index af0d0f069..6b7bb7a54 100644 --- a/raven/rpc/methods/model.py +++ b/raven/rpc/methods/model.py @@ -67,6 +67,7 @@ ModelAddEndpointParams, ModelAddModelParams, ModelAddModelsParams, + ModelCheckKeyParams, ModelDisconnectParams, ModelEndpointsParams, ModelFetchModelsParams, @@ -741,6 +742,53 @@ def _stated_overlay(parsed: "ModelAddModelParams") -> dict[str, object]: return stated +#: How long a credential check may take before the form gives up on a verdict. +#: Shorter than a catalogue fetch: the person is holding a button, and a vendor +#: that takes this long to answer has answered "cannot be reached" already. +_CHECK_TIMEOUT_S = 10 + + +async def model_check_key(params: dict) -> dict: + """What the vendor says about a credential before it is stored. + + The same probe `model_fetch_models` runs with ``verify`` on a saved key, + run on the key a form holds: the stored section with these fields laid + over it, so a relay's headers and a stored address still apply. A read -- + nothing is written, whatever the answer. + + ``refused`` is the vendor's own refusal (`credential_refused`), and it is + the only answer the page holds a key back on. Everything else -- a + catalogue that answers anyone, a vendor that is down, a code nobody mapped + -- is reported beside the key, which is stored: those are things that can + be wrong about the check, not about the key. + """ + from raven.config.update_providers import credential_refused, test_provider + + parsed = _parse(ModelCheckKeyParams, params) + slug = canonical_provider_name(parsed.slug) + override: dict[str, Any] = {} + if parsed.api_key: + override["api_key"] = parsed.api_key + if parsed.api_base: + override["api_base"] = parsed.api_base + # Off-thread, like the fetch: a round trip to the vendor while the loop + # may be carrying a token stream. + probe = await asyncio.to_thread( + test_provider, slug, timeout_s=_CHECK_TIMEOUT_S, check_credential=True, section_override=override + ) + http_status = probe.get("http_status") + models_count = probe.get("models_count") + error = probe.get("error") + return { + "ok": bool(probe.get("ok")), + "status": str(probe.get("status") or "unknown"), + "http_status": http_status if isinstance(http_status, int) else None, + "models_count": models_count if isinstance(models_count, int) else None, + "error": str(error) if error else None, + "refused": credential_refused(probe), + } + + async def model_fetch_models(params: dict) -> dict: """What this provider can serve: the bundled catalogue, plus whatever it says. @@ -1031,6 +1079,7 @@ def register_model_methods(dispatcher: "Dispatcher", *, agent_loop_factory: "Age dispatcher.register("model.save_key", partial(model_save_key, agent_loop_factory=agent_loop_factory)) dispatcher.register("model.disconnect", partial(model_disconnect, agent_loop_factory=agent_loop_factory)) dispatcher.register("model.reveal_key", model_reveal_key) + dispatcher.register("model.check_key", model_check_key) dispatcher.register("model.fetch_models", model_fetch_models) dispatcher.register("model.add_model", model_add_model) dispatcher.register("model.add_models", model_add_models) @@ -1044,6 +1093,7 @@ def register_model_methods(dispatcher: "Dispatcher", *, agent_loop_factory: "Age __all__ = [ "model_options", + "model_check_key", "model_set_protocol", "model_save_key", "model_disconnect", diff --git a/raven/rpc/models.py b/raven/rpc/models.py index 6878d2edd..aee6bd6a6 100644 --- a/raven/rpc/models.py +++ b/raven/rpc/models.py @@ -2146,6 +2146,44 @@ class ModelDisconnectResult(_Strict): disconnected: bool +class ModelCheckKeyParams(_Strict): + """Ask the vendor about a credential before it is stored.""" + + slug: str + # The key a form holds; empty for a local deployment, which is reached by + # address and has no key. + api_key: str = "" + api_base: str | None = None + + +class ModelCheckKeyResult(_Strict): + """What the vendor said about the credential, and whether that refuses it. + + ``refused`` is the one verdict that holds a key back from being stored: the + vendor itself answered this key with 401, or named it invalid. Every other + answer -- a catalogue that checks no keys, a vendor that cannot be reached, + a code nobody mapped -- is reported here and leaves the key worth storing. + """ + + ok: bool + status: str = Field( + description=( + "The probe's word: `valid`, `invalid_key`, `key_unchecked`, `no_credits`, `rate_limited`, " + "`network_error`, `proxy_unreachable`, `certificate_untrusted`, `no_probe_endpoint`, " + "`not_configured`, `http_NNN`." + ) + ) + http_status: int | None = None + models_count: int | None = None + error: str | None = None + refused: bool = Field( + description=( + "The vendor itself refused this key -- answered it with 401, or named it invalid -- so storing it " + "would store a key that cannot work. Every other answer leaves the key worth storing." + ) + ) + + class ModelRevealKeyParams(_Strict): slug: str @@ -6944,6 +6982,7 @@ class ImportedUploadResult(_Strict): "model.save_key": (ModelSaveKeyParams, ModelSaveKeyResult), "model.disconnect": (ModelDisconnectParams, ModelDisconnectResult), "model.reveal_key": (ModelRevealKeyParams, ModelRevealKeyResult), + "model.check_key": (ModelCheckKeyParams, ModelCheckKeyResult), "model.add_model": (ModelAddModelParams, ModelAddModelResult), "model.add_models": (ModelAddModelsParams, ModelAddModelsResult), "model.set_fields": (ModelSetFieldsParams, ModelSetFieldsResult), diff --git a/rpc-schema/openrpc.json b/rpc-schema/openrpc.json index 1488b5a90..8b484f132 100644 --- a/rpc-schema/openrpc.json +++ b/rpc-schema/openrpc.json @@ -1269,6 +1269,69 @@ }, "errors": [] }, + { + "name": "model.check_key", + "summary": "What the vendor says about a credential before it is stored: the stored section with these fields laid over it is probed the way `model.fetch_models` with `verify` probes a saved key. A read -- nothing is written, whatever the answer.", + "params": [ + { + "name": "slug", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "api_key", + "description": "The key to try; empty for a local deployment, which is reached by address and has no key.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "api_base", + "required": false, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "ModelCheckKeyResult", + "schema": { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "status", + "refused" + ], + "properties": { + "ok": { + "type": "boolean" + }, + "status": { + "type": "string", + "description": "The probe's word: `valid`, `invalid_key`, `key_unchecked`, `no_credits`, `rate_limited`, `network_error`, `proxy_unreachable`, `certificate_untrusted`, `no_probe_endpoint`, `not_configured`, `http_NNN`." + }, + "http_status": { + "type": "integer" + }, + "models_count": { + "type": "integer" + }, + "error": { + "type": "string" + }, + "refused": { + "type": "boolean", + "description": "The vendor itself refused this key -- answered it with 401, or named it invalid -- so storing it would store a key that cannot work. Every other answer leaves the key worth storing." + } + } + } + }, + "errors": [] + }, { "name": "model.fetch_models", "summary": "What a provider can serve: the bundled catalogue unioned with whatever the vendor names when it can be reached. Each row carries the registry's name and tags, its window, the bucket it filters into, whether this provider's list already has it, and which source it came from. A read -- nothing is written until a row is added.", diff --git a/tests/test_cli_onboard_commands.py b/tests/test_cli_onboard_commands.py index 651fe3315..dfb8a7851 100644 --- a/tests/test_cli_onboard_commands.py +++ b/tests/test_cli_onboard_commands.py @@ -4529,7 +4529,7 @@ def test_a_certificate_the_machine_does_not_trust_points_at_the_certificate_stor monkeypatch.setattr( update_providers, "test_provider", - lambda provider: { + lambda provider, **_: { "ok": False, "status": "certificate_untrusted", "error": "[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate", @@ -4557,7 +4557,7 @@ def test_a_refused_certificate_names_the_refusals_a_ca_does_not_fix(monkeypatch, monkeypatch.setattr( update_providers, "test_provider", - lambda provider: { + lambda provider, **_: { "ok": False, "status": "certificate_untrusted", "error": '"api.deepseek.com" certificate is not standards compliant', @@ -8572,3 +8572,57 @@ def test_a_narrower_embedding_model_still_fails_the_wizard_check(monkeypatch: py monkeypatch.setattr(onboard_everos, "_probe_embedding_dim", lambda url, headers, model: 768) assert not onboard_everos._verify_embedding_dim(model="m", api_key="k", base_url="http://p", non_interactive=True) + + +# --------------------------------------------------------------------------- +# Step 1: the key itself is confirmed, not only the model list +# --------------------------------------------------------------------------- + + +def test_verify_provider_confirms_the_key_itself(monkeypatch: pytest.MonkeyPatch) -> None: + """OpenRouter's list answers anyone; the probe is asked to check the key too.""" + seen: dict[str, Any] = {} + + def probe(name: str, **kwargs: Any) -> dict[str, Any]: + seen.update(kwargs) + return {"ok": True, "status": "valid", "models_count": 2, "model_ids": ["a", "b"], "elapsed_ms": 1} + + monkeypatch.setattr("raven.config.update_providers.test_provider", probe) + + assert onboard_commands._verify_provider("openrouter") == (True, "valid", ["a", "b"]) + assert seen["check_credential"] is True + + +def test_verify_provider_says_an_unconfirmed_key_is_unconfirmed( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """A catalogue that checks no keys, on a vendor with no path that would. + + Not a failure -- the list is real and the wizard goes on with it -- and not + "Connected!" either: the test message is the check that remains, and the + line says so. With ``--skip-test`` there is no such message, and the line + says that instead. + """ + + def probe(name: str, **kwargs: Any) -> dict[str, Any]: + return { + "ok": False, + "status": "key_unchecked", + "models_count": 2, + "model_ids": ["a", "b"], + "error": "this vendor's models endpoint answers without checking the key", + "elapsed_ms": 1, + } + + monkeypatch.setattr("raven.config.update_providers.test_provider", probe) + + assert onboard_commands._verify_provider("custom") == (True, "unchecked", ["a", "b"]) + out = " ".join(capsys.readouterr().out.split()) + assert "Connected" not in out + assert "without checking the key" in out + assert "test message" in out + + assert onboard_commands._verify_provider("custom", skip_test=True) == (True, "unchecked", ["a", "b"]) + out = " ".join(capsys.readouterr().out.split()) + assert "Connected" not in out + assert "--skip-test" in out diff --git a/tests/test_cli_provider_commands.py b/tests/test_cli_provider_commands.py index e843889e7..13fe19866 100644 --- a/tests/test_cli_provider_commands.py +++ b/tests/test_cli_provider_commands.py @@ -502,7 +502,7 @@ def test_test_command_success_renders_models_count( ) -> None: from raven.config import update_providers - def fake_probe(name: str, *, timeout_s: int = 10) -> dict: + def fake_probe(name: str, *, timeout_s: int = 10, **_: object) -> dict: assert name == "openrouter" return { "ok": True, @@ -527,7 +527,7 @@ def test_test_command_failure_renders_hint( ) -> None: from raven.config import update_providers - def fake_probe(name: str, *, timeout_s: int = 10) -> dict: + def fake_probe(name: str, *, timeout_s: int = 10, **_: object) -> dict: return { "ok": False, "status": "invalid_key", @@ -552,7 +552,7 @@ def test_test_command_points_an_untrusted_certificate_at_the_certificate_store( """The handshake refused the certificate, so the network advice would mislead.""" from raven.config import update_providers - def fake_probe(name: str, *, timeout_s: int = 10) -> dict: + def fake_probe(name: str, *, timeout_s: int = 10, **_: object) -> dict: return { "ok": False, "status": "certificate_untrusted", @@ -583,7 +583,7 @@ def test_test_command_names_the_refusals_a_ca_does_not_fix( """ from raven.config import update_providers - def fake_probe(name: str, *, timeout_s: int = 10) -> dict: + def fake_probe(name: str, *, timeout_s: int = 10, **_: object) -> dict: return { "ok": False, "status": "certificate_untrusted", @@ -1109,3 +1109,45 @@ def test_endpoint_list_unknown_provider_exits_1(tmp_config: Path) -> None: r = runner.invoke(app, ["provider", "endpoint", "list", "no-such-provider"]) assert r.exit_code == 1 assert "Unknown provider" in r.output + + +def test_test_command_confirms_the_key_itself(tmp_config: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A 200 from a list that answers anyone is never read as a valid key on its own.""" + from raven.config import update_providers + + seen: dict = {} + + def fake_probe(name: str, *, timeout_s: int = 10, **kwargs: object) -> dict: + seen.update(kwargs) + return {"ok": True, "status": "valid", "elapsed_ms": 9, "http_status": 200, "models_count": 3, "error": None} + + monkeypatch.setattr(update_providers, "test_provider", fake_probe) + + r = runner.invoke(app, ["provider", "test", "openrouter"]) + assert r.exit_code == 0, r.output + assert seen["check_credential"] is True + + +def test_test_command_says_an_unconfirmed_key_is_not_confirmed( + tmp_config: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Reached, list real, key unconfirmed: said as that, and not as a failure.""" + from raven.config import update_providers + + def fake_probe(name: str, *, timeout_s: int = 10, **kwargs: object) -> dict: + return { + "ok": False, + "status": "key_unchecked", + "elapsed_ms": 9, + "http_status": 200, + "models_count": 3, + "error": "this vendor's models endpoint answers without checking the key", + } + + monkeypatch.setattr(update_providers, "test_provider", fake_probe) + + r = runner.invoke(app, ["provider", "test", "custom"]) + out = " ".join(r.output.split()) + assert r.exit_code == 0, r.output + assert "key not confirmed" in out + assert "custom OK" not in out diff --git a/tests/test_config_update_providers.py b/tests/test_config_update_providers.py index a6d328e03..d426e5db7 100644 --- a/tests/test_config_update_providers.py +++ b/tests/test_config_update_providers.py @@ -2745,3 +2745,86 @@ def test_a_differently_cased_tenant_header_still_reaches_the_relay(cfg_path: Pat result = probe_provider("custom", config_path=cfg_path, transport=_tenant_relay(seen)) assert result["status"] == "valid" assert seen == ["test-tenant"] + + +# --------------------------------------------------------------------------- +# A credential judged before it is stored +# --------------------------------------------------------------------------- + + +def test_an_unsaved_key_is_judged_as_the_stored_one_would_be(cfg_path: Path) -> None: + """``section_override`` lays the form's fields over the section; nothing is written. + + The stored key is the good one and the tried one is not, then the other way + round: the verdict follows the key being tried, and the section on disk is + the same bytes afterwards either way. + """ + _seed_key(cfg_path, key="sk-real") + before = cfg_path.read_text() + + tried = probe_provider( + "openrouter", + config_path=cfg_path, + transport=_public_catalogue("sk-real"), + check_credential=True, + section_override={"api_key": "111"}, + ) + assert tried["status"] == "invalid_key" + assert tried["http_status"] == 401 + assert cfg_path.read_text() == before + + _seed_key(cfg_path, key="111") + before = cfg_path.read_text() + tried = probe_provider( + "openrouter", + config_path=cfg_path, + transport=_public_catalogue("sk-real"), + check_credential=True, + section_override={"api_key": "sk-real"}, + ) + assert tried["status"] == "valid" + assert cfg_path.read_text() == before + + +def test_the_tried_field_replaces_the_stored_spelling_too() -> None: + """The section model reads ``apiKey`` over ``api_key`` when both are present. + + So laying the schema spelling over a section that keeps the serialized one + would have judged the stored key. The stored spelling goes; a ``None`` leaves + the field alone, the way a form that draws no field for it does. + """ + from raven.config.schema import ProviderConfig + from raven.config.update_providers import _with_override + + merged = _with_override( + {"apiKey": "stored", "apiBase": "https://relay.test/v1"}, + {"api_key": "tried", "api_base": None}, + ) + + assert merged == {"apiBase": "https://relay.test/v1", "api_key": "tried"} + section = ProviderConfig.model_validate(merged) + assert section.api_key == "tried" + assert section.api_base == "https://relay.test/v1" + + +@pytest.mark.parametrize( + ("status", "http_status", "refused"), + [ + ("invalid_key", 401, True), + # Google names a key it does not know in a 400. + ("invalid_key", 400, True), + # Forbidden is what vendors say for a region, a plan or a permission as + # well, and what a proxy says for anything: reported, not refused. + ("invalid_key", 403, False), + # A key that could not be sent at all was never shown to the vendor. + ("invalid_key", None, False), + ("key_unchecked", None, False), + ("network_error", None, False), + ("no_probe_endpoint", None, False), + ("valid", 200, False), + ], +) +def test_only_the_vendors_own_refusal_refuses_a_key(status: str, http_status: int | None, refused: bool) -> None: + from raven.config.update_providers import credential_refused + + assert credential_refused({"status": status, "http_status": http_status}) is refused diff --git a/tests/test_rpc_model.py b/tests/test_rpc_model.py index ffe0b9d00..bf224d949 100644 --- a/tests/test_rpc_model.py +++ b/tests/test_rpc_model.py @@ -20,6 +20,7 @@ from raven.rpc.methods.model import ( model_add_endpoint, model_add_model, + model_check_key, model_disconnect, model_endpoints, model_fetch_models, @@ -368,6 +369,82 @@ async def test_save_key_missing_params_rejected(fake_home: Path) -> None: await model_save_key({"slug": "anthropic"}) +# ---------------------------------------------------------------------------- +# model.check_key +# ---------------------------------------------------------------------------- + + +def _probe_answering(answer: dict, seen: dict) -> object: + def fake_probe(name: str, **kwargs: object) -> dict: + seen.update(name=name, **kwargs) + return {"elapsed_ms": 3, "models_count": None, "model_ids": None, "error": None, "http_status": None, **answer} + + return fake_probe + + +async def test_check_key_asks_the_vendor_about_the_unsaved_key_and_writes_nothing( + fake_home: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + seen: dict = {} + monkeypatch.setattr( + "raven.config.update_providers.test_provider", + _probe_answering({"ok": False, "status": "invalid_key", "http_status": 401, "error": "HTTP 401"}, seen), + ) + + result = await model_check_key({"slug": "openrouter", "api_key": "sk-or-never-issued"}) + + assert result == { + "ok": False, + "status": "invalid_key", + "http_status": 401, + "models_count": None, + "error": "HTTP 401", + "refused": True, + } + # The same probe the saved key gets, on the key the form holds. + assert seen["name"] == "openrouter" + assert seen["check_credential"] is True + assert seen["section_override"] == {"api_key": "sk-or-never-issued"} + config = fake_home / ".raven" / "config.json" + assert not config.exists() or "sk-or-never-issued" not in config.read_text() + + +@pytest.mark.parametrize( + ("answer", "status"), + [ + ({"ok": True, "status": "valid", "http_status": 200, "models_count": 12}, "valid"), + ({"ok": False, "status": "key_unchecked", "error": "answers without checking the key"}, "key_unchecked"), + ({"ok": False, "status": "network_error", "error": "connection refused"}, "network_error"), + # Forbidden is not the vendor's word for a wrong key alone. + ({"ok": False, "status": "invalid_key", "http_status": 403, "error": "HTTP 403"}, "invalid_key"), + ], +) +async def test_check_key_reports_what_is_not_the_keys_fault_without_refusing( + fake_home: Path, monkeypatch: pytest.MonkeyPatch, answer: dict, status: str +) -> None: + monkeypatch.setattr("raven.config.update_providers.test_provider", _probe_answering(answer, {})) + + result = await model_check_key({"slug": "openrouter", "api_key": "sk-or-1"}) + + assert result["status"] == status + assert result["refused"] is False + assert result["models_count"] == answer.get("models_count") + + +async def test_check_key_sends_only_the_fields_the_form_holds(fake_home: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A local deployment is reached by address: no key is laid over the section.""" + seen: dict = {} + monkeypatch.setattr( + "raven.config.update_providers.test_provider", + _probe_answering({"ok": True, "status": "valid", "http_status": 200, "models_count": 3}, seen), + ) + + result = await model_check_key({"slug": "ollama", "api_base": "http://localhost:11434"}) + + assert result["refused"] is False + assert seen["section_override"] == {"api_base": "http://localhost:11434"} + + # ---------------------------------------------------------------------------- # model.disconnect # ---------------------------------------------------------------------------- diff --git a/ui-tui/src/i18n/messages.generated.ts b/ui-tui/src/i18n/messages.generated.ts index 634329ef9..8541fdd68 100644 --- a/ui-tui/src/i18n/messages.generated.ts +++ b/ui-tui/src/i18n/messages.generated.ts @@ -2424,6 +2424,9 @@ export const UI_TEXT: Record> = { 'No new API key to save. To replace the saved one, type a new key in the API key field, then press {button}.', 'gui.settings.providers.key_not_ascii': "This doesn't look like an API key: it has characters a key never contains. Check what was pasted", + 'gui.settings.providers.key_refused': + "{vendor} rejected this key, so it was not saved: check that it is complete and is this vendor's key, then connect again", + 'gui.settings.providers.key_rejected': 'Key rejected', 'gui.settings.providers.base': 'Service address', 'gui.settings.providers.api_base': 'API address', 'gui.settings.providers.base_default': 'Default', @@ -5738,6 +5741,9 @@ export const UI_TEXT: Record> = { 'gui.settings.providers.key_unchanged': '没有新的 API key 要保存。要更换,先在「API 密钥」框里填入新的,再点「{button}」。', 'gui.settings.providers.key_not_ascii': '这不像是 API key:里面有 Key 不会包含的字符,请检查粘贴的内容', + 'gui.settings.providers.key_refused': + '{vendor} 不认这个 Key,没有保存:检查有没有复制全、是不是这家的 Key,改好再点「连接」', + 'gui.settings.providers.key_rejected': 'Key 被拒', 'gui.settings.providers.base': '服务地址', 'gui.settings.providers.api_base': 'API 地址', 'gui.settings.providers.base_default': '默认', diff --git a/ui-tui/src/rpc/generated.ts b/ui-tui/src/rpc/generated.ts index 4fb9af0f3..030958f7d 100644 --- a/ui-tui/src/rpc/generated.ts +++ b/ui-tui/src/rpc/generated.ts @@ -3894,6 +3894,33 @@ export interface ModelRevealKeyResult { */ api_key?: string | null; } +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "ModelCheckKeyParams". + */ +export interface ModelCheckKeyParams { + slug: string; + api_key?: string; + api_base?: string; +} +/** + * This interface was referenced by `RavenRpcRoot`'s JSON-Schema + * via the `definition` "ModelCheckKeyResult". + */ +export interface ModelCheckKeyResult { + ok: boolean; + /** + * The probe's word: `valid`, `invalid_key`, `key_unchecked`, `no_credits`, `rate_limited`, `network_error`, `proxy_unreachable`, `certificate_untrusted`, `no_probe_endpoint`, `not_configured`, `http_NNN`. + */ + status: string; + http_status?: number; + models_count?: number; + error?: string; + /** + * The vendor itself refused this key -- answered it with 401, or named it invalid -- so storing it would store a key that cannot work. Every other answer leaves the key worth storing. + */ + refused: boolean; +} /** * This interface was referenced by `RavenRpcRoot`'s JSON-Schema * via the `definition` "ModelFetchModelsParams". diff --git a/ui-web/scripts/fixture-params.mjs b/ui-web/scripts/fixture-params.mjs index 74b359cef..be1def6b5 100644 --- a/ui-web/scripts/fixture-params.mjs +++ b/ui-web/scripts/fixture-params.mjs @@ -86,6 +86,8 @@ export const PARAMS = { 'model.save_key': { slug: 'openai', api_key: 'sk-x' }, 'model.disconnect': { slug: 'openai' }, 'model.reveal_key': { slug: 'anthropic' }, + /* A key the library refuses, so the refusal's fields are drawn once. */ + 'model.check_key': { slug: 'openrouter', api_key: 'bad' }, 'model.add_model': { slug: 'openai', model: 'gpt-5.2' }, 'model.remove_model': { slug: 'openai', model: 'gpt-5.1' }, 'model.fetch_models': { slug: 'openai' }, diff --git a/ui-web/src/features/settings/SetupBodies.test.tsx b/ui-web/src/features/settings/SetupBodies.test.tsx index 99f990f16..b31cf89ce 100644 --- a/ui-web/src/features/settings/SetupBodies.test.tsx +++ b/ui-web/src/features/settings/SetupBodies.test.tsx @@ -207,8 +207,11 @@ describe('ModelStepBody', () => { expect(calls.filter(([m]) => m === 'provider')).toEqual([['provider', { op: 'save_key', slug: 'ollama', api_base: 'http://localhost:11434' }]]) }) - it('connect saves at once, closes the form, and the row tests the key on the side', async () => { - let answer: (v: unknown) => void = () => {} + it('connect asks the vendor first; a key it did not refuse is saved, the form closes, and the row carries the verdict', async () => { + /* A 403 is reported but not refused -- vendors answer it for a region or a + plan as well -- so the key is stored, the row says the vendor rejects it + rather than "Connected", and a retry is offered. No second test on the + side: the check already was the test. */ const data = snap() const { calls } = install(data, { provider: async (op, params) => { @@ -216,9 +219,9 @@ describe('ModelStepBody', () => { data.providers = data.providers.map((p) => (p.id === params.slug ? { ...p, on: true } : p)) return { ...data } }, - fetchModels: (slug, verify) => { - calls.push([verify ? 'fetchModels:verify' : 'fetchModels', slug]) - return new Promise((r) => { answer = r as (v: unknown) => void }) + checkKey: async (slug, params) => { + calls.push(['checkKey', { slug, ...params }]) + return { ok: false, status: 'invalid_key', http_status: 403, error: 'HTTP 403', refused: false } }, }) await openBody(ModelStepBody) @@ -227,17 +230,57 @@ describe('ModelStepBody', () => { await act(async () => { fireEvent.click(screen.getByText('gui.settings.providers.connect')) }) const saved = calls.find(([m]) => m === 'provider')![1] as { op: string; slug: string } expect(saved.op).toBe('save_key') + expect(calls.map(([m]) => m)).toEqual(['checkKey', 'provider']) + expect(calls[0]![1]).toEqual({ slug: saved.slug, api_key: '111' }) expect(document.querySelector('.settings-padd')).toBeNull() - expect(calls).toContainEqual(['fetchModels:verify', saved.slug]) const row = (): Element => [...document.querySelectorAll('.settings-prow2')].find((r) => r.querySelector('.settings-pnote'))! - expect(row().querySelector('.settings-pnote')!.textContent).toBe('gui.settings.providers.probe_checking') - expect(row().querySelector('.settings-chip')!.className).toContain('settings-on') - await act(async () => { answer({ models: [], status: 'invalid_key', error: 'HTTP 401' }) }) expect(row().querySelector('.settings-pnote')!.textContent).toContain('gui.settings.providers.probe_invalid_saved') expect(row().querySelector('.settings-chip')!.className).toContain('settings-warn') + expect(row().querySelector('.settings-chip')!.textContent).toBe('gui.settings.providers.key_rejected') expect(screen.getByText('gui.settings.providers.probe_recheck')).toBeTruthy() }) + it('a key the vendor refuses is not saved, and the form says so', async () => { + const data = snap() + const { calls } = install(data, { + checkKey: async (slug, params) => { + calls.push(['checkKey', { slug, ...params }]) + return { ok: false, status: 'invalid_key', http_status: 401, error: 'HTTP 401', refused: true } + }, + }) + await openBody(ModelStepBody) + await act(async () => { fireEvent.click(screen.getByText('gui.settings.providers.add')) }) + await act(async () => { fireEvent.change(screen.getByLabelText('gui.settings.providers.api_key'), { target: { value: 'sk-never-issued' } }) }) + await act(async () => { fireEvent.click(screen.getByText('gui.settings.providers.connect')) }) + expect(calls.map(([m]) => m)).toEqual(['checkKey']) + expect(document.querySelector('.settings-padd')).not.toBeNull() + const { slug } = calls[0]![1] as { slug: string } + const vendor = data.providers.find((p) => p.id === slug)!.name + expect(screen.getByRole('alert').textContent).toBe(`gui.settings.providers.key_refused {"vendor":"${vendor}"}`) + expect(document.querySelectorAll('.settings-prow2')).toHaveLength(data.providers.filter((p) => p.on).length) + }) + + it('a check that cannot run does not hold the key back: saved, then tested on the side', async () => { + const data = snap() + const { calls } = install(data, { + provider: async (op, params) => { + calls.push(['provider', { op, ...params }]) + data.providers = data.providers.map((p) => (p.id === params.slug ? { ...p, on: true } : p)) + return { ...data } + }, + checkKey: async () => { throw new Error('gateway gone') }, + fetchModels: async (slug, verify) => { calls.push([verify ? 'fetchModels:verify' : 'fetchModels', slug]); return { models: [], status: 'ok' } }, + }) + await openBody(ModelStepBody) + await act(async () => { fireEvent.click(screen.getByText('gui.settings.providers.add')) }) + await act(async () => { fireEvent.change(screen.getByLabelText('gui.settings.providers.api_key'), { target: { value: '111' } }) }) + await act(async () => { fireEvent.click(screen.getByText('gui.settings.providers.connect')) }) + const saved = calls.find(([m]) => m === 'provider')![1] as { slug: string } + expect(calls.map(([m]) => m)).toEqual(['provider', 'fetchModels:verify']) + expect(calls[1]![1]).toBe(saved.slug) + expect(document.querySelector('.settings-padd')).toBeNull() + }) + it('a verified key says how many models the vendor named', async () => { const data = snap() install(data, { @@ -245,14 +288,7 @@ describe('ModelStepBody', () => { data.providers = data.providers.map((p) => (p.id === params.slug ? { ...p, on: true } : p)) return { ...data } }, - fetchModels: async () => ({ - status: 'ok', - models: [ - { id: 'a', label: 'a', kind: 'text', added: false, source: 'live' }, - { id: 'b', label: 'b', kind: 'text', added: false, source: 'live' }, - { id: 'c', label: 'c', kind: 'text', added: false, source: 'registry' }, - ], - }), + checkKey: async () => ({ ok: true, status: 'valid', http_status: 200, models_count: 2, refused: false }), }) await openBody(ModelStepBody) await act(async () => { fireEvent.click(screen.getByText('gui.settings.providers.add')) }) @@ -324,7 +360,7 @@ describe('ModelStepBody', () => { data.providers = data.providers.map((p) => (p.id === params.slug ? { ...p, on: true } : p)) return { ...data } }, - fetchModels: async () => ({ status: 'key_unchecked', models: [] }), + checkKey: async () => ({ ok: false, status: 'key_unchecked', refused: false }), }) await openBody(ModelStepBody) await act(async () => { fireEvent.click(screen.getByText('gui.settings.providers.add')) }) @@ -338,20 +374,19 @@ describe('ModelStepBody', () => { it('a failed test checks again on request', async () => { const data = snap() - let status = 'network_error' install(data, { provider: async (_op, params) => { data.providers = data.providers.map((p) => (p.id === params.slug ? { ...p, on: true } : p)) return { ...data } }, - fetchModels: async () => ({ status, models: [] }), + checkKey: async () => ({ ok: false, status: 'network_error', refused: false }), + fetchModels: async () => ({ status: 'ok', models: [] }), }) await openBody(ModelStepBody) await act(async () => { fireEvent.click(screen.getByText('gui.settings.providers.add')) }) await act(async () => { fireEvent.change(screen.getByLabelText('gui.settings.providers.api_key'), { target: { value: 'sk-1' } }) }) await act(async () => { fireEvent.click(screen.getByText('gui.settings.providers.connect')) }) expect(document.querySelector('.settings-pnote')!.textContent).toContain('gui.settings.providers.probe_unreachable') - status = 'ok' await act(async () => { fireEvent.click(screen.getByText('gui.settings.providers.probe_recheck')) }) expect(document.querySelector('.settings-pnote')!.getAttribute('data-tone')).toBe('ok') }) @@ -363,7 +398,7 @@ describe('ModelStepBody', () => { data.providers = data.providers.map((p) => (p.id === params.slug ? { ...p, on: true } : p)) return { ...data } }, - fetchModels: async () => ({ status: 'certificate_untrusted', models: [] }), + checkKey: async () => ({ ok: false, status: 'certificate_untrusted', refused: false }), }) await openBody(ModelStepBody) await act(async () => { fireEvent.click(screen.getByText('gui.settings.providers.add')) }) diff --git a/ui-web/src/features/settings/providers/ProviderDetail.test.tsx b/ui-web/src/features/settings/providers/ProviderDetail.test.tsx index 60c8fd900..6bca26026 100644 --- a/ui-web/src/features/settings/providers/ProviderDetail.test.tsx +++ b/ui-web/src/features/settings/providers/ProviderDetail.test.tsx @@ -152,6 +152,14 @@ describe('the eye beside a connected provider\'s key', () => { expect(screen.getByRole('alert').textContent).toBe(UNCHANGED) }) + it('the head says the key is rejected once the vendor refused the stored one', async () => { + install(snap(), { fetchModels: async () => ({ models: [], status: 'invalid_key', error: 'HTTP 401' }) }) + await open('openrouter') + expect(document.querySelector('.settings-tp-state')!.textContent).toBe('gui.settings.providers.connected') + await act(async () => { await store.recheck('openrouter') }) + expect(document.querySelector('.settings-tp-state')!.textContent).toBe('gui.settings.providers.key_rejected') + }) + it('saves an edited revealed key as the new key', async () => { const { calls } = install(snap(), { revealKey: answering('sk-or-saved') }) await open('openrouter') @@ -315,8 +323,8 @@ describe('provider detail', () => { const box = screen.getByLabelText('gui.settings.providers.api_key') as HTMLInputElement await act(async () => { fireEvent.change(box, { target: { value: 'sk-new' } }) }) await act(async () => { fireEvent.click(screen.getByText('gui.settings.providers.connect')) }) - /* The save, then the test it kicks off on the side. */ - expect(calls).toEqual([['provider', { op: 'save_key', slug: 'openai', api_key: 'sk-new' }], ['fetchModels:verify', 'openai']]) + /* The vendor is asked first; it did not refuse, so the save follows. */ + expect(calls).toEqual([['checkKey', { slug: 'openai', api_key: 'sk-new' }], ['provider', { op: 'save_key', slug: 'openai', api_key: 'sk-new' }]]) }) it('a key-only vendor connects with its key alone, whatever address it was handed', async () => { @@ -335,7 +343,7 @@ describe('provider detail', () => { const box = screen.getByLabelText('gui.settings.providers.api_key') as HTMLInputElement await act(async () => { fireEvent.change(box, { target: { value: 'AIza-new' } }) }) await act(async () => { fireEvent.click(screen.getByText('gui.settings.providers.connect')) }) - expect(calls).toEqual([['provider', { op: 'save_key', slug: 'gemini', api_key: 'AIza-new' }], ['fetchModels:verify', 'gemini']]) + expect(calls).toEqual([['checkKey', { slug: 'gemini', api_key: 'AIza-new' }], ['provider', { op: 'save_key', slug: 'gemini', api_key: 'AIza-new' }]]) }) it('a local provider connects by address alone and refuses an empty one', async () => { @@ -347,7 +355,7 @@ describe('provider detail', () => { expect(calls).toEqual([]) await act(async () => { fireEvent.change(box, { target: { value: 'http://localhost:11434' } }) }) await act(async () => { fireEvent.click(screen.getByText('gui.settings.providers.connect')) }) - expect(calls).toEqual([['provider', { op: 'save_key', slug: 'ollama', api_base: 'http://localhost:11434' }], ['fetchModels:verify', 'ollama']]) + expect(calls).toEqual([['checkKey', { slug: 'ollama', api_base: 'http://localhost:11434' }], ['provider', { op: 'save_key', slug: 'ollama', api_base: 'http://localhost:11434' }]]) }) it('sends the address from the field it draws alone for a vendor that takes no key', async () => { @@ -367,7 +375,7 @@ describe('provider detail', () => { const box = boxes[0] as HTMLInputElement await act(async () => { fireEvent.change(box, { target: { value: 'https://keyless.test/v1' } }) }) await act(async () => { fireEvent.click(box.parentElement!.querySelector('button')!) }) - expect(calls).toEqual([['provider', { op: 'save_key', slug: 'keyless', api_base: 'https://keyless.test/v1' }], ['fetchModels:verify', 'keyless']]) + expect(calls).toEqual([['checkKey', { slug: 'keyless', api_base: 'https://keyless.test/v1' }], ['provider', { op: 'save_key', slug: 'keyless', api_base: 'https://keyless.test/v1' }]]) }) it('a header is added as a one-name patch and removed as a one-name null', async () => { diff --git a/ui-web/src/features/settings/providers/ProviderDetail.tsx b/ui-web/src/features/settings/providers/ProviderDetail.tsx index 59209653f..5195344de 100644 --- a/ui-web/src/features/settings/providers/ProviderDetail.tsx +++ b/ui-web/src/features/settings/providers/ProviderDetail.tsx @@ -408,7 +408,7 @@ function Advanced({ p }: { p: ProviderRow }): JSX.Element { it -- on a two-column page there is nothing to go back to. */ function Head({ p }: { p: ProviderRow }): JSX.Element { const state = p.on - ? t('gui.settings.providers.connected') + ? t(store.get().probes[p.id]?.status === 'invalid_key' ? 'gui.settings.providers.key_rejected' : 'gui.settings.providers.connected') : t(kindOf(p) === 'oauth' ? 'gui.settings.providers.needs_auth' : kindOf(p) === 'local' ? 'gui.settings.providers.needs_base' : 'gui.settings.providers.needs_key') /* One way out of the page, not two. The name's arrow was `keyUrl || homepage`, so on any provider with a key page it opened the same page as diff --git a/ui-web/src/features/settings/providers/Providers.tsx b/ui-web/src/features/settings/providers/Providers.tsx index 8ced9af50..2837ea292 100644 --- a/ui-web/src/features/settings/providers/Providers.tsx +++ b/ui-web/src/features/settings/providers/Providers.tsx @@ -167,6 +167,18 @@ function probeText(p: ConnectProbe): string { } } +/* A row's chip. "Connected" -- unless the vendor itself refused the stored + key, where "Connected" beside "the provider rejects the saved key" was two + answers to one question, and the chip now says which. Any other failed + test keeps "Connected" in the warning colour: the key may well be fine and + the vendor unreachable. */ +function rowChip(slug: string): { state: 'on' | 'warn'; text: string } { + const probe = store.get().probes[slug] + if (store.isBusy(`probe:${slug}`) || !probe) return { state: 'on', text: t('gui.settings.providers.connected') } + if (probe.status === 'invalid_key') return { state: 'warn', text: t('gui.settings.providers.key_rejected') } + return { state: probeTone(probe) === 'warn' ? 'warn' : 'on', text: t('gui.settings.providers.connected') } +} + /* A connected provider's test: "testing" while it runs, then its verdict, with a way to ask again when the answer was anything but a plain yes. Nothing when it was not tested this session. */ @@ -304,7 +316,7 @@ export function Providers({ setup }: { setup?: boolean }): JSX.Element { {!on.length && s.provAdd === null &&
{t('gui.settings.providers.none')}
} {on.map((p) => (
- {t('gui.settings.providers.connected')} + {rowChip(p.id).text} {p.name} {kindOf(p) === 'oauth' && {t('gui.settings.providers.subscription')}} {(p.configured || []).length > 0 && {t('gui.settings.providers.n_models', { n: (p.configured || []).length })}} diff --git a/ui-web/src/features/settings/source.test.ts b/ui-web/src/features/settings/source.test.ts index 18bbc84db..2be8c8a9d 100644 --- a/ui-web/src/features/settings/source.test.ts +++ b/ui-web/src/features/settings/source.test.ts @@ -75,6 +75,14 @@ describe('settings source', () => { ]) }) + it('asks check_key about a key before it is stored, with the fields the form sent', async () => { + const mod = await load({ 'model.check_key': { ok: false, status: 'invalid_key', http_status: 401, refused: true } }) + const verdict = await mod.settingsSource.checkKey('openrouter', { api_key: 'sk-or-never-issued' }) + expect(verdict.refused).toBe(true) + expect(seen).toEqual([['model.check_key', { slug: 'openrouter', api_key: 'sk-or-never-issued' }]]) + expect(toasts).toEqual([]) + }) + it('reads one provider\'s saved key back on request, and null when it keeps none', async () => { const mod = await load({ 'model.reveal_key': { api_key: 'sk-or-saved' } }) expect(await mod.settingsSource.revealKey('openrouter')).toBe('sk-or-saved') diff --git a/ui-web/src/features/settings/source.ts b/ui-web/src/features/settings/source.ts index 3a9103257..7b981f9ac 100644 --- a/ui-web/src/features/settings/source.ts +++ b/ui-web/src/features/settings/source.ts @@ -293,6 +293,9 @@ export const settingsSource: SettingsSource = { state. What it taught the server's cache reaches the offer through `reloadProviders`, which the store runs once the read succeeds. */ fetchModels: (slug, verify) => gateway().call('model.fetch_models', verify ? { slug, verify: true } : { slug }), + /* A read as well: the verdict is the form's to act on, and a key the vendor + refused is refused where the wording lives, not toasted. */ + checkKey: (slug, params) => gateway().call('model.check_key', { slug, ...params }), revealKey: (slug) => run(gateway().call('model.reveal_key', { slug }).then((r) => r.api_key ?? null)), reloadProviders: afterProviders, addModels: (slug, models) => run(gateway().call('model.add_models', { slug, models }).then(afterProviders)), diff --git a/ui-web/src/features/settings/store.ts b/ui-web/src/features/settings/store.ts index 1283bf923..a77fd722f 100644 --- a/ui-web/src/features/settings/store.ts +++ b/ui-web/src/features/settings/store.ts @@ -21,14 +21,7 @@ import { statedTags } from '../model/types' import type { Kind } from '../model/types' import type { - ArchivedSession, - ConnectProbe, - ModelCandidate, - SettingsSnapshot, - SettingsSource, - SkillDetail, - UsageRange, - UsageStats, + ArchivedSession, ConnectProbe, KeyCheck, ModelCandidate, SettingsSnapshot, SettingsSource, SkillDetail, UsageRange, UsageStats, } from './types' export type SectionId = @@ -397,10 +390,22 @@ const setProbe = (slug: string, probe: ConnectProbe | null): void => { set({ probes }) } -/* Store a credential, then test it on the side. The save is the connect: a - check can be wrong -- a public catalogue, a proxy, a vendor that is down -- - so its answer never holds the key back. The row says "testing" until the - verdict lands, and a failed one is marked and offers a retry. */ +/* The fields a check is asked about: the key and the address the form sends, + and nothing it did not. */ +const keyParams = (params: Record): { api_key?: string; api_base?: string } => ({ + ...(typeof params.api_key === 'string' && params.api_key ? { api_key: params.api_key } : {}), + ...(typeof params.api_base === 'string' && params.api_base ? { api_base: params.api_base } : {}), +}) + +/* Ask the vendor about the credential, then store it. One answer holds the key + back: the vendor's own refusal (`refused` -- a 401 to this key, or the key + named invalid). A key the vendor has never issued cannot work, and storing it + only moved the failure to the first message while the row read "Connected" + beside "the provider rejects the saved key". Every other answer stores the + key and says what is still worth knowing: a check can be wrong -- a public + catalogue, a proxy, a vendor that is down -- and none of that is the key's + fault. A check that cannot run at all does not hold the key back either: the + key is stored and tested on the side, as before. */ export async function connect(key: string, slug: string, params: Record): Promise { /* A header carries ASCII only: a key with anything else in it is text pasted from the wrong place and could never be sent, so it is refused here rather @@ -409,9 +414,31 @@ export async function connect(key: string, slug: string, params: Record source().provider('save_key', params)) - if (stored) void recheck(slug) - return stored + /* Held in an object rather than a `let`: an assignment inside the closure is + not one the type narrowing after `run` sees. */ + const seen: { verdict: KeyCheck | null } = { verdict: null } + const stored = await run(key, async () => { + try { + seen.verdict = await source().checkKey(slug, keyParams(params)) + } catch { + seen.verdict = null + } + if (seen.verdict?.refused) { + const vendor = get().snap.providers.find((p) => p.id === slug)?.name ?? slug + refuse(t('gui.settings.providers.key_refused', { vendor })) + throw { handled: true } + } + return source().provider('save_key', params) + }) + if (!stored) return false + const verdict = seen.verdict + if (verdict) { + setProbe(slug, { ok: verdict.ok, status: verdict.status, models_count: verdict.models_count ?? null, error: verdict.error ?? null }) + if (verdict.status === 'valid') reloadOffer() + } else { + void recheck(slug) + } + return true } /* A live read that succeeded wrote what the vendor serves to the server's diff --git a/ui-web/src/features/settings/types.ts b/ui-web/src/features/settings/types.ts index 57aa7c282..c956eadd4 100644 --- a/ui-web/src/features/settings/types.ts +++ b/ui-web/src/features/settings/types.ts @@ -142,6 +142,8 @@ export type ArchivedSession = ResultOf<'session.list'>['sessions'][number] /* The EverOS Cloud card's data: settings.everosCloud's own shape. */ export type EverosCloudInfo = ResultOf<'settings.everosCloud'> export type OauthStart = ResultOf<'model.oauth_login'> +/* What the vendor said about a key before it was stored (`model.check_key`). */ +export type KeyCheck = ResultOf<'model.check_key'> /* Everything the dialog draws from, in one read. `raw` is the config settings.get returned (camelCased keys, one level per dot). The skill and @@ -241,6 +243,9 @@ export interface SettingsSource { /* Ask the provider what it serves right now. A read: nothing is written until a row is added. */ fetchModels(slug: string, verify?: boolean): Promise + /* The vendor's answer to a credential a form holds, before anything is + stored: a read, with `refused` for a key the vendor itself turned away. */ + checkKey(slug: string, params: { api_key?: string; api_base?: string }): Promise /* The key a connected provider saved, for the eye beside its empty key field; null when it keeps none in its own section. Asked for one provider on a press, never part of the snapshot. */ diff --git a/ui-web/src/rpc/fixtures/model.ts b/ui-web/src/rpc/fixtures/model.ts index 8abed2e71..538d1ba0b 100644 --- a/ui-web/src/rpc/fixtures/model.ts +++ b/ui-web/src/rpc/fixtures/model.ts @@ -131,6 +131,17 @@ const wire = (e: Entry, current: string): Provider => ({ ...(e.default_api_base ? { default_api_base: e.default_api_base } : {}), }) +/* The vendor's answer to a key before it is stored. A key spelled `bad` (or + `sk-bad...`) is refused the way a vendor refuses one it never issued; + anything else is confirmed against the row's own list. */ +const keyCheck = (row: Entry | undefined, key?: string): ResultOf<'model.check_key'> => { + if (key && /^(sk-)?bad/.test(key)) { + return { ok: false, status: 'invalid_key', http_status: 401, models_count: 0, error: 'HTTP 401', refused: true } + } + if (!row) return { ok: false, status: 'unknown_provider', refused: false } + return { ok: true, status: 'valid', http_status: 200, models_count: row.models.length, refused: false } +} + export interface ModelFixture { fixtures: Fixtures } @@ -144,6 +155,7 @@ export function createModel(_env: FixtureEnv): ModelFixture { return { fixtures: { 'model.options': (p) => ({ model, provider, providers: p.include_providers === false ? [] : rows.map((p) => wire(p, provider)) }), + 'model.check_key': (p) => keyCheck(find(p.slug), p.api_key), 'model.save_key': (p) => { const row = find(p.slug) if (row) row.authenticated = true @@ -228,6 +240,7 @@ export function onboardDemoOverrides(schedule: (ms: number, fn: () => void) => v 'model.options': (p) => p.include_providers === false ? { model: picked?.model ?? '', provider: current(), providers: [] } : wait({ model: picked?.model ?? '', provider: current(), providers: rows.map((p) => wire(p, current())) }), + 'model.check_key': (p) => wait(keyCheck(rows.find((x) => x.slug === p.slug), p.api_key)), 'model.save_key': (p) => { const row = rows.find((x) => x.slug === p.slug) /* The contract's answer is a provider, so a slug this library does not diff --git a/ui-web/src/rpc/generated.ts b/ui-web/src/rpc/generated.ts index d3194637a..2283eb2f1 100644 --- a/ui-web/src/rpc/generated.ts +++ b/ui-web/src/rpc/generated.ts @@ -3,7 +3,7 @@ // Source of truth: rpc-schema/openrpc.json (OpenRPC 1.2.6). // Drift check: `npm run gen:check` (CI runs this; a stale file fails the build). // -// 248 methods, 138 component schemas. +// 249 methods, 138 component schemas. /* eslint-disable */ /** @@ -3112,6 +3112,25 @@ export interface ModelRevealKeyResult { */ api_key?: string | null; } +export interface ModelCheckKeyParams { + slug: string; + api_key?: string; + api_base?: string; +} +export interface ModelCheckKeyResult { + ok: boolean; + /** + * The probe's word: `valid`, `invalid_key`, `key_unchecked`, `no_credits`, `rate_limited`, `network_error`, `proxy_unreachable`, `certificate_untrusted`, `no_probe_endpoint`, `not_configured`, `http_NNN`. + */ + status: string; + http_status?: number; + models_count?: number; + error?: string; + /** + * The vendor itself refused this key -- answered it with 401, or named it invalid -- so storing it would store a key that cannot work. Every other answer leaves the key worth storing. + */ + refused: boolean; +} export interface ModelFetchModelsParams { slug: string; verify?: boolean; @@ -6419,6 +6438,7 @@ export interface RpcMethods { 'model.save_key': { params: ModelSaveKeyParams; result: ModelSaveKeyResult }; 'model.disconnect': { params: ModelDisconnectParams; result: ModelDisconnectResult }; 'model.reveal_key': { params: ModelRevealKeyParams; result: ModelRevealKeyResult }; + 'model.check_key': { params: ModelCheckKeyParams; result: ModelCheckKeyResult }; 'model.fetch_models': { params: ModelFetchModelsParams; result: ModelFetchModelsResult }; 'model.add_model': { params: ModelAddModelParams; result: ModelAddModelResult }; 'model.add_models': { params: ModelAddModelsParams; result: ModelAddModelsResult }; @@ -6755,6 +6775,7 @@ export const RPC_METHODS = [ "model.add_endpoint", "model.add_model", "model.add_models", + "model.check_key", "model.disconnect", "model.endpoints", "model.fetch_models", diff --git a/ui-web/src/test/settingsHarness.ts b/ui-web/src/test/settingsHarness.ts index e58ee3d7f..d74d4ee61 100644 --- a/ui-web/src/test/settingsHarness.ts +++ b/ui-web/src/test/settingsHarness.ts @@ -170,6 +170,7 @@ export function install(data: SettingsSnapshot = snap(), over: Partial { calls.push(['usage', range]); return null }, provider: async (op, params) => rec('provider', { op, ...params }), fetchModels: async (slug, verify) => { calls.push([verify ? 'fetchModels:verify' : 'fetchModels', slug]); return { models: [], status: 'ok' } }, + checkKey: async (slug, params) => { calls.push(['checkKey', { slug, ...params }]); return { ok: true, status: 'valid', http_status: 200, models_count: 0, refused: false } }, revealKey: async (slug) => { calls.push(['revealKey', slug]); return null }, reloadProviders: async () => data, addModels: async (slug, models) => rec('addModels', { slug, models }), From 961030317bd38efc946a9ffac5e42adb4a24541d Mon Sep 17 00:00:00 2001 From: Handsome-wzw <68996445+Handsome-wzw@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:15:26 +0800 Subject: [PATCH 2/3] fix(*): say a key was rejected only on the vendor's own refusal The row's chip and the detail head read "Key rejected" off the probe's invalid_key status. That status is broader than a refusal: the status table maps every 403 to it, and a 403 can mean a region, a plan, a permission or a proxy policy -- a stored key that may well be fine. model.fetch_models with verify now answers `refused` from the same credential_refused() the check uses, the page keeps it on the probe, and only that turns the label. The TLS trust test asserted "custom OK" from `provider test`; its stand-in's list answers anyone, so the probe's truthful word is now "reached, key not confirmed", and the test asserts the handshake either way. Co-authored-by: Claude (claude-fable-5-1) --- raven/rpc/methods/model.py | 5 +++- raven/rpc/models.py | 8 ++++++ rpc-schema/openrpc.json | 4 +++ tests/test_cli_system_ca.py | 5 +++- tests/test_rpc_model.py | 24 +++++++++++++++++ ui-tui/src/rpc/generated.ts | 4 +++ .../features/settings/SetupBodies.test.tsx | 26 ++++++++++++++++--- .../providers/ProviderDetail.test.tsx | 2 +- .../settings/providers/ProviderDetail.tsx | 2 +- .../features/settings/providers/Providers.tsx | 9 ++++--- ui-web/src/features/settings/store.ts | 6 ++--- ui-web/src/features/settings/types.ts | 7 +++++ ui-web/src/rpc/fixtures/model.ts | 1 + ui-web/src/rpc/generated.ts | 4 +++ 14 files changed, 92 insertions(+), 15 deletions(-) diff --git a/raven/rpc/methods/model.py b/raven/rpc/methods/model.py index 6b7bb7a54..b174e4865 100644 --- a/raven/rpc/methods/model.py +++ b/raven/rpc/methods/model.py @@ -814,7 +814,7 @@ async def model_fetch_models(params: dict) -> dict: A read throughout -- nothing is written until a row is added. """ from raven.config.loader import load_config - from raven.config.update_providers import test_provider + from raven.config.update_providers import credential_refused, test_provider from raven.providers.catalog import describe from raven.providers.rates import resolve_context_window from raven.providers.registry import find_by_name @@ -898,6 +898,9 @@ async def model_fetch_models(params: dict) -> dict: "models": ordered, "status": "ok" if asked else str(probe.get("status") or "not_asked"), "error": None if asked else (str(probe.get("error") or "") or None), + # The vendor's own refusal, the one verdict a surface may call "rejected"; + # `invalid_key` alone also covers a 403, which is not that. + "refused": False if asked else credential_refused(probe), } diff --git a/raven/rpc/models.py b/raven/rpc/models.py index aee6bd6a6..a2ee16d22 100644 --- a/raven/rpc/models.py +++ b/raven/rpc/models.py @@ -2243,6 +2243,14 @@ class ModelFetchModelsResult(_Strict): models: list[ModelCandidate] status: str error: str | None = None + refused: bool = Field( + default=False, + description=( + "With `verify`: the vendor itself refused the stored key -- answered it with 401, or named it invalid. " + "`invalid_key` alone is broader (a 403 maps to it too, and a 403 can mean a region, a plan or a proxy), " + "so a surface that says a key was rejected reads this, not the status." + ), + ) class ModelAddModelParams(_Strict): diff --git a/rpc-schema/openrpc.json b/rpc-schema/openrpc.json index 8b484f132..a7e1174be 100644 --- a/rpc-schema/openrpc.json +++ b/rpc-schema/openrpc.json @@ -1374,6 +1374,10 @@ }, "error": { "type": "string" + }, + "refused": { + "type": "boolean", + "description": "With `verify`: the vendor itself refused the stored key -- answered it with 401, or named it invalid. `invalid_key` alone is broader (a 403 maps to it too, and a 403 can mean a region, a plan or a proxy), so a surface that says a key was rejected reads this, not the status." } } } diff --git a/tests/test_cli_system_ca.py b/tests/test_cli_system_ca.py index 5c79ff544..c3ec33bde 100644 --- a/tests/test_cli_system_ca.py +++ b/tests/test_cli_system_ca.py @@ -79,7 +79,10 @@ def test_a_root_only_the_system_store_holds_is_trusted(corporate, launch, tmp_pa done = _provider_test(launch, corporate, tmp_path) assert done.returncode == 0, done.stdout + done.stderr - assert "custom OK" in done.stdout + # What this asserts is that the handshake got through. The stand-in's list + # answers anyone, so the probe's truthful word for it is "reached, key not + # confirmed"; a stand-in that checked its bearer would read "OK" instead. + assert "custom OK" in done.stdout or "custom reached, key not confirmed" in done.stdout, done.stdout @pytest.mark.slow diff --git a/tests/test_rpc_model.py b/tests/test_rpc_model.py index bf224d949..fde5f8abb 100644 --- a/tests/test_rpc_model.py +++ b/tests/test_rpc_model.py @@ -445,6 +445,30 @@ async def test_check_key_sends_only_the_fields_the_form_holds(fake_home: Path, m assert seen["section_override"] == {"api_base": "http://localhost:11434"} +@pytest.mark.parametrize( + ("answer", "status", "refused"), + [ + ({"ok": False, "status": "invalid_key", "http_status": 401, "error": "HTTP 401"}, "invalid_key", True), + # A 403 is `invalid_key` to the status table and not a refusal: a region, + # a plan or a proxy answers it as well. + ({"ok": False, "status": "invalid_key", "http_status": 403, "error": "HTTP 403"}, "invalid_key", False), + ({"ok": False, "status": "network_error", "error": "refused"}, "network_error", False), + ({"ok": True, "status": "valid", "http_status": 200, "model_ids": ["m1"]}, "ok", False), + ], +) +async def test_fetch_models_says_whether_the_vendor_itself_refused_the_key( + fake_home: Path, monkeypatch: pytest.MonkeyPatch, answer: dict, status: str, refused: bool +) -> None: + """The verdict a row may call "rejected", kept apart from the broader status.""" + _write_config(fake_home, {"providers": {"openrouter": {"apiKey": "sk-or"}}}) + monkeypatch.setattr("raven.config.update_providers.test_provider", _probe_answering(answer, {})) + + out = await model_fetch_models({"slug": "openrouter", "verify": True}) + + assert out["status"] == status + assert out["refused"] is refused + + # ---------------------------------------------------------------------------- # model.disconnect # ---------------------------------------------------------------------------- diff --git a/ui-tui/src/rpc/generated.ts b/ui-tui/src/rpc/generated.ts index 030958f7d..8e3ce60d1 100644 --- a/ui-tui/src/rpc/generated.ts +++ b/ui-tui/src/rpc/generated.ts @@ -3940,6 +3940,10 @@ export interface ModelFetchModelsResult { */ status: string; error?: string; + /** + * With `verify`: the vendor itself refused the stored key -- answered it with 401, or named it invalid. `invalid_key` alone is broader (a 403 maps to it too, and a 403 can mean a region, a plan or a proxy), so a surface that says a key was rejected reads this, not the status. + */ + refused?: boolean; } /** * This interface was referenced by `RavenRpcRoot`'s JSON-Schema diff --git a/ui-web/src/features/settings/SetupBodies.test.tsx b/ui-web/src/features/settings/SetupBodies.test.tsx index b31cf89ce..bf9edd146 100644 --- a/ui-web/src/features/settings/SetupBodies.test.tsx +++ b/ui-web/src/features/settings/SetupBodies.test.tsx @@ -209,9 +209,9 @@ describe('ModelStepBody', () => { it('connect asks the vendor first; a key it did not refuse is saved, the form closes, and the row carries the verdict', async () => { /* A 403 is reported but not refused -- vendors answer it for a region or a - plan as well -- so the key is stored, the row says the vendor rejects it - rather than "Connected", and a retry is offered. No second test on the - side: the check already was the test. */ + plan as well -- so the key is stored, the row keeps "Connected" in the + warning colour with the note beside it, and a retry is offered. No + second test on the side: the check already was the test. */ const data = snap() const { calls } = install(data, { provider: async (op, params) => { @@ -236,10 +236,28 @@ describe('ModelStepBody', () => { const row = (): Element => [...document.querySelectorAll('.settings-prow2')].find((r) => r.querySelector('.settings-pnote'))! expect(row().querySelector('.settings-pnote')!.textContent).toContain('gui.settings.providers.probe_invalid_saved') expect(row().querySelector('.settings-chip')!.className).toContain('settings-warn') - expect(row().querySelector('.settings-chip')!.textContent).toBe('gui.settings.providers.key_rejected') + expect(row().querySelector('.settings-chip')!.textContent).toBe('gui.settings.providers.connected') expect(screen.getByText('gui.settings.providers.probe_recheck')).toBeTruthy() }) + it('a stored key the vendor refuses on a re-check reads Key rejected; a 403 does not', async () => { + /* `refused` is the backend's own verdict (a 401, or the key named invalid). + `invalid_key` alone also covers a 403, which can mean a region, a plan or + a proxy, so that row keeps "Connected". */ + const data = snap() + let answer: { status: string; refused?: boolean } = { status: 'invalid_key', refused: true } + install(data, { fetchModels: async () => ({ ...answer, models: [], error: 'HTTP 401' }) }) + await openBody(ModelStepBody) + const chip = (): Element => [...document.querySelectorAll('.settings-prow2')].find((r) => r.textContent!.includes('OpenRouter'))!.querySelector('.settings-chip')! + await act(async () => { await store.recheck('openrouter') }) + expect(chip().textContent).toBe('gui.settings.providers.key_rejected') + expect(chip().className).toContain('settings-warn') + answer = { status: 'invalid_key' } + await act(async () => { await store.recheck('openrouter') }) + expect(chip().textContent).toBe('gui.settings.providers.connected') + expect(chip().className).toContain('settings-warn') + }) + it('a key the vendor refuses is not saved, and the form says so', async () => { const data = snap() const { calls } = install(data, { diff --git a/ui-web/src/features/settings/providers/ProviderDetail.test.tsx b/ui-web/src/features/settings/providers/ProviderDetail.test.tsx index 6bca26026..de70ecbdf 100644 --- a/ui-web/src/features/settings/providers/ProviderDetail.test.tsx +++ b/ui-web/src/features/settings/providers/ProviderDetail.test.tsx @@ -153,7 +153,7 @@ describe('the eye beside a connected provider\'s key', () => { }) it('the head says the key is rejected once the vendor refused the stored one', async () => { - install(snap(), { fetchModels: async () => ({ models: [], status: 'invalid_key', error: 'HTTP 401' }) }) + install(snap(), { fetchModels: async () => ({ models: [], status: 'invalid_key', error: 'HTTP 401', refused: true }) }) await open('openrouter') expect(document.querySelector('.settings-tp-state')!.textContent).toBe('gui.settings.providers.connected') await act(async () => { await store.recheck('openrouter') }) diff --git a/ui-web/src/features/settings/providers/ProviderDetail.tsx b/ui-web/src/features/settings/providers/ProviderDetail.tsx index 5195344de..2fd82e3e1 100644 --- a/ui-web/src/features/settings/providers/ProviderDetail.tsx +++ b/ui-web/src/features/settings/providers/ProviderDetail.tsx @@ -408,7 +408,7 @@ function Advanced({ p }: { p: ProviderRow }): JSX.Element { it -- on a two-column page there is nothing to go back to. */ function Head({ p }: { p: ProviderRow }): JSX.Element { const state = p.on - ? t(store.get().probes[p.id]?.status === 'invalid_key' ? 'gui.settings.providers.key_rejected' : 'gui.settings.providers.connected') + ? t(store.get().probes[p.id]?.refused ? 'gui.settings.providers.key_rejected' : 'gui.settings.providers.connected') : t(kindOf(p) === 'oauth' ? 'gui.settings.providers.needs_auth' : kindOf(p) === 'local' ? 'gui.settings.providers.needs_base' : 'gui.settings.providers.needs_key') /* One way out of the page, not two. The name's arrow was `keyUrl || homepage`, so on any provider with a key page it opened the same page as diff --git a/ui-web/src/features/settings/providers/Providers.tsx b/ui-web/src/features/settings/providers/Providers.tsx index 2837ea292..8471a6af7 100644 --- a/ui-web/src/features/settings/providers/Providers.tsx +++ b/ui-web/src/features/settings/providers/Providers.tsx @@ -169,13 +169,14 @@ function probeText(p: ConnectProbe): string { /* A row's chip. "Connected" -- unless the vendor itself refused the stored key, where "Connected" beside "the provider rejects the saved key" was two - answers to one question, and the chip now says which. Any other failed - test keeps "Connected" in the warning colour: the key may well be fine and - the vendor unreachable. */ + answers to one question, and the chip now says which. Read off `refused`, + the backend's own verdict, not off `invalid_key`: a 403 maps to that status + too, and a 403 can mean a region, a plan or a proxy -- a key that may well + be fine. Any other failed test keeps "Connected" in the warning colour. */ function rowChip(slug: string): { state: 'on' | 'warn'; text: string } { const probe = store.get().probes[slug] if (store.isBusy(`probe:${slug}`) || !probe) return { state: 'on', text: t('gui.settings.providers.connected') } - if (probe.status === 'invalid_key') return { state: 'warn', text: t('gui.settings.providers.key_rejected') } + if (probe.refused) return { state: 'warn', text: t('gui.settings.providers.key_rejected') } return { state: probeTone(probe) === 'warn' ? 'warn' : 'on', text: t('gui.settings.providers.connected') } } diff --git a/ui-web/src/features/settings/store.ts b/ui-web/src/features/settings/store.ts index a77fd722f..0557594ae 100644 --- a/ui-web/src/features/settings/store.ts +++ b/ui-web/src/features/settings/store.ts @@ -433,7 +433,7 @@ export async function connect(key: string, slug: string, params: Record { await run(`probe:${slug}`, async () => { const r = await source().fetchModels(slug, true) setProbe(slug, r.status === 'ok' - ? { ok: true, status: 'valid', models_count: (r.models || []).filter((m) => m.source === 'live').length } - : { ok: false, status: r.status, error: r.error ?? null }) + ? { ok: true, status: 'valid', models_count: (r.models || []).filter((m) => m.source === 'live').length, refused: false } + : { ok: false, status: r.status, error: r.error ?? null, refused: r.refused ?? false }) if (r.status === 'ok') reloadOffer() }) } diff --git a/ui-web/src/features/settings/types.ts b/ui-web/src/features/settings/types.ts index c956eadd4..6770168b2 100644 --- a/ui-web/src/features/settings/types.ts +++ b/ui-web/src/features/settings/types.ts @@ -184,6 +184,9 @@ export interface ModelCatalogue { models: ModelCandidate[] status: string error?: string | null + /* With a verify: the vendor itself refused the stored key. `invalid_key` + alone also covers a 403, which is not that. */ + refused?: boolean } /* What a provider's credential check answered, after a connect or on a @@ -196,6 +199,10 @@ export interface ConnectProbe { http_status?: number | null models_count?: number | null error?: string | null + /* The vendor's own refusal of the key -- the one verdict a row calls + "rejected". `invalid_key` alone is broader: a 403 maps to it as well, and + a 403 can mean a region, a plan or a proxy, not the key. */ + refused?: boolean } /* The DS.settings contract the rpc source implements. A write that fails diff --git a/ui-web/src/rpc/fixtures/model.ts b/ui-web/src/rpc/fixtures/model.ts index 538d1ba0b..a3e2f07d6 100644 --- a/ui-web/src/rpc/fixtures/model.ts +++ b/ui-web/src/rpc/fixtures/model.ts @@ -200,6 +200,7 @@ export function createModel(_env: FixtureEnv): ModelFixture { added: !!row && row.models.includes(id), })), status: 'ok', + refused: false, } }, 'model.add_models': (p) => { diff --git a/ui-web/src/rpc/generated.ts b/ui-web/src/rpc/generated.ts index 2283eb2f1..8e37ac44e 100644 --- a/ui-web/src/rpc/generated.ts +++ b/ui-web/src/rpc/generated.ts @@ -3142,6 +3142,10 @@ export interface ModelFetchModelsResult { */ status: string; error?: string; + /** + * With `verify`: the vendor itself refused the stored key -- answered it with 401, or named it invalid. `invalid_key` alone is broader (a 403 maps to it too, and a 403 can mean a region, a plan or a proxy), so a surface that says a key was rejected reads this, not the status. + */ + refused?: boolean; } export interface ModelAddModelParams { slug: string; From d9f50ce79e4a6e0f7c88493dd8d4764e8670d991 Mon Sep 17 00:00:00 2001 From: Handsome-wzw <68996445+Handsome-wzw@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:19:39 +0800 Subject: [PATCH 3/3] fix(rpc): a connect's check remembers what the vendor serves, so the pickers list it The connect's test used to be the verified catalogue read, which is the one writer of the served-models cache; `model.check_key` replaced it and wrote nothing, so a valid connect left every picker on the bundled catalogue until an add-model sheet was opened. The check now asks for the whole catalogue and remembers it through the same rows the catalogue read builds, which keeps one probe per connect and one writer. Also from the same acceptance pass: the check's answer declares its nullable fields and every status word it can send, the refused-key line names no button (it reads Connect on the form and Update on a connected provider's page), the two onboard lines about an unchecked key have their zh text, and the changelog says when a row reads Key rejected. Co-authored-by: Claude (claude-fable-5-1) --- CHANGELOG.md | 3 +- i18n/messages.json | 4 +- raven/i18n/zh.py | 2 + raven/rpc/methods/model.py | 114 +++++++++++++++++--------- raven/rpc/models.py | 9 +- rpc-schema/openrpc.json | 20 ++++- tests/test_rpc_model.py | 35 ++++++++ ui-tui/src/i18n/messages.generated.ts | 4 +- ui-tui/src/rpc/generated.ts | 17 +++- ui-web/src/rpc/generated.ts | 17 +++- 10 files changed, 164 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06e6d87ac..394c53140 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -434,7 +434,8 @@ All notable changes to Raven are documented here. (`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 later refuses reads **Key rejected** on its row. + 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 diff --git a/i18n/messages.json b/i18n/messages.json index 680a97340..08fdfd4d5 100644 --- a/i18n/messages.json +++ b/i18n/messages.json @@ -8202,8 +8202,8 @@ "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 connect again", - "zh": "{vendor} 不认这个 Key,没有保存:检查有没有复制全、是不是这家的 Key,改好再点「连接」" + "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", diff --git a/raven/i18n/zh.py b/raven/i18n/zh.py index cd0389329..3bc4a623d 100644 --- a/raven/i18n/zh.py +++ b/raven/i18n/zh.py @@ -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:": "选择要管理的服务商:", diff --git a/raven/rpc/methods/model.py b/raven/rpc/methods/model.py index b174e4865..5ff9cb6ca 100644 --- a/raven/rpc/methods/model.py +++ b/raven/rpc/methods/model.py @@ -753,8 +753,12 @@ async def model_check_key(params: dict) -> dict: The same probe `model_fetch_models` runs with ``verify`` on a saved key, run on the key a form holds: the stored section with these fields laid - over it, so a relay's headers and a stored address still apply. A read -- - nothing is written, whatever the answer. + over it, so a relay's headers and a stored address still apply. The config + is read and not written, whatever the answer; what the vendor said it + serves is remembered (``raven.providers.served_models``), as every ask is, + because this is the one ask a connect makes -- without it a valid connect + left the pickers on the bundled catalogue until an add-model sheet was + opened. ``refused`` is the vendor's own refusal (`credential_refused`), and it is the only answer the page holds a key back on. Everything else -- a @@ -774,8 +778,15 @@ async def model_check_key(params: dict) -> dict: # Off-thread, like the fetch: a round trip to the vendor while the loop # may be carrying a token stream. probe = await asyncio.to_thread( - test_provider, slug, timeout_s=_CHECK_TIMEOUT_S, check_credential=True, section_override=override + test_provider, + slug, + timeout_s=_CHECK_TIMEOUT_S, + full_catalogue=True, + check_credential=True, + section_override=override, ) + if probe.get("ok"): + await _remember_served(slug, _served_rows(slug, probe)) http_status = probe.get("http_status") models_count = probe.get("models_count") error = probe.get("error") @@ -789,50 +800,23 @@ async def model_check_key(params: dict) -> dict: } -async def model_fetch_models(params: dict) -> dict: - """What this provider can serve: the bundled catalogue, plus whatever it says. - - Two sources, unioned, because they answer the same question with different - weaknesses. The registry ships with Raven and needs no credential, so there - is a list to pick from before a key is entered -- and something to show when - the vendor cannot be reached, where an empty list would claim it serves - nothing. The live call is current, and carries the models a registry refresh - has not caught up with. - - The asking is ``config.update_providers.test_provider``, which is already - the one place that knows where each vendor's catalogue lives and what opens - it -- a Codex account's entitlements, a Copilot seat's exchanged token, an - OAuth resource URL, Anthropic's and Google's own header shapes, and the - OpenAI-compatible GET everything else answers. A second implementation here - would be a second set of addresses to keep current. +def _served_rows(slug: str, probe: dict[str, Any]) -> dict[str, dict[str, Any]]: + """What ``slug`` can serve, as the picker's rows, from one probe's answer. - Not reaching the vendor is not an error to this method: no key configured is - the ordinary state of a provider a person is still setting up, and the - catalogue is exactly what they want to see. ``status`` says what happened so - a surface can word its note, and every row says which source it came from. - - A read throughout -- nothing is written until a row is added. + The vendor's live list unioned with the bundled catalogue (except for a + gateway that renames what it resells, which answers to its own spelling + alone), keyed by identity so the two spellings of one model are one row. + Shared by the catalogue read and the connect's check, which ask the vendor + the same question. """ from raven.config.loader import load_config - from raven.config.update_providers import credential_refused, test_provider from raven.providers.catalog import describe from raven.providers.rates import resolve_context_window from raven.providers.registry import find_by_name from raven.providers.registry_data import catalogue_for, kind_of from raven.providers.wire import merge_key - parsed = _parse(ModelFetchModelsParams, params) - slug = canonical_provider_name(parsed.slug) - - # Off-thread: this is a network round trip to somebody else's server, and - # the gateway's loop is carrying a token stream while it happens. Cheap when - # there is no credential to send -- the probe refuses before any socket. - probe = await asyncio.to_thread( - test_provider, slug, timeout_s=_FETCH_TIMEOUT_S, full_catalogue=True, check_credential=parsed.verify - ) - asked = bool(probe.get("ok")) live = [m for m in (probe.get("model_ids") or []) if isinstance(m, str) and m] - try: section = load_config().providers.get(slug) except Exception: @@ -886,10 +870,60 @@ async def model_fetch_models(params: dict) -> dict: entry["context_window"] = window rows[key] = entry - if asked: - from raven.providers.served_models import remember + return rows + + +async def _remember_served(slug: str, rows: dict[str, dict[str, Any]]) -> None: + """Record what the vendor just said it serves (``raven.providers.served_models``). + + Every ask writes it -- the catalogue read, the connect's check -- so the + pickers list the live catalogue right after a connect without asking + again. Only the rows the vendor listed: the bundled ones are not its word. + """ + from raven.providers.served_models import remember - await asyncio.to_thread(remember, slug, {r["id"]: r["kind"] for r in rows.values() if r["source"] == "live"}) + await asyncio.to_thread(remember, slug, {r["id"]: r["kind"] for r in rows.values() if r["source"] == "live"}) + + +async def model_fetch_models(params: dict) -> dict: + """What this provider can serve: the bundled catalogue, plus whatever it says. + + Two sources, unioned, because they answer the same question with different + weaknesses. The registry ships with Raven and needs no credential, so there + is a list to pick from before a key is entered -- and something to show when + the vendor cannot be reached, where an empty list would claim it serves + nothing. The live call is current, and carries the models a registry refresh + has not caught up with. + + The asking is ``config.update_providers.test_provider``, which is already + the one place that knows where each vendor's catalogue lives and what opens + it -- a Codex account's entitlements, a Copilot seat's exchanged token, an + OAuth resource URL, Anthropic's and Google's own header shapes, and the + OpenAI-compatible GET everything else answers. A second implementation here + would be a second set of addresses to keep current. + + Not reaching the vendor is not an error to this method: no key configured is + the ordinary state of a provider a person is still setting up, and the + catalogue is exactly what they want to see. ``status`` says what happened so + a surface can word its note, and every row says which source it came from. + + A read throughout -- nothing is written until a row is added. + """ + from raven.config.update_providers import credential_refused, test_provider + + parsed = _parse(ModelFetchModelsParams, params) + slug = canonical_provider_name(parsed.slug) + + # Off-thread: this is a network round trip to somebody else's server, and + # the gateway's loop is carrying a token stream while it happens. Cheap when + # there is no credential to send -- the probe refuses before any socket. + probe = await asyncio.to_thread( + test_provider, slug, timeout_s=_FETCH_TIMEOUT_S, full_catalogue=True, check_credential=parsed.verify + ) + asked = bool(probe.get("ok")) + rows = _served_rows(slug, probe) + if asked: + await _remember_served(slug, rows) # By name, and stably: the order a vendor lists its catalogue in is not an # order anybody reads, and it changes between calls for some of them. diff --git a/raven/rpc/models.py b/raven/rpc/models.py index 09ce1eebe..f8d4ba353 100644 --- a/raven/rpc/models.py +++ b/raven/rpc/models.py @@ -2218,12 +2218,13 @@ class ModelCheckKeyResult(_Strict): description=( "The probe's word: `valid`, `invalid_key`, `key_unchecked`, `no_credits`, `rate_limited`, " "`network_error`, `proxy_unreachable`, `certificate_untrusted`, `no_probe_endpoint`, " - "`not_configured`, `http_NNN`." + "`not_configured`, `unknown_provider`, `oauth_token_missing`, `http_NNN`; `unknown` when the probe " + "gave no word." ) ) - http_status: int | None = None - models_count: int | None = None - error: str | None = None + http_status: int | None = Field(None, description="The HTTP status the probe met, when it met one.") + models_count: int | None = Field(None, description="How many models the vendor listed, when it listed any.") + error: str | None = Field(None, description="What went wrong, when something did.") refused: bool = Field( description=( "The vendor itself refused this key -- answered it with 401, or named it invalid -- so storing it " diff --git a/rpc-schema/openrpc.json b/rpc-schema/openrpc.json index 0e08ee3e7..bdb25cc45 100644 --- a/rpc-schema/openrpc.json +++ b/rpc-schema/openrpc.json @@ -1324,16 +1324,28 @@ }, "status": { "type": "string", - "description": "The probe's word: `valid`, `invalid_key`, `key_unchecked`, `no_credits`, `rate_limited`, `network_error`, `proxy_unreachable`, `certificate_untrusted`, `no_probe_endpoint`, `not_configured`, `http_NNN`." + "description": "The probe's word: `valid`, `invalid_key`, `key_unchecked`, `no_credits`, `rate_limited`, `network_error`, `proxy_unreachable`, `certificate_untrusted`, `no_probe_endpoint`, `not_configured`, `unknown_provider`, `oauth_token_missing`, `http_NNN`; `unknown` when the probe gave no word." }, "http_status": { - "type": "integer" + "type": [ + "integer", + "null" + ], + "description": "The HTTP status the probe met, when it met one." }, "models_count": { - "type": "integer" + "type": [ + "integer", + "null" + ], + "description": "How many models the vendor listed, when it listed any." }, "error": { - "type": "string" + "type": [ + "string", + "null" + ], + "description": "What went wrong, when something did." }, "refused": { "type": "boolean", diff --git a/tests/test_rpc_model.py b/tests/test_rpc_model.py index 2a32215b4..73200e901 100644 --- a/tests/test_rpc_model.py +++ b/tests/test_rpc_model.py @@ -407,6 +407,10 @@ async def test_check_key_asks_the_vendor_about_the_unsaved_key_and_writes_nothin assert seen["section_override"] == {"api_key": "sk-or-never-issued"} config = fake_home / ".raven" / "config.json" assert not config.exists() or "sk-or-never-issued" not in config.read_text() + # And nothing remembered: a refused key listed nothing. + from raven.providers.served_models import recall + + assert recall("openrouter") == {} @pytest.mark.parametrize( @@ -429,6 +433,37 @@ async def test_check_key_reports_what_is_not_the_keys_fault_without_refusing( assert result["status"] == status assert result["refused"] is False assert result["models_count"] == answer.get("models_count") + # An answer that carries no list remembers none, whatever its status. + from raven.providers.served_models import recall + + assert recall("openrouter") == {} + + +async def test_check_key_remembers_what_a_valid_key_serves(fake_home: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The connect's check is the one ask a connect makes, so it is what writes + the served-models cache (`raven/providers/served_models.py`): without it a + valid connect left every picker on the bundled catalogue until an + add-model sheet was opened, and `model.options` recalled nothing.""" + from raven.providers.served_models import recall + + seen: dict = {} + monkeypatch.setattr( + "raven.config.update_providers.test_provider", + _probe_answering( + {"ok": True, "status": "valid", "http_status": 200, "models_count": 1, "model_ids": ["zz-live-only-model"]}, + seen, + ), + ) + + result = await model_check_key({"slug": "openrouter", "api_key": "sk-or-good"}) + + assert result["status"] == "valid" and result["refused"] is False + # The whole catalogue, as the verified read asks for it, not a count. + assert seen["full_catalogue"] is True + assert [m for m in recall("openrouter") if "zz-live-only-model" in m] + # Still a read of the config: the key is the form's until save_key. + config = fake_home / ".raven" / "config.json" + assert not config.exists() or "sk-or-good" not in config.read_text() async def test_check_key_sends_only_the_fields_the_form_holds(fake_home: Path, monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/ui-tui/src/i18n/messages.generated.ts b/ui-tui/src/i18n/messages.generated.ts index b1aa20cf2..bce1d1906 100644 --- a/ui-tui/src/i18n/messages.generated.ts +++ b/ui-tui/src/i18n/messages.generated.ts @@ -2452,7 +2452,7 @@ export const UI_TEXT: Record> = { 'gui.settings.providers.key_not_ascii': "This doesn't look like an API key: it has characters a key never contains. Check what was pasted", 'gui.settings.providers.key_refused': - "{vendor} rejected this key, so it was not saved: check that it is complete and is this vendor's key, then connect again", + "{vendor} rejected this key, so it was not saved: check that it is complete and is this vendor's key, then submit it again", 'gui.settings.providers.key_rejected': 'Key rejected', 'gui.settings.providers.base': 'Service address', 'gui.settings.providers.api_base': 'API address', @@ -5811,7 +5811,7 @@ export const UI_TEXT: Record> = { '没有新的 API key 要保存。要更换,先在「API 密钥」框里填入新的,再点「{button}」。', 'gui.settings.providers.key_not_ascii': '这不像是 API key:里面有 Key 不会包含的字符,请检查粘贴的内容', 'gui.settings.providers.key_refused': - '{vendor} 不认这个 Key,没有保存:检查有没有复制全、是不是这家的 Key,改好再点「连接」', + '{vendor} 不认这个 Key,没有保存:检查有没有复制全、是不是这家的 Key,改好再提交一次', 'gui.settings.providers.key_rejected': 'Key 被拒', 'gui.settings.providers.base': '服务地址', 'gui.settings.providers.api_base': 'API 地址', diff --git a/ui-tui/src/rpc/generated.ts b/ui-tui/src/rpc/generated.ts index f97d5cc9f..c23458dc3 100644 --- a/ui-tui/src/rpc/generated.ts +++ b/ui-tui/src/rpc/generated.ts @@ -3951,12 +3951,21 @@ export interface ModelCheckKeyParams { export interface ModelCheckKeyResult { ok: boolean; /** - * The probe's word: `valid`, `invalid_key`, `key_unchecked`, `no_credits`, `rate_limited`, `network_error`, `proxy_unreachable`, `certificate_untrusted`, `no_probe_endpoint`, `not_configured`, `http_NNN`. + * The probe's word: `valid`, `invalid_key`, `key_unchecked`, `no_credits`, `rate_limited`, `network_error`, `proxy_unreachable`, `certificate_untrusted`, `no_probe_endpoint`, `not_configured`, `unknown_provider`, `oauth_token_missing`, `http_NNN`; `unknown` when the probe gave no word. */ status: string; - http_status?: number; - models_count?: number; - error?: string; + /** + * The HTTP status the probe met, when it met one. + */ + http_status?: number | null; + /** + * How many models the vendor listed, when it listed any. + */ + models_count?: number | null; + /** + * What went wrong, when something did. + */ + error?: string | null; /** * The vendor itself refused this key -- answered it with 401, or named it invalid -- so storing it would store a key that cannot work. Every other answer leaves the key worth storing. */ diff --git a/ui-web/src/rpc/generated.ts b/ui-web/src/rpc/generated.ts index f797f6ae4..f4924ecec 100644 --- a/ui-web/src/rpc/generated.ts +++ b/ui-web/src/rpc/generated.ts @@ -3161,12 +3161,21 @@ export interface ModelCheckKeyParams { export interface ModelCheckKeyResult { ok: boolean; /** - * The probe's word: `valid`, `invalid_key`, `key_unchecked`, `no_credits`, `rate_limited`, `network_error`, `proxy_unreachable`, `certificate_untrusted`, `no_probe_endpoint`, `not_configured`, `http_NNN`. + * The probe's word: `valid`, `invalid_key`, `key_unchecked`, `no_credits`, `rate_limited`, `network_error`, `proxy_unreachable`, `certificate_untrusted`, `no_probe_endpoint`, `not_configured`, `unknown_provider`, `oauth_token_missing`, `http_NNN`; `unknown` when the probe gave no word. */ status: string; - http_status?: number; - models_count?: number; - error?: string; + /** + * The HTTP status the probe met, when it met one. + */ + http_status?: number | null; + /** + * How many models the vendor listed, when it listed any. + */ + models_count?: number | null; + /** + * What went wrong, when something did. + */ + error?: string | null; /** * The vendor itself refused this key -- answered it with 401, or named it invalid -- so storing it would store a key that cannot work. Every other answer leaves the key worth storing. */