From 42acd2398f6ff3ff27b0b8049ae4dc34280e0a93 Mon Sep 17 00:00:00 2001 From: lifelmy Date: Tue, 1 Sep 2026 02:54:05 +0800 Subject: [PATCH 1/4] feat(onboarding): verify connectivity without a model id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test connection required a model id: probe_llm_provider raises when the model is empty and the Web UI disabled the button until a model was entered. That is a chicken-and-egg bind — a user cannot list a provider's models until the connection is verified, but cannot verify it without already knowing a model. onboarding.provider.probe now falls back to the model-list endpoint (discover_provider_models) when the model field is empty, mapping the listing outcome onto the frozen probe envelope (chat-only timings stay at their never-reached sentinels). The Web UI no longer treats an empty model as a missing required field for draft primary-provider probes; stored/draft profile probes still resolve a concrete model. Fixes #792 --- .../setup/useSetupCatalog.privacy.test.ts | 8 +- .../src/composables/setup/useSetupCatalog.ts | 17 ++-- src/opensquilla/gateway/rpc_onboarding.py | 87 ++++++++++++++++--- tests/test_gateway/test_rpc_onboarding.py | 56 ++++++++++++ 4 files changed, 144 insertions(+), 24 deletions(-) diff --git a/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts b/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts index f75b410f79..6749163935 100644 --- a/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts +++ b/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts @@ -5523,16 +5523,19 @@ describe('useSetupCatalog optional provider credentials', () => { source: 'not_required', probeReady: false, }) + // The model id no longer gates the probe (#792): only the still-empty + // non-model required field (Base URL) blocks it. expect(credential?.probeDisabledReason).toBe( - 'Complete required fields before verifying: Model, Base URL.', + 'Complete required fields before verifying: Base URL.', ) api.probeProviderConnection() expect(rpcCall.mock.calls.some(call => call[0] === 'onboarding.provider.probe')).toBe(false) - api.updateProviderField('model', 'test-model') api.updateProviderField('base_url', 'https://custom.example.test/v1') credential = api.providerPanel.value.credentialPanel + // An empty model is now allowed: reachability is verified via the + // model-list endpoint. expect(credential?.probeReady).toBe(true) expect(credential?.probeDisabledReason).toBe('') @@ -5541,7 +5544,6 @@ describe('useSetupCatalog optional provider credentials', () => { expect(rpcCall).toHaveBeenCalledWith('onboarding.provider.probe', { providerId, baseUrl: 'https://custom.example.test/v1', - model: 'test-model', }) app.unmount() }, diff --git a/opensquilla-webui/src/composables/setup/useSetupCatalog.ts b/opensquilla-webui/src/composables/setup/useSetupCatalog.ts index 506d6dbb01..efc4d2dc3c 100644 --- a/opensquilla-webui/src/composables/setup/useSetupCatalog.ts +++ b/opensquilla-webui/src/composables/setup/useSetupCatalog.ts @@ -1533,19 +1533,20 @@ const providerProbeModel = computed(() => { const providerProbeMissingFields = computed(() => { if (!providerForm.selectedProvider.value) return [] + // Stored/draft profiles probe through onboarding.llmProfile[.draft].probe, + // which still resolves a concrete deployment model, so keep requiring one. if (providerSelectionKind.value === 'profile') { return providerProbeModel.value ? [] : [t('setup.common.model')] } + // For a draft primary-provider config the model id no longer gates the + // probe: an empty model makes onboarding.provider.probe verify reachability + // via the model-list endpoint instead of a chat turn (#792). return providerFields.value .filter(field => field.required === true && !isProviderCredentialField(field)) - .filter(field => { - const value = field.name === 'model' - && editingPrimaryProvider.value - && hasConfiguredPrimaryProvider.value - ? currentFormModelValue() - : providerForm.fieldValue(field, currentProviderConfig.value) - return !String(value ?? '').trim() - }) + .filter(field => field.name !== 'model') + .filter(field => !String( + providerForm.fieldValue(field, currentProviderConfig.value) ?? '', + ).trim()) .map(providerProbeFieldLabel) }) diff --git a/src/opensquilla/gateway/rpc_onboarding.py b/src/opensquilla/gateway/rpc_onboarding.py index e7ebc7242d..e200119f5b 100644 --- a/src/opensquilla/gateway/rpc_onboarding.py +++ b/src/opensquilla/gateway/rpc_onboarding.py @@ -1184,9 +1184,56 @@ async def _llm_profile_draft_models_discover(params: Any, ctx: RpcContext) -> di return result.to_payload() +async def _connectivity_probe_via_discovery( + *, + provider_id: str, + api_key: str, + api_key_env: str, + base_url: str, + proxy: str, + allow_default_api_key_env: bool, +) -> ProviderProbeResult: + """Verify a provider config without a model id via the model-list endpoint. + + ``discover_provider_models`` builds the same throwaway provider as the chat + probe but needs no bound model, so it answers "is this key/URL/proxy + reachable?" before the user knows any model id. The discovery outcome is + adapted back onto the frozen ``onboarding.provider.probe`` envelope so + clients keep a single result shape: ``ok``/``failureKind``/``message`` map + across, while the chat-only timing fields stay 0/None (no chat round-trip + happened). An ``ok`` discovery that simply lists nothing still counts as + reachable — the credentials verified even if the endpoint has no catalog. + """ + from opensquilla.onboarding.probe import ( + ProviderProbeResult, + discover_provider_models, + ) + + listing = await discover_provider_models( + provider_id=provider_id, + api_key=api_key, + api_key_env=api_key_env, + base_url=base_url, + proxy=proxy, + allow_default_api_key_env=allow_default_api_key_env, + ) + return ProviderProbeResult( + ok=listing.ok, + provider_id=provider_id, + model="", + failure_kind=listing.failure_kind, + message=listing.detail, + ) + + @_d.method("onboarding.provider.probe", scope="operator.admin") async def _provider_probe(params: Any, ctx: RpcContext) -> dict[str, Any]: - """Live one-token probe of a candidate provider config (nothing is saved).""" + """Live probe of a candidate provider config (nothing is saved). + + With a model id this runs a one-token chat turn; with an empty model it + falls back to the model-list endpoint so reachability can be verified + before any model is chosen (#792). + """ provider_id = _require(params, "providerId") p = params if isinstance(params, dict) else {} cfg = _active_config(ctx) @@ -1219,19 +1266,33 @@ async def _provider_probe(params: Any, ctx: RpcContext) -> dict[str, Any]: if not proxy: proxy = str(getattr(cfg.llm, "proxy", "") or "") model = str(p.get("model", "") or "") + allow_default_api_key_env = not same_provider or reuse_stored_credentials with _validation_error("onboarding.provider.invalid"): - result = await _usage_accounted_provider_probe( - ctx, - provider_id=str(provider_id), - model=model, - api_key=api_key, - api_key_env=api_key_env, - base_url=base_url, - proxy=proxy, - allow_default_api_key_env=( - not same_provider or reuse_stored_credentials - ), - ) + if model.strip(): + result = await _usage_accounted_provider_probe( + ctx, + provider_id=str(provider_id), + model=model, + api_key=api_key, + api_key_env=api_key_env, + base_url=base_url, + proxy=proxy, + allow_default_api_key_env=allow_default_api_key_env, + ) + else: + # No model id yet: verify reachability with the model-list + # endpoint instead of a chat turn. This breaks the chicken-and-egg + # bind where a user must guess a valid model before they can + # confirm the API key / base URL / proxy are even reachable, and + # before discovery can fetch the model list. (#792) + result = await _connectivity_probe_via_discovery( + provider_id=str(provider_id), + api_key=api_key, + api_key_env=api_key_env, + base_url=base_url, + proxy=proxy, + allow_default_api_key_env=allow_default_api_key_env, + ) saved_model = str(getattr(cfg.llm, "model", "") or "").strip() if ( same_provider diff --git a/tests/test_gateway/test_rpc_onboarding.py b/tests/test_gateway/test_rpc_onboarding.py index b6602dc329..fd54d61766 100644 --- a/tests/test_gateway/test_rpc_onboarding.py +++ b/tests/test_gateway/test_rpc_onboarding.py @@ -1949,6 +1949,62 @@ def _unexpected_build(*_args, **_kwargs): } +@pytest.mark.asyncio +async def test_provider_probe_without_model_verifies_via_model_list( + tmp_path, monkeypatch +): + """An empty model probes reachability through the model-list endpoint + instead of raising ``Model is required`` (#792).""" + monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(tmp_path / "c.toml")) + _stub_openai_transport( + monkeypatch, + httpx.Response( + 200, + headers={"content-type": "application/json"}, + content=b'{"data": [{"id": "gpt-x", "context_length": 32000}]}', + ), + ) + res = await get_dispatcher().dispatch( + "r1", + "onboarding.provider.probe", + {"providerId": "openrouter", "apiKey": "sk-test"}, + _admin_ctx(), + ) + assert res.error is None, res.error + assert res.payload["ok"] is True + assert res.payload["model"] == "" + assert res.payload["failureKind"] == "" + # No chat round-trip happened; the chat-only timings stay at their + # never-reached-the-network sentinels. + assert res.payload["latencyMs"] == 0 + assert res.payload["firstResponseMs"] is None + + +@pytest.mark.asyncio +async def test_provider_probe_without_model_reports_auth_failure( + tmp_path, monkeypatch +): + """A model-less probe surfaces a bad key through the same envelope.""" + monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(tmp_path / "c.toml")) + _stub_openai_transport( + monkeypatch, + httpx.Response( + 401, + headers={"content-type": "application/json"}, + content=b'{"error": {"message": "Incorrect API key provided"}}', + ), + ) + res = await get_dispatcher().dispatch( + "r1", + "onboarding.provider.probe", + {"providerId": "openrouter", "apiKey": "sk-bad"}, + _admin_ctx(), + ) + assert res.error is None, res.error + assert res.payload["ok"] is False + assert res.payload["failureKind"] == "auth_invalid" + + @pytest.mark.asyncio async def test_image_models_discover_requires_admin_scope(tmp_path, monkeypatch): monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(tmp_path / "c.toml")) From 64f14dcdfa6d0b4cdc6809e319bcc206cdf6f634 Mon Sep 17 00:00:00 2001 From: "liumingyao.marvin" Date: Thu, 3 Sep 2026 14:45:02 +0800 Subject: [PATCH 2/4] style(onboarding): apply current formatting --- src/opensquilla/gateway/rpc_onboarding.py | 95 +++++++---------------- tests/test_gateway/test_rpc_onboarding.py | 67 ++++------------ 2 files changed, 45 insertions(+), 117 deletions(-) diff --git a/src/opensquilla/gateway/rpc_onboarding.py b/src/opensquilla/gateway/rpc_onboarding.py index f7b03a0392..4ad4572904 100644 --- a/src/opensquilla/gateway/rpc_onboarding.py +++ b/src/opensquilla/gateway/rpc_onboarding.py @@ -75,6 +75,7 @@ def _channel_error() -> Iterator[None]: details={"fields": details} if details else None, ) from exc + log = structlog.get_logger(__name__) _d = get_dispatcher() @@ -287,9 +288,7 @@ def _status_payload(ctx: RpcContext) -> dict[str, Any]: "memoryEmbeddingSource": s.memory_embedding_source, "memoryEmbeddingEnvKey": s.memory_embedding_env_key, "capabilityConfiguration": { - capability_id: { - "resettable": capability_resettable(cfg, capability_id=capability_id) - } + capability_id: {"resettable": capability_resettable(cfg, capability_id=capability_id)} for capability_id in ( "search", "image_generation", @@ -497,8 +496,8 @@ def _setup_application_ports(ctx: RpcContext) -> tuple[Any, Any]: refresh_catalog=_refresh_setup_catalog, broadcast_routing=_broadcast_setup_routing, discard_profile=discard_profile_credential_pool, - reconcile_profile=lambda previous, current, provider: ( - _reconcile_saved_llm_profile(previous, current, provider) + reconcile_profile=lambda previous, current, provider: _reconcile_saved_llm_profile( + previous, current, provider ), ) return config, runtime @@ -585,13 +584,9 @@ async def _models_discover(params: Any, ctx: RpcContext) -> dict[str, Any]: return await _provider_setup(ctx).discover_primary_models(command) -async def _image_generation_models_discover( - params: Any, ctx: RpcContext -) -> dict[str, Any]: +async def _image_generation_models_discover(params: Any, ctx: RpcContext) -> dict[str, Any]: - return await _provider_setup(ctx).discover_image_models( - str(_require(params, "providerId")) - ) + return await _provider_setup(ctx).discover_image_models(str(_require(params, "providerId"))) def _profile_probe_command(params: Any) -> Any: @@ -624,18 +619,12 @@ async def _llm_profile_draft_probe(params: Any, ctx: RpcContext) -> dict[str, An return await _profile_lifecycle(ctx).probe_draft(_profile_probe_command(params)) -async def _llm_profile_models_discover( - params: Any, ctx: RpcContext -) -> dict[str, Any]: +async def _llm_profile_models_discover(params: Any, ctx: RpcContext) -> dict[str, Any]: return await _profile_lifecycle(ctx).discover_models(_profile_probe_command(params)) -async def _llm_profile_draft_models_discover( - params: Any, ctx: RpcContext -) -> dict[str, Any]: - return await _profile_lifecycle(ctx).discover_draft_models( - _profile_probe_command(params) - ) +async def _llm_profile_draft_models_discover(params: Any, ctx: RpcContext) -> dict[str, Any]: + return await _profile_lifecycle(ctx).discover_draft_models(_profile_probe_command(params)) async def _onboarding_status(_params: Any, ctx: RpcContext) -> dict[str, Any]: @@ -710,9 +699,9 @@ def _request_changes_active_provider_connection(params: Any, cfg: Any) -> bool: canonical_tokenrhythm_base_url, ) - requested_provider = str( - params.get("providerId") or getattr(llm, "provider", "") or "" - ).strip().lower() + requested_provider = ( + str(params.get("providerId") or getattr(llm, "provider", "") or "").strip().lower() + ) comparisons = ( ("apiKey", "api_key"), @@ -800,9 +789,7 @@ async def _provider_configure(params: Any, ctx: RpcContext) -> dict[str, Any]: proxy=str(_param(params, "proxy", "")), preset_id=str(_param(params, "presetId", "")), router_action=str(_param(params, "routerAction", "preserve")), - image_generation_intent=str( - _param(params, "imageGenerationIntent", "preserve") - ), + image_generation_intent=str(_param(params, "imageGenerationIntent", "preserve")), ) result = await _provider_setup(ctx).configure_primary(command) return result.to_payload() @@ -888,9 +875,7 @@ async def _llm_profile_active_remove(params: Any, ctx: RpcContext) -> dict[str, replacement_provider_id = str(_require(params, "replacementProviderId")) replacement_model = str(_param(params, "replacementModel", "") or "") router_action = str(_param(params, "routerAction", "preserve")) - image_generation_intent = str( - _param(params, "imageGenerationIntent", "preserve") - ) + image_generation_intent = str(_param(params, "imageGenerationIntent", "preserve")) try: result = await _profile_lifecycle(ctx).remove_active( RemoveActiveProfile( @@ -903,12 +888,8 @@ async def _llm_profile_active_remove(params: Any, ctx: RpcContext) -> dict[str, ) except LlmProfileActivationError as exc: code_by_reason = { - "primary_pool_unsupported": ( - "onboarding.llmProfile.primary_pool_unsupported" - ), - "router_provider_conflict": ( - "onboarding.llmProfile.router_provider_conflict" - ), + "primary_pool_unsupported": ("onboarding.llmProfile.primary_pool_unsupported"), + "router_provider_conflict": ("onboarding.llmProfile.router_provider_conflict"), } raise RpcHandlerError( code_by_reason.get(exc.reason, "onboarding.llmProfile.invalid"), @@ -967,12 +948,8 @@ async def _llm_profile_activate(params: Any, ctx: RpcContext) -> dict[str, Any]: ) except LlmProfileActivationError as exc: code_by_reason = { - "primary_pool_unsupported": ( - "onboarding.llmProfile.primary_pool_unsupported" - ), - "router_provider_conflict": ( - "onboarding.llmProfile.router_provider_conflict" - ), + "primary_pool_unsupported": ("onboarding.llmProfile.primary_pool_unsupported"), + "router_provider_conflict": ("onboarding.llmProfile.router_provider_conflict"), } code = code_by_reason.get(exc.reason, "onboarding.llmProfile.invalid") details = { @@ -1204,9 +1181,7 @@ async def _llm_profile_draft_probe_impl(params: Any, ctx: RpcContext) -> dict[st return result.to_payload() -async def _llm_profile_models_discover_impl( - params: Any, ctx: RpcContext -) -> dict[str, Any]: +async def _llm_profile_models_discover_impl(params: Any, ctx: RpcContext) -> dict[str, Any]: """Discover picker-safe models through one stored profile deployment.""" from opensquilla.onboarding.probe import discover_selectable_provider_models @@ -1242,9 +1217,7 @@ async def _llm_profile_models_discover_impl( return result.to_payload() -async def _llm_profile_draft_models_discover_impl( - params: Any, ctx: RpcContext -) -> dict[str, Any]: +async def _llm_profile_draft_models_discover_impl(params: Any, ctx: RpcContext) -> dict[str, Any]: """Discover models through the editor's unsaved profile deployment.""" from opensquilla.onboarding.probe import discover_selectable_provider_models @@ -1415,8 +1388,8 @@ async def _provider_credential_reveal(params: Any, ctx: RpcContext) -> dict[str, credentials = RpcContextCredentialResolutionPort( ctx, reveal=_active_llm_credential_reveal_payload, - describe=lambda current, provider, active: ( - _credential_clear_effective_payload(current, provider, active=active) + describe=lambda current, provider, active: _credential_clear_effective_payload( + current, provider, active=active ), ) return ProviderCredentials( @@ -1439,8 +1412,8 @@ async def _provider_credential_clear(params: Any, ctx: RpcContext) -> dict[str, credentials = RpcContextCredentialResolutionPort( ctx, reveal=_active_llm_credential_reveal_payload, - describe=lambda current, provider, active: ( - _credential_clear_effective_payload(current, provider, active=active) + describe=lambda current, provider, active: _credential_clear_effective_payload( + current, provider, active=active ), ) with _validation_error("onboarding.provider.invalid"): @@ -1511,13 +1484,9 @@ async def _models_discover_impl(params: Any, ctx: RpcContext) -> dict[str, Any]: api_key_env=api_key_env, base_url=base_url, proxy=proxy, - allow_default_api_key_env=( - not same_provider or reuse_stored_credentials - ), + allow_default_api_key_env=(not same_provider or reuse_stored_credentials), force_refresh=force_refresh, - persist_catalog=( - same_provider and reuse_stored_credentials and not request_overrides - ), + persist_catalog=(same_provider and reuse_stored_credentials and not request_overrides), catalog_config=cfg, ) return result.to_payload() @@ -1625,9 +1594,7 @@ async def _channel_probe(params: Any, ctx: RpcContext) -> dict[str, Any]: "probeKind": "local_validation", "restartRequired": True, "entry": redact_channel_entry(type_name, normalized), - "warnings": [ - "Configuration is locally valid; no provider connection was attempted." - ], + "warnings": ["Configuration is locally valid; no provider connection was attempted."], } @@ -1794,9 +1761,7 @@ async def _audio_configure(params: Any, ctx: RpcContext) -> dict[str, Any]: async def _capability_reset(params: Any, ctx: RpcContext) -> dict[str, Any]: with _validation_error("onboarding.capability.invalid"): - result = await _capability_setup(ctx).reset( - str(_require(params, "capabilityId")) - ) + result = await _capability_setup(ctx).reset(str(_require(params, "capabilityId"))) return result.to_payload() @@ -1937,9 +1902,7 @@ async def _channel_disable(params: Any, ctx: RpcContext) -> dict[str, Any]: "onboarding.llmProfile.probe": _llm_profile_probe, "onboarding.llmProfile.draft.probe": _llm_profile_draft_probe, "onboarding.llmProfile.models.discover": _llm_profile_models_discover, - "onboarding.llmProfile.draft.models.discover": ( - _llm_profile_draft_models_discover - ), + "onboarding.llmProfile.draft.models.discover": (_llm_profile_draft_models_discover), "onboarding.router.configure": _router_configure, "onboarding.ensemble.configure": _ensemble_configure, "onboarding.search.configure": _search_configure, diff --git a/tests/test_gateway/test_rpc_onboarding.py b/tests/test_gateway/test_rpc_onboarding.py index fd54d61766..ae92a74d00 100644 --- a/tests/test_gateway/test_rpc_onboarding.py +++ b/tests/test_gateway/test_rpc_onboarding.py @@ -345,9 +345,7 @@ def sync_primary(self, provider_config): sync_calls.append(provider_config) ctx = _admin_ctx() - ctx.config = GatewayConfig( - llm={"provider": "dashscope", "model": "qwen3.7-plus"} - ) + ctx.config = GatewayConfig(llm={"provider": "dashscope", "model": "qwen3.7-plus"}) ctx.config.config_path = str(tmp_path / "c.toml") ctx.provider_selector = FakeSelector() @@ -375,10 +373,7 @@ def sync_primary(self, provider_config): assert res.error is None, res.error assert ctx.config.squilla_router.default_tier == "c0" assert ctx.config.squilla_router.tiers["c0"]["provider"] == "volcengine" - assert ( - ctx.config.squilla_router.tiers["c0"]["model"] - == "doubao-seed-1-6-251015" - ) + assert ctx.config.squilla_router.tiers["c0"]["model"] == "doubao-seed-1-6-251015" assert ctx.config.llm.provider == "dashscope" assert ctx.config.llm.model == "qwen3.7-plus" assert len(sync_calls) == 1 @@ -389,10 +384,7 @@ def sync_primary(self, provider_config): assert persisted["llm"]["provider"] == "dashscope" assert persisted["llm"]["model"] == "qwen3.7-plus" assert persisted["squilla_router"]["default_tier"] == "c0" - assert ( - persisted["squilla_router"]["tiers"]["c0"]["model"] - == "doubao-seed-1-6-251015" - ) + assert persisted["squilla_router"]["tiers"]["c0"]["model"] == "doubao-seed-1-6-251015" @pytest.mark.asyncio @@ -501,9 +493,7 @@ async def test_router_catalog_rpc(tmp_path, monkeypatch): @pytest.mark.asyncio -async def test_ensemble_configure_partial_payload_updates_and_persists( - tmp_path, monkeypatch -): +async def test_ensemble_configure_partial_payload_updates_and_persists(tmp_path, monkeypatch): monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(tmp_path / "c.toml")) from opensquilla.gateway.config import GatewayConfig @@ -540,9 +530,7 @@ async def test_ensemble_configure_partial_payload_updates_and_persists( @pytest.mark.asyncio -async def test_ensemble_configure_accepts_full_camel_case_payload( - tmp_path, monkeypatch -): +async def test_ensemble_configure_accepts_full_camel_case_payload(tmp_path, monkeypatch): monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(tmp_path / "c.toml")) res = await get_dispatcher().dispatch( "r1", @@ -575,9 +563,7 @@ async def test_ensemble_configure_accepts_full_camel_case_payload( @pytest.mark.asyncio -async def test_ensemble_configure_rejects_out_of_range_proposer_retries( - tmp_path, monkeypatch -): +async def test_ensemble_configure_rejects_out_of_range_proposer_retries(tmp_path, monkeypatch): monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(tmp_path / "c.toml")) res = await get_dispatcher().dispatch( "r1", @@ -751,10 +737,7 @@ async def test_image_generation_configure_redacts_api_key(tmp_path, monkeypatch) data = tomllib.loads(target.read_text()) assert data["image_generation"]["enabled"] is True - assert ( - data["image_generation"]["primary"] - == "openrouter/google/gemini-3.1-flash-image-preview" - ) + assert data["image_generation"]["primary"] == "openrouter/google/gemini-3.1-flash-image-preview" assert data["image_generation"]["providers"]["openrouter"]["api_key"] == "sk-or" @@ -1132,10 +1115,7 @@ async def test_image_generation_configure_can_disable_legacy_invalid_config( assert data["image_generation"]["enabled"] is False assert data["image_generation"]["primary"] == "openrouter/google//image" assert data["image_generation"]["fallbacks"] == ["openai/"] - assert ( - data["image_generation"]["providers"]["openrouter"]["base_url"] - == "not-a-url" - ) + assert data["image_generation"]["providers"]["openrouter"]["base_url"] == "not-a-url" @pytest.mark.asyncio @@ -1171,9 +1151,7 @@ async def test_onboarding_status_marks_legacy_image_endpoint_mismatch_degraded( ctx = _read_ctx() ctx.config = GatewayConfig() ctx.config.image_generation.enabled = True - ctx.config.image_generation.primary = ( - "openrouter/google/gemini-3.1-flash-image-preview" - ) + ctx.config.image_generation.primary = "openrouter/google/gemini-3.1-flash-image-preview" openrouter_provider = ctx.config.image_generation.providers.openrouter openrouter_provider.api_key = "sk-synthetic-image" openrouter_provider.base_url = "https://api.openai.com/v1" @@ -1416,9 +1394,7 @@ async def test_memory_embedding_configure_updates_ctx_config(tmp_path, monkeypat @pytest.mark.asyncio -async def test_memory_embedding_configure_auto_can_store_remote_fallback( - tmp_path, monkeypatch -): +async def test_memory_embedding_configure_auto_can_store_remote_fallback(tmp_path, monkeypatch): monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(tmp_path / "c.toml")) from opensquilla.gateway.config import GatewayConfig @@ -1534,9 +1510,7 @@ async def test_provider_configure_does_not_persist_runtime_api_key(tmp_path, mon @pytest.mark.asyncio -async def test_provider_configure_persists_explicit_replacement_for_env_key( - tmp_path, monkeypatch -): +async def test_provider_configure_persists_explicit_replacement_for_env_key(tmp_path, monkeypatch): monkeypatch.setenv("OPENROUTER_API_KEY", "startup-key") from opensquilla.gateway.config import GatewayConfig @@ -1921,9 +1895,7 @@ async def fake_discover(**kwargs): @pytest.mark.asyncio -async def test_models_discover_unverified_provider_stays_empty_without_build( - tmp_path, monkeypatch -): +async def test_models_discover_unverified_provider_stays_empty_without_build(tmp_path, monkeypatch): monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(tmp_path / "c.toml")) def _unexpected_build(*_args, **_kwargs): @@ -1950,9 +1922,7 @@ def _unexpected_build(*_args, **_kwargs): @pytest.mark.asyncio -async def test_provider_probe_without_model_verifies_via_model_list( - tmp_path, monkeypatch -): +async def test_provider_probe_without_model_verifies_via_model_list(tmp_path, monkeypatch): """An empty model probes reachability through the model-list endpoint instead of raising ``Model is required`` (#792).""" monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(tmp_path / "c.toml")) @@ -1981,9 +1951,7 @@ async def test_provider_probe_without_model_verifies_via_model_list( @pytest.mark.asyncio -async def test_provider_probe_without_model_reports_auth_failure( - tmp_path, monkeypatch -): +async def test_provider_probe_without_model_reports_auth_failure(tmp_path, monkeypatch): """A model-less probe surfaces a bad key through the same envelope.""" monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(tmp_path / "c.toml")) _stub_openai_transport( @@ -2021,9 +1989,7 @@ async def test_image_models_discover_requires_admin_scope(tmp_path, monkeypatch) @pytest.mark.asyncio -async def test_image_models_discover_returns_image_specific_catalog( - tmp_path, monkeypatch -): +async def test_image_models_discover_returns_image_specific_catalog(tmp_path, monkeypatch): monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(tmp_path / "c.toml")) async def _discover(provider_id: str): @@ -2036,8 +2002,7 @@ async def _discover(provider_id: str): } monkeypatch.setattr( - "opensquilla.onboarding.image_generation_model_discovery." - "discover_image_generation_models", + "opensquilla.onboarding.image_generation_model_discovery.discover_image_generation_models", _discover, ) From 5d3eec1d2ef720c6f63a55bf2cf6f115f4694bd8 Mon Sep 17 00:00:00 2001 From: "liumingyao.marvin" Date: Fri, 11 Sep 2026 12:13:54 +0800 Subject: [PATCH 3/4] style(gateway): format onboarding RPC --- src/opensquilla/gateway/rpc_onboarding.py | 95 +++++++---------------- 1 file changed, 26 insertions(+), 69 deletions(-) diff --git a/src/opensquilla/gateway/rpc_onboarding.py b/src/opensquilla/gateway/rpc_onboarding.py index 032d8a2b4a..b847735c56 100644 --- a/src/opensquilla/gateway/rpc_onboarding.py +++ b/src/opensquilla/gateway/rpc_onboarding.py @@ -92,6 +92,7 @@ def _channel_error() -> Iterator[None]: details={"fields": details} if details else None, ) from exc + log = structlog.get_logger(__name__) _d = get_dispatcher() @@ -143,9 +144,7 @@ def __init__(self, config: Any, usage_event_sink: Any) -> None: self._config = config self._usage_event_sink = usage_event_sink - async def probe_primary( - self, command: ProbePrimaryProvider - ) -> ProviderProbePayload: + async def probe_primary(self, command: ProbePrimaryProvider) -> ProviderProbePayload: return cast( "ProviderProbePayload", await _probe_primary_provider( @@ -163,9 +162,7 @@ async def discover_primary_models( await _discover_primary_models(command, config=self._config), ) - async def discover_image_models( - self, provider_id: str - ) -> ImageModelDiscoveryResult: + async def discover_image_models(self, provider_id: str) -> ImageModelDiscoveryResult: return cast( "ImageModelDiscoveryResult", await _discover_image_models(provider_id), @@ -227,9 +224,7 @@ def __init__( self._connection_id = connection_id self._usage_event_sink = usage_event_sink - async def probe_saved( - self, command: ProfileProbeCommand - ) -> ProviderProbePayload: + async def probe_saved(self, command: ProfileProbeCommand) -> ProviderProbePayload: return cast( "ProviderProbePayload", await _probe_saved_profile( @@ -240,9 +235,7 @@ async def probe_saved( ), ) - async def probe_draft( - self, command: ProfileProbeCommand - ) -> ProviderProbePayload: + async def probe_draft(self, command: ProfileProbeCommand) -> ProviderProbePayload: return cast( "ProviderProbePayload", await _probe_draft_profile( @@ -253,9 +246,7 @@ async def probe_draft( ), ) - async def discover_saved( - self, command: ProfileProbeCommand - ) -> ProviderModelDiscoveryResult: + async def discover_saved(self, command: ProfileProbeCommand) -> ProviderModelDiscoveryResult: return cast( "ProviderModelDiscoveryResult", await _discover_saved_profile_models( @@ -265,9 +256,7 @@ async def discover_saved( ), ) - async def discover_draft( - self, command: ProfileProbeCommand - ) -> ProviderModelDiscoveryResult: + async def discover_draft(self, command: ProfileProbeCommand) -> ProviderModelDiscoveryResult: return cast( "ProviderModelDiscoveryResult", await _discover_draft_profile_models( @@ -322,15 +311,11 @@ async def _models_discover(params: Any, ctx: RpcContext) -> dict[str, Any]: ) -async def _image_generation_models_discover( - params: Any, ctx: RpcContext -) -> dict[str, Any]: +async def _image_generation_models_discover(params: Any, ctx: RpcContext) -> dict[str, Any]: return cast( dict[str, Any], - await _provider_setup(ctx).discover_image_models( - str(_require(params, "providerId")) - ), + await _provider_setup(ctx).discover_image_models(str(_require(params, "providerId"))), ) @@ -370,23 +355,17 @@ async def _llm_profile_draft_probe(params: Any, ctx: RpcContext) -> dict[str, An ) -async def _llm_profile_models_discover( - params: Any, ctx: RpcContext -) -> dict[str, Any]: +async def _llm_profile_models_discover(params: Any, ctx: RpcContext) -> dict[str, Any]: return cast( dict[str, Any], await _profile_lifecycle(ctx).discover_models(_profile_probe_command(params)), ) -async def _llm_profile_draft_models_discover( - params: Any, ctx: RpcContext -) -> dict[str, Any]: +async def _llm_profile_draft_models_discover(params: Any, ctx: RpcContext) -> dict[str, Any]: return cast( dict[str, Any], - await _profile_lifecycle(ctx).discover_draft_models( - _profile_probe_command(params) - ), + await _profile_lifecycle(ctx).discover_draft_models(_profile_probe_command(params)), ) @@ -461,9 +440,9 @@ def _request_changes_active_provider_connection(params: Any, cfg: Any) -> bool: canonical_tokenrhythm_base_url, ) - requested_provider = str( - params.get("providerId") or getattr(llm, "provider", "") or "" - ).strip().lower() + requested_provider = ( + str(params.get("providerId") or getattr(llm, "provider", "") or "").strip().lower() + ) comparisons = ( ("apiKey", "api_key"), @@ -514,9 +493,7 @@ async def _provider_configure(params: Any, ctx: RpcContext) -> dict[str, Any]: proxy=str(_param(params, "proxy", "")), preset_id=str(_param(params, "presetId", "")), router_action=str(_param(params, "routerAction", "preserve")), - image_generation_intent=str( - _param(params, "imageGenerationIntent", "preserve") - ), + image_generation_intent=str(_param(params, "imageGenerationIntent", "preserve")), ) result = await _provider_setup(ctx).configure_primary(command) return cast(dict[str, Any], result.to_payload()) @@ -600,9 +577,7 @@ async def _llm_profile_active_remove(params: Any, ctx: RpcContext) -> dict[str, replacement_provider_id = str(_require(params, "replacementProviderId")) replacement_model = str(_param(params, "replacementModel", "") or "") router_action = str(_param(params, "routerAction", "preserve")) - image_generation_intent = str( - _param(params, "imageGenerationIntent", "preserve") - ) + image_generation_intent = str(_param(params, "imageGenerationIntent", "preserve")) try: result = await _profile_lifecycle(ctx).remove_active( RemoveActiveProfile( @@ -615,12 +590,8 @@ async def _llm_profile_active_remove(params: Any, ctx: RpcContext) -> dict[str, ) except LlmProfileActivationError as exc: code_by_reason = { - "primary_pool_unsupported": ( - "onboarding.llmProfile.primary_pool_unsupported" - ), - "router_provider_conflict": ( - "onboarding.llmProfile.router_provider_conflict" - ), + "primary_pool_unsupported": ("onboarding.llmProfile.primary_pool_unsupported"), + "router_provider_conflict": ("onboarding.llmProfile.router_provider_conflict"), } raise RpcHandlerError( code_by_reason.get(exc.reason, "onboarding.llmProfile.invalid"), @@ -679,12 +650,8 @@ async def _llm_profile_activate(params: Any, ctx: RpcContext) -> dict[str, Any]: ) except LlmProfileActivationError as exc: code_by_reason = { - "primary_pool_unsupported": ( - "onboarding.llmProfile.primary_pool_unsupported" - ), - "router_provider_conflict": ( - "onboarding.llmProfile.router_provider_conflict" - ), + "primary_pool_unsupported": ("onboarding.llmProfile.primary_pool_unsupported"), + "router_provider_conflict": ("onboarding.llmProfile.router_provider_conflict"), } code = code_by_reason.get(exc.reason, "onboarding.llmProfile.invalid") details = { @@ -1216,13 +1183,9 @@ async def _discover_primary_models( api_key_env=api_key_env, base_url=base_url, proxy=proxy, - allow_default_api_key_env=( - not same_provider or reuse_stored_credentials - ), + allow_default_api_key_env=(not same_provider or reuse_stored_credentials), force_refresh=force_refresh, - persist_catalog=( - same_provider and reuse_stored_credentials and not request_overrides - ), + persist_catalog=(same_provider and reuse_stored_credentials and not request_overrides), catalog_config=cfg, ) return result.to_payload() @@ -1325,9 +1288,7 @@ async def _channel_probe(params: Any, ctx: RpcContext) -> dict[str, Any]: "probeKind": "local_validation", "restartRequired": True, "entry": redact_channel_entry(type_name, normalized), - "warnings": [ - "Configuration is locally valid; no provider connection was attempted." - ], + "warnings": ["Configuration is locally valid; no provider connection was attempted."], } @@ -1420,9 +1381,7 @@ async def _audio_configure(params: Any, ctx: RpcContext) -> dict[str, Any]: async def _capability_reset(params: Any, ctx: RpcContext) -> dict[str, Any]: with _validation_error("onboarding.capability.invalid"): - result = await _capability_setup(ctx).reset( - str(_require(params, "capabilityId")) - ) + result = await _capability_setup(ctx).reset(str(_require(params, "capabilityId"))) return cast(dict[str, Any], result.to_payload()) @@ -1560,9 +1519,7 @@ async def _channel_disable(params: Any, ctx: RpcContext) -> dict[str, Any]: "onboarding.llmProfile.probe": _llm_profile_probe, "onboarding.llmProfile.draft.probe": _llm_profile_draft_probe, "onboarding.llmProfile.models.discover": _llm_profile_models_discover, - "onboarding.llmProfile.draft.models.discover": ( - _llm_profile_draft_models_discover - ), + "onboarding.llmProfile.draft.models.discover": (_llm_profile_draft_models_discover), "onboarding.router.configure": _router_configure, "onboarding.ensemble.configure": _ensemble_configure, "onboarding.search.configure": _search_configure, From 6893c5770ef1679ce66d3011e67429b196fd2e96 Mon Sep 17 00:00:00 2001 From: "liumingyao.marvin" Date: Tue, 15 Sep 2026 23:15:38 +0800 Subject: [PATCH 4/4] style(onboarding): apply current ruff formatting --- src/opensquilla/gateway/rpc_onboarding.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/opensquilla/gateway/rpc_onboarding.py b/src/opensquilla/gateway/rpc_onboarding.py index 3d9a98c5bc..5cc697ae51 100644 --- a/src/opensquilla/gateway/rpc_onboarding.py +++ b/src/opensquilla/gateway/rpc_onboarding.py @@ -57,9 +57,7 @@ @contextmanager -def _validation_error( - code: str, *, router_provider_id: str | None = None -) -> Iterator[None]: +def _validation_error(code: str, *, router_provider_id: str | None = None) -> Iterator[None]: """Translate a mutation validation error into a stable, client-localizable ``RpcHandlerError`` code, keeping the original English text as the message so the Web UI can fall back to it (and developers keep the detail). @@ -572,9 +570,7 @@ async def _llm_profile_upsert_and_activate(params: Any, ctx: RpcContext) -> dict if not isinstance(params, dict) or any(value is None for value in params.values()): # Generated optional Python fields use None for omission. The new # wire Contract excludes explicit null so keep absence and clear distinct. - raise RpcHandlerError( - "INVALID_REQUEST", "Invalid save-and-activate profile parameters" - ) + raise RpcHandlerError("INVALID_REQUEST", "Invalid save-and-activate profile parameters") try: p = validate_upsert_and_activate_params(params) except ValidationError as exc: @@ -591,9 +587,7 @@ async def _llm_profile_upsert_and_activate(params: Any, ctx: RpcContext) -> dict api_key=p.get("apiKey"), api_key_env=p.get("apiKeyEnv"), api_key_env_pool=p.get("apiKeyEnvPool"), - keep_current_secret=p.get( - "keepCurrentSecret", p.get("preserveApiKey", False) - ), + keep_current_secret=p.get("keepCurrentSecret", p.get("preserveApiKey", False)), base_url=p.get("baseUrl"), proxy=p.get("proxy"), router_action=p.get("routerAction", "preserve"),