diff --git a/pyproject.toml b/pyproject.toml index d4914c293..03078a517 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -117,8 +117,8 @@ journal-host = [ # Pinned to >=1.27.1: that release reverts the Gemini prompt-cache markers # (upstream PR #3586 / issue #3585). Emitting cache_control on Gemini froze # its cache at the static prefix and disabled Google's implicit caching - # (~6-14x cost); our default gemini-flash-latest never matched the allowlist, - # but the bump removes the latent trap for any explicit gemini-2.5/3 pin. + # (~6-14x cost); keep implicit caching available for exact Gemini defaults + # and any explicit gemini-2.5/3 pin. # openhands-sdk pulls lmnr's OpenTelemetry beta stack. Pin the matching line # so pip does not mix newer 1.42/0.63 packages with lmnr's 0.60b1 # semantic-conventions pin. diff --git a/solstone/apps/settings/tests/conftest.py b/solstone/apps/settings/tests/conftest.py index 7a9ad0930..6137521db 100644 --- a/solstone/apps/settings/tests/conftest.py +++ b/solstone/apps/settings/tests/conftest.py @@ -47,7 +47,7 @@ def settings_env(tmp_path, monkeypatch): "providers": { "active": { "provider": "google", - "model": "gemini-flash-latest", + "model": "gemini-3.5-flash", }, "key_validation": {}, }, diff --git a/solstone/apps/thinking/google_model_pins.py b/solstone/apps/thinking/google_model_pins.py new file mode 100644 index 000000000..1393c3609 --- /dev/null +++ b/solstone/apps/thinking/google_model_pins.py @@ -0,0 +1,114 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Pure helpers for exact Google model pinning and guidance.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +GOOGLE_ALIAS_PIN_MAP = { + "gemini-flash-latest": "gemini-3.5-flash", + "gemini-flash-lite-latest": "gemini-3.1-flash-lite", +} +GOOGLE_PRO_ALIAS = "gemini-pro-latest" +GOOGLE_PROVIDER = "google" +THINKING_BYO_MODEL_HREF = "/app/thinking/#byoModelPanel" + +ChangedModelField = tuple[str, str, str] + + +def _google_profile_model(profile: Any) -> str | None: + if not isinstance(profile, Mapping): + return None + if profile.get("provider") != GOOGLE_PROVIDER: + return None + model = profile.get("model") + return model if isinstance(model, str) else None + + +def _pin_profile_model(profile: Any, path: str) -> ChangedModelField | None: + if not isinstance(profile, dict): + return None + old_model = _google_profile_model(profile) + if old_model not in GOOGLE_ALIAS_PIN_MAP: + return None + new_model = GOOGLE_ALIAS_PIN_MAP[old_model] + profile["model"] = new_model + return (path, old_model, new_model) + + +def pin_google_model_aliases(config: dict[str, Any]) -> list[ChangedModelField]: + """Pin byte-exact Google alias slots in place and return changed fields.""" + + changed: list[ChangedModelField] = [] + providers = config.get("providers") + if isinstance(providers, dict): + active = _pin_profile_model(providers.get("active"), "providers.active.model") + if active is not None: + changed.append(active) + + byo_models = providers.get("byo_models") + if isinstance(byo_models, dict): + old_model = byo_models.get(GOOGLE_PROVIDER) + if old_model in GOOGLE_ALIAS_PIN_MAP: + new_model = GOOGLE_ALIAS_PIN_MAP[old_model] + byo_models[GOOGLE_PROVIDER] = new_model + changed.append(("providers.byo_models.google", old_model, new_model)) + + services = config.get("services") + confidential = services.get("confidential") if isinstance(services, dict) else None + if isinstance(confidential, dict): + prior = _pin_profile_model( + confidential.get("prior_active"), + "services.confidential.prior_active.model", + ) + if prior is not None: + changed.append(prior) + + return changed + + +def read_google_pro_alias_paths(config: Mapping[str, Any]) -> list[str]: + """Return config field paths that hold the exact Google Pro alias.""" + + paths: list[str] = [] + providers = config.get("providers") + if isinstance(providers, Mapping): + if _google_profile_model(providers.get("active")) == GOOGLE_PRO_ALIAS: + paths.append("providers.active.model") + + byo_models = providers.get("byo_models") + if ( + isinstance(byo_models, Mapping) + and byo_models.get(GOOGLE_PROVIDER) == GOOGLE_PRO_ALIAS + ): + paths.append("providers.byo_models.google") + + services = config.get("services") + confidential = ( + services.get("confidential") if isinstance(services, Mapping) else None + ) + if isinstance(confidential, Mapping): + if _google_profile_model(confidential.get("prior_active")) == GOOGLE_PRO_ALIAS: + paths.append("services.confidential.prior_active.model") + + return paths + + +def read_google_exact_model_advisory( + config: Mapping[str, Any], +) -> dict[str, Any] | None: + """Return owner guidance when a saved Google Pro alias needs manual choice.""" + + if not read_google_pro_alias_paths(config): + return None + return { + "id": "choose_exact_gemini_model", + "heading": "choose an exact Gemini model", + "action": { + "label": "choose model", + "href": THINKING_BYO_MODEL_HREF, + }, + } diff --git a/solstone/apps/thinking/maint/002_pin_google_model_aliases.py b/solstone/apps/thinking/maint/002_pin_google_model_aliases.py new file mode 100644 index 000000000..7d3d6b89f --- /dev/null +++ b/solstone/apps/thinking/maint/002_pin_google_model_aliases.py @@ -0,0 +1,65 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +"""Pin byte-exact Google model aliases in thinking provider config.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from solstone.apps.thinking.google_model_pins import ( + GOOGLE_PRO_ALIAS, + ChangedModelField, + pin_google_model_aliases, + read_google_pro_alias_paths, +) +from solstone.think.journal_config import ( + JournalConfigMutation, + mutate_journal_config, +) +from solstone.think.utils import get_journal + +MAINT_RETRY_ON_NEXT_START = True +MAINT_BLOCKS_SUPERVISOR_START = True + + +def _pin_line(field: ChangedModelField) -> str: + path, old_model, new_model = field + return f"{path}: {old_model} -> {new_model}" + + +def _pro_line(path: str) -> str: + return f"{path}: {GOOGLE_PRO_ALIAS} -> choose exact Gemini model" + + +def _history_lines( + changed_fields: list[ChangedModelField], + pro_paths: list[str], +) -> list[str]: + return [_pin_line(field) for field in changed_fields] + [ + _pro_line(path) for path in pro_paths + ] + + +def main() -> None: + journal = Path(get_journal()) + + def apply(config: dict[str, Any]) -> JournalConfigMutation[list[str]]: + changed_fields = pin_google_model_aliases(config) + pro_paths = read_google_pro_alias_paths(config) + return JournalConfigMutation( + changed=bool(changed_fields), + value=_history_lines(changed_fields, pro_paths), + ) + + result = mutate_journal_config(apply, journal_path=journal) + if not result.value: + print("Google model aliases already pinned.") + return + for line in result.value: + print(line) + + +if __name__ == "__main__": + main() diff --git a/solstone/apps/thinking/model_tiers.py b/solstone/apps/thinking/model_tiers.py index b9fbe802a..6fe6e484e 100644 --- a/solstone/apps/thinking/model_tiers.py +++ b/solstone/apps/thinking/model_tiers.py @@ -3,9 +3,9 @@ """Presentation-layer model tiers. -These are vendor-facing ids that need tending as vendors evolve. Google's -``-latest`` aliases self-tend; the Anthropic/OpenAI rows are pinned family -aliases. Nothing under ``solstone/think/`` should import this catalog. +These are vendor-facing ids that need tending as vendors evolve. Provider rows +use explicit model ids. Nothing under ``solstone/think/`` should import this +catalog. """ from __future__ import annotations @@ -13,9 +13,8 @@ from __future__ import annotations # fmt: off MODEL_TIERS = { "google": [ - {"tier": "top", "label": "Gemini Pro Latest", "model": "gemini-pro-latest"}, - {"tier": "mid", "label": "Gemini Flash Latest", "model": "gemini-flash-latest"}, - {"tier": "lite", "label": "Gemini Flash Lite Latest", "model": "gemini-flash-lite-latest"}, + {"tier": "mid", "label": "Gemini 3.5 Flash", "model": "gemini-3.5-flash"}, + {"tier": "lite", "label": "Gemini 3.1 Flash Lite", "model": "gemini-3.1-flash-lite"}, ], "anthropic": [ {"tier": "top", "label": "Claude Opus", "model": "claude-opus-4-8"}, diff --git a/solstone/apps/thinking/routes.py b/solstone/apps/thinking/routes.py index 40a33c91b..27e653319 100644 --- a/solstone/apps/thinking/routes.py +++ b/solstone/apps/thinking/routes.py @@ -18,6 +18,7 @@ from flask import Blueprint, current_app, jsonify, request from solstone.apps.thinking import copy as thinking_copy from solstone.apps.thinking import local_bootstrap, local_recovery, scout_lane from solstone.apps.thinking.copy import thinking_copy_payload +from solstone.apps.thinking.google_model_pins import read_google_exact_model_advisory from solstone.apps.thinking.model_tiers import MODEL_TIERS from solstone.apps.utils import log_app_action from solstone.convey.readiness_snapshot import build_readiness_snapshot @@ -518,6 +519,7 @@ def _provider_payload(config: dict[str, Any], local_model_id: str) -> dict[str, ), "active": active_settings, "model_tiers": MODEL_TIERS, + "configuration_guidance": read_google_exact_model_advisory(config), "byo_models": providers_config.get("byo_models", {}), "api_keys": _api_key_status(config), "key_validation": _filtered_ai_key_validation(config), diff --git a/solstone/apps/thinking/static/thinking.js b/solstone/apps/thinking/static/thinking.js index 7e3bc90df..07c1e8d7d 100644 --- a/solstone/apps/thinking/static/thinking.js +++ b/solstone/apps/thinking/static/thinking.js @@ -1470,6 +1470,7 @@ setButtonText('byoCheckAgain', byoText.check_again || ''); setText('byoModelHeading', byoText.model_heading || ''); setText('byoModelSub', formatCopy(byoText.model_sub || '', {provider: providerName})); + renderConfigurationGuidance(); setButtonText('byoCustomToggle', byoText.custom_toggle || ''); $('byoCustomToggle')?.setAttribute('aria-expanded', state.byoCustomOpen ? 'true' : 'false'); setHidden('byoCustomRow', !state.byoCustomOpen); @@ -1652,6 +1653,26 @@ } } + function renderConfigurationGuidance() { + const guidance = state.providers.configuration_guidance; + const notice = $('byoConfigurationGuidance'); + if (!notice) return; + notice.textContent = ''; + if (!guidance) { + setHidden('byoConfigurationGuidance', true); + return; + } + const heading = document.createElement('strong'); + heading.textContent = guidance.heading || ''; + const action = guidance.action || {}; + const link = document.createElement('a'); + link.className = 'textlink'; + link.href = action.href || '#byoModelPanel'; + link.textContent = action.label || ''; + notice.append(heading, ' ', link); + setHidden('byoConfigurationGuidance', false); + } + function localCopy() { if (localEndpointConfigured()) { return { diff --git a/solstone/apps/thinking/tests/conftest.py b/solstone/apps/thinking/tests/conftest.py index 909c42836..a5f58d77c 100644 --- a/solstone/apps/thinking/tests/conftest.py +++ b/solstone/apps/thinking/tests/conftest.py @@ -100,7 +100,7 @@ def settings_env(tmp_path, monkeypatch): "providers": { "active": { "provider": "google", - "model": "gemini-flash-latest", + "model": "gemini-3.5-flash", }, "key_validation": {}, }, diff --git a/solstone/apps/thinking/tests/test_confidential_routes.py b/solstone/apps/thinking/tests/test_confidential_routes.py index abb27b326..4afa9472e 100644 --- a/solstone/apps/thinking/tests/test_confidential_routes.py +++ b/solstone/apps/thinking/tests/test_confidential_routes.py @@ -243,7 +243,7 @@ def test_disable_confidential_restores_synchronously( config = _read_config(journal_copy) assert config["providers"]["active"] == { "provider": "google", - "model": "gemini-flash-latest", + "model": "gemini-3.5-flash", } assert config["providers"]["local"] == {} assert "confidential" not in config["services"] diff --git a/solstone/apps/thinking/tests/test_google_model_pin_migration.py b/solstone/apps/thinking/tests/test_google_model_pin_migration.py new file mode 100644 index 000000000..d630346cd --- /dev/null +++ b/solstone/apps/thinking/tests/test_google_model_pin_migration.py @@ -0,0 +1,265 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright (c) 2026 sol pbc + +from __future__ import annotations + +import importlib +import json +from pathlib import Path +from typing import Any + +import pytest + +from solstone.apps.thinking.google_model_pins import ( + GOOGLE_PRO_ALIAS, + THINKING_BYO_MODEL_HREF, + read_google_exact_model_advisory, +) +from solstone.think.journal_io import LockTimeout + +migration = importlib.import_module( + "solstone.apps.thinking.maint.002_pin_google_model_aliases" +) + + +def _config_path(journal: Path) -> Path: + return journal / "config" / "journal.json" + + +def _write_config(journal: Path, config: dict[str, Any]) -> None: + path = _config_path(journal) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") + + +def _read_config(journal: Path) -> dict[str, Any]: + return json.loads(_config_path(journal).read_text(encoding="utf-8")) + + +def _run_main( + journal: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> list[str]: + monkeypatch.setattr(migration, "get_journal", lambda: str(journal)) + migration.main() + return capsys.readouterr().out.strip().splitlines() + + +def test_migration_pins_all_slots_and_reports_secret_free_history( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + config = { + "env": {"GOOGLE_API_KEY": "secret-key"}, + "providers": { + "active": {"provider": "google", "model": "gemini-flash-latest"}, + "byo_models": {"google": "gemini-flash-lite-latest"}, + }, + "services": { + "confidential": { + "endpoint_url": "https://spp.example.test/v1", + "served_model_id": "served", + "prior_active": { + "provider": "google", + "model": "gemini-flash-latest", + }, + "prior_local_endpoint": { + "endpoint_url": "https://prior.example.test/v1" + }, + "prompt": "do-not-print", + "response": "do-not-print", + } + }, + } + _write_config(tmp_path, config) + + lines = _run_main(tmp_path, monkeypatch, capsys) + + assert lines == [ + "providers.active.model: gemini-flash-latest -> gemini-3.5-flash", + "providers.byo_models.google: gemini-flash-lite-latest -> gemini-3.1-flash-lite", + "services.confidential.prior_active.model: gemini-flash-latest -> gemini-3.5-flash", + ] + stored = _read_config(tmp_path) + assert stored["providers"]["active"]["model"] == "gemini-3.5-flash" + assert stored["providers"]["byo_models"]["google"] == "gemini-3.1-flash-lite" + assert ( + stored["services"]["confidential"]["prior_active"]["model"] + == "gemini-3.5-flash" + ) + output = "\n".join(lines) + for secret in ( + "secret-key", + "https://spp.example.test/v1", + "https://prior.example.test/v1", + "do-not-print", + ): + assert secret not in output + + before = _config_path(tmp_path).read_bytes() + lines = _run_main(tmp_path, monkeypatch, capsys) + assert lines == ["Google model aliases already pinned."] + assert _config_path(tmp_path).read_bytes() == before + + +def test_migration_keeps_pro_aliases_and_reports_choose_model_history( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + config = { + "providers": { + "active": {"provider": "google", "model": GOOGLE_PRO_ALIAS}, + "byo_models": {"google": GOOGLE_PRO_ALIAS}, + }, + "services": { + "confidential": { + "prior_active": {"provider": "google", "model": GOOGLE_PRO_ALIAS} + } + }, + } + _write_config(tmp_path, config) + + lines = _run_main(tmp_path, monkeypatch, capsys) + + assert lines == [ + "providers.active.model: gemini-pro-latest -> choose exact Gemini model", + "providers.byo_models.google: gemini-pro-latest -> choose exact Gemini model", + "services.confidential.prior_active.model: gemini-pro-latest -> choose exact Gemini model", + ] + assert _read_config(tmp_path) == config + + +def test_migration_ignores_custom_provider_mismatched_and_non_exact_values( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + config = { + "providers": { + "active": {"provider": "anthropic", "model": "gemini-flash-latest"}, + "byo_models": {"google": " gemini-flash-lite-latest "}, + }, + "services": { + "confidential": { + "prior_active": { + "provider": "google", + "model": "Gemini-Flash-Latest", + } + } + }, + } + _write_config(tmp_path, config) + before = _config_path(tmp_path).read_bytes() + + lines = _run_main(tmp_path, monkeypatch, capsys) + + assert lines == ["Google model aliases already pinned."] + assert _config_path(tmp_path).read_bytes() == before + assert _read_config(tmp_path) == config + + +def test_migration_ignores_custom_google_ids( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + config = { + "providers": { + "active": {"provider": "google", "model": "gemini-custom-flash"}, + "byo_models": {"google": "gemini-custom-remembered"}, + }, + "services": { + "confidential": { + "prior_active": { + "provider": "google", + "model": "gemini-custom-prior", + } + } + }, + } + _write_config(tmp_path, config) + before = _config_path(tmp_path).read_bytes() + + lines = _run_main(tmp_path, monkeypatch, capsys) + + assert lines == ["Google model aliases already pinned."] + assert _config_path(tmp_path).read_bytes() == before + assert _read_config(tmp_path) == config + + +@pytest.mark.parametrize( + "error", + [ + LockTimeout(path=Path("config/journal.json"), timeout=0.01), + OSError("commit failed"), + ], +) +def test_migration_propagates_transaction_failures_without_partial_pin( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + error: Exception, +) -> None: + config = { + "providers": {"active": {"provider": "google", "model": "gemini-flash-latest"}} + } + _write_config(tmp_path, config) + before = _config_path(tmp_path).read_bytes() + monkeypatch.setattr(migration, "get_journal", lambda: str(tmp_path)) + + def fail_mutation(*_args: Any, **_kwargs: Any) -> None: + raise error + + monkeypatch.setattr(migration, "mutate_journal_config", fail_mutation) + + with pytest.raises(type(error)): + migration.main() + + assert migration.MAINT_RETRY_ON_NEXT_START is True + assert migration.MAINT_BLOCKS_SUPERVISOR_START is True + assert _config_path(tmp_path).read_bytes() == before + + +@pytest.mark.parametrize( + "config", + [ + {"providers": {"active": {"provider": "google", "model": GOOGLE_PRO_ALIAS}}}, + {"providers": {"byo_models": {"google": GOOGLE_PRO_ALIAS}}}, + { + "services": { + "confidential": { + "prior_active": {"provider": "google", "model": GOOGLE_PRO_ALIAS} + } + } + }, + ], +) +def test_read_google_exact_model_advisory_is_content_free( + config: dict[str, Any], +) -> None: + advisory = read_google_exact_model_advisory(config) + + assert advisory == { + "id": "choose_exact_gemini_model", + "heading": "choose an exact Gemini model", + "action": {"label": "choose model", "href": THINKING_BYO_MODEL_HREF}, + } + assert GOOGLE_PRO_ALIAS not in json.dumps(advisory) + + +def test_read_google_exact_model_advisory_clears_for_exact_models() -> None: + config = { + "providers": { + "active": {"provider": "google", "model": "gemini-3.5-flash"}, + "byo_models": {"google": "gemini-3.5-flash"}, + }, + "services": { + "confidential": { + "prior_active": {"provider": "google", "model": "gemini-3.5-flash"} + } + }, + } + + assert read_google_exact_model_advisory(config) is None diff --git a/solstone/apps/thinking/tests/test_keys_check_route.py b/solstone/apps/thinking/tests/test_keys_check_route.py index 9e32ffe39..d2d4e4aba 100644 --- a/solstone/apps/thinking/tests/test_keys_check_route.py +++ b/solstone/apps/thinking/tests/test_keys_check_route.py @@ -174,7 +174,7 @@ def test_keys_same_value_validation_refresh_persists(tmp_path, monkeypatch): "setup": {"completed_at": 1700000000000}, "env": {"GOOGLE_API_KEY": "same-key"}, "providers": { - "active": {"provider": "google", "model": "gemini-flash-latest"}, + "active": {"provider": "google", "model": "gemini-3.5-flash"}, "key_validation": { "google": { "valid": False, diff --git a/solstone/apps/thinking/tests/test_local_bootstrap_routes.py b/solstone/apps/thinking/tests/test_local_bootstrap_routes.py index 569c7a15e..213225a46 100644 --- a/solstone/apps/thinking/tests/test_local_bootstrap_routes.py +++ b/solstone/apps/thinking/tests/test_local_bootstrap_routes.py @@ -43,7 +43,7 @@ def _settings_config() -> dict: return { "setup": {"completed_at": 1700000000000}, "providers": { - "active": {"provider": "google", "model": "gemini-flash-latest"}, + "active": {"provider": "google", "model": "gemini-3.5-flash"}, "auth": {"google": "api_key", "openai": "api_key"}, }, } diff --git a/solstone/apps/thinking/tests/test_providers_payload_extended.py b/solstone/apps/thinking/tests/test_providers_payload_extended.py index 711122de9..5e845cca9 100644 --- a/solstone/apps/thinking/tests/test_providers_payload_extended.py +++ b/solstone/apps/thinking/tests/test_providers_payload_extended.py @@ -10,10 +10,13 @@ from typing import get_args import pytest from solstone.apps.thinking import routes +from solstone.apps.thinking.google_model_pins import THINKING_BYO_MODEL_HREF from solstone.apps.thinking.local_bootstrap import LOCAL_MODEL_SPECS from solstone.apps.thinking.model_tiers import MODEL_TIERS +from solstone.convey import provider_readiness from solstone.think.models import LOCAL_MODEL, NO_BRAIN_PROVIDER, resolve_provider from solstone.think.providers.artifact_proof import ReadinessOutcome +from solstone.think.providers.brain_state import BRAIN_REASON_CODES from solstone.think.providers.install_state import InstallState from solstone.think.providers.state import ProviderState @@ -186,7 +189,7 @@ def test_get_providers_reports_active_personal_cloud_lane( assert payload["active_lane"]["lane"] == "byo" assert payload["active"] == { "provider": "google", - "model": "gemini-flash-latest", + "model": "gemini-3.5-flash", } @@ -264,7 +267,7 @@ def test_scout_enabled_google_provider_derives_byo_with_provenance( } config["providers"]["active"] = { "provider": "google", - "model": "gemini-flash-latest", + "model": "gemini-3.5-flash", } _write_config(journal_path, config) @@ -312,7 +315,7 @@ def test_key_clear_pops_remembered_byo_model( config_path = journal_path / "config" / "journal.json" config = json.loads(config_path.read_text()) config["providers"]["byo_models"] = { - "google": "gemini-pro-latest", + "google": "gemini-3.5-flash", "openai": "gpt-5.5", } _write_config(journal_path, config) @@ -426,7 +429,7 @@ def test_thinking_status_payloads_are_secret_free_with_scout_provenance( "served_model_id": "confidential-model", "credential_created_at": "2026-05-24T00:00:00Z", "credential_fingerprint_sha256": "confidential-fingerprint-secret", - "prior_active": {"provider": "google", "model": "gemini-flash-latest"}, + "prior_active": {"provider": "google", "model": "gemini-3.5-flash"}, "prior_local_endpoint": None, } config.setdefault("providers", {}).setdefault("local", {})["credential"] = ( @@ -434,7 +437,7 @@ def test_thinking_status_payloads_are_secret_free_with_scout_provenance( ) config["providers"]["active"] = { "provider": "google", - "model": "gemini-flash-latest", + "model": "gemini-3.5-flash", } _write_config(journal_path, config) @@ -526,7 +529,7 @@ def test_providers_payload_includes_model_tiers_and_byo_models_as_is( config_path = journal_path / "config" / "journal.json" config = json.loads(config_path.read_text()) config["providers"]["byo_models"] = { - "google": "gemini-pro-latest", + "google": "gemini-3.5-flash", "unexpected": "kept-as-is", } _write_config(journal_path, config) @@ -537,9 +540,44 @@ def test_providers_payload_includes_model_tiers_and_byo_models_as_is( payload = response.get_json() assert payload["model_tiers"] == MODEL_TIERS assert payload["byo_models"] == { - "google": "gemini-pro-latest", + "google": "gemini-3.5-flash", "unexpected": "kept-as-is", } + assert payload["configuration_guidance"] is None + + +@pytest.mark.parametrize("slot", ["active", "byo", "prior"]) +def test_providers_payload_configuration_guidance_for_google_pro_alias( + settings_client_with_journal, + slot: str, +): + client, journal_path = settings_client_with_journal + config_path = journal_path / "config" / "journal.json" + config = json.loads(config_path.read_text()) + if slot == "active": + config["providers"]["active"] = { + "provider": "google", + "model": "gemini-pro-latest", + } + elif slot == "byo": + config["providers"]["byo_models"] = {"google": "gemini-pro-latest"} + else: + config.setdefault("services", {})["confidential"] = { + "prior_active": {"provider": "google", "model": "gemini-pro-latest"} + } + _write_config(journal_path, config) + + response = client.get("/app/thinking/api/providers") + + assert response.status_code == 200 + guidance = response.get_json()["configuration_guidance"] + assert guidance == { + "id": "choose_exact_gemini_model", + "heading": "choose an exact Gemini model", + "action": {"label": "choose model", "href": THINKING_BYO_MODEL_HREF}, + } + assert guidance["id"] not in BRAIN_REASON_CODES + assert guidance["id"] not in provider_readiness.mapped_reason_codes() def test_think_layer_does_not_import_thinking_model_tiers(): @@ -642,7 +680,7 @@ def test_local_endpoint_override_derives_confidential_only_with_provenance( "served_model_id": "served-model", "credential_created_at": "2026-05-24T00:00:00Z", "credential_fingerprint_sha256": "fingerprint", - "prior_active": {"provider": "google", "model": "gemini-flash-latest"}, + "prior_active": {"provider": "google", "model": "gemini-3.5-flash"}, "prior_local_endpoint": None, } _write_config(journal_path, config) @@ -791,7 +829,7 @@ def test_byo_lane_with_top_level_model_writes_active_profile_and_memory( ("payload", "detail"), [ ( - {"lane": "local", "model": "gemini-pro-latest"}, + {"lane": "local", "model": "gemini-3.5-flash"}, "model is only valid with cloud BYO providers: anthropic, google, openai.", ), ( @@ -862,7 +900,7 @@ def test_byo_lane_fills_remembered_model_after_hygiene_pop( config = json.loads(config_path.read_text()) config["providers"]["active"] = { "provider": "google", - "model": "gemini-pro-latest", + "model": "gemini-3.5-flash", } config["providers"]["byo_models"] = {"anthropic": "claude-opus-4-8"} _write_config(journal_path, config) @@ -936,7 +974,7 @@ def test_lane_switch_to_confidential_rejects_without_config_write( "served_model_id": "served-model", "credential_created_at": "2026-05-24T00:00:00Z", "credential_fingerprint_sha256": "fingerprint", - "prior_active": {"provider": "google", "model": "gemini-flash-latest"}, + "prior_active": {"provider": "google", "model": "gemini-3.5-flash"}, "prior_local_endpoint": None, } _write_config(journal_path, config) @@ -962,7 +1000,7 @@ def test_switch_from_byo_model_to_local_resolves_local_default( json={ "lane": "byo", "provider": "google", - "model": "gemini-pro-latest", + "model": "gemini-3.5-flash", }, ) assert response.status_code == 200 @@ -981,7 +1019,7 @@ def test_switch_to_cloud_without_memory_resolves_provider_default( config = json.loads(config_path.read_text()) config["providers"]["active"] = { "provider": "google", - "model": "gemini-pro-latest", + "model": "gemini-3.5-flash", } config["providers"].pop("byo_models", None) _write_config(journal_path, config) @@ -1090,7 +1128,7 @@ def test_get_providers_scout_google_grandfather_is_zero_touch( } config["providers"]["active"] = { "provider": "google", - "model": "gemini-flash-latest", + "model": "gemini-3.5-flash", } _write_config(journal_path, config) before = config_path.read_bytes() diff --git a/solstone/apps/thinking/tests/test_thinking_byo_flow_js.py b/solstone/apps/thinking/tests/test_thinking_byo_flow_js.py index 557a3ca5c..8e7eef4f0 100644 --- a/solstone/apps/thinking/tests/test_thinking_byo_flow_js.py +++ b/solstone/apps/thinking/tests/test_thinking_byo_flow_js.py @@ -219,6 +219,7 @@ def _node_render_script(body: str) -> str: extract_js_function(source, "setSelectedByoProvider"), extract_js_function(source, "selectedByoProvider"), extract_js_function(source, "changeByoProvider"), + extract_js_function(source, "renderConfigurationGuidance"), extract_js_function(source, "renderByoModelPanel"), _extract_js_function_exact(source, "renderByo"), extract_js_function(source, "bindOpenView"), @@ -654,6 +655,45 @@ console.log('PASS'); ) +def test_byo_model_panel_renders_configuration_guidance() -> None: + _run_node( + _node_render_script( + """ +const state = { + selectedByoProvider: 'google', + byoSelectedModel: '', + byoCustomOpen: false, + byoCustomModel: '', + byoCustomCheckedModel: '', + providers: { + active: {provider: 'google', model: 'gemini-3.5-flash'}, + configuration_guidance: { + id: 'choose_exact_gemini_model', + heading: 'choose an exact Gemini model', + action: {label: 'choose model', href: '/app/thinking/#byoModelPanel'}, + }, + model_tiers: { + google: [ + {tier: 'mid', label: 'Gemini 3.5 Flash', model: 'gemini-3.5-flash'}, + {tier: 'lite', label: 'Gemini 3.1 Flash Lite', model: 'gemini-3.1-flash-lite'}, + ], + }, + }, +}; + +renderByoModelPanel('google', {valid: true, timestamp: '2026-07-13T12:00:00Z'}, text); + +const notice = $('byoConfigurationGuidance'); +assert(notice.hidden === false, 'configuration guidance should be shown'); +assert(collectText(notice).includes('choose an exact Gemini model'), 'heading should render'); +assert(collectText(notice).includes('choose model'), 'action should render'); +assert(notice.children[2].href === '/app/thinking/#byoModelPanel', 'action href should target BYO model panel'); +console.log('PASS'); +""" + ) + ) + + def test_byo_render_shows_model_panel_only_for_valid_non_scout_key() -> None: _run_node( _node_render_script( diff --git a/solstone/apps/thinking/tests/test_validate_model_route.py b/solstone/apps/thinking/tests/test_validate_model_route.py index a65e8c58a..0d3a3506b 100644 --- a/solstone/apps/thinking/tests/test_validate_model_route.py +++ b/solstone/apps/thinking/tests/test_validate_model_route.py @@ -109,18 +109,18 @@ def test_validate_model_route_relays_success_and_does_not_write_config( ) as mock_validate: response = client.post( "/app/thinking/api/validate-model", - json={"provider": "google", "model": " gemini-pro-latest "}, + json={"provider": "google", "model": " gemini-3.5-flash "}, ) assert response.status_code == 200 assert response.get_json() == { "valid": True, "provider": "google", - "model": "gemini-pro-latest", + "model": "gemini-3.5-flash", } mock_validate.assert_called_once_with( "google", - "gemini-pro-latest", + "gemini-3.5-flash", "test-google-key", ) assert config_path.read_bytes() == before diff --git a/solstone/apps/thinking/workspace.html b/solstone/apps/thinking/workspace.html index c32a1a26e..93dd4edfd 100644 --- a/solstone/apps/thinking/workspace.html +++ b/solstone/apps/thinking/workspace.html @@ -775,6 +775,7 @@
+