From d8d698af41592df25fc7400724e36c67037466c8 Mon Sep 17 00:00:00 2001 From: nandanadileep <116407139+nandanadileep@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:56:50 +0000 Subject: [PATCH] fix(ai): route framework agents by configured --- .../ai/agents/frameworks/serializer.py | 61 +++++++++++++++++++ tests/unit/ai/test_framework_model_routing.py | 60 ++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 tests/unit/ai/test_framework_model_routing.py diff --git a/src/conductor/ai/agents/frameworks/serializer.py b/src/conductor/ai/agents/frameworks/serializer.py index f455ce96..e2efa81a 100644 --- a/src/conductor/ai/agents/frameworks/serializer.py +++ b/src/conductor/ai/agents/frameworks/serializer.py @@ -11,6 +11,7 @@ import enum import inspect import logging +import os from dataclasses import dataclass, is_dataclass from dataclasses import fields as dc_fields from typing import Any, Callable, Dict, List, Optional, Set, Tuple @@ -72,6 +73,61 @@ def detect_framework(agent_obj: Any) -> Optional[str]: return None +# ── Model resolution ───────────────────────────────────────────────── + +#: Environment variable naming the default ``"provider/model"`` used when a +#: foreign framework agent does not declare a model of its own. +_FRAMEWORK_MODEL_ENV = "CONDUCTOR_AGENT_LLM_MODEL" + + +def normalize_model_string(model: Any) -> str: + """Add a provider prefix when a bare model name lacks one. + + ``"gpt-4o"`` → ``"openai/gpt-4o"`` + ``"claude-opus-4-6"`` → ``"anthropic/claude-opus-4-6"`` + ``"anthropic/x"`` → ``"anthropic/x"`` (already qualified) + ``None``/``""`` → ``""`` + """ + if not model: + return "" + model = str(model) + if "/" in model: + return model + if model.startswith(("gpt", "o1", "o3", "o4")): + return f"openai/{model}" + if model.startswith("claude"): + return f"anthropic/{model}" + if model.startswith("gemini"): + return f"google/{model}" + return f"openai/{model}" + + +def resolve_framework_model(config: Dict[str, Any]) -> Dict[str, Any]: + """Ensure a serialized framework config carries a routable model. + + Foreign framework agents (e.g. openai-agents) frequently leave their + ``model`` unset, which makes the server fall back to its OpenAI default + regardless of the operator's configured model. When the serialized + config has no model, populate it from ``CONDUCTOR_AGENT_LLM_MODEL`` so + the server routes the request to the intended provider. + + A model explicitly declared on the agent is always preserved. + + Returns the same dict, mutated in place. + """ + if not isinstance(config, dict): + return config + + existing = config.get("model") + if existing: + return config + + configured = os.environ.get(_FRAMEWORK_MODEL_ENV) + if configured: + config["model"] = normalize_model_string(configured) + return config + + # ── Worker info ────────────────────────────────────────────────────── @@ -240,6 +296,11 @@ def _serialize(obj: Any) -> Any: seen.discard(obj_id) config = _serialize(agent_obj) + if framework == "openai": + # openai-agents leaves ``model`` unset unless the author sets it; the + # server would then default to OpenAI even when the operator configured + # a different provider. Resolve it from CONDUCTOR_AGENT_LLM_MODEL. + resolve_framework_model(config) return config, workers diff --git a/tests/unit/ai/test_framework_model_routing.py b/tests/unit/ai/test_framework_model_routing.py new file mode 100644 index 00000000..0b6a8e8f --- /dev/null +++ b/tests/unit/ai/test_framework_model_routing.py @@ -0,0 +1,60 @@ +"""Regression tests for issue #482 — framework agents must route by the +configured model. + +The openai-agents ``Agent`` leaves ``model`` unset unless the author sets it. +The framework serialization path previously forwarded that ``None`` to the +server, which then fell back to OpenAI regardless of +``CONDUCTOR_AGENT_LLM_MODEL`` — so an Anthropic-configured run hit OpenAI with +an empty key and silently failed. + +These tests exercise the public ``serialize_agent`` contract (which exists on +the original code) so they fail with an assertion before the fix. +""" + +import pytest + +pytest.importorskip("agents") + +from agents import Agent # noqa: E402 + +from conductor.ai.agents.frameworks.serializer import serialize_agent # noqa: E402 + + +def test_unset_agent_model_resolves_from_env(monkeypatch): + """An openai Agent with no model inherits CONDUCTOR_AGENT_LLM_MODEL.""" + monkeypatch.setenv("CONDUCTOR_AGENT_LLM_MODEL", "anthropic/claude-sonnet-4-6") + + agent = Agent(name="Assistant", instructions="You only respond in haikus.") + config, _ = serialize_agent(agent) + + assert config["model"] == "anthropic/claude-sonnet-4-6" + + +def test_unset_agent_model_resolves_bare_env_model(monkeypatch): + """A bare provider-less env model is qualified with its provider.""" + monkeypatch.setenv("CONDUCTOR_AGENT_LLM_MODEL", "claude-opus-4-6") + + agent = Agent(name="Assistant", instructions="haiku") + config, _ = serialize_agent(agent) + + assert config["model"] == "anthropic/claude-opus-4-6" + + +def test_explicit_agent_model_is_preserved(monkeypatch): + """An explicitly declared model wins over the configured default.""" + monkeypatch.setenv("CONDUCTOR_AGENT_LLM_MODEL", "anthropic/claude-sonnet-4-6") + + agent = Agent(name="Assistant", instructions="haiku", model="gpt-4o") + config, _ = serialize_agent(agent) + + assert config["model"] == "gpt-4o" + + +def test_no_env_and_no_model_stays_unset(monkeypatch): + """Without a configured default the model is left untouched.""" + monkeypatch.delenv("CONDUCTOR_AGENT_LLM_MODEL", raising=False) + + agent = Agent(name="Assistant", instructions="haiku") + config, _ = serialize_agent(agent) + + assert not config.get("model")