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
1 change: 1 addition & 0 deletions agent/chat_completion_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1400,6 +1400,7 @@ def _build_chat_completions_kwargs(agent, api_messages, tools_for_api, reasoning
cache_scope_id=cache_scope_id, ollama_num_ctx=agent._ollama_num_ctx,
provider_preferences=_prefs or None, openrouter_min_coding_score=agent.openrouter_min_coding_score,
supports_reasoning=agent._supports_reasoning_extra_body(),
api_key=getattr(agent, "api_key", None),
qwen_session_metadata=_qwen_meta)
if _profile:
# Profiles handle per-provider quirks via hooks fed the context above.
Expand Down
8 changes: 4 additions & 4 deletions agent/reasoning_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@ def _supports_reasoning_extra_body(self) -> bool:
return True
if base_url_host_matches(url, "models.github.ai") or base_url_host_matches(url, "githubcopilot.com"):
try:
from hermes_cli.models import github_model_reasoning_efforts
from hermes_cli.models import get_copilot_reasoning_efforts

return bool(github_model_reasoning_efforts(self.model))
return bool(get_copilot_reasoning_efforts(self.model, self.api_key))
except Exception:
return False
if (self.provider or "").strip().lower() == "lmstudio":
Expand Down Expand Up @@ -100,11 +100,11 @@ def _ollama_supports_thinking_cached(self) -> bool:
def _github_models_reasoning_extra_body(self) -> dict | None:
"""Format reasoning payload for GitHub Models/OpenAI-compatible routes."""
try:
from hermes_cli.models import github_model_reasoning_efforts
from hermes_cli.models import get_copilot_reasoning_efforts
except Exception:
return None

supported = github_model_reasoning_efforts(self.model)
supported = get_copilot_reasoning_efforts(self.model, self.api_key)
if not supported:
return None

Expand Down
3 changes: 2 additions & 1 deletion agent/transports/chat_completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,8 @@ def _build_kwargs_from_profile(self, profile, model, sanitized, tools, params):
extra_body_from_profile, top_level_from_profile = profile.build_api_kwargs_extras(
reasoning_config=reasoning_config, supports_reasoning=params.get("supports_reasoning", False),
qwen_session_metadata=params.get("qwen_session_metadata"), model=model,
base_url=params.get("base_url"), ollama_num_ctx=params.get("ollama_num_ctx"),
base_url=params.get("base_url"), api_key=params.get("api_key"),
ollama_num_ctx=params.get("ollama_num_ctx"),
session_id=params.get("session_id"),
)
api_kwargs.update(top_level_from_profile)
Expand Down
136 changes: 128 additions & 8 deletions hermes_cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import contextvars
import copy
import hashlib
import json
import logging
import os
Expand Down Expand Up @@ -1971,26 +1972,54 @@ def fetch_github_model_catalog(
# Module-level cache: {model_id: max_prompt_tokens}
_copilot_context_cache: dict[str, int] = {}
_copilot_context_cache_time: float = 0.0
# Negative cache: timestamp of the last *failed* catalog fetch. The positive
# cache is a dict that starts empty (falsy), so a failed fetch would otherwise
# re-attempt a slow HTTP call on every lookup. After a failure we back off for a
# short window before retrying; a successful fetch clears it.
_copilot_context_failed_time: float = 0.0
_copilot_context_cache_key: Optional[str] = None # fingerprint of the api_key the entry was fetched with
_COPILOT_CONTEXT_CACHE_TTL = 3600 # 1 hour
_COPILOT_CONTEXT_CACHE_TTL = 3600 # 1 hour (successful fetch)
_COPILOT_CONTEXT_NEGATIVE_TTL = 60 # 60s backoff after a failed fetch


def get_copilot_model_context(model_id: str, api_key: Optional[str] = None) -> Optional[int]:
"""``max_prompt_tokens`` for a Copilot model from the live /models API (cached in-process 1h; a
miss on a fresh cache does not re-fetch), or None."""
global _copilot_context_cache, _copilot_context_cache_time, _copilot_context_cache_key
"""Look up max_prompt_tokens for a Copilot model from the live /models API.

Results are cached in-process for 1 hour to avoid repeated API calls.
Returns the token limit or None if not found.
"""
global _copilot_context_cache, _copilot_context_cache_time, _copilot_context_failed_time
global _copilot_context_cache_key

# Keyed on the credential like fetch_github_model_catalog: the catalog (and its limits) is
# per-account, so another profile's token must not be served this entry.
from agent.credential_persistence import fingerprint_secret_value
key_fp = fingerprint_secret_value(api_key)
if (_copilot_context_cache and _copilot_context_cache_key == key_fp
and (time.time() - _copilot_context_cache_time < _COPILOT_CONTEXT_CACHE_TTL)):
return _copilot_context_cache.get(model_id)

now = time.time()
# Serve from cache if fresh and fetched with the same credential
if (
_copilot_context_cache
and _copilot_context_cache_key == key_fp
and (now - _copilot_context_cache_time < _COPILOT_CONTEXT_CACHE_TTL)
):
if model_id in _copilot_context_cache:
return _copilot_context_cache[model_id]
# Cache is fresh but model not in it β€” don't re-fetch
return None

# Negative cache: after a failed fetch, back off briefly so callers don't
# tight-loop on slow HTTP attempts while offline / auth is failing. Serve any
# stale value we still hold rather than forcing a default.
if now - _copilot_context_failed_time < _COPILOT_CONTEXT_NEGATIVE_TTL:
return _copilot_context_cache.get(model_id) if _copilot_context_cache else None

# Fetch and populate cache
catalog = fetch_github_model_catalog(api_key=api_key)
if not catalog:
return None
_copilot_context_failed_time = now # start backoff window
return _copilot_context_cache.get(model_id) if _copilot_context_cache else None

cache: dict[str, int] = {}
for item in catalog:
mid = str(item.get("id") or "").strip()
Expand Down Expand Up @@ -2212,6 +2241,97 @@ def github_model_reasoning_efforts(
return _github_reasoning_efforts_for_model_id(str(model_id or normalized))


# Module-level cache for the Copilot catalog used by reasoning-effort lookups.
# Mirrors the get_copilot_model_context cache: the live /models catalog is the
# only source that reports reasoning_effort for Copilot-hosted Claude models, so
# it must be supplied to github_model_reasoning_efforts. Caching it in-process
# for 1 hour avoids an HTTP round-trip on every turn (the reasoning gates below
# are hit several times per turn).
#
# Keyed per-credential: fetch_github_model_catalog(api_key=...) returns an
# ACCOUNT-SPECIFIC catalog, so a single unkeyed cache would leak one Copilot
# account's reasoning-capability list to every other credential in this
# process for the full TTL. The key is a short digest of the api_key, never
# the secret itself.
_copilot_reasoning_catalog_cache: dict[str, list[dict[str, Any]]] = {}
_copilot_reasoning_catalog_cache_time: dict[str, float] = {}
# Negative cache: timestamp of the last *failed* catalog fetch, per credential.
# Without it, a fetch that returns None (offline / auth failure) leaves the
# positive cache empty, so every call would re-attempt a slow HTTP fetch β€” and
# the reasoning gates call this several times per turn, turning one outage
# into a refetch storm. After a failure we back off for a short window before
# retrying; a successful fetch clears it.
_copilot_reasoning_catalog_failed_time: dict[str, float] = {}
_COPILOT_REASONING_CATALOG_CACHE_TTL = 3600 # 1 hour (successful fetch)
_COPILOT_REASONING_CATALOG_NEGATIVE_TTL = 60 # 60s backoff after a failed fetch


def _copilot_credential_cache_key(api_key: Optional[str]) -> str:
"""Derive a non-secret cache key from a Copilot api_key.

Never use the raw api_key as a dict key held in process memory longer than
necessary for lookups β€” a short salted digest is enough to distinguish
credentials without retaining the secret in a form that's easy to leak via
a debugger/heap dump/repr(). Missing api_key gets its own stable bucket.
"""
if not api_key:
return "__no_api_key__"
digest = hashlib.sha256(f"hermes-copilot-reasoning-cache:{api_key}".encode("utf-8")).hexdigest()
return digest[:16]


def get_copilot_reasoning_efforts(
model_id: Optional[str], api_key: Optional[str] = None
) -> list[str]:
"""Return reasoning-effort levels for a Copilot model, catalog-backed.

``github_model_reasoning_efforts(model_id)`` with no catalog falls through to
the static GPT/o-series table and returns ``[]`` for Claude, even though the
live Copilot ``/models`` catalog advertises ``reasoning_effort`` support for
opus/sonnet. This wrapper supplies that catalog from a 1-hour in-process
cache so Claude (and any future catalog-only model) resolves correctly,
without an HTTP fetch on every call.

The cache is scoped per-credential (see ``_copilot_credential_cache_key``)
so multiple Copilot accounts in the same process don't share reasoning
capability lists.

Falls back to the bare resolver (static table) when no catalog is available,
so behaviour degrades gracefully offline instead of raising.
"""
global _copilot_reasoning_catalog_cache, _copilot_reasoning_catalog_cache_time
global _copilot_reasoning_catalog_failed_time

key = _copilot_credential_cache_key(api_key)
now = time.time()
catalog = _copilot_reasoning_catalog_cache.get(key)
fresh = catalog is not None and (
now - _copilot_reasoning_catalog_cache_time.get(key, 0.0)
< _COPILOT_REASONING_CATALOG_CACHE_TTL
)
# Negative cache: after a failed fetch, hold off on re-fetching for a short
# window so the per-turn reasoning gates don't tight-loop on slow HTTP
# attempts while offline / auth is failing. A successful fetch clears it.
backing_off = (
now - _copilot_reasoning_catalog_failed_time.get(key, 0.0)
< _COPILOT_REASONING_CATALOG_NEGATIVE_TTL
)
if not fresh and not backing_off:
fetched = fetch_github_model_catalog(api_key=api_key)
if fetched:
catalog = fetched
_copilot_reasoning_catalog_cache[key] = fetched
_copilot_reasoning_catalog_cache_time[key] = now
_copilot_reasoning_catalog_failed_time.pop(key, None) # clear backoff
else:
catalog = _copilot_reasoning_catalog_cache.get(key) # keep stale catalog
_copilot_reasoning_catalog_failed_time[key] = now # start backoff window

# Pass catalog explicitly: github_model_reasoning_efforts re-fetches when
# api_key is set but catalog is None, which would defeat this cache.
return github_model_reasoning_efforts(model_id, catalog=catalog)


# Negative cache: monotonic timestamp of the last fully-failed probe, keyed
# by ``host:port`` so both URL candidates (``/v1`` + root) share one entry.
# Without this, an unreachable endpoint (TCP blackhole β€” SYN draws no reply,
Expand Down
80 changes: 56 additions & 24 deletions plugins/model-providers/copilot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,31 +15,63 @@ class CopilotProfile(ProviderProfile):
"""GitHub Copilot / GitHub Models β€” editor headers + reasoning."""

def build_api_kwargs_extras(
self, *, model: str | None = None, reasoning_config: dict | None = None,
supports_reasoning: bool = False, **ctx,
self,
*,
model: str | None = None,
reasoning_config: dict | None = None,
supports_reasoning: bool = False,
api_key: str | None = None,
**ctx,
) -> tuple[dict[str, Any], dict[str, Any]]:
if not (supports_reasoning and model):
return {}, {}
try:
from hermes_cli.models import clamp_reasoning_effort_to_supported, github_model_reasoning_efforts

supported = github_model_reasoning_efforts(model)
if not supported:
return {}, {}
if not reasoning_config:
return {"reasoning": {"effort": "medium"}}, {}
effort = reasoning_config.get("effort", "medium")
# Honor a level the live catalog lists; otherwise clamp to the nearest WEAKER
# supported level (never drop straight to medium, which inverted the ladder:
# ultra < high). Bespoke levels the ladder can't place fall to medium (or [0]).
# See #74295.
if effort not in supported:
effort = clamp_reasoning_effort_to_supported(effort, list(supported))
if effort not in supported:
effort = "medium" if "medium" in supported else supported[0]
return {"reasoning": {"effort": effort}}, {}
except Exception:
return {}, {}
extra_body: dict[str, Any] = {}
if supports_reasoning and model:
try:
# Resolve supported efforts through the cached catalog helper, not
# the bare ``github_model_reasoning_efforts(model)``. The bare call
# has no catalog/api_key, so it falls through to the static
# GPT/o-series table and returns ``[]`` for Copilot-hosted Claude,
# silently dropping ``reasoning_effort`` even though the live
# ``/models`` catalog advertises it. ``get_copilot_reasoning_efforts``
# consults the live catalog (1-hour cache) and degrades to the
# static table only on fetch failure. (PR #51953 fixed the gate and
# the legacy path but not this registered-profile path.)
from hermes_cli.models import get_copilot_reasoning_efforts

supported_efforts = get_copilot_reasoning_efforts(model, api_key)
if supported_efforts and reasoning_config:
effort = reasoning_config.get("effort", "medium")
# Honor the requested level when the live Copilot catalog
# lists it as supported: gpt-5.5/gpt-5.4 DO support
# ``xhigh``. Otherwise clamp to the nearest WEAKER
# supported level via the shared ladder helper β€” the old
# ad-hoc rules dropped everything unrecognized to
# ``medium``, which inverted the ladder: ``ultra`` (the
# strongest ask) resolved weaker than an explicit
# ``high`` (#74295).
if effort not in supported_efforts:
from hermes_cli.models import (
clamp_reasoning_effort_to_supported,
)

effort = clamp_reasoning_effort_to_supported(
effort, list(supported_efforts)
)
if effort not in supported_efforts:
# Unrecognized/bespoke level the ladder can't
# place β€” fall back to medium, then to the
# catalog's first entry.
effort = (
"medium"
if "medium" in supported_efforts
else supported_efforts[0]
)
if effort in supported_efforts:
extra_body["reasoning"] = {"effort": effort}
elif supported_efforts:
extra_body["reasoning"] = {"effort": "medium"}
except Exception:
pass
return extra_body, {}


copilot = CopilotProfile(
Expand Down
25 changes: 23 additions & 2 deletions tests/agent/test_run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1442,12 +1442,33 @@ def test_reasoning_sent_for_copilot_gpt5(self, agent):
)
assert kwargs["extra_body"]["reasoning"] == {"effort": "medium"}

def test_reasoning_xhigh_preserved_for_copilot_when_supported(self, agent, monkeypatch):
"""The registered Copilot profile must preserve a supported xhigh."""
from agent.transports import get_transport
from providers import get_provider_profile

monkeypatch.setattr(
"hermes_cli.models.get_copilot_reasoning_efforts",
lambda _model, api_key=None: ["none", "low", "medium", "high", "xhigh"],
)
transport = get_transport("chat_completions")
profile = get_provider_profile("copilot")
msgs = [{"role": "user", "content": "hi"}]
kwargs = transport.build_kwargs(
model="gpt-5.5",
messages=msgs,
tools=None,
supports_reasoning=True,
reasoning_config={"enabled": True, "effort": "xhigh"},
provider_profile=profile,
)
assert kwargs["extra_body"]["reasoning"] == {"effort": "xhigh"}

def test_core_responses_preserves_supported_xhigh(self, agent, monkeypatch):
"""The core GitHub Responses path must preserve a supported xhigh."""
monkeypatch.setattr(
"hermes_cli.models.github_model_reasoning_efforts",
lambda _model: ["none", "low", "medium", "high", "xhigh"],
"hermes_cli.models.get_copilot_reasoning_efforts",
lambda _model, api_key=None: ["none", "low", "medium", "high", "xhigh"],
)
agent.model = "gpt-5.5"
agent.reasoning_config = {"enabled": True, "effort": "xhigh"}
Expand Down
Loading