From 61ef628485122ad95bc60c3232b236af6f8e9581 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Sun, 12 Jul 2026 13:44:55 -0600 Subject: [PATCH] refactor: retire legacy provider selection Use one active brain per lane: the journal never picks, ranks, or switches providers on its own. Resolution is now explicit provider -> key-presence order (google/anthropic/openai) -> local-if-ready -> no-brain, with one default model per provider. The context string no longer influences resolution. Delete automatic provider fallback, model tiers, per-context routing, and per-interface backups. An active-brain failure now fails honestly with its reason code, recording a quota row from both executors first. Leave legacy tier/backup/contexts/models config keys inert and byte-intact on disk; no migration rewrites them. Fix a live local-lane bug where timeline rollup pinned a cloud model and failed on local installs. --- CHANGELOG.md | 12 + solstone/apps/entities/routes.py | 4 +- solstone/apps/entities/talent/detection.md | 1 - .../apps/entities/talent/entities_review.md | 1 - .../apps/entities/talent/entity_observer.md | 1 - solstone/apps/settings/call.py | 4 - solstone/apps/settings/tests/conftest.py | 6 +- .../006_rename_unified_triage_providers.py | 83 +- solstone/apps/thinking/call.py | 24 +- solstone/apps/thinking/routes.py | 181 +- solstone/apps/thinking/static/thinking.js | 10 +- solstone/apps/thinking/tests/conftest.py | 15 +- .../tests/test_local_bootstrap_routes.py | 4 +- .../tests/test_providers_payload_extended.py | 127 +- .../thinking/tests/test_workspace_html.py | 2 - solstone/apps/thinking/workspace.html | 16 - .../002_register_segment_summary_model.py | 84 +- solstone/apps/timeline/maintenance.py | 14 +- solstone/apps/timeline/rollup.py | 7 - .../apps/timeline/talent/segment_summary.md | 2 - .../apps/timeline/talent/segment_summary.py | 5 +- .../test_register_segment_summary_model.py | 98 +- .../apps/timeline/tests/test_rollup_day.py | 16 +- .../apps/timeline/tests/test_rollup_master.py | 16 +- .../tests/test_segment_summary_hooks.py | 12 +- solstone/convey/readiness_snapshot.py | 22 +- solstone/observe/categories/__init__.py | 2 +- solstone/observe/describe.md | 1 - solstone/observe/describe.py | 14 +- solstone/observe/enrich.md | 1 - solstone/observe/extract.md | 1 - solstone/observe/transcribe/gemini.md | 1 - solstone/talent/chat.md | 1 - solstone/talent/conversation.md | 1 - solstone/talent/participation.md | 1 - solstone/talent/pulse.md | 1 - solstone/talent/screen.md | 1 - solstone/talent/sense.md | 1 - solstone/talent/steward.md | 1 - solstone/think/batch.py | 2 +- solstone/think/detect_created.md | 1 - solstone/think/detect_transcript_json.md | 1 - solstone/think/detect_transcript_segment.md | 1 - solstone/think/models.py | 525 +----- solstone/think/planner.md | 1 - solstone/think/providers/shared.py | 12 - solstone/think/providers/state.py | 4 +- solstone/think/providers_cli.py | 209 +-- solstone/think/talent.py | 4 - solstone/think/talent_provenance.py | 2 - solstone/think/talents.py | 240 +-- solstone/think/thinking.py | 3 +- tests/baselines/api/stats/stats.json | 12 - tests/baselines/api/thinking/providers.json | 570 +------ tests/fixtures/journal/config/journal.json | 44 +- tests/test_bad_media_corpus.py | 2 +- tests/test_batch.py | 1 + tests/test_cluster.py | 16 +- tests/test_describe_preflight.py | 12 +- tests/test_describe_promote.py | 6 +- tests/test_entity_observer_context.py | 2 +- tests/test_local.py | 9 +- ...int_006_rename_unified_triage_providers.py | 77 +- tests/test_models.py | 505 ++---- tests/test_models_health.py | 16 +- tests/test_no_implicit_cloud.py | 85 +- tests/test_participation_talent.py | 2 +- tests/test_provider_state.py | 13 +- tests/test_providers_check.py | 645 ++----- tests/test_sense_schema.py | 2 +- tests/test_talent_fallback.py | 1485 ++--------------- tests/test_think_segment_prephase.py | 1 - tests/test_thinking_call_parity.py | 4 +- tests/test_thinking_defaults.py | 20 +- 74 files changed, 876 insertions(+), 4452 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e9da871e..bebc2956f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to solstone (the Python package) will be documented in this Format adapted from [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), aligned with `cmo/brand/changelog-voice.md`. +## [Unreleased] + +### Changed + +- sol now keeps one active brain for each lane instead of switching providers behind the scenes. old tier, context-routing, and backup keys are left in your config untouched, but they no longer steer model choice. +- paths that used to be pinned to lite-class models now use the single model chosen for that lane. for byo-token owners this can raise per-token cost on those paths; local-lane installs are unaffected. +- a legacy config with both `tier` and `model` now honors the model you named instead of ignoring it. + +### Fixed + +- timeline rollups now work on local-lane installs. they used to pin a cloud model internally, which made local setups fail before the rollup could run. + ## [0.8.4] - 2026-07-12 ### Added diff --git a/solstone/apps/entities/routes.py b/solstone/apps/entities/routes.py index 787e3abd4..a41823757 100644 --- a/solstone/apps/entities/routes.py +++ b/solstone/apps/entities/routes.py @@ -1301,10 +1301,8 @@ def generate_description(facet_name: str) -> Any: def _entity_describe_generate_readiness_error() -> Any | None: from solstone.think.models import resolve_provider from solstone.think.providers.state import readiness_for_provider - from solstone.think.talent import key_to_context - context = key_to_context("entities:entity_describe") - provider, model = resolve_provider(context, "generate") + provider, model = resolve_provider("generate") readiness = readiness_for_provider(provider, "generate", model) if readiness.status not in {"blocked", "unhealthy"}: return None diff --git a/solstone/apps/entities/talent/detection.md b/solstone/apps/entities/talent/detection.md index dae33410f..26463d945 100644 --- a/solstone/apps/entities/talent/detection.md +++ b/solstone/apps/entities/talent/detection.md @@ -5,7 +5,6 @@ "color": "#00695c", "schedule": "segment", "priority": 15, - "provider": "google", "thinking_budget": 2048, "max_output_tokens": 1024, "output": "json", diff --git a/solstone/apps/entities/talent/entities_review.md b/solstone/apps/entities/talent/entities_review.md index e6cf13135..32fd12757 100644 --- a/solstone/apps/entities/talent/entities_review.md +++ b/solstone/apps/entities/talent/entities_review.md @@ -1,6 +1,5 @@ { "type": "generate", - "tier": 2, "title": "Entity Reviewer", "description": "Reviews detected entities and promotes recurring ones to attached status", "color": "#00796b", diff --git a/solstone/apps/entities/talent/entity_observer.md b/solstone/apps/entities/talent/entity_observer.md index b14eaff4f..1b6da966e 100644 --- a/solstone/apps/entities/talent/entity_observer.md +++ b/solstone/apps/entities/talent/entity_observer.md @@ -1,6 +1,5 @@ { "type": "generate", - "tier": 2, "title": "Entity Observer", "description": "Extracts durable factoids about attached entities from journal content", diff --git a/solstone/apps/settings/call.py b/solstone/apps/settings/call.py index bd1d13294..c6285f448 100644 --- a/solstone/apps/settings/call.py +++ b/solstone/apps/settings/call.py @@ -338,8 +338,6 @@ def providers_clear_local_endpoint() -> None: @providers_app.command("set-generate") def providers_set_generate( provider: str | None = typer.Option(None, "--provider", help="Primary provider."), - tier: int | None = typer.Option(None, "--tier", help="Tier (1, 2, or 3)."), - backup: str | None = typer.Option(None, "--backup", help="Backup provider."), ) -> None: """Moved to ``sol call thinking providers set-generate``.""" @@ -349,8 +347,6 @@ def providers_set_generate( @providers_app.command("set-cogitate") def providers_set_cogitate( provider: str | None = typer.Option(None, "--provider", help="Primary provider."), - tier: int | None = typer.Option(None, "--tier", help="Tier (1, 2, or 3)."), - backup: str | None = typer.Option(None, "--backup", help="Backup provider."), ) -> None: """Moved to ``sol call thinking providers set-cogitate``.""" diff --git a/solstone/apps/settings/tests/conftest.py b/solstone/apps/settings/tests/conftest.py index 46bd71b69..c8243de05 100644 --- a/solstone/apps/settings/tests/conftest.py +++ b/solstone/apps/settings/tests/conftest.py @@ -47,13 +47,11 @@ def settings_env(tmp_path, monkeypatch): "providers": { "generate": { "provider": "google", - "tier": 2, - "backup": "anthropic", + "model": "gemini-flash-latest", }, "cogitate": { "provider": "openai", - "tier": 2, - "backup": "anthropic", + "model": "gpt-5.4-mini", }, "auth": { "google": "api_key", diff --git a/solstone/apps/sol/maint/006_rename_unified_triage_providers.py b/solstone/apps/sol/maint/006_rename_unified_triage_providers.py index 972101fad..8919d8270 100644 --- a/solstone/apps/sol/maint/006_rename_unified_triage_providers.py +++ b/solstone/apps/sol/maint/006_rename_unified_triage_providers.py @@ -1,26 +1,18 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""Rename legacy unified and triage provider contexts for the chat refactor.""" +"""Retired provider-context rename for the chat refactor.""" from __future__ import annotations import argparse -import json import logging import sys from dataclasses import dataclass from pathlib import Path -from solstone.think.journal_config import write_journal_config -from solstone.think.utils import get_journal, setup_cli - logger = logging.getLogger(__name__) -_UNIFIED_CONTEXT = "talent.system.unified" -_CHAT_CONTEXT = "talent.system.chat" -_TRIAGE_CONTEXT = "talent.system.triage" - @dataclass class MigrationSummary: @@ -32,73 +24,8 @@ class MigrationSummary: def run_migration(journal_path: Path, *, dry_run: bool) -> MigrationSummary: - summary = MigrationSummary() - config_path = journal_path / "config" / "journal.json" - - if not config_path.exists(): - summary.skipped_reason = "no file" - return summary - - try: - raw_bytes = config_path.read_bytes() - except OSError: - logger.exception("Failed to read %s", config_path) - summary.errors += 1 - return summary - - if not raw_bytes.strip(): - summary.skipped_reason = "empty file" - return summary - - try: - raw = json.loads(raw_bytes) - except json.JSONDecodeError: - summary.skipped_reason = "unparseable" - return summary - - if not isinstance(raw, dict): - summary.skipped_reason = "unparseable" - return summary - - providers = raw.get("providers") - if not isinstance(providers, dict): - summary.skipped_reason = "no providers" - return summary - - contexts = providers.get("contexts") - if not isinstance(contexts, dict): - summary.skipped_reason = "no contexts" - return summary - - changed = False - if _UNIFIED_CONTEXT in contexts: - legacy_chat = contexts[_UNIFIED_CONTEXT] - if _CHAT_CONTEXT not in contexts: - contexts[_CHAT_CONTEXT] = legacy_chat - summary.renamed += 1 - else: - summary.preserved += 1 - del contexts[_UNIFIED_CONTEXT] - changed = True - - if _TRIAGE_CONTEXT in contexts: - del contexts[_TRIAGE_CONTEXT] - summary.removed += 1 - changed = True - - if not changed: - return summary - - if dry_run: - return summary - - try: - write_journal_config(raw) - except OSError: - logger.exception("Failed to write %s", config_path) - summary.errors += 1 - - return summary + _ = (journal_path, dry_run) + return MigrationSummary(skipped_reason="retired") def _print_summary(summary: MigrationSummary) -> None: @@ -118,10 +45,10 @@ def main() -> None: action="store_true", help="Preview the provider-context rename without writing files.", ) - args = setup_cli(parser) + args = parser.parse_args() logging.basicConfig(level=logging.INFO, format="%(message)s") - journal_path = Path(get_journal()) + journal_path = Path.cwd() summary = run_migration(journal_path, dry_run=args.dry_run) _print_summary(summary) diff --git a/solstone/apps/thinking/call.py b/solstone/apps/thinking/call.py index fdacb1d90..bec9a1f6b 100644 --- a/solstone/apps/thinking/call.py +++ b/solstone/apps/thinking/call.py @@ -136,11 +136,6 @@ def _validate_provider_or_exit(provider: str, *, cloud_only: bool = False) -> No _exit_with(f"Invalid provider: {provider}. Must be one of: {', '.join(valid)}") -def _validate_tier_or_exit(tier: int | None) -> None: - if tier is not None and tier not in {1, 2, 3}: - _exit_with(f"Invalid tier: {tier}. Must be one of: 1, 2, 3") - - def _get_providers() -> dict[str, Any]: return _request("GET", "/app/thinking/api/providers") @@ -420,20 +415,15 @@ def providers_show( def _set_provider_type( agent_type: str, provider: str | None, - tier: int | None, - backup: str | None, + model: str | None, ) -> dict[str, Any]: if provider is not None: _validate_provider_or_exit(provider) - if backup is not None: - _validate_provider_or_exit(backup) - _validate_tier_or_exit(tier) payload = { key: value for key, value in { "provider": provider, - "tier": tier, - "backup": backup, + "model": model, }.items() if value is not None } @@ -454,24 +444,22 @@ def _set_provider_type( @convey_cli def providers_set_generate( provider: str | None = typer.Option(None, "--provider", help="Primary provider."), - tier: int | None = typer.Option(None, "--tier", help="Tier (1, 2, or 3)."), - backup: str | None = typer.Option(None, "--backup", help="Backup provider."), + model: str | None = typer.Option(None, "--model", help="Model override."), ) -> None: """Set generate provider defaults.""" - _echo_json(_set_provider_type("generate", provider, tier, backup)) + _echo_json(_set_provider_type("generate", provider, model)) @providers_app.command("set-cogitate") @convey_cli def providers_set_cogitate( provider: str | None = typer.Option(None, "--provider", help="Primary provider."), - tier: int | None = typer.Option(None, "--tier", help="Tier (1, 2, or 3)."), - backup: str | None = typer.Option(None, "--backup", help="Backup provider."), + model: str | None = typer.Option(None, "--model", help="Model override."), ) -> None: """Set cogitate provider defaults.""" - _echo_json(_set_provider_type("cogitate", provider, tier, backup)) + _echo_json(_set_provider_type("cogitate", provider, model)) @app.command("set-local-endpoint") diff --git a/solstone/apps/thinking/routes.py b/solstone/apps/thinking/routes.py index f7ead3939..46f5744d9 100644 --- a/solstone/apps/thinking/routes.py +++ b/solstone/apps/thinking/routes.py @@ -48,7 +48,6 @@ from solstone.think.journal_config import ( from solstone.think.models import ( LOCAL_MODEL, NO_BRAIN_PROVIDER, - TYPE_DEFAULTS, resolve_provider, ) from solstone.think.providers import ( @@ -96,7 +95,6 @@ AI_ENV_TO_PROVIDER = { "OPENAI_API_KEY": "openai", } AI_PROVIDERS = frozenset(AI_ENV_TO_PROVIDER.values()) -VALID_TIERS = {1, 2, 3} LANES = {"byo", "confidential", "local"} GENERIC_THINKING_ERROR = ( "something went wrong - try again, and if it persists, check the health dashboard" @@ -335,15 +333,13 @@ def _validate_local_endpoint_url(endpoint_url: str) -> str | Any: def _type_settings(providers_config: dict[str, Any]) -> dict[str, dict[str, Any]]: settings: dict[str, dict[str, Any]] = {} for agent_type in ("generate", "cogitate"): - defaults = TYPE_DEFAULTS[agent_type] type_config = providers_config.get(agent_type, {}) if not isinstance(type_config, dict): type_config = {} - provider, _ = resolve_provider("", agent_type) + provider, model = resolve_provider(agent_type) settings[agent_type] = { "provider": provider, - "tier": type_config.get("tier", defaults["tier"]), - "backup": type_config.get("backup", defaults["backup"]), + "model": type_config.get("model") or model, } return settings @@ -461,35 +457,11 @@ def _compute_ai_key_validation(config: dict[str, Any]) -> dict[str, Any]: def _provider_payload(config: dict[str, Any], local_model_id: str) -> dict[str, Any]: - from solstone.think.models import get_context_registry, resolve_effective_route - from solstone.think.talent import get_talent_configs, key_to_context - providers_config = config.get("providers", {}) if not isinstance(providers_config, dict): providers_config = {} type_settings = _type_settings(providers_config) - contexts = providers_config.get("contexts", {}) - if not isinstance(contexts, dict): - contexts = {} - - context_defaults: dict[str, dict[str, Any]] = {} - for pattern, ctx_config in get_context_registry().items(): - context_defaults[pattern] = { - "tier": ctx_config["tier"], - "label": ctx_config["label"], - "group": ctx_config["group"], - } - if "type" in ctx_config: - context_defaults[pattern]["type"] = ctx_config["type"] - - talent_configs = get_talent_configs(include_disabled=True) - for key, info in talent_configs.items(): - context_key = key_to_context(key) - if context_key in context_defaults: - if "schedule" in info: - context_defaults[context_key]["schedule"] = info["schedule"] - context_defaults[context_key]["disabled"] = info.get("disabled", False) providers_list = get_provider_list() vertex_creds_path = providers_config.get("vertex_credentials") @@ -508,26 +480,6 @@ def _provider_payload(config: dict[str, Any], local_model_id: str) -> dict[str, local_model_id=local_model_id, include_local=True, ) - effective_contexts: dict[str, dict[str, Any]] = {} - for pattern, raw_ctx in contexts.items(): - try: - interface, provider, model = resolve_effective_route(pattern) - raw_provider = ( - raw_ctx.get("provider") if isinstance(raw_ctx, dict) else None - ) - raw_model = raw_ctx.get("model") if isinstance(raw_ctx, dict) else None - effective_contexts[pattern] = { - "interface": interface, - "provider": provider, - "model": model, - "differs_from_raw": ( - (raw_provider is not None and raw_provider != provider) - or (raw_model is not None and raw_model != model) - ), - } - except Exception: - logger.exception("error resolving effective route for context %s", pattern) - effective_contexts[pattern] = {"unavailable": True} return { "providers": providers_list, @@ -539,9 +491,6 @@ def _provider_payload(config: dict[str, Any], local_model_id: str) -> dict[str, "active_lane": _active_lane_payload(type_settings), "generate": type_settings["generate"], "cogitate": type_settings["cogitate"], - "contexts": contexts, - "effective_contexts": effective_contexts, - "context_defaults": context_defaults, "api_keys": _api_key_status(config), "key_validation": _filtered_ai_key_validation(config), "local": local_status, @@ -1069,29 +1018,31 @@ def _apply_type_update( config["providers"][agent_type]["provider"] = provider if "tier" in type_data: - tier = type_data["tier"] - if tier not in VALID_TIERS: + return error_response( + INVALID_CONFIG_VALUE, + detail=f"{agent_type}.tier is retired; set provider/model instead.", + ) + + if "backup" in type_data: + return error_response( + INVALID_CONFIG_VALUE, + detail=f"{agent_type}.backup is retired; automatic provider switching is disabled.", + ) + + if "model" in type_data: + model = type_data["model"] + if not isinstance(model, str) or not model.strip(): return error_response( INVALID_CONFIG_VALUE, - detail=f"Invalid tier: {tier}. Must be 1, 2, or 3.", + detail=f"{agent_type}.model must be a non-empty string.", ) - if old_type.get("tier") != tier: - changed_fields[f"{agent_type}.tier"] = { - "old": old_type.get("tier"), - "new": tier, + model = model.strip() + if old_type.get("model") != model: + changed_fields[f"{agent_type}.model"] = { + "old": old_type.get("model"), + "new": model, } - config["providers"][agent_type]["tier"] = tier - - if "backup" in type_data: - backup = _validate_provider(type_data["backup"], "backup provider") - if not isinstance(backup, str): - return backup - if old_type.get("backup") != backup: - changed_fields[f"{agent_type}.backup"] = { - "old": old_type.get("backup"), - "new": backup, - } - config["providers"][agent_type]["backup"] = backup + config["providers"][agent_type]["model"] = model return None @@ -1150,14 +1101,29 @@ def update_providers() -> Any: config.setdefault("providers", {}) changed_fields: dict[str, Any] = {} + for legacy_key in ("tier", "backup"): + if legacy_key in request_data: + return error_response( + INVALID_CONFIG_VALUE, + detail=( + f"{legacy_key} is retired; configure generate/cogitate " + "provider/model instead." + ), + ) + if "contexts" in request_data: + return error_response( + INVALID_CONFIG_VALUE, + detail=( + "providers.contexts routing is retired; use /api/generators " + "for disabled/extract toggles." + ), + ) + if "lane" in request_data: provider = _lane_provider(request_data) if not isinstance(provider, str): return provider lane_update = {"provider": provider} - for optional in ("tier", "backup"): - if optional in request_data: - lane_update[optional] = request_data[optional] for agent_type in ("generate", "cogitate"): error = _apply_type_update( config, @@ -1185,71 +1151,6 @@ def update_providers() -> Any: if error is not None: return error - if "contexts" in request_data: - contexts_data = request_data["contexts"] - if not isinstance(contexts_data, dict): - return error_response(INVALID_REQUEST_VALUE, detail="contexts") - config["providers"].setdefault("contexts", {}) - old_contexts = old_providers.get("contexts", {}) - for pattern, ctx_config in contexts_data.items(): - old_ctx = old_contexts.get(pattern) - if ctx_config is None: - if pattern in config["providers"]["contexts"]: - changed_fields[f"contexts.{pattern}"] = { - "old": old_ctx, - "new": None, - } - del config["providers"]["contexts"][pattern] - continue - if not isinstance(ctx_config, dict): - return error_response( - INVALID_CONFIG_VALUE, - detail=f"context for {pattern} must be an object or null", - ) - if "provider" in ctx_config: - provider = _validate_provider( - ctx_config["provider"], - f"provider for {pattern}", - ) - if not isinstance(provider, str): - return provider - if "tier" in ctx_config and ctx_config["tier"] not in VALID_TIERS: - return error_response( - INVALID_CONFIG_VALUE, - detail=f"Invalid tier for {pattern}: {ctx_config['tier']}", - ) - if "disabled" in ctx_config and not isinstance( - ctx_config["disabled"], - bool, - ): - return error_response( - INVALID_CONFIG_VALUE, - detail=f"disabled for {pattern} must be a boolean", - ) - if "extract" in ctx_config and not isinstance( - ctx_config["extract"], - bool, - ): - return error_response( - INVALID_CONFIG_VALUE, - detail=f"extract for {pattern} must be a boolean", - ) - if "model" in ctx_config: - model_value = ctx_config["model"] - if not isinstance(model_value, str) or not model_value.strip(): - return error_response( - INVALID_CONFIG_VALUE, - detail=f"model for {pattern} must be a non-empty string", - ) - ctx_config["model"] = model_value.strip() - if ctx_config: - if old_ctx != ctx_config: - changed_fields[f"contexts.{pattern}"] = { - "old": old_ctx, - "new": ctx_config, - } - config["providers"]["contexts"][pattern] = ctx_config - if "google_backend" in request_data: backend = request_data["google_backend"] if backend not in ("auto", "aistudio", "vertex"): diff --git a/solstone/apps/thinking/static/thinking.js b/solstone/apps/thinking/static/thinking.js index 9ecdf9128..050f14c31 100644 --- a/solstone/apps/thinking/static/thinking.js +++ b/solstone/apps/thinking/static/thinking.js @@ -1073,12 +1073,6 @@ function renderAdvanced() { populateProviderSelect($('field-generate-provider'), state.providers.generate?.provider); populateProviderSelect($('field-cogitate-provider'), state.providers.cogitate?.provider); - if ($('field-generate-tier')) { - $('field-generate-tier').value = String(state.providers.generate?.tier || 2); - } - if ($('field-cogitate-tier')) { - $('field-cogitate-tier').value = String(state.providers.cogitate?.tier || 2); - } if ($('field-google-backend')) { $('field-google-backend').value = state.providers.google_backend || 'auto'; } @@ -1749,7 +1743,7 @@ } async function saveAdvanced(agentType, field, value) { - const payload = {[agentType]: {[field]: field === 'tier' ? Number(value) : value}}; + const payload = {[agentType]: {[field]: value}}; state.providers = await api('api/providers', { method: 'PUT', body: JSON.stringify(payload), @@ -1957,8 +1951,6 @@ }); $('field-generate-provider')?.addEventListener('change', (event) => saveAdvanced('generate', 'provider', event.target.value).catch((err) => setMessage('advancedStatus', err.message, 'error'))); $('field-cogitate-provider')?.addEventListener('change', (event) => saveAdvanced('cogitate', 'provider', event.target.value).catch((err) => setMessage('advancedStatus', err.message, 'error'))); - $('field-generate-tier')?.addEventListener('change', (event) => saveAdvanced('generate', 'tier', event.target.value).catch((err) => setMessage('advancedStatus', err.message, 'error'))); - $('field-cogitate-tier')?.addEventListener('change', (event) => saveAdvanced('cogitate', 'tier', event.target.value).catch((err) => setMessage('advancedStatus', err.message, 'error'))); $('field-google-backend')?.addEventListener('change', () => saveGoogleBackend().catch((err) => setMessage('vertexStatus', err.message, 'error'))); $('vertexSave')?.addEventListener('click', () => saveVertexCredentials().catch((err) => setMessage('vertexStatus', err.message, 'error'))); $('vertexClear')?.addEventListener('click', () => clearVertexCredentials().catch((err) => setMessage('vertexStatus', err.message, 'error'))); diff --git a/solstone/apps/thinking/tests/conftest.py b/solstone/apps/thinking/tests/conftest.py index 4e95b1dfe..0eb9ce0d1 100644 --- a/solstone/apps/thinking/tests/conftest.py +++ b/solstone/apps/thinking/tests/conftest.py @@ -63,22 +63,11 @@ def settings_env(tmp_path, monkeypatch): "providers": { "generate": { "provider": "google", - "tier": 2, - "backup": "anthropic", + "model": "gemini-flash-latest", }, "cogitate": { "provider": "openai", - "tier": 2, - "backup": "anthropic", - }, - "contexts": { - "work": { - "provider": "google", - "tier": 2, - } - }, - "models": { - "generate": "gemini-2.5-pro", + "model": "gpt-5.4-mini", }, "auth": { "google": "api_key", diff --git a/solstone/apps/thinking/tests/test_local_bootstrap_routes.py b/solstone/apps/thinking/tests/test_local_bootstrap_routes.py index 433f2f752..c020dbaa1 100644 --- a/solstone/apps/thinking/tests/test_local_bootstrap_routes.py +++ b/solstone/apps/thinking/tests/test_local_bootstrap_routes.py @@ -38,8 +38,8 @@ def _settings_config() -> dict: return { "setup": {"completed_at": "2026-05-09T00:00:00Z"}, "providers": { - "generate": {"provider": "google", "tier": 2, "backup": "anthropic"}, - "cogitate": {"provider": "openai", "tier": 2, "backup": "anthropic"}, + "generate": {"provider": "google", "model": "gemini-flash-latest"}, + "cogitate": {"provider": "openai", "model": "gpt-5.4-mini"}, "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 9a525ecac..073a5c202 100644 --- a/solstone/apps/thinking/tests/test_providers_payload_extended.py +++ b/solstone/apps/thinking/tests/test_providers_payload_extended.py @@ -66,7 +66,7 @@ def _assert_install_status(payload: dict) -> None: def _patch_selected_providers(monkeypatch, *, provider: str = "google") -> None: monkeypatch.setattr( "solstone.think.models.resolve_provider", - lambda _context, _interface: (provider, f"{provider}-model"), + lambda _interface: (provider, f"{provider}-model"), ) @@ -104,7 +104,7 @@ def _assert_ai_readiness_shape(payload: dict) -> None: "recovery_action", "operator_detail", } - assert set(ai_readiness) >= {"summary", "interfaces", "context_routes", "groups"} + assert set(ai_readiness) >= {"summary", "interfaces", "groups"} assert set(ai_readiness["summary"]) == { "status", "severity", @@ -112,11 +112,8 @@ def _assert_ai_readiness_shape(payload: dict) -> None: "blocked_count", } assert set(ai_readiness["interfaces"]) == {"generate", "cogitate"} - assert isinstance(ai_readiness["context_routes"], list) for view in ai_readiness["interfaces"].values(): assert set(view) == expected_view_keys - for view in ai_readiness["context_routes"]: - assert set(view) == expected_view_keys if ai_readiness.get("local") is not None: assert set(ai_readiness["local"]) == expected_view_keys @@ -194,21 +191,44 @@ def test_get_providers_reports_advanced_when_generate_and_cogitate_lanes_split( assert payload["active_lane"]["split"] is True -@pytest.mark.parametrize("model_value", [""]) -def test_context_update_rejects_invalid_model_value( - settings_client_with_journal, model_value -): +def test_provider_update_rejects_context_payload(settings_client_with_journal): client, _journal_path = settings_client_with_journal response = client.put( "/app/thinking/api/providers", - json={"contexts": {"talent.x": {"provider": "local", "model": model_value}}}, + json={"contexts": {"talent.x": {"provider": "local", "model": ""}}}, ) assert response.status_code != 200 payload = response.get_json() assert payload["reason_code"] == "invalid_config_value" - assert "talent.x" in payload["detail"] + assert "providers.contexts routing is retired" in payload["detail"] + + +@pytest.mark.parametrize( + "payload", + [ + {"tier": 2}, + {"backup": "anthropic"}, + {"generate": {"tier": 2}}, + {"cogitate": {"backup": "anthropic"}}, + ], +) +def test_provider_update_rejects_retired_routing_keys( + settings_client_with_journal, + payload, +): + client, journal_path = settings_client_with_journal + config_path = journal_path / "config" / "journal.json" + before = config_path.read_bytes() + + response = client.put("/app/thinking/api/providers", json=payload) + + assert response.status_code == 400 + body = response.get_json() + assert body["reason_code"] == "invalid_config_value" + assert "retired" in body["detail"] + assert config_path.read_bytes() == before def test_scout_enabled_google_provider_derives_byo_with_provenance( @@ -795,91 +815,6 @@ def test_get_providers_ai_readiness_shape(settings_client): assert payload["ai_readiness"]["local"]["provider"] == "local" -def test_providers_payload_includes_effective_contexts(settings_client): - response = settings_client.get("/app/thinking/api/providers") - - assert response.status_code == 200 - payload = response.get_json() - effective_contexts = payload["effective_contexts"] - assert isinstance(effective_contexts, dict) - for pattern in payload["contexts"]: - entry = effective_contexts[pattern] - assert set(entry) == { - "interface", - "provider", - "model", - "differs_from_raw", - } - - -def test_effective_contexts_show_local_resolution_differences( - settings_client_with_journal, -): - client, journal_path = settings_client_with_journal - config = json.loads((journal_path / "config" / "journal.json").read_text()) - config["providers"]["generate"]["provider"] = "local" - config["providers"]["contexts"]["talent.cloud.pin"] = { - "provider": "google", - "model": "gemini-flash-lite-latest", - } - config["providers"]["contexts"]["talent.local.blank"] = { - "provider": "local", - "model": "", - } - _write_config(journal_path, config) - - response = client.get("/app/thinking/api/providers") - - assert response.status_code == 200 - effective_contexts = response.get_json()["effective_contexts"] - cloud_pin = effective_contexts["talent.cloud.pin"] - assert cloud_pin["interface"] == "generate" - assert cloud_pin["provider"] == "local" - assert cloud_pin["model"] == LOCAL_MODEL - assert cloud_pin["differs_from_raw"] is True - blank_pin = effective_contexts["talent.local.blank"] - assert blank_pin["provider"] == "local" - assert blank_pin["model"] - assert blank_pin["model"] != "" - assert blank_pin["differs_from_raw"] is True - - -def test_ai_readiness_context_routes_use_effective_local_route( - settings_client_with_journal, monkeypatch -): - client, journal_path = settings_client_with_journal - config = json.loads((journal_path / "config" / "journal.json").read_text()) - config["providers"]["generate"]["provider"] = "local" - config["providers"]["contexts"]["talent.cloud.pin"] = { - "provider": "google", - "model": "gemini-flash-lite-latest", - } - config["providers"]["contexts"]["talent.local.blank"] = { - "provider": "local", - "model": "", - } - _write_config(journal_path, config) - - def fake_readiness(provider: str, interface: str, model: str): - return ProviderState( - provider=provider, - interface=interface, - status="ready", - model=model, - ) - - monkeypatch.setattr( - "solstone.think.providers.state.readiness_for_provider", - fake_readiness, - ) - - response = client.get("/app/thinking/api/providers") - - assert response.status_code == 200 - context_routes = response.get_json()["ai_readiness"]["context_routes"] - assert {route["provider"] for route in context_routes} == {"local"} - - def test_get_providers_ai_readiness_surfaces_gpu_probe_failed_from_inspect( settings_client, monkeypatch ): diff --git a/solstone/apps/thinking/tests/test_workspace_html.py b/solstone/apps/thinking/tests/test_workspace_html.py index 44a877870..d554fce3c 100644 --- a/solstone/apps/thinking/tests/test_workspace_html.py +++ b/solstone/apps/thinking/tests/test_workspace_html.py @@ -88,8 +88,6 @@ def test_workspace_renders_each_lane(settings_env): for control_id in ( "field-generate-provider", "field-cogitate-provider", - "field-generate-tier", - "field-cogitate-tier", "field-google-backend", "vertexSave", "vertexClear", diff --git a/solstone/apps/thinking/workspace.html b/solstone/apps/thinking/workspace.html index 2303a2b07..fce0723dd 100644 --- a/solstone/apps/thinking/workspace.html +++ b/solstone/apps/thinking/workspace.html @@ -544,22 +544,6 @@ -
- - -
-
- - -
diff --git a/solstone/apps/timeline/maint/002_register_segment_summary_model.py b/solstone/apps/timeline/maint/002_register_segment_summary_model.py index 386bf8a6f..b1dace2f5 100644 --- a/solstone/apps/timeline/maint/002_register_segment_summary_model.py +++ b/solstone/apps/timeline/maint/002_register_segment_summary_model.py @@ -1,25 +1,15 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""Register timeline segment summary provider context.""" +"""Retired provider-context registration for timeline segment summary.""" from __future__ import annotations import argparse -import json -import logging import sys from dataclasses import dataclass from pathlib import Path -from solstone.think.journal_config import write_journal_config -from solstone.think.utils import get_journal, setup_cli - -logger = logging.getLogger(__name__) - -CONTEXT_NAME = "talent.timeline.segment_summary" -EXPECTED_CONTEXT = {"provider": "google", "model": "gemini-flash-lite-latest"} - @dataclass class RegistrationSummary: @@ -32,71 +22,8 @@ class RegistrationSummary: def run_registration( journal_path: Path, *, dry_run: bool = False ) -> RegistrationSummary: - summary = RegistrationSummary() - config_path = journal_path / "config" / "journal.json" - - if config_path.exists(): - try: - raw_bytes = config_path.read_bytes() - except OSError as exc: - logger.warning("Failed to read %s: %s", config_path, exc) - summary.errors += 1 - return summary - - if raw_bytes.strip(): - try: - raw = json.loads(raw_bytes) - except json.JSONDecodeError as exc: - logger.warning("Malformed JSON in %s: %s", config_path, exc) - summary.errors += 1 - return summary - if not isinstance(raw, dict): - logger.warning( - "Malformed journal config in %s: expected object", config_path - ) - summary.errors += 1 - return summary - else: - raw = {} - else: - raw = {} - - providers = raw.setdefault("providers", {}) - if not isinstance(providers, dict): - logger.warning("Preserving divergent providers config in %s", config_path) - summary.warnings += 1 - return summary - - contexts = providers.setdefault("contexts", {}) - if not isinstance(contexts, dict): - logger.warning( - "Preserving divergent providers.contexts config in %s", config_path - ) - summary.warnings += 1 - return summary - - existing = contexts.get(CONTEXT_NAME) - if existing == EXPECTED_CONTEXT: - summary.preserved += 1 - return summary - if existing is not None: - logger.warning("Preserving divergent provider context %s", CONTEXT_NAME) - summary.warnings += 1 - return summary - - contexts[CONTEXT_NAME] = dict(EXPECTED_CONTEXT) - summary.added += 1 - - if dry_run: - return summary - - try: - write_journal_config(raw) - except OSError as exc: - logger.warning("Failed to write %s: %s", config_path, exc) - summary.errors += 1 - - return summary + _ = (journal_path, dry_run) + return RegistrationSummary() def _print_summary(summary: RegistrationSummary) -> None: @@ -105,6 +32,7 @@ def _print_summary(summary: RegistrationSummary) -> None: print(f" preserved: {summary.preserved}") print(f" warnings: {summary.warnings}") print(f" errors: {summary.errors}") + print(" retired: provider context registration is no longer needed") def main() -> None: @@ -114,9 +42,9 @@ def main() -> None: action="store_true", help="Preview provider context registration without writing files.", ) - args = setup_cli(parser) + args = parser.parse_args() - summary = run_registration(Path(get_journal()), dry_run=args.dry_run) + summary = run_registration(Path.cwd(), dry_run=args.dry_run) _print_summary(summary) if summary.errors: sys.exit(1) diff --git a/solstone/apps/timeline/maintenance.py b/solstone/apps/timeline/maintenance.py index 00e3b70df..378cde0d4 100644 --- a/solstone/apps/timeline/maintenance.py +++ b/solstone/apps/timeline/maintenance.py @@ -15,12 +15,12 @@ from datetime import datetime, timedelta from pathlib import Path from solstone.apps.timeline.rollup import ( - MODEL, pick_top_events_async, pick_top_events_batch, ) from solstone.think.journal_io import atomic_replace from solstone.think.maintenance import MaintenanceRoutine +from solstone.think.models import resolve_provider from solstone.think.utils import ( EXIT_EMPTY, get_journal, @@ -34,6 +34,10 @@ SEGMENT_RE = re.compile(r"^(\d{2})(\d{2})(\d{2})_\d{1,6}$") DAY_RE = re.compile(r"^\d{8}$") +def _resolved_generate_model() -> str: + return resolve_provider("generate")[1] + + def _default_day() -> str: return (datetime.now(get_owner_timezone()) - timedelta(days=1)).strftime("%Y%m%d") @@ -233,9 +237,10 @@ async def _rollup_day( day_rationale = day_result["rationale"] t_total = time.time() - t0 + model = _resolved_generate_model() payload = { "day": day, - "model": MODEL, + "model": model, "generated_at": int(time.time()), "segment_count": len(segments), "hour_count": len(hours_out), @@ -352,8 +357,9 @@ async def _rollup_master( print(" [empty] no month candidates found") return 0 + model = _resolved_generate_model() print( - f"rolling up {len(jobs_in)} month(s) with model={MODEL} top={top} jobs={jobs}" + f"rolling up {len(jobs_in)} month(s) with model={model} top={top} jobs={jobs}" ) t0 = time.time() try: @@ -408,7 +414,7 @@ async def _rollup_master( payload = { "generated_at": int(time.time()), - "model": MODEL, + "model": model, "top_n": top, "year_top": year_top, "months": months_out, diff --git a/solstone/apps/timeline/rollup.py b/solstone/apps/timeline/rollup.py index 793dc2ba1..c52e5d07f 100644 --- a/solstone/apps/timeline/rollup.py +++ b/solstone/apps/timeline/rollup.py @@ -7,11 +7,6 @@ from __future__ import annotations import json -from solstone.think.models import GEMINI_FLASH - -# Use the full flash, not lite — qualitative ranking benefits from headroom. -MODEL = GEMINI_FLASH - # Schema: a list of indices into the candidate array. We don't ask the model # to re-emit titles/descriptions because (a) we want lossless preservation of @@ -104,7 +99,6 @@ async def pick_top_events_async( req = batch.create( contents=build_user_prompt(events), context="timeline.scratch.rollup", - model=MODEL, system_instruction=build_system_instruction(scope_label, n), json_output=True, json_schema=build_rollup_schema(n), @@ -177,7 +171,6 @@ async def pick_top_events_batch( req = batch.create( contents=build_user_prompt(events), context="timeline.scratch.rollup", - model=MODEL, system_instruction=build_system_instruction(scope_label, n), json_output=True, json_schema=build_rollup_schema(n), diff --git a/solstone/apps/timeline/talent/segment_summary.md b/solstone/apps/timeline/talent/segment_summary.md index 5a54a2e9d..b455109b2 100644 --- a/solstone/apps/timeline/talent/segment_summary.md +++ b/solstone/apps/timeline/talent/segment_summary.md @@ -1,9 +1,7 @@ { "type": "generate", - "tier": 3, "schedule": "segment", "priority": 41, - "provider": "google", "output": "json", "schema": "segment_summary.schema.json", "hook": {"pre": "timeline:segment_summary", "post": "timeline:segment_summary"}, diff --git a/solstone/apps/timeline/talent/segment_summary.py b/solstone/apps/timeline/talent/segment_summary.py index a11e2d616..be60ad6b4 100644 --- a/solstone/apps/timeline/talent/segment_summary.py +++ b/solstone/apps/timeline/talent/segment_summary.py @@ -11,13 +11,10 @@ import time from pathlib import Path from solstone.think.journal_io import atomic_replace -from solstone.think.models import GEMINI_LITE from solstone.think.utils import day_path, iter_segments, segment_path logger = logging.getLogger(__name__) -MODEL = GEMINI_LITE - SYSTEM_INSTRUCTION = ( "Pick the SINGLE MOST IMPORTANT EVENT from this ~5-minute slice of a " "personal life-journal and name it. The output is one cell in a multi-scale " @@ -201,7 +198,7 @@ def post_process(result: str, context: dict) -> str | None: "title": parsed.get("title", ""), "description": parsed.get("description", ""), "origin": origin_for_segment(seg_dir), - "model": context.get("model", MODEL), + "model": context["model"], "generated_at": int(time.time()), } atomic_replace( diff --git a/solstone/apps/timeline/tests/test_register_segment_summary_model.py b/solstone/apps/timeline/tests/test_register_segment_summary_model.py index a01977626..a70bf2c46 100644 --- a/solstone/apps/timeline/tests/test_register_segment_summary_model.py +++ b/solstone/apps/timeline/tests/test_register_segment_summary_model.py @@ -1,19 +1,14 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -"""Tests for timeline segment-summary provider context registration.""" +"""Tests for retired timeline segment-summary provider context registration.""" from __future__ import annotations import importlib -import json import sys -import pytest - from solstone.apps.timeline.tests.conftest import write_json -from solstone.think.models import resolve_provider -from solstone.think.utils import CorruptConfigError mod = importlib.import_module( "solstone.apps.timeline.maint.002_register_segment_summary_model" @@ -24,79 +19,40 @@ def _journal_config_path(journal): return journal / "config" / "journal.json" -def test_adds_providers_contexts_entry_when_missing(timeline_journal): - write_json(_journal_config_path(timeline_journal), {"providers": {"contexts": {}}}) - - summary = mod.run_registration(timeline_journal) - - data = json.loads(_journal_config_path(timeline_journal).read_text()) - assert summary.added == 1 - assert data["providers"]["contexts"][mod.CONTEXT_NAME] == mod.EXPECTED_CONTEXT - - -def test_idempotent_when_present_and_matches(timeline_journal, monkeypatch): +def test_registration_is_retired_noop(timeline_journal): + path = _journal_config_path(timeline_journal) write_json( - _journal_config_path(timeline_journal), - {"providers": {"contexts": {mod.CONTEXT_NAME: mod.EXPECTED_CONTEXT}}}, - ) - monkeypatch.setattr( - mod, "write_journal_config", lambda *args, **kwargs: pytest.fail("rewrite") - ) - - summary = mod.run_registration(timeline_journal) - - assert summary.preserved == 1 - - -def test_warns_and_preserves_divergent_model(timeline_journal, monkeypatch): - data = { - "providers": { - "contexts": { - mod.CONTEXT_NAME: {"provider": "google", "model": "different-model"} + path, + { + "providers": { + "contexts": { + "talent.timeline.segment_summary": { + "provider": "google", + "model": "legacy-model", + } + } } - } - } - write_json(_journal_config_path(timeline_journal), data) - monkeypatch.setattr( - mod, "write_journal_config", lambda *args, **kwargs: pytest.fail("rewrite") - ) - - summary = mod.run_registration(timeline_journal) - - assert summary.warnings == 1 - assert json.loads(_journal_config_path(timeline_journal).read_text()) == data - - -def test_creates_providers_contexts_when_missing_section(timeline_journal): - write_json(_journal_config_path(timeline_journal), {"identity": {"name": "Test"}}) - - summary = mod.run_registration(timeline_journal) - - data = json.loads(_journal_config_path(timeline_journal).read_text()) - assert summary.added == 1 - assert data["providers"]["contexts"][mod.CONTEXT_NAME] == mod.EXPECTED_CONTEXT - - -def test_local_generate_default_wins_over_registered_cloud_context(timeline_journal): - write_json( - _journal_config_path(timeline_journal), - {"providers": {"generate": {"provider": "local"}}}, + }, ) + before = path.read_bytes() summary = mod.run_registration(timeline_journal) - provider, _ = resolve_provider(mod.CONTEXT_NAME, "generate") - assert summary.added == 1 - assert provider == "local" + assert summary.added == 0 + assert summary.preserved == 0 + assert summary.warnings == 0 + assert summary.errors == 0 + assert path.read_bytes() == before -def test_malformed_json_fails_loud(timeline_journal, monkeypatch): - config_path = _journal_config_path(timeline_journal) - config_path.write_text("{bad", encoding="utf-8") - before = config_path.read_bytes() +def test_registration_main_succeeds_without_reading_malformed_config( + timeline_journal, monkeypatch +): + path = _journal_config_path(timeline_journal) + path.write_text("{bad", encoding="utf-8") + before = path.read_bytes() monkeypatch.setattr(sys, "argv", ["register-segment-summary-model"]) - with pytest.raises(CorruptConfigError): - mod.main() + mod.main() - assert config_path.read_bytes() == before + assert path.read_bytes() == before diff --git a/solstone/apps/timeline/tests/test_rollup_day.py b/solstone/apps/timeline/tests/test_rollup_day.py index c62fc610a..d23d4b76c 100644 --- a/solstone/apps/timeline/tests/test_rollup_day.py +++ b/solstone/apps/timeline/tests/test_rollup_day.py @@ -10,9 +10,11 @@ import json from solstone.apps.timeline.maintenance import _rollup_day, run_rollup_day from solstone.apps.timeline.tests.conftest import write_json -from solstone.think.models import GEMINI_FLASH, GEMINI_LITE, SchemaValidationError +from solstone.think.models import SchemaValidationError DAY = "20260512" +SEGMENT_MODEL = "segment-test-model" +ROLLUP_MODEL = "resolved-rollup-model" def _write_segment(journal, day, segment, title, hour_stream="archon"): @@ -22,7 +24,7 @@ def _write_segment(journal, day, segment, title, hour_stream="archon"): "title": title, "description": f"{title} description.", "origin": f"{day}/{hour_stream}/{segment}", - "model": GEMINI_LITE, + "model": SEGMENT_MODEL, "generated_at": 1770000000, }, ) @@ -85,8 +87,12 @@ def test_rollup_day_dry_run_no_llm_calls(timeline_journal, mock_agenerate): assert mock.call_count == 0 -def test_rollup_day_writes_seed_shape(timeline_journal, mock_agenerate): +def test_rollup_day_writes_seed_shape(timeline_journal, mock_agenerate, monkeypatch): """AC#6.""" + monkeypatch.setattr( + "solstone.apps.timeline.maintenance.resolve_provider", + lambda _interface: ("google", ROLLUP_MODEL), + ) for i in range(5): title = "Café Event" if i == 0 else f"Event {i}" _write_segment(timeline_journal, DAY, f"12000{i}_60", title) @@ -99,12 +105,12 @@ def test_rollup_day_writes_seed_shape(timeline_journal, mock_agenerate): timeline_path = timeline_journal / "chronicle" / DAY / "timeline.json" payload = json.loads(timeline_path.read_text()) assert payload["day"] == DAY - assert payload["model"] == GEMINI_FLASH + assert payload["model"] == ROLLUP_MODEL assert payload["segment_count"] == 5 assert payload["hour_count"] == 1 assert len(payload["day_top"]) == 4 assert payload["hours"]["12"]["rationale"] == "highest consequence" - assert mock.call_args.kwargs["model"] == GEMINI_FLASH + assert "model" not in mock.call_args.kwargs raw = timeline_path.read_bytes() assert b"Caf\xc3\xa9 Event" in raw assert b"\\u00e9" not in raw diff --git a/solstone/apps/timeline/tests/test_rollup_master.py b/solstone/apps/timeline/tests/test_rollup_master.py index 1eaf30852..0db974e5c 100644 --- a/solstone/apps/timeline/tests/test_rollup_master.py +++ b/solstone/apps/timeline/tests/test_rollup_master.py @@ -12,7 +12,9 @@ import pytest from solstone.apps.timeline.maintenance import _rollup_master, run_rollup_master from solstone.apps.timeline.tests.conftest import write_json -from solstone.think.models import GEMINI_FLASH, SchemaValidationError +from solstone.think.models import SchemaValidationError + +ROLLUP_MODEL = "resolved-rollup-model" def _write_day(journal, day, titles): @@ -20,7 +22,7 @@ def _write_day(journal, day, titles): journal / "chronicle" / day / "timeline.json", { "day": day, - "model": GEMINI_FLASH, + "model": "day-rollup-model", "generated_at": 1770000000, "segment_count": len(titles), "hour_count": 1, @@ -104,8 +106,12 @@ def test_rollup_master_empty_input_exits_empty_sentinel(timeline_journal): assert not (timeline_journal / "timeline.json").exists() -def test_rollup_master_writes_seed_shape(timeline_journal, mock_agenerate): +def test_rollup_master_writes_seed_shape(timeline_journal, mock_agenerate, monkeypatch): """AC#9, AC#10.""" + monkeypatch.setattr( + "solstone.apps.timeline.maintenance.resolve_provider", + lambda _interface: ("google", ROLLUP_MODEL), + ) _write_day(timeline_journal, "20260510", ["A", "B", "C"]) _write_day(timeline_journal, "20260511", ["D", "Café E"]) mock = mock_agenerate({"picks": [4, 0, 1, 2], "rationale": "monthly consequence"}) @@ -123,13 +129,13 @@ def test_rollup_master_writes_seed_shape(timeline_journal, mock_agenerate): timeline_path = timeline_journal / "timeline.json" payload = json.loads(timeline_path.read_text()) - assert payload["model"] == GEMINI_FLASH + assert payload["model"] == ROLLUP_MODEL assert payload["top_n"] == 4 assert list(payload["months"]) == ["202605"] assert payload["months"]["202605"]["day_count"] == 2 assert payload["months"]["202605"]["month_top"][0]["title"] == "Café E" assert payload["year_top"][0]["month"] == "202605" - assert mock.call_args.kwargs["model"] == GEMINI_FLASH + assert "model" not in mock.call_args.kwargs raw = timeline_path.read_bytes() assert b"Caf\xc3\xa9 E" in raw assert b"\\u00e9" not in raw diff --git a/solstone/apps/timeline/tests/test_segment_summary_hooks.py b/solstone/apps/timeline/tests/test_segment_summary_hooks.py index 1e00fbd00..daa0205ac 100644 --- a/solstone/apps/timeline/tests/test_segment_summary_hooks.py +++ b/solstone/apps/timeline/tests/test_segment_summary_hooks.py @@ -11,10 +11,10 @@ from pathlib import Path import pytest from solstone.apps.timeline.talent import segment_summary -from solstone.think.models import GEMINI_LITE DAY = "20260512" SEGMENT = "120000_60" +TEST_MODEL = "test-dispatched-model" def _make_activity( @@ -111,7 +111,7 @@ def test_post_process_writes_augmented_timeline_atomically( returned = segment_summary.post_process( json.dumps({"title": "Café Reset", "description": "Restarts café display."}), - {"day": DAY, "segment": SEGMENT, "stream": "archon"}, + {"day": DAY, "segment": SEGMENT, "stream": "archon", "model": TEST_MODEL}, ) assert returned is None @@ -123,7 +123,7 @@ def test_post_process_writes_augmented_timeline_atomically( "title": "Café Reset", "description": "Restarts café display.", "origin": f"{DAY}/archon/{SEGMENT}", - "model": GEMINI_LITE, + "model": TEST_MODEL, "generated_at": 1770000000, } raw = timeline.read_bytes() @@ -139,11 +139,11 @@ def test_post_process_records_literal_model(timeline_journal): segment_summary.post_process( json.dumps({"title": "Short Title", "description": "Brief description here"}), - {"day": DAY, "segment": SEGMENT, "stream": ""}, + {"day": DAY, "segment": SEGMENT, "stream": "", "model": TEST_MODEL}, ) timeline = timeline_journal / "chronicle" / DAY / SEGMENT / "timeline.json" - assert json.loads(timeline.read_text(encoding="utf-8"))["model"] == GEMINI_LITE + assert json.loads(timeline.read_text(encoding="utf-8"))["model"] == TEST_MODEL @pytest.mark.parametrize( @@ -170,7 +170,7 @@ def test_post_process_origin_matches_seed_for_all_4_layouts( segment_summary.post_process( json.dumps({"title": "Short Title", "description": "Brief description here"}), - {"day": DAY, "segment": SEGMENT, "stream": stream}, + {"day": DAY, "segment": SEGMENT, "stream": stream, "model": TEST_MODEL}, ) timeline = timeline_journal / "chronicle" / expected_origin / "timeline.json" diff --git a/solstone/convey/readiness_snapshot.py b/solstone/convey/readiness_snapshot.py index b12869257..c298f1ae8 100644 --- a/solstone/convey/readiness_snapshot.py +++ b/solstone/convey/readiness_snapshot.py @@ -64,15 +64,12 @@ def _build_interface_snapshot() -> dict[str, Any]: interface_views = {} views = [] for interface in ("generate", "cogitate"): - provider, model = models.resolve_provider("", interface) + provider, model = models.resolve_provider(interface) readiness = provider_state.readiness_for_provider(provider, interface, model) view = present_readiness(readiness) interface_views[interface] = view_to_dict(view) views.append(view) - context_route_views = _build_context_route_views() - views.extend(context_route_views) - groups_by_key = {} for view in views: if view.severity not in {"blocker", "attention"}: @@ -95,27 +92,10 @@ def _build_interface_snapshot() -> dict[str, Any]: ), }, "interfaces": interface_views, - "context_routes": [view_to_dict(view) for view in context_route_views], "groups": groups, } -def _build_context_route_views() -> list: - config = models.get_config() - providers = config.get("providers", {}) - contexts = providers.get("contexts", {}) if isinstance(providers, dict) else {} - if not isinstance(contexts, dict): - return [] - views_by_route: dict[tuple[str, str, str], Any] = {} - for context in sorted(contexts): - interface, provider, model = models.resolve_effective_route(context) - key = (provider, model, interface) - if key not in views_by_route: - state = provider_state.readiness_for_provider(provider, interface, model) - views_by_route[key] = present_readiness(state) - return list(views_by_route.values()) - - def highest_severity_group(snapshot: dict[str, Any]) -> dict[str, Any] | None: """Return the snapshot group with the highest presenter severity, or None. diff --git a/solstone/observe/categories/__init__.py b/solstone/observe/categories/__init__.py index 4abf8f814..7e826b19d 100644 --- a/solstone/observe/categories/__init__.py +++ b/solstone/observe/categories/__init__.py @@ -4,7 +4,7 @@ """Category prompts and formatters for screen description. This package contains: -- .md: Prompt template with JSON frontmatter (metadata: description, output format, tier) +- .md: Prompt template with JSON frontmatter (metadata: description, output format) - .py: Optional formatter for rich markdown output Categories with extraction prompts in their .md files are extractable. diff --git a/solstone/observe/describe.md b/solstone/observe/describe.md index 933f69ddc..56a737e53 100644 --- a/solstone/observe/describe.md +++ b/solstone/observe/describe.md @@ -1,6 +1,5 @@ --- context: observe.describe.frame -tier: 3 label: Screen Categorization group: Observe --- diff --git a/solstone/observe/describe.py b/solstone/observe/describe.py index 9a98dc67d..7bb01cf1f 100644 --- a/solstone/observe/describe.py +++ b/solstone/observe/describe.py @@ -144,7 +144,6 @@ def _discover_categories() -> dict[str, dict]: Each category is a .md file with JSON frontmatter containing: - description (required): Single-line description for categorization prompt - output (optional, default: "markdown"): Response format for extraction - - tier (optional, default: 2): Model tier for this category (1=pro, 2=flash, 3=lite) - label (optional): Human-readable name for settings UI - group (optional, default: "Screen Analysis"): Category for grouping in settings UI @@ -177,9 +176,6 @@ def _discover_categories() -> dict[str, dict]: # Apply defaults for observation settings metadata.setdefault("output", "markdown") - # Apply defaults for tier routing - # tier: 1=pro, 2=flash, 3=lite (default: flash) - metadata.setdefault("tier", 2) metadata.setdefault("max_output_tokens", 4096) # label: Human-readable name (default: title-cased category name) metadata.setdefault("label", category.replace("_", " ").title()) @@ -287,7 +283,7 @@ def _dedup_readiness_contexts(contexts: list[str]) -> list[str]: selected: list[str] = [] seen: set[tuple[str, str]] = set() for context in contexts: - key = resolve_provider(context, "generate") + key = resolve_provider("generate") if key in seen: continue seen.add(key) @@ -764,8 +760,7 @@ class VideoProcessor: promoted = True try: - # Resolve model for frame description (tier from describe.md frontmatter) - frame_provider, frame_model = resolve_provider(FRAME_CONTEXT, "generate") + frame_provider, frame_model = resolve_provider("generate") if frame_provider == NO_BRAIN_PROVIDER: logger.info("No thinking engine selected; deferring frame description") return @@ -1092,10 +1087,7 @@ class VideoProcessor: # Determine output format from metadata is_json = cat_meta.get("output") == "json" - # Resolve model for this category context - cat_provider, cat_model = resolve_provider( - cat_meta["context"], "generate" - ) + cat_provider, cat_model = resolve_provider("generate") if cat_provider == NO_BRAIN_PROVIDER: logger.info( "No thinking engine selected; deferring %s extraction", diff --git a/solstone/observe/enrich.md b/solstone/observe/enrich.md index 76d53a2c7..af812e6a9 100644 --- a/solstone/observe/enrich.md +++ b/solstone/observe/enrich.md @@ -1,6 +1,5 @@ --- context: observe.enrich -tier: 2 label: Audio Enrichment group: Observe --- diff --git a/solstone/observe/extract.md b/solstone/observe/extract.md index c0bd0071e..bc6120012 100644 --- a/solstone/observe/extract.md +++ b/solstone/observe/extract.md @@ -1,6 +1,5 @@ --- context: observe.extract.selection -tier: 2 label: Frame Selection group: Observe --- diff --git a/solstone/observe/transcribe/gemini.md b/solstone/observe/transcribe/gemini.md index 42f3d091e..f49887abd 100644 --- a/solstone/observe/transcribe/gemini.md +++ b/solstone/observe/transcribe/gemini.md @@ -1,6 +1,5 @@ --- context: observe.transcribe.gemini -tier: 2 label: Audio Transcription (Gemini) group: Observe --- diff --git a/solstone/talent/chat.md b/solstone/talent/chat.md index 76768bf19..cbca0c7b9 100644 --- a/solstone/talent/chat.md +++ b/solstone/talent/chat.md @@ -2,7 +2,6 @@ "type": "generate", "title": "Chat", "description": "Structured conversational reply planner for the chat backend rewrite", - "tier": 2, "thinking_budget": 4096, "max_output_tokens": 2048, "output": "json", diff --git a/solstone/talent/conversation.md b/solstone/talent/conversation.md index 84a851cdd..e4bc679aa 100644 --- a/solstone/talent/conversation.md +++ b/solstone/talent/conversation.md @@ -6,7 +6,6 @@ "schedule": "activity", "activities": ["meeting", "call", "messaging", "email"], "priority": 20, - "tier": 3, "output": "json", "max_output_tokens": 12288, "schema": "story.schema.json", diff --git a/solstone/talent/participation.md b/solstone/talent/participation.md index aa440b5d2..f5d91ec0a 100644 --- a/solstone/talent/participation.md +++ b/solstone/talent/participation.md @@ -6,7 +6,6 @@ "schedule": "activity", "activities": ["*"], "priority": 10, - "tier": 3, "output": "json", "schema": "participation.schema.json", "max_output_tokens": 12288, diff --git a/solstone/talent/pulse.md b/solstone/talent/pulse.md index fda68607c..e764724f6 100644 --- a/solstone/talent/pulse.md +++ b/solstone/talent/pulse.md @@ -5,7 +5,6 @@ "schedule": "cadence", "cadence_minutes": 5, "priority": 50, - "tier": 3, "hook": {"pre": "pulse", "post": "pulse"}, "output": "json", "schema": "pulse.schema.json", diff --git a/solstone/talent/screen.md b/solstone/talent/screen.md index d783e4f00..9b1ba82fe 100644 --- a/solstone/talent/screen.md +++ b/solstone/talent/screen.md @@ -6,7 +6,6 @@ "color": "#9c27b0", "schedule": "segment", "priority": 10, - "tier": 3, "output": "json", "schema": "screen.schema.json", "max_output_tokens": 12288, diff --git a/solstone/talent/sense.md b/solstone/talent/sense.md index f4f2bae56..21a1b0710 100644 --- a/solstone/talent/sense.md +++ b/solstone/talent/sense.md @@ -6,7 +6,6 @@ "color": "#ff6f00", "schedule": "segment", "priority": 5, - "tier": 3, "output": "json", "schema": "sense.schema.json", "max_output_tokens": 6144, diff --git a/solstone/talent/steward.md b/solstone/talent/steward.md index 39ac41cda..1e6dc2a8f 100644 --- a/solstone/talent/steward.md +++ b/solstone/talent/steward.md @@ -6,7 +6,6 @@ "schedule": "cadence", "cadence_minutes": 30, "priority": 45, - "tier": 3, "hook": {"pre": "steward", "post": "steward"}, "output": "json", "schema": "steward.schema.json", diff --git a/solstone/think/batch.py b/solstone/think/batch.py index a63240e3a..db931f412 100644 --- a/solstone/think/batch.py +++ b/solstone/think/batch.py @@ -316,7 +316,7 @@ class Batch: request.provider = getattr(e, "provider", None) if request.provider is None: try: - request.provider = resolve_provider(request.context, "generate")[0] + request.provider = resolve_provider("generate")[0] except (KeyError, TypeError, ValueError): request.provider = None diff --git a/solstone/think/detect_created.md b/solstone/think/detect_created.md index a4a8cb6fe..7ae13df2a 100644 --- a/solstone/think/detect_created.md +++ b/solstone/think/detect_created.md @@ -1,6 +1,5 @@ --- context: detect.created -tier: 3 label: Date Detection group: Import --- diff --git a/solstone/think/detect_transcript_json.md b/solstone/think/detect_transcript_json.md index d2ec9197a..e41d608a4 100644 --- a/solstone/think/detect_transcript_json.md +++ b/solstone/think/detect_transcript_json.md @@ -1,6 +1,5 @@ --- context: observe.detect.json -tier: 2 label: Normalization group: Import --- diff --git a/solstone/think/detect_transcript_segment.md b/solstone/think/detect_transcript_segment.md index a1a22d208..db6fe21c9 100644 --- a/solstone/think/detect_transcript_segment.md +++ b/solstone/think/detect_transcript_segment.md @@ -1,6 +1,5 @@ --- context: observe.detect.segment -tier: 2 label: Segmentation group: Import --- diff --git a/solstone/think/models.py b/solstone/think/models.py index 6a66b9579..2395c3dfe 100644 --- a/solstone/think/models.py +++ b/solstone/think/models.py @@ -1,7 +1,6 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -import fnmatch import functools import inspect import json @@ -20,14 +19,6 @@ from solstone.think.utils import get_config, get_journal logger = logging.getLogger(__name__) -# --------------------------------------------------------------------------- -# Tier constants -# --------------------------------------------------------------------------- - -TIER_PRO = 1 -TIER_FLASH = 2 -TIER_LITE = 3 - # --------------------------------------------------------------------------- # Model constants # @@ -151,9 +142,6 @@ LOCAL_MODEL = "local/qwen3.5-4b" QWEN_35_9B = "qwen3.5:9b" GEMMA4_26B_A4B_4BIT = "gemma-4-26b-a4b-it-mlx-4bit" -MLX_PRO = QWEN_35_9B -MLX_FLASH = QWEN_35_9B -MLX_LITE = QWEN_35_9B # Per-model request parameter capability overrides. @@ -169,40 +157,14 @@ def model_supports(model: str, param: str) -> bool: return MODEL_CAPABILITIES.get(model, {}).get(param) is not False -# --------------------------------------------------------------------------- -# System defaults: provider -> tier -> model -# --------------------------------------------------------------------------- - -PROVIDER_DEFAULTS: Dict[str, Dict[int, str]] = { - "google": { - TIER_PRO: GEMINI_PRO, - TIER_FLASH: GEMINI_FLASH, - TIER_LITE: GEMINI_LITE, - }, - "openai": { - TIER_PRO: GPT_5, - TIER_FLASH: GPT_5_MINI, - TIER_LITE: GPT_5_NANO, - }, - "anthropic": { - TIER_PRO: CLAUDE_OPUS_4, - TIER_FLASH: CLAUDE_SONNET_4, - TIER_LITE: CLAUDE_HAIKU_4, - }, - "local": { - TIER_PRO: LOCAL_MODEL, - TIER_FLASH: LOCAL_MODEL, - TIER_LITE: LOCAL_MODEL, - }, -} - -TYPE_DEFAULTS: Dict[str, Dict[str, Any]] = { - "generate": {"tier": TIER_FLASH, "backup": "anthropic"}, - "cogitate": {"tier": TIER_FLASH, "backup": "anthropic"}, -} - NO_BRAIN_PROVIDER = "none" IMPLICIT_CLOUD_PROVIDER_ORDER = ("google", "anthropic", "openai") +DEFAULT_MODEL_BY_PROVIDER: dict[str, str] = { + "google": GEMINI_FLASH, + "openai": GPT_5_MINI, + "anthropic": CLAUDE_SONNET_4, + "local": LOCAL_MODEL, +} # --------------------------------------------------------------------------- @@ -333,7 +295,7 @@ def _confidential_attestation_verifier() -> Callable[[dict[str, Any]], None]: # --------------------------------------------------------------------------- # Prompt context discovery # -# Context metadata (tier, label, group) is defined in prompt .md files via +# Context metadata (label, group, type) is defined in prompt .md files via # YAML frontmatter. This eliminates duplication between code and config. # # NAMING CONVENTION: @@ -348,18 +310,17 @@ def _confidential_attestation_verifier() -> Callable[[dict[str, Any]], None]: # # DISCOVERY SOURCES: # 1. Prompt files listed in PROMPT_PATHS (with context in frontmatter) -# 2. Categories from observe/categories/*.md (tier/label/group in frontmatter) +# 2. Categories from observe/categories/*.md (label/group in frontmatter) # 3. Talent configs from talent/*.md and apps/*/talent/*.md # # When adding new contexts: # 1. Create a .md prompt file with YAML frontmatter containing: -# context, tier, label, group +# context, label, group # 2. Add the path to PROMPT_PATHS -# 3. If not listed, context falls back to the type's default tier # --------------------------------------------------------------------------- # Flat list of prompt files that define context metadata in frontmatter. -# Each must have: context, tier, label, group in YAML frontmatter. +# Each must have: context, label, group in YAML frontmatter. PROMPT_PATHS: List[str] = [ "observe/describe.md", "observe/enrich.md", @@ -387,14 +348,13 @@ def _discover_prompt_contexts() -> Dict[str, Dict[str, Any]]: Each file must have YAML frontmatter with: - context: The context string (e.g., "observe.enrich") - - tier: Tier number (1=pro, 2=flash, 3=lite) - label: Human-readable name - group: Settings UI category Returns ------- Dict[str, Dict[str, Any]] - Mapping of context patterns to {tier, label, group} dicts. + Mapping of context patterns to {label, group} dicts. """ contexts = {} base_dir = Path(__file__).parent.parent # Package root @@ -415,7 +375,6 @@ def _discover_prompt_contexts() -> Dict[str, Dict[str, Any]]: continue contexts[context] = { - "tier": meta.get("tier", TIER_FLASH), "label": meta.get("label", context), "group": meta.get("group", "Other"), } @@ -428,13 +387,14 @@ def _discover_prompt_contexts() -> Dict[str, Dict[str, Any]]: def _discover_talent_contexts() -> Dict[str, Dict[str, Any]]: """Discover talent context defaults from talent/*.md config files. - Uses get_talent_configs() from solstone.think.talent to load all talent configurations - and converts them to context patterns with tier/label/group metadata. + Uses get_talent_configs() from solstone.think.talent to load all talent + configurations and converts them to context patterns with label/group/type + metadata. Returns ------- Dict[str, Dict[str, Any]] - Mapping of context patterns to {tier, label, group, type} dicts. + Mapping of context patterns to {label, group, type} dicts. Context patterns are: talent.system.{name} or talent.{app}.{name} """ from solstone.think.talent import get_talent_configs, key_to_context @@ -447,7 +407,6 @@ def _discover_talent_contexts() -> Dict[str, Dict[str, Any]]: for key, config in all_configs.items(): context = key_to_context(key) contexts[context] = { - "tier": config.get("tier", TIER_FLASH), "label": config.get("label", config.get("title", key)), "group": config.get("group", "Think"), "type": config.get("type"), @@ -467,7 +426,7 @@ def _build_context_registry() -> Dict[str, Dict[str, Any]]: Returns ------- Dict[str, Dict[str, Any]] - Complete context registry mapping patterns to {tier, label, group}. + Complete context registry mapping patterns to {label, group, type?}. """ # Start with prompt contexts (from PROMPT_PATHS) registry = _discover_prompt_contexts() @@ -479,7 +438,6 @@ def _build_context_registry() -> Dict[str, Dict[str, Any]]: for category, metadata in CATEGORIES.items(): context = metadata.get("context", f"observe.describe.{category}") registry[context] = { - "tier": metadata.get("tier", TIER_FLASH), "label": metadata.get("label", category.replace("_", " ").title()), "group": metadata.get("group", "Screen Analysis"), } @@ -499,7 +457,7 @@ def get_context_registry() -> Dict[str, Dict[str, Any]]: Returns ------- Dict[str, Dict[str, Any]] - Complete context registry mapping patterns to {tier, label, group}. + Complete context registry mapping patterns to {label, group, type?}. """ global _context_registry if _context_registry is None: @@ -507,54 +465,6 @@ def get_context_registry() -> Dict[str, Dict[str, Any]]: return _context_registry -def _resolve_tier(context: str, agent_type: str) -> int: - """Resolve context to tier number. - - Checks journal config contexts first, then dynamic context registry with glob matching. - - Parameters - ---------- - context - Context string (e.g., "talent.system.default", "observe.describe.frame"). - agent_type - Agent type ("generate" or "cogitate"). - - Returns - ------- - int - Tier number (1=pro, 2=flash, 3=lite). - """ - from solstone.think.utils import get_config - - default_tier = TYPE_DEFAULTS[agent_type]["tier"] - - journal_config = get_config() - providers_config = journal_config.get("providers", {}) - contexts = providers_config.get("contexts", {}) - - # Get dynamic context registry (discovered prompts, categories, talent configs) - registry = get_context_registry() - - # Check journal config contexts first (exact match) - if context in contexts: - return contexts[context].get("tier", default_tier) - - # Check context registry (exact match) - if context in registry: - return registry[context]["tier"] - - # Check glob patterns in both - for pattern, ctx_config in contexts.items(): - if fnmatch.fnmatch(context, pattern): - return ctx_config.get("tier", default_tier) - - for pattern, ctx_default in registry.items(): - if fnmatch.fnmatch(context, pattern): - return ctx_default["tier"] - - return default_tier - - def _resolve_default_provider( providers: dict[str, Any], agent_type: str, @@ -587,253 +497,41 @@ def _resolve_default_provider( return NO_BRAIN_PROVIDER -def _resolve_model(provider: str, tier: int, config_models: Dict[str, Any]) -> str: - """Resolve tier to model string for a given provider. - - Checks config overrides first, then falls back to system defaults. - If requested tier is unavailable, falls back to more capable tiers - (3→2→1, i.e., lite→flash→pro). - - Parameters - ---------- - provider - Provider name ("google", "openai", "anthropic"). - tier - Tier number (1=pro, 2=flash, 3=lite). - config_models - The "models" section from providers config, mapping provider to tier overrides. - - Returns - ------- - str - Model identifier string. - """ - # Check config overrides first - provider_overrides = config_models.get(provider, {}) - - # Try requested tier, then fall back to more capable tiers (lower numbers) - for t in [tier, tier - 1, tier - 2] if tier > 1 else [tier]: - if t < 1: - continue - - # Check config override (tier as string key in JSON) - tier_key = str(t) - if tier_key in provider_overrides: - return provider_overrides[tier_key] - - # Check system defaults - provider_defaults = PROVIDER_DEFAULTS.get(provider, {}) - if t in provider_defaults: - return provider_defaults[t] - +def default_model_for_provider(provider: str) -> str: + """Return the single default model for a provider.""" if provider == NO_BRAIN_PROVIDER: - raise NoBrainConfiguredError() - provider_defaults = PROVIDER_DEFAULTS.get(provider) - if provider_defaults is None: - raise ValueError(f"Unknown provider: {provider!r}") - if TIER_FLASH in provider_defaults: - return provider_defaults[TIER_FLASH] - raise ValueError(f"Unknown provider: {provider!r}") - - -def resolve_model_for_provider( - context: str, provider: str, agent_type: str = "generate" -) -> str: - """Resolve model for a specific provider based on context tier. - - Use this when provider is overridden from the default - resolves the - appropriate model for the given provider at the context's tier. - - Parameters - ---------- - context - Context string (e.g., "talent.system.default"). - provider - Provider name ("google", "openai", "anthropic"). - agent_type - Agent type ("generate" or "cogitate"). - - Returns - ------- - str - Model identifier string for the provider at the context's tier. - """ - from solstone.think.utils import get_config - - tier = _resolve_tier(context, agent_type) - journal_config = get_config() - providers_config = journal_config.get("providers", {}) - config_models = providers_config.get("models", {}) - - return _resolve_model(provider, tier, config_models) - - -def resolve_provider(context: str, agent_type: str) -> tuple[str, str]: - """Resolve context to provider and model based on configuration. - - Matches context against configured contexts using exact match first, - then glob patterns (via fnmatch), falling back to type-specific defaults. - - Provider precedence: - - 0. If ``type_default_is_local(agent_type)``: resolve to local. A cloud - context / frontmatter / request pin may NOT override an explicit local - type default — its cloud model string is neutralized. An explicit local - context pin (provider: local + model) is honored verbatim. (D8 hard - promise; predates this lode and is load-bearing for privacy.) - 1. Explicit ``providers.contexts..provider``. - 2. Explicit ``providers..provider``. - 3. Key-presence fallback: first of google -> anthropic -> openai with - cloud_key_configured(...). This order equals today's TYPE_DEFAULTS - provider -> backup -> remainder, so every existing keyed install resolves - to exactly the provider it does today. That IS the grandfather guarantee. - 4. ``local``, if bundled artifacts are present. - 5. NO_BRAIN_PROVIDER. - - The key-presence order is ``google -> anthropic -> openai`` because that is - exactly today's provider -> backup -> remainder order. This is the - grandfather guarantee: every existing keyed install with no explicit - provider resolves to the same provider it does today. - - Supports both explicit model strings and tier-based routing: - - {"provider": "google", "model": "gemini-flash-latest"} - explicit model - - {"provider": "google", "tier": 2} - tier-based (2=flash) - - {"tier": 1} - tier only, inherits the resolved default provider + return "" + try: + return DEFAULT_MODEL_BY_PROVIDER[provider] + except KeyError as exc: + raise ValueError(f"Unknown provider: {provider!r}") from exc - The "models" section in providers config allows overriding which model - is used for each tier per provider. - Parameters - ---------- - context - Context string (e.g., "observe.describe.frame", "talent.system.meetings"). - agent_type - Agent type ("generate" or "cogitate"). +def resolve_provider(agent_type: str) -> tuple[str, str]: + """Resolve the active provider and model for an interface. - Returns - ------- - tuple[str, str] - (provider_name, model) tuple. Provider is one of "google", "openai", - "anthropic", "local", or NO_BRAIN_PROVIDER. Model is the full model - identifier string, or "" for NO_BRAIN_PROVIDER. + Precedence is explicit per-interface provider, configured cloud-key + presence in the grandfathered google -> anthropic -> openai order, local + runtime readiness, then NO_BRAIN_PROVIDER. Contexts, tiers, backups, and + providers.models overrides are intentionally ignored. """ config = get_config() providers = config.get("providers", {}) if not isinstance(providers, dict): providers = {} - config_models = providers.get("models", {}) - if not isinstance(config_models, dict): - config_models = {} - # Get type-specific defaults from config, falling back to system constants - type_defaults = TYPE_DEFAULTS[agent_type] type_config = providers.get(agent_type, {}) if not isinstance(type_config, dict): type_config = {} - default_provider = _resolve_default_provider(providers, agent_type) - default_tier = type_config.get("tier", type_defaults["tier"]) - - # Handle explicit "model" key in type config (overrides tier-based resolution) - if default_provider == NO_BRAIN_PROVIDER: - default_model = "" - elif "model" in type_config and "tier" not in type_config: - default_model = type_config["model"] - else: - default_model = _resolve_model(default_provider, default_tier, config_models) - - contexts = providers.get("contexts", {}) - if not isinstance(contexts, dict): - contexts = {} - - # Find matching context config - match_config: Optional[Dict[str, Any]] = None - - if context and contexts: - # Check for exact match first - if context in contexts: - match_config = contexts[context] - else: - # Check glob patterns - most specific (longest non-wildcard prefix) wins - matches = [] - for pattern, ctx_config in contexts.items(): - if fnmatch.fnmatch(context, pattern): - specificity = len(pattern.split("*")[0]) - matches.append((specificity, pattern, ctx_config)) - - if matches: - matches.sort(key=lambda x: x[0], reverse=True) - _, _, match_config = matches[0] - - # No context match - check dynamic context registry for this context - if match_config is None: - # Get dynamic context registry (discovered prompts, categories, talent configs) - registry = get_context_registry() - - # Check for matching context default (exact match first, then glob) - context_tier = None - if context: - if context in registry: - context_tier = registry[context]["tier"] - else: - # Check glob patterns - matches = [] - for pattern, ctx_default in registry.items(): - if fnmatch.fnmatch(context, pattern): - specificity = len(pattern.split("*")[0]) - matches.append((specificity, ctx_default["tier"])) - if matches: - matches.sort(key=lambda x: x[0], reverse=True) - context_tier = matches[0][1] - - if context_tier is not None: - if default_provider == NO_BRAIN_PROVIDER: - return (NO_BRAIN_PROVIDER, "") - model = _resolve_model(default_provider, context_tier, config_models) - return (default_provider, model) - - return (default_provider, default_model) - - # Resolve provider (from match or default) - provider = match_config.get("provider", default_provider) - - # Local type-default is a hard promise: a cloud context provider pin cannot - # override it -- its cloud model string is neutralized. An explicit local - # context pin (provider: local + model) is honored verbatim; otherwise only - # the context's tier feeds local model selection. - if type_default_is_local(agent_type, config): - pinned = match_config.get("model") - if ( - match_config.get("provider") == "local" - and isinstance(pinned, str) - and pinned.strip() - ): - return ("local", pinned) - tier = match_config.get("tier", default_tier) - if not isinstance(tier, int) or tier < 1 or tier > 3: - tier = default_tier - return ("local", _resolve_model("local", tier, config_models)) + provider = _resolve_default_provider(providers, agent_type) if provider == NO_BRAIN_PROVIDER: return (NO_BRAIN_PROVIDER, "") - # Resolve model: explicit non-empty model takes precedence over tier - explicit_model = match_config.get("model") + explicit_model = type_config.get("model") if isinstance(explicit_model, str) and explicit_model.strip(): - model = explicit_model - elif "tier" in match_config: - tier = match_config["tier"] - # Validate tier - if not isinstance(tier, int) or tier < 1 or tier > 3: - logging.getLogger(__name__).warning( - "Invalid tier %r in context %r, using default", tier, context - ) - tier = default_tier - model = _resolve_model(provider, tier, config_models) - else: - # No model or tier specified - use default tier - model = _resolve_model(provider, default_tier, config_models) - - return (provider, model) + return (provider, explicit_model.strip()) + return (provider, default_model_for_provider(provider)) def resolve_effective_route(context: str) -> tuple[str, str, str]: @@ -841,7 +539,7 @@ def resolve_effective_route(context: str) -> tuple[str, str, str]: Interface is the talent context's registry ``type`` when it is one of generate/cogitate, else "generate" — never pass any other value to - resolve_provider (TYPE_DEFAULTS is keyed only on generate/cogitate). + resolve_provider. """ registry_entry = get_context_registry().get(context) interface = ( @@ -849,7 +547,7 @@ def resolve_effective_route(context: str) -> tuple[str, str, str]: if registry_entry and registry_entry.get("type") in ("generate", "cogitate") else "generate" ) - provider, model = resolve_provider(context, interface) + provider, model = resolve_provider(interface) return (interface, provider, model) @@ -865,15 +563,6 @@ def is_local_provider_needed(config: dict[str, Any] | None = None) -> bool: if isinstance(type_config, dict) and type_config.get("provider") == "local": return True - contexts = providers.get("contexts", {}) - if not isinstance(contexts, dict): - return False - if any( - isinstance(context_config, dict) and context_config.get("provider") == "local" - for context_config in contexts.values() - ): - return True - from solstone.think.providers.state import local_runtime_ready local_ready = local_runtime_ready() @@ -1452,18 +1141,14 @@ def generate( timeout_s: Optional[float] = None, **kwargs: Any, ) -> str: - """Generate text using the configured provider for the given context. - - Routes the request to the appropriate backend (Google, OpenAI, or Anthropic) - based on the providers configuration in journal.json. + """Generate text using the configured generate provider. Parameters ---------- contents : str or List The content to send to the model. context : str - Context string for routing and token logging (e.g., "talent.system.meetings"). - This is required and determines which provider/model to use. + Context string for token logging and telemetry. temperature : float Temperature for generation (default: 0.3). max_output_tokens : int @@ -1503,7 +1188,7 @@ def generate( # Allow model override via kwargs (used by callers with explicit model selection) model_override = kwargs.pop("model", None) - provider, model = resolve_provider(context, "generate") + provider, model = resolve_provider("generate") if model_override: model = model_override @@ -1553,114 +1238,6 @@ def generate( return result["text"] -# --------------------------------------------------------------------------- -# Provider Health & Fallback Helpers -# --------------------------------------------------------------------------- - - -def get_backup_provider(agent_type: str) -> Optional[str]: - """Get the backup provider for the given agent type. - - Reads from the type-specific section in journal config, falling back - to TYPE_DEFAULTS. - - Returns None if backup would be the same as the primary provider. - """ - type_defaults = TYPE_DEFAULTS[agent_type] - config = get_config() - providers_config = config.get("providers", {}) - if not isinstance(providers_config, dict): - providers_config = {} - type_config = providers_config.get(agent_type, {}) - if not isinstance(type_config, dict): - type_config = {} - primary_provider = _resolve_default_provider(providers_config, agent_type) - backup = type_config.get("backup", type_defaults["backup"]) - if primary_provider in {"local", NO_BRAIN_PROVIDER}: - return None - if backup == primary_provider: - return None - return backup - - -def load_health_status() -> Optional[dict]: - """Load health status from journal/health/talents.json. - - Returns parsed dict or None if file is missing/unreadable. - """ - # Delegating wrapper -> providers.state (see lode contract). - from solstone.think.providers import state - - return state.read_health_status() - - -def is_provider_healthy(provider: str, health_data: Optional[dict]) -> bool: - """Check if a provider is healthy based on health data. - - Returns True (assume healthy) when: - - health_data is None (no data available) - - No results exist for the provider - - Any result for the provider has ok=True - - Returns False only when all results for the provider have ok=False. - """ - # Delegating wrapper -> providers.state (see lode contract). - from solstone.think.providers import state - - return state.is_provider_healthy(provider, health_data) - - -def is_provider_model_interface_healthy( - provider: str, - model: str, - interface: str, - health_data: Optional[dict], -) -> bool: - """Check health for a specific provider/model/interface row.""" - # Delegating wrapper -> providers.state (see lode contract). - from solstone.think.providers import state - - return state.is_provider_model_interface_healthy( - provider, - model, - interface, - health_data, - ) - - -def record_provider_failure( - provider: str, - tier: str, - model: str, - interface: str, - reset_at_ms: int, -) -> None: - """Record a provider/model/interface quota failure in health status.""" - # Delegating wrapper -> providers.state (see lode contract). - from solstone.think.providers import state - - state.record_quota_failure(provider, tier, model, interface, reset_at_ms) - - -def should_recheck_health(health_data: Optional[dict]) -> bool: - """Check if health data should be rechecked. - - Returns False when health_data is None or on parse errors. - """ - # Delegating wrapper -> providers.state (see lode contract). - from solstone.think.providers import state - - return state.should_recheck_health(health_data) - - -def request_health_recheck() -> None: - """Request a health re-check through the supervisor.""" - # Delegating wrapper -> providers.state (see lode contract). - from solstone.think.providers import state - - state.request_recheck() - - def generate_with_result( contents: Union[str, List[Any]], context: str, @@ -1720,11 +1297,11 @@ def generate_with_result( model_override = kwargs.pop("model", None) provider_override = kwargs.pop("provider", None) - provider, model = resolve_provider(context, "generate") + provider, model = resolve_provider("generate") if provider_override: provider = provider_override if not model_override: - model = resolve_model_for_provider(context, provider, "generate") + model = default_model_for_provider(provider) if model_override: model = model_override @@ -1792,11 +1369,11 @@ async def agenerate_with_result( model_override = kwargs.pop("model", None) provider_override = kwargs.pop("provider", None) - provider, model = resolve_provider(context, "generate") + provider, model = resolve_provider("generate") if provider_override: provider = provider_override if not model_override: - model = resolve_model_for_provider(context, provider, "generate") + model = default_model_for_provider(provider) if model_override: model = model_override @@ -1852,18 +1429,14 @@ async def agenerate( timeout_s: Optional[float] = None, **kwargs: Any, ) -> str: - """Async generate text using the configured provider for the given context. - - Routes the request to the appropriate backend (Google, OpenAI, or Anthropic) - based on the providers configuration in journal.json. + """Async generate text using the configured generate provider. Parameters ---------- contents : str or List The content to send to the model. context : str - Context string for routing and token logging (e.g., "talent.system.meetings"). - This is required and determines which provider/model to use. + Context string for token logging and telemetry. temperature : float Temperature for generation (default: 0.3). max_output_tokens : int @@ -1903,7 +1476,7 @@ async def agenerate( # Allow model override via kwargs (used by Batch for explicit model selection) model_override = kwargs.pop("model", None) - provider, model = resolve_provider(context, "generate") + provider, model = resolve_provider("generate") if model_override: model = model_override @@ -1955,7 +1528,7 @@ async def agenerate( __all__ = [ # Provider configuration - "TYPE_DEFAULTS", + "DEFAULT_MODEL_BY_PROVIDER", "NO_BRAIN_PROVIDER", "NoBrainConfiguredError", "AttestationFailedError", @@ -1965,13 +1538,12 @@ __all__ = [ "get_context_registry", # Model constants (used by provider backends for defaults) "GEMINI_FLASH", - "GPT_5", + "GPT_5_MINI", "CLAUDE_SONNET_4", "DEFAULT_PROVIDER_TIMEOUT_S", "QWEN_35_9B", "GEMMA4_26B_A4B_4BIT", "LOCAL_MODEL", - "MLX_FLASH", # Model capability helpers "model_supports", # Unified API @@ -1982,6 +1554,7 @@ __all__ = [ "finish_reason_error", "IncompleteTextError", "ProviderResponseInvalidError", + "default_model_for_provider", "resolve_provider", "resolve_effective_route", "is_local_provider_needed", diff --git a/solstone/think/planner.md b/solstone/think/planner.md index 29bf3c190..57cf90919 100644 --- a/solstone/think/planner.md +++ b/solstone/think/planner.md @@ -1,6 +1,5 @@ --- context: planner.generate -tier: 2 label: Agent Prompt Generation group: Think --- diff --git a/solstone/think/providers/shared.py b/solstone/think/providers/shared.py index 355fe2512..44891e9ab 100644 --- a/solstone/think/providers/shared.py +++ b/solstone/think/providers/shared.py @@ -126,17 +126,6 @@ class TextDeltaEvent(TypedDict, total=False): raw: Optional[list[dict[str, Any]]] # Original provider JSON event(s) -class FallbackEvent(TypedDict, total=False): - """Event emitted when provider fallback occurs.""" - - event: Required[Literal["fallback"]] - ts: Required[int] - original_provider: Required[str] - backup_provider: Required[str] - reason: Required[str] # "preflight" or "on_failure" - error: Optional[str] # Error message for on_failure case - - Event = Union[ ToolStartEvent, ToolEndEvent, @@ -146,7 +135,6 @@ Event = Union[ ThinkingEvent, TextDeltaEvent, TalentUpdatedEvent, - FallbackEvent, ] diff --git a/solstone/think/providers/state.py b/solstone/think/providers/state.py index d5b713112..b47caa918 100644 --- a/solstone/think/providers/state.py +++ b/solstone/think/providers/state.py @@ -159,7 +159,6 @@ def _summarize_health_results(results: list[dict[str, Any]]) -> dict[str, int]: def record_quota_failure( provider: str, - tier: str, model: str, interface: str, reset_at_ms: int, @@ -187,7 +186,6 @@ def record_quota_failure( results = [] failure_row = { "provider": provider, - "tier": tier, "model": model, "interface": interface, "ok": False, @@ -749,7 +747,7 @@ def readiness_for_context(context: str, interface: str) -> ProviderState: """Resolve a context then return passive readiness for its provider.""" from solstone.think.models import resolve_provider - provider, model = resolve_provider(context, interface) + provider, model = resolve_provider(interface) provider_state = readiness_for_provider(provider, interface, model) return replace(provider_state, context=context) diff --git a/solstone/think/providers_cli.py b/solstone/think/providers_cli.py index 73895b3e6..2df5f6a56 100644 --- a/solstone/think/providers_cli.py +++ b/solstone/think/providers_cli.py @@ -41,11 +41,10 @@ def _provider_status(provider_name: str) -> dict[str, object]: def _check_generate( provider_name: str, - tier: int, + model: str, timeout: int, ) -> tuple[str, str, str | None]: """Check generate interface for a provider.""" - from solstone.think.models import PROVIDER_DEFAULTS from solstone.think.providers import PROVIDER_METADATA, get_provider_module env_key = PROVIDER_METADATA[provider_name]["env_key"] @@ -76,7 +75,6 @@ def _check_generate( try: module = get_provider_module(provider_name) - model = PROVIDER_DEFAULTS[provider_name][tier] # Connectivity probe with canned content; deliberately outside the # confidential attestation gate so diagnostics can always run. result = module.run_generate( @@ -97,7 +95,7 @@ def _check_generate( from solstone.think.models import log_token_usage log_token_usage( - model=PROVIDER_DEFAULTS[provider_name][tier], + model=model, usage=usage, context="health.check.generate", type="generate", @@ -111,10 +109,9 @@ def _check_generate( async def _check_cogitate( - provider_name: str, tier: int, timeout: int + provider_name: str, model: str, timeout: int ) -> tuple[str, str, str | None]: """Check cogitate interface for a provider by running a real prompt.""" - from solstone.think.models import PROVIDER_DEFAULTS from solstone.think.providers import PROVIDER_METADATA, get_provider_module env_key = PROVIDER_METADATA[provider_name]["env_key"] @@ -176,7 +173,6 @@ async def _check_cogitate( try: module = get_provider_module(provider_name) - model = PROVIDER_DEFAULTS[provider_name][tier] config = {"prompt": "Say OK", "model": model, "provider": provider_name} # Connectivity probe with canned content; deliberately outside the # confidential attestation gate so diagnostics can always run. @@ -197,47 +193,30 @@ async def _check_cogitate( async def _run_check(args: argparse.Namespace) -> None: """Run connectivity checks against AI providers.""" - from solstone.think.models import PROVIDER_DEFAULTS, TIER_FLASH, TIER_LITE, TIER_PRO + from solstone.think.models import ( + NO_BRAIN_PROVIDER, + default_model_for_provider, + resolve_provider, + ) from solstone.think.providers import PROVIDER_REGISTRY - targeted_pairs = None - if args.targeted and not args.provider and not args.tier: + lock_fd = None + if args.targeted and not args.provider: import fcntl - from solstone.think.models import ( - NO_BRAIN_PROVIDER, - TYPE_DEFAULTS, - get_backup_provider, - resolve_provider, - ) - from solstone.think.utils import get_config - - targeted_pairs = set() - config = get_config() - providers_config = config.get("providers", {}) - if not isinstance(providers_config, dict): - providers_config = {} - for talent_type, defaults in TYPE_DEFAULTS.items(): - type_config = providers_config.get(talent_type, {}) - if not isinstance(type_config, dict): - type_config = {} - provider, _ = resolve_provider("", talent_type) - tier = type_config.get("tier", defaults["tier"]) - if provider != NO_BRAIN_PROVIDER: - targeted_pairs.add((provider, tier)) - backup = get_backup_provider(talent_type) - if backup: - targeted_pairs.add((backup, tier)) - lock_dir = Path(get_journal()) / "health" lock_dir.mkdir(parents=True, exist_ok=True) - lock_fd = open(lock_dir / "recheck.lock", "w") + lock_fd = open(lock_dir / "recheck.lock", "w", encoding="utf-8") try: fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: lock_fd.close() return + if args.model and not args.provider: + print("--model requires --provider", file=sys.stderr) + sys.exit(1) + if args.provider: providers = args.provider for name in providers: @@ -252,13 +231,21 @@ async def _run_check(args: argparse.Namespace) -> None: providers = list(PROVIDER_REGISTRY.keys()) interfaces = [args.interface] if args.interface else ["generate", "cogitate"] - tier_names = {1: "pro", 2: "flash", 3: "lite"} - tiers = [args.tier] if args.tier else [TIER_PRO, TIER_FLASH, TIER_LITE] - provider_width = max(len(n) for n in providers) if providers else 0 - tier_width = max(len(tier_names[t]) for t in tiers) - model_names = {PROVIDER_DEFAULTS[p][t] for p in providers for t in tiers} - model_width = max(len(m) for m in model_names) if model_names else 0 + probes: list[tuple[str, str, str]] = [] + if args.targeted and not args.provider: + for interface_name in interfaces: + provider, model = resolve_provider(interface_name) + if provider != NO_BRAIN_PROVIDER: + probes.append((provider, model, interface_name)) + else: + for provider_name in providers: + model = args.model or default_model_for_provider(provider_name) + for interface_name in interfaces: + probes.append((provider_name, model, interface_name)) + + provider_width = max((len(provider) for provider, _, _ in probes), default=0) + model_width = max((len(model) for _, model, _ in probes), default=0) interface_width = max(len(n) for n in interfaces) if interfaces else 0 total = 0 @@ -266,86 +253,54 @@ async def _run_check(args: argparse.Namespace) -> None: failed = 0 skipped = 0 results: list[dict[str, object]] = [] - cache: dict[tuple[str, str, str], tuple[str, str, str | None, str]] = {} - - for provider_name in providers: - for tier in tiers: - if ( - targeted_pairs is not None - and (provider_name, tier) not in targeted_pairs - ): - continue - model = PROVIDER_DEFAULTS[provider_name][tier] - for interface_name in interfaces: - cache_key = (provider_name, model, interface_name) - if cache_key in cache: - status, message, reason_code, source_tier = cache[cache_key] - elapsed_s = 0.0 - elapsed_s_rounded = 0.0 - reused_from = source_tier - else: - start = time.perf_counter() - if interface_name == "generate": - status, message, reason_code = _check_generate( - provider_name, tier, args.timeout - ) - else: - status, message, reason_code = await _check_cogitate( - provider_name, tier, args.timeout - ) - elapsed_s = time.perf_counter() - start - elapsed_s_rounded = round(elapsed_s, 1) - cache[cache_key] = ( - status, - message, - reason_code, - tier_names[tier], - ) - reused_from = None - - result: dict[str, object] = { - "provider": provider_name, - "tier": tier_names[tier], - "model": model, - "interface": interface_name, - "ok": status != "fail", - "status": status, - "reason_code": reason_code, - "message": str(message), - "elapsed_s": elapsed_s_rounded, - } - if reused_from: - result["reused_from"] = reused_from - results.append(result) - - if not args.json: - if reused_from: - mark = "=" - display_message = f"{message} (={reused_from})" - else: - if status == "ok": - mark = "✓" - elif status == "skip": - mark = "-" - else: - mark = "✗" - display_message = str(message) - print( - f"{mark} " - f"{provider_name:<{provider_width}} " - f"{tier_names[tier]:<{tier_width}} " - f"{model:<{model_width}} " - f"{interface_name:<{interface_width}} " - f"{display_message} ({elapsed_s:.1f}s)" - ) - - total += 1 - if status == "ok": - passed += 1 - elif status == "skip": - skipped += 1 - else: - failed += 1 + + for provider_name, model, interface_name in probes: + start = time.perf_counter() + if interface_name == "generate": + status, message, reason_code = _check_generate( + provider_name, model, args.timeout + ) + else: + status, message, reason_code = await _check_cogitate( + provider_name, model, args.timeout + ) + elapsed_s = time.perf_counter() - start + elapsed_s_rounded = round(elapsed_s, 1) + + result: dict[str, object] = { + "provider": provider_name, + "model": model, + "interface": interface_name, + "ok": status != "fail", + "status": status, + "reason_code": reason_code, + "message": str(message), + "elapsed_s": elapsed_s_rounded, + } + results.append(result) + + if not args.json: + if status == "ok": + mark = "✓" + elif status == "skip": + mark = "-" + else: + mark = "✗" + print( + f"{mark} " + f"{provider_name:<{provider_width}} " + f"{model:<{model_width}} " + f"{interface_name:<{interface_width}} " + f"{message} ({elapsed_s:.1f}s)" + ) + + total += 1 + if status == "ok": + passed += 1 + elif status == "skip": + skipped += 1 + else: + failed += 1 any_failed = any(r["status"] == "fail" for r in results) @@ -373,6 +328,8 @@ async def _run_check(args: argparse.Namespace) -> None: ) else: print(f"{total} checks: {passed} passed, {skipped} skipped, {failed} failed") + if lock_fd is not None: + lock_fd.close() sys.exit(1 if any_failed else 0) @@ -401,11 +358,9 @@ async def main_async() -> None: help="Timeout in seconds for generate checks (default: 30)", ) check_parser.add_argument( - "--tier", - type=int, - choices=[1, 2, 3], + "--model", default=None, - help="Tier to check (1=pro, 2=flash, 3=lite; default: all)", + help="Model to check with --provider (default: provider default model)", ) check_parser.add_argument( "--json", action="store_true", help="Output results as JSON" @@ -413,7 +368,7 @@ async def main_async() -> None: check_parser.add_argument( "--targeted", action="store_true", - help="Only check configured provider+tier pairs (used by automated rechecks)", + help="Only check configured active routes (used by automated rechecks)", ) args = setup_cli(parser) diff --git a/solstone/think/talent.py b/solstone/think/talent.py index 97a0d3ccb..e64487567 100644 --- a/solstone/think/talent.py +++ b/solstone/think/talent.py @@ -334,10 +334,6 @@ def get_talent_configs( info["disabled"] = override["disabled"] if "extract" in override: info["extract"] = override["extract"] - if "tier" in override: - info["tier"] = override["tier"] - if "provider" in override: - info["provider"] = override["provider"] # Validate: scheduled prompts must have explicit priority for key, info in configs.items(): diff --git a/solstone/think/talent_provenance.py b/solstone/think/talent_provenance.py index 329146cae..24bbd10a5 100644 --- a/solstone/think/talent_provenance.py +++ b/solstone/think/talent_provenance.py @@ -109,7 +109,6 @@ def write_provenance( output_size: int, provider: str | None, model: str | None, - fallback_from: str | None, generation_params: dict[str, Any], completed_at_ms: int, use_id: str | None, @@ -124,7 +123,6 @@ def write_provenance( "output_size": output_size, "provider": provider, "model": model, - "fallback_from": fallback_from, "generation_params": generation_params, "completed_at_ms": completed_at_ms, "use_id": use_id, diff --git a/solstone/think/talents.py b/solstone/think/talents.py index 0bbc74732..41873f0f8 100644 --- a/solstone/think/talents.py +++ b/solstone/think/talents.py @@ -553,12 +553,8 @@ def prepare_config(request: dict) -> dict: """ from solstone.think.models import ( NO_BRAIN_PROVIDER, - TIER_FLASH, - TIER_LITE, - TIER_PRO, NoBrainConfiguredError, - _resolve_tier, - resolve_model_for_provider, + default_model_for_provider, resolve_provider, type_default_is_local, ) @@ -655,15 +651,13 @@ def prepare_config(request: dict) -> dict: # Resolve provider and model from context context = key_to_context(name) talent_type = config["type"] - default_provider, default_model = resolve_provider(context, talent_type) + default_provider, default_model = resolve_provider(talent_type) if type_default_is_local(talent_type): # Local type-default is a hard runtime promise: a frontmatter/request - # cloud provider pin may not force a local-lane talent onto cloud. An - # explicit local pin (provider: local + model) at the talent/request - # level is honored verbatim; otherwise the local model comes from - # resolve_provider (which already carries any context-level local pin). - provider = default_provider # "local" (resolve_provider already forced it) + # cloud provider pin may not force a local-lane talent onto cloud. + # An explicit local request pin with a model is honored verbatim. + provider = default_provider if config.get("provider") == "local" and config.get("model"): model = config["model"] else: @@ -673,7 +667,7 @@ def prepare_config(request: dict) -> dict: model = config.get("model") if not model: if provider != default_provider: - model = resolve_model_for_provider(context, provider, talent_type) + model = default_model_for_provider(provider) else: model = default_model @@ -682,37 +676,6 @@ def prepare_config(request: dict) -> dict: config["context"] = context if provider == NO_BRAIN_PROVIDER: raise NoBrainConfiguredError() - tier = _resolve_tier(context, talent_type) - config["tier"] = { - TIER_PRO: "pro", - TIER_FLASH: "flash", - TIER_LITE: "lite", - }.get(tier, str(tier)) - - # --- Provider fallback: preflight swap if primary is unhealthy --- - from solstone.think.models import ( - get_backup_provider, - is_provider_model_interface_healthy, - load_health_status, - should_recheck_health, - ) - from solstone.think.providers import PROVIDER_METADATA - - health_data = load_health_status() - config["health_stale"] = should_recheck_health(health_data) - - if provider != "local" and not is_provider_model_interface_healthy( - provider, model, talent_type, health_data - ): - backup = get_backup_provider(talent_type) - if backup and backup != provider: - env_key = PROVIDER_METADATA.get(backup, {}).get("env_key") - if not env_key or os.getenv(env_key): - config["fallback_from"] = provider - config["provider"] = backup - config["model"] = resolve_model_for_provider( - context, backup, talent_type - ) # Check if disabled if config.get("disabled"): @@ -1031,7 +994,6 @@ def _runtime_identity(config: dict, runtime_json_schema: Any) -> dict[str, Any]: "sources": _normalized_sources(config), "provider": config.get("provider"), "model": config.get("model"), - "fallback_from": config.get("fallback_from"), "generation_params": _generation_params(config), "runtime": { "day": config.get("day"), @@ -1221,7 +1183,6 @@ def _write_clean_provenance( output_size=output_size, provider=config.get("provider"), model=config.get("model"), - fallback_from=config.get("fallback_from"), generation_params=_generation_params(config), completed_at_ms=completed_at_ms, use_id=config.get("use_id"), @@ -1310,41 +1271,9 @@ def _emit_terminal_hook_error( from solstone.think.models import ( NO_BRAIN_PROVIDER, - AttestationNotVerifiedError, - NoBrainConfiguredError, _raise_if_confidential_unverified, ) -_NON_RETRYABLE_ERRORS = ( - TalentHookError, - # No implicit cloud fallback: a journal with no thinking engine selected - # must stop here rather than retrying on any cloud provider. - NoBrainConfiguredError, - AttestationNotVerifiedError, - ValueError, - json.JSONDecodeError, - KeyError, - TypeError, - AttributeError, - FileNotFoundError, - PermissionError, - NotImplementedError, - QuotaExhaustedError, -) - - -def _is_retryable_error(exc: Exception) -> bool: - """Check if an exception is likely a provider error worth retrying. - - Returns False for local/code errors (ValueError, KeyError, etc.). - Returns True for everything else (SDK connection, timeout, server errors). - """ - return not isinstance(exc, _NON_RETRYABLE_ERRORS) - - -def _should_fallback(exc: Exception) -> bool: - return _is_retryable_error(exc) or isinstance(exc, QuotaExhaustedError) - def _classify_degraded(usage: dict | None, config: dict) -> dict | None: """Flag an opted-in talent run whose model produced near-zero output. @@ -1365,6 +1294,20 @@ def _classify_degraded(usage: dict | None, config: dict) -> dict | None: return None +def _record_quota_failure(config: dict, exc: QuotaExhaustedError) -> int: + """Persist a quota failure row and return its reset time.""" + reset_at_ms = now_ms() + (exc.retry_delay_ms or 0) + from solstone.think.providers import state + + state.record_quota_failure( + str(config.get("provider") or ""), + str(config.get("model") or ""), + str(config.get("type") or ""), + reset_at_ms, + ) + return reset_at_ms + + async def _execute_with_tools( config: dict, emit_event: Callable[[dict], None], @@ -1454,12 +1397,8 @@ async def _execute_with_tools( _emit_terminal_hook_error(config, emit_event, exc) return except Exception as exc: - if provider in {"local", NO_BRAIN_PROVIDER}: - raise - if config.get("fallback_from") or not _should_fallback(exc): - raise if isinstance(exc, QuotaExhaustedError): - reset_at_ms = now_ms() + (exc.retry_delay_ms or 0) + reset_at_ms = _record_quota_failure(config, exc) emit_event( { "event": "error", @@ -1472,73 +1411,7 @@ async def _execute_with_tools( "terminal": False, } ) - from solstone.think.models import record_provider_failure - - record_provider_failure( - provider, - config["tier"], - config["model"], - config["type"], - reset_at_ms, - ) - from solstone.think.models import ( - get_backup_provider, - resolve_model_for_provider, - ) - from solstone.think.providers import PROVIDER_METADATA - - backup = get_backup_provider("cogitate") - if not backup or backup == provider: - raise - env_key = PROVIDER_METADATA.get(backup, {}).get("env_key") - if env_key and not os.getenv(env_key): - raise - - context = config.get("context") - if not context: - from solstone.think.talent import key_to_context - - context = key_to_context(config["name"]) - backup_model = resolve_model_for_provider(context, backup, "cogitate") - - emit_event( - { - "event": "fallback", - "ts": now_ms(), - "original_provider": provider, - "backup_provider": backup, - "reason": "on_failure", - "error": str(exc), - } - ) - - config["fallback_from"] = provider - config["provider"] = backup - config["model"] = backup_model - - backup_mod = get_provider_module(backup) - - # Suppress error events from backup provider — if backup also fails - # we report the original error, not the backup's error. - def backup_emit(data: Event) -> None: - if data.get("event") == "error": - return - talent_emit_event(data) - - try: - await backup_mod.run_cogitate(config=config, on_event=backup_emit) - except Exception: - # Ensure the original error is reported by the caller even if the - # primary provider already emitted its own error event (_evented). - if hasattr(exc, "_evented"): - delattr(exc, "_evented") - raise exc - finally: - if config.get("health_stale"): - from solstone.think.models import request_health_recheck - - request_health_recheck() - config["health_stale"] = False + raise async def _execute_generate( @@ -1592,6 +1465,8 @@ async def _execute_generate( ) except Exception as exc: provider = config.get("provider", "google") + if isinstance(exc, QuotaExhaustedError): + _record_quota_failure(config, exc) if provider == NO_BRAIN_PROVIDER: raise if provider == "local": @@ -1641,60 +1516,7 @@ async def _execute_generate( retry_exc.retries = retries raise else: - if config.get("fallback_from") or not _should_fallback(exc): - raise - from solstone.think.models import ( - get_backup_provider, - resolve_model_for_provider, - ) - from solstone.think.providers import PROVIDER_METADATA - - backup = get_backup_provider("generate") - if not backup or backup == provider: - raise - env_key = PROVIDER_METADATA.get(backup, {}).get("env_key") - if env_key and not os.getenv(env_key): - raise - - backup_model = resolve_model_for_provider(context, backup, "generate") - - emit_event( - { - "event": "fallback", - "ts": now_ms(), - "original_provider": provider, - "backup_provider": backup, - "reason": "on_failure", - "error": str(exc), - } - ) - - config["fallback_from"] = provider - config["provider"] = backup - config["model"] = backup_model - - try: - gen_result = generate_with_result( - contents=contents, - context=context, - temperature=temperature, - max_output_tokens=max_output_tokens, - thinking_budget=thinking_budget, - system_instruction=system_instruction, - json_output=is_json_output, - json_schema=runtime_json_schema, - timeout_s=timeout_s, - provider=backup, - model=backup_model, - ) - except Exception: - raise exc - finally: - if config.get("health_stale"): - from solstone.think.models import request_health_recheck - - request_health_recheck() - config["health_stale"] = False + raise raw_result = gen_result["text"] if output_format == "md": @@ -1844,18 +1666,6 @@ async def _run_talent( start_event["chat_id"] = config["chat_id"] emit_event(start_event) - # Emit preflight fallback event if provider was swapped - if config.get("fallback_from"): - emit_event( - { - "event": "fallback", - "ts": now_ms(), - "original_provider": config["fallback_from"], - "backup_provider": config["provider"], - "reason": "preflight", - } - ) - # Handle skip conditions skip_reason = config.get("skip_reason") if skip_reason: diff --git a/solstone/think/thinking.py b/solstone/think/thinking.py index 663fcb488..71bd53514 100644 --- a/solstone/think/thinking.py +++ b/solstone/think/thinking.py @@ -24,7 +24,6 @@ from datetime import date, datetime, timedelta, timezone from pathlib import Path from typing import Any -from solstone.observe.describe import FRAME_CONTEXT from solstone.think import admission from solstone.think.activities import ( append_activity_record, @@ -510,7 +509,7 @@ def _segment_work_uses_local() -> bool: def _describe_uses_local() -> bool: """Return True when screen-describe resolves to the local provider.""" - provider, _ = resolve_provider(FRAME_CONTEXT, "generate") + provider, _ = resolve_provider("generate") return provider == "local" diff --git a/tests/baselines/api/stats/stats.json b/tests/baselines/api/stats/stats.json index e5ce75eeb..de5499f37 100644 --- a/tests/baselines/api/stats/stats.json +++ b/tests/baselines/api/stats/stats.json @@ -13,7 +13,6 @@ "schema": "chat.schema.json", "source": "system", "thinking_budget": 4096, - "tier": 2, "title": "Chat", "type": "generate" }, @@ -43,7 +42,6 @@ "schedule": "activity", "schema": "story.schema.json", "source": "system", - "tier": 3, "title": "Conversation Story", "type": "generate" }, @@ -113,7 +111,6 @@ "output": "json", "path": "/solstone/apps/entities/talent/detection.md", "priority": 15, - "provider": "google", "schedule": "segment", "schema": "detection.schema.json", "source": "app", @@ -144,7 +141,6 @@ "schema": "entities_review.schema.json", "source": "app", "thinking_budget": 2048, - "tier": 2, "title": "Entity Reviewer", "type": "generate" }, @@ -186,7 +182,6 @@ "schema": "entity_observer.schema.json", "source": "app", "thinking_budget": 2048, - "tier": 2, "title": "Entity Observer", "type": "generate" }, @@ -282,7 +277,6 @@ "schedule": "activity", "schema": "participation.schema.json", "source": "system", - "tier": 3, "timeout_s": 480, "title": "Participation", "type": "generate" @@ -310,7 +304,6 @@ "schema": "pulse.schema.json", "source": "system", "thinking_budget": 1024, - "tier": 3, "title": "Pulse", "type": "generate" }, @@ -353,7 +346,6 @@ "schedule": "segment", "schema": "screen.schema.json", "source": "system", - "tier": 3, "title": "Screen Record", "type": "generate" }, @@ -373,7 +365,6 @@ "schedule": "segment", "schema": "sense.schema.json", "source": "system", - "tier": 3, "timeout_s": 480, "title": "Segment Sense", "type": "generate" @@ -424,7 +415,6 @@ "schema": "steward.schema.json", "source": "system", "thinking_budget": 1024, - "tier": 3, "title": "Steward", "type": "generate" }, @@ -444,11 +434,9 @@ "output": "json", "path": "/solstone/apps/timeline/talent/segment_summary.md", "priority": 41, - "provider": "google", "schedule": "segment", "schema": "segment_summary.schema.json", "source": "app", - "tier": 3, "type": "generate" }, "work": { diff --git a/tests/baselines/api/thinking/providers.json b/tests/baselines/api/thinking/providers.json index fb8a51305..fd4ccab71 100644 --- a/tests/baselines/api/thinking/providers.json +++ b/tests/baselines/api/thinking/providers.json @@ -17,153 +17,7 @@ "split": false }, "ai_readiness": { - "context_routes": [ - { - "context": null, - "detail": "Finish local model setup, then try the request again.", - "interface": "generate", - "model": "local/qwen3.5-4b", - "operator_detail": "reason_code=local_model_missing; provider=local; status=blocked; model=local/qwen3.5-4b; interface=generate", - "provider": "local", - "reason_code": "local_model_missing", - "recovery_action": { - "href": "/app/thinking/#local-setup", - "label": "Open Local Model Setup" - }, - "semantic_key": "local_model_missing:local:local/qwen3.5-4b", - "severity": "blocker", - "status": "blocked", - "summary": "local model setup is not finished", - "work_key": null - }, - { - "context": null, - "detail": "Open provider setup and add credentials, then try again.", - "interface": "generate", - "model": "claude-sonnet-4-5", - "operator_detail": "reason_code=provider_key_missing; provider=anthropic; status=blocked; model=claude-sonnet-4-5; interface=generate; message=ANTHROPIC_API_KEY not set", - "provider": "anthropic", - "reason_code": "provider_key_missing", - "recovery_action": { - "href": "/app/thinking/#main", - "label": "Open Settings" - }, - "semantic_key": "provider_key_missing:anthropic:", - "severity": "blocker", - "status": "blocked", - "summary": "Anthropic needs credentials before it can read your screen descriptions", - "work_key": null - }, - { - "context": null, - "detail": "Open provider setup and add credentials, then try again.", - "interface": "generate", - "model": "gemini-custom-flash-test", - "operator_detail": "reason_code=provider_key_missing; provider=google; status=blocked; model=gemini-custom-flash-test; interface=generate; message=GOOGLE_API_KEY not set", - "provider": "google", - "reason_code": "provider_key_missing", - "recovery_action": { - "href": "/app/thinking/#main", - "label": "Open Settings" - }, - "semantic_key": "provider_key_missing:google:", - "severity": "blocker", - "status": "blocked", - "summary": "Gemini needs credentials before it can read your screen descriptions", - "work_key": null - }, - { - "context": null, - "detail": "Open provider setup and add credentials, then try again.", - "interface": "generate", - "model": "gemini-flash-lite-latest", - "operator_detail": "reason_code=provider_key_missing; provider=google; status=blocked; model=gemini-flash-lite-latest; interface=generate; message=GOOGLE_API_KEY not set", - "provider": "google", - "reason_code": "provider_key_missing", - "recovery_action": { - "href": "/app/thinking/#main", - "label": "Open Settings" - }, - "semantic_key": "provider_key_missing:google:", - "severity": "blocker", - "status": "blocked", - "summary": "Gemini needs credentials before it can read your screen descriptions", - "work_key": null - }, - { - "context": null, - "detail": "Open provider setup and add credentials, then try again.", - "interface": "generate", - "model": "gemini-pro-latest", - "operator_detail": "reason_code=provider_key_missing; provider=google; status=blocked; model=gemini-pro-latest; interface=generate; message=GOOGLE_API_KEY not set", - "provider": "google", - "reason_code": "provider_key_missing", - "recovery_action": { - "href": "/app/thinking/#main", - "label": "Open Settings" - }, - "semantic_key": "provider_key_missing:google:", - "severity": "blocker", - "status": "blocked", - "summary": "Gemini needs credentials before it can read your screen descriptions", - "work_key": null - }, - { - "context": null, - "detail": "Open provider setup and add credentials, then try again.", - "interface": "generate", - "model": "gpt-5-mini", - "operator_detail": "reason_code=provider_key_missing; provider=openai; status=blocked; model=gpt-5-mini; interface=generate; message=OPENAI_API_KEY not set", - "provider": "openai", - "reason_code": "provider_key_missing", - "recovery_action": { - "href": "/app/thinking/#main", - "label": "Open Settings" - }, - "semantic_key": "provider_key_missing:openai:", - "severity": "blocker", - "status": "blocked", - "summary": "OpenAI needs credentials before it can read your screen descriptions", - "work_key": null - }, - { - "context": null, - "detail": "Open provider setup and add credentials, then try again.", - "interface": "generate", - "model": "gpt-5.4-mini", - "operator_detail": "reason_code=provider_key_missing; provider=openai; status=blocked; model=gpt-5.4-mini; interface=generate; message=OPENAI_API_KEY not set", - "provider": "openai", - "reason_code": "provider_key_missing", - "recovery_action": { - "href": "/app/thinking/#main", - "label": "Open Settings" - }, - "semantic_key": "provider_key_missing:openai:", - "severity": "blocker", - "status": "blocked", - "summary": "OpenAI needs credentials before it can read your screen descriptions", - "work_key": null - } - ], "groups": [ - { - "context": null, - "detail": "Finish local model setup, then try the request again.", - "interface": "generate", - "model": "local/qwen3.5-4b", - "operator_detail": "reason_code=local_model_missing; provider=local; status=blocked; model=local/qwen3.5-4b; interface=generate", - "provider": "local", - "reason_code": "local_model_missing", - "recovery_action": { - "href": "/app/thinking/#local-setup", - "label": "Open Local Model Setup" - }, - "semantic_key": "local_model_missing:local:local/qwen3.5-4b", - "severity": "blocker", - "status": "blocked", - "summary": "local model setup is not finished", - "work_key": null - }, { "context": null, "detail": "Open provider setup and add credentials, then try again.", @@ -186,26 +40,8 @@ "context": null, "detail": "Open provider setup and add credentials, then try again.", "interface": "generate", - "model": "claude-sonnet-4-5", - "operator_detail": "reason_code=provider_key_missing; provider=anthropic; status=blocked; model=claude-sonnet-4-5; interface=generate; message=ANTHROPIC_API_KEY not set", - "provider": "anthropic", - "reason_code": "provider_key_missing", - "recovery_action": { - "href": "/app/thinking/#main", - "label": "Open Settings" - }, - "semantic_key": "provider_key_missing:anthropic:", - "severity": "blocker", - "status": "blocked", - "summary": "Anthropic needs credentials before it can read your screen descriptions", - "work_key": null - }, - { - "context": null, - "detail": "Open provider setup and add credentials, then try again.", - "interface": "generate", - "model": "gemini-custom-flash-test", - "operator_detail": "reason_code=provider_key_missing; provider=google; status=blocked; model=gemini-custom-flash-test; interface=generate; message=GOOGLE_API_KEY not set", + "model": "gemini-flash-latest", + "operator_detail": "reason_code=provider_key_missing; provider=google; status=blocked; model=gemini-flash-latest; interface=generate; message=GOOGLE_API_KEY not set", "provider": "google", "reason_code": "provider_key_missing", "recovery_action": { @@ -242,8 +78,8 @@ "context": null, "detail": "Open provider setup and add credentials, then try again.", "interface": "generate", - "model": "gemini-custom-flash-test", - "operator_detail": "reason_code=provider_key_missing; provider=google; status=blocked; model=gemini-custom-flash-test; interface=generate; message=GOOGLE_API_KEY not set", + "model": "gemini-flash-latest", + "operator_detail": "reason_code=provider_key_missing; provider=google; status=blocked; model=gemini-flash-latest; interface=generate; message=GOOGLE_API_KEY not set", "provider": "google", "reason_code": "provider_key_missing", "recovery_action": { @@ -276,8 +112,8 @@ "work_key": null }, "summary": { - "active_groups": 4, - "blocked_count": 4, + "active_groups": 2, + "blocked_count": 2, "severity": "blocker", "status": "blocked" } @@ -288,396 +124,12 @@ "openai": false }, "cogitate": { - "backup": "anthropic", - "provider": "openai", - "tier": 2 - }, - "context_defaults": { - "detect.created": { - "group": "Import", - "label": "Date Detection", - "tier": 3 - }, - "observe.describe.browsing": { - "group": "Screen Analysis", - "label": "Browsing", - "tier": 2 - }, - "observe.describe.calendar": { - "group": "Screen Analysis", - "label": "Calendar", - "tier": 2 - }, - "observe.describe.code": { - "group": "Screen Analysis", - "label": "Code", - "tier": 2 - }, - "observe.describe.frame": { - "group": "Observe", - "label": "Screen Categorization", - "tier": 3 - }, - "observe.describe.gaming": { - "group": "Screen Analysis", - "label": "Gaming", - "tier": 2 - }, - "observe.describe.media": { - "group": "Screen Analysis", - "label": "Media", - "tier": 2 - }, - "observe.describe.meeting": { - "group": "Screen Analysis", - "label": "Meeting", - "tier": 2 - }, - "observe.describe.messaging": { - "group": "Screen Analysis", - "label": "Messaging", - "tier": 2 - }, - "observe.describe.productivity": { - "group": "Screen Analysis", - "label": "Productivity", - "tier": 2 - }, - "observe.describe.reading": { - "group": "Screen Analysis", - "label": "Reading", - "tier": 2 - }, - "observe.describe.social": { - "group": "Screen Analysis", - "label": "Social", - "tier": 2 - }, - "observe.describe.terminal": { - "group": "Screen Analysis", - "label": "Terminal", - "tier": 2 - }, - "observe.detect.json": { - "group": "Import", - "label": "Normalization", - "tier": 2 - }, - "observe.detect.segment": { - "group": "Import", - "label": "Segmentation", - "tier": 2 - }, - "observe.enrich": { - "group": "Observe", - "label": "Audio Enrichment", - "tier": 2 - }, - "observe.extract.selection": { - "group": "Observe", - "label": "Frame Selection", - "tier": 2 - }, - "observe.transcribe.gemini": { - "group": "Observe", - "label": "Audio Transcription (Gemini)", - "tier": 2 - }, - "planner.generate": { - "group": "Think", - "label": "Agent Prompt Generation", - "tier": 2 - }, - "talent.entities.detection": { - "disabled": false, - "group": "Think", - "label": "Entity Detection", - "schedule": "segment", - "tier": 2, - "type": "generate" - }, - "talent.entities.entities_review": { - "disabled": false, - "group": "Entities", - "label": "Entity Reviewer", - "schedule": "daily", - "tier": 2, - "type": "generate" - }, - "talent.entities.entity_assist": { - "disabled": false, - "group": "Entities", - "label": "Entity Assistant", - "tier": 2, - "type": "cogitate" - }, - "talent.entities.entity_describe": { - "disabled": false, - "group": "Entities", - "label": "Entity Description", - "tier": 2, - "type": "generate" - }, - "talent.entities.entity_observer": { - "disabled": false, - "group": "Entities", - "label": "Entity Observer", - "schedule": "daily", - "tier": 2, - "type": "generate" - }, - "talent.support.support": { - "disabled": false, - "group": "Think", - "label": "Support", - "tier": 2, - "type": "cogitate" - }, - "talent.system.chat": { - "disabled": false, - "group": "Think", - "label": "Chat", - "tier": 2, - "type": "generate" - }, - "talent.system.conversation": { - "disabled": false, - "group": "Think", - "label": "Conversation Story", - "schedule": "activity", - "tier": 3, - "type": "generate" - }, - "talent.system.daily_schedule": { - "disabled": false, - "group": "Think", - "label": "Maintenance Window", - "schedule": "daily", - "tier": 2, - "type": "generate" - }, - "talent.system.documents": { - "disabled": false, - "group": "Think", - "label": "Document Analysis", - "schedule": "segment", - "tier": 2, - "type": "generate" - }, - "talent.system.event": { - "disabled": false, - "group": "Think", - "label": "Event Story", - "schedule": "activity", - "tier": 2, - "type": "generate" - }, - "talent.system.exec": { - "disabled": false, - "group": "Think", - "label": "Exec", - "tier": 2, - "type": "cogitate" - }, - "talent.system.facet_newsletter": { - "disabled": false, - "group": "Think", - "label": "Facet Newsletter Generator", - "schedule": "daily", - "tier": 2, - "type": "generate" - }, - "talent.system.morning_briefing": { - "disabled": false, - "group": "Think", - "label": "Morning Briefing", - "schedule": "daily", - "tier": 2, - "type": "generate" - }, - "talent.system.participation": { - "disabled": false, - "group": "Think", - "label": "Participation", - "schedule": "activity", - "tier": 3, - "type": "generate" - }, - "talent.system.partner": { - "disabled": false, - "group": "Think", - "label": "Partner Profile", - "schedule": "weekly", - "tier": 2, - "type": "cogitate" - }, - "talent.system.pulse": { - "disabled": false, - "group": "Think", - "label": "Pulse", - "schedule": "cadence", - "tier": 3, - "type": "generate" - }, - "talent.system.read": { - "disabled": false, - "group": "Think", - "label": "Read", - "tier": 2, - "type": "cogitate" - }, - "talent.system.schedule": { - "disabled": false, - "group": "Think", - "label": "Upcoming Schedule", - "schedule": "daily", - "tier": 2, - "type": "generate" - }, - "talent.system.screen": { - "disabled": false, - "group": "Think", - "label": "Screen Record", - "schedule": "segment", - "tier": 3, - "type": "generate" - }, - "talent.system.sense": { - "disabled": false, - "group": "Think", - "label": "Segment Sense", - "schedule": "segment", - "tier": 3, - "type": "generate" - }, - "talent.system.speaker_attribution": { - "disabled": false, - "group": "Think", - "label": "Speaker Attribution", - "schedule": "segment", - "tier": 2, - "type": "generate" - }, - "talent.system.steward": { - "disabled": false, - "group": "Think", - "label": "Steward", - "schedule": "cadence", - "tier": 3, - "type": "generate" - }, - "talent.system.weekly_reflection": { - "disabled": false, - "group": "Think", - "label": "Weekly Reflection", - "schedule": "weekly", - "tier": 2, - "type": "cogitate" - }, - "talent.system.work": { - "disabled": false, - "group": "Think", - "label": "Work Story", - "schedule": "activity", - "tier": 2, - "type": "generate" - }, - "talent.timeline.segment_summary": { - "disabled": false, - "group": "Think", - "label": "timeline:segment_summary", - "schedule": "segment", - "tier": 3, - "type": "generate" - } - }, - "contexts": { - "observe.*": { - "provider": "google", - "tier": 3 - }, - "test.anthropic": { - "model": "claude-sonnet-4-5", - "provider": "anthropic" - }, - "test.config.override": { - "provider": "google", - "tier": 2 - }, - "test.local": { - "model": "local/qwen3.5-4b", - "provider": "local" - }, - "test.openai": { - "model": "gpt-5-mini", - "provider": "openai" - }, - "test.tier": { - "provider": "google", - "tier": 1 - }, - "test.tier.inherit": { - "tier": 3 - }, - "test.tier.override": { - "provider": "openai", - "tier": 2 - } - }, - "effective_contexts": { - "observe.*": { - "differs_from_raw": false, - "interface": "generate", - "model": "gemini-flash-lite-latest", - "provider": "google" - }, - "test.anthropic": { - "differs_from_raw": false, - "interface": "generate", - "model": "claude-sonnet-4-5", - "provider": "anthropic" - }, - "test.config.override": { - "differs_from_raw": false, - "interface": "generate", - "model": "gemini-custom-flash-test", - "provider": "google" - }, - "test.local": { - "differs_from_raw": false, - "interface": "generate", - "model": "local/qwen3.5-4b", - "provider": "local" - }, - "test.openai": { - "differs_from_raw": false, - "interface": "generate", - "model": "gpt-5-mini", - "provider": "openai" - }, - "test.tier": { - "differs_from_raw": false, - "interface": "generate", - "model": "gemini-pro-latest", - "provider": "google" - }, - "test.tier.inherit": { - "differs_from_raw": false, - "interface": "generate", - "model": "gemini-flash-lite-latest", - "provider": "google" - }, - "test.tier.override": { - "differs_from_raw": false, - "interface": "generate", - "model": "gpt-5.4-mini", - "provider": "openai" - } + "model": "gpt-5.4-mini", + "provider": "openai" }, "generate": { - "backup": "anthropic", - "provider": "google", - "tier": 2 + "model": "gemini-flash-latest", + "provider": "google" }, "google_backend": "auto", "key_validation": {}, @@ -727,7 +179,7 @@ "model_missing", "run `journal install-provider local`" ], - "selected": true + "selected": false }, "openai": { "cogitate_ready": false, diff --git a/tests/fixtures/journal/config/journal.json b/tests/fixtures/journal/config/journal.json index b8385ac2e..0d05bd368 100644 --- a/tests/fixtures/journal/config/journal.json +++ b/tests/fixtures/journal/config/journal.json @@ -43,51 +43,11 @@ "providers": { "generate": { "provider": "google", - "tier": 2, - "backup": "anthropic" + "model": "gemini-custom-flash-test" }, "cogitate": { "provider": "openai", - "tier": 2, - "backup": "anthropic" - }, - "contexts": { - "test.openai": { - "provider": "openai", - "model": "gpt-5-mini" - }, - "test.anthropic": { - "provider": "anthropic", - "model": "claude-sonnet-4-5" - }, - "test.tier": { - "provider": "google", - "tier": 1 - }, - "test.tier.inherit": { - "tier": 3 - }, - "test.tier.override": { - "provider": "openai", - "tier": 2 - }, - "test.config.override": { - "provider": "google", - "tier": 2 - }, - "observe.*": { - "provider": "google", - "tier": 3 - }, - "test.local": { - "provider": "local", - "model": "local/qwen3.5-4b" - } - }, - "models": { - "google": { - "2": "gemini-custom-flash-test" - } + "model": "gpt-5.4-mini" }, "bundled": { "parakeet": { diff --git a/tests/test_bad_media_corpus.py b/tests/test_bad_media_corpus.py index 760fe4c35..a3a945dac 100644 --- a/tests/test_bad_media_corpus.py +++ b/tests/test_bad_media_corpus.py @@ -258,7 +258,7 @@ def _drive_describe( ) monkeypatch.setattr( "solstone.think.models.resolve_provider", - lambda _context, _interface: ("google", "gemini-test"), + lambda _interface: ("google", "gemini-test"), ) monkeypatch.setattr(describe, "callosum_send", lambda *args, **kwargs: None) monkeypatch.setattr(describe, "select_frames_for_extraction", lambda *a, **k: []) diff --git a/tests/test_batch.py b/tests/test_batch.py index 9ab5f4022..dd8552ea4 100644 --- a/tests/test_batch.py +++ b/tests/test_batch.py @@ -225,6 +225,7 @@ async def test_batch_classifies_exception_metadata_when_attrs_missing( assert results[0].reason_code == "network_unreachable" assert results[0].reset_at_ms is None assert results[0].provider == "google" + mock_resolve_provider.assert_called_once_with("generate") def test_batch_update_clears_error_metadata(): diff --git a/tests/test_cluster.py b/tests/test_cluster.py index 629f15555..43baeea00 100644 --- a/tests/test_cluster.py +++ b/tests/test_cluster.py @@ -18,27 +18,13 @@ SEGMENT = "120000_300" def _patch_prepare_config_model_deps(monkeypatch) -> None: - from solstone.think.models import TIER_LITE - monkeypatch.setattr( "solstone.think.models.resolve_provider", - lambda _context, _type: ("google", "gemini-test"), + lambda _type: ("google", "gemini-test"), ) monkeypatch.setattr( "solstone.think.models.type_default_is_local", lambda _type: False ) - monkeypatch.setattr( - "solstone.think.models._resolve_tier", - lambda _context, _type: TIER_LITE, - ) - monkeypatch.setattr("solstone.think.models.load_health_status", lambda: {}) - monkeypatch.setattr( - "solstone.think.models.should_recheck_health", lambda _health: False - ) - monkeypatch.setattr( - "solstone.think.models.is_provider_model_interface_healthy", - lambda _provider, _model, _type, _health: True, - ) def _write_probe_talent(talent_dir: Path, load: dict | str) -> str: diff --git a/tests/test_describe_preflight.py b/tests/test_describe_preflight.py index cc5a1e44b..250b82a38 100644 --- a/tests/test_describe_preflight.py +++ b/tests/test_describe_preflight.py @@ -137,17 +137,13 @@ def test_blocked_then_ready_leaves_media_reeligible(tmp_path, monkeypatch): ] -def test_preflight_dedups_contexts_by_resolved_provider_model(monkeypatch): +def test_preflight_dedups_contexts_by_active_generate_route(monkeypatch): from solstone.think import models monkeypatch.setattr( models, "resolve_provider", - lambda context, _interface: { - "observe.describe.frame": ("google", "gemini-test"), - "observe.describe.meeting": ("google", "gemini-test"), - "observe.describe.terminal": ("anthropic", "claude-test"), - }[context], + lambda _interface: ("google", "gemini-test"), ) assert describe_module._dedup_readiness_contexts( @@ -156,7 +152,7 @@ def test_preflight_dedups_contexts_by_resolved_provider_model(monkeypatch): "observe.describe.meeting", "observe.describe.terminal", ] - ) == ["observe.describe.frame", "observe.describe.terminal"] + ) == ["observe.describe.frame"] @pytest.mark.asyncio @@ -234,7 +230,7 @@ async def test_mid_run_blocker_unlinks_partial_output_and_does_not_retry( monkeypatch.setattr( models, "resolve_provider", - lambda _context, _interface: ("google", "gemini-test"), + lambda _interface: ("google", "gemini-test"), ) monkeypatch.setattr( describe_module, diff --git a/tests/test_describe_promote.py b/tests/test_describe_promote.py index a0ddd1c35..a5128166a 100644 --- a/tests/test_describe_promote.py +++ b/tests/test_describe_promote.py @@ -96,7 +96,7 @@ def _install_fakes(monkeypatch, outcomes: dict[int, dict]) -> list[tuple]: monkeypatch.setattr( models, "resolve_provider", - lambda _context, _interface: ("google", "gemini-test"), + lambda _interface: ("google", "gemini-test"), ) monkeypatch.setattr( processing_record_module, "now_iso_utc", lambda: "2026-06-30T12:00:00Z" @@ -339,12 +339,12 @@ async def test_browsing_truncation_does_not_promote_category_content( monkeypatch.setattr( batch_module, "resolve_provider", - lambda _context, _interface: ("google", "gemini-test"), + lambda _interface: ("google", "gemini-test"), ) monkeypatch.setattr( models, "resolve_provider", - lambda _context, _interface: ("google", "gemini-test"), + lambda _interface: ("google", "gemini-test"), ) monkeypatch.setattr( processing_record_module, "now_iso_utc", lambda: "2026-06-30T12:00:00Z" diff --git a/tests/test_entity_observer_context.py b/tests/test_entity_observer_context.py index 70f669d44..a4ea087c1 100644 --- a/tests/test_entity_observer_context.py +++ b/tests/test_entity_observer_context.py @@ -1048,7 +1048,7 @@ def test_entity_observer_agent_config(monkeypatch): assert config["type"] == "generate" assert config.get("output") == "json" - assert config.get("tier") == 2 + assert "tier" not in config assert config.get("thinking_budget") == 2048 assert config.get("hook", {}).get("pre") == "entities:entity_observer" assert config.get("hook", {}).get("post") == "entities:entity_observer" diff --git a/tests/test_local.py b/tests/test_local.py index 5452e6904..87e734623 100644 --- a/tests/test_local.py +++ b/tests/test_local.py @@ -17,11 +17,8 @@ from types import SimpleNamespace import pytest from solstone.think.models import ( + DEFAULT_MODEL_BY_PROVIDER, LOCAL_MODEL, - PROVIDER_DEFAULTS, - TIER_FLASH, - TIER_LITE, - TIER_PRO, get_model_provider, ) from solstone.think.talents import TalentHookError @@ -122,9 +119,7 @@ def test_local_model_specs(): def test_local_provider_defaults_and_registry(): from solstone.think.providers import PROVIDER_METADATA, PROVIDER_REGISTRY - assert PROVIDER_DEFAULTS["local"][TIER_PRO] == LOCAL_MODEL - assert PROVIDER_DEFAULTS["local"][TIER_FLASH] == LOCAL_MODEL - assert PROVIDER_DEFAULTS["local"][TIER_LITE] == LOCAL_MODEL + assert DEFAULT_MODEL_BY_PROVIDER["local"] == LOCAL_MODEL assert PROVIDER_REGISTRY["local"] == "solstone.think.providers.local" assert PROVIDER_METADATA["local"] == { "label": "Local (on-device)", diff --git a/tests/test_maint_006_rename_unified_triage_providers.py b/tests/test_maint_006_rename_unified_triage_providers.py index cd93e14e4..b1c8773c4 100644 --- a/tests/test_maint_006_rename_unified_triage_providers.py +++ b/tests/test_maint_006_rename_unified_triage_providers.py @@ -18,7 +18,9 @@ def _write_journal_config(journal: Path, data: object) -> Path: return config_path -def test_rename_unified_and_remove_triage_idempotent(tmp_path, monkeypatch): +def test_rename_unified_and_triage_provider_contexts_is_retired_noop( + tmp_path, monkeypatch +): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) config_path = _write_journal_config( tmp_path, @@ -32,82 +34,29 @@ def test_rename_unified_and_remove_triage_idempotent(tmp_path, monkeypatch): } }, ) - - summary = mod.run_migration(tmp_path, dry_run=False) - - assert summary.renamed == 1 - assert summary.removed == 1 - assert summary.preserved == 0 - assert summary.errors == 0 - data = json.loads(config_path.read_text(encoding="utf-8")) - assert "talent.system.unified" not in data["providers"]["contexts"] - assert "talent.system.triage" not in data["providers"]["contexts"] - assert data["providers"]["contexts"]["talent.system.chat"] == {"provider": "openai"} - assert data["providers"]["contexts"]["talent.system.morning_briefing"] == { - "provider": "google" - } - before_bytes = config_path.read_bytes() before_mtime_ns = config_path.stat().st_mtime_ns - rerun = mod.run_migration(tmp_path, dry_run=False) - - assert rerun.renamed == 0 - assert rerun.removed == 0 - assert rerun.preserved == 0 - assert rerun.errors == 0 - assert rerun.skipped_reason is None - assert config_path.read_bytes() == before_bytes - assert config_path.stat().st_mtime_ns == before_mtime_ns - - -def test_preserves_existing_chat_context_when_unified_exists(tmp_path, monkeypatch): - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - config_path = _write_journal_config( - tmp_path, - { - "providers": { - "contexts": { - "talent.system.unified": {"provider": "openai"}, - "talent.system.chat": {"provider": "google"}, - } - } - }, - ) - summary = mod.run_migration(tmp_path, dry_run=False) assert summary.renamed == 0 assert summary.removed == 0 - assert summary.preserved == 1 + assert summary.preserved == 0 assert summary.errors == 0 - data = json.loads(config_path.read_text(encoding="utf-8")) - assert "talent.system.unified" not in data["providers"]["contexts"] - assert data["providers"]["contexts"]["talent.system.chat"] == {"provider": "google"} + assert summary.skipped_reason == "retired" + assert config_path.read_bytes() == before_bytes + assert config_path.stat().st_mtime_ns == before_mtime_ns -def test_noop_when_no_legacy_provider_contexts_present(tmp_path, monkeypatch): +def test_retired_migration_dry_run_is_also_noop(tmp_path, monkeypatch): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) config_path = _write_journal_config( tmp_path, - { - "providers": { - "contexts": { - "talent.system.chat": {"provider": "openai"}, - "talent.system.morning_briefing": {"provider": "google"}, - } - } - }, + {"providers": {"contexts": {"talent.system.unified": {"provider": "openai"}}}}, ) - before_bytes = config_path.read_bytes() - before_mtime_ns = config_path.stat().st_mtime_ns + before = config_path.read_bytes() - summary = mod.run_migration(tmp_path, dry_run=False) + summary = mod.run_migration(tmp_path, dry_run=True) - assert summary.renamed == 0 - assert summary.removed == 0 - assert summary.preserved == 0 - assert summary.errors == 0 - assert summary.skipped_reason is None - assert config_path.read_bytes() == before_bytes - assert config_path.stat().st_mtime_ns == before_mtime_ns + assert summary.skipped_reason == "retired" + assert config_path.read_bytes() == before diff --git a/tests/test_models.py b/tests/test_models.py index d8da8160b..1f54e7f3c 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -14,26 +14,17 @@ import pytest import solstone.think.models as models_module from solstone.think.models import ( - CLAUDE_HAIKU_4, CLAUDE_OPUS_4, CLAUDE_SONNET_4, + DEFAULT_MODEL_BY_PROVIDER, DEFAULT_PROVIDER_TIMEOUT_S, GEMINI_FLASH, - GEMINI_LITE, - GEMINI_PRO, GEMMA4_26B_A4B_4BIT, - GPT_5, GPT_5_MINI, - GPT_5_NANO, LOCAL_MODEL, NO_BRAIN_PROVIDER, PROMPT_PATHS, - PROVIDER_DEFAULTS, QWEN_35_9B, - TIER_FLASH, - TIER_LITE, - TIER_PRO, - TYPE_DEFAULTS, IncompleteJSONError, IncompleteTextError, NoBrainConfiguredError, @@ -49,6 +40,7 @@ from solstone.think.models import ( agenerate_with_result, calc_agent_cost, calc_token_cost, + default_model_for_provider, finish_reason_error, generate, generate_with_result, @@ -58,7 +50,6 @@ from solstone.think.models import ( is_local_provider_needed, iter_token_log, model_supports, - request_health_recheck, resolve_provider, ) @@ -246,54 +237,71 @@ def _write_tmp_journal_config( def test_resolve_provider_default_generate(use_fixtures_journal): - """Test that generate default provider is returned for unknown context.""" - provider, model = resolve_provider("unknown.context", "generate") + """Generate resolves the configured active provider/model.""" + provider, model = resolve_provider("generate") assert provider == "google" - # Default tier is 2, which is overridden in fixture config to custom model assert model == "gemini-custom-flash-test" def test_resolve_provider_default_cogitate(use_fixtures_journal): - """Test that cogitate default provider is returned for unknown context.""" - provider, model = resolve_provider("unknown.context", "cogitate") + """Cogitate resolves the configured active provider/model.""" + provider, model = resolve_provider("cogitate") assert provider == "openai" assert model == GPT_5_MINI -def test_resolve_provider_exact_match(use_fixtures_journal): - """Test that exact context match works.""" - provider, model = resolve_provider("test.openai", "generate") - assert provider == "openai" - assert model == "gpt-5-mini" - +def test_resolve_provider_contexts_are_inert(monkeypatch, tmp_path): + """Legacy exact/glob contexts cannot influence active route resolution.""" + _write_tmp_journal_config( + tmp_path, + monkeypatch, + { + "providers": { + "generate": { + "provider": "google", + "model": "gemini-flash-latest", + }, + "contexts": { + "test.openai": { + "provider": "openai", + "model": "gpt-5-mini", + }, + "observe.*": { + "provider": "anthropic", + "model": "claude-haiku-4-5", + }, + }, + } + }, + ) -def test_resolve_provider_glob_match(use_fixtures_journal): - """Test that glob pattern matching works.""" - # observe.* pattern should match - provider, model = resolve_provider("observe.describe.frame", "generate") + provider, model = resolve_provider("generate") assert provider == "google" - assert model == GEMINI_LITE + assert model == GEMINI_FLASH - # Also matches with other suffixes - provider, model = resolve_provider("observe.enrich", "generate") - assert provider == "google" - assert model == GEMINI_LITE +def test_resolve_provider_ordering_witness(monkeypatch, tmp_path): + """Explicit provider/model wins over key-presence fallback.""" + _write_tmp_journal_config( + tmp_path, + monkeypatch, + { + "providers": { + "generate": { + "provider": "anthropic", + "model": "claude-haiku-4-5", + } + } + }, + ) + monkeypatch.setenv("OPENAI_API_KEY", "test-openai-key") -def test_resolve_provider_anthropic(use_fixtures_journal): - """Test anthropic provider routing.""" - provider, model = resolve_provider("test.anthropic", "generate") - assert provider == "anthropic" - # Explicit per-context model override in the fixture config wins over - # the PROVIDER_DEFAULTS tier constant — this asserts override behavior, - # not the default Sonnet pin, so it stays literal by design. - assert model == "claude-sonnet-4-5" + assert resolve_provider("generate") == ("anthropic", "claude-haiku-4-5") def test_resolve_provider_empty_context(use_fixtures_journal): - """Test that empty context returns default.""" - provider, model = resolve_provider("", "generate") - assert provider == "google" + """The resolver takes only an interface, not a context.""" + assert resolve_provider("generate") == ("google", "gemini-custom-flash-test") def test_resolve_provider_no_config(monkeypatch, tmp_path): @@ -309,44 +317,17 @@ def test_resolve_provider_no_config(monkeypatch, tmp_path): "solstone.think.providers.state.local_runtime_ready", lambda: False ) - provider, model = resolve_provider("anything", "generate") + provider, model = resolve_provider("generate") assert provider == NO_BRAIN_PROVIDER assert provider != "google" assert model == "" - provider, model = resolve_provider("anything", "cogitate") + provider, model = resolve_provider("cogitate") assert provider == NO_BRAIN_PROVIDER assert provider != "google" assert model == "" -# --------------------------------------------------------------------------- -# Tier system tests -# --------------------------------------------------------------------------- - - -def test_tier_constants(): - """Test tier constant values.""" - assert TIER_PRO == 1 - assert TIER_FLASH == 2 - assert TIER_LITE == 3 - - -def test_type_defaults(): - """Test TYPE_DEFAULTS structure for generate and cogitate.""" - assert "generate" in TYPE_DEFAULTS - assert "cogitate" in TYPE_DEFAULTS - - for agent_type in ("generate", "cogitate"): - defaults = TYPE_DEFAULTS[agent_type] - assert "provider" not in defaults - assert "tier" in defaults - assert "backup" in defaults - - assert TYPE_DEFAULTS["generate"]["backup"] == "anthropic" - assert TYPE_DEFAULTS["cogitate"]["backup"] == "anthropic" - - def test_prompt_paths_exist(): """Test all PROMPT_PATHS files exist and have valid frontmatter.""" from pathlib import Path @@ -354,7 +335,7 @@ def test_prompt_paths_exist(): import frontmatter base_dir = Path(__file__).parent.parent / "solstone" # Package root - required_keys = {"context", "tier", "label", "group"} + required_keys = {"context", "label", "group"} for rel_path in PROMPT_PATHS: path = base_dir / rel_path @@ -366,11 +347,6 @@ def test_prompt_paths_exist(): assert required_keys <= set(meta.keys()), ( f"{rel_path} missing keys: {required_keys - set(meta.keys())}" ) - assert meta["tier"] in ( - TIER_PRO, - TIER_FLASH, - TIER_LITE, - ), f"{rel_path} has invalid tier: {meta['tier']}" assert isinstance(meta["label"], str) and meta["label"], ( f"{rel_path} has invalid label: {meta['label']}" ) @@ -385,45 +361,22 @@ def test_prompt_contexts_in_registry(): # Verify known prompt contexts exist with correct values assert "observe.describe.frame" in registry - assert registry["observe.describe.frame"]["tier"] == TIER_LITE assert registry["observe.describe.frame"]["group"] == "Observe" assert "observe.enrich" in registry - assert registry["observe.enrich"]["tier"] == TIER_FLASH assert "detect.created" in registry - assert registry["detect.created"]["tier"] == TIER_LITE - - -def test_provider_defaults_structure(): - """Test PROVIDER_DEFAULTS contains all providers and tiers.""" - assert "google" in PROVIDER_DEFAULTS - assert "openai" in PROVIDER_DEFAULTS - assert "anthropic" in PROVIDER_DEFAULTS - - for provider in PROVIDER_DEFAULTS: - assert TIER_PRO in PROVIDER_DEFAULTS[provider] - assert TIER_FLASH in PROVIDER_DEFAULTS[provider] - assert TIER_LITE in PROVIDER_DEFAULTS[provider] - -def test_provider_defaults_models(): - """Test PROVIDER_DEFAULTS maps to correct model constants.""" - assert PROVIDER_DEFAULTS["google"][TIER_PRO] == GEMINI_PRO - assert PROVIDER_DEFAULTS["google"][TIER_FLASH] == GEMINI_FLASH - assert PROVIDER_DEFAULTS["google"][TIER_LITE] == GEMINI_LITE - assert PROVIDER_DEFAULTS["openai"][TIER_PRO] == GPT_5 - assert PROVIDER_DEFAULTS["openai"][TIER_FLASH] == GPT_5_MINI - assert PROVIDER_DEFAULTS["openai"][TIER_LITE] == GPT_5_NANO - - assert PROVIDER_DEFAULTS["anthropic"][TIER_PRO] == CLAUDE_OPUS_4 - assert PROVIDER_DEFAULTS["anthropic"][TIER_FLASH] == CLAUDE_SONNET_4 - assert PROVIDER_DEFAULTS["anthropic"][TIER_LITE] == CLAUDE_HAIKU_4 - - assert PROVIDER_DEFAULTS["local"][TIER_PRO] == LOCAL_MODEL - assert PROVIDER_DEFAULTS["local"][TIER_FLASH] == LOCAL_MODEL - assert PROVIDER_DEFAULTS["local"][TIER_LITE] == LOCAL_MODEL +def test_default_model_by_provider(): + assert DEFAULT_MODEL_BY_PROVIDER == { + "google": GEMINI_FLASH, + "openai": GPT_5_MINI, + "anthropic": CLAUDE_SONNET_4, + "local": LOCAL_MODEL, + } + for provider, model in DEFAULT_MODEL_BY_PROVIDER.items(): + assert default_model_for_provider(provider) == model @pytest.mark.parametrize( @@ -431,7 +384,6 @@ def test_provider_defaults_models(): [ {"providers": {"generate": {"provider": "local"}}}, {"providers": {"cogitate": {"provider": "local"}}}, - {"providers": {"contexts": {"talent.*": {"provider": "local"}}}}, ], ) def test_is_local_provider_needed_true_for_selected_surfaces(config): @@ -444,6 +396,7 @@ def test_is_local_provider_needed_true_for_selected_surfaces(config): {}, {"providers": {"generate": {"provider": "google"}}}, {"providers": {"contexts": {"talent.*": {"provider": "anthropic"}}}}, + {"providers": {"contexts": {"talent.*": {"provider": "local"}}}}, {"providers": []}, ], ) @@ -465,238 +418,93 @@ def test_is_local_provider_needed_true_for_implicit_local(monkeypatch): assert is_local_provider_needed({}) is True -def test_resolve_provider_tier_based(use_fixtures_journal): - """Test tier-based resolution.""" - # test.tier has tier: 1 (pro) - provider, model = resolve_provider("test.tier", "generate") - assert provider == "google" - assert model == GEMINI_PRO - - -def test_resolve_provider_tier_inherit_provider(use_fixtures_journal): - """Test tier with inherited provider from type default.""" - # test.tier.inherit has tier: 3 only, should inherit google from generate default - provider, model = resolve_provider("test.tier.inherit", "generate") - assert provider == "google" - assert model == GEMINI_LITE - - # Same context with cogitate should inherit openai - provider, model = resolve_provider("test.tier.inherit", "cogitate") - assert provider == "openai" - assert model == GPT_5_NANO - - -def test_resolve_provider_tier_with_provider(use_fixtures_journal): - """Test tier with explicit provider.""" - # test.tier.override has provider: openai, tier: 2 - provider, model = resolve_provider("test.tier.override", "generate") - assert provider == "openai" - assert model == GPT_5_MINI - - -def test_resolve_provider_tier_glob(use_fixtures_journal): - """Test tier-based glob pattern matching.""" - # observe.* now uses tier: 3 instead of explicit model - provider, model = resolve_provider("observe.describe.frame", "generate") - assert provider == "google" - assert model == GEMINI_LITE - - -def test_resolve_provider_model_overrides_tier(use_fixtures_journal): - """Test that explicit model takes precedence over tier.""" - # test.openai has explicit model, not tier - provider, model = resolve_provider("test.openai", "generate") - assert provider == "openai" - assert model == "gpt-5-mini" - - -def test_resolve_provider_default_tier(use_fixtures_journal): - """Test default uses tier-based resolution with config override.""" - # Generate default is tier: 2, which is overridden in config to custom model - provider, model = resolve_provider("unknown.context", "generate") - assert provider == "google" - assert model == "gemini-custom-flash-test" - - -def test_resolve_provider_config_model_override(use_fixtures_journal): - """Test that config models section overrides system defaults.""" - # test.config.override uses tier: 2, which is overridden in config - provider, model = resolve_provider("test.config.override", "generate") - assert provider == "google" - # Should use the custom model from config, not system default GEMINI_FLASH - assert model == "gemini-custom-flash-test" - assert model != GEMINI_FLASH - - -def test_resolve_provider_tier_fallback_to_system_default(use_fixtures_journal): - """Test that tiers not in config fall back to system defaults.""" - # test.tier uses tier: 1 (pro), which is NOT overridden in config - # Should fall back to system default GEMINI_PRO - provider, model = resolve_provider("test.tier", "generate") - assert provider == "google" - assert model == GEMINI_PRO - - -def test_resolve_provider_invalid_tier(use_fixtures_journal, monkeypatch, tmp_path): - """Test that invalid tier values fall back to default tier.""" - # Create a config with an invalid tier - config_dir = tmp_path / "config" - config_dir.mkdir() - config = { - "providers": { - "generate": {"provider": "google", "tier": 2}, - "contexts": { - "test.invalid": {"provider": "google", "tier": 99}, - "test.string": {"provider": "google", "tier": "flash"}, - }, - } - } - (config_dir / "journal.json").write_text(json.dumps(config)) - monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - - # Invalid tier 99 should fall back to generate default tier (2) - provider, model = resolve_provider("test.invalid", "generate") - assert provider == "google" - assert model == GEMINI_FLASH # tier 2 system default - - # String tier should also fall back - provider, model = resolve_provider("test.string", "generate") - assert provider == "google" - assert model == GEMINI_FLASH - - -def test_resolve_provider_local_type_default_neutralizes_cloud_context_pin( +def test_resolve_provider_legacy_keys_are_inert( use_fixtures_journal, monkeypatch, tmp_path ): - """A cloud context provider/model pin cannot override an explicit local lane.""" + """Tier/backup/contexts/models legacy keys do not affect active routing.""" _write_tmp_journal_config( tmp_path, monkeypatch, { "providers": { - "generate": {"provider": "local"}, - "contexts": { - "talent.timeline.segment_summary": { - "provider": "google", - "model": "gemini-flash-lite-latest", - } + "generate": { + "provider": "google", + "model": "gemini-flash-latest", + "tier": 1, + "backup": "anthropic", }, - } - }, - ) - - provider, model = resolve_provider("talent.timeline.segment_summary", "generate") - - assert provider == "local" - assert model == LOCAL_MODEL - assert model != "gemini-flash-lite-latest" - - -def test_resolve_provider_local_context_model_pin_is_honored( - use_fixtures_journal, monkeypatch, tmp_path -): - """An explicit local context pin (provider: local + model) keeps its model.""" - _write_tmp_journal_config( - tmp_path, - monkeypatch, - { - "providers": { - "generate": {"provider": "local"}, "contexts": { - "talent.timeline.segment_summary": { - "provider": "local", - "model": "local/custom-7b", - } + "talent.timeline.segment_summary": {"provider": "local"}, + "observe.*": {"provider": "anthropic", "tier": 3}, + }, + "models": { + "google": {"1": "gemini-pro-latest"}, + "anthropic": {"3": "claude-haiku-4-5"}, }, } }, ) - provider, model = resolve_provider("talent.timeline.segment_summary", "generate") + assert resolve_provider("generate") == ("google", GEMINI_FLASH) - assert provider == "local" - assert model == "local/custom-7b" - assert model != LOCAL_MODEL - -def test_resolve_provider_local_context_blank_pin_uses_context_tier( +def test_resolve_provider_model_key_wins_even_when_tier_present( use_fixtures_journal, monkeypatch, tmp_path ): - """Blank local context model pins fall through to tier-based local routing.""" + """The retired AC3a quirk is gone: model is honored even with tier present.""" _write_tmp_journal_config( tmp_path, monkeypatch, { "providers": { - "generate": {"provider": "local"}, - "models": {"local": {"3": "local/lite-test"}}, - "contexts": { - "talent.blank": { - "provider": "local", - "model": "", - "tier": 3, - } - }, + "generate": { + "provider": "google", + "tier": 1, + "model": "gemini-custom-flash-test", + } } }, ) - provider, model = resolve_provider("talent.blank", "generate") - - assert provider == "local" - assert model == "local/lite-test" - assert model != "" + assert resolve_provider("generate") == ("google", "gemini-custom-flash-test") -def test_resolve_provider_local_context_malformed_pin_uses_type_default_tier( +def test_resolve_provider_local_type_default_ignores_context_pins( use_fixtures_journal, monkeypatch, tmp_path ): - """Malformed local model pins fall through without crashing.""" + """Context pins cannot push a local active interface onto cloud.""" _write_tmp_journal_config( tmp_path, monkeypatch, { "providers": { - "generate": {"provider": "local", "tier": 2}, - "models": {"local": {"2": "local/default-test"}}, + "generate": {"provider": "local"}, "contexts": { - "talent.blank.no_tier": { - "provider": "local", - "model": "", - }, - "talent.numeric.invalid_tier": { - "provider": "local", - "model": 123, - "tier": 99, + "talent.timeline.segment_summary": { + "provider": "google", + "model": "gemini-flash-lite-latest", }, }, } }, ) - assert resolve_provider("talent.blank.no_tier", "generate") == ( - "local", - "local/default-test", - ) - assert resolve_provider("talent.numeric.invalid_tier", "generate") == ( - "local", - "local/default-test", - ) + assert resolve_provider("generate") == ("local", LOCAL_MODEL) -def test_resolve_provider_local_honors_context_tier_and_models_override( +def test_legacy_context_toggles_remain_on_disk_but_not_routing( use_fixtures_journal, monkeypatch, tmp_path ): - """Tier-only context config inherits the local type default.""" + """Grandfathered context keys stay inert next to disabled/extract toggles.""" _write_tmp_journal_config( tmp_path, monkeypatch, { "providers": { - "generate": {"provider": "local"}, - "models": {"local": {"3": "local/lite-test"}}, + "generate": {"provider": "google", "model": GEMINI_FLASH}, "contexts": { "talent.x": { + "provider": "local", "tier": 3, "disabled": True, "extract": "foo", @@ -706,15 +514,66 @@ def test_resolve_provider_local_honors_context_tier_and_models_override( }, ) - assert resolve_provider("talent.x", "generate") == ("local", "local/lite-test") + assert resolve_provider("generate") == ("google", GEMINI_FLASH) stored = json.loads((tmp_path / "config" / "journal.json").read_text()) context = stored["providers"]["contexts"]["talent.x"] + assert context["provider"] == "local" assert context["disabled"] is True assert context["extract"] == "foo" assert context["tier"] == 3 +def test_prepare_config_legacy_context_routing_keys_are_inert( + journal_copy: Path, +) -> None: + """Legacy contexts survive on disk but cannot change prepared identity.""" + from solstone.think.talents import prepare_config + + config_path = journal_copy / "config" / "journal.json" + config = json.loads(config_path.read_text(encoding="utf-8")) + config["providers"] = { + "generate": {"provider": "anthropic", "model": CLAUDE_SONNET_4}, + "contexts": { + "talent.timeline.segment_summary": { + "provider": "google", + "model": "gemini-flash-lite-latest", + "tier": 3, + "backup": "openai", + } + }, + } + config_path.write_text(json.dumps(config), encoding="utf-8") + + prepared = prepare_config({"name": "timeline:segment_summary"}) + + assert prepared["provider"] == "anthropic" + assert prepared["model"] == CLAUDE_SONNET_4 + + +def test_prepare_config_frontmatter_provider_pin_is_dead_through_dispatch_identity( + journal_copy: Path, +) -> None: + """Removed google frontmatter pins do not override the active brain.""" + from solstone.think.talents import prepare_config + + config_path = journal_copy / "config" / "journal.json" + config = json.loads(config_path.read_text(encoding="utf-8")) + config["env"] = {"GOOGLE_API_KEY": "test-google-key"} + config["providers"] = { + "generate": {"provider": "anthropic", "model": CLAUDE_SONNET_4} + } + config_path.write_text(json.dumps(config), encoding="utf-8") + + segment = prepare_config({"name": "timeline:segment_summary"}) + detection = prepare_config({"name": "entities:detection"}) + + assert segment["provider"] == "anthropic" + assert segment["model"] == CLAUDE_SONNET_4 + assert detection["provider"] == "anthropic" + assert detection["model"] == CLAUDE_SONNET_4 + + def test_resolve_provider_cogitate_system_talents_stay_local( use_fixtures_journal, monkeypatch, tmp_path ): @@ -725,11 +584,7 @@ def test_resolve_provider_cogitate_system_talents_stay_local( {"providers": {"cogitate": {"provider": "local"}}}, ) - weekly_provider, _ = resolve_provider("talent.system.weekly_reflection", "cogitate") - partner_provider, _ = resolve_provider("talent.system.partner", "cogitate") - - assert weekly_provider == "local" - assert partner_provider == "local" + assert resolve_provider("cogitate") == ("local", LOCAL_MODEL) def test_resolve_provider_split_lane_other_type_stays_cloud( @@ -742,24 +597,13 @@ def test_resolve_provider_split_lane_other_type_stays_cloud( { "providers": { "generate": {"provider": "local"}, - "cogitate": {"provider": "openai", "tier": 2}, - "contexts": { - "talent.timeline.segment_summary": { - "provider": "google", - "model": "gemini-flash-lite-latest", - } - }, + "cogitate": {"provider": "openai"}, } }, ) - generate_provider, _ = resolve_provider( - "talent.timeline.segment_summary", "generate" - ) - cogitate_provider, _ = resolve_provider("talent.system.partner", "cogitate") - - assert generate_provider == "local" - assert cogitate_provider == "openai" + assert resolve_provider("generate") == ("local", LOCAL_MODEL) + assert resolve_provider("cogitate") == ("openai", GPT_5_MINI) def test_generate_rejects_cloud_model_override_for_local_provider(): @@ -900,7 +744,7 @@ def test_context_registry_includes_prompt_contexts(): registry = get_context_registry() base_dir = Path(__file__).parent.parent / "solstone" - # All prompt contexts should be in registry with correct tier + # All prompt contexts should be in registry with matching metadata for rel_path in PROMPT_PATHS: path = base_dir / rel_path post = frontmatter.load(path) @@ -908,7 +752,8 @@ def test_context_registry_includes_prompt_contexts(): context = meta.get("context") assert context in registry, f"Prompt context {context} not in registry" - assert registry[context]["tier"] == meta["tier"] + assert registry[context]["label"] == meta["label"] + assert registry[context]["group"] == meta["group"] def test_context_registry_includes_categories(): @@ -923,10 +768,8 @@ def test_context_registry_includes_categories(): # Each category context should have required fields for context in category_contexts: - assert "tier" in registry[context] assert "label" in registry[context] assert "group" in registry[context] - assert registry[context]["tier"] in (TIER_PRO, TIER_FLASH, TIER_LITE) def test_context_registry_includes_talent_configs(): @@ -959,18 +802,13 @@ def test_context_registry_includes_talent_configs(): def test_context_registry_structure(): """Test that all registry entries have required fields.""" registry = get_context_registry() - required_keys = {"tier", "label", "group"} + required_keys = {"label", "group"} for context, config in registry.items(): assert isinstance(config, dict), f"{context} should be a dict" assert required_keys <= set(config.keys()), ( f"{context} missing keys: {required_keys - set(config.keys())}" ) - assert config["tier"] in ( - TIER_PRO, - TIER_FLASH, - TIER_LITE, - ), f"{context} has invalid tier: {config['tier']}" def test_context_registry_is_cached(): @@ -988,7 +826,7 @@ def test_context_registry_is_cached(): def test_all_default_models_have_pricing(): - """Verify all models in PROVIDER_DEFAULTS have genai-prices support. + """Verify all default provider models have genai-prices support. This test ensures that when default models are updated, we catch any missing pricing data early. If this test fails: @@ -999,25 +837,8 @@ def test_all_default_models_have_pricing(): See think/models.py model constants section for more details. """ - # Collect all unique models from PROVIDER_DEFAULTS - all_models = set() - for provider_models in PROVIDER_DEFAULTS.values(): - all_models.update(provider_models.values()) - - # Also include the named constants directly (in case they differ) - all_models.update( - [ - GEMINI_PRO, - GEMINI_FLASH, - GEMINI_LITE, - GPT_5, - GPT_5_MINI, - GPT_5_NANO, - CLAUDE_OPUS_4, - CLAUDE_SONNET_4, - CLAUDE_HAIKU_4, - ] - ) + all_models = set(DEFAULT_MODEL_BY_PROVIDER.values()) + all_models.add(CLAUDE_OPUS_4) missing_pricing = [] for model in sorted(all_models): @@ -1978,10 +1799,12 @@ class TestDefaultProviderTimeout: def test_request_health_recheck_emits_callosum_request(): + from solstone.think.providers.state import request_recheck + with patch( "solstone.think.providers.state.callosum_send", return_value=True ) as send: - request_health_recheck() + request_recheck() send.assert_called_once_with( "supervisor", @@ -1991,13 +1814,15 @@ def test_request_health_recheck_emits_callosum_request(): def test_request_health_recheck_does_not_raise_on_send_failure(caplog): + from solstone.think.providers.state import request_recheck + with ( patch( "solstone.think.providers.state.callosum_send", return_value=False ) as send, caplog.at_level(logging.WARNING), ): - request_health_recheck() + request_recheck() send.assert_called_once() assert "request_health_recheck: callosum_send returned false" in caplog.text diff --git a/tests/test_models_health.py b/tests/test_models_health.py index 3de1ebffc..3c476fda6 100644 --- a/tests/test_models_health.py +++ b/tests/test_models_health.py @@ -5,7 +5,8 @@ import json import pytest -from solstone.think.models import GEMINI_FLASH, record_provider_failure +from solstone.think.models import GEMINI_FLASH +from solstone.think.providers.state import record_quota_failure def _read_health(tmp_path): @@ -15,9 +16,8 @@ def _read_health(tmp_path): def test_record_provider_failure_appends_new_row(monkeypatch, tmp_path): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - record_provider_failure( + record_quota_failure( "google", - "flash", GEMINI_FLASH, "cogitate", 12345, @@ -27,7 +27,7 @@ def test_record_provider_failure_appends_new_row(monkeypatch, tmp_path): assert payload["summary"] == {"total": 1, "passed": 0, "skipped": 0, "failed": 1} row = payload["results"][0] assert row["provider"] == "google" - assert row["tier"] == "flash" + assert "tier" not in row assert row["model"] == GEMINI_FLASH assert row["interface"] == "cogitate" assert row["ok"] is False @@ -42,8 +42,8 @@ def test_record_provider_failure_appends_new_row(monkeypatch, tmp_path): def test_record_provider_failure_updates_duplicate_key(monkeypatch, tmp_path): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - record_provider_failure("google", "flash", "gemini", "cogitate", 100) - record_provider_failure("google", "flash", "gemini", "cogitate", 200) + record_quota_failure("google", "gemini", "cogitate", 100) + record_quota_failure("google", "gemini", "cogitate", 200) payload = _read_health(tmp_path) assert len(payload["results"]) == 1 @@ -73,7 +73,7 @@ def test_record_provider_failure_recomputes_summary(monkeypatch, tmp_path): ) ) - record_provider_failure("google", "flash", "gemini", "cogitate", 300) + record_quota_failure("google", "gemini", "cogitate", 300) payload = _read_health(tmp_path) assert payload["summary"] == {"total": 3, "passed": 1, "skipped": 1, "failed": 1} @@ -100,6 +100,6 @@ def test_record_provider_failure_atomic_replace_failure_preserves_file( monkeypatch.setattr("solstone.think.providers.state.os.replace", fail_replace) with pytest.raises(OSError, match="replace failed"): - record_provider_failure("google", "flash", "gemini", "cogitate", 400) + record_quota_failure("google", "gemini", "cogitate", 400) assert json.loads(health_path.read_text()) == original diff --git a/tests/test_no_implicit_cloud.py b/tests/test_no_implicit_cloud.py index 93cc0fcca..adaaf23e7 100644 --- a/tests/test_no_implicit_cloud.py +++ b/tests/test_no_implicit_cloud.py @@ -19,10 +19,7 @@ from solstone.think.models import ( LOCAL_MODEL, NO_BRAIN_PROVIDER, AttestationFailedError, - AttestationNotVerifiedError, - AttestationStaleError, NoBrainConfiguredError, - get_backup_provider, is_local_provider_needed, resolve_provider, ) @@ -131,7 +128,7 @@ def test_unconfigured_journal_resolves_to_no_brain(tmp_path, monkeypatch): _empty_journal(tmp_path, monkeypatch) for agent_type in ("generate", "cogitate"): - provider, model = resolve_provider("any.context", agent_type) + provider, model = resolve_provider(agent_type) assert provider == NO_BRAIN_PROVIDER assert provider != "google" @@ -152,6 +149,17 @@ def test_unconfigured_execution_stops_before_cloud(tmp_path, monkeypatch): assert not (tmp_path / "config" / "journal.json").exists() +def test_no_brain_configured_error_is_not_retried(tmp_path, monkeypatch): + _empty_journal(tmp_path, monkeypatch) + mocks = _cloud_call_mocks(monkeypatch) + + with pytest.raises(NoBrainConfiguredError): + talents.prepare_config({"name": "chat"}) + + for mock in mocks: + mock.assert_not_called() + + def test_confidential_generate_stops_before_any_provider_dispatch( tmp_path, monkeypatch, @@ -260,10 +268,6 @@ def test_confidential_attestation_error_is_non_retryable(tmp_path, monkeypatch): establish = _install_failing_confidential_transport(monkeypatch) mocks = _cloud_call_mocks(monkeypatch) - assert talents._is_retryable_error(AttestationNotVerifiedError()) is False - for exc in (AttestationFailedError("x"), AttestationStaleError("x")): - assert talents._is_retryable_error(exc) is False - assert talents._should_fallback(exc) is False from solstone.think.services.spp_transport import verify_confidential_attestation assert ( @@ -273,7 +277,7 @@ def test_confidential_attestation_error_is_non_retryable(tmp_path, monkeypatch): with pytest.raises(AttestationFailedError) as exc_info: asyncio.run( talents._execute_with_tools( - {"provider": "google", "backup": "anthropic"}, + {"provider": "google", "type": "cogitate"}, lambda _event: None, ) ) @@ -346,27 +350,30 @@ def test_confidential_stt_chokepoint_blocks_cloud_audio_egress( parakeet_transcribe.assert_called_once() -def test_none_provider_module_and_backup_fail_closed(tmp_path, monkeypatch): +def test_none_provider_module_fails_closed(tmp_path, monkeypatch): _empty_journal(tmp_path, monkeypatch) with pytest.raises(NoBrainConfiguredError): get_provider_module(NO_BRAIN_PROVIDER) - assert get_backup_provider("generate") is None assert not (tmp_path / "config" / "journal.json").exists() @pytest.mark.parametrize( - ("env_key", "expected_provider", "expected_model"), + ("agent_type", "env_key", "expected_provider", "expected_model"), [ - ("GOOGLE_API_KEY", "google", GEMINI_FLASH), - ("ANTHROPIC_API_KEY", "anthropic", CLAUDE_SONNET_4), - ("OPENAI_API_KEY", "openai", GPT_5_MINI), + ("generate", "GOOGLE_API_KEY", "google", GEMINI_FLASH), + ("generate", "ANTHROPIC_API_KEY", "anthropic", CLAUDE_SONNET_4), + ("generate", "OPENAI_API_KEY", "openai", GPT_5_MINI), + ("cogitate", "GOOGLE_API_KEY", "google", GEMINI_FLASH), + ("cogitate", "ANTHROPIC_API_KEY", "anthropic", CLAUDE_SONNET_4), + ("cogitate", "OPENAI_API_KEY", "openai", GPT_5_MINI), ], ) def test_key_presence_grandfathers_existing_installs( tmp_path, monkeypatch, + agent_type: str, env_key: str, expected_provider: str, expected_model: str, @@ -374,7 +381,7 @@ def test_key_presence_grandfathers_existing_installs( _empty_journal(tmp_path, monkeypatch) original = _write_journal_config(tmp_path, {"env": {env_key: "test-key"}}) - provider, model = resolve_provider("any.context", "generate") + provider, model = resolve_provider(agent_type) assert provider == expected_provider assert model == expected_model @@ -383,13 +390,55 @@ def test_key_presence_grandfathers_existing_installs( ) == original +def test_model_only_config_uses_key_selected_provider(tmp_path, monkeypatch): + _empty_journal(tmp_path, monkeypatch) + _write_journal_config( + tmp_path, + { + "env": {"GOOGLE_API_KEY": "test-key"}, + "providers": {"generate": {"model": "gemini-custom"}}, + }, + ) + + assert resolve_provider("generate") == ("google", "gemini-custom") + + +def test_explicit_provider_does_not_fall_through_to_keyed_provider( + tmp_path, monkeypatch +): + _empty_journal(tmp_path, monkeypatch) + _write_journal_config( + tmp_path, + { + "env": {"GOOGLE_API_KEY": "test-key"}, + "providers": {"generate": {"provider": "anthropic"}}, + }, + ) + google = Mock(side_effect=AssertionError("google dispatched")) + monkeypatch.setattr("solstone.think.providers.google.run_generate", google) + + assert resolve_provider("generate") == ("anthropic", CLAUDE_SONNET_4) + with pytest.raises(ValueError, match="ANTHROPIC_API_KEY not found"): + models.generate("hello", "any.context") + google.assert_not_called() + + +def test_accepted_grandfather_divergence_lite_context_now_uses_brain_model( + tmp_path, monkeypatch +): + _empty_journal(tmp_path, monkeypatch) + _write_journal_config(tmp_path, {"env": {"GOOGLE_API_KEY": "test-key"}}) + + assert resolve_provider("generate") == ("google", GEMINI_FLASH) + + def test_implicit_local_when_runtime_ready(tmp_path, monkeypatch): _empty_journal(tmp_path, monkeypatch) monkeypatch.setattr( "solstone.think.providers.state.local_runtime_ready", lambda: True ) - provider, model = resolve_provider("any.context", "generate") + provider, model = resolve_provider("generate") assert provider == "local" assert model == LOCAL_MODEL @@ -417,7 +466,7 @@ def test_explicit_local_type_default_neutralizes_cloud_context_pin( }, ) - provider, model = resolve_provider("talent.timeline.segment_summary", "generate") + provider, model = resolve_provider("generate") assert provider == "local" assert provider != "google" diff --git a/tests/test_participation_talent.py b/tests/test_participation_talent.py index 87a2bff3e..b1603100f 100644 --- a/tests/test_participation_talent.py +++ b/tests/test_participation_talent.py @@ -15,7 +15,7 @@ def test_participation_talent_frontmatter_and_placeholders(): assert post.metadata["schedule"] == "activity" assert post.metadata["activities"] == ["*"] - assert post.metadata["tier"] == 3 + assert "tier" not in post.metadata assert post.metadata["output"] == "json" assert post.metadata["schema"] == "participation.schema.json" assert post.metadata["priority"] == 10 diff --git a/tests/test_provider_state.py b/tests/test_provider_state.py index 5020dbdc4..c5074f2a5 100644 --- a/tests/test_provider_state.py +++ b/tests/test_provider_state.py @@ -897,7 +897,7 @@ def test_local_status_dict_darwin(monkeypatch): def test_readiness_for_context_routes_to_resolved_local_provider(monkeypatch): monkeypatch.setattr( "solstone.think.models.resolve_provider", - lambda _context, _interface: ("local", LOCAL_MODEL), + lambda _interface: ("local", LOCAL_MODEL), ) monkeypatch.setattr( local_install, @@ -917,7 +917,7 @@ def test_readiness_for_context_routes_to_resolved_local_provider(monkeypatch): def test_readiness_for_context_routes_to_resolved_cloud_provider(monkeypatch): monkeypatch.setattr( "solstone.think.models.resolve_provider", - lambda _context, _interface: ("google", "gemini"), + lambda _interface: ("google", "gemini"), ) monkeypatch.setattr(state, "cloud_key_configured", lambda _env_key: True) monkeypatch.setattr( @@ -947,7 +947,12 @@ def test_readiness_for_context_routes_to_resolved_cloud_provider(monkeypatch): def test_record_quota_failure_writes_reason_code(monkeypatch, tmp_path): monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) - state.record_quota_failure("google", "flash", "gemini", "cogitate", 12345) + state.record_quota_failure("google", "gemini", "cogitate", 12345) payload = json.loads((tmp_path / "health" / "talents.json").read_text()) - assert payload["results"][0]["reason_code"] == "provider_quota_exceeded" + row = payload["results"][0] + assert row["provider"] == "google" + assert row["model"] == "gemini" + assert row["interface"] == "cogitate" + assert "tier" not in row + assert row["reason_code"] == "provider_quota_exceeded" diff --git a/tests/test_providers_check.py b/tests/test_providers_check.py index 287c228b2..8f2306206 100644 --- a/tests/test_providers_check.py +++ b/tests/test_providers_check.py @@ -3,6 +3,7 @@ import argparse import asyncio +import fcntl import json from datetime import datetime from types import SimpleNamespace @@ -19,21 +20,29 @@ def _patch_health_journal(monkeypatch, providers_cli, tmp_path): ) +def _args(**overrides): + values = { + "provider": None, + "interface": None, + "model": None, + "json": False, + "timeout": 1, + "targeted": False, + } + values.update(overrides) + return argparse.Namespace(**values) + + def test_run_check_writes_health_file(tmp_path, monkeypatch): - """_run_check writes provider health results to SOLSTONE_JOURNAL/health/talents.json.""" import solstone.think.providers_cli as providers_cli - fake_registry = {"fake": object()} - fake_defaults = { - "fake": { - 1: "fake-pro-model", - 2: "fake-flash-model", - 3: "fake-lite-model", - } - } - - monkeypatch.setattr("solstone.think.providers.PROVIDER_REGISTRY", fake_registry) - monkeypatch.setattr("solstone.think.models.PROVIDER_DEFAULTS", fake_defaults) + monkeypatch.setattr( + "solstone.think.providers.PROVIDER_REGISTRY", {"fake": object()} + ) + monkeypatch.setattr( + "solstone.think.models.default_model_for_provider", + lambda provider: f"{provider}-model", + ) _patch_health_journal(monkeypatch, providers_cli, tmp_path) monkeypatch.setattr( providers_cli, @@ -46,47 +55,27 @@ def test_run_check_writes_health_file(tmp_path, monkeypatch): monkeypatch.setattr(providers_cli, "_check_cogitate", mock_check_cogitate) - args = argparse.Namespace( - provider=None, - interface=None, - tier=None, - json=False, - timeout=1, - targeted=False, - ) - with pytest.raises(SystemExit) as exc_info: - asyncio.run(providers_cli._run_check(args)) + asyncio.run(providers_cli._run_check(_args())) assert exc_info.value.code == 0 - - health_file = tmp_path / "health" / "talents.json" - assert health_file.exists() - - payload = json.loads(health_file.read_text()) - assert "results" in payload - assert "summary" in payload - assert "checked_at" in payload + payload = json.loads((tmp_path / "health" / "talents.json").read_text()) assert datetime.fromisoformat(payload["checked_at"]).tzinfo is not None - assert payload["summary"]["passed"] > 0 - assert payload["summary"]["skipped"] == 0 + assert payload["summary"] == {"total": 2, "passed": 2, "skipped": 0, "failed": 0} + assert all("tier" not in row for row in payload["results"]) + assert all("reused_from" not in row for row in payload["results"]) def test_run_check_partial_failure_exits_one(tmp_path, monkeypatch): - """_run_check exits 1 when any check fails.""" import solstone.think.providers_cli as providers_cli - fake_registry = {"fake": object()} - fake_defaults = { - "fake": { - 1: "fake-pro-model", - 2: "fake-flash-model", - 3: "fake-lite-model", - } - } - - monkeypatch.setattr("solstone.think.providers.PROVIDER_REGISTRY", fake_registry) - monkeypatch.setattr("solstone.think.models.PROVIDER_DEFAULTS", fake_defaults) + monkeypatch.setattr( + "solstone.think.providers.PROVIDER_REGISTRY", {"fake": object()} + ) + monkeypatch.setattr( + "solstone.think.models.default_model_for_provider", + lambda _provider: "fake-model", + ) _patch_health_journal(monkeypatch, providers_cli, tmp_path) monkeypatch.setattr( providers_cli, @@ -99,97 +88,26 @@ def test_run_check_partial_failure_exits_one(tmp_path, monkeypatch): monkeypatch.setattr(providers_cli, "_check_cogitate", mock_check_cogitate) - args = argparse.Namespace( - provider=None, - interface=None, - tier=None, - json=False, - timeout=1, - targeted=False, - ) - with pytest.raises(SystemExit) as exc_info: - asyncio.run(providers_cli._run_check(args)) + asyncio.run(providers_cli._run_check(_args())) assert exc_info.value.code == 1 - - health_file = tmp_path / "health" / "talents.json" - payload = json.loads(health_file.read_text()) - assert payload["summary"]["passed"] == 3 - assert payload["summary"]["skipped"] == 0 - assert payload["summary"]["failed"] == 3 - assert all( - row["reason_code"] == "unknown" - for row in payload["results"] - if row["status"] == "fail" - ) + payload = json.loads((tmp_path / "health" / "talents.json").read_text()) + assert payload["summary"] == {"total": 2, "passed": 1, "skipped": 0, "failed": 1} + assert payload["results"][1]["reason_code"] == "unknown" -def test_run_check_full_provider_failure_exits_one(tmp_path, monkeypatch): - """_run_check exits 1 when all checks for a provider fail.""" +def test_run_check_targeted_uses_active_routes(tmp_path, monkeypatch): import solstone.think.providers_cli as providers_cli - fake_registry = {"fake": object()} - fake_defaults = { - "fake": { - 1: "fake-pro-model", - 2: "fake-flash-model", - 3: "fake-lite-model", - } - } - - monkeypatch.setattr("solstone.think.providers.PROVIDER_REGISTRY", fake_registry) - monkeypatch.setattr("solstone.think.models.PROVIDER_DEFAULTS", fake_defaults) _patch_health_journal(monkeypatch, providers_cli, tmp_path) monkeypatch.setattr( - providers_cli, - "_check_generate", - lambda *_args: ("fail", "FAIL: key not set", "unknown"), - ) - - async def mock_check_cogitate(*_args): - return "fail", "FAIL: key not set", "unknown" - - monkeypatch.setattr(providers_cli, "_check_cogitate", mock_check_cogitate) - - args = argparse.Namespace( - provider=None, - interface=None, - tier=None, - json=False, - timeout=1, - targeted=False, + "solstone.think.models.resolve_provider", + lambda interface: { + "generate": ("google", "gemini-flash-latest"), + "cogitate": ("openai", "gpt-5.4-mini"), + }[interface], ) - - with pytest.raises(SystemExit) as exc_info: - asyncio.run(providers_cli._run_check(args)) - - assert exc_info.value.code == 1 - - health_file = tmp_path / "health" / "talents.json" - payload = json.loads(health_file.read_text()) - assert payload["summary"]["passed"] == 0 - assert payload["summary"]["skipped"] == 0 - assert payload["summary"]["failed"] == 6 - - -def test_run_check_dedup_same_model(tmp_path, monkeypatch): - """_run_check deduplicates checks when tiers resolve to the same model.""" - import solstone.think.providers_cli as providers_cli - - fake_registry = {"fake": object()} - fake_defaults = { - "fake": { - 1: "fake-same-model", - 2: "fake-same-model", - 3: "fake-same-model", - } - } - - monkeypatch.setattr("solstone.think.providers.PROVIDER_REGISTRY", fake_registry) - monkeypatch.setattr("solstone.think.models.PROVIDER_DEFAULTS", fake_defaults) - _patch_health_journal(monkeypatch, providers_cli, tmp_path) - gen_mock = MagicMock(return_value=("ok", "ok", None)) monkeypatch.setattr(providers_cli, "_check_generate", gen_mock) @@ -200,115 +118,24 @@ def test_run_check_dedup_same_model(tmp_path, monkeypatch): monkeypatch.setattr(providers_cli, "_check_cogitate", mock_check_cogitate) - args = argparse.Namespace( - provider=None, - interface=None, - tier=None, - json=False, - timeout=1, - targeted=False, - ) - with pytest.raises(SystemExit) as exc_info: - asyncio.run(providers_cli._run_check(args)) + asyncio.run(providers_cli._run_check(_args(targeted=True, json=True))) assert exc_info.value.code == 0 - assert gen_mock.call_count == 1 - assert cog_inner.call_count == 1 - - health_file = tmp_path / "health" / "talents.json" - assert health_file.exists() - - payload = json.loads(health_file.read_text()) - results = payload["results"] - assert len(results) == 6 - assert payload["summary"]["total"] == 6 - assert payload["summary"]["passed"] == 6 - assert payload["summary"]["skipped"] == 0 - - non_reused = [result for result in results if "reused_from" not in result] - reused = [result for result in results if "reused_from" in result] - assert len(non_reused) == 2 - assert len(reused) == 4 - assert all(result["reused_from"] == "pro" for result in reused) - assert all(result["elapsed_s"] == 0.0 for result in reused) - - -def test_run_check_targeted_filters_to_configured_pairs(tmp_path, monkeypatch): - """--targeted filters checks to only configured provider+tier pairs.""" - import solstone.think.providers_cli as providers_cli - - fake_registry = {"provA": object(), "provB": object(), "provC": object()} - fake_defaults = { - "provA": {1: "a-pro", 2: "a-flash", 3: "a-lite"}, - "provB": {1: "b-pro", 2: "b-flash", 3: "b-lite"}, - "provC": {1: "c-pro", 2: "c-flash", 3: "c-lite"}, - } - fake_type_defaults = { - "generate": {"provider": "provA", "tier": 2, "backup": "provB"}, - "cogitate": {"provider": "provC", "tier": 2, "backup": "provB"}, + gen_mock.assert_called_once_with("google", "gemini-flash-latest", 1) + cog_inner.assert_called_once_with("openai", "gpt-5.4-mini", 1) + payload = json.loads((tmp_path / "health" / "talents.json").read_text()) + assert { + (row["provider"], row["model"], row["interface"]) for row in payload["results"] + } == { + ("google", "gemini-flash-latest", "generate"), + ("openai", "gpt-5.4-mini", "cogitate"), } - monkeypatch.setattr("solstone.think.providers.PROVIDER_REGISTRY", fake_registry) - monkeypatch.setattr("solstone.think.models.PROVIDER_DEFAULTS", fake_defaults) - monkeypatch.setattr("solstone.think.models.TYPE_DEFAULTS", fake_type_defaults) - monkeypatch.setattr( - "solstone.think.models.resolve_provider", - lambda _context, agent_type: ( - fake_type_defaults[agent_type]["provider"], - fake_defaults[fake_type_defaults[agent_type]["provider"]][2], - ), - ) - _patch_health_journal(monkeypatch, providers_cli, tmp_path) - monkeypatch.setattr( - providers_cli, - "_check_generate", - lambda *_args: ("ok", "ok", None), - ) - - async def mock_check_cogitate(*_args): - return "ok", "ok", None - - monkeypatch.setattr(providers_cli, "_check_cogitate", mock_check_cogitate) - - # Mock get_config to return no overrides (use TYPE_DEFAULTS) - monkeypatch.setattr("solstone.think.utils.get_config", lambda: {}) - - # Mock get_backup_provider to return the backup from fake_type_defaults - def fake_get_backup(agent_type): - d = fake_type_defaults[agent_type] - if d["backup"] == d["provider"]: - return None - return d["backup"] - - monkeypatch.setattr("solstone.think.models.get_backup_provider", fake_get_backup) - - args = argparse.Namespace( - provider=None, - interface=None, - tier=None, - json=True, - timeout=1, - targeted=True, - ) - - with pytest.raises(SystemExit) as exc_info: - asyncio.run(providers_cli._run_check(args)) - - assert exc_info.value.code == 0 - - health_file = tmp_path / "health" / "talents.json" - payload = json.loads(health_file.read_text()) - # Expected targeted pairs: (provA, 2), (provB, 2), (provC, 2) = 3 pairs × 2 interfaces = 6 checks - assert payload["summary"]["total"] == 6 - checked_pairs = {(r["provider"], r["tier"]) for r in payload["results"]} - assert checked_pairs == {("provA", "flash"), ("provB", "flash"), ("provC", "flash")} - def test_run_check_targeted_empty_journal_uses_real_resolution( tmp_path, monkeypatch, capsys ): - """--targeted uses resolve_provider and selects no cloud pair for no-brain.""" import solstone.think.providers_cli as providers_cli from solstone.think.models import NO_BRAIN_PROVIDER, resolve_provider @@ -319,9 +146,8 @@ def test_run_check_targeted_empty_journal_uses_real_resolution( "solstone.think.providers.state.local_runtime_ready", lambda: False ) _patch_health_journal(monkeypatch, providers_cli, tmp_path) - - gen_mock = MagicMock(side_effect=AssertionError("cloud provider selected")) - cog_mock = MagicMock(side_effect=AssertionError("cloud provider selected")) + gen_mock = MagicMock(side_effect=AssertionError("provider selected")) + cog_mock = MagicMock(side_effect=AssertionError("provider selected")) monkeypatch.setattr(providers_cli, "_check_generate", gen_mock) async def mock_check_cogitate(*_args): @@ -329,89 +155,93 @@ def test_run_check_targeted_empty_journal_uses_real_resolution( monkeypatch.setattr(providers_cli, "_check_cogitate", mock_check_cogitate) - args = argparse.Namespace( - provider=None, - interface=None, - tier=None, - json=True, - timeout=1, - targeted=True, - ) - with pytest.raises(SystemExit) as exc_info: - asyncio.run(providers_cli._run_check(args)) + asyncio.run(providers_cli._run_check(_args(targeted=True, json=True))) assert exc_info.value.code == 0 - assert resolve_provider("", "generate") == (NO_BRAIN_PROVIDER, "") - assert resolve_provider("", "cogitate") == (NO_BRAIN_PROVIDER, "") + assert resolve_provider("generate") == (NO_BRAIN_PROVIDER, "") + assert resolve_provider("cogitate") == (NO_BRAIN_PROVIDER, "") gen_mock.assert_not_called() cog_mock.assert_not_called() - payload = json.loads((tmp_path / "health" / "talents.json").read_text()) assert payload["results"] == [] assert payload["summary"] == {"total": 0, "passed": 0, "skipped": 0, "failed": 0} - printed = capsys.readouterr().out - assert '"results": []' in printed - assert "google" not in printed + assert '"results": []' in capsys.readouterr().out def test_run_check_targeted_flock_dedup(tmp_path, monkeypatch): - """--targeted exits silently when another targeted check holds the lock.""" - import fcntl - import solstone.think.providers_cli as providers_cli - fake_registry = {"fake": object()} - fake_defaults = {"fake": {1: "m", 2: "m", 3: "m"}} - fake_type_defaults = { - "generate": {"provider": "fake", "tier": 2, "backup": "fake"}, - "cogitate": {"provider": "fake", "tier": 2, "backup": "fake"}, - } - - monkeypatch.setattr("solstone.think.providers.PROVIDER_REGISTRY", fake_registry) - monkeypatch.setattr("solstone.think.models.PROVIDER_DEFAULTS", fake_defaults) - monkeypatch.setattr("solstone.think.models.TYPE_DEFAULTS", fake_type_defaults) + _patch_health_journal(monkeypatch, providers_cli, tmp_path) monkeypatch.setattr( "solstone.think.models.resolve_provider", - lambda _context, agent_type: ( - fake_type_defaults[agent_type]["provider"], - fake_defaults[fake_type_defaults[agent_type]["provider"]][2], - ), + lambda _interface: ("google", "gemini-flash-latest"), ) - _patch_health_journal(monkeypatch, providers_cli, tmp_path) - monkeypatch.setattr("solstone.think.utils.get_config", lambda: {}) - monkeypatch.setattr("solstone.think.models.get_backup_provider", lambda _: None) - - # Pre-acquire the lock to simulate a concurrent check lock_dir = tmp_path / "health" lock_dir.mkdir(parents=True, exist_ok=True) - lock_file = open(lock_dir / "recheck.lock", "w") + lock_file = open(lock_dir / "recheck.lock", "w", encoding="utf-8") fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) - gen_mock = MagicMock(return_value=("ok", "ok", None)) monkeypatch.setattr(providers_cli, "_check_generate", gen_mock) - args = argparse.Namespace( - provider=None, - interface=None, - tier=None, - json=False, - timeout=1, - targeted=True, + asyncio.run(providers_cli._run_check(_args(targeted=True))) + + gen_mock.assert_not_called() + assert not (tmp_path / "health" / "talents.json").exists() + lock_file.close() + + +def test_explicit_provider_uses_single_default_model(tmp_path, monkeypatch): + import solstone.think.providers_cli as providers_cli + + _patch_health_journal(monkeypatch, providers_cli, tmp_path) + monkeypatch.setattr( + "solstone.think.providers.PROVIDER_REGISTRY", + {"fake": object(), "other": object()}, ) + monkeypatch.setattr( + "solstone.think.models.default_model_for_provider", + lambda provider: f"{provider}-default", + ) + gen_mock = MagicMock(return_value=("ok", "ok", None)) + monkeypatch.setattr(providers_cli, "_check_generate", gen_mock) - # Should return silently (no SystemExit, no checks run) - asyncio.run(providers_cli._run_check(args)) - assert gen_mock.call_count == 0 + with pytest.raises(SystemExit): + asyncio.run( + providers_cli._run_check( + _args(provider=["fake"], interface="generate", json=True) + ) + ) - # No health file written - assert not (tmp_path / "health" / "talents.json").exists() + gen_mock.assert_called_once_with("fake", "fake-default", 1) - lock_file.close() + +def test_explicit_provider_can_override_model(tmp_path, monkeypatch): + import solstone.think.providers_cli as providers_cli + + _patch_health_journal(monkeypatch, providers_cli, tmp_path) + monkeypatch.setattr( + "solstone.think.providers.PROVIDER_REGISTRY", {"fake": object()} + ) + gen_mock = MagicMock(return_value=("ok", "ok", None)) + monkeypatch.setattr(providers_cli, "_check_generate", gen_mock) + + with pytest.raises(SystemExit): + asyncio.run( + providers_cli._run_check( + _args( + provider=["fake"], + interface="generate", + model="custom-model", + json=True, + ) + ) + ) + + gen_mock.assert_called_once_with("fake", "custom-model", 1) def test_check_generate_logs_token_usage(monkeypatch): - """_check_generate logs token usage when result includes usage data.""" import solstone.think.providers_cli as providers_cli fake_module = MagicMock() @@ -419,56 +249,31 @@ def test_check_generate_logs_token_usage(monkeypatch): "text": "OK", "usage": {"input_tokens": 5, "output_tokens": 2}, } - monkeypatch.setattr( "solstone.think.providers.get_provider_module", lambda _: fake_module ) monkeypatch.setattr( "solstone.think.providers.PROVIDER_METADATA", - {"fake": {"env_key": "FAKE_API_KEY"}}, - ) - monkeypatch.setattr( - "solstone.think.models.PROVIDER_DEFAULTS", {"fake": {2: "fake-flash"}} + {"fake": {"env_key": "FAKE_API_KEY", "label": "Fake Provider"}}, ) monkeypatch.setenv("FAKE_API_KEY", "test-key") - log_mock = MagicMock() monkeypatch.setattr("solstone.think.models.log_token_usage", log_mock) - status, msg, reason_code = providers_cli._check_generate("fake", 2, 30) + status, msg, reason_code = providers_cli._check_generate("fake", "fake-model", 30) assert status == "ok" assert msg == "OK" assert reason_code is None log_mock.assert_called_once_with( - model="fake-flash", + model="fake-model", usage={"input_tokens": 5, "output_tokens": 2}, context="health.check.generate", type="generate", ) -def test_cortex_start_emits_providers_check(tmp_path): - """Cortex startup requests a providers health check via supervisor.""" - from solstone.think.cortex import CortexService - - cortex = CortexService(journal_path=str(tmp_path)) - cortex.callosum = MagicMock() - cortex.callosum.start.return_value = None - cortex.shutdown_requested.set() - - with patch("solstone.think.cortex.threading.Thread") as mock_thread: - mock_thread.return_value = MagicMock() - with patch("solstone.think.cortex.time.sleep", return_value=None): - cortex.start() - - cortex.callosum.emit.assert_any_call( - "supervisor", "request", cmd=["journal", "providers", "check"] - ) - - def test_missing_env_key_returns_skip(monkeypatch): - """_check_generate returns skip status when env key is not set.""" import solstone.think.providers_cli as providers_cli monkeypatch.setattr( @@ -477,7 +282,7 @@ def test_missing_env_key_returns_skip(monkeypatch): ) monkeypatch.delenv("FAKE_API_KEY", raising=False) - status, msg, reason_code = providers_cli._check_generate("fake", 2, 30) + status, msg, reason_code = providers_cli._check_generate("fake", "fake-model", 30) assert status == "skip" assert reason_code == "provider_key_missing" assert "Fake Provider not configured" in msg @@ -499,7 +304,7 @@ def test_check_cogitate_cloud_configured_runs_without_install_skip(monkeypatch): ) status, msg, reason_code = asyncio.run( - providers_cli._check_cogitate("anthropic", 2, 30) + providers_cli._check_cogitate("anthropic", "claude-sonnet-4-6", 30) ) assert (status, msg) == ("ok", "OK") @@ -517,7 +322,6 @@ def test_check_cogitate_local_missing_runtime_names_local_install_hint(monkeypat "cogitate_cli_found": False, }, ) - monkeypatch.setattr( "solstone.think.providers.state.readiness_for_provider", lambda *_args: type("FakeState", (), {"reason_code": "local_model_missing"})(), @@ -528,7 +332,7 @@ def test_check_cogitate_local_missing_runtime_names_local_install_hint(monkeypat ) status, msg, reason_code = asyncio.run( - providers_cli._check_cogitate("local", 2, 30) + providers_cli._check_cogitate("local", "local/qwen3.5-4b", 30) ) assert status == "skip" @@ -561,7 +365,7 @@ def test_check_cogitate_local_endpoint_unreachable_uses_endpoint_reason(monkeypa ) status, msg, reason_code = asyncio.run( - providers_cli._check_cogitate("local", 2, 30) + providers_cli._check_cogitate("local", "local/qwen3.5-4b", 30) ) assert status == "skip" @@ -570,206 +374,19 @@ def test_check_cogitate_local_endpoint_unreachable_uses_endpoint_reason(monkeypa assert "journal install-provider local" not in msg -def test_check_cogitate_local_gpu_unavailable_uses_issue_copy(monkeypatch): - import solstone.think.providers_cli as providers_cli - - monkeypatch.setattr( - providers_cli, - "_provider_status", - lambda _name: { - "configured": True, - "cogitate_cli_found": True, - "cogitate_ready": False, - "issues": ["gpu_unavailable"], - }, - ) - - monkeypatch.setattr( - "solstone.think.providers.state.readiness_for_provider", - lambda *_args: type("FakeState", (), {"reason_code": "gpu_unavailable"})(), - ) - - status, msg, reason_code = asyncio.run( - providers_cli._check_cogitate("local", 2, 30) - ) - - assert status == "skip" - assert msg == "gpu_unavailable" - assert reason_code == "gpu_unavailable" - - -def test_all_skip_exits_zero(tmp_path, monkeypatch): - """Exit code is 0 when all results are skipped (no fails).""" - import solstone.think.providers_cli as providers_cli - - fake_registry = {"fake": object()} - fake_defaults = {"fake": {1: "m1", 2: "m2", 3: "m3"}} - - monkeypatch.setattr("solstone.think.providers.PROVIDER_REGISTRY", fake_registry) - monkeypatch.setattr("solstone.think.models.PROVIDER_DEFAULTS", fake_defaults) - _patch_health_journal(monkeypatch, providers_cli, tmp_path) - monkeypatch.setattr( - providers_cli, - "_check_generate", - lambda *_args: ("skip", "not configured", "provider_key_missing"), - ) - - async def mock_check_cogitate(*_args): - return "skip", "not configured", "provider_key_missing" - - monkeypatch.setattr(providers_cli, "_check_cogitate", mock_check_cogitate) - - args = argparse.Namespace( - provider=None, - interface=None, - tier=None, - json=False, - timeout=1, - targeted=False, - ) - - with pytest.raises(SystemExit) as exc_info: - asyncio.run(providers_cli._run_check(args)) - - assert exc_info.value.code == 0 - - payload = json.loads((tmp_path / "health" / "talents.json").read_text()) - assert payload["summary"]["skipped"] == 6 - assert payload["summary"]["failed"] == 0 - assert payload["summary"]["passed"] == 0 - for result in payload["results"]: - assert result["status"] == "skip" - assert result["reason_code"] == "provider_key_missing" - assert result["ok"] is True - - -def test_mix_skip_and_fail_exits_one(tmp_path, monkeypatch): - """Exit code is 1 when there's a mix of skip and fail results.""" - import solstone.think.providers_cli as providers_cli - - fake_registry = {"fake": object()} - fake_defaults = {"fake": {1: "m1", 2: "m2", 3: "m3"}} - - monkeypatch.setattr("solstone.think.providers.PROVIDER_REGISTRY", fake_registry) - monkeypatch.setattr("solstone.think.models.PROVIDER_DEFAULTS", fake_defaults) - _patch_health_journal(monkeypatch, providers_cli, tmp_path) - monkeypatch.setattr( - providers_cli, - "_check_generate", - lambda *_args: ("skip", "not configured", "provider_key_missing"), - ) - - async def mock_check_cogitate(*_args): - return "fail", "FAIL: broken", "unknown" - - monkeypatch.setattr(providers_cli, "_check_cogitate", mock_check_cogitate) - - args = argparse.Namespace( - provider=None, - interface=None, - tier=None, - json=False, - timeout=1, - targeted=False, - ) - - with pytest.raises(SystemExit) as exc_info: - asyncio.run(providers_cli._run_check(args)) - - assert exc_info.value.code == 1 - - payload = json.loads((tmp_path / "health" / "talents.json").read_text()) - assert payload["summary"]["skipped"] == 3 - assert payload["summary"]["failed"] == 3 - - -def test_skipped_count_in_summary(tmp_path, monkeypatch): - """Summary total equals passed + skipped + failed.""" - import solstone.think.providers_cli as providers_cli - - fake_registry = {"okp": object(), "skipP": object()} - fake_defaults = { - "okp": {1: "m1", 2: "m2", 3: "m3"}, - "skipP": {1: "s1", 2: "s2", 3: "s3"}, - } - - monkeypatch.setattr("solstone.think.providers.PROVIDER_REGISTRY", fake_registry) - monkeypatch.setattr("solstone.think.models.PROVIDER_DEFAULTS", fake_defaults) - _patch_health_journal(monkeypatch, providers_cli, tmp_path) - - def mock_gen(provider, tier, timeout): - if provider == "okp": - return "ok", "OK", None - return "skip", "not configured", "provider_key_missing" - - monkeypatch.setattr(providers_cli, "_check_generate", mock_gen) - - async def mock_cog(provider, tier, timeout): - if provider == "okp": - return "ok", "OK", None - return "skip", "not configured", "provider_key_missing" - - monkeypatch.setattr(providers_cli, "_check_cogitate", mock_cog) - - args = argparse.Namespace( - provider=None, - interface=None, - tier=None, - json=True, - timeout=1, - targeted=False, - ) - - with pytest.raises(SystemExit) as exc_info: - asyncio.run(providers_cli._run_check(args)) - - assert exc_info.value.code == 0 - payload = json.loads((tmp_path / "health" / "talents.json").read_text()) - summary = payload["summary"] - assert ( - summary["total"] == summary["passed"] + summary["skipped"] + summary["failed"] - ) - assert summary["passed"] == 6 - assert summary["skipped"] == 6 - assert summary["failed"] == 0 - - -def test_status_field_in_json_output(tmp_path, monkeypatch, capsys): - """JSON output includes status per result and skipped in summary.""" - import solstone.think.providers_cli as providers_cli - - fake_registry = {"fake": object()} - fake_defaults = {"fake": {1: "m1", 2: "m2", 3: "m3"}} - - monkeypatch.setattr("solstone.think.providers.PROVIDER_REGISTRY", fake_registry) - monkeypatch.setattr("solstone.think.models.PROVIDER_DEFAULTS", fake_defaults) - _patch_health_journal(monkeypatch, providers_cli, tmp_path) - monkeypatch.setattr( - providers_cli, - "_check_generate", - lambda *_args: ("ok", "OK", None), - ) +def test_cortex_start_emits_providers_check(tmp_path): + from solstone.think.cortex import CortexService - async def mock_cog(*_args): - return "ok", "OK", None + cortex = CortexService(journal_path=str(tmp_path)) + cortex.callosum = MagicMock() + cortex.callosum.start.return_value = None + cortex.shutdown_requested.set() - monkeypatch.setattr(providers_cli, "_check_cogitate", mock_cog) + with patch("solstone.think.cortex.threading.Thread") as mock_thread: + mock_thread.return_value = MagicMock() + with patch("solstone.think.cortex.time.sleep", return_value=None): + cortex.start() - args = argparse.Namespace( - provider=None, - interface=None, - tier=None, - json=True, - timeout=1, - targeted=False, + cortex.callosum.emit.assert_any_call( + "supervisor", "request", cmd=["journal", "providers", "check"] ) - - with pytest.raises(SystemExit): - asyncio.run(providers_cli._run_check(args)) - - captured = capsys.readouterr() - data = json.loads(captured.out) - for result in data["results"]: - assert "status" in result - assert result["status"] in ("ok", "skip", "fail") - assert "skipped" in data["summary"] diff --git a/tests/test_sense_schema.py b/tests/test_sense_schema.py index ee0fa9490..40c88b8ef 100644 --- a/tests/test_sense_schema.py +++ b/tests/test_sense_schema.py @@ -111,7 +111,7 @@ def _render_sense_for_tmp_journal( def test_sense_prompt_parses_and_documents_role_and_source(): post = frontmatter.load(SENSE_PATH) - assert post.metadata["tier"] == 3 + assert "tier" not in post.metadata output_schema = _section( post.content, "## Output Schema", "## Field-by-Field Instructions" diff --git a/tests/test_talent_fallback.py b/tests/test_talent_fallback.py index 415b16160..7ae4670ba 100644 --- a/tests/test_talent_fallback.py +++ b/tests/test_talent_fallback.py @@ -1,1446 +1,179 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc -import asyncio import json -from datetime import datetime, timedelta, timezone -from io import StringIO -from types import SimpleNamespace from unittest.mock import MagicMock import pytest -from solstone.think.models import ( - LOCAL_MODEL, - NO_BRAIN_PROVIDER, - TYPE_DEFAULTS, - IncompleteJSONError, - NoBrainConfiguredError, - get_backup_provider, - is_provider_healthy, - is_provider_model_interface_healthy, - should_recheck_health, -) +from solstone.think.models import LOCAL_MODEL, IncompleteJSONError from solstone.think.providers.cli import QuotaExhaustedError from solstone.think.providers.local import LocalCapacityExhausted -from solstone.think.talents import ( - TalentHookError, - _is_retryable_error, - _should_fallback, -) -from solstone.think.utils import now_ms +from solstone.think.talents import _execute_generate, _execute_with_tools -def test_is_provider_healthy_all_failed(): - health_data = { - "results": [ - {"provider": "google", "ok": False}, - {"provider": "google", "ok": False}, - ] - } - assert is_provider_healthy("google", health_data) is False - - -def test_is_provider_healthy_some_passed(): - health_data = { - "results": [ - {"provider": "google", "ok": False}, - {"provider": "google", "ok": True}, - ] - } - assert is_provider_healthy("google", health_data) is True - - -def test_is_provider_healthy_no_data(): - assert is_provider_healthy("google", None) is True - - -def test_is_provider_healthy_no_results_for_provider(): - health_data = {"results": [{"provider": "anthropic", "ok": False}]} - assert is_provider_healthy("google", health_data) is True - - -def test_is_provider_model_interface_healthy_match_failed(): - health_data = { - "results": [ - { - "provider": "google", - "model": "gemini-3-flash-preview", - "interface": "cogitate", - "ok": False, - } - ] - } - assert ( - is_provider_model_interface_healthy( - "google", "gemini-3-flash-preview", "cogitate", health_data - ) - is False - ) - - -def test_is_provider_model_interface_healthy_mismatch_is_healthy(): - health_data = { - "results": [ - { - "provider": "google", - "model": "gemini-3-flash-preview", - "interface": "generate", - "ok": False, - } - ] - } - assert ( - is_provider_model_interface_healthy( - "google", "gemini-3-flash-preview", "cogitate", health_data - ) - is True - ) - - -def test_is_provider_model_interface_healthy_missing_fields_are_healthy(): - health_data = {"results": [{"provider": "google", "ok": False}]} - assert ( - is_provider_model_interface_healthy( - "google", "gemini-3-flash-preview", "cogitate", health_data - ) - is True - ) - - -def test_is_provider_model_interface_healthy_none_data(): - assert ( - is_provider_model_interface_healthy( - "google", "gemini-3-flash-preview", "cogitate", None - ) - is True - ) - - -def test_should_recheck_health_stale(): - checked_at = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat() - health_data = {"checked_at": checked_at} - assert should_recheck_health(health_data) is True - - -def test_should_recheck_health_fresh(): - checked_at = (datetime.now(timezone.utc) - timedelta(minutes=10)).isoformat() - health_data = {"checked_at": checked_at} - assert should_recheck_health(health_data) is False - - -def test_should_recheck_health_honors_reset_at_ms(): - checked_at = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat() - pre_reset = { - "checked_at": checked_at, - "results": [{"ok": False, "reset_at_ms": now_ms() + 60_000}], - } - post_reset = { - "checked_at": checked_at, - "results": [{"ok": False, "reset_at_ms": now_ms() - 1_000}], - } - no_reset = { - "checked_at": checked_at, - "results": [{"ok": False}], - } - - assert should_recheck_health(pre_reset) is False - assert should_recheck_health(post_reset) is True - assert should_recheck_health(no_reset) is True - - -def test_get_backup_provider_from_config(monkeypatch): - monkeypatch.setattr( - "solstone.think.models.get_config", - lambda: {"providers": {"generate": {"provider": "google", "backup": "openai"}}}, - ) - assert get_backup_provider("generate") == "openai" - - -def test_get_backup_provider_no_brain_disables_backup(monkeypatch): - monkeypatch.setattr("solstone.think.models.get_config", lambda: {}) - monkeypatch.setattr( - "solstone.think.providers.state.local_runtime_ready", lambda: False - ) - monkeypatch.delenv("GOOGLE_API_KEY", raising=False) - monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) - monkeypatch.delenv("OPENAI_API_KEY", raising=False) - - assert get_backup_provider("generate") is None - assert get_backup_provider("cogitate") is None - - -def test_get_backup_provider_keyed_default_uses_fallback_constant(monkeypatch): - monkeypatch.setattr("solstone.think.models.get_config", lambda: {}) - monkeypatch.setenv("GOOGLE_API_KEY", "test-key") - - assert get_backup_provider("generate") == TYPE_DEFAULTS["generate"]["backup"] - assert get_backup_provider("cogitate") == TYPE_DEFAULTS["cogitate"]["backup"] - - -def test_get_backup_provider_none_when_same_as_primary(monkeypatch): - monkeypatch.setattr( - "solstone.think.models.get_config", - lambda: { - "providers": { - "generate": {"provider": "openai", "backup": "openai"}, - } - }, - ) - assert get_backup_provider("generate") is None - - -@pytest.mark.parametrize("agent_type", ["generate", "cogitate"]) -def test_get_backup_provider_local_disables_backup(monkeypatch, agent_type): - monkeypatch.setattr( - "solstone.think.models.get_config", - lambda: { - "providers": { - agent_type: {"provider": "local", "backup": "anthropic"}, - } - }, - ) - assert get_backup_provider(agent_type) is None - - -@pytest.mark.parametrize("agent_type", ["generate", "cogitate"]) -def test_get_backup_provider_no_brain_primary_disables_backup(monkeypatch, agent_type): - monkeypatch.setattr( - "solstone.think.models.get_config", - lambda: { - "providers": { - agent_type: {"provider": NO_BRAIN_PROVIDER, "backup": "anthropic"}, - } - }, - ) - assert get_backup_provider(agent_type) is None - - -def test_no_brain_error_is_non_retryable(): - assert _is_retryable_error(NoBrainConfiguredError()) is False - assert _should_fallback(NoBrainConfiguredError()) is False - - -def test_execute_with_tools_local_failure_does_not_consult_backup(monkeypatch): - from solstone.think import talents - - class LocalModule: - @staticmethod - async def run_cogitate(config, on_event=None): - raise RuntimeError("binary_missing") - - monkeypatch.setattr( - "solstone.think.providers.get_provider_module", - lambda provider: LocalModule, - ) - - def fail_backup(_agent_type): - raise AssertionError("local failure must not consult cloud backup") - - monkeypatch.setattr("solstone.think.models.get_backup_provider", fail_backup) - - with pytest.raises(RuntimeError, match="binary_missing"): - asyncio.run( - talents._execute_with_tools( - { - "provider": "local", - "model": LOCAL_MODEL, - "output_path": None, - }, - lambda _event: None, - ) - ) - - -def _mock_base_agent_config() -> dict: +def _generate_config( + *, + provider: str = "anthropic", + model: str = "claude-sonnet-4-6", +) -> dict: return { - "type": "cogitate", - "path": None, - "sources": {}, - "system_instruction": "", - "user_instruction": "", - "prompt": "", - "disabled": False, + "name": "test_generator", + "type": "generate", + "provider": provider, + "model": model, + "prompt": "say ok", + "output": "md", + "output_path": None, + "thinking_budget": 0, + "max_output_tokens": 32, } -def _patch_prepare_config_dependencies(monkeypatch): - monkeypatch.setattr( - "solstone.think.talent.get_talent", - lambda *args, **kwargs: _mock_base_agent_config(), - ) - monkeypatch.setattr( - "solstone.think.talent.key_to_context", lambda _name: "talent.system.default" - ) - monkeypatch.setattr( - "solstone.think.models.resolve_provider", - lambda _context, _type: ("google", "gemini-3-flash-preview"), - ) - monkeypatch.setattr("solstone.think.models.get_context_registry", lambda: {}) - - -def test_prepare_config_rejects_frontmatter_outbound_approval(tmp_path, monkeypatch): - import solstone.think.talent as talent_module - from solstone.think.talents import prepare_config - - monkeypatch.setattr(talent_module, "TALENT_DIR", tmp_path) - (tmp_path / "approval_static.md").write_text( - "{\n" - ' "type": "cogitate",\n' - ' "outbound_approval": "static-template-value"\n' - "}\n\n" - "Prompt body\n" - ) - - with pytest.raises( - ValueError, - match=( - "declares 'outbound_approval' in frontmatter; " - "this field is launch-config-only" - ), - ): - prepare_config({"name": "approval_static", "prompt": "hello"}) - - -def test_preflight_swap_unhealthy_primary(monkeypatch): - from solstone.think.talents import prepare_config - - _patch_prepare_config_dependencies(monkeypatch) - monkeypatch.setattr( - "solstone.think.models.load_health_status", - lambda: { - "results": [ - { - "provider": "google", - "model": "gemini-3-flash-preview", - "interface": "cogitate", - "ok": False, - } - ] - }, - ) - monkeypatch.setattr("solstone.think.models.should_recheck_health", lambda _h: False) - monkeypatch.setattr( - "solstone.think.models.get_backup_provider", lambda _type: "anthropic" - ) - monkeypatch.setattr( - "solstone.think.models.resolve_model_for_provider", - lambda _context, _provider, _type="generate": "claude-sonnet-4-5", - ) - monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") - - config = prepare_config({"name": "chat", "prompt": "hello"}) - - assert config["provider"] == "anthropic" - assert config["model"] == "claude-sonnet-4-5" - assert config["fallback_from"] == "google" - - -def test_preflight_swap_reads_unified_backend(monkeypatch): - from solstone.think.models import CLAUDE_SONNET_4 - from solstone.think.providers import state - from solstone.think.talents import prepare_config - - _patch_prepare_config_dependencies(monkeypatch) - monkeypatch.setattr( - state, - "read_health_status", - lambda: { - "results": [ - { - "provider": "google", - "model": "gemini-3-flash-preview", - "interface": "cogitate", - "ok": False, - "reason_code": "provider_unavailable", - } - ] - }, - ) - monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") - - config = prepare_config({"name": "chat", "prompt": "hello"}) - - assert config["provider"] == "anthropic" - assert config["model"] == CLAUDE_SONNET_4 - assert config["fallback_from"] == "google" - - -def test_preflight_no_swap_healthy_primary(monkeypatch): - from solstone.think.talents import prepare_config - - _patch_prepare_config_dependencies(monkeypatch) - monkeypatch.setattr( - "solstone.think.models.load_health_status", - lambda: { - "results": [ - { - "provider": "google", - "model": "gemini-3-flash-preview", - "interface": "cogitate", - "ok": True, - } - ] - }, - ) - monkeypatch.setattr("solstone.think.models.should_recheck_health", lambda _h: False) - - config = prepare_config({"name": "chat", "prompt": "hello"}) - - assert config["provider"] == "google" - assert "fallback_from" not in config - - -def test_preflight_no_swap_no_backup_key(monkeypatch): - from solstone.think.talents import prepare_config - - _patch_prepare_config_dependencies(monkeypatch) - monkeypatch.setattr( - "solstone.think.models.load_health_status", - lambda: { - "results": [ - { - "provider": "google", - "model": "gemini-3-flash-preview", - "interface": "cogitate", - "ok": False, - } - ] - }, - ) - monkeypatch.setattr("solstone.think.models.should_recheck_health", lambda _h: False) - monkeypatch.setattr( - "solstone.think.models.get_backup_provider", lambda _type: "anthropic" - ) - monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) - - config = prepare_config({"name": "chat", "prompt": "hello"}) - - assert config["provider"] == "google" - assert "fallback_from" not in config - - -def test_on_failure_retry_cogitate(monkeypatch): - from solstone.think.talents import _execute_with_tools - - events = [] - attempts = {"primary": 0, "backup": 0} - - async def fail_cogitate(*_args, **_kwargs): - attempts["primary"] += 1 - raise RuntimeError("primary down") +def _health_rows(tmp_path): + return json.loads((tmp_path / "health" / "talents.json").read_text())["results"] - async def pass_cogitate(*_args, **kwargs): - attempts["backup"] += 1 - on_event = kwargs.get("on_event") - if on_event: - on_event({"event": "finish", "result": "backup result"}) - return "backup result" - monkeypatch.setattr( - "solstone.think.providers.PROVIDER_REGISTRY", {"google": "x", "anthropic": "y"} - ) - monkeypatch.setattr( - "solstone.think.providers.get_provider_module", - lambda provider: SimpleNamespace( - run_cogitate=fail_cogitate if provider == "google" else pass_cogitate - ), - ) - monkeypatch.setattr( - "solstone.think.models.get_backup_provider", lambda _type: "anthropic" - ) - monkeypatch.setattr( - "solstone.think.models.resolve_model_for_provider", - lambda _context, _provider, _type="cogitate": "claude-sonnet-4-5", +@pytest.mark.asyncio +async def test_execute_with_tools_quota_records_and_does_not_switch( + monkeypatch, + tmp_path, +): + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + run_cogitate = MagicMock( + side_effect=QuotaExhaustedError("quota exhausted", retry_delay_ms=5000) ) - monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") - - config = { - "provider": "google", - "model": "gemini-3-flash-preview", - "health_stale": False, - "context": "talent.system.default", - } - - asyncio.run(_execute_with_tools(config, events.append)) - - assert attempts["primary"] == 1 - assert attempts["backup"] == 1 - assert config["provider"] == "anthropic" - assert config["model"] == "claude-sonnet-4-5" - assert config["fallback_from"] == "google" - assert any(e.get("event") == "fallback" for e in events) - - -def test_quota_failure_records_health_and_falls_back(monkeypatch): - from solstone.think.talents import _execute_with_tools + monkeypatch.setattr("solstone.think.providers.openhands.run_cogitate", run_cogitate) events = [] - record_mock = MagicMock() - - async def fail_quota(*_args, **_kwargs): - raise QuotaExhaustedError("quota exhausted", retry_delay_ms=1000) - - async def pass_cogitate(*_args, **kwargs): - on_event = kwargs.get("on_event") - if on_event: - on_event({"event": "finish", "result": "backup result"}) - return "backup result" - - monkeypatch.setattr( - "solstone.think.providers.PROVIDER_REGISTRY", {"google": "x", "anthropic": "y"} - ) - monkeypatch.setattr( - "solstone.think.providers.get_provider_module", - lambda provider: SimpleNamespace( - run_cogitate=fail_quota if provider == "google" else pass_cogitate - ), - ) - monkeypatch.setattr( - "solstone.think.models.get_backup_provider", lambda _type: "anthropic" - ) - monkeypatch.setattr( - "solstone.think.models.resolve_model_for_provider", - lambda _context, _provider, _type="cogitate": "claude-sonnet-4-5", - ) - monkeypatch.setattr("solstone.think.models.record_provider_failure", record_mock) - monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") - config = { + "name": "test_agent", "type": "cogitate", - "provider": "google", - "tier": "flash", - "model": "gemini-3-flash-preview", - "health_stale": False, - "context": "talent.system.default", - } - before_ms = now_ms() - - asyncio.run(_execute_with_tools(config, events.append)) - - quota_event = next(e for e in events if e.get("reason") == "quota_exhausted") - assert quota_event["terminal"] is False - assert quota_event["reset_at_ms"] >= before_ms + 1000 - record_mock.assert_called_once_with( - "google", - "flash", - "gemini-3-flash-preview", - "cogitate", - quota_event["reset_at_ms"], - ) - assert config["provider"] == "anthropic" - assert events[-1]["event"] == "finish" - - -def test_on_failure_retry_cogitate_uses_context_from_name(monkeypatch): - from solstone.think.talents import _execute_with_tools - - events = [] - seen = {} - - async def fail_cogitate(*_args, **_kwargs): - raise RuntimeError("primary down") - - async def pass_cogitate(*_args, **kwargs): - on_event = kwargs.get("on_event") - if on_event: - on_event({"event": "finish", "result": "backup result"}) - return "backup result" - - def resolve_model(context, _provider, _type="cogitate"): - seen["context"] = context - return "claude-sonnet-4-5" - - monkeypatch.setattr( - "solstone.think.providers.PROVIDER_REGISTRY", {"google": "x", "anthropic": "y"} - ) - monkeypatch.setattr( - "solstone.think.providers.get_provider_module", - lambda provider: SimpleNamespace( - run_cogitate=fail_cogitate if provider == "google" else pass_cogitate - ), - ) - monkeypatch.setattr( - "solstone.think.talent.key_to_context", - lambda _name: "talent.system.default", - ) - monkeypatch.setattr( - "solstone.think.models.get_backup_provider", lambda _type: "anthropic" - ) - monkeypatch.setattr( - "solstone.think.models.resolve_model_for_provider", resolve_model - ) - monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") - - config = { - "name": "chat", - "provider": "google", - "model": "gemini-3-flash-preview", - "health_stale": False, - } - - asyncio.run(_execute_with_tools(config, events.append)) - - assert seen["context"] == "talent.system.default" - - -def test_execute_generate_uses_messages_when_present(monkeypatch): - from solstone.think.talents import _execute_generate - - events = [] - seen = {} - messages = [ - {"role": "user", "content": "first"}, - {"role": "assistant", "content": "second"}, - {"role": "user", "content": "third"}, - ] - - def mock_generate_with_result(**kwargs): - seen["contents"] = kwargs["contents"] - return {"text": "ok", "usage": {"input_tokens": 1, "output_tokens": 1}} - - monkeypatch.setattr( - "solstone.think.talent.key_to_context", lambda _name: "talent.system.default" - ) - monkeypatch.setattr( - "solstone.think.models.generate_with_result", mock_generate_with_result - ) - - config = { - "name": "chat", - "messages": messages, - "transcript": "ignored transcript", - "user_instruction": "ignored instruction", - "prompt": "ignored prompt", - "health_stale": False, - } - - asyncio.run(_execute_generate(config, events.append)) - - assert seen["contents"] == messages - assert events[-1]["event"] == "finish" - - -def test_execute_generate_preserves_string_contents_order(monkeypatch): - from solstone.think.talents import _execute_generate - - events = [] - seen = {} - - def mock_generate_with_result(**kwargs): - seen["contents"] = kwargs["contents"] - return {"text": "ok", "usage": {"input_tokens": 1, "output_tokens": 1}} - - monkeypatch.setattr( - "solstone.think.talent.key_to_context", lambda _name: "talent.system.default" - ) - monkeypatch.setattr( - "solstone.think.models.generate_with_result", mock_generate_with_result - ) - - config = { - "name": "chat", - "transcript": "transcript", - "user_instruction": "instruction", - "prompt": "prompt", - "health_stale": False, - } - - asyncio.run(_execute_generate(config, events.append)) - - assert seen["contents"] == ["transcript", "instruction", "prompt"] - assert events[-1]["event"] == "finish" - - -def test_execute_generate_passes_prepared_provider_and_model(monkeypatch): - from solstone.think.talents import _execute_generate - - events = [] - seen = {} - - def mock_generate_with_result(**kwargs): - seen["provider"] = kwargs.get("provider") - seen["model"] = kwargs.get("model") - return {"text": "ok", "usage": {"input_tokens": 1, "output_tokens": 1}} - - monkeypatch.setattr( - "solstone.think.talent.key_to_context", lambda _name: "talent.system.default" - ) - monkeypatch.setattr( - "solstone.think.models.generate_with_result", mock_generate_with_result - ) - - config = { - "name": "chat", - "provider": "google", - "model": "gemini-3-flash-preview", - "prompt": "hello", - "health_stale": False, - } - - asyncio.run(_execute_generate(config, events.append)) - - assert seen["provider"] == "google" - assert seen["model"] == "gemini-3-flash-preview" - assert events[-1]["event"] == "finish" - - -def test_execute_generate_local_failure_does_not_consult_backup(monkeypatch): - from solstone.think.talents import _execute_generate - - events = [] - calls = {"count": 0} - - def mock_generate_with_result(**_kwargs): - calls["count"] += 1 - raise RuntimeError("binary_missing") - - def fail_backup(_agent_type): - raise AssertionError("local failure must not consult cloud backup") - - monkeypatch.setattr( - "solstone.think.talent.key_to_context", lambda _name: "talent.system.default" - ) - monkeypatch.setattr( - "solstone.think.models.generate_with_result", mock_generate_with_result - ) - monkeypatch.setattr("solstone.think.models.get_backup_provider", fail_backup) - - config = { - "name": "chat", - "provider": "local", - "prompt": "hello", - "health_stale": False, + "provider": "anthropic", + "model": "claude-sonnet-4-6", } - with pytest.raises(RuntimeError, match="binary_missing"): - asyncio.run(_execute_generate(config, events.append)) + with pytest.raises(QuotaExhaustedError, match="quota exhausted"): + await _execute_with_tools(config, events.append) - assert calls["count"] == 1 - assert not any(e.get("event") == "fallback" for e in events) + run_cogitate.assert_called_once() + assert run_cogitate.call_args.kwargs["config"]["provider"] == "anthropic" + assert run_cogitate.call_args.kwargs["config"]["model"] == "claude-sonnet-4-6" + rows = _health_rows(tmp_path) + assert len(rows) == 1 + row = rows[0] + assert row["provider"] == "anthropic" + assert row["model"] == "claude-sonnet-4-6" + assert row["interface"] == "cogitate" + assert row["reason_code"] == "provider_quota_exceeded" + assert "tier" not in row + assert row["reset_at_ms"] > 0 + assert [event["event"] for event in events] == ["error"] -@pytest.mark.parametrize( - ("base_temperature", "expected_retry_temperature"), - [ - (None, 0.7), - (0.8, 0.8), - ], -) -def test_execute_generate_local_length_retry_succeeds( - monkeypatch, base_temperature, expected_retry_temperature +@pytest.mark.asyncio +async def test_execute_generate_quota_records_and_does_not_switch( + monkeypatch, + tmp_path, ): - from solstone.think.talents import _execute_generate - - events = [] + monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) + quota = QuotaExhaustedError("quota exhausted", retry_delay_ms=7000) + active = MagicMock(side_effect=quota) + inactive_google = MagicMock(side_effect=AssertionError("google called")) + inactive_openai = MagicMock(side_effect=AssertionError("openai called")) + inactive_local = MagicMock(side_effect=AssertionError("local called")) + monkeypatch.setattr("solstone.think.providers.anthropic.run_generate", active) + monkeypatch.setattr("solstone.think.providers.google.run_generate", inactive_google) + monkeypatch.setattr("solstone.think.providers.openai.run_generate", inactive_openai) + monkeypatch.setattr("solstone.think.providers.local.run_generate", inactive_local) + + with pytest.raises(QuotaExhaustedError, match="quota exhausted"): + await _execute_generate(_generate_config(), lambda _event: None) + + active.assert_called_once() + inactive_google.assert_not_called() + inactive_openai.assert_not_called() + inactive_local.assert_not_called() + row = _health_rows(tmp_path)[0] + assert row["provider"] == "anthropic" + assert row["model"] == "claude-sonnet-4-6" + assert row["interface"] == "generate" + assert row["reason_code"] == "provider_quota_exceeded" + assert "tier" not in row + assert row["reset_at_ms"] > 0 + + +@pytest.mark.asyncio +async def test_execute_generate_local_length_retry_succeeds(monkeypatch): calls = [] - def mock_generate_with_result(**kwargs): + def fake_generate_with_result(**kwargs): calls.append(kwargs) if len(calls) == 1: raise IncompleteJSONError("length", '{"partial":') - assert kwargs["temperature"] == expected_retry_temperature - return {"text": "ok", "usage": {"input_tokens": 1, "output_tokens": 1}} + return {"text": '{"ok": true}', "usage": {}} - def fail_backup(_agent_type): - raise AssertionError("local retry must not consult cloud backup") - - monkeypatch.setattr( - "solstone.think.talent.key_to_context", lambda _name: "talent.system.default" - ) monkeypatch.setattr( - "solstone.think.models.generate_with_result", mock_generate_with_result + "solstone.think.models.generate_with_result", + fake_generate_with_result, ) - monkeypatch.setattr("solstone.think.models.get_backup_provider", fail_backup) - config = { - "name": "chat", - "provider": "local", - "model": LOCAL_MODEL, - "prompt": "hello", - "health_stale": False, - } - if base_temperature is not None: - config["temperature"] = base_temperature - - asyncio.run(_execute_generate(config, events.append)) + events = [] + await _execute_generate( + _generate_config(provider="local", model=LOCAL_MODEL), + events.append, + ) assert len(calls) == 2 - assert calls[0]["temperature"] == (base_temperature or 0.3) - assert calls[1]["temperature"] == expected_retry_temperature assert calls[1]["inference_retry_index"] == 1 assert "local_exclusive_admission" not in calls[1] - assert not any(e.get("event") == "fallback" for e in events) assert events[-1]["event"] == "finish" - assert events[-1]["retries"] == 1 - -def test_execute_generate_local_capacity_retry_succeeds(monkeypatch): - from solstone.think.talents import _execute_generate - events = [] +@pytest.mark.asyncio +async def test_execute_generate_local_capacity_retry_succeeds(monkeypatch): calls = [] - def mock_generate_with_result(**kwargs): + def fake_generate_with_result(**kwargs): calls.append(kwargs) if len(calls) == 1: raise LocalCapacityExhausted() - return {"text": "ok", "usage": {"input_tokens": 1, "output_tokens": 1}} - - def fail_backup(_agent_type): - raise AssertionError("local capacity retry must not consult cloud backup") + return {"text": "ok", "usage": {}} monkeypatch.setattr( - "solstone.think.talent.key_to_context", lambda _name: "talent.system.default" + "solstone.think.models.generate_with_result", + fake_generate_with_result, ) - monkeypatch.setattr( - "solstone.think.models.generate_with_result", mock_generate_with_result - ) - monkeypatch.setattr("solstone.think.models.get_backup_provider", fail_backup) - - config = { - "name": "chat", - "provider": "local", - "model": LOCAL_MODEL, - "prompt": "hello", - "health_stale": False, - } - asyncio.run(_execute_generate(config, events.append)) + events = [] + await _execute_generate( + _generate_config(provider="local", model=LOCAL_MODEL), + events.append, + ) assert len(calls) == 2 - assert "inference_retry_index" not in calls[0] - assert "local_exclusive_admission" not in calls[0] assert calls[1]["inference_retry_index"] == 1 assert calls[1]["local_exclusive_admission"] is True - assert calls[0]["temperature"] == 0.3 - assert calls[1]["temperature"] == calls[0]["temperature"] - assert not any(e.get("event") == "fallback" for e in events) - assert not any(e.get("event") == "error" for e in events) assert events[-1]["event"] == "finish" - assert events[-1]["retries"] == 1 - -def test_main_async_local_capacity_retry_second_failure_emits_one_error( - monkeypatch, - capsys, -): - from solstone.think import talents - ndjson_input = json.dumps({"name": "chat", "prompt": "hello"}) +@pytest.mark.asyncio +async def test_execute_generate_local_non_retry_error_propagates(monkeypatch): calls = [] - def mock_generate_with_result(**kwargs): + def fake_generate_with_result(**kwargs): calls.append(kwargs) - raise LocalCapacityExhausted() - - def fail_backup(_agent_type): - raise AssertionError("local capacity retry must not consult cloud backup") - - mock_args = MagicMock() - mock_args.verbose = False - mock_args.dry_run = False - mock_args.subcommand = None - - config = { - "type": "generate", - "name": "chat", - "provider": "local", - "model": LOCAL_MODEL, - "prompt": "hello", - "health_stale": False, - } + raise RuntimeError("not retryable") - monkeypatch.setattr("sys.stdin", StringIO(ndjson_input)) - monkeypatch.setattr("solstone.think.talents.setup_cli", lambda _parser: mock_args) - monkeypatch.setattr( - "solstone.think.talents.setup_logging", - lambda _verbose=False: MagicMock(), - ) - monkeypatch.setattr( - "solstone.think.talents.prepare_config", lambda _request: config - ) - monkeypatch.setattr("solstone.think.talents.validate_config", lambda _config: None) - monkeypatch.setattr("solstone.think.talents._run_pre_hooks", lambda _config: {}) - monkeypatch.setattr( - "solstone.think.talent.key_to_context", lambda _name: "talent.system.default" - ) - monkeypatch.setattr( - "solstone.think.models.generate_with_result", mock_generate_with_result - ) - monkeypatch.setattr("solstone.think.models.get_backup_provider", fail_backup) monkeypatch.setattr( - "solstone.think._extraction_utils.log_extraction_failure", - lambda _exc, _name: None, + "solstone.think.models.generate_with_result", + fake_generate_with_result, ) - asyncio.run(talents.main_async()) - - lines = [line for line in capsys.readouterr().out.splitlines() if line.strip()] - events = [json.loads(line) for line in lines] - error_events = [event for event in events if event.get("event") == "error"] - finish_events = [event for event in events if event.get("event") == "finish"] - - assert len(calls) == 2 - assert "inference_retry_index" not in calls[0] - assert "local_exclusive_admission" not in calls[0] - assert calls[1]["inference_retry_index"] == 1 - assert calls[1]["local_exclusive_admission"] is True - assert calls[1]["temperature"] == calls[0]["temperature"] - assert not any(e.get("event") == "fallback" for e in events) - assert len(error_events) == 1 - assert error_events[0]["reason_code"] == "local_capacity_exhausted" - assert error_events[0]["retries"] == 1 - assert finish_events == [] - - -def test_execute_generate_local_length_retry_success_writes_once_and_runs_hook_once( - tmp_path, monkeypatch -): - from solstone.think import models, talents - from solstone.think.talents import _execute_generate - - output_path = tmp_path / "out.json" - events = [] - generate_calls = [] - hook_calls = [] - - def mock_generate_with_result(**kwargs): - generate_calls.append(kwargs) - if len(generate_calls) == 1: - raise IncompleteJSONError("length", '{"partial":') - return { - "text": '{"summary": "ok"}', - "usage": {"input_tokens": 1, "output_tokens": 2}, - } - - def post_hook(result, config): - hook_calls.append((result, config["name"])) - return result - - def fail_backup(_agent_type): - raise AssertionError("local retry must not consult cloud backup") - - monkeypatch.setattr( - "solstone.think.talent.key_to_context", lambda _name: "talent.system.default" - ) - monkeypatch.setattr(models, "generate_with_result", mock_generate_with_result) - monkeypatch.setattr(models, "get_backup_provider", fail_backup) - monkeypatch.setattr(talents, "load_post_hook", lambda _config: post_hook) - - asyncio.run( - _execute_generate( - { - "name": "chat", - "provider": "local", - "model": LOCAL_MODEL, - "prompt": "hello", - "health_stale": False, - "output": "json", - "output_path": str(output_path), - "hook": {"post": "test"}, - "json_schema": { - "type": "object", - "additionalProperties": False, - "required": ["summary"], - "properties": {"summary": {"type": "string"}}, - }, - }, - events.append, + with pytest.raises(RuntimeError, match="not retryable"): + await _execute_generate( + _generate_config(provider="local", model=LOCAL_MODEL), + lambda _event: None, ) - ) - - assert len(generate_calls) == 2 - assert hook_calls == [('{"summary": "ok"}', "chat")] - assert output_path.read_text(encoding="utf-8") == '{"summary": "ok"}' - assert [path.name for path in tmp_path.iterdir()] == ["out.json"] - assert events[-1]["event"] == "finish" - assert events[-1]["retries"] == 1 - - -def test_main_async_local_length_retry_second_failure_emits_one_error( - monkeypatch, capsys -): - from solstone.think import talents - - ndjson_input = json.dumps({"name": "chat", "prompt": "hello"}) - calls = {"count": 0} - - def mock_generate_with_result(**_kwargs): - calls["count"] += 1 - raise IncompleteJSONError("length", '{"partial":') - - mock_args = MagicMock() - mock_args.verbose = False - mock_args.dry_run = False - mock_args.subcommand = None - - config = { - "type": "generate", - "name": "chat", - "provider": "local", - "model": LOCAL_MODEL, - "prompt": "hello", - "health_stale": False, - } - - monkeypatch.setattr("sys.stdin", StringIO(ndjson_input)) - monkeypatch.setattr("solstone.think.talents.setup_cli", lambda _parser: mock_args) - monkeypatch.setattr( - "solstone.think.talents.setup_logging", - lambda _verbose=False: MagicMock(), - ) - monkeypatch.setattr( - "solstone.think.talents.prepare_config", lambda _request: config - ) - monkeypatch.setattr("solstone.think.talents.validate_config", lambda _config: None) - monkeypatch.setattr("solstone.think.talents._run_pre_hooks", lambda _config: {}) - monkeypatch.setattr( - "solstone.think.talent.key_to_context", lambda _name: "talent.system.default" - ) - monkeypatch.setattr( - "solstone.think.models.generate_with_result", mock_generate_with_result - ) - monkeypatch.setattr( - "solstone.think._extraction_utils.log_extraction_failure", - lambda _exc, _name: None, - ) - - asyncio.run(talents.main_async()) - - lines = [line for line in capsys.readouterr().out.splitlines() if line.strip()] - events = [json.loads(line) for line in lines] - error_events = [event for event in events if event.get("event") == "error"] - finish_events = [event for event in events if event.get("event") == "finish"] - - assert calls["count"] == 2 - assert len(error_events) == 1 - assert error_events[0]["reason_code"] == "incomplete_json_length" - assert error_events[0]["retries"] == 1 - assert finish_events == [] - - -@pytest.mark.parametrize( - "exc", - [ - IncompleteJSONError("safety", '{"partial":'), - RuntimeError("binary_missing"), - ], -) -def test_execute_generate_local_non_length_errors_do_not_retry(monkeypatch, exc): - from solstone.think.talents import _execute_generate - - events = [] - calls = {"count": 0} - - def mock_generate_with_result(**_kwargs): - calls["count"] += 1 - raise exc - - def fail_backup(_agent_type): - raise AssertionError("local failure must not consult cloud backup") - - monkeypatch.setattr( - "solstone.think.talent.key_to_context", lambda _name: "talent.system.default" - ) - monkeypatch.setattr( - "solstone.think.models.generate_with_result", mock_generate_with_result - ) - monkeypatch.setattr("solstone.think.models.get_backup_provider", fail_backup) - - config = { - "name": "chat", - "provider": "local", - "model": LOCAL_MODEL, - "prompt": "hello", - "health_stale": False, - } - - with pytest.raises(type(exc)): - asyncio.run(_execute_generate(config, events.append)) - - assert calls["count"] == 1 - assert not any(e.get("event") == "fallback" for e in events) - - -def test_execute_generate_cloud_incomplete_json_preserves_existing_nonlocal_path( - monkeypatch, -): - from solstone.think.talents import _execute_generate - - events = [] - calls = {"count": 0} - - def mock_generate_with_result(**_kwargs): - calls["count"] += 1 - raise IncompleteJSONError("length", '{"partial":') - - def fail_backup(_agent_type): - raise AssertionError("IncompleteJSONError is ValueError; no fallback today") - - monkeypatch.setattr( - "solstone.think.talent.key_to_context", lambda _name: "talent.system.default" - ) - monkeypatch.setattr( - "solstone.think.models.generate_with_result", mock_generate_with_result - ) - monkeypatch.setattr("solstone.think.models.get_backup_provider", fail_backup) - - config = { - "name": "chat", - "provider": "google", - "model": "gemini-3-flash-preview", - "prompt": "hello", - "health_stale": False, - } - - with pytest.raises(IncompleteJSONError): - asyncio.run(_execute_generate(config, events.append)) - - assert calls["count"] == 1 - assert not any(e.get("event") == "fallback" for e in events) - - -def test_on_failure_retry_generate(monkeypatch): - from solstone.think.talents import _execute_generate - - events = [] - calls = {"count": 0} - - def mock_generate_with_result(**kwargs): - calls["count"] += 1 - if calls["count"] == 1: - raise RuntimeError("primary generate failed") - assert kwargs.get("provider") == "anthropic" - assert kwargs.get("model") == "claude-sonnet-4-5" - return {"text": "backup text", "usage": {"input_tokens": 1, "output_tokens": 1}} - - monkeypatch.setattr( - "solstone.think.talent.key_to_context", lambda _name: "talent.system.default" - ) - monkeypatch.setattr( - "solstone.think.models.generate_with_result", mock_generate_with_result - ) - monkeypatch.setattr( - "solstone.think.models.get_backup_provider", lambda _type: "anthropic" - ) - monkeypatch.setattr( - "solstone.think.models.resolve_model_for_provider", - lambda _context, _provider, _type="generate": "claude-sonnet-4-5", - ) - monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") - config = { - "name": "chat", - "provider": "google", - "model": "gemini-3-flash-preview", - "prompt": "hello", - "health_stale": False, - } - - asyncio.run(_execute_generate(config, events.append)) - - assert calls["count"] == 2 - assert config["provider"] == "anthropic" - assert config["fallback_from"] == "google" - assert any(e.get("event") == "fallback" for e in events) - assert events[-1]["event"] == "finish" - assert events[-1]["result"] == "backup text" - - -def test_on_failure_no_retry_value_error(monkeypatch): - from solstone.think.talents import _execute_generate - - events = [] - assert _is_retryable_error(ValueError("bad input")) is False - - def bad_generate(**_kwargs): - raise ValueError("bad input") - - monkeypatch.setattr( - "solstone.think.talent.key_to_context", lambda _name: "talent.system.default" - ) - monkeypatch.setattr("solstone.think.models.generate_with_result", bad_generate) - - config = { - "name": "chat", - "provider": "google", - "model": "gemini-3-flash-preview", - "prompt": "hello", - "health_stale": False, - } - - with pytest.raises(ValueError, match="bad input"): - asyncio.run(_execute_generate(config, events.append)) - - assert not any(e.get("event") == "fallback" for e in events) - - -def test_talent_hook_error_is_non_retryable(): - exc = TalentHookError("post", "broken", "chat", RuntimeError("boom")) - - assert _is_retryable_error(exc) is False - assert _should_fallback(exc) is False - - -def test_on_failure_both_fail_raises_original(monkeypatch): - from solstone.think.talents import _execute_generate - - events = [] - calls = {"count": 0} - - def always_fail(**kwargs): - calls["count"] += 1 - if kwargs.get("provider") == "anthropic": - raise RuntimeError("backup failed") - raise RuntimeError("primary failed") - - monkeypatch.setattr( - "solstone.think.talent.key_to_context", lambda _name: "talent.system.default" - ) - monkeypatch.setattr("solstone.think.models.generate_with_result", always_fail) - monkeypatch.setattr( - "solstone.think.models.get_backup_provider", lambda _type: "anthropic" - ) - monkeypatch.setattr( - "solstone.think.models.resolve_model_for_provider", - lambda _context, _provider, _type="generate": "claude-sonnet-4-5", - ) - monkeypatch.setenv("ANTHROPIC_API_KEY", "test-key") - - config = { - "name": "chat", - "provider": "google", - "model": "gemini-3-flash-preview", - "prompt": "hello", - "health_stale": False, - } - - with pytest.raises(RuntimeError, match="primary failed"): - asyncio.run(_execute_generate(config, events.append)) - - assert calls["count"] == 2 - - -def test_fallback_event_emitted(): - from solstone.think.talents import _run_talent - - events = [] - config = { - "type": "cogitate", - "name": "chat", - "provider": "anthropic", - "model": "claude-sonnet-4-5", - "prompt": "hello", - "fallback_from": "google", - } - - asyncio.run(_run_talent(config, events.append, dry_run=True)) - - fallback_events = [e for e in events if e.get("event") == "fallback"] - assert len(fallback_events) == 1 - assert fallback_events[0]["reason"] == "preflight" - - -def test_run_talent_refresh_bypasses_output_exists_guard(tmp_path, monkeypatch): - from solstone.think import talents - - out = tmp_path / "out" - out.write_text("STALE", encoding="utf-8") - events = [] - called = {"execute": False} - - async def fake_execute(config, emit_event): - called["execute"] = True - emit_event({"event": "finish", "ts": 0, "result": "FRESH"}) - - monkeypatch.setattr(talents, "_execute_with_tools", fake_execute) - monkeypatch.setattr(talents, "_run_pre_hooks", lambda config: {}) - - config = { - "type": "cogitate", - "name": "alpha", - "provider": "google", - "model": "x", - "prompt": "hi", - "output_path": str(out), - "refresh": True, - } - - asyncio.run(talents._run_talent(config, events.append, dry_run=False)) - - finish_events = [event for event in events if event.get("event") == "finish"] - assert called["execute"] is True - assert finish_events[-1]["result"] == "FRESH" - - -def test_run_talent_regenerates_existing_output_without_provenance( - tmp_path, monkeypatch -): - from solstone.think import talents - - out = tmp_path / "out" - out.write_text("STALE", encoding="utf-8") - events = [] - called = {"execute": False} - - async def fake_execute(config, emit_event): - called["execute"] = True - emit_event({"event": "finish", "ts": 0, "result": "FRESH"}) - - monkeypatch.setattr(talents, "_execute_with_tools", fake_execute) - monkeypatch.setattr(talents, "_run_pre_hooks", lambda config: {}) - - config = { - "type": "cogitate", - "name": "alpha", - "provider": "google", - "model": "x", - "prompt": "hi", - "output_path": str(out), - } - - asyncio.run(talents._run_talent(config, events.append, dry_run=False)) - - finish_events = [event for event in events if event.get("event") == "finish"] - assert called["execute"] is True - assert finish_events[-1]["result"] == "FRESH" - - -def test_recheck_requested_on_stale(monkeypatch): - from solstone.think.talents import _execute_with_tools - - async def pass_cogitate(*_args, **kwargs): - on_event = kwargs.get("on_event") - if on_event: - on_event({"event": "finish", "result": "ok"}) - return "ok" - - recheck_mock = MagicMock() - - monkeypatch.setattr("solstone.think.providers.PROVIDER_REGISTRY", {"google": "x"}) - monkeypatch.setattr( - "solstone.think.providers.get_provider_module", - lambda _provider: SimpleNamespace(run_cogitate=pass_cogitate), - ) - monkeypatch.setattr("solstone.think.models.request_health_recheck", recheck_mock) - - config = { - "provider": "google", - "model": "gemini-3-flash-preview", - "health_stale": True, - } - - asyncio.run(_execute_with_tools(config, lambda _e: None)) - - recheck_mock.assert_called_once() - assert config["health_stale"] is False - - -def test_main_async_no_duplicate_error_when_evented(monkeypatch, capsys): - from solstone.think.talents import main_async - - ndjson_input = json.dumps({"name": "chat", "prompt": "hello"}) - monkeypatch.setattr("sys.stdin", StringIO(ndjson_input)) - - async def fake_run_talent(_config, emit_event, dry_run=False): - emit_event({"event": "error", "error": "provider failed"}) - exc = RuntimeError("provider failed") - setattr(exc, "_evented", True) - raise exc - - mock_args = MagicMock() - mock_args.verbose = False - mock_args.dry_run = False - mock_args.subcommand = None - - monkeypatch.setattr("solstone.think.talents.setup_cli", lambda _parser: mock_args) - monkeypatch.setattr( - "solstone.think.talents.setup_logging", - lambda _verbose=False: MagicMock(), - ) - monkeypatch.setattr( - "solstone.think.talents.prepare_config", lambda _request: {"type": "cogitate"} - ) - monkeypatch.setattr("solstone.think.talents.validate_config", lambda _config: None) - monkeypatch.setattr("solstone.think.talents._run_talent", fake_run_talent) - - asyncio.run(main_async()) - - lines = [line for line in capsys.readouterr().out.splitlines() if line.strip()] - events = [json.loads(line) for line in lines] - error_events = [event for event in events if event.get("event") == "error"] - assert len(error_events) == 1 - - -def test_main_async_cogitate_hook_error_no_fallback_no_double_emit( - monkeypatch, - capsys, -): - from solstone.think import talents - - ndjson_input = json.dumps({"name": "chat", "prompt": "hello"}) - monkeypatch.setattr("sys.stdin", StringIO(ndjson_input)) - - provider_calls = [] - - async def fake_run_cogitate(config, on_event): - provider_calls.append(config["provider"]) - on_event({"event": "finish", "result": "provider result"}) - - def broken_post_hook(result, context): - raise RuntimeError("hook exploded") - - mock_args = MagicMock() - mock_args.verbose = False - mock_args.dry_run = False - mock_args.subcommand = None - - config = { - "type": "cogitate", - "name": "chat", - "provider": "google", - "model": "gemini-3-flash-preview", - "tier": "flash", - "prompt": "hello", - "hook": {"post": "broken_hook"}, - } - - monkeypatch.setattr("solstone.think.talents.setup_cli", lambda _parser: mock_args) - monkeypatch.setattr( - "solstone.think.talents.setup_logging", - lambda _verbose=False: MagicMock(), - ) - monkeypatch.setattr( - "solstone.think.talents.prepare_config", lambda _request: config - ) - monkeypatch.setattr("solstone.think.talents.validate_config", lambda _config: None) - monkeypatch.setattr( - "solstone.think.talents.load_post_hook", lambda _config: broken_post_hook - ) - monkeypatch.setattr( - "solstone.think.providers.PROVIDER_REGISTRY", - {"google": object(), "anthropic": object()}, - ) - monkeypatch.setattr( - "solstone.think.providers.get_provider_module", - lambda _provider: SimpleNamespace(run_cogitate=fake_run_cogitate), - ) - monkeypatch.setenv("ANTHROPIC_API_KEY", "x") - - asyncio.run(talents.main_async()) - - lines = [line for line in capsys.readouterr().out.splitlines() if line.strip()] - events = [json.loads(line) for line in lines] - error_events = [event for event in events if event.get("event") == "error"] - - assert provider_calls == ["google"] - assert [event for event in events if event.get("event") == "fallback"] == [] - assert [event for event in events if event.get("event") == "finish"] == [] - assert len(error_events) == 1 - assert error_events[0]["reason_code"] == "hook_error" - assert error_events[0]["terminal"] is True - assert ( - "post-hook 'broken_hook' failed for talent 'chat'" in error_events[0]["error"] - ) - assert "hook exploded" in error_events[0]["error"] + assert len(calls) == 1 diff --git a/tests/test_think_segment_prephase.py b/tests/test_think_segment_prephase.py index 772d4efd1..42063da0d 100644 --- a/tests/test_think_segment_prephase.py +++ b/tests/test_think_segment_prephase.py @@ -1003,7 +1003,6 @@ def test_json_cache_reuse_requires_current_schema_validation( output_size=output_size, provider="google", model="x", - fallback_from=None, generation_params=talents._generation_params(config), completed_at_ms=100, use_id="seed", diff --git a/tests/test_thinking_call_parity.py b/tests/test_thinking_call_parity.py index 76afeb40e..d635bacbf 100644 --- a/tests/test_thinking_call_parity.py +++ b/tests/test_thinking_call_parity.py @@ -379,8 +379,8 @@ def test_providers_show_human_and_set_errors( thinking_call.app, ["providers", "set-generate", "--tier", "9"], ) - assert bad_tier.exit_code == 1 - assert bad_tier.stderr == "Invalid tier: 9. Must be one of: 1, 2, 3\n" + assert bad_tier.exit_code != 0 + assert "No such option: --tier" in bad_tier.stderr def test_local_endpoint_verbs_use_http_shapes( diff --git a/tests/test_thinking_defaults.py b/tests/test_thinking_defaults.py index 096d6959c..fd03cce82 100644 --- a/tests/test_thinking_defaults.py +++ b/tests/test_thinking_defaults.py @@ -95,16 +95,8 @@ def test_default_segment_workers_nonlocal_cpu_formula(monkeypatch): # --- AC4 ------------------------------------------------------------------- -def test_default_segment_workers_mixed_segment_context_local_caps( - monkeypatch, tmp_path -): - """A cloud default plus one segment talent pinned local still caps the fan-out. - - Resolution runs for real here — no monkeypatched predicate. Under - default-provider-only semantics the default provider is google, so this - would return the CPU formula (6) instead of the floor tier's 1. That - difference is the point of the test. - """ +def test_default_segment_workers_ignores_context_local_pin(monkeypatch, tmp_path): + """A retired context-local pin does not cap segment fan-out.""" _write_journal_config( monkeypatch, tmp_path, @@ -116,7 +108,7 @@ def test_default_segment_workers_mixed_segment_context_local_caps( _pin_cpu_count(monkeypatch, 12) _pin_slots(monkeypatch, FLOOR_SLOTS) - assert think._default_segment_workers() == FLOOR_SLOTS == 1 + assert think._default_segment_workers() == 6 # --- AC6 ------------------------------------------------------------------- @@ -250,8 +242,7 @@ def test_describe_default_byo_endpoint_uses_configured_slot_cap_and_does_not_pro monkeypatch, tmp_path, { - "generate": {"provider": "google"}, - "contexts": {think.FRAME_CONTEXT: {"provider": "local"}}, + "generate": {"provider": "local"}, "local": { "endpoint_url": "https://example.invalid/v1", "served_model_id": "some-model", @@ -273,8 +264,7 @@ def test_describe_default_confidential_endpoint_uses_cpu_formula_and_does_not_pr monkeypatch, tmp_path, { - "generate": {"provider": "google"}, - "contexts": {think.FRAME_CONTEXT: {"provider": "local"}}, + "generate": {"provider": "local"}, "local": { "endpoint_url": "https://example.invalid/v1", "served_model_id": "some-model", -- 2.51.2