From 29e5e652640c7c69fa87b6eacf7f44dc2364f0c9 Mon Sep 17 00:00:00 2001 From: Nick Bobrowski <39348559+nicko-ai@users.noreply.github.com> Date: Fri, 24 Jul 2026 04:06:03 +0100 Subject: [PATCH 1/2] fix: preserve slides helper model transport - inherit request and caller model wrappers in Slides planner and writer\n- route only verified Codex browser auth through Responses streaming\n- keep generic compatible, OpenRouter, LiteLLM, and Ollama transports intact\n- recover non-empty structured text from streamed helper output --- slides_agent/tools/InsertNewSlides.py | 162 +------- slides_agent/tools/ModifySlide.py | 171 +-------- slides_agent/tools/internal_model.py | 349 +++++++++++++++++ tests/test_slides_internal_models.py | 528 ++++++++++++++++++++++++++ 4 files changed, 909 insertions(+), 301 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 dbbfdfed..432fdb82 100644 --- a/slides_agent/tools/InsertNewSlides.py +++ b/slides_agent/tools/InsertNewSlides.py @@ -12,16 +12,16 @@ from pathlib import Path 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 agency_swarm.utils.openrouter import OPENROUTER_BASE_URL, build_openrouter_chat_model -from openai import AsyncOpenAI -from agents.extensions.models.litellm_model import LitellmModel from pydantic import BaseModel, Field, ValidationError -from config import get_default_model from run_utils import _load_openswarm_dotenv +from .internal_model import ( + get_internal_agent_response, + make_internal_model, + make_internal_model_settings, +) from .slide_file_utils import ( apply_renames, build_slide_name, @@ -33,9 +33,6 @@ from .template_registry import load_template_index -_PLANNER_MODEL_CLAUDE = "anthropic/claude-sonnet-4-6" - - class _PlanSlide(BaseModel): page: int title: str @@ -50,49 +47,6 @@ class _PlanResponse(BaseModel): slides: list[_PlanSlide] -def _get_caller_info(tool) -> "tuple[AsyncOpenAI | None, str | None]": - """Return (openai_client, model_name_str, model_obj) from the calling agent's context.""" - 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) - client = None - for attr in ("_client", "openai_client", "client"): - maybe = getattr(model, attr, None) - if isinstance(maybe, AsyncOpenAI): - client = maybe - break - model_name = model if isinstance(model, str) else getattr(model, "model", None) - if not isinstance(model_name, str): - model_name = None - return client, model_name, model - - -class _CodexResponsesModel: - """Subclass of OpenAIResponsesModel that strips parameters unsupported by the Codex endpoint.""" - - _cls = None - - @classmethod - def _get_cls(cls): - if cls._cls is None: - from agents import OpenAIResponsesModel - from dataclasses import replace - - class _Impl(OpenAIResponsesModel): - async def _fetch_response(self, system_instructions, input, model_settings, *args, **kwargs): - model_settings = replace(model_settings, truncation=None) - return await super()._fetch_response(system_instructions, input, model_settings, *args, **kwargs) - - cls._cls = _Impl - return cls._cls - - def __new__(cls, model: str, openai_client): - return cls._get_cls()(model=model, openai_client=openai_client) - - def _register_sub_agent_usage(tool, agent_model, result) -> None: """Push sub-agent raw API responses into the master context for UI cost tracking.""" try: @@ -112,101 +66,17 @@ def _register_sub_agent_usage(tool, agent_model, result) -> None: pass -async def _agent_get_response(agent: Agent, prompt: str, *, use_stream: bool = False): - """Call agent.get_response or stream-based equivalent. - - Codex endpoint requires stream=True; use get_response_stream() in that case. - """ - if use_stream: - stream = agent.get_response_stream(prompt) - text_deltas: list[str] = [] - async for event in stream: - data = getattr(event, "data", None) - if data is not None and getattr(data, "type", None) == "response.output_text.delta": - delta = getattr(data, "delta", None) - if delta and isinstance(delta, str): - text_deltas.append(delta) - try: - result = await stream.wait_final_result() - except Exception: # noqa: BLE001 - result = None - fo = getattr(result, "final_output", None) if result is not None else None - if fo: - return result - - assembled = "".join(text_deltas) - _raw = getattr(result, "raw_responses", []) or [] - - class _R: - final_output = assembled - raw_responses = _raw - - return _R() - return await agent.get_response(prompt) - - 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 (explicit key) - 2. Non-OpenAI DEFAULT_MODEL (e.g. anthropic/claude-*) → LiteLLM - 3. Calling agent's OpenAI client (browser auth / per-request ClientConfig) - 4. AsyncOpenAI() default (env vars) + 1. Request-level RunConfig model + 2. Calling agent's selected model and transport + 3. DEFAULT_MODEL from the OpenSwarm environment Returns (agent, is_codex). """ - anthropic_key = os.getenv("ANTHROPIC_API_KEY") - is_codex = False - is_openrouter = False - model = None - if anthropic_key: - model = LitellmModel(model=_PLANNER_MODEL_CLAUDE, api_key=anthropic_key) - else: - from agents import OpenAIResponsesModel - from openai import AsyncOpenAI - caller_client, caller_model_name, caller_model_obj = tool and _get_caller_info(tool) or (None, None, None) - if caller_client is None: - # When the calling agent uses a string model its client isn't on the model - # object. Fall back to the SDK global default client (set by Codex browser - # auth) so we can copy credentials into a fresh thread-safe client. - try: - from agents.models._openai_shared import get_default_openai_client as _sdk_default - caller_client = _sdk_default() - except Exception: - pass - if caller_model_obj is not None and not isinstance(caller_model_obj, str) and caller_client is None: - # Non-OpenAI provider (e.g. LiteLLM/Anthropic) — reuse the caller's model directly - model = caller_model_obj - else: - 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. - # Also copy any non-standard headers (e.g. ChatGPT-Account-Id for Codex auth). - _STD_HDR_PREFIXES = ( - "accept", "content-type", "user-agent", - "x-stainless-", "openai-", - ) - extra_headers = { - k: v for k, v in caller_client.default_headers.items() - if not any(k.lower().startswith(p) for p in _STD_HDR_PREFIXES) - } - client = AsyncOpenAI( - api_key=caller_client.api_key, - base_url=str(caller_client.base_url), - default_headers=extra_headers if extra_headers else None, - ) - else: - client = AsyncOpenAI() - model_name = caller_model_name or get_default_model() - is_openrouter = str(client.base_url).rstrip("/") == OPENROUTER_BASE_URL.rstrip("/") - is_codex = not is_openrouter and not str(client.base_url).startswith("https://api.openai.com") - if is_openrouter: - model = build_openrouter_chat_model(model_name, openai_client=client) - elif is_codex: - model = _CodexResponsesModel(model=model_name, openai_client=client) - else: - model = OpenAIResponsesModel(model=model_name, openai_client=client) + route = make_internal_model(tool) agent = Agent( name="Slide Planner", description="Creates structured slide outline plans.", @@ -215,15 +85,11 @@ def _make_planner_agent(tool=None) -> "tuple[Agent, bool]": "Fill the requested output schema exactly." ), tools=[], - model=model, + model=route.model, output_type=_PlanResponse, - model_settings=ModelSettings( - reasoning=Reasoning(effort="high", summary="auto" if not is_openrouter else None), - verbosity=None if (is_codex or is_openrouter) else "medium", - store=False if is_codex else None, - ), + model_settings=make_internal_model_settings(route), ) - return agent, is_codex + return agent, route.is_codex def _run_awaitable(awaitable): @@ -476,7 +342,7 @@ def run(self): self.task_brief, n, insert_position, existing_templates ) plan_result = _run_awaitable( - _agent_get_response(planner, prompt, use_stream=is_codex) + get_internal_agent_response(planner, prompt, use_stream=is_codex) ) _register_sub_agent_usage(self, planner.model, plan_result) except Exception as exc: diff --git a/slides_agent/tools/ModifySlide.py b/slides_agent/tools/ModifySlide.py index ab565ce5..431c6d18 100644 --- a/slides_agent/tools/ModifySlide.py +++ b/slides_agent/tools/ModifySlide.py @@ -9,23 +9,23 @@ import asyncio import base64 import mimetypes -import os import re import tempfile import threading from datetime import datetime, timezone from pathlib import Path -from typing import Any, Callable +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 agency_swarm.utils.openrouter import OPENROUTER_BASE_URL, build_openrouter_chat_model -from agents.extensions.models.litellm_model import LitellmModel -from openai import AsyncOpenAI from pydantic import Field -from config import get_default_model from run_utils import _load_openswarm_dotenv +from .internal_model import ( + get_internal_agent_response, + make_internal_model, + make_internal_model_settings, +) from .slide_file_utils import get_project_dir from .slide_html_utils import ( ensure_full_html, @@ -34,6 +34,7 @@ _strip_html_to_text, ) from .template_registry import load_template_index, save_template_index, template_path + # Per-project locks for the template-index read-modify-write. _index_locks: dict[str, threading.Lock] = {} _index_locks_guard = threading.Lock() @@ -221,53 +222,9 @@ def replace_href(match: re.Match) -> str: return html -_HTML_WRITER_MODEL_CLAUDE = "anthropic/claude-sonnet-4-6" _HTML_WRITER_MAX_ATTEMPTS = 3 -def _get_caller_info(tool) -> "tuple[AsyncOpenAI | None, str | None]": - """Return (openai_client, model_name_str) from the calling agent's context.""" - 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) - client = None - for attr in ("_client", "openai_client", "client"): - maybe = getattr(model, attr, None) - if isinstance(maybe, AsyncOpenAI): - client = maybe - break - model_name = model if isinstance(model, str) else getattr(model, "model", None) - if not isinstance(model_name, str): - model_name = None - return client, model_name, model - - -class _CodexResponsesModel: - """Subclass of OpenAIResponsesModel that strips parameters unsupported by the Codex endpoint.""" - - _cls = None - - @classmethod - def _get_cls(cls): - if cls._cls is None: - from agents import OpenAIResponsesModel - from dataclasses import replace - - class _Impl(OpenAIResponsesModel): - async def _fetch_response(self, system_instructions, input, model_settings, *args, **kwargs): - model_settings = replace(model_settings, truncation=None) - return await super()._fetch_response(system_instructions, input, model_settings, *args, **kwargs) - - cls._cls = _Impl - return cls._cls - - def __new__(cls, model: str, openai_client): - return cls._get_cls()(model=model, openai_client=openai_client) - - def _register_sub_agent_usage(tool, agent_model, result) -> None: """Push sub-agent raw API responses into the master context for UI cost tracking.""" try: @@ -287,119 +244,26 @@ def _register_sub_agent_usage(tool, agent_model, result) -> None: pass -async def _agent_get_response( - agent: Agent, - prompt: str, - *, - use_stream: bool = False, - on_delta: "Callable[[str], None] | None" = None, -): - """Call agent.get_response or stream-based equivalent. - - Codex endpoint requires stream=True; use get_response_stream() in that case. - on_delta, if provided, forces streaming and is called for each text token. - """ - if use_stream or on_delta is not None: - stream = agent.get_response_stream(prompt) - text_deltas: list[str] = [] - async for event in stream: - data = getattr(event, "data", None) - if data is not None and getattr(data, "type", None) == "response.output_text.delta": - delta = getattr(data, "delta", None) - if delta and isinstance(delta, str): - text_deltas.append(delta) - if on_delta is not None: - try: - on_delta(delta) - except Exception: - pass - result = await stream.wait_final_result() - fo = getattr(result, "final_output", None) if result is not None else None - if not fo and text_deltas: - assembled = "".join(text_deltas) - _raw = getattr(result, "raw_responses", []) or [] - - class _R: - final_output = assembled - raw_responses = _raw - - return _R() - return result - return await agent.get_response(prompt) - - 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 (explicit key) - 2. Non-OpenAI DEFAULT_MODEL (e.g. anthropic/claude-*) → LiteLLM - 3. Calling agent's OpenAI client (browser auth / per-request ClientConfig) - 4. AsyncOpenAI() default (env vars) + 1. Request-level RunConfig model + 2. Calling agent's selected model and transport + 3. DEFAULT_MODEL from the OpenSwarm environment Returns (agent, is_codex). """ - anthropic_key = os.getenv("ANTHROPIC_API_KEY") - is_codex = False - is_openrouter = False - model = None - 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, caller_model_name, caller_model_obj = tool and _get_caller_info(tool) or (None, None, None) - if caller_client is None: - # When the calling agent uses a string model its client isn't on the model - # object. Fall back to the SDK global default client (set by Codex browser - # auth) so we can copy credentials into a fresh thread-safe client. - try: - from agents.models._openai_shared import get_default_openai_client as _sdk_default - caller_client = _sdk_default() - except Exception: - pass - if caller_model_obj is not None and not isinstance(caller_model_obj, str) and caller_client is None: - # Non-OpenAI provider (e.g. LiteLLM/Anthropic) — reuse the caller's model directly - model = caller_model_obj - else: - if caller_client: - _STD_HDR_PREFIXES = ( - "accept", "content-type", "user-agent", - "x-stainless-", "openai-", - ) - extra_headers = { - k: v for k, v in caller_client.default_headers.items() - if not any(k.lower().startswith(p) for p in _STD_HDR_PREFIXES) - } - client = AsyncOpenAI( - api_key=caller_client.api_key, - base_url=str(caller_client.base_url), - default_headers=extra_headers if extra_headers else None, - ) - else: - client = AsyncOpenAI() - model_name = caller_model_name or get_default_model() - is_openrouter = str(client.base_url).rstrip("/") == OPENROUTER_BASE_URL.rstrip("/") - is_codex = not is_openrouter and not str(client.base_url).startswith("https://api.openai.com") - if is_openrouter: - model = build_openrouter_chat_model(model_name, openai_client=client) - elif is_codex: - model = _CodexResponsesModel(model=model_name, openai_client=client) - else: - model = OpenAIResponsesModel(model=model_name, openai_client=client) + route = make_internal_model(tool) 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" if not is_openrouter else None), - verbosity=None if (is_codex or is_openrouter) else "medium", - store=False if is_codex else None, - ), + model=route.model, + model_settings=make_internal_model_settings(route), ) - return agent, is_codex + return agent, route.is_codex def _extract_html_from_output(text: str) -> str: @@ -706,8 +570,9 @@ def _preview_done() -> None: ) try: - final_result = await _agent_get_response( - writer, prompt, + final_result = await get_internal_agent_response( + writer, + prompt, use_stream=is_codex, on_delta=_on_delta if attempt == 1 else None, ) diff --git a/slides_agent/tools/internal_model.py b/slides_agent/tools/internal_model.py new file mode 100644 index 00000000..49d8f538 --- /dev/null +++ b/slides_agent/tools/internal_model.py @@ -0,0 +1,349 @@ +"""Model and transport helpers for Slides internal agents.""" + +from __future__ import annotations + +import inspect +from dataclasses import dataclass, replace +from typing import Any, Literal +from urllib.parse import urlsplit + +from agency_swarm import ModelSettings, Reasoning +from agency_swarm.messages.codex_input import is_codex_base_url +from agency_swarm.utils.openrouter import ( + build_openrouter_chat_model, + get_openrouter_model_name, + is_openrouter_model_name, +) +from agents import OpenAIChatCompletionsModel, OpenAIResponsesModel +from agents.extensions.models.litellm_model import LitellmModel +from agents.models._openai_shared import get_default_openai_client +from config import get_default_model +from openai import AsyncOpenAI + + +Transport = Literal[ + "openai_responses", + "codex_responses", + "chat_completions", + "openrouter", + "litellm", +] + +_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", + "should_replay_reasoning_content", +) +_OPENAI_CLIENT_FIELDS = ( + "api_key", + "base_url", + "organization", + "project", + "timeout", + "max_retries", + "default_headers", + "default_query", +) + + +@dataclass(frozen=True) +class InternalModelRoute: + model: Any + transport: Transport + + @property + def is_codex(self) -> bool: + return self.transport == "codex_responses" + + +@dataclass +class _StreamResult: + final_output: str + raw_responses: list[Any] + + +class _CodexResponsesModel(OpenAIResponsesModel): + """Responses model with settings accepted by Codex browser auth.""" + + async def _fetch_response( + self, + system_instructions, + input, + model_settings, + *args, + **kwargs, + ): + model_settings = replace( + model_settings, + truncation=None, + verbosity=None, + ) + return await super()._fetch_response( + system_instructions, + input, + model_settings, + *args, + **kwargs, + ) + + +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: + return getattr(_current_agent(tool), "model", None) + + +def _run_config_model(tool: Any) -> Any | None: + ctx = getattr(tool, "_context", None) + return getattr(getattr(ctx, "run_config", None), "model", None) + + +def _source_openai_client(source: Any | None) -> AsyncOpenAI | None: + if source is None: + return None + for attr in ("_client", "openai_client", "client"): + maybe = getattr(source, attr, None) + if isinstance(maybe, AsyncOpenAI): + return maybe + return None + + +def _resolved_model(tool: Any) -> tuple[str, Any | None]: + caller = _caller_model(tool) + request = _run_config_model(tool) + request_name = _model_name(request) + if request_name: + source = request if not isinstance(request, str) else caller + return request_name, source + + caller_name = _model_name(caller) + if caller_name: + return caller_name, caller + + default = get_default_model() + return _model_name(default) or "gpt-5.4", default + + +def _client_value(client: AsyncOpenAI, field: str) -> Any | None: + if field == "api_key": + provider = getattr(client, "_api_key_provider", None) + return provider if provider is not None else getattr(client, "api_key", None) + if field == "base_url": + value = getattr(client, "base_url", None) + return str(value) if value is not None else None + if field == "default_headers": + return getattr(client, "_custom_headers", None) + if field == "default_query": + return getattr(client, "_custom_query", None) + return getattr(client, field, None) + + +def _clone_openai_client(client: AsyncOpenAI | None) -> AsyncOpenAI: + source = client or get_default_openai_client() + if source is None: + return AsyncOpenAI() + kwargs = { + field: value + for field in _OPENAI_CLIENT_FIELDS + if (value := _client_value(source, field)) is not None + } + return AsyncOpenAI(**kwargs) + + +def _is_direct_openai_url(value: str | None) -> bool: + if not value: + return True + parsed = urlsplit(value) + return ( + parsed.scheme == "https" + and parsed.hostname == "api.openai.com" + and parsed.path.rstrip("/") in ("", "/v1") + and not parsed.query + and not parsed.fragment + ) + + +def _is_litellm_route(model: str, source: Any | None) -> bool: + if isinstance(source, LitellmModel): + return True + if model.startswith(_LITELLM_PREFIX): + return True + if get_openrouter_model_name(source): + return False + if model.startswith((_OPENAI_PREFIX, "openrouter/")): + 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] + client = _source_openai_client(source) + if client is not None and field in _OPENAI_CLIENT_FIELDS: + return _client_value(client, field) + return None + + +def _accepted_litellm_fields() -> set[str]: + try: + params = inspect.signature(LitellmModel).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 _make_litellm_model(model: str, source: Any | None) -> LitellmModel: + bare = model[len(_LITELLM_PREFIX) :] if model.startswith(_LITELLM_PREFIX) else model + kwargs: dict[str, Any] = {"model": bare} + for field in _accepted_litellm_fields(): + value = _config_value(source, field) + if value is not None: + kwargs[field] = value + return LitellmModel(**kwargs) + + +def make_internal_model(tool: Any) -> InternalModelRoute: + """Clone the selected model and its transport for a Slides sub-agent.""" + model_name, source = _resolved_model(tool) + + if _is_litellm_route(model_name, source): + return InternalModelRoute( + model=_make_litellm_model(model_name, source), + transport="litellm", + ) + + source_client = _source_openai_client(source) + client = _clone_openai_client(source_client) + openrouter_name = get_openrouter_model_name(source) + if openrouter_name or is_openrouter_model_name(model_name): + alias = model_name if is_openrouter_model_name(model_name) else openrouter_name + return InternalModelRoute( + model=build_openrouter_chat_model( + alias, + openai_client=client, + should_replay_reasoning_content=getattr( + source, + "should_replay_reasoning_content", + None, + ), + ), + transport="openrouter", + ) + + base_url = str(client.base_url) + if is_codex_base_url(base_url): + return InternalModelRoute( + model=_CodexResponsesModel(model=model_name, openai_client=client), + transport="codex_responses", + ) + + if isinstance(source, OpenAIChatCompletionsModel) or not _is_direct_openai_url( + base_url + ): + return InternalModelRoute( + model=OpenAIChatCompletionsModel( + model=model_name, + openai_client=client, + ), + transport="chat_completions", + ) + + return InternalModelRoute( + model=OpenAIResponsesModel(model=model_name, openai_client=client), + transport="openai_responses", + ) + + +def make_internal_model_settings(route: InternalModelRoute) -> ModelSettings: + """Use OpenAI-only settings only on verified Responses transports.""" + if route.transport not in {"openai_responses", "codex_responses"}: + return ModelSettings() + return ModelSettings( + reasoning=Reasoning(effort="high", summary="auto"), + verbosity=None if route.is_codex else "medium", + store=False if route.is_codex else None, + ) + + +async def get_internal_agent_response( + agent: Any, + prompt: str, + *, + use_stream: bool = False, + on_delta: Any | None = None, +) -> Any: + """Run a helper agent and retain streamed text when final parsing is empty.""" + if not use_stream and on_delta is None: + return await agent.get_response(prompt) + + stream = agent.get_response_stream(prompt) + text_deltas: list[str] = [] + async for event in stream: + data = getattr(event, "data", None) + if getattr(data, "type", None) != "response.output_text.delta": + continue + delta = getattr(data, "delta", None) + if not isinstance(delta, str) or not delta: + continue + text_deltas.append(delta) + if on_delta is not None: + try: + on_delta(delta) + except Exception: + pass + + result = None + final_error: Exception | None = None + try: + result = await stream.wait_final_result() + except Exception as exc: + final_error = exc + + if getattr(result, "final_output", None): + return result + + assembled = "".join(text_deltas) + if assembled: + return _StreamResult( + final_output=assembled, + raw_responses=getattr(result, "raw_responses", []) or [], + ) + if final_error is not None: + raise final_error + return result diff --git a/tests/test_slides_internal_models.py b/tests/test_slides_internal_models.py new file mode 100644 index 00000000..9cbebf15 --- /dev/null +++ b/tests/test_slides_internal_models.py @@ -0,0 +1,528 @@ +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] +OPENAI_URL = "https://api.openai.com/v1" +CODEX_URL = "https://chatgpt.com/backend-api/codex" +COMPATIBLE_URL = "https://codex.example.test/v1" +OPENROUTER_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.kwargs = kwargs + for name in ("reasoning", "verbosity", "store", "truncation"): + setattr(self, name, kwargs.get(name)) + + +class FakeReasoning: + def __init__(self, **kwargs): + self.kwargs = kwargs + + +class FakeAsyncOpenAI: + def __init__( + self, + *, + api_key="env-key", + base_url=OPENAI_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._custom_query = default_query + + +class FakeResponsesModel: + def __init__(self, *, model, openai_client): + self.model = model + self._client = openai_client + + async def _fetch_response(self, _system, _input, settings, *args, **kwargs): + return settings + + +class FakeChatModel: + def __init__( + self, + *, + model, + openai_client, + should_replay_reasoning_content=None, + ): + self.model = model + self._client = openai_client + self.should_replay_reasoning_content = should_replay_reasoning_content + + +class FakeLitellmModel: + def __init__(self, **kwargs): + self.kwargs = kwargs + for key, value in kwargs.items(): + setattr(self, key, value) + + +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 + + agency_messages = types.ModuleType("agency_swarm.messages") + codex_input = types.ModuleType("agency_swarm.messages.codex_input") + codex_input.is_codex_base_url = lambda value: bool( + value and value.rstrip("/") == CODEX_URL + ) + + agency_utils = types.ModuleType("agency_swarm.utils") + openrouter = types.ModuleType("agency_swarm.utils.openrouter") + openrouter.is_openrouter_model_name = lambda value: value.startswith("openrouter/") + openrouter.get_openrouter_model_name = lambda model: getattr( + model, + "_agency_swarm_openrouter_model_name", + None, + ) + + def build_openrouter_chat_model( + model_name, + *, + openai_client, + should_replay_reasoning_content=None, + ): + actual = ( + model_name[len("openrouter/") :] + if model_name.startswith("openrouter/") + else model_name + ) + model = FakeChatModel( + model=actual, + openai_client=openai_client, + should_replay_reasoning_content=should_replay_reasoning_content, + ) + model._agency_swarm_openrouter_model_name = f"openrouter/{actual}" + return model + + openrouter.build_openrouter_chat_model = build_openrouter_chat_model + + agents = types.ModuleType("agents") + agents.OpenAIResponsesModel = FakeResponsesModel + agents.OpenAIChatCompletionsModel = FakeChatModel + + 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 + agents_shared = types.ModuleType("agents.models._openai_shared") + agents_shared.get_default_openai_client = lambda: None + + 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, + "agency_swarm.messages": agency_messages, + "agency_swarm.messages.codex_input": codex_input, + "agency_swarm.utils": agency_utils, + "agency_swarm.utils.openrouter": openrouter, + "agents": agents, + "agents.extensions": agents_extensions, + "agents.extensions.models": agents_models, + "agents.extensions.models.litellm_model": agents_litellm, + "agents.models._openai_shared": agents_shared, + "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")] + + files = types.ModuleType("slides_agent.tools.slide_file_utils") + files.get_project_dir = lambda name: Path(name) + files.apply_renames = lambda _renames: None + files.build_slide_name = lambda prefix, index, pad, suffix="": ( + f"{prefix}_{index:0{pad}d}{suffix}" + ) + files.compute_pad_width = lambda _slides, extra_count=0: 2 + files.list_slide_files = lambda *_args, **_kwargs: [] + + html = types.ModuleType("slides_agent.tools.slide_html_utils") + html.ensure_full_html = lambda value: (value or "", False) + html.list_slide_filenames = lambda _project_dir: [] + html.validate_html = lambda *_args, **_kwargs: {"valid": True} + html._strip_html_to_text = lambda value: value + + templates = types.ModuleType("slides_agent.tools.template_registry") + templates.load_template_index = lambda _project_dir: {} + templates.save_template_index = lambda *_args, **_kwargs: None + templates.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": files, + "slides_agent.tools.slide_html_utils": html, + "slides_agent.tools.template_registry": templates, + } + ) + + +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(): + internal = 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 internal, modify, insert + + +def tool_for(model, *, run_model=None): + 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, + run_config=types.SimpleNamespace(model=run_model), + ) + ) + + +def nested_agents(modify, insert, tool): + writer, writer_codex = modify._make_html_writer_agent(tool=tool) + planner, planner_codex = insert._make_planner_agent(tool=tool) + return (writer, planner), (writer_codex, planner_codex) + + +class SlidesInternalModelTests(unittest.TestCase): + def setUp(self): + install_stubs() + install_package_stubs() + os.environ.pop("DEFAULT_MODEL", None) + for name in ( + "config", + "slides_agent.tools.internal_model", + "slides_agent.tools.ModifySlide", + "slides_agent.tools.InsertNewSlides", + ): + sys.modules.pop(name, None) + + def test_direct_openai_preserves_selected_model_and_client_config(self): + _internal, modify, insert = load_slides_tools() + client = FakeAsyncOpenAI( + api_key="request-key", + base_url=OPENAI_URL, + organization="request-org", + project="request-project", + timeout=42, + max_retries=4, + default_headers={"X-Request": "slides"}, + default_query={"source": "request"}, + ) + source = FakeResponsesModel(model="gpt-5.4-mini", openai_client=client) + + agents, codex = nested_agents(modify, insert, tool_for(source)) + + self.assertEqual(codex, (False, False)) + for agent in agents: + self.assertIsInstance(agent.model, FakeResponsesModel) + self.assertEqual(agent.model.model, "gpt-5.4-mini") + nested = agent.model._client + self.assertIsNot(nested, client) + self.assertEqual(nested.api_key, "request-key") + self.assertEqual(nested.base_url, OPENAI_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._custom_query, {"source": "request"}) + self.assertEqual(agent.kwargs["model_settings"].verbosity, "medium") + + def test_only_verified_codex_url_uses_responses_stream_route(self): + internal, modify, insert = load_slides_tools() + source = FakeChatModel( + model="gpt-5.4-mini", + openai_client=FakeAsyncOpenAI( + api_key="codex-key", + base_url=f"{CODEX_URL}/", + ), + ) + + agents, codex = nested_agents(modify, insert, tool_for(source)) + + self.assertEqual(codex, (True, True)) + for agent in agents: + self.assertIsInstance(agent.model, internal._CodexResponsesModel) + self.assertEqual(agent.model.model, "gpt-5.4-mini") + settings = agent.kwargs["model_settings"] + self.assertIsNone(settings.verbosity) + self.assertFalse(settings.store) + + def test_generic_openai_compatible_url_uses_chat_completions(self): + _internal, modify, insert = load_slides_tools() + source = FakeResponsesModel( + model="custom-model", + openai_client=FakeAsyncOpenAI( + api_key="compatible-key", + base_url=COMPATIBLE_URL, + ), + ) + + agents, codex = nested_agents(modify, insert, tool_for(source)) + + self.assertEqual(codex, (False, False)) + for agent in agents: + self.assertIsInstance(agent.model, FakeChatModel) + self.assertEqual(agent.model.model, "custom-model") + self.assertEqual(agent.model._client.base_url, COMPATIBLE_URL) + self.assertEqual(agent.kwargs["model_settings"].kwargs, {}) + + def test_openrouter_wrapper_and_client_are_preserved(self): + _internal, modify, insert = load_slides_tools() + source = FakeChatModel( + model="anthropic/claude-sonnet-4.6", + openai_client=FakeAsyncOpenAI( + api_key="openrouter-key", + base_url=OPENROUTER_URL, + ), + should_replay_reasoning_content="replay", + ) + source._agency_swarm_openrouter_model_name = ( + "openrouter/anthropic/claude-sonnet-4.6" + ) + + agents, codex = nested_agents(modify, insert, tool_for(source)) + + self.assertEqual(codex, (False, False)) + for agent in agents: + self.assertIsInstance(agent.model, FakeChatModel) + self.assertEqual(agent.model.model, "anthropic/claude-sonnet-4.6") + self.assertEqual( + agent.model._agency_swarm_openrouter_model_name, + "openrouter/anthropic/claude-sonnet-4.6", + ) + self.assertEqual(agent.model._client.api_key, "openrouter-key") + self.assertEqual( + agent.model.should_replay_reasoning_content, + "replay", + ) + self.assertEqual(agent.kwargs["model_settings"].kwargs, {}) + + def test_litellm_and_ollama_routes_preserve_wrapper_config(self): + _internal, modify, insert = load_slides_tools() + cases = ( + FakeLitellmModel( + model="openrouter/anthropic/claude-sonnet-4.6", + api_key="litellm-key", + base_url=OPENROUTER_URL, + should_replay_reasoning_content="litellm-replay", + ), + FakeLitellmModel( + model="ollama_chat/gemma4:e4b", + api_key="ollama", + base_url="http://localhost:11434", + ), + ) + + for source in cases: + with self.subTest(model=source.model): + agents, codex = nested_agents(modify, insert, tool_for(source)) + self.assertEqual(codex, (False, False)) + for agent in agents: + self.assertIsInstance(agent.model, FakeLitellmModel) + self.assertEqual(agent.model.model, source.model) + self.assertEqual(agent.model.api_key, source.api_key) + self.assertEqual(agent.model.base_url, source.base_url) + self.assertEqual( + getattr(agent.model, "should_replay_reasoning_content", None), + getattr(source, "should_replay_reasoning_content", None), + ) + self.assertEqual(agent.kwargs["model_settings"].kwargs, {}) + + def test_run_config_model_keeps_caller_chat_transport(self): + _internal, modify, insert = load_slides_tools() + source = FakeChatModel( + model="agent-model", + openai_client=FakeAsyncOpenAI( + api_key="gateway-key", + base_url=COMPATIBLE_URL, + ), + ) + + agents, codex = nested_agents( + modify, + insert, + tool_for(source, run_model="request-model"), + ) + + self.assertEqual(codex, (False, False)) + for agent in agents: + self.assertIsInstance(agent.model, FakeChatModel) + self.assertEqual(agent.model.model, "request-model") + self.assertEqual(agent.model._client.api_key, "gateway-key") + + def test_streamed_text_is_returned_when_final_result_is_empty(self): + internal, _modify, _insert = load_slides_tools() + + class Stream: + def __init__(self): + self.events = iter( + ( + types.SimpleNamespace( + data=types.SimpleNamespace( + type="response.output_text.delta", + delta='{"slides":', + ) + ), + types.SimpleNamespace( + data=types.SimpleNamespace( + type="response.output_text.delta", + delta="[]}", + ) + ), + ) + ) + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self.events) + except StopIteration as exc: + raise StopAsyncIteration from exc + + async def wait_final_result(self): + raise RuntimeError("structured parser returned no result") + + agent = types.SimpleNamespace( + get_response_stream=lambda _prompt: Stream(), + ) + result = asyncio.run( + internal.get_internal_agent_response( + agent, + "plan", + use_stream=True, + ) + ) + + self.assertEqual(result.final_output, '{"slides":[]}') + self.assertEqual( + insert._coerce_plan_response(result.final_output).slides, + [], + ) + + def test_codex_model_strips_unsupported_fetch_settings(self): + internal, _modify, _insert = load_slides_tools() + model = internal._CodexResponsesModel( + model="gpt-5.4-mini", + openai_client=FakeAsyncOpenAI(base_url=CODEX_URL), + ) + settings = FakeModelSettings( + reasoning=FakeReasoning(effort="high", summary="auto"), + store=False, + truncation="auto", + verbosity="low", + ) + + resolved = asyncio.run( + model._fetch_response(None, [], settings), + ) + + self.assertIsNone(resolved.truncation) + self.assertIsNone(resolved.verbosity) + self.assertFalse(resolved.store) + + +if __name__ == "__main__": + unittest.main() From 3e7cddfc49406fe50e168f70237a3415ed1ed0bb Mon Sep 17 00:00:00 2001 From: Nick Bobrowski <39348559+nicko-ai@users.noreply.github.com> Date: Fri, 24 Jul 2026 04:20:31 +0100 Subject: [PATCH 2/2] fix: preserve OpenRouter slide overrides --- slides_agent/tools/internal_model.py | 10 +++++- tests/test_slides_internal_models.py | 48 ++++++++++++++++++---------- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/slides_agent/tools/internal_model.py b/slides_agent/tools/internal_model.py index 49d8f538..aa3d9533 100644 --- a/slides_agent/tools/internal_model.py +++ b/slides_agent/tools/internal_model.py @@ -252,7 +252,11 @@ def make_internal_model(tool: Any) -> InternalModelRoute: client = _clone_openai_client(source_client) openrouter_name = get_openrouter_model_name(source) if openrouter_name or is_openrouter_model_name(model_name): - alias = model_name if is_openrouter_model_name(model_name) else openrouter_name + alias = ( + model_name + if is_openrouter_model_name(model_name) + else f"openrouter/{model_name}" + ) return InternalModelRoute( model=build_openrouter_chat_model( alias, @@ -292,6 +296,10 @@ def make_internal_model(tool: Any) -> InternalModelRoute: def make_internal_model_settings(route: InternalModelRoute) -> ModelSettings: """Use OpenAI-only settings only on verified Responses transports.""" + if route.transport == "openrouter": + return ModelSettings( + reasoning=Reasoning(effort="high", summary=None), + ) if route.transport not in {"openai_responses", "codex_responses"}: return ModelSettings() return ModelSettings( diff --git a/tests/test_slides_internal_models.py b/tests/test_slides_internal_models.py index 9cbebf15..56f01f11 100644 --- a/tests/test_slides_internal_models.py +++ b/tests/test_slides_internal_models.py @@ -381,22 +381,38 @@ def test_openrouter_wrapper_and_client_are_preserved(self): "openrouter/anthropic/claude-sonnet-4.6" ) - agents, codex = nested_agents(modify, insert, tool_for(source)) + cases = ( + (None, "anthropic/claude-sonnet-4.6"), + ("openai/gpt-5.4-mini", "openai/gpt-5.4-mini"), + ) + for run_model, expected_model in cases: + with self.subTest(run_model=run_model): + agents, codex = nested_agents( + modify, + insert, + tool_for(source, run_model=run_model), + ) - self.assertEqual(codex, (False, False)) - for agent in agents: - self.assertIsInstance(agent.model, FakeChatModel) - self.assertEqual(agent.model.model, "anthropic/claude-sonnet-4.6") - self.assertEqual( - agent.model._agency_swarm_openrouter_model_name, - "openrouter/anthropic/claude-sonnet-4.6", - ) - self.assertEqual(agent.model._client.api_key, "openrouter-key") - self.assertEqual( - agent.model.should_replay_reasoning_content, - "replay", - ) - self.assertEqual(agent.kwargs["model_settings"].kwargs, {}) + self.assertEqual(codex, (False, False)) + for agent in agents: + self.assertIsInstance(agent.model, FakeChatModel) + self.assertEqual(agent.model.model, expected_model) + self.assertEqual( + agent.model._agency_swarm_openrouter_model_name, + f"openrouter/{expected_model}", + ) + self.assertEqual(agent.model._client.api_key, "openrouter-key") + self.assertEqual( + agent.model.should_replay_reasoning_content, + "replay", + ) + settings = agent.kwargs["model_settings"] + self.assertEqual( + settings.reasoning.kwargs, + {"effort": "high", "summary": None}, + ) + self.assertIsNone(settings.verbosity) + self.assertIsNone(settings.store) def test_litellm_and_ollama_routes_preserve_wrapper_config(self): _internal, modify, insert = load_slides_tools() @@ -452,7 +468,7 @@ def test_run_config_model_keeps_caller_chat_transport(self): self.assertEqual(agent.model._client.api_key, "gateway-key") def test_streamed_text_is_returned_when_final_result_is_empty(self): - internal, _modify, _insert = load_slides_tools() + internal, _modify, insert = load_slides_tools() class Stream: def __init__(self):