Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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('')

Expand All @@ -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()
},
Expand Down
17 changes: 9 additions & 8 deletions opensquilla-webui/src/composables/setup/useSetupCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})

Expand Down
49 changes: 36 additions & 13 deletions src/opensquilla/gateway/rpc_onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "")
Expand Down Expand Up @@ -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
Expand Down
111 changes: 66 additions & 45 deletions tests/test_gateway/test_rpc_onboarding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand All @@ -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"))
Expand All @@ -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):
Expand All @@ -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,
)

Expand Down
Loading