diff --git a/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts b/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts index d9f320e30f..2d7c021b79 100644 --- a/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts +++ b/opensquilla-webui/src/composables/setup/useSetupCatalog.privacy.test.ts @@ -6037,16 +6037,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('') @@ -6055,7 +6058,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 d2bd3aa9ad..c81572bfb5 100644 --- a/opensquilla-webui/src/composables/setup/useSetupCatalog.ts +++ b/opensquilla-webui/src/composables/setup/useSetupCatalog.ts @@ -1548,19 +1548,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 1146fdffa7..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). @@ -115,6 +113,7 @@ def _channel_error() -> Iterator[None]: details={"fields": details} if details else None, ) from exc + log = structlog.get_logger(__name__) _d = get_dispatcher() @@ -166,9 +165,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( @@ -186,9 +183,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), @@ -250,9 +245,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( @@ -263,9 +256,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( @@ -276,9 +267,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( @@ -288,9 +277,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( @@ -345,15 +332,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"))), ) @@ -393,23 +376,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)), ) @@ -484,9 +461,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"), @@ -540,9 +517,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()) @@ -595,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: @@ -614,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"), @@ -684,9 +655,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( @@ -699,12 +668,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"), @@ -763,12 +728,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 = { @@ -1104,7 +1065,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 "") @@ -1145,19 +1106,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 @@ -1277,13 +1261,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() @@ -1386,9 +1366,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."], } @@ -1481,9 +1459,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()) @@ -1622,9 +1598,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 2a9fe060fd..573aacfde4 100644 --- a/tests/test_gateway/test_rpc_onboarding.py +++ b/tests/test_gateway/test_rpc_onboarding.py @@ -347,9 +347,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() @@ -377,10 +375,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 @@ -391,10 +386,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 @@ -503,9 +495,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 @@ -542,9 +532,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", @@ -577,9 +565,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", @@ -753,10 +739,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" @@ -1134,10 +1117,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 @@ -1173,9 +1153,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" @@ -1418,9 +1396,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 @@ -1536,9 +1512,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 @@ -1923,9 +1897,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): @@ -1951,6 +1923,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")) @@ -1967,9 +1991,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): @@ -1982,8 +2004,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, )