diff --git a/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts b/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts index 9c279b22f..2aeba05b9 100644 --- a/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts +++ b/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts @@ -5627,16 +5627,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('') @@ -5645,7 +5648,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 0ec47b967..b252a09a1 100644 --- a/opensquilla-webui/src/composables/setup/useSetupCatalog.ts +++ b/opensquilla-webui/src/composables/setup/useSetupCatalog.ts @@ -1521,19 +1521,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 ad877c9ca..032d8a2b4 100644 --- a/src/opensquilla/gateway/rpc_onboarding.py +++ b/src/opensquilla/gateway/rpc_onboarding.py @@ -1020,7 +1020,7 @@ async def _probe_primary_provider( config: Any, usage_event_sink: Any, ) -> dict[str, Any]: - """Live one-token probe of a candidate provider config (nothing is saved).""" + """Live probe of a candidate provider config without saving it.""" provider_id = command.provider_id cfg = config api_key = str(command.api_key or "") @@ -1061,19 +1061,42 @@ async def _probe_primary_provider( if not proxy: proxy = str(getattr(cfg.llm, "proxy", "") or "") model = str(command.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( - usage_event_sink, - 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( + usage_event_sink, + 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: + # A model is unnecessary for an endpoint/credential connectivity + # check; model discovery exercises that path without a chat turn. + from opensquilla.onboarding.probe import ( + ProviderProbeResult, + discover_provider_models, + ) + + listing = await discover_provider_models( + 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, + ) + result = ProviderProbeResult( + ok=listing.ok, + provider_id=str(provider_id), + model="", + failure_kind=listing.failure_kind, + message=listing.detail, + ) 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 b6602dc32..ae92a74d0 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): @@ -1949,6 +1921,58 @@ 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")) @@ -1965,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): @@ -1980,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, )