diff --git a/.flocks/flocks.json.example b/.flocks/flocks.json.example index 1f8b273c7..dc9ffd359 100644 --- a/.flocks/flocks.json.example +++ b/.flocks/flocks.json.example @@ -1,5 +1,10 @@ { "provider": {}, + "default_models": { + "default_parameters": { + "reasoning_effort": "high" + } + }, "mcp": {}, "channels": {}, "plugin": [], diff --git a/flocks/config/config_writer.py b/flocks/config/config_writer.py index 2b8d6a900..51129ba11 100644 --- a/flocks/config/config_writer.py +++ b/flocks/config/config_writer.py @@ -21,7 +21,13 @@ _FALLBACK_CONFIG_TEMPLATES: Dict[str, Dict[str, Any]] = { - "flocks.json": {}, + "flocks.json": { + "default_models": { + "default_parameters": { + "reasoning_effort": "high", + }, + }, + }, ".secret.json": {}, "mcp_list.json": { "version": "1.0.0", @@ -454,6 +460,31 @@ def get_all_model_settings(cls) -> Dict[str, Dict[str, Any]]: data = cls._read_raw() return data.get("model_settings", {}) + @classmethod + def get_effective_model_default_parameters( + cls, + provider_id: str, + model_id: str, + ) -> Dict[str, Any]: + """Resolve global defaults with model-specific overrides.""" + data = cls._read_raw() + result: Dict[str, Any] = {} + default_models = data.get("default_models") + global_parameters = ( + default_models.get("default_parameters", {}) + if isinstance(default_models, dict) + else {} + ) + if isinstance(global_parameters, dict): + result.update(global_parameters) + + settings = data.get("model_settings", {}) + setting = settings.get(f"{provider_id}/{model_id}", {}) + model_parameters = setting.get("default_parameters", {}) + if isinstance(model_parameters, dict): + result.update(model_parameters) + return result + # ------------------------------------------------------------------ # Default models (default_models section) # ------------------------------------------------------------------ @@ -503,7 +534,14 @@ def delete_default_model(cls, model_type: str) -> bool: def get_all_default_models(cls) -> Dict[str, Dict[str, Any]]: """Get all default model configs.""" data = cls._read_raw() - return data.get("default_models", {}) + defaults = data.get("default_models") + if not isinstance(defaults, dict): + return {} + return { + model_type: config + for model_type, config in defaults.items() + if model_type != "default_parameters" and isinstance(config, dict) + } # ------------------------------------------------------------------ # Runtime model fallbacks (fallback_providers section) diff --git a/flocks/provider/model_manager.py b/flocks/provider/model_manager.py index bb4e813cb..cd897d955 100644 --- a/flocks/provider/model_manager.py +++ b/flocks/provider/model_manager.py @@ -139,6 +139,17 @@ def update_setting( model_id=model_id, ) + def get_effective_default_parameters( + self, + provider_id: str, + model_id: str, + ) -> Dict[str, Any]: + """Get inherited default parameters for a provider model.""" + return ConfigWriter.get_effective_model_default_parameters( + provider_id, + model_id, + ) + # ==================== Default Models ==================== def get_default_model( diff --git a/flocks/provider/options.py b/flocks/provider/options.py index 733093df5..bea626cb2 100644 --- a/flocks/provider/options.py +++ b/flocks/provider/options.py @@ -29,7 +29,8 @@ # --------------------------------------------------------------------------- DEFAULT_THINKING_BUDGET = 16000 DEFAULT_OUTPUT_BUFFER = 8192 -DEFAULT_KIMI_K3_REASONING_EFFORT = "max" +DEFAULT_REASONING_EFFORT = "high" +DEFAULT_KIMI_K3_REASONING_EFFORT = DEFAULT_REASONING_EFFORT KIMI_K3_REASONING_EFFORTS = frozenset({"low", "high", "max"}) _GENERIC_CHAT_REASONING_EXTRA_BODY_KEYS = { @@ -76,15 +77,15 @@ def _resolve_reasoning_enabled(provider_id: str, model_id: str) -> Optional[bool def _resolve_reasoning_effort(provider_id: str, model_id: str) -> Optional[str]: - """Read a model-level reasoning effort from flocks.json.""" + """Read the effective reasoning effort from flocks.json.""" try: from flocks.provider.model_manager import get_model_manager - setting = get_model_manager().get_setting(provider_id, model_id) - if not setting: - return None - - value = (setting.default_parameters or {}).get("reasoning_effort") + default_parameters = get_model_manager().get_effective_default_parameters( + provider_id, + model_id, + ) + value = default_parameters.get("reasoning_effort") return value.strip().lower() if isinstance(value, str) else None except Exception as exc: log.debug("options.reasoning_effort_setting_lookup_failed", { @@ -370,7 +371,7 @@ def build_provider_options( # -- OpenAI reasoning (o1 / o3 / gpt-5) -------------------------------- elif provider_id == "openai": if reasoning_enabled is not False and any(tag in model_lower for tag in ("o1", "o3", "gpt-5")): - options["reasoningEffort"] = "medium" + options["reasoningEffort"] = reasoning_effort or DEFAULT_REASONING_EFFORT # -- Google Gemini thinking --------------------------------------------- elif provider_id == "google": diff --git a/tests/config/test_config_writer.py b/tests/config/test_config_writer.py index 73c0b1af6..b735f0a06 100644 --- a/tests/config/test_config_writer.py +++ b/tests/config/test_config_writer.py @@ -309,6 +309,35 @@ def test_get_all_model_settings(self, temp_project): assert "anthropic/claude-sonnet" in all_settings assert len(all_settings) == 2 + def test_effective_default_parameters_follow_scope_precedence(self, temp_project): + from flocks.config.config_writer import ConfigWriter + + data = ConfigWriter._read_raw() + data["default_models"] = { + "default_parameters": { + "reasoning_effort": "low", + "temperature": 0.1, + }, + } + data["model_settings"] = { + "anthropic/claude-sonnet-4-5": { + "default_parameters": { + "reasoning_effort": "high", + } + } + } + ConfigWriter._write_raw(data) + + parameters = ConfigWriter.get_effective_model_default_parameters( + "anthropic", + "claude-sonnet-4-5", + ) + + assert parameters == { + "reasoning_effort": "high", + "temperature": 0.1, + } + def test_model_settings_preserve_other_sections(self, temp_project): from flocks.config.config_writer import ConfigWriter ConfigWriter.set_model_setting("openai", "gpt-4o", {"enabled": True}) @@ -439,6 +468,26 @@ def test_get_all_default_models(self, temp_project): assert "text-embedding" in all_defaults assert len(all_defaults) == 2 + def test_get_all_default_models_excludes_default_parameters(self, temp_project): + from flocks.config.config_writer import ConfigWriter + + data = ConfigWriter._read_raw() + data["default_models"] = { + "default_parameters": {"reasoning_effort": "medium"}, + "llm": { + "provider_id": "anthropic", + "model_id": "claude-sonnet", + }, + } + ConfigWriter._write_raw(data) + + assert ConfigWriter.get_all_default_models() == { + "llm": { + "provider_id": "anthropic", + "model_id": "claude-sonnet", + } + } + def test_default_models_preserve_other_sections(self, temp_project): from flocks.config.config_writer import ConfigWriter ConfigWriter.set_default_model("llm", "anthropic", "claude") diff --git a/tests/provider/test_provider_options.py b/tests/provider/test_provider_options.py index 45f2af82b..7ae8d17fd 100644 --- a/tests/provider/test_provider_options.py +++ b/tests/provider/test_provider_options.py @@ -86,7 +86,7 @@ def test_moonshot_kimi_k3_uses_default_reasoning_effort(self): resolve_max_tokens=False, ) - assert options["extra_body"] == {"reasoning_effort": "max"} + assert options["extra_body"] == {"reasoning_effort": "high"} def test_kimi_k27_forces_thinking_even_when_toggle_is_disabled(self): options = provider_options.build_provider_options( @@ -127,7 +127,7 @@ def test_kimi_k3_uses_reasoning_effort_instead_of_thinking(self): resolve_max_tokens=False, ) - assert options["extra_body"] == {"reasoning_effort": "max"} + assert options["extra_body"] == {"reasoning_effort": "high"} assert "thinking" not in options["extra_body"] def test_kimi_k3_respects_supported_reasoning_effort(self): @@ -443,3 +443,27 @@ def test_openai_reasoning_can_be_disabled(self): ) assert "reasoningEffort" not in options + + def test_openai_reasoning_defaults_to_high(self): + options = provider_options.build_provider_options( + "openai", + "gpt-5.4", + resolve_max_tokens=False, + ) + + assert options["reasoningEffort"] == "high" + + def test_openai_uses_configured_reasoning_effort(self, monkeypatch): + monkeypatch.setattr( + provider_options, + "_resolve_reasoning_effort", + lambda *_args: "low", + ) + + options = provider_options.build_provider_options( + "openai", + "gpt-5.4", + resolve_max_tokens=False, + ) + + assert options["reasoningEffort"] == "low" diff --git a/tests/provider/test_thinking_params.py b/tests/provider/test_thinking_params.py index d9c638214..046564d37 100644 --- a/tests/provider/test_thinking_params.py +++ b/tests/provider/test_thinking_params.py @@ -100,7 +100,7 @@ def _expected_generic_chat_extra_body( if "mimo" in model_lower: return MIMO_THINKING_EXTRA_BODY if is_kimi_k3_model(model_id): - return {"reasoning_effort": "max"} + return {"reasoning_effort": "high"} if is_kimi_k27_code_model(model_id): return KIMI_THINKING_EXTRA_BODY if "kimi" in model_lower: @@ -444,7 +444,7 @@ def test_anthropic_transport_still_uses_thinking_field( ("kimi-k2.6-uncatalogued", KIMI_THINKING_EXTRA_BODY), ("kimi-k2.7-code", KIMI_THINKING_EXTRA_BODY), ("kimi-k2.7-code-highspeed", KIMI_THINKING_EXTRA_BODY), - ("kimi-k3", {"reasoning_effort": "max"}), + ("kimi-k3", {"reasoning_effort": "high"}), ("mimo-v2.5-pro-uncatalogued", MIMO_THINKING_EXTRA_BODY), ("minimax-m4-uncatalogued", {"reasoning_split": True}), ("step-3.5-flash-uncatalogued", {"enable_thinking": True}),