From ba73cc28c54ffb90090932d1f92376aa20949f95 Mon Sep 17 00:00:00 2001 From: Nick Bobrowski <39348559+nicko-ai@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:52:10 +0100 Subject: [PATCH] fix: route slides helper models from request - make planner and HTML writer inherit the caller model/client config - keep the default-model fallback when no request model exists - cover OpenAI, Codex-compatible, OpenAI-compatible, and LiteLLM routes --- slides_agent/tools/InsertNewSlides.py | 71 ++--- slides_agent/tools/ModifySlide.py | 64 ++-- slides_agent/tools/internal_model.py | 225 +++++++++++++ tests/test_slides_internal_models.py | 435 ++++++++++++++++++++++++++ 4 files changed, 698 insertions(+), 97 deletions(-) create mode 100644 slides_agent/tools/internal_model.py create mode 100644 tests/test_slides_internal_models.py diff --git a/slides_agent/tools/InsertNewSlides.py b/slides_agent/tools/InsertNewSlides.py index 8faf1cd9..fbc06827 100644 --- a/slides_agent/tools/InsertNewSlides.py +++ b/slides_agent/tools/InsertNewSlides.py @@ -13,14 +13,13 @@ from types import SimpleNamespace from typing import Literal -import os -from agency_swarm import Agent, ModelSettings, Reasoning +from agency_swarm import Agent from agency_swarm.tools import BaseTool -from openai import AsyncOpenAI from agents.extensions.models.litellm_model import LitellmModel from pydantic import BaseModel, Field, ValidationError from run_utils import _load_openswarm_dotenv +from .internal_model import make_internal_model, make_internal_model_settings from .slide_file_utils import ( apply_renames, build_slide_name, @@ -32,10 +31,6 @@ from .template_registry import load_template_index -_PLANNER_MODEL_CLAUDE = "anthropic/claude-sonnet-4-6" -_PLANNER_MODEL_OAI = "gpt-5.3-codex" - - class _PlanSlide(BaseModel): page: int title: str @@ -50,20 +45,6 @@ class _PlanResponse(BaseModel): slides: list[_PlanSlide] -def _get_caller_openai_client(tool) -> "AsyncOpenAI | None": - ctx = getattr(tool, "_context", None) - master = getattr(ctx, "context", None) - agent_name = getattr(master, "current_agent_name", None) - agents = getattr(master, "agents", {}) - agent = agents.get(agent_name) if agent_name else None - model = getattr(agent, "model", None) - for attr in ("_client", "openai_client", "client"): - maybe = getattr(model, attr, None) - if isinstance(maybe, AsyncOpenAI): - return maybe - return None - - class _CodexResponsesModel: """Subclass of OpenAIResponsesModel that strips parameters unsupported by the Codex endpoint.""" @@ -77,7 +58,11 @@ def _get_cls(cls): class _Impl(OpenAIResponsesModel): async def _fetch_response(self, system_instructions, input, model_settings, *args, **kwargs): - model_settings = replace(model_settings, truncation=None) + model_settings = replace( + model_settings, + truncation=None, + verbosity=None, + ) return await super()._fetch_response(system_instructions, input, model_settings, *args, **kwargs) cls._cls = _Impl @@ -151,34 +136,20 @@ def _make_planner_agent(tool=None) -> "tuple[Agent, bool]": """Create a fresh, stateless agent instance for one InsertNewSlides call. Model priority: - 1. ANTHROPIC_API_KEY in env → Claude Sonnet 4.6 (best planning quality) - 2. Calling agent's OpenAI client (browser auth / per-request ClientConfig) - 3. AsyncOpenAI() default (env vars) + 1. Calling agent's selected model + 2. DEFAULT_MODEL from the OpenSwarm environment + 3. OpenSwarm's standard OpenAI fallback Returns (agent, is_codex). """ - anthropic_key = os.getenv("ANTHROPIC_API_KEY") - is_codex = False - if anthropic_key: - model = LitellmModel(model=_PLANNER_MODEL_CLAUDE, api_key=anthropic_key) - else: - from agents import OpenAIResponsesModel - from openai import AsyncOpenAI - caller_client = tool and _get_caller_openai_client(tool) - if caller_client: - # Create a fresh client with the same credentials — the caller's client is - # bound to FastAPI's event loop and cannot be reused in asyncio.run() threads. - client = AsyncOpenAI( - api_key=caller_client.api_key, - base_url=str(caller_client.base_url), - ) - else: - client = AsyncOpenAI() - is_codex = bool(caller_client and not str(caller_client.base_url).startswith("https://api.openai.com")) - if is_codex: - model = _CodexResponsesModel(model=_PLANNER_MODEL_OAI, openai_client=client) - else: - model = OpenAIResponsesModel(model=_PLANNER_MODEL_OAI, openai_client=client) + from agents import OpenAIResponsesModel + + model, is_codex = make_internal_model( + tool, + litellm_model=LitellmModel, + openai_model=OpenAIResponsesModel, + codex_model=_CodexResponsesModel, + ) agent = Agent( name="Slide Planner", description="Creates structured slide outline plans.", @@ -189,11 +160,7 @@ def _make_planner_agent(tool=None) -> "tuple[Agent, bool]": tools=[], model=model, output_type=_PlanResponse, - model_settings=ModelSettings( - reasoning=Reasoning(effort="high", summary="auto"), - verbosity=None if is_codex else "medium", - store=False if is_codex else None, - ), + model_settings=make_internal_model_settings(tool, is_codex=is_codex), ) return agent, is_codex diff --git a/slides_agent/tools/ModifySlide.py b/slides_agent/tools/ModifySlide.py index cd32cf70..594167fa 100644 --- a/slides_agent/tools/ModifySlide.py +++ b/slides_agent/tools/ModifySlide.py @@ -9,7 +9,6 @@ import asyncio import base64 import mimetypes -import os import re import tempfile import threading @@ -17,13 +16,13 @@ from pathlib import Path from typing import Any -from agency_swarm import Agent, ModelSettings, Reasoning +from agency_swarm import Agent from agency_swarm.tools import BaseTool, ToolOutputText, tool_output_image_from_path from agents.extensions.models.litellm_model import LitellmModel -from openai import AsyncOpenAI from pydantic import Field from run_utils import _load_openswarm_dotenv +from .internal_model import make_internal_model, make_internal_model_settings from .slide_file_utils import get_project_dir from .slide_html_utils import ( ensure_full_html, @@ -219,25 +218,9 @@ def replace_href(match: re.Match) -> str: return html -_HTML_WRITER_MODEL_CLAUDE = "anthropic/claude-sonnet-4-6" -_HTML_WRITER_MODEL_OAI = "gpt-5.3-codex" _HTML_WRITER_MAX_ATTEMPTS = 3 -def _get_caller_openai_client(tool) -> "AsyncOpenAI | None": - ctx = getattr(tool, "_context", None) - master = getattr(ctx, "context", None) - agent_name = getattr(master, "current_agent_name", None) - agents = getattr(master, "agents", {}) - agent = agents.get(agent_name) if agent_name else None - model = getattr(agent, "model", None) - for attr in ("_client", "openai_client", "client"): - maybe = getattr(model, attr, None) - if isinstance(maybe, AsyncOpenAI): - return maybe - return None - - class _CodexResponsesModel: """Subclass of OpenAIResponsesModel that strips parameters unsupported by the Codex endpoint.""" @@ -251,7 +234,11 @@ def _get_cls(cls): class _Impl(OpenAIResponsesModel): async def _fetch_response(self, system_instructions, input, model_settings, *args, **kwargs): - model_settings = replace(model_settings, truncation=None) + model_settings = replace( + model_settings, + truncation=None, + verbosity=None, + ) return await super()._fetch_response(system_instructions, input, model_settings, *args, **kwargs) cls._cls = _Impl @@ -296,40 +283,27 @@ def _make_html_writer_agent(tool=None) -> "tuple[Agent, bool]": """Create a fresh, stateless agent instance for one ModifySlide call. Model priority: - 1. ANTHROPIC_API_KEY in env → Claude Sonnet 4.6 (best HTML quality) - 2. Calling agent's OpenAI client (browser auth / per-request ClientConfig) - 3. AsyncOpenAI() default (env vars) + 1. Calling agent's selected model + 2. DEFAULT_MODEL from the OpenSwarm environment + 3. OpenSwarm's standard OpenAI fallback Returns (agent, is_codex). """ - anthropic_key = os.getenv("ANTHROPIC_API_KEY") - is_codex = False - if anthropic_key: - model = LitellmModel(model=_HTML_WRITER_MODEL_CLAUDE, api_key=anthropic_key) - else: - from agents import OpenAIResponsesModel - from openai import AsyncOpenAI - caller_client = tool and _get_caller_openai_client(tool) - client = AsyncOpenAI( - api_key=caller_client.api_key, - base_url=str(caller_client.base_url), - ) if caller_client else AsyncOpenAI() - is_codex = bool(caller_client and not str(caller_client.base_url).startswith("https://api.openai.com")) - if is_codex: - model = _CodexResponsesModel(model=_HTML_WRITER_MODEL_OAI, openai_client=client) - else: - model = OpenAIResponsesModel(model=_HTML_WRITER_MODEL_OAI, openai_client=client) + from agents import OpenAIResponsesModel + + model, is_codex = make_internal_model( + tool, + litellm_model=LitellmModel, + openai_model=OpenAIResponsesModel, + codex_model=_CodexResponsesModel, + ) agent = Agent( name="Slide HTML Writer", description="Generates complete slide HTML from task briefs.", instructions=_read_html_writer_instructions(), tools=[], model=model, - model_settings=ModelSettings( - reasoning=Reasoning(effort="high", summary="auto"), - verbosity="medium", - store=False if is_codex else None, - ), + model_settings=make_internal_model_settings(tool, is_codex=is_codex), ) return agent, is_codex diff --git a/slides_agent/tools/internal_model.py b/slides_agent/tools/internal_model.py new file mode 100644 index 00000000..1c3d92e9 --- /dev/null +++ b/slides_agent/tools/internal_model.py @@ -0,0 +1,225 @@ +"""Model selection helpers for Slides internal agents.""" + +from __future__ import annotations + +import inspect +from typing import Any, Callable + +from config import get_default_model +from openai import AsyncOpenAI + + +_OPENAI_PREFIX = "openai/" +_LITELLM_PREFIX = "litellm/" +_OPENAI_BASE_URL = "https://api.openai.com" +_LITELLM_CONFIG_FIELDS = ( + "api_key", + "base_url", + "api_base", + "api_version", + "organization", + "project", + "timeout", + "max_retries", + "headers", + "default_headers", + "extra_headers", +) +_OPENAI_CLIENT_FIELDS = ( + "api_key", + "base_url", + "organization", + "project", + "timeout", + "max_retries", + "default_headers", + "default_query", +) + + +def _current_agent(tool: Any) -> Any | None: + ctx = getattr(tool, "_context", None) + master = getattr(ctx, "context", None) + name = getattr(master, "current_agent_name", None) + agents = getattr(master, "agents", {}) + return agents.get(name) if name else None + + +def _model_name(value: Any) -> str | None: + if isinstance(value, str): + return value.strip() or None + for attr in ("model", "model_name", "name"): + maybe = getattr(value, attr, None) + if isinstance(maybe, str) and maybe.strip(): + return maybe.strip() + return None + + +def _caller_model(tool: Any) -> Any | None: + agent = _current_agent(tool) + return getattr(agent, "model", None) + + +def _source_openai_client(source: Any | None) -> Any | None: + if source is None: + return None + for attr in ("_client", "openai_client", "client"): + maybe = getattr(source, attr, None) + if maybe is not None: + return maybe + return None + + +def _resolved_model(tool: Any) -> tuple[str, Any | None]: + source = _caller_model(tool) + name = _model_name(source) + if name: + return name, source + default = get_default_model() + return _model_name(default) or "gpt-5.2", default + + +def _client_value(client: Any | None, field: str) -> Any | None: + if client is None: + return None + if field == "api_key": + provider = getattr(client, "_api_key_provider", None) + if provider is not None: + return provider + return getattr(client, "api_key", None) or None + if field == "base_url": + value = getattr(client, "base_url", None) + return str(value) if value is not None else None + if field in {"headers", "default_headers", "extra_headers"}: + return getattr(client, "_custom_headers", None) + if field == "default_query": + return getattr(client, "_custom_query", None) or getattr(client, field, None) + return getattr(client, field, None) + + +def _clone_openai_client(client: AsyncOpenAI | None) -> AsyncOpenAI: + if client is None: + return AsyncOpenAI() + kwargs = { + field: value + for field in _OPENAI_CLIENT_FIELDS + if (value := _client_value(client, field)) is not None + } + return AsyncOpenAI(**kwargs) + + +def _base_url(client: Any | None) -> str: + value = getattr(client, "base_url", None) if client is not None else None + return str(value).rstrip("/") if value is not None else "" + + +def _is_openai_client(client: Any | None) -> bool: + base = _base_url(client) + return ( + not base + or base == _OPENAI_BASE_URL + or base.startswith(f"{_OPENAI_BASE_URL}/") + ) + + +def _is_codex_client(client: Any | None) -> bool: + base = _base_url(client).lower() + return bool(base and not _is_openai_client(client) and "codex" in base) + + +def _is_litellm_model(model: str, source: Any | None) -> bool: + if source is None: + source_is_litellm = False + else: + typ = type(source) + source_is_litellm = ( + "litellm" in typ.__name__.lower() or "litellm" in typ.__module__.lower() + ) + if source_is_litellm: + return True + if model.startswith(_LITELLM_PREFIX): + return True + if model.startswith(_OPENAI_PREFIX): + return False + return "/" in model + + +def _config_value(source: Any | None, field: str) -> Any | None: + if source is None: + return None + value = getattr(source, field, None) + if value is not None: + return value + for attr in ("kwargs", "_kwargs", "model_kwargs", "_model_kwargs"): + values = getattr(source, attr, None) + if isinstance(values, dict) and values.get(field) is not None: + return values[field] + return _client_value(_source_openai_client(source), field) + + +def _accepted_litellm_fields(litellm_model: type) -> set[str]: + try: + params = inspect.signature(litellm_model).parameters + except (TypeError, ValueError): + return {"api_key", "base_url"} + if any(param.kind == inspect.Parameter.VAR_KEYWORD for param in params.values()): + return set(_LITELLM_CONFIG_FIELDS) + return {field for field in _LITELLM_CONFIG_FIELDS if field in params} + + +def _litellm_kwargs( + model: str, + source: Any | None, + litellm_model: type, +) -> dict[str, Any]: + bare = model[len(_LITELLM_PREFIX) :] if model.startswith(_LITELLM_PREFIX) else model + kwargs: dict[str, Any] = {"model": bare} + for field in _accepted_litellm_fields(litellm_model): + value = _config_value(source, field) + if value is not None: + kwargs[field] = value + return kwargs + + +def make_internal_model( + tool: Any, + *, + litellm_model: type, + openai_model: Callable[..., Any], + codex_model: Callable[..., Any], +) -> tuple[Any, bool]: + """Build a model for a Slides sub-agent, preferring the caller's model.""" + model, source = _resolved_model(tool) + if _is_litellm_model(model, source): + return litellm_model(**_litellm_kwargs(model, source, litellm_model)), False + + caller_client = _source_openai_client(source) + client = _clone_openai_client( + caller_client if isinstance(caller_client, AsyncOpenAI) else None + ) + is_codex = _is_codex_client(caller_client) + factory = codex_model if is_codex else openai_model + return factory(model=model, openai_client=client), is_codex + + +def uses_openai_model_settings(tool: Any) -> bool: + model, source = _resolved_model(tool) + if _is_litellm_model(model, source): + return False + client = _source_openai_client(source) + if client is None: + return True + return _is_openai_client(client) or _is_codex_client(client) + + +def make_internal_model_settings(tool: Any, *, is_codex: bool): + """Build settings that avoid OpenAI-only fields for LiteLLM-routed models.""" + from agency_swarm import ModelSettings, Reasoning + + if not uses_openai_model_settings(tool): + return ModelSettings() + return ModelSettings( + reasoning=Reasoning(effort="high", summary="auto"), + verbosity=None if is_codex else "medium", + store=False if is_codex else None, + ) diff --git a/tests/test_slides_internal_models.py b/tests/test_slides_internal_models.py new file mode 100644 index 00000000..b8f9b170 --- /dev/null +++ b/tests/test_slides_internal_models.py @@ -0,0 +1,435 @@ +from __future__ import annotations + +import asyncio +import importlib.util +import os +import sys +import types +import unittest +from dataclasses import dataclass +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +OPENAI_BASE_URL = "https://api.openai.com/v1" +CODEX_COMPATIBLE_BASE_URL = "https://codex.example.test/v1" +OPENAI_COMPATIBLE_BASE_URL = "https://gateway.example.test/v1" +OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" + + +class FakeAgent: + def __init__(self, **kwargs): + self.kwargs = kwargs + self.model = kwargs.get("model") + + +@dataclass(init=False) +class FakeModelSettings: + reasoning: object | None = None + verbosity: object | None = None + store: object | None = None + truncation: object | None = None + + def __init__(self, **kwargs): + self.reasoning = kwargs.get("reasoning") + self.verbosity = kwargs.get("verbosity") + self.store = kwargs.get("store") + self.truncation = kwargs.get("truncation") + self.kwargs = kwargs + + +class FakeReasoning: + def __init__(self, **kwargs): + self.kwargs = kwargs + + +class FakeAsyncOpenAI: + def __init__( + self, + *, + api_key="env-key", + base_url=OPENAI_BASE_URL, + organization=None, + project=None, + timeout=None, + max_retries=2, + default_headers=None, + default_query=None, + ): + self.api_key = api_key + self.base_url = base_url + self.organization = organization + self.project = project + self.timeout = timeout + self.max_retries = max_retries + self._custom_headers = default_headers + self.default_query = default_query + self._custom_query = default_query + + +class FakeOpenAIResponsesModel: + def __init__(self, *, model, openai_client): + self.model = model + self.openai_client = openai_client + + async def _fetch_response(self, _system, _input, model_settings, *args, **kwargs): + return model_settings + + +class FakeLitellmModel: + def __init__(self, **kwargs): + self.kwargs = kwargs + self.model = kwargs.get("model") + + +class FakeBaseModel: + @classmethod + def model_validate(cls, value): + return cls(**value) + + def __init__(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) + + def model_dump(self): + return self.__dict__.copy() + + +def fake_field(default=None, **_kwargs): + return default + + +def install_stubs() -> None: + agency = types.ModuleType("agency_swarm") + agency.Agent = FakeAgent + agency.LitellmModel = FakeLitellmModel + agency.ModelSettings = FakeModelSettings + agency.Reasoning = FakeReasoning + + agency_tools = types.ModuleType("agency_swarm.tools") + agency_tools.BaseTool = object + agency_tools.ToolOutputText = str + agency_tools.tool_output_image_from_path = lambda path: path + + agents = types.ModuleType("agents") + agents.OpenAIResponsesModel = FakeOpenAIResponsesModel + + agents_extensions = types.ModuleType("agents.extensions") + agents_models = types.ModuleType("agents.extensions.models") + agents_litellm = types.ModuleType("agents.extensions.models.litellm_model") + agents_litellm.LitellmModel = FakeLitellmModel + + openai = types.ModuleType("openai") + openai.AsyncOpenAI = FakeAsyncOpenAI + + pydantic = types.ModuleType("pydantic") + pydantic.BaseModel = FakeBaseModel + pydantic.Field = fake_field + pydantic.ValidationError = ValueError + + run_utils = types.ModuleType("run_utils") + run_utils._load_openswarm_dotenv = lambda *, override=False: False + + sys.modules.update( + { + "agency_swarm": agency, + "agency_swarm.tools": agency_tools, + "agents": agents, + "agents.extensions": agents_extensions, + "agents.extensions.models": agents_models, + "agents.extensions.models.litellm_model": agents_litellm, + "openai": openai, + "pydantic": pydantic, + "run_utils": run_utils, + } + ) + + +def install_package_stubs() -> None: + slides_agent = types.ModuleType("slides_agent") + slides_agent.__path__ = [str(ROOT / "slides_agent")] + tools = types.ModuleType("slides_agent.tools") + tools.__path__ = [str(ROOT / "slides_agent" / "tools")] + + slide_file_utils = types.ModuleType("slides_agent.tools.slide_file_utils") + slide_file_utils.get_project_dir = lambda name: Path(name) + slide_file_utils.apply_renames = lambda _renames: None + slide_file_utils.build_slide_name = ( + lambda prefix, idx, pad, suffix="": f"{prefix}_{idx:0{pad}d}{suffix}" + ) + slide_file_utils.compute_pad_width = lambda _slides, extra_count=0: 2 + slide_file_utils.list_slide_files = lambda *_args, **_kwargs: [] + + slide_html_utils = types.ModuleType("slides_agent.tools.slide_html_utils") + slide_html_utils.ensure_full_html = lambda html: (html or "", []) + slide_html_utils.list_slide_filenames = lambda _project_dir: [] + slide_html_utils.validate_html = lambda *_args, **_kwargs: {"valid": True} + slide_html_utils._strip_html_to_text = lambda html: html + + template_registry = types.ModuleType("slides_agent.tools.template_registry") + template_registry.load_template_index = lambda _project_dir: {} + template_registry.save_template_index = lambda *_args, **_kwargs: None + template_registry.template_path = lambda project_dir, key: Path(project_dir) / key + + sys.modules.update( + { + "slides_agent": slides_agent, + "slides_agent.tools": tools, + "slides_agent.tools.slide_file_utils": slide_file_utils, + "slides_agent.tools.slide_html_utils": slide_html_utils, + "slides_agent.tools.template_registry": template_registry, + } + ) + + +def load_module(name: str, relative: str): + path = ROOT / relative + spec = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + assert spec and spec.loader + spec.loader.exec_module(module) + return module + + +def load_slides_tools(): + load_module("slides_agent.tools.internal_model", "slides_agent/tools/internal_model.py") + modify = load_module( + "slides_agent.tools.ModifySlide", + "slides_agent/tools/ModifySlide.py", + ) + insert = load_module( + "slides_agent.tools.InsertNewSlides", + "slides_agent/tools/InsertNewSlides.py", + ) + return modify, insert + + +def tool_for(model): + agent = types.SimpleNamespace(model=model) + master = types.SimpleNamespace( + current_agent_name="Slides Agent", + agents={"Slides Agent": agent}, + ) + return types.SimpleNamespace(_context=types.SimpleNamespace(context=master)) + + +def settings_for(agent): + return agent.kwargs["model_settings"].kwargs + + +class SlidesInternalModelTests(unittest.TestCase): + def setUp(self): + install_stubs() + install_package_stubs() + os.environ.pop("DEFAULT_MODEL", None) + for name in ( + "slides_agent.tools.internal_model", + "slides_agent.tools.ModifySlide", + "slides_agent.tools.InsertNewSlides", + ): + sys.modules.pop(name, None) + + def assert_openai_settings(self, agent, *, is_codex=False): + settings = settings_for(agent) + self.assertEqual( + settings["reasoning"].kwargs, + {"effort": "high", "summary": "auto"}, + ) + self.assertEqual(settings["verbosity"], None if is_codex else "medium") + self.assertEqual(settings["store"], False if is_codex else None) + + def assert_no_openai_settings(self, agent): + self.assertEqual(settings_for(agent), {}) + + def test_internal_agents_inherit_selected_openai_model_and_client_config(self): + modify, insert = load_slides_tools() + + client = FakeAsyncOpenAI( + api_key="request-key", + base_url=OPENAI_BASE_URL, + organization="request-org", + project="request-project", + timeout=42, + max_retries=4, + default_headers={"X-Request": "slides"}, + default_query={"source": "request"}, + ) + selected = FakeOpenAIResponsesModel( + model="gpt-5.4-mini", + openai_client=client, + ) + + writer, writer_codex = modify._make_html_writer_agent(tool=tool_for(selected)) + planner, planner_codex = insert._make_planner_agent(tool=tool_for(selected)) + + self.assertFalse(writer_codex) + self.assertFalse(planner_codex) + self.assertEqual(writer.model.model, "gpt-5.4-mini") + self.assertEqual(planner.model.model, "gpt-5.4-mini") + for nested in (writer.model.openai_client, planner.model.openai_client): + self.assertIsNot(nested, client) + self.assertEqual(nested.api_key, "request-key") + self.assertEqual(nested.base_url, OPENAI_BASE_URL) + self.assertEqual(nested.organization, "request-org") + self.assertEqual(nested.project, "request-project") + self.assertEqual(nested.timeout, 42) + self.assertEqual(nested.max_retries, 4) + self.assertEqual(nested._custom_headers, {"X-Request": "slides"}) + self.assertEqual(nested.default_query, {"source": "request"}) + self.assert_openai_settings(writer) + self.assert_openai_settings(planner) + + def test_codex_compatible_client_keeps_model_and_uses_stream_settings(self): + modify, insert = load_slides_tools() + + client = FakeAsyncOpenAI( + api_key="request-key", + base_url=CODEX_COMPATIBLE_BASE_URL, + ) + selected = types.SimpleNamespace(model="gpt-5.4-mini", _client=client) + + writer, writer_codex = modify._make_html_writer_agent(tool=tool_for(selected)) + planner, planner_codex = insert._make_planner_agent(tool=tool_for(selected)) + + self.assertTrue(writer_codex) + self.assertTrue(planner_codex) + self.assertEqual(writer.model.model, "gpt-5.4-mini") + self.assertEqual(planner.model.model, "gpt-5.4-mini") + self.assertEqual(writer.model.openai_client.api_key, "request-key") + self.assertEqual(planner.model.openai_client.base_url, CODEX_COMPATIBLE_BASE_URL) + self.assert_openai_settings(writer, is_codex=True) + self.assert_openai_settings(planner, is_codex=True) + + def test_openai_compatible_plain_client_is_not_treated_as_codex(self): + modify, insert = load_slides_tools() + + client = FakeAsyncOpenAI( + api_key="request-compatible-key", + base_url=OPENAI_COMPATIBLE_BASE_URL, + ) + selected = FakeOpenAIResponsesModel( + model="custom-chat-model", + openai_client=client, + ) + + writer, writer_codex = modify._make_html_writer_agent(tool=tool_for(selected)) + planner, planner_codex = insert._make_planner_agent(tool=tool_for(selected)) + + self.assertFalse(writer_codex) + self.assertFalse(planner_codex) + self.assertIsInstance(writer.model, FakeOpenAIResponsesModel) + self.assertIsInstance(planner.model, FakeOpenAIResponsesModel) + self.assertEqual(writer.model.model, "custom-chat-model") + self.assertEqual(planner.model.model, "custom-chat-model") + self.assertEqual(writer.model.openai_client.base_url, OPENAI_COMPATIBLE_BASE_URL) + self.assertEqual(planner.model.openai_client.base_url, OPENAI_COMPATIBLE_BASE_URL) + self.assert_no_openai_settings(writer) + self.assert_no_openai_settings(planner) + + def test_litellm_selected_model_and_config_are_preserved(self): + modify, insert = load_slides_tools() + + selected = FakeLitellmModel( + model="openrouter/anthropic/claude-sonnet-4-6", + api_key="request-openrouter-key", + base_url=OPENROUTER_BASE_URL, + ) + + writer, writer_codex = modify._make_html_writer_agent(tool=tool_for(selected)) + planner, planner_codex = insert._make_planner_agent(tool=tool_for(selected)) + + self.assertFalse(writer_codex) + self.assertFalse(planner_codex) + self.assertEqual(writer.model.model, "openrouter/anthropic/claude-sonnet-4-6") + self.assertEqual(planner.model.model, "openrouter/anthropic/claude-sonnet-4-6") + self.assertEqual(writer.model.kwargs["api_key"], "request-openrouter-key") + self.assertEqual(planner.model.kwargs["api_key"], "request-openrouter-key") + self.assertEqual(writer.model.kwargs["base_url"], OPENROUTER_BASE_URL) + self.assertEqual(planner.model.kwargs["base_url"], OPENROUTER_BASE_URL) + self.assert_no_openai_settings(writer) + self.assert_no_openai_settings(planner) + + def test_openai_wrapper_provider_model_routes_to_litellm_with_client_config(self): + modify, insert = load_slides_tools() + + client = FakeAsyncOpenAI( + api_key="request-openrouter-key", + base_url=OPENROUTER_BASE_URL, + ) + selected = FakeOpenAIResponsesModel( + model="openrouter/anthropic/claude-sonnet-4-6", + openai_client=client, + ) + + writer, writer_codex = modify._make_html_writer_agent(tool=tool_for(selected)) + planner, planner_codex = insert._make_planner_agent(tool=tool_for(selected)) + + self.assertFalse(writer_codex) + self.assertFalse(planner_codex) + self.assertIsInstance(writer.model, FakeLitellmModel) + self.assertIsInstance(planner.model, FakeLitellmModel) + self.assertEqual(writer.model.model, "openrouter/anthropic/claude-sonnet-4-6") + self.assertEqual(planner.model.model, "openrouter/anthropic/claude-sonnet-4-6") + self.assertEqual(writer.model.kwargs["api_key"], "request-openrouter-key") + self.assertEqual(planner.model.kwargs["api_key"], "request-openrouter-key") + self.assertEqual(writer.model.kwargs["base_url"], OPENROUTER_BASE_URL) + self.assertEqual(planner.model.kwargs["base_url"], OPENROUTER_BASE_URL) + self.assert_no_openai_settings(writer) + self.assert_no_openai_settings(planner) + + def test_internal_agents_fall_back_to_default_model_env(self): + os.environ["DEFAULT_MODEL"] = "gpt-5.2" + modify, insert = load_slides_tools() + + writer, writer_codex = modify._make_html_writer_agent(tool=None) + planner, planner_codex = insert._make_planner_agent(tool=None) + + self.assertFalse(writer_codex) + self.assertFalse(planner_codex) + self.assertEqual(writer.model.model, "gpt-5.2") + self.assertEqual(planner.model.model, "gpt-5.2") + self.assert_openai_settings(writer) + self.assert_openai_settings(planner) + + def test_codex_model_strips_unsupported_settings_at_fetch_boundary(self): + modify, insert = load_slides_tools() + + settings = FakeModelSettings( + reasoning=FakeReasoning(effort="high", summary="auto"), + store=False, + truncation="auto", + verbosity="low", + ) + + writer = modify._CodexResponsesModel( + model="gpt-5.4-mini", + openai_client=FakeAsyncOpenAI(base_url=CODEX_COMPATIBLE_BASE_URL), + ) + planner = insert._CodexResponsesModel( + model="gpt-5.4-mini", + openai_client=FakeAsyncOpenAI(base_url=CODEX_COMPATIBLE_BASE_URL), + ) + + writer_settings = asyncio.run( + writer._fetch_response(None, [], settings, [], None, []) + ) + planner_settings = asyncio.run( + planner._fetch_response(None, [], settings, [], None, []) + ) + + for nested in (writer_settings, planner_settings): + self.assertIsNone(nested.truncation) + self.assertIsNone(nested.verbosity) + self.assertFalse(nested.store) + self.assertEqual( + nested.reasoning.kwargs, + {"effort": "high", "summary": "auto"}, + ) + + +if __name__ == "__main__": + unittest.main()