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
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **`conductor doctor --models` surfaces per-model reasoning-effort support and
context-window limits** — a new optional `AgentProvider.get_model_capabilities`
hook (alongside the existing `get_max_prompt_tokens` / `get_model_pricing`
hooks) reports, per model: which `reasoning.effort` levels it accepts, its
default effort, and its prompt/output/context-window token limits. `--models`
now renders a separate per-provider **Models** detail table with this data
(the Providers table's Models column shows a count); the JSON `models` field
is now a list of capability objects rather than plain id strings. The Copilot
provider implements the hook fully via `client.list_models()`; the Claude
provider derives reasoning-effort support from the existing thinking-model
heuristic and reports prompt tokens only (the Anthropic SDK exposes no
output/total-context split); other providers (`claude-agent-sdk`, `hermes`,
`openai-agents`) don't implement model enumeration at all, so they show
`n/a` in the Providers table and get no Models detail table. See the
"Per-model capabilities" section in
[`docs/cli-reference.md`](docs/cli-reference.md#per-model-capabilities---models).
([#301](https://github.com/microsoft/conductor/issues/301))

- **`max` reasoning-effort level** — the unified reasoning scale is now
`low | medium | high | xhigh | max`, unifying it with the GitHub Copilot CLI.
On the Copilot provider `max` is forwarded to the SDK and still validated
Expand All @@ -25,6 +43,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **Copilot per-model `reasoning_effort` validation was a silent no-op** —
`_validate_reasoning_effort_for_model` read
`capabilities.supported_reasoning_efforts`, but the installed
`github-copilot-sdk` (>=1.0.0) exposes that field (and
`default_reasoning_effort`) at the top level of the `Model` object, not
nested under `capabilities`. The lookup always returned `None`, so the
per-model check (including the `max`-rejection behavior from #299) never
fired against the real SDK — a model without `max` support would only be
caught by the backend, not by Conductor's own validation. Fixed to read the
correct field; discovered and corrected while implementing #301, which
needed the same field for `doctor --models`.

- **Install-script tests no longer pollute the developer's shell profile** — the
install scripts ran `uv tool update-shell` unconditionally, so the
`-m install_scripts` integration tests appended each run's throwaway
Expand Down
45 changes: 44 additions & 1 deletion docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -395,10 +395,53 @@ in the `env` section (cache-first, short timeout, silent, and skipped when
installed, the capability tier (`stable` / `experimental`), which
credential environment variables are **present** (presence only — values
are never printed), and — with `--check` / `--models` — connection status
and available models. `openai-agents` is surfaced as "not yet implemented".
and a model count. `openai-agents` is surfaced as "not yet implemented".
- **registries** — configured workflow registries and which is the default
(see [`conductor registry`](#conductor-registry)).

### Per-model capabilities (`--models`)

Beyond a model count in the Providers table, `--models` renders a separate
**Models** detail table per provider with each model's reasoning-effort
support and context-window limits:

| Column | Description |
|--------|--------------|
| Model | The model identifier. |
| Reasoning efforts | `reasoning.effort` levels the model accepts (e.g. `low, medium, high, xhigh`), `none` when the model definitively supports none (e.g. a non-thinking Claude model), or `n/a` when the provider can't determine support. |
| Default | The model's default reasoning-effort level, or `—` when unknown/not applicable. |
| Prompt / Output / Context | Maximum prompt (input), output (completion), and total context-window tokens, or `—` when the provider doesn't expose that limit. |

Coverage varies by provider — every field degrades independently to `n/a` /
`—` rather than failing the command:

- **Copilot** reports reasoning-effort levels + default, and prompt/context
token limits, from the SDK's per-model metadata (`Output` is frequently
`—` — the live API does not currently populate it for most models).
- **Claude** derives reasoning-effort support from a static heuristic
(Claude 3.7+ / 4.x models support all five levels; older models support
none) and reports only `Prompt` (via the Anthropic API's
`max_input_tokens`) — `Output` and `Context` are always `—` and `Default`
is always `—` (Anthropic has no per-model default-effort concept).
- **`claude-agent-sdk`**, **`hermes`**, and **`openai-agents`** don't
implement model enumeration (`list_models`) at all, so `--models` shows
`n/a` for them in the Providers table and they get **no** Models detail
table — there is nothing to detail.

In `--json`, each provider's `models` field is a list of objects (not plain
id strings):

```json
{
"id": "gpt-5.5",
"supported_reasoning_efforts": ["low", "medium", "high", "xhigh"],
"default_reasoning_effort": "medium",
"max_prompt_tokens": 128000,
"max_output_tokens": 64000,
"max_context_window_tokens": 192000
}
```

### Credential detection

Only the **presence** of credential environment variables is reported —
Expand Down
77 changes: 70 additions & 7 deletions src/conductor/cli/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
ALL_SECTIONS,
DoctorReport,
EnvDiagnostic,
ModelDiagnostic,
ProviderDiagnostic,
RegistryDiagnostic,
gather,
Expand Down Expand Up @@ -94,6 +95,8 @@ def run_doctor(
_render_env(report.env, console)
if report.providers is not None:
_render_providers(report.providers, console, check=check, models=models)
if models:
_render_models(report.providers, console)
if report.registries is not None:
_render_registries(report.registries, console)

Expand Down Expand Up @@ -248,20 +251,80 @@ def _connection_cell(diag: ProviderDiagnostic) -> str:


def _models_cell(diag: ProviderDiagnostic) -> str:
"""Format the models cell.
"""Format the models cell in the Providers summary table.

Lists every model id (comma-separated); Rich wraps the cell to the column
width. The leading count makes long lists scannable. ``n/a`` when models
is None (not enumerated), ``(none)`` for an empty list.
Shows a count/status only — per-model reasoning-effort and
context-window details are rendered in the separate Models detail table
(see :func:`_render_models`) below the Providers table. ``n/a`` when
models is ``None`` (not enumerated), ``(none)`` for an empty list.
"""
if diag.models_error:
return f"{_CROSS} [dim]{escape(diag.models_error)}[/dim]"
if diag.models is None:
return "[dim]n/a[/dim]"
if not diag.models:
count = len(diag.models)
if not count:
return "[dim](none)[/dim]"
shown = ", ".join(escape(model) for model in diag.models)
return f"[dim]{len(diag.models)}:[/dim] {shown}"
return f"{_CHECK} {count} model{'s' if count != 1 else ''}"


def _format_tokens(value: int | None) -> str:
"""Format a token-limit value with grouped digits, or ``—`` when unknown."""
if value is None:
return _DASH
return f"{value:,}"


def _efforts_cell(model: ModelDiagnostic) -> str:
"""Format the supported-reasoning-efforts cell.

``n/a`` when unknown (``None``), ``none`` for a definitive empty list
(e.g. a non-thinking Claude model), otherwise a comma-separated list.
"""
if model.supported_reasoning_efforts is None:
return "[dim]n/a[/dim]"
if not model.supported_reasoning_efforts:
return "[dim]none[/dim]"
return ", ".join(escape(effort) for effort in model.supported_reasoning_efforts)


def _default_effort_cell(model: ModelDiagnostic) -> str:
"""Format the default-reasoning-effort cell."""
if model.default_reasoning_effort is None:
return _DASH
return escape(model.default_reasoning_effort)


def _render_models(providers: list[ProviderDiagnostic], console: Console) -> None:
"""Render a per-provider Models detail table (``--models`` only).

One table per provider that returned at least one model, with columns
for reasoning-effort support and prompt/output/context token limits.
Providers with no models (``None``/empty/error) are already summarized
in the Providers table and are skipped here — there is nothing to detail.
"""
for diag in providers:
if not diag.models:
continue
table = Table(title=f"Models — {diag.name}", show_lines=True)
table.add_column("Model", style="cyan", no_wrap=True)
table.add_column("Reasoning efforts")
table.add_column("Default")
table.add_column("Prompt", justify="right")
table.add_column("Output", justify="right")
table.add_column("Context", justify="right")

for model in diag.models:
table.add_row(
escape(model.id),
_efforts_cell(model),
_default_effort_cell(model),
_format_tokens(model.max_prompt_tokens),
_format_tokens(model.max_output_tokens),
_format_tokens(model.max_context_window_tokens),
)

console.print(table)


def _render_registries(registries: RegistryDiagnostic, console: Console) -> None:
Expand Down
79 changes: 79 additions & 0 deletions src/conductor/providers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,52 @@ class AgentOutput:
"""Whether this output is partial (from a mid-agent interrupt)."""


@dataclass(frozen=True)
class ModelCapabilityInfo:
"""Provider-reported reasoning-effort and context-window metadata for a model.

Returned by the optional :meth:`AgentProvider.get_model_capabilities` hook
(see issue #301) and surfaced by ``conductor doctor --models``. Every field
is best-effort and independently optional — a provider that can report
token limits but not reasoning-effort support (or vice versa) should
leave the unknown fields at their ``None`` default rather than omit the
whole object.
"""

supported_reasoning_efforts: list[str] | None = None
"""Reasoning-effort levels the model accepts, or ``None`` when unknown.

An empty list is a meaningful, distinct value: it means the provider
positively knows the model supports *no* reasoning-effort levels
(e.g. a non-thinking Claude model), whereas ``None`` means the provider
could not determine support either way.
"""

default_reasoning_effort: str | None = None
"""The model's default reasoning-effort level, or ``None`` when unknown
or not applicable."""

max_prompt_tokens: int | None = None
"""Maximum prompt (input) tokens, or ``None`` when unknown."""

max_output_tokens: int | None = None
"""Maximum output (completion) tokens, or ``None`` when unknown."""

max_context_window_tokens: int | None = None
"""Maximum total context window (prompt + output) tokens, or ``None``
when unknown."""

def to_dict(self) -> dict[str, Any]:
"""Return a JSON-safe representation."""
return {
"supported_reasoning_efforts": self.supported_reasoning_efforts,
"default_reasoning_effort": self.default_reasoning_effort,
"max_prompt_tokens": self.max_prompt_tokens,
"max_output_tokens": self.max_output_tokens,
"max_context_window_tokens": self.max_context_window_tokens,
}


class AgentProvider(ABC):
"""Abstract base class for SDK providers.

Expand Down Expand Up @@ -361,3 +407,36 @@ async def list_models(self) -> list[str] | None:
provider does not enumerate models.
"""
return None

async def get_model_capabilities(self, model: str) -> ModelCapabilityInfo | None:
"""Return provider-supplied reasoning-effort and context-window metadata.

This is the provider hook behind ``conductor doctor --models`` (see
issue #301), alongside :meth:`get_max_prompt_tokens` and
:meth:`get_model_pricing`. A provider that knows which
``reasoning.effort`` levels a model accepts (and its default), plus
its prompt/output/context token limits, should return a
:class:`ModelCapabilityInfo` populating whichever fields it can
determine — fields the provider can't determine should stay at their
``None`` default rather than causing the whole call to fail.

Implementations must:

* Return ``None`` when the model is unknown to the provider, the SDK
exposes no usable capability metadata, or the SDK call fails.
* Never raise — capability metadata is best-effort and must not
interrupt workflow execution or the ``doctor`` command.

The default implementation returns ``None``, which causes ``doctor``
to render every capability column as "n/a" for this provider — a
safe degradation matching the sibling hooks above.

Args:
model: The model identifier as it would be sent to the SDK
(e.g. ``"gpt-5.2"``, ``"claude-sonnet-4-5-20250929"``).

Returns:
A :class:`ModelCapabilityInfo` when the provider can supply
capability metadata for ``model``, otherwise ``None``.
"""
return None
62 changes: 60 additions & 2 deletions src/conductor/providers/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
import logging
import random
import time
from typing import TYPE_CHECKING, Any, Protocol
from typing import TYPE_CHECKING, Any, Protocol, get_args

from pydantic import BaseModel

Expand All @@ -34,7 +34,13 @@
extract_tool_result_text,
format_tool_arguments,
)
from conductor.providers.base import AgentOutput, AgentProvider, EventCallback, match_model_id
from conductor.providers.base import (
AgentOutput,
AgentProvider,
EventCallback,
ModelCapabilityInfo,
match_model_id,
)
from conductor.providers.capabilities import ProviderCapabilities
from conductor.providers.reasoning import (
CLAUDE_ANSWER_HEADROOM_TOKENS,
Expand Down Expand Up @@ -502,6 +508,58 @@ async def list_models(self) -> list[str] | None:
return None
return [model.id for model in page.data]

async def get_model_capabilities(self, model: str) -> ModelCapabilityInfo | None:
"""Return reasoning-effort support and prompt-token limits for ``model``.

Implements the :meth:`AgentProvider.get_model_capabilities` hook (see
#301).

Reasoning-effort support is derived from the same static heuristic
used to gate extended thinking (:func:`is_claude_thinking_model`):
thinking-capable models (Claude 3.7+ / 4.x) advertise all five
:data:`ReasoningEffort` levels; other models advertise an empty list
— a definitive "supports none", not "unknown". Anthropic has no
notion of a model-specific *default* effort (unlike the Copilot SDK),
so ``default_reasoning_effort`` is always ``None``.

``max_prompt_tokens`` reuses :meth:`get_max_prompt_tokens` (the
Anthropic SDK's ``max_input_tokens``). ``max_output_tokens`` and
``max_context_window_tokens`` are always ``None`` — the Anthropic
SDK's ``models.list()`` exposes no output/total-context split.

Unlike :meth:`get_max_prompt_tokens` (which only catches its
documented ``(TimeoutError, AnthropicError, OSError)`` tuple and lets
anything else propagate, by design, for its own caller), this hook
upholds the base class's stricter "never raise" contract on its own:
each field is resolved behind its own guard, so a failure in one
(e.g. an unexpected exception from the delegated
``get_max_prompt_tokens`` call, or a non-string ``model``) degrades
only that field rather than the whole result or the caller. The
reasoning-effort fields are populated even when the SDK is
unavailable, ``model`` can't be resolved, or the token-limit lookup
fails (the heuristic is a pure name match independent of the SDK
call), so this never returns ``None`` outright.
"""
try:
supported_reasoning_efforts = (
list(get_args(ReasoningEffort)) if is_claude_thinking_model(model) else []
)
except Exception as e: # noqa: BLE001 - diagnostics must never raise
logger.debug("Failed to resolve reasoning-effort support for %r: %s", model, e)
supported_reasoning_efforts = None
try:
max_prompt_tokens = await self.get_max_prompt_tokens(model)
except Exception as e: # noqa: BLE001 - diagnostics must never raise
logger.debug("Failed to resolve max_prompt_tokens for %r: %s", model, e)
max_prompt_tokens = None
return ModelCapabilityInfo(
supported_reasoning_efforts=supported_reasoning_efforts,
default_reasoning_effort=None,
max_prompt_tokens=max_prompt_tokens,
max_output_tokens=None,
max_context_window_tokens=None,
)

async def _ensure_mcp_connected(self) -> None:
"""Connect to MCP servers if configured.

Expand Down
Loading
Loading