Skip to content
Merged
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
64 changes: 19 additions & 45 deletions src/conductor/engine/pricing.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def _warn_fuzzy_match(requested: str, matched_key: str, strategy: str) -> None:
_FUZZY_MATCH_WARNED.add(requested)
logger.warning(
"Pricing for model %r resolved via %s fallback to %r. "
"Cost and context_window metadata may be inaccurate. "
"Cost calculation may be inaccurate. "
"Add %r to DEFAULT_PRICING or pass an override to silence this warning.",
requested,
strategy,
Expand All @@ -41,202 +41,176 @@ def _warn_fuzzy_match(requested: str, matched_key: str, strategy: str) -> None:

@dataclass(frozen=True)
class ModelPricing:
"""Pricing and metadata per model.
"""Pricing per model.

Attributes:
input_per_mtok: Cost per million input tokens (USD).
output_per_mtok: Cost per million output tokens (USD).
cache_read_per_mtok: Cost per million cache read tokens (USD).
cache_write_per_mtok: Cost per million cache write tokens (USD).
context_window: Context window size in tokens, or None if unknown.
"""

input_per_mtok: float
output_per_mtok: float
cache_read_per_mtok: float = 0.0
cache_write_per_mtok: float = 0.0
context_window: int | None = None


# Default model table (pricing + context window metadata)
# Sources: OpenAI pricing page, Anthropic pricing page, provider docs
# Default model table (pricing only).
# Context-window metadata is sourced from each provider's SDK at runtime via
# ``AgentProvider.get_max_prompt_tokens()`` — see ``providers/base.py``.
# Sources: OpenAI pricing page, Anthropic pricing page, provider docs.
DEFAULT_PRICING: dict[str, ModelPricing] = {
# OpenAI / Copilot models
"gpt-4-turbo": ModelPricing(
input_per_mtok=10.00, output_per_mtok=30.00, context_window=128_000
),
"gpt-4o": ModelPricing(input_per_mtok=2.50, output_per_mtok=10.00, context_window=128_000),
"gpt-4o-mini": ModelPricing(input_per_mtok=0.15, output_per_mtok=0.60, context_window=128_000),
"gpt-4.1": ModelPricing(input_per_mtok=2.00, output_per_mtok=8.00, context_window=1_047_576),
"gpt-4.1-mini": ModelPricing(
input_per_mtok=0.15, output_per_mtok=0.60, context_window=1_047_576
),
"gpt-4": ModelPricing(input_per_mtok=30.00, output_per_mtok=60.00, context_window=8_192),
"gpt-3.5-turbo": ModelPricing(input_per_mtok=0.50, output_per_mtok=1.50, context_window=16_385),
"gpt-5.2": ModelPricing(input_per_mtok=2.00, output_per_mtok=8.00, context_window=400_000),
"gpt-5.1": ModelPricing(input_per_mtok=2.00, output_per_mtok=8.00, context_window=400_000),
"gpt-4-turbo": ModelPricing(input_per_mtok=10.00, output_per_mtok=30.00),
"gpt-4o": ModelPricing(input_per_mtok=2.50, output_per_mtok=10.00),
"gpt-4o-mini": ModelPricing(input_per_mtok=0.15, output_per_mtok=0.60),
"gpt-4.1": ModelPricing(input_per_mtok=2.00, output_per_mtok=8.00),
"gpt-4.1-mini": ModelPricing(input_per_mtok=0.15, output_per_mtok=0.60),
"gpt-4": ModelPricing(input_per_mtok=30.00, output_per_mtok=60.00),
"gpt-3.5-turbo": ModelPricing(input_per_mtok=0.50, output_per_mtok=1.50),
"gpt-5.2": ModelPricing(input_per_mtok=2.00, output_per_mtok=8.00),
"gpt-5.1": ModelPricing(input_per_mtok=2.00, output_per_mtok=8.00),
# O-series
"o1": ModelPricing(input_per_mtok=15.00, output_per_mtok=60.00, context_window=200_000),
"o1-mini": ModelPricing(input_per_mtok=3.00, output_per_mtok=12.00, context_window=128_000),
"o1-preview": ModelPricing(input_per_mtok=15.00, output_per_mtok=60.00, context_window=128_000),
"o3-mini": ModelPricing(input_per_mtok=1.10, output_per_mtok=4.40, context_window=200_000),
"o1": ModelPricing(input_per_mtok=15.00, output_per_mtok=60.00),
"o1-mini": ModelPricing(input_per_mtok=3.00, output_per_mtok=12.00),
"o1-preview": ModelPricing(input_per_mtok=15.00, output_per_mtok=60.00),
"o3-mini": ModelPricing(input_per_mtok=1.10, output_per_mtok=4.40),
# Claude 4.5 Series (newest)
"claude-opus-4-5": ModelPricing(
input_per_mtok=5.00,
output_per_mtok=25.00,
cache_read_per_mtok=0.50,
cache_write_per_mtok=6.25,
context_window=200_000,
),
"claude-sonnet-4-5": ModelPricing(
input_per_mtok=3.00,
output_per_mtok=15.00,
cache_read_per_mtok=0.30,
cache_write_per_mtok=3.75,
context_window=200_000,
),
"claude-haiku-4-5": ModelPricing(
input_per_mtok=1.00,
output_per_mtok=5.00,
cache_read_per_mtok=0.10,
cache_write_per_mtok=1.25,
context_window=200_000,
),
# Short aliases for Claude 4.5 Series (used in workflow files)
"opus-4.5": ModelPricing(
input_per_mtok=5.00,
output_per_mtok=25.00,
cache_read_per_mtok=0.50,
cache_write_per_mtok=6.25,
context_window=200_000,
),
"sonnet-4.5": ModelPricing(
input_per_mtok=3.00,
output_per_mtok=15.00,
cache_read_per_mtok=0.30,
cache_write_per_mtok=3.75,
context_window=200_000,
),
"haiku-4.5": ModelPricing(
input_per_mtok=1.00,
output_per_mtok=5.00,
cache_read_per_mtok=0.10,
cache_write_per_mtok=1.25,
context_window=200_000,
),
# Claude 4.6 Series
"claude-opus-4.6": ModelPricing(
input_per_mtok=5.00,
output_per_mtok=25.00,
cache_read_per_mtok=0.50,
cache_write_per_mtok=6.25,
context_window=1_000_000,
),
"claude-opus-4.6-1m": ModelPricing(
input_per_mtok=5.00,
output_per_mtok=25.00,
cache_read_per_mtok=0.50,
cache_write_per_mtok=6.25,
context_window=1_000_000,
),
"claude-sonnet-4.6": ModelPricing(
input_per_mtok=3.00,
output_per_mtok=15.00,
cache_read_per_mtok=0.30,
cache_write_per_mtok=3.75,
context_window=1_000_000,
),
# Claude 4 Series
"claude-opus-4": ModelPricing(
input_per_mtok=15.00,
output_per_mtok=75.00,
cache_read_per_mtok=1.50,
cache_write_per_mtok=18.75,
context_window=200_000,
),
"claude-sonnet-4": ModelPricing(
input_per_mtok=3.00,
output_per_mtok=15.00,
cache_read_per_mtok=0.30,
cache_write_per_mtok=3.75,
context_window=200_000,
),
"claude-haiku-4": ModelPricing(
input_per_mtok=0.25,
output_per_mtok=1.25,
cache_read_per_mtok=0.03,
cache_write_per_mtok=0.30,
context_window=200_000,
),
# Claude 3.x Series
"claude-3-7-sonnet": ModelPricing(
input_per_mtok=3.00,
output_per_mtok=15.00,
cache_read_per_mtok=0.30,
cache_write_per_mtok=3.75,
context_window=200_000,
),
"claude-3.7-sonnet": ModelPricing(
input_per_mtok=3.00,
output_per_mtok=15.00,
cache_read_per_mtok=0.30,
cache_write_per_mtok=3.75,
context_window=200_000,
),
"claude-3-5-sonnet": ModelPricing(
input_per_mtok=3.00,
output_per_mtok=15.00,
cache_read_per_mtok=0.30,
cache_write_per_mtok=3.75,
context_window=200_000,
),
"claude-3.5-sonnet": ModelPricing(
input_per_mtok=3.00,
output_per_mtok=15.00,
cache_read_per_mtok=0.30,
cache_write_per_mtok=3.75,
context_window=200_000,
),
"claude-3-5-haiku": ModelPricing(
input_per_mtok=0.80,
output_per_mtok=4.00,
cache_read_per_mtok=0.08,
cache_write_per_mtok=1.00,
context_window=200_000,
),
"claude-3.5-haiku": ModelPricing(
input_per_mtok=0.80,
output_per_mtok=4.00,
cache_read_per_mtok=0.08,
cache_write_per_mtok=1.00,
context_window=200_000,
),
"claude-3-opus": ModelPricing(
input_per_mtok=15.00,
output_per_mtok=75.00,
cache_read_per_mtok=1.50,
cache_write_per_mtok=18.75,
context_window=200_000,
),
"claude-3-sonnet": ModelPricing(
input_per_mtok=3.00,
output_per_mtok=15.00,
cache_read_per_mtok=0.30,
cache_write_per_mtok=3.75,
context_window=200_000,
),
"claude-3-haiku": ModelPricing(
input_per_mtok=0.25,
output_per_mtok=1.25,
cache_read_per_mtok=0.03,
cache_write_per_mtok=0.30,
context_window=200_000,
),
# Gemini
"gemini-3.1-pro-preview": ModelPricing(
input_per_mtok=1.25,
output_per_mtok=5.00,
context_window=1_000_000,
),
}

Expand Down
78 changes: 66 additions & 12 deletions src/conductor/engine/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -766,16 +766,64 @@ async def _execute_subworkflow_with_inputs(
usage = child_engine.usage_tracker.get_summary()
return output, usage

def _get_context_window_for_agent(self, agent: AgentDef) -> int | None:
"""Return the context window size for an agent's model."""
from conductor.engine.pricing import get_pricing

model = agent.model
if not model:
async def _get_provider_for_agent(self, agent: AgentDef) -> AgentProvider | None:
"""Resolve the provider that will (or did) execute ``agent``.

Mirrors the executor-resolution logic in ``_get_executor_for_agent``
so context-window metadata lookups go through the same provider that
handles execution. Returns ``None`` only when no provider can be
determined (e.g. transient registry failures); callers must treat
``None`` as "metadata unavailable".
"""
if self._registry is not None:
try:
return await self._registry.get_provider(agent)
except Exception as e:
logger.debug("Provider lookup via registry failed for %s: %s", agent.name, e)
return None
return self._single_provider

async def _get_context_window_for_agent(
self, agent: AgentDef, output: AgentOutput | None = None
) -> int | None:
"""Return the SDK-reported max prompt tokens for an agent.

Tries each candidate model in priority order — the model the SDK
actually used (``output.model``), the agent's configured model, the
workflow's runtime default — and returns the first non-``None``
result. This is a real fallback chain: if ``output.model`` is an
SDK-specific variant the provider doesn't know about, the lookup
retries with ``agent.model`` before giving up.

Returns ``None`` when no candidate resolves, no provider can be
reached, or the provider's metadata call fails — context-window
metadata is best-effort and must never break workflow execution.
"""
provider = await self._get_provider_for_agent(agent)
if provider is None:
return None

pricing = get_pricing(model)
return pricing.context_window if pricing else None
candidates: list[str] = []
if output is not None and output.model:
candidates.append(output.model)
if agent.model and agent.model not in candidates:
candidates.append(agent.model)
default = self.config.workflow.runtime.default_model
if default and default not in candidates:
candidates.append(default)
for model in candidates:
try:
value = await provider.get_max_prompt_tokens(model)
except Exception as e:
logger.debug(
"get_max_prompt_tokens(%r) raised on provider for agent %s: %s",
model,
agent.name,
e,
)
continue
if value is not None:
return value
return None

async def run(self, inputs: dict[str, Any]) -> dict[str, Any]:
"""Execute the workflow from entry_point to $end.
Expand Down Expand Up @@ -1520,7 +1568,9 @@ async def _execute_loop(self, current_agent_name: str) -> dict[str, Any]:
"agent_name": agent.name,
"iteration": agent_execution_count,
"agent_type": agent.type or "agent",
"context_window_max": self._get_context_window_for_agent(agent),
"context_window_max": await self._get_context_window_for_agent(
agent
),
},
)

Expand Down Expand Up @@ -1844,7 +1894,9 @@ async def _execute_loop(self, current_agent_name: str) -> dict[str, Any]:
"output": output.content,
"output_keys": output_keys,
"context_window_used": output.input_tokens,
"context_window_max": self._get_context_window_for_agent(agent),
"context_window_max": await self._get_context_window_for_agent(
agent, output
),
},
)

Expand Down Expand Up @@ -2472,7 +2524,9 @@ async def execute_single_agent(agent: AgentDef) -> tuple[str, Any]:
"tokens": output.tokens_used,
"cost_usd": usage.cost_usd,
"context_window_used": output.input_tokens,
"context_window_max": self._get_context_window_for_agent(agent),
"context_window_max": await self._get_context_window_for_agent(
agent, output
),
},
)

Expand Down
Loading
Loading