From 25864ae36cab4389b3c6cc9445ceb7118df796b1 Mon Sep 17 00:00:00 2001 From: Nick Bobrowski <39348559+nicko-ai@users.noreply.github.com> Date: Wed, 17 Jun 2026 05:53:51 +0100 Subject: [PATCH] fix: inherit selected model in slide tools - route Slides planner and HTML writer through the caller-selected model - preserve Codex/OpenAI client settings and LiteLLM provider routes - cover gpt-5.4-mini, openai-prefixed, OpenRouter, and Ollama-style paths --- slides_agent/tools/InsertNewSlides.py | 71 +-- slides_agent/tools/ModifySlide.py | 64 +-- slides_agent/tools/internal_model.py | 220 +++++++++ tests/test_slides_internal_models.py | 653 ++++++++++++++++++++++++++ 4 files changed, 911 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..f0d45696 --- /dev/null +++ b/slides_agent/tools/internal_model.py @@ -0,0 +1,220 @@ +"""Model selection helpers for Slides internal agents.""" + +from __future__ import annotations + +import inspect +import os +from typing import Any, Callable + +from openai import AsyncOpenAI + + +_OPENAI_MODEL_FALLBACK = "gpt-5.2" +_LITELLM_PREFIX = "litellm/" +_OPENAI_PREFIX = "openai/" +_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) + agent_name = getattr(master, "current_agent_name", None) + agents = getattr(master, "agents", {}) + return agents.get(agent_name) if agent_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_name(tool: Any) -> str | None: + agent = _current_agent(tool) + return _model_name(getattr(agent, "model", None)) + + +def _caller_model(tool: Any) -> Any | None: + agent = _current_agent(tool) + return getattr(agent, "model", None) + + +def caller_openai_client(tool: Any) -> AsyncOpenAI | None: + agent = _current_agent(tool) + model = getattr(agent, "model", None) + maybe = _source_openai_client(model) + if isinstance(maybe, AsyncOpenAI): + return maybe + return 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]: + model = _caller_model(tool) + name = _model_name(model) + if name: + return name, model + return os.getenv("DEFAULT_MODEL", "").strip() or _OPENAI_MODEL_FALLBACK, None + + +def _client_config_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_config_value(client, field)) is not None + } + return AsyncOpenAI(**kwargs) + + +def _is_codex_client(client: AsyncOpenAI | None) -> bool: + return bool( + client and not str(client.base_url).startswith("https://api.openai.com") + ) + + +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 + if "/" in model: + return True + return False + + +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_config_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 + if bare.startswith("anthropic/"): + anthropic_key = os.getenv("ANTHROPIC_API_KEY", "").strip() + if anthropic_key and "api_key" not in kwargs: + kwargs["api_key"] = anthropic_key + 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 = caller_openai_client(tool) + client = _clone_openai_client(caller_client) + 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) + return not _is_litellm_model(model, source) + + +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..cc08505f --- /dev/null +++ b/tests/test_slides_internal_models.py @@ -0,0 +1,653 @@ +from __future__ import annotations + +import asyncio +import importlib.util +import os +import subprocess +import sys +import types +import unittest +from dataclasses import dataclass +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] + + +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="https://api.openai.com/v1", + 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.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 _html: [] + 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 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) + os.environ.pop("ANTHROPIC_API_KEY", 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(self): + 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", + ) + + tool = tool_for("gpt-5.4-mini") + + writer, writer_codex = modify._make_html_writer_agent(tool=tool) + planner, planner_codex = insert._make_planner_agent(tool=tool) + + 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") + self.assert_openai_settings(writer) + self.assert_openai_settings(planner) + + def test_internal_agents_use_codex_model_wrapper_with_selected_model(self): + 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", + ) + + client = FakeAsyncOpenAI( + api_key="request-key", + base_url="https://codex.local/v1", + ) + selected = types.SimpleNamespace(model="gpt-5.4-mini", _client=client) + tool = tool_for(selected) + + writer, writer_codex = modify._make_html_writer_agent(tool=tool) + planner, planner_codex = insert._make_planner_agent(tool=tool) + + 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, "https://codex.local/v1") + self.assert_openai_settings(writer, is_codex=True) + self.assert_openai_settings(planner, is_codex=True) + + def test_openai_prefixed_codex_model_uses_openai_wrapper_not_litellm(self): + 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", + ) + + client = FakeAsyncOpenAI( + api_key="request-key", + base_url="https://codex.local/v1", + ) + selected = FakeOpenAIResponsesModel( + model="openai/gpt-5.4-mini", + openai_client=client, + ) + tool = tool_for(selected) + + writer, writer_codex = modify._make_html_writer_agent(tool=tool) + planner, planner_codex = insert._make_planner_agent(tool=tool) + + self.assertTrue(writer_codex) + self.assertTrue(planner_codex) + self.assertIsInstance(writer.model, FakeOpenAIResponsesModel) + self.assertIsInstance(planner.model, FakeOpenAIResponsesModel) + self.assertNotIsInstance(writer.model, FakeLitellmModel) + self.assertNotIsInstance(planner.model, FakeLitellmModel) + self.assertEqual(writer.model.model, "openai/gpt-5.4-mini") + self.assertEqual(planner.model.model, "openai/gpt-5.4-mini") + self.assertEqual(writer.model.openai_client.api_key, "request-key") + self.assertEqual(planner.model.openai_client.base_url, "https://codex.local/v1") + self.assert_openai_settings(writer, is_codex=True) + self.assert_openai_settings(planner, is_codex=True) + + def test_codex_model_strips_unsupported_settings_at_fetch_boundary(self): + 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", + ) + + 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="https://codex.local/v1"), + ) + planner = insert._CodexResponsesModel( + model="gpt-5.4-mini", + openai_client=FakeAsyncOpenAI(base_url="https://codex.local/v1"), + ) + + 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"}, + ) + + def test_codex_client_request_config_is_preserved(self): + 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", + ) + + client = FakeAsyncOpenAI( + api_key="request-key", + base_url="https://codex.local/v1", + organization="request-org", + project="request-project", + timeout=42, + max_retries=4, + default_headers={"X-Request": "slides"}, + default_query={"source": "codex"}, + ) + selected = types.SimpleNamespace(model="gpt-5.4-mini", _client=client) + tool = tool_for(selected) + + writer, _ = modify._make_html_writer_agent(tool=tool) + planner, _ = insert._make_planner_agent(tool=tool) + + for nested in (writer.model.openai_client, planner.model.openai_client): + self.assertEqual(nested.api_key, "request-key") + self.assertEqual(nested.base_url, "https://codex.local/v1") + 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": "codex"}) + + def test_internal_agents_fall_back_to_default_model_env(self): + os.environ["DEFAULT_MODEL"] = "gpt-5.2" + 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", + ) + + writer, _ = modify._make_html_writer_agent(tool=None) + planner, _ = insert._make_planner_agent(tool=None) + + 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_litellm_selected_model_is_preserved(self): + os.environ["ANTHROPIC_API_KEY"] = "anthropic-key" + 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", + ) + + tool = tool_for("anthropic/claude-sonnet-4-6") + + writer, writer_codex = modify._make_html_writer_agent(tool=tool) + planner, planner_codex = insert._make_planner_agent(tool=tool) + + self.assertFalse(writer_codex) + self.assertFalse(planner_codex) + self.assertEqual(writer.model.model, "anthropic/claude-sonnet-4-6") + self.assertEqual(planner.model.model, "anthropic/claude-sonnet-4-6") + self.assertEqual(writer.model.kwargs["api_key"], "anthropic-key") + self.assertEqual(planner.model.kwargs["api_key"], "anthropic-key") + self.assert_no_openai_settings(writer) + self.assert_no_openai_settings(planner) + + def test_ollama_model_path_uses_litellm_without_openai_settings(self): + 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", + ) + + tool = tool_for("litellm/ollama/llama3.1") + + writer, writer_codex = modify._make_html_writer_agent(tool=tool) + planner, planner_codex = insert._make_planner_agent(tool=tool) + + self.assertFalse(writer_codex) + self.assertFalse(planner_codex) + self.assertEqual(writer.model.model, "ollama/llama3.1") + self.assertEqual(planner.model.model, "ollama/llama3.1") + self.assert_no_openai_settings(writer) + self.assert_no_openai_settings(planner) + + def test_litellm_wrapper_request_config_is_preserved(self): + 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", + ) + + selected = FakeLitellmModel( + model="openrouter/anthropic/claude-sonnet-4-6", + api_key="request-openrouter-key", + base_url="https://openrouter.ai/api/v1", + ) + tool = tool_for(selected) + + writer, writer_codex = modify._make_html_writer_agent(tool=tool) + planner, planner_codex = insert._make_planner_agent(tool=tool) + + 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"], "https://openrouter.ai/api/v1" + ) + self.assertEqual( + planner.model.kwargs["base_url"], "https://openrouter.ai/api/v1" + ) + self.assert_no_openai_settings(writer) + self.assert_no_openai_settings(planner) + + def test_openai_compatible_wrapper_config_is_preserved_for_litellm_route(self): + 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", + ) + + client = FakeAsyncOpenAI( + api_key="request-openrouter-key", + base_url="https://openrouter.ai/api/v1", + ) + selected = FakeOpenAIResponsesModel( + model="openrouter/anthropic/claude-sonnet-4-6", + openai_client=client, + ) + tool = tool_for(selected) + + writer, writer_codex = modify._make_html_writer_agent(tool=tool) + planner, planner_codex = insert._make_planner_agent(tool=tool) + + 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"], "https://openrouter.ai/api/v1" + ) + self.assertEqual( + planner.model.kwargs["base_url"], "https://openrouter.ai/api/v1" + ) + self.assert_no_openai_settings(writer) + self.assert_no_openai_settings(planner) + + +class RealSlidesInternalModelSmokeTests(unittest.TestCase): + def test_real_agent_sdk_model_routes_are_smoke_checked_offline(self): + script = r''' +import importlib.util +import types +from pathlib import Path + +root = Path.cwd() +spec = importlib.util.spec_from_file_location( + "slides_internal_model_real", + root / "slides_agent/tools/internal_model.py", +) +internal = importlib.util.module_from_spec(spec) +assert spec and spec.loader +spec.loader.exec_module(internal) + +from agents import OpenAIChatCompletionsModel, OpenAIResponsesModel +from agents.extensions.models.litellm_model import LitellmModel +from openai import AsyncOpenAI + + +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)) + + +client = AsyncOpenAI( + api_key="request-key", + base_url="https://codex.local/v1", + default_headers={"X-Request": "slides"}, + default_query={"source": "codex"}, +) +for source_type in (OpenAIResponsesModel, OpenAIChatCompletionsModel): + for model_name in ("gpt-5.4-mini", "openai/gpt-5.4-mini"): + selected = source_type(model=model_name, openai_client=client) + nested, is_codex = internal.make_internal_model( + tool_for(selected), + litellm_model=LitellmModel, + openai_model=OpenAIResponsesModel, + codex_model=OpenAIResponsesModel, + ) + assert is_codex + assert isinstance(nested, OpenAIResponsesModel) + assert not isinstance(nested, LitellmModel) + assert nested.model == model_name + assert nested._client is not client + assert nested._client.api_key == "request-key" + assert str(nested._client.base_url) == "https://codex.local/v1/" + assert nested._client._custom_headers == {"X-Request": "slides"} + assert nested._client._custom_query == {"source": "codex"} + settings = internal.make_internal_model_settings( + tool_for(selected), + is_codex=is_codex, + ) + assert settings.reasoning.effort == "high" + assert settings.reasoning.summary == "auto" + assert settings.verbosity is None + assert settings.store is False + +openrouter_client = AsyncOpenAI( + api_key="request-openrouter-key", + base_url="https://openrouter.ai/api/v1", +) +selected = OpenAIResponsesModel( + model="openrouter/anthropic/claude-sonnet-4-6", + openai_client=openrouter_client, +) +nested, is_codex = internal.make_internal_model( + tool_for(selected), + litellm_model=LitellmModel, + openai_model=OpenAIResponsesModel, + codex_model=OpenAIResponsesModel, +) +assert not is_codex +assert isinstance(nested, LitellmModel) +assert nested.model == "openrouter/anthropic/claude-sonnet-4-6" +assert nested.api_key == "request-openrouter-key" +assert str(nested.base_url).rstrip("/") == "https://openrouter.ai/api/v1" +print("real smoke ok") +''' + env = os.environ.copy() + env.pop("DEFAULT_MODEL", None) + env.pop("ANTHROPIC_API_KEY", None) + result = subprocess.run( + [sys.executable, "-c", script], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + timeout=30, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main()