Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions src/conductor/ai/agents/frameworks/serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ──────────────────────────────────────────────────────


Expand Down Expand Up @@ -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


Expand Down
60 changes: 60 additions & 0 deletions tests/unit/ai/test_framework_model_routing.py
Original file line number Diff line number Diff line change
@@ -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")