fix(moa): route Copilot slots by target model

This commit is contained in:
gexin 2026-07-07 18:03:14 +10:00 committed by Teknium
parent 4cb85fb7fc
commit 02f1dd0857
4 changed files with 86 additions and 16 deletions

View file

@ -3649,19 +3649,11 @@ def copilot_model_api_mode(
if _should_use_copilot_responses_api(normalized):
return "codex_responses"
# Secondary: check catalog for non-GPT-5 models (Claude via /v1/messages, etc.)
if catalog:
catalog_entry = next((item for item in catalog if item.get("id") == normalized), None)
if isinstance(catalog_entry, dict):
supported_endpoints = {
str(endpoint).strip()
for endpoint in (catalog_entry.get("supported_endpoints") or [])
if str(endpoint).strip()
}
# For non-GPT-5 models, check if they only support messages API
if "/v1/messages" in supported_endpoints and "/chat/completions" not in supported_endpoints:
return "anthropic_messages"
# Copilot's Claude models are exposed through its OpenAI-compatible chat
# endpoint, not through Hermes' native Anthropic adapter. The live catalog may
# advertise /v1/messages, but the Copilot token/header scheme is handled by
# the OpenAI client path; selecting anthropic_messages would send the wrong
# auth/wire shape. Keep non-GPT Copilot slots on chat_completions.
return "chat_completions"

View file

@ -317,13 +317,24 @@ def _provider_supports_explicit_api_mode(provider: Optional[str], configured_pro
return normalized_configured == normalized_provider
def _copilot_runtime_api_mode(model_cfg: Dict[str, Any], api_key: str) -> str:
def _copilot_runtime_api_mode(
model_cfg: Dict[str, Any],
api_key: str,
*,
target_model: Optional[str] = None,
) -> str:
configured_provider = str(model_cfg.get("provider") or "").strip().lower()
configured_mode = _parse_api_mode(model_cfg.get("api_mode"))
if configured_mode and _provider_supports_explicit_api_mode("copilot", configured_provider):
return configured_mode
model_name = str(model_cfg.get("default") or "").strip()
# Use the model being resolved for this runtime, not the persisted global
# default. MoA slots, fallback models, and mid-session model switches all
# resolve credentials for a target model that can differ from config.yaml's
# model.default. If we derive Copilot api_mode from the stale default, a
# Claude/Gemini MoA slot can inherit codex_responses from a GPT-5 default and
# fail with "model ... does not support Responses API".
model_name = str(target_model or model_cfg.get("default") or "").strip()
if not model_name:
return "chat_completions"
@ -449,7 +460,11 @@ def _resolve_runtime_from_pool_entry(
api_mode = "chat_completions"
base_url = _nous_inference_base_url_override() or base_url
elif provider == "copilot":
api_mode = _copilot_runtime_api_mode(model_cfg, getattr(entry, "runtime_api_key", ""))
api_mode = _copilot_runtime_api_mode(
model_cfg,
getattr(entry, "runtime_api_key", ""),
target_model=effective_model,
)
base_url = base_url or PROVIDER_REGISTRY["copilot"].inference_base_url
elif provider == "azure-foundry":
# Azure Foundry: read api_mode and base_url from config

View file

@ -0,0 +1,23 @@
"""Tests for Copilot model API-mode routing."""
from __future__ import annotations
def test_copilot_claude_stays_on_chat_completions_even_if_catalog_lists_messages():
from hermes_cli.models import copilot_model_api_mode
catalog = [
{
"id": "claude-opus-4.8",
"supported_endpoints": ["/v1/messages"],
}
]
assert copilot_model_api_mode("claude-opus-4.8", catalog=catalog) == "chat_completions"
def test_copilot_gpt5_still_uses_responses_api():
from hermes_cli.models import copilot_model_api_mode
assert copilot_model_api_mode("gpt-5.5", catalog=[]) == "codex_responses"
assert copilot_model_api_mode("gpt-5-mini", catalog=[]) == "chat_completions"

View file

@ -0,0 +1,40 @@
"""Regression tests for Copilot runtime api_mode resolution."""
from __future__ import annotations
def test_copilot_runtime_api_mode_uses_target_model_over_stale_config_default(monkeypatch):
"""MoA/fallback slots must derive Copilot api_mode from the slot model.
Repro: user's main/default Copilot model is GPT-5.5 (Responses API), but a
MoA reference slot is Copilot Claude Opus (Chat Completions). Runtime
resolution previously looked only at model.default and returned
codex_responses for the Claude slot, causing Copilot to reject it with
"model ... does not support Responses API".
"""
from hermes_cli import runtime_provider as rp
monkeypatch.setattr(
"hermes_cli.models.copilot_model_api_mode",
lambda model, api_key=None: "codex_responses" if str(model).startswith("gpt-5") else "chat_completions",
)
assert rp._copilot_runtime_api_mode(
{"provider": "copilot", "default": "gpt-5.5"},
"token",
target_model="claude-opus-4.8",
) == "chat_completions"
def test_copilot_runtime_api_mode_still_uses_default_without_target(monkeypatch):
from hermes_cli import runtime_provider as rp
monkeypatch.setattr(
"hermes_cli.models.copilot_model_api_mode",
lambda model, api_key=None: "codex_responses" if str(model).startswith("gpt-5") else "chat_completions",
)
assert rp._copilot_runtime_api_mode(
{"provider": "copilot", "default": "gpt-5.5"},
"token",
) == "codex_responses"