refactor!: remove GitHub Copilot from core (now the github-copilot labs plugin)

Delete the in-tree model-provider plugins plugins/model-providers/copilot/
(provider `copilot`: Copilot API via the GitHub->Copilot token exchange) and
plugins/model-providers/copilot-acp/ (provider `copilot-acp`: Copilot CLI over
ACP). Both ship as ONE plugin, `github-copilot`, in
NousResearch/hermes-labs-plugins; the LEFT_CORE row added before this commit
installs it for every profile that routes through copilot/copilot-acp, and
every "unknown provider" surface names `hermes plugins install
github-copilot`. Core keeps no Copilot code: the generic seams the plugins
fill stay, and agent/copilot_acp_client.py stays as the deprecated alias the
kimchi-acp catalog plugin subclasses (agent.acp_client.ACPClient is the
public base). This commit is revertible on its own: reverting it restores the
bundled providers (the labs plugin stays inert on a core that bundles them).

Test files that only tested the plugins move to the labs plugin's tests:
  tests/plugins/model_providers/test_copilot_acp_client.py
  tests/plugins/model_providers/test_copilot_acp_deprecation.py
  tests/plugins/model_providers/test_copilot_auth.py
  tests/plugins/model_providers/test_copilot_catalog_oauth_fallback.py
  tests/plugins/model_providers/test_copilot_context.py
  tests/plugins/model_providers/test_copilot_custom_model_ids.py
  tests/plugins/model_providers/test_copilot_hosts.py
  tests/plugins/model_providers/test_copilot_pool_exchange.py
  tests/plugins/model_providers/test_copilot_profile.py
  tests/plugins/model_providers/test_copilot_refresh_notice.py
  tests/plugins/model_providers/test_copilot_setup.py
  tests/plugins/model_providers/test_copilot_token_exchange.py
  tests/agent/test_copilot_initiator.py
  tests/agent/test_copilot_native_vision_headers.py
  tests/hermes_cli/test_copilot_in_model_list.py
  tests/hermes_cli/test_copilot_model_api_mode.py
  tests/hermes_cli/test_copilot_runtime_api_mode.py
  tests/hermes_cli/test_copilot_runtime_api_mode_stale.py
  tests/hermes_cli/test_model_switch_copilot_api_mode.py
  tests/e2e/core/providers/test_native_copilot_acp.py
  tests/e2e/core/providers/test_native_copilot_acp_errors.py
  tests/e2e/core/providers/test_native_copilot_acp_streaming.py
  tests/fakes/providers/copilot_acp.py

Copilot-behaviour cases extracted from generic test files (same move):
  test_credential_pool (Copilot pool seeding / gh / env suppression),
  test_reasoning_carriers (Copilot carriers), test_run_agent (Copilot gpt-5
  reasoning, xhigh), test_run_agent_codex_responses (Copilot 401 refresh),
  test_codex_transport (Copilot reasoning), test_auxiliary_main_first (vision
  headers), test_private_credential_writers (JWT store row),
  test_provider_client_seam (copilot-acp client), test_provider_wire_snapshot
  + its fixture (copilot / github-models cells), test_api_key_providers,
  test_auth_commands, test_credential_pool_off_loop (exchange single-flight),
  test_external_process_auth_status (Copilot CLI token stores),
  test_external_process_provider_seam, test_model_cache_fallback_rows,
  test_model_validation, test_models_relay_base_url (copilot row),
  test_multiplex_cli_cache_scope, test_provider_catalog, test_cold_start_gil_stall.

Tests that used Copilot only as an example provider now use a fake
ProviderProfile (or another in-tree provider) with the same assertions:
  test_auxiliary_client, test_auxiliary_named_custom_providers,
  test_init_fallback_on_exhausted_pool, test_intent_ack_continuation,
  test_models_dev, test_turn_retry_state, test_one_turn_route_publish,
  test_mcp_catalog_env_boundary, test_plugin_provider_picker_residue,
  test_delegate_external_provider, test_delegation_profile_route,
  test_overlay_slug_resolution, test_installed_plugin_discovery, plus the
  generic halves of test_run_agent_codex_responses, test_codex_transport,
  test_api_key_providers, test_external_process_auth_status and
  test_model_cache_fallback_rows.
This commit is contained in:
Teknium committed 2026-10-25 20:57:22 -07:00
1 parent 70c2919ed3
commit 1da2eb42dc
73 files changed
+341 -7192

No files matched your search

@@ -1,99 +0,0 @@
"""GitHub Copilot ACP provider profile.
copilot-acp does not speak OpenAI-over-HTTP: it drives an external ACP subprocess over
stdio, so the profile supplies its own client via :meth:`ProviderProfile.create_client`.
An out-of-tree ACP provider (``~/.hermes/plugins/model-providers/`` or a pip entry point)
uses the same three lines without touching core.
"""
from typing import Any
from providers import register_provider
from providers.base import ProviderProfile
from .auth_rows import AUTH_ROW_FIELDS
from .catalog import acp_picker_catalog, clear_session_memo
from .picker import PICKER_FIELDS
from .setup import SETUP_FIELDS
class CopilotACPProfile(ProviderProfile):
"""GitHub Copilot ACP — external process, no REST models endpoint."""
def external_auth_evidence(self) -> tuple[bool, str | None] | None:
"""The Copilot CLI's token stores, read without spawning it; None (binary check) without the
copilot plugin that owns the token rules."""
try:
from plugins.model_providers.copilot.credentials import copilot_cli_auth_evidence
except ImportError:
return None
return copilot_cli_auth_evidence()
def create_client(self, **client_kwargs: Any) -> Any:
"""Build the ACP stdio shim rather than an HTTP client."""
from .client import CopilotACPClient
return CopilotACPClient(**client_kwargs)
def fetch_models(
self, *, api_key: str | None = None, base_url: str | None = None, timeout: float = 15.0
) -> list[str] | None:
"""Enabled models advertised by a short-lived signed-in ACP session (``session/new``).
The CLI may keep its login in an OS credential store with no token Hermes can reuse, so
the session is the only source that reflects the account's enablement. ``api_key`` /
``base_url`` are ignored: the subprocess owns auth. None when the CLI is missing, refuses
``--acp``, or the probe fails/times out — callers fall back to their next source.
"""
from hermes_cli.auth import resolve_external_process_provider_credentials
try:
creds = resolve_external_process_provider_credentials(self.name)
if not str(creds.get("base_url") or "").startswith("acp://"):
return None
client = self.create_client(
api_key=creds.get("api_key"), base_url=creds.get("base_url"),
command=creds.get("command"), args=creds.get("args"))
return client.list_models(timeout_seconds=timeout) or None
except Exception:
# Missing CLI (AuthError), refused --acp / failed spawn (RuntimeError), probe
# timeout — the base fetch_models contract is "None if the fetch failed".
return None
def live_context_length(self, model: str, *, api_key: str = "", base_url: str = "") -> int | None:
"""Same account catalog as the HTTP ``copilot`` provider (lane L4 seam)."""
from providers import get_provider_profile
copilot = get_provider_profile("copilot")
return copilot.live_context_length(model, api_key=api_key, base_url=base_url) if copilot else None
def invalidate_catalog_cache(self) -> None:
clear_session_memo()
def normalize_model_id(self, model: str, *, api_key: str = "") -> str | None:
"""Copilot id space; the ACP marker key never reaches the HTTP catalog."""
from providers import get_provider_profile
copilot = get_provider_profile("copilot")
return copilot.normalize_model_id(model) if copilot else None
copilot_acp = CopilotACPProfile(
name="copilot-acp", aliases=("github-copilot-acp", "copilot-acp-agent"),
api_mode="chat_completions", # ACP subprocess uses chat_completions routing
env_vars=(), # Managed by ACP subprocess
base_url="acp://copilot", # ACP internal scheme
auth_type="external_process",
# How to launch the CLI; env var names predate this profile (formerly hardcoded in
# hermes_cli/auth.py), so existing setups keep working.
process_command="copilot",
process_args=("--acp", "--stdio"),
process_command_env_vars=("HERMES_COPILOT_ACP_COMMAND", "COPILOT_CLI_PATH"),
process_args_env_var="HERMES_COPILOT_ACP_ARGS",
# A delegation.command no other profile claims has always run through this ACP client.
process_command_fallback=True,
picker_catalog=acp_picker_catalog, static_models=("copilot-acp",),
**SETUP_FIELDS, **PICKER_FIELDS, **AUTH_ROW_FIELDS,
)
register_provider(copilot_acp)
@@ -1,9 +0,0 @@
"""The ``hermes auth`` registry row this profile mirrors into ``hermes_cli.auth.PROVIDER_REGISTRY``:
right after the ``copilot`` row, with its ``acp://`` base URL overridable from the environment."""
from typing import Any
AUTH_ROW_FIELDS: dict[str, Any] = {
"base_url_env_var": "COPILOT_ACP_BASE_URL",
"registry_after": "copilot",
}
@@ -1,55 +0,0 @@
"""copilot-acp picker catalog: the signed-in CLI session's models (memoized), else the HTTP
``copilot`` provider's catalog (same account id space)."""
from __future__ import annotations
import logging
import time
from typing import Optional
logger = logging.getLogger(__name__)
_SESSION_MEMO_TTL = 300.0 # 5 min; SWR disk cache handles the rest
_SESSION_FAIL_TTL = 30.0 # failed probes re-probe quickly so a fresh CLI login is picked up
_session_memo: Optional[tuple[float, float, Optional[list[str]]]] = None # (at, ttl, models)
def session_models(force_refresh: bool) -> Optional[list[str]]:
"""Enabled models from a signed-in ``copilot --acp`` session, memoized for a few minutes —
successes AND failures. Model-switch validation (``models_validate._static_catalog``) reads
this uncached on every ``/model`` switch, and each miss is a CLI spawn + handshake (up to the
probe timeout), so without the memo every switch paid a subprocess. A failed probe is
memoized much more briefly so a user who signs in to the CLI right after a miss is picked up
on the next switch (or immediately via ``/model --refresh``, which clears this memo)."""
global _session_memo
now = time.monotonic()
memo = _session_memo
if not force_refresh and memo is not None and now - memo[0] < memo[1]:
return memo[2]
from providers import get_provider_profile
try:
live = get_provider_profile("copilot-acp").fetch_models() or None
except Exception:
logger.debug("copilot-acp session model discovery failed", exc_info=True)
live = None
_session_memo = (now, _SESSION_MEMO_TTL if live else _SESSION_FAIL_TTL, live)
return live
def clear_session_memo() -> None:
"""A fresh CLI login must show on the next ``/model`` switch: ``--refresh`` drops the memo
(and its failure memo) so it never serves a stale signed-out probe."""
global _session_memo
_session_memo = None
def acp_picker_catalog(force_refresh: bool = False) -> Optional[list[str]]:
if live := session_models(force_refresh):
return live
from providers import get_provider_profile
copilot = get_provider_profile("copilot")
if copilot is None or copilot.picker_catalog is None:
return None
return copilot.picker_catalog(force_refresh)
@@ -1,52 +0,0 @@
"""GitHub Copilot CLI (``copilot --acp --stdio``) client: the generic ACP client plus Copilot's quirks."""
from __future__ import annotations
from typing import Any
from agent.acp_client import ACPClient
# Stderr fingerprint of the deprecated `gh copilot` extension. Require BOTH the product name
# AND a deprecation marker: the NEW `@github/copilot` CLI legitimately mentions "copilot-cli".
_DEPRECATION_REQUIRED = ("gh-copilot",)
_DEPRECATION_MARKERS = ("has been deprecated", "no commands will be executed")
_DEPRECATED_CLI_ERROR = (
"Hermes ACP mode requires the NEW GitHub Copilot CLI (github.com/github/copilot-cli), but the binary it just "
"spawned is the deprecated `gh copilot` extension.\n\n"
"Install the new CLI:\n npm install -g @github/copilot\n # then verify with: copilot --help\n\n"
"If `copilot` already resolves to the new CLI but you still see this,\npoint Hermes at it explicitly:\n"
" export HERMES_COPILOT_ACP_COMMAND=/path/to/new/copilot\n\n"
"Alternative: use the `copilot` provider (no ACP, hits the Copilot API\ndirectly with a Copilot subscription "
"token) via `hermes setup`.\n\nOriginal error:\n"
)
def is_gh_copilot_deprecation_message(stderr_text: str) -> bool:
"""True iff stderr looks like the deprecated gh-copilot extension's banner."""
lower = stderr_text.lower()
return any(req in lower for req in _DEPRECATION_REQUIRED) and any(m in lower for m in _DEPRECATION_MARKERS)
class CopilotACPClient(ACPClient):
"""ACP client for the GitHub Copilot CLI."""
# Same launch spec as the copilot-acp profile's process_* fields (the fallback when a
# caller passes no command/args).
default_command = "copilot"
default_args = ("--acp", "--stdio")
command_env_vars = ("HERMES_COPILOT_ACP_COMMAND", "COPILOT_CLI_PATH")
args_env_var = "HERMES_COPILOT_ACP_ARGS"
marker_base_url = "acp://copilot"
placeholder_model = "copilot-acp"
label = "Copilot ACP"
missing_command_hint = "Install GitHub Copilot CLI or set HERMES_COPILOT_ACP_COMMAND/COPILOT_CLI_PATH."
unsupported_transport_hint = (
"Either install a CLI that ships with --acp support (e.g. `@github/copilot` late 2025+), or set "
"HERMES_COPILOT_ACP_COMMAND / HERMES_COPILOT_ACP_ARGS to a working pair.")
def model_entry_disabled(self, entry: dict[str, Any]) -> bool:
"""Copilot marks org-policy-disabled models with ``_meta.copilotEnablement: disabled``."""
return str((entry.get("_meta") or {}).get("copilotEnablement") or "").strip().lower() == "disabled"
def early_exit_error(self, stderr_text: str) -> str | None:
return _DEPRECATED_CLI_ERROR + stderr_text if is_gh_copilot_deprecation_message(stderr_text) else None
@@ -1,16 +0,0 @@
"""GitHub Copilot ACP's place in the provider tables: picker row, label, group, aliases, overlay.
Core reads these only through generic ``ProviderProfile`` fields (``PICKER_FIELDS``).
"""
PICKER_FIELDS = dict(
display_name="GitHub Copilot ACP",
description="GitHub Copilot ACP (Spawns copilot --acp --stdio)",
picker_after="copilot",
# Same group as the ``copilot`` provider: one "GitHub Copilot" row in the interactive pickers.
picker_group=("copilot", "GitHub Copilot", "GitHub token API or copilot --acp process"),
catalog_aliases=True,
provider_overlay={"transport": "codex_responses", "auth_type": "external_process",
"base_url_override": "acp://copilot", "base_url_env_var": "COPILOT_ACP_BASE_URL"},
overlay_aliases=("github-copilot-acp",),
)
@@ -1,5 +0,0 @@
name: copilot-acp-provider
kind: model-provider
version: 1.0.0
description: GitHub Copilot via ACP subprocess
author: Nous Research
@@ -1,88 +0,0 @@
"""GitHub Copilot ACP setup and Accounts-card surfaces: the ``hermes model`` flow and the
Desktop card that points at the Copilot CLI's own login. Reached through ``SETUP_FIELDS``."""
from __future__ import annotations
import contextlib
from typing import Any
def account_status() -> dict[str, Any]:
"""Status for copilot-acp. ``logged_in`` only on positive evidence (env token or known on-disk
store); the CLI may hold its session in an OS keychain Hermes can't read, so the unverified
state reads "managed by the Copilot CLI" — never signed out."""
try:
from hermes_cli.auth import get_external_process_provider_status
status = get_external_process_provider_status("copilot-acp") or {}
except Exception:
status = {}
verified = bool(status.get("auth_verified"))
configured = bool(status.get("configured"))
if verified:
source_label = status.get("auth_source") or "Copilot credentials detected"
elif configured:
found = status.get("resolved_command") or status.get("command") or "copilot"
source_label = f"Managed by the GitHub Copilot CLI ({found})"
else:
source_label = "GitHub Copilot CLI not found on PATH"
return {
"logged_in": verified, "source": "copilot_cli", "source_label": source_label, "token_preview": None,
"expires_at": None, "has_refresh_token": False, "configured": configured,
}
def model_flow_copilot_acp(config, current_model="", args=None):
"""GitHub Copilot ACP flow using the local Copilot CLI."""
from hermes_cli.auth import (
PROVIDER_REGISTRY, get_external_process_provider_status, resolve_api_key_provider_credentials,
resolve_external_process_provider_credentials)
from hermes_cli.model_setup_flows_common import _finish_model, _pick_model_or_prompt, _say
from providers import get_provider_profile
del config, args
provider_id = "copilot-acp"
pconfig = PROVIDER_REGISTRY[provider_id]
status = get_external_process_provider_status(provider_id)
resolved_command = status.get("resolved_command") or status.get("command") or "copilot"
effective_base = status.get("base_url") or pconfig.inference_base_url
_say(" GitHub Copilot ACP delegates Hermes turns to `copilot --acp`.",
" Hermes currently starts its own ACP subprocess for each request.",
" Hermes uses your selected model as a hint for the Copilot ACP session.",
f" Command: {resolved_command}", f" Backend marker: {effective_base}", "")
try:
creds = resolve_external_process_provider_credentials(provider_id)
except Exception as exc: # health: allow BLE001 -- shown to the user, then the flow stops (moved verbatim)
_say(f" ⚠ {exc}", " Set HERMES_COPILOT_ACP_COMMAND or COPILOT_CLI_PATH if Copilot CLI is installed elsewhere.")
return
effective_base = creds.get("base_url") or effective_base
catalog_api_key = ""
with contextlib.suppress(Exception):
catalog_api_key = resolve_api_key_provider_credentials("copilot").get("api_key", "")
# The GitHub catalog helpers belong to the sibling ``copilot`` profile (one plugin in the labs repo).
copilot_setup = _sibling_setup_module(get_provider_profile("copilot"))
_catalog, catalog_ids, _normalize = copilot_setup._copilot_catalog(catalog_api_key)
selected = _pick_model_or_prompt(
copilot_setup._copilot_model_list(catalog_ids), "Model name: ", current_model=_normalize(current_model),
confirm_provider=provider_id, confirm_base_url=effective_base, confirm_api_key=catalog_api_key)
if selected:
selected = _normalize(selected)
_finish_model(selected, provider_id, f"Default model set to: {selected} (via {pconfig.name})",
base_url=effective_base, api_mode="chat_completions")
def _sibling_setup_module(profile) -> Any:
"""The ``setup`` module next to *profile*'s plugin package (it registered under its own import name)."""
import importlib
return importlib.import_module(f"{type(profile).__module__}.setup")
SETUP_FIELDS: dict[str, Any] = {
"model_setup_flow": model_flow_copilot_acp,
# `copilot login` is the non-interactive subcommand; `copilot /login` is not valid
# (slash-commands only exist inside an interactive session).
"account_card": {"name": "GitHub Copilot (ACP)", "flow": "external", "cli_command": "copilot login",
"docs_url": "https://docs.github.com/en/copilot"},
"account_status": account_status,
}
@@ -1,54 +0,0 @@
"""Copilot / GitHub Models provider profile.
Core routes GPT-5+/Codex -> codex_responses and Claude -> anthropic_messages;
this profile covers the chat_completions remainder: editor attribution headers
(copilot_default_headers()) and catalog-gated GitHub Models reasoning.
"""
from typing import Any
from providers import register_provider
from providers.base import ProviderProfile
from .auth_rows import AUTH_ROW_FIELDS
from .catalog import CATALOG_FIELDS, HOSTNAMES, METADATA_HOSTNAMES, MODELS_DEV_ID, CopilotCatalogMixin
from .credentials import CopilotCredentialsMixin
from .picker import PICKER_FIELDS
from .responses import RESPONSES_PROFILE_FIELDS, CopilotResponsesMixin
from .setup import SETUP_FIELDS
from .wire import CopilotWireMixin
class CopilotProfile(CopilotWireMixin, CopilotResponsesMixin, CopilotCatalogMixin, CopilotCredentialsMixin, 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,
) -> tuple[dict[str, Any], dict[str, Any]]:
if not (supports_reasoning and model):
return {}, {}
try:
from .catalog import clamp_github_reasoning_effort, github_model_reasoning_efforts
supported = github_model_reasoning_efforts(model)
if not supported:
return {}, {}
if not reasoning_config:
return {"reasoning": {"effort": "medium"}}, {}
# Never drop straight to medium, which inverted the ladder (ultra < high). See #74295.
effort = clamp_github_reasoning_effort(reasoning_config.get("effort"), supported)
return {"reasoning": {"effort": effort}}, {}
except Exception:
return {}, {}
copilot = CopilotProfile(
name="copilot", aliases=("github-copilot", "github-models", "github-model", "github"),
env_vars=("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"), base_url="https://api.githubcopilot.com",
auth_type="copilot", ambient_credentials=True, **RESPONSES_PROFILE_FIELDS, **SETUP_FIELDS,
hostnames=HOSTNAMES, metadata_hostnames=METADATA_HOSTNAMES, models_dev_id=MODELS_DEV_ID, **CATALOG_FIELDS,
**PICKER_FIELDS, **AUTH_ROW_FIELDS,
)
register_provider(copilot)
@@ -1,13 +0,0 @@
"""The ``hermes auth`` registry row this profile mirrors into ``hermes_cli.auth.PROVIDER_REGISTRY``.
Credentials resolve like an api-key provider (env tokens, pool, ``hermes auth add copilot`` paste), while
``auth_type="copilot"`` names its own setup flow; the row keeps its historical place right after LM Studio.
"""
from typing import Any
AUTH_ROW_FIELDS: dict[str, Any] = {
"registry_auth_type": "api_key",
"base_url_env_var": "COPILOT_API_BASE_URL",
"registry_after": "lmstudio",
}
-410
View File
@@ -1,410 +0,0 @@
"""Copilot catalog and model metadata: hosts, models.dev id, the live ``/models`` catalog (picker,
context windows, id normalization, reasoning efforts) and the curated fallback list.
Fills the generic ProviderProfile catalog seams (``providers/base.py`` L4 + I2-cat blocks). Core
helpers (``hermes_cli.models._get_json`` / ``_api_key_credentials``) are imported at call time, so
this module never imports ``hermes_cli`` while the provider registry is being discovered.
"""
from __future__ import annotations
import copy
import json
import logging
import os
import time
from typing import Any, Optional
logger = logging.getLogger(__name__)
# Copilot business/enterprise accounts are served from ``api.<plan>.githubcopilot.com``; GitHub Models
# (models.github.ai) speaks the same wire (Responses quirks, reasoning payloads, x-initiator billing).
# Per-site differences (carriers, editor headers: githubcopilot.com only) live in the hooks, keyed on base_url.
HOSTNAMES = ("githubcopilot.com", "models.github.ai")
# The ~8K-capped GitHub Models free tier shares the Copilot catalog only (model metadata lookups).
METADATA_HOSTNAMES = ("models.inference.ai.azure.com",)
MODELS_DEV_ID = "github-copilot"
# Credential stores whose change re-keys every cached provider catalog (core's catalog fingerprint).
CATALOG_CREDENTIAL_FILES = ("~/.config/github-copilot/hosts.json",)
COPILOT_BASE_URL = "https://api.githubcopilot.com"
COPILOT_MODELS_URL = f"{COPILOT_BASE_URL}/models"
# Base URLs whose ``/models`` probe is answered from the Copilot catalog (GitHub Models included).
_CATALOG_BASE_URLS = (COPILOT_BASE_URL, "https://models.github.ai/inference", "https://models.inference.ai.azure.com")
REASONING_EFFORTS_GPT5 = ["minimal", "low", "medium", "high"]
REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"]
# Curated list (core's static catalog row): served when the live catalog is unreachable.
STATIC_MODELS = (
"gpt-5.4", "gpt-5.4-mini", "gpt-5-mini", "gpt-5.3-codex", "gpt-5.2-codex", "gpt-4.1", "gpt-4o", "gpt-4o-mini",
"claude-sonnet-4.6", "claude-sonnet-5", "claude-sonnet-4", "claude-sonnet-4.5", "claude-haiku-4.5",
"gemini-3.1-pro-preview", "gemini-3-pro-preview", "gemini-3-flash-preview", "gemini-2.5-pro",
)
# OpenRouter-style ids -> Copilot ids. Dash-notation Claude ids are accepted too: Hermes' default
# Claude IDs use hyphens (Anthropic native) but Copilot's API only accepts dot-notation, so a
# copilot + hyphenated default would otherwise hit HTTP 400 "model_not_supported".
MODEL_ALIASES: dict[str, str] = dict((
("openai/gpt-5", "gpt-5-mini"), ("openai/gpt-5-chat", "gpt-5-mini"), ("openai/gpt-5-mini", "gpt-5-mini"),
("openai/gpt-5-nano", "gpt-5-mini"), ("openai/gpt-4.1", "gpt-4.1"), ("openai/gpt-4.1-mini", "gpt-4.1"),
("openai/gpt-4.1-nano", "gpt-4.1"), ("openai/gpt-4o", "gpt-4o"), ("openai/gpt-4o-mini", "gpt-4o-mini"),
("openai/o1", "gpt-5.2"), ("openai/o1-mini", "gpt-5-mini"), ("openai/o1-preview", "gpt-5.2"),
("openai/o3", "gpt-5.3-codex"), ("openai/o3-mini", "gpt-5-mini"), ("openai/o4-mini", "gpt-5-mini"),
("anthropic/claude-opus-4.6", "claude-opus-4.6"), ("anthropic/claude-sonnet-5", "claude-sonnet-5"),
("anthropic/claude-sonnet-4.6", "claude-sonnet-4.6"), ("anthropic/claude-sonnet-4", "claude-sonnet-4"),
("anthropic/claude-sonnet-4.5", "claude-sonnet-4.5"), ("anthropic/claude-haiku-4.5", "claude-haiku-4.5"),
("claude-sonnet-5", "claude-sonnet-5"), ("claude-opus-4-6", "claude-opus-4.6"),
("claude-sonnet-4-6", "claude-sonnet-4.6"), ("claude-sonnet-4-0", "claude-sonnet-4"),
("claude-sonnet-4-5", "claude-sonnet-4.5"), ("claude-haiku-4-5", "claude-haiku-4.5"),
("anthropic/claude-opus-4-6", "claude-opus-4.6"), ("anthropic/claude-sonnet-4-6", "claude-sonnet-4.6"),
("anthropic/claude-sonnet-4-0", "claude-sonnet-4"), ("anthropic/claude-sonnet-4-5", "claude-sonnet-4.5"),
("anthropic/claude-haiku-4-5", "claude-haiku-4.5"),
))
# ─── catalog credential (best effort) ───
def _first_exchangeable_copilot_token(raw_tokens) -> str:
"""Exchange stored GitHub tokens in order; the first that validates AND exchanges wins (every
entry is tried so a later valid token survives an earlier malformed one)."""
from .credentials import exchange_copilot_token, validate_copilot_token
for raw in raw_tokens:
raw = str(raw or "").strip()
if not raw or not validate_copilot_token(raw)[0]:
continue
try:
api_token = exchange_copilot_token(raw)[0] # (api_token, expires_at, base_url)
except Exception:
continue
if api_token:
return api_token
return ""
def _copilot_cli_config_tokens() -> list[str]:
"""``copilotTokens`` from the GitHub Copilot CLI's own plaintext store (JSONC — strip
``//``-comment lines), written by ``copilot login`` on hosts without an OS keychain."""
cli_config = os.path.expanduser("~/.copilot/config.json")
if not os.path.isfile(cli_config):
return []
with open(cli_config, "r", encoding="utf-8-sig", errors="ignore") as fh:
raw_text = "\n".join(
line for line in fh.read().splitlines() if not line.lstrip().startswith("//"))
data = json.loads(raw_text) if raw_text.strip() else {}
tokens = data.get("copilotTokens")
return list(tokens.values()) if isinstance(tokens, dict) else []
def _resolve_copilot_catalog_api_key() -> str:
"""Best-effort GitHub token for the Copilot catalog: env vars / ``gh auth token`` via
``resolve_api_key_provider_credentials``, then ``auth.json`` ``credential_pool.copilot[]``, then
``~/.copilot/config.json`` ``copilotTokens`` (the ACP CLI's own store). Without the latter two,
keyless users see the picker fall back to the stale curated list on a silent 401."""
def _env_token() -> str:
from hermes_cli.models import _api_key_credentials
return _api_key_credentials("copilot")[0]
def _pool_token() -> str:
from hermes_cli.auth import read_credential_pool
return _first_exchangeable_copilot_token(
entry.get("access_token") for entry in read_credential_pool("copilot") if isinstance(entry, dict))
sources = (
_env_token,
_pool_token,
lambda: _first_exchangeable_copilot_token(_copilot_cli_config_tokens()),
)
for source in sources:
try:
token = source()
except Exception:
continue
if token:
return token
return ""
# ─── live /models catalog ───
def copilot_default_headers(*, is_agent_turn: bool = True) -> dict[str, str]:
"""Standard headers for Copilot API requests."""
from .credentials import copilot_request_headers
return copilot_request_headers(is_agent_turn=is_agent_turn)
_COPILOT_CHAT_ENDPOINTS = {"/chat/completions", "/responses", "/v1/messages"}
def _payload_items(payload: Any) -> list[dict[str, Any]]:
data = payload.get("data", []) if isinstance(payload, dict) else payload
return [item for item in data if isinstance(item, dict)] if isinstance(data, list) else []
def _copilot_catalog_item_is_text_model(
item: dict[str, Any], *, ignore_picker_flag: bool = False) -> bool:
if not str(item.get("id") or "").strip():
return False
if not ignore_picker_flag and item.get("model_picker_enabled") is False:
return False
capabilities = item.get("capabilities")
if isinstance(capabilities, dict):
model_type = str(capabilities.get("type") or "").strip().lower()
if model_type and model_type != "chat":
return False
supported_endpoints = item.get("supported_endpoints")
if isinstance(supported_endpoints, list):
endpoints = {e for endpoint in supported_endpoints if (e := str(endpoint).strip())}
if endpoints and not endpoints & _COPILOT_CHAT_ENDPOINTS:
return False
return True
def _copilot_text_models(items: list[dict[str, Any]], *, ignore_picker_flag: bool = False) -> list[dict[str, Any]]:
"""Chat-capable catalog rows, deduped by id, in catalog order."""
models: list[dict[str, Any]] = []
seen_ids: set[str] = set()
for item in items:
model_id = str(item.get("id") or "").strip()
if model_id in seen_ids:
continue
if not _copilot_catalog_item_is_text_model(item, ignore_picker_flag=ignore_picker_flag):
continue
seen_ids.add(model_id)
models.append(item)
return models
# Short-TTL cache of the filtered GitHub Copilot /models catalog (picker + context/normalize helpers
# share it). Keyed by the api_key of the successful fetch so a credential swap never serves the
# previous account's catalog; monotonic clock; lock-free (a race at worst duplicates one fetch).
_github_model_catalog_cache: Optional[list[dict[str, Any]]] = None
_github_model_catalog_cache_key: Optional[str] = None
_github_model_catalog_cache_time: float = 0.0
_GITHUB_MODEL_CATALOG_CACHE_TTL = 300 # 5 minutes
def fetch_github_model_catalog(
api_key: Optional[str] = None, timeout: float = 5.0) -> Optional[list[dict[str, Any]]]:
"""Fetch the live GitHub Copilot model catalog for this account."""
global _github_model_catalog_cache, _github_model_catalog_cache_key
global _github_model_catalog_cache_time
from hermes_cli.models import _get_json
if (
_github_model_catalog_cache is not None
and _github_model_catalog_cache_key == api_key
and (time.monotonic() - _github_model_catalog_cache_time) < _GITHUB_MODEL_CATALOG_CACHE_TTL
):
return copy.deepcopy(_github_model_catalog_cache) # deep: callers must not mutate cached dicts
attempts: list[dict[str, str]] = []
if api_key:
attempts.append({**copilot_default_headers(), "Authorization": f"Bearer {api_key}"})
attempts.append(copilot_default_headers())
for headers in attempts:
try:
items = _payload_items(_get_json(COPILOT_MODELS_URL, timeout=timeout, headers=headers))
except Exception:
continue
models = _copilot_text_models(items)
if not models and items:
# GitHub has been observed returning ``model_picker_enabled: false`` for EVERY model on
# some accounts, which would strand the picker on the stale curated fallback. The flag
# is a display hint, not an availability contract — retry without it (chat/endpoint
# checks still apply).
models = _copilot_text_models(items, ignore_picker_flag=True)
if models:
_github_model_catalog_cache = copy.deepcopy(models)
_github_model_catalog_cache_key = api_key
_github_model_catalog_cache_time = time.monotonic()
return models
return None
def _fetch_github_models(api_key: Optional[str] = None, timeout: float = 5.0) -> Optional[list[str]]:
catalog = fetch_github_model_catalog(api_key=api_key, timeout=timeout)
return [item.get("id", "") for item in catalog if item.get("id")] if catalog else None
def copilot_picker_catalog(force_refresh: bool = False) -> Optional[list[str]]:
"""The account's live catalog ids, else the curated list marked as a placeholder."""
from hermes_cli.models import CuratedFallbackModels, _PROVIDER_MODELS
try:
live = _fetch_github_models(_resolve_copilot_catalog_api_key())
if live:
return live
except Exception: # the picker degrades to the curated list on any catalog failure
logger.debug("copilot live catalog fetch failed", exc_info=True)
return CuratedFallbackModels(_PROVIDER_MODELS.get("copilot", list(STATIC_MODELS)))
def _is_github_models_base_url(base_url: Optional[str]) -> bool:
return (base_url or "").strip().rstrip("/").lower().startswith(_CATALOG_BASE_URLS)
# ─── context windows ───
# Module-level cache: {model_id: max_prompt_tokens}
_copilot_context_cache: dict[str, int] = {}
_copilot_context_cache_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
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
# 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)
catalog = fetch_github_model_catalog(api_key=api_key)
if not catalog:
return None
cache: dict[str, int] = {}
for item in catalog:
mid = str(item.get("id") or "").strip()
max_prompt = ((item.get("capabilities") or {}).get("limits") or {}).get("max_prompt_tokens")
if mid and isinstance(max_prompt, int) and max_prompt > 0:
cache[mid] = max_prompt
_copilot_context_cache = cache
_copilot_context_cache_time = time.time()
_copilot_context_cache_key = key_fp
return cache.get(model_id)
# ─── id normalization and reasoning efforts ───
def _copilot_catalog_ids(
catalog: Optional[list[dict[str, Any]]] = None, api_key: Optional[str] = None) -> set[str]:
if catalog is None and api_key:
catalog = fetch_github_model_catalog(api_key=api_key)
return {mid for item in (catalog or []) if (mid := str(item.get("id") or "").strip())}
def normalize_copilot_model_id(
model_id: Optional[str], *, catalog: Optional[list[dict[str, Any]]] = None,
api_key: Optional[str] = None) -> str:
raw = str(model_id or "").strip()
if not raw:
return ""
catalog_ids = _copilot_catalog_ids(catalog=catalog, api_key=api_key)
alias = MODEL_ALIASES.get(raw)
if alias:
return alias
candidates = [raw]
if "/" in raw:
candidates.append(raw.split("/", 1)[1].strip())
if raw.endswith(("-mini", "-nano", "-chat")):
candidates.append(raw[:-5])
seen: set[str] = set()
for candidate in candidates:
if not candidate or candidate in seen:
continue
seen.add(candidate)
if candidate in MODEL_ALIASES:
return MODEL_ALIASES[candidate]
if candidate in catalog_ids:
return candidate
if "/" in raw:
stripped = raw.split("/", 1)[1].strip()
# Enterprise BYOK custom models expose ``owner/sub/model`` ids (two
# slashes). A strip guess that still contains "/" cannot be a Copilot
# id, so pass the input through untouched instead of corrupting it.
if stripped and "/" not in stripped:
return stripped
return raw
def clamp_github_reasoning_effort(effort: Any, supported: list[str]) -> str:
"""Copilot/GitHub Models effort for a non-empty *supported* list: the level itself when listed,
else the nearest WEAKER listed level; bespoke names the ladder can't place fall to ``medium``
(or the first listed level)."""
from agent.reasoning_effort import clamp_effort
effort = str(effort or "medium").strip().lower()
if effort not in supported:
effort = clamp_effort(effort, supported)
if effort not in supported:
effort = "medium" if "medium" in supported else supported[0]
return effort
def _github_reasoning_efforts_for_model_id(model_id: str) -> list[str]:
from agent.reasoning_effort import CODEX_ASTRA_EFFORTS, is_astra_model
raw = (model_id or "").strip().lower()
if raw.startswith(("openai/o1", "openai/o3", "openai/o4", "o1", "o3", "o4")):
return list(REASONING_EFFORTS_O_SERIES)
normalized = normalize_copilot_model_id(model_id).lower()
if is_astra_model(normalized):
return list(CODEX_ASTRA_EFFORTS)
if normalized.startswith("gpt-5"):
return list(REASONING_EFFORTS_GPT5)
return []
def github_model_reasoning_efforts(
model_id: Optional[str], *, catalog: Optional[list[dict[str, Any]]] = None,
api_key: Optional[str] = None) -> list[str]:
"""Return supported reasoning-effort levels for a Copilot-visible model."""
normalized = normalize_copilot_model_id(model_id, catalog=catalog, api_key=api_key)
if not normalized:
return []
if catalog is None and api_key:
catalog = fetch_github_model_catalog(api_key=api_key)
catalog_entry = next((item for item in catalog if item.get("id") == normalized), None) if catalog else None
if catalog_entry is not None:
capabilities = catalog_entry.get("capabilities")
if isinstance(capabilities, dict):
# Structured catalog: the advertised list is authoritative (empty when absent).
supports = capabilities.get("supports")
efforts = supports.get("reasoning_effort") if isinstance(supports, dict) else None
if not isinstance(efforts, list):
return []
return list(dict.fromkeys(e for effort in efforts if (e := str(effort).strip().lower())))
# Legacy list-shaped capabilities: only a "reasoning" tag unlocks the pattern defaults.
if "reasoning" not in {str(c).strip().lower() for c in catalog_entry.get("capabilities", [])}:
return []
return _github_reasoning_efforts_for_model_id(str(model_id or normalized))
# ProviderProfile kwargs filling the I2-cat catalog seams.
CATALOG_FIELDS = dict(
picker_catalog=copilot_picker_catalog, static_models=STATIC_MODELS,
catalog_credential_files=CATALOG_CREDENTIAL_FILES,
)
class CopilotCatalogMixin:
"""Catalog/metadata hooks for the Copilot profiles."""
def live_context_length(self, model: str, *, api_key: str = "", base_url: str = "") -> int | None:
return get_copilot_model_context(model, api_key=api_key)
def normalize_model_id(self, model: str, *, api_key: str = "") -> str | None:
return normalize_copilot_model_id(model, api_key=api_key or None)
def probe_models(
self, *, base_url: str, api_key: str | None = None, timeout: float = 5.0,
) -> tuple[list[str] | None, str, str] | None:
if not _is_github_models_base_url(base_url):
return None
return _fetch_github_models(api_key=api_key, timeout=timeout), COPILOT_MODELS_URL, COPILOT_BASE_URL
@@ -1,776 +0,0 @@
"""GitHub Copilot credentials: token resolution (order matches the Copilot CLI: COPILOT_GITHUB_TOKEN,
GH_TOKEN, GITHUB_TOKEN, then ``gh auth token``), device-code login, the GitHub→Copilot token exchange,
and :class:`CopilotCredentialsMixin`, which fills core's generic ``ProviderProfile`` credential seams."""
from __future__ import annotations
import contextlib
import hashlib
import json
import logging
import os
import re
import shutil
import subprocess
import threading
import time
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any, Callable, Optional
from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
from utils import atomic_json_write
logger = logging.getLogger(__name__)
# VS Code's GitHub App client ID: mints ghu_* tokens exchangeable for Copilot API JWTs (needed for
# internal-only models / enterprise endpoints). The opencode App ID mints gho_* tokens that 404.
COPILOT_OAUTH_CLIENT_ID = "Iv1.b507a08c87ecfe98"
_CLASSIC_PAT_PREFIX = "ghp_" # rejected by the Copilot API
# Token families the Copilot API exchanges. Anything else (a random string in GITHUB_TOKEN) used
# to pass validation and then fail downstream with an opaque auth error (#12650).
_SUPPORTED_PREFIXES = ("gho_", "github_pat_", "ghu_")
COPILOT_ENV_VARS = ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN")
_DEVICE_CODE_POLL_INTERVAL = 5 # seconds
_DEVICE_CODE_POLL_SAFETY_MARGIN = 3 # seconds
def validate_copilot_token(token: str) -> tuple[bool, str]:
"""Validate that a token is usable with the Copilot API."""
token = token.strip()
if not token:
return False, "Empty token"
if token.startswith(_CLASSIC_PAT_PREFIX):
return False, (
"Classic Personal Access Tokens (ghp_*) are not supported by the "
"Copilot API. Use one of:\n"
" → `copilot login` or `hermes model` to authenticate via OAuth\n"
" → A fine-grained PAT (github_pat_*) with Copilot Requests permission\n"
" → `gh auth login` with the default device code flow (produces gho_* tokens)")
if not token.startswith(_SUPPORTED_PREFIXES):
return False, (
"Unsupported GitHub token format for the Copilot API. "
f"Supported token prefixes: {', '.join(_SUPPORTED_PREFIXES)}.")
return True, "OK"
def resolve_copilot_token() -> tuple[str, str]:
"""Resolve a GitHub token suitable for Copilot API use → (token, source); ("", "") if none.
Raises ValueError if only a classic PAT is available.
"""
any_env_var_set = False
for env_var in COPILOT_ENV_VARS:
val = os.getenv(env_var, "").strip()
if not val:
continue
any_env_var_set = True
valid, msg = validate_copilot_token(val)
if valid:
return val, env_var
logger.warning("Token from %s is not supported: %s", env_var, msg)
# `gh auth token` fallback ONLY when no Copilot env var was set: an exported GITHUB_TOKEN
# (even a classic PAT) means the user intends *that* token; skipping also avoids a slow
# subprocess (up to 5s on Windows) on every cold start.
if any_env_var_set:
logger.debug("Copilot env var(s) set but none held a supported token; skipping `gh auth "
"token` fallback to honor explicit env-var intent (and avoid the subprocess "
"cost on cold start, #60800).")
return "", ""
token = _try_gh_cli_token()
if token:
valid, msg = validate_copilot_token(token)
if not valid:
raise ValueError(f"Token from `gh auth token` is not usable with Copilot. {msg}")
return token, "gh auth token"
return "", ""
def _gh_cli_candidates() -> list[str]:
"""Every present ``gh`` in probe order: PATH first, then Homebrew and ``~/.local/bin``."""
from hermes_platform.resolver import locate_command
from hermes_platform.resolver.known_dirs import homebrew_dirs, user_local_bin
res = locate_command("gh", known_dirs=(*homebrew_dirs(), *user_local_bin()))
seen: list[str] = []
for cand in res.present:
if cand.value not in seen:
seen.append(cand.value)
return seen
# ``gh auth token`` cache (misses too). With no credential store the probe blocks its full 5s on
# keyring / D-Bus, and provider inventory probes Copilot several times per request — an uncached
# miss made one settings page a 4×5s stall past Desktop's 15s IPC budget. Short TTL keeps a
# fresh ``gh auth login`` discoverable.
_GH_CLI_TOKEN_CACHE_TTL_SECONDS = 300.0
_gh_cli_token_cache: tuple[float, Optional[str]] | None = None
def _invalidate_gh_cli_token_cache() -> None:
"""Reset the ``gh auth token`` probe cache (used by tests and re-auth flows)."""
global _gh_cli_token_cache
_gh_cli_token_cache = None
def _try_gh_cli_token() -> Optional[str]:
"""Token from ``gh auth token`` when available; the result (incl. a miss) is cached per TTL."""
global _gh_cli_token_cache
now = time.monotonic()
cache = _gh_cli_token_cache
if cache is not None and now - cache[0] < _GH_CLI_TOKEN_CACHE_TTL_SECONDS:
return cache[1]
token = _probe_gh_cli_token()
_gh_cli_token_cache = (now, token)
return token
def _probe_gh_cli_token() -> Optional[str]:
"""Uncached ``gh auth token`` subprocess probe (see ``_try_gh_cli_token``)."""
hostname = os.getenv("COPILOT_GH_HOST", "").strip()
# gh must not short-circuit on GITHUB_TOKEN / GH_TOKEN, nor prompt from a backend process.
clean_env = {k: v for k, v in os.environ.items() if k not in {"GITHUB_TOKEN", "GH_TOKEN"}}
clean_env.setdefault("GH_PROMPT_DISABLED", "1")
clean_env.setdefault("GH_NO_UPDATE_NOTIFIER", "1")
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
host_args = ["--hostname", hostname] if hostname else []
for gh_path in _gh_cli_candidates():
cmd = [gh_path, "auth", "token", *host_args]
try:
result = subprocess.run(cmd, capture_output=True, text=True, encoding='utf-8',
errors='replace', timeout=5, env=clean_env,
stdin=subprocess.DEVNULL, **_popen_kwargs, check=False)
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
logger.debug("gh CLI token lookup failed (%s): %s", gh_path, exc)
continue
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
return None
_DEVICE_CODE_TERMINAL_ERRORS = {"expired_token": " ✗ Device code expired. Please try again.",
"access_denied": " ✗ Authorization was denied."}
def _post_form(url: str, fields: dict, timeout: float) -> dict:
req = urllib.request.Request(
url, data=urllib.parse.urlencode(fields).encode(),
headers={"Accept": "application/json", "User-Agent": "HermesAgent/1.0",
"Content-Type": "application/x-www-form-urlencoded"})
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode())
def copilot_device_code_login(
*, host: str = "github.com", timeout_seconds: float = 300) -> Optional[str]:
"""Run the GitHub OAuth device code flow for Copilot."""
domain = host.rstrip("/")
try:
device_data = _post_form(f"https://{domain}/login/device/code",
{"client_id": COPILOT_OAUTH_CLIENT_ID, "scope": "read:user"}, 15)
except Exception as exc:
logger.error("Failed to initiate device authorization: %s", exc)
print(f" ✗ Failed to start device authorization: {exc}")
return None
verification_uri = device_data.get("verification_uri", "https://github.com/login/device")
user_code = device_data.get("user_code", "")
device_code = device_data.get("device_code", "")
interval = max(device_data.get("interval", _DEVICE_CODE_POLL_INTERVAL), 1)
if not device_code or not user_code:
print(" ✗ GitHub did not return a device code.")
return None
print(f"\n Open this URL in your browser: {verification_uri}\n"
f" Enter this code: {user_code}\n")
print(" Waiting for authorization...", end="", flush=True)
poll_fields = {"client_id": COPILOT_OAUTH_CLIENT_ID, "device_code": device_code,
"grant_type": "urn:ietf:params:oauth:grant-type:device_code"}
deadline = time.monotonic() + timeout_seconds
while time.monotonic() < deadline:
time.sleep(interval + _DEVICE_CODE_POLL_SAFETY_MARGIN)
try:
result = _post_form(f"https://{domain}/login/oauth/access_token", poll_fields, 10)
except Exception:
print(".", end="", flush=True)
continue
if result.get("access_token"):
print(" ✓")
return result["access_token"]
error = result.get("error", "")
if error == "slow_down":
# RFC 8628: add 5 seconds to polling interval (or honor a server-supplied one)
server_interval = result.get("interval")
is_num = isinstance(server_interval, (int, float)) and server_interval > 0
interval = int(server_interval) if is_num else interval + 5
if error in ("authorization_pending", "slow_down"):
print(".", end="", flush=True)
continue
if error:
print("\n" + _DEVICE_CODE_TERMINAL_ERRORS.get(error,
f" ✗ Authorization failed: {error}"))
return None
print("\n ✗ Timed out waiting for authorization.")
return None
# In-process cache: raw_token_fingerprint -> (api_token, expires_at_epoch, base_url).
_jwt_cache: dict[str, tuple[str, float, Optional[str]]] = {}
_JWT_REFRESH_MARGIN_SECONDS = 120 # refresh 2 min before expiry
# Exchange endpoint and headers (matching VS Code / Copilot CLI)
_TOKEN_EXCHANGE_URL = "https://api.github.com/copilot_internal/v2/token"
_EDITOR_VERSION = "vscode/1.104.1"
_EXCHANGE_USER_AGENT = "GitHubCopilotChat/0.26.7"
# Transient-failure hardening: gateway startup races network readiness, and a single-shot
# exchange failing there silently degrades to the RAW GitHub token, whose integrator allowlist
# omits enterprise-only models → HTTP 400 every turn until restart. Retry, and persist the last
# good JWT so a restart during a blip reuses the still-valid ~30-min token.
_EXCHANGE_MAX_ATTEMPTS = 3
_EXCHANGE_BACKOFF_BASE_SECONDS = 1.5 # sleeps ~1.5s, ~3.0s between attempts
_JWT_DISK_FILENAME = ".copilot_jwt.json"
_JWT_DISK_MAX_BYTES = 1_048_576 # 1 MiB cap on the persisted JWT store read
# Negative cache: fingerprint -> epoch until which attempts raise immediately (success clears
# it). Without it a permanently-rejected token burned ~4.5s of retry backoff on EVERY
# provider-discovery pass (/model picker, delegation spawns, dashboard).
_exchange_failure_cache: dict[str, float] = {}
# Single-flight per fingerprint: concurrent callers (dashboard polls every few seconds) wait on
# the ONE in-flight exchange instead of each spawning a hung resolver thread during a DNS outage.
_exchange_locks: dict[str, threading.Lock] = {}
_exchange_locks_guard = threading.Lock()
def _exchange_lock_for(fp: str) -> threading.Lock:
with _exchange_locks_guard:
lock = _exchange_locks.get(fp)
if lock is None:
lock = _exchange_locks[fp] = threading.Lock()
return lock
_EXCHANGE_FAILURE_TTL_TRANSIENT_SECONDS = 60.0 # network blips: retry soon
_EXCHANGE_FAILURE_TTL_PERMANENT_SECONDS = 1800.0 # 401/403/404: won't heal
# The token itself is rejected — retrying with backoff just blocks the caller.
_EXCHANGE_PERMANENT_HTTP_STATUSES = frozenset({401, 403, 404})
def _token_fingerprint(raw_token: str) -> str:
"""Short fingerprint of a raw token for cache keying (avoids storing full token)."""
return hashlib.sha256(raw_token.encode()).hexdigest()[:16]
def _read_jwt_store(path: Path) -> Optional[dict]:
"""Bounded read of the on-disk JWT store → dict, or None if missing/unusable (a store over
the 1 MiB cap or non-dict can't balloon memory or get rewritten back out)."""
if not path.exists():
return None
try:
if path.stat().st_size > _JWT_DISK_MAX_BYTES:
logger.debug("Persisted Copilot JWT store exceeds %d bytes; ignoring",
_JWT_DISK_MAX_BYTES)
return None
loaded = json.loads(path.read_text(encoding="utf-8-sig"))
return loaded if isinstance(loaded, dict) else None
except Exception as exc:
logger.debug("Failed to read persisted Copilot JWT store: %s", exc)
return None
def _jwt_disk_path() -> Optional[Path]:
"""Path to the on-disk exchanged-JWT cache (profile-aware), or None."""
try:
from hermes_constants import get_hermes_home
return Path(get_hermes_home()) / _JWT_DISK_FILENAME
except Exception:
return None
def _with_jwt_store(verb: str, op):
"""Run ``op(path, store_or_None)`` against the disk store; failures are logged, never raised."""
path = _jwt_disk_path()
if not path:
return None
try:
return op(path, _read_jwt_store(path))
except Exception as exc:
logger.debug("Failed to %s Copilot JWT: %s", verb, exc)
return None
def evict_cached_exchanged_token(raw_token: str) -> None:
"""Drop any cached exchanged JWT for ``raw_token`` (in-process + on-disk) — the runtime
stale-credential recovery path for ``model_not_available_for_integrator`` 400s."""
if not raw_token:
return
fp = _token_fingerprint(raw_token)
_jwt_cache.pop(fp, None)
# Eviction = "force a fresh exchange": the negative-cache entry must go too.
_exchange_failure_cache.pop(fp, None)
def _evict(path, store):
if store is not None and fp in store:
del store[fp]
atomic_json_write(path, store, indent=None, mode=0o600)
_with_jwt_store("evict cached", _evict)
def _load_jwt_from_disk(fp: str) -> Optional[tuple[str, float, Optional[str]]]:
"""Persisted exchanged JWT for ``fp`` → (api_token, expires_at, base_url), or None."""
def _load(path, store):
entry = (store or {}).get(fp)
if not isinstance(entry, dict):
return None
api_token = entry.get("api_token", "")
expires_at = float(entry.get("expires_at", 0) or 0)
return (api_token, expires_at, entry.get("base_url")) if api_token and expires_at else None
return _with_jwt_store("load persisted", _load)
def _save_jwt_to_disk(fp: str, api_token: str, expires_at: float, base_url: Optional[str]) -> None:
"""Persist an exchanged JWT (0o600), pruning expired entries."""
def _save(path, store):
now = time.time()
kept = {
k: v for k, v in (store or {}).items()
if isinstance(v, dict) and float(v.get("expires_at", 0) or 0) > now}
kept[fp] = {"api_token": api_token, "expires_at": expires_at, "base_url": base_url}
atomic_json_write(path, kept, indent=None, mode=0o600)
_with_jwt_store("persist", _save)
# urllib's ``timeout`` only bounds socket ops AFTER DNS; getaddrinfo ignores it, so a networkless
# Windows host can hang for minutes (observed: a 17-minute event-loop stall).
_DNS_GRACE_SECONDS = 5.0
def _urlopen_bounded(req, timeout: float):
"""urlopen() on a daemon thread, abandoned after timeout + _DNS_GRACE_SECONDS so a
DNS/getaddrinfo hang cannot block the caller. Raises the worker's exception or TimeoutError."""
box: dict = {}
abandoned = threading.Event()
def _worker() -> None:
try:
resp = urllib.request.urlopen(req, timeout=timeout)
except BaseException as exc: # re-raised on the caller's thread
box["exc"] = exc
return
if abandoned.is_set(): # caller already timed out — release the socket
with contextlib.suppress(Exception):
resp.close()
return
box["resp"] = resp
t = threading.Thread(target=_worker, name="copilot-token-exchange", daemon=True)
t.start()
t.join(timeout + _DNS_GRACE_SECONDS)
if t.is_alive():
abandoned.set()
raise TimeoutError("copilot token exchange exceeded hard cap of "
f"{timeout + _DNS_GRACE_SECONDS:.0f}s (DNS/getaddrinfo hang?)")
if "exc" in box:
raise box["exc"]
if "resp" not in box:
raise TimeoutError("copilot token exchange worker died without result")
return box["resp"]
def _fetch_exchange_with_retry(req, timeout: float, fp: str) -> dict:
"""GET the exchange with backoff for startup network races; raises ValueError on failure.
Permanent rejections (401/403/404) skip the retry loop. Failures populate the negative
cache (long TTL for permanent, short for transient); success clears it.
"""
last_exc: Optional[Exception] = None
permanent_failure = False
for attempt in range(1, _EXCHANGE_MAX_ATTEMPTS + 1):
try:
with _urlopen_bounded(req, timeout) as resp:
data = json.loads(resp.read().decode())
_exchange_failure_cache.pop(fp, None)
return data
except Exception as exc:
last_exc = exc
status = getattr(exc, "code", None) or getattr(exc, "status", None)
permanent_failure = status in _EXCHANGE_PERMANENT_HTTP_STATUSES
if permanent_failure:
logger.debug("Copilot token exchange rejected (HTTP %s); not retrying", status)
break
if attempt < _EXCHANGE_MAX_ATTEMPTS:
sleep_s = _EXCHANGE_BACKOFF_BASE_SECONDS * attempt
logger.debug("Copilot token exchange attempt %d/%d failed (%s); retrying in %.1fs",
attempt, _EXCHANGE_MAX_ATTEMPTS, exc, sleep_s)
time.sleep(sleep_s)
_exchange_failure_cache[fp] = time.time() + (
_EXCHANGE_FAILURE_TTL_PERMANENT_SECONDS if permanent_failure
else _EXCHANGE_FAILURE_TTL_TRANSIENT_SECONDS)
raise ValueError(f"Copilot token exchange failed after {_EXCHANGE_MAX_ATTEMPTS} attempts: "
f"{last_exc}") from last_exc
def _cache_entry_fresh(cached) -> bool:
return bool(cached) and time.time() < cached[1] - _JWT_REFRESH_MARGIN_SECONDS
def exchange_copilot_token(
raw_token: str, *, timeout: float = 10.0) -> tuple[str, float, Optional[str]]:
"""Exchange a raw GitHub token for a Copilot API token → (token, expires_at, base_url).
The token is a semicolon-separated string (not a JWT) used as a Bearer token. ``base_url``
is the account-specific host: the exchange's ``endpoints.api`` (enterprise/proxied
accounts), else derived from the token's ``proxy-ep``; individual accounts have neither,
so it is None. Cached in-process until close to expiry. Raises ``ValueError`` on failure.
"""
fp = _token_fingerprint(raw_token)
# Fast paths outside the lock: a valid in-process JWT needs no exchange, and a recent failure
# means queueing behind the in-flight holder (up to ~50 s) would only park an executor thread
# to learn the same answer.
cached = _jwt_cache.get(fp)
if _cache_entry_fresh(cached):
return cached
_fail_until = _exchange_failure_cache.get(fp, 0.0)
if time.time() < _fail_until:
raise ValueError("Copilot token exchange recently failed; skipping re-attempt "
f"for another {int(_fail_until - time.time())}s")
# Note: a waiter's own ``timeout`` is not honoured across the lock wait — by design of
# single-flight, it observes the holder's outcome instead.
with _exchange_lock_for(fp):
return _exchange_copilot_token_locked(raw_token, fp, timeout=timeout)
def _exchange_copilot_token_locked(
raw_token: str, fp: str, *, timeout: float) -> tuple[str, float, Optional[str]]:
# Re-check in-process under the lock (a queued-behind caller may have just exchanged), then
# on-disk: a fresh process may hold a still-valid persisted JWT, avoiding a network
# round-trip precisely when the network is most likely flaky.
for lookup in (_jwt_cache.get, _load_jwt_from_disk):
cached = lookup(fp)
if _cache_entry_fresh(cached):
_jwt_cache[fp] = cached
return cached
# Negative cache: fail fast so provider discovery / picker opens don't block.
_fail_until = _exchange_failure_cache.get(fp, 0.0)
if time.time() < _fail_until:
raise ValueError("Copilot token exchange recently failed; skipping re-attempt "
f"for another {int(_fail_until - time.time())}s")
req = urllib.request.Request(
_TOKEN_EXCHANGE_URL, method="GET",
headers={"Authorization": f"token {raw_token}", "User-Agent": _EXCHANGE_USER_AGENT,
"Accept": "application/json", "Editor-Version": _EDITOR_VERSION})
data = _fetch_exchange_with_retry(req, timeout, fp)
api_token = data.get("token", "")
if not api_token:
raise ValueError("Copilot token exchange returned empty token")
expires_at = float(data.get("expires_at") or 0) or time.time() + 1800
# ``endpoints.api`` is authoritative (Copilot Enterprise / proxied accounts); else derive from
# the token's ``proxy-ep``. Individual accounts have neither → None (registry default).
endpoints = data.get("endpoints")
base_url: Optional[str] = (
str(endpoints.get("api") or "").strip().rstrip("/") if isinstance(endpoints, dict) else ""
) or _derive_base_url_from_proxy_ep(api_token)
_jwt_cache[fp] = (api_token, expires_at, base_url)
_save_jwt_to_disk(fp, api_token, expires_at, base_url)
logger.debug("Copilot token exchanged, expires_at=%s, base_url=%s", expires_at, base_url)
return api_token, expires_at, base_url
def _derive_base_url_from_proxy_ep(token: str) -> Optional[str]:
"""Copilot API base URL from the token's ``proxy-ep=proxy.<host>`` field (→ ``api.``)."""
m = re.search(r'(?:^|;)\s*proxy-ep=([^;\s]+)', token)
if not m:
return None
proxy_ep = re.sub(r"^https?://", "", m.group(1), count=1).rstrip("/")
proxy_ep = re.sub(r"^proxy\.", "api.", proxy_ep, count=1)
return f"https://{proxy_ep}"
def get_copilot_api_token(raw_token: str) -> tuple[str, Optional[str]]:
"""``(api_token, base_url)`` from the exchange, or ``(raw_token, None)`` when it fails
(accounts that don't need exchange keep working)."""
if not raw_token:
return raw_token, None
try:
api_token, _, base_url = exchange_copilot_token(raw_token)
return api_token, base_url
except Exception as exc:
logger.debug("Copilot token exchange failed, using raw token: %s", exc)
return raw_token, None
def copilot_request_headers(
*, is_agent_turn: bool = True, is_vision: bool = False) -> dict[str, str]:
"""Build the standard headers for Copilot API requests."""
headers: dict[str, str] = {"Editor-Version": _EDITOR_VERSION, "User-Agent": "HermesAgent/1.0",
"Copilot-Integration-Id": "vscode-chat",
"Openai-Intent": "conversation-edits",
"x-initiator": "agent" if is_agent_turn else "user"}
if is_vision:
headers["Copilot-Vision-Request"] = "true"
return headers
# ── ProviderProfile credential seams ────────────────────────────────────────────────────────────
# Warn once per token per process when the exchange degrades to the raw token (#114740).
_RAW_DEGRADATION_WARNED: set[str] = set()
def _warn_raw_degradation_once(token: str) -> None:
"""WARN once per token per process when Copilot exchange degrades to raw token (#114740)."""
from agent.credential_persistence import fingerprint_secret_value
fingerprint = fingerprint_secret_value(token) or "unknown"
if fingerprint in _RAW_DEGRADATION_WARNED:
return
_RAW_DEGRADATION_WARNED.add(fingerprint)
logger.warning(
"Copilot token exchange degraded to RAW token (exchange "
"unavailable); enterprise-only models may 400 with "
"model_not_available_for_integrator until exchange recovers."
)
def _reset_raw_degradation_warned() -> None:
"""Clear the degradation warning cache (for test isolation)."""
_RAW_DEGRADATION_WARNED.clear()
# Every pool source the same token can be seeded under (gh_cli + one per env var).
_POOL_SOURCES = ("gh_cli",) + tuple(f"env:{v}" for v in COPILOT_ENV_VARS)
_STALE_CREDENTIAL_MARKERS = (
"model_not_available_for_integrator",
"not available for integrator",
"model_not_supported",
"the requested model is not supported",
)
_GITHUB_MODELS_FREE_TIER_HOST = "models.inference.ai.azure.com"
_GITHUB_MODELS_HINT = (
" 💡 GitHub Models free tier (models.inference.ai.azure.com) caps every",
" request at ~8K tokens. Hermes' system prompt + tool schemas baseline",
" exceeds that floor, so this endpoint cannot run an agentic loop.",
" Use the `copilot` provider with a Copilot subscription token (`hermes",
" setup` → GitHub Copilot), or pick any other provider.",
)
def _refresh_exchanged_token() -> Optional[tuple[str, Optional[str]]]:
"""401 on the main client: the raw GitHub token is stable, the short-TTL *exchanged* IDE token is
what expires mid-turn (``401 IDE token expired``), so force a fresh exchange; fall back to the
raw token only if the exchange itself is unavailable."""
try:
new_token, token_source = resolve_copilot_token()
except Exception as exc: # health: allow BLE001 -- moved verbatim from core (hermes_cli/copilot_auth.py, credential_pool, client_lifecycle, auth.py)
logger.debug("Copilot credential refresh failed: %s", exc)
return None
new_token, enterprise_base_url = (new_token.strip() if isinstance(new_token, str) else ""), None
if not new_token:
return None
try:
evict_cached_exchanged_token(new_token)
api_token, exchanged_base_url = get_copilot_api_token(new_token)
if isinstance(api_token, str) and api_token.strip():
new_token, enterprise_base_url = api_token.strip(), exchanged_base_url
except Exception as exc: # health: allow BLE001 -- moved verbatim from core (hermes_cli/copilot_auth.py, credential_pool, client_lifecycle, auth.py)
logger.debug("Copilot 401 re-exchange failed, using resolved token: %s", exc)
logger.info("Copilot credentials refreshed from %s", token_source)
return new_token, enterprise_base_url
def _reexchange_stale_token() -> Optional[tuple[str, Optional[str]]]:
"""Stale-credential 400 (``model_not_available_for_integrator``): typically a raw ``ghu_`` token
cached when the startup exchange degraded. Evict + re-exchange; None when still degraded (a
rebuild would not help, so the caller's single-shot retry is not burned)."""
try:
raw_token, token_source = resolve_copilot_token()
if not isinstance(raw_token, str) or not raw_token.strip():
return None
raw_token = raw_token.strip()
evict_cached_exchanged_token(raw_token)
api_token, enterprise_base_url = get_copilot_api_token(raw_token)
except Exception as exc: # health: allow BLE001 -- moved verbatim from core (hermes_cli/copilot_auth.py, credential_pool, client_lifecycle, auth.py)
logger.debug("Copilot stale-credential recovery failed: %s", exc)
return None
if not isinstance(api_token, str) or not api_token.strip():
return None
if api_token == raw_token and not enterprise_base_url:
logger.warning(
"Copilot stale-credential recovery: exchange still degraded to raw token; skipping retry "
"(network/exchange endpoint unavailable)."
)
return None
logger.info("Copilot credentials re-exchanged after stale-credential 400 (source=%s)", token_source)
return api_token.strip(), enterprise_base_url
def _revalidate_in_memory_token() -> Optional[tuple[str, Optional[str]]]:
"""Auxiliary-client 401: drop only the in-process JWT and exchange again (a persisted, still-valid
JWT and the negative cache are honoured); raises when the exchange fails."""
raw_token, _source = resolve_copilot_token()
if not str(raw_token or "").strip():
return None
_jwt_cache.pop(_token_fingerprint(raw_token), None)
api_token, _expires_at, base_url = exchange_copilot_token(raw_token)
return api_token, base_url
_RUNTIME_REFRESHERS = {
"unauthorized": _refresh_exchanged_token,
"stale": _reexchange_stale_token,
"auxiliary_unauthorized": _revalidate_in_memory_token,
}
class CopilotCredentialsMixin:
"""Fills the generic credential seams of ``providers.base.ProviderProfile`` for Copilot."""
def resolve_api_key_secret(self) -> tuple[str, str]:
try:
token, source = resolve_copilot_token()
if token:
api_token, _base_url = get_copilot_api_token(token)
return api_token, source
except ValueError as exc:
logger.warning("Copilot token validation failed: %s", exc)
except Exception: # health: allow BLE001 S110 -- moved verbatim from core (hermes_cli/copilot_auth.py, credential_pool, client_lifecycle, auth.py)
pass
return "", ""
def runtime_base_url(self, *, api_key: str, default: str, env_url: str) -> str:
"""The API base comes from the token-exchange response (endpoints.api, proxy-ep fallback),
authoritative for Enterprise / proxied accounts; falls back to the registry default."""
base_url = env_url.rstrip("/") if env_url else default
try:
raw_token, _ = resolve_copilot_token()
if raw_token:
resolved = (get_copilot_api_token(raw_token)[1] or "").strip()
if resolved:
base_url = resolved
except Exception as exc: # health: allow BLE001 -- moved verbatim from core (hermes_cli/copilot_auth.py, credential_pool, client_lifecycle, auth.py)
logger.debug("Copilot base URL resolution fell back to default: %s", exc)
return base_url
def prepare_pool_entry(self, entry: Any, api_key: str) -> str:
"""A pool row may still carry the RAW GitHub token: the seeder skips the exchange while
copilot is merely discovered (ambient gh login, not in config). Here copilot IS the runtime
target, and a raw token routes to the language-server integrator whose allowlist omits
enterprise-only models (400 model_not_available_for_integrator)."""
if not api_key or not validate_copilot_token(api_key)[0]:
return api_key # already an exchanged API token
api_token, enterprise_base_url = get_copilot_api_token(api_key)
if api_token == api_key and not enterprise_base_url:
_warn_raw_degradation_once(api_key)
return api_key
entry.access_token = api_token
if enterprise_base_url:
entry.base_url = enterprise_base_url
return api_token
def seed_pool_credentials(self, *, is_suppressed: Callable[[str], bool],
explicitly_configured: Callable[[], bool]) -> list[dict]:
# Tokens are resolved dynamically via env vars or `gh auth token`; they don't live in the
# auth store. All-sources gate BEFORE any work: resolve_copilot_token() shells out and the
# exchange retries 3x with backoff (~35s worst case); a user who suppressed every copilot
# source must not pay that on every pool load.
try:
if all(is_suppressed(s) for s in _POOL_SOURCES):
return []
token, source = resolve_copilot_token()
if not token:
return []
# Exact match: a substring test would classify GH_TOKEN/GITHUB_TOKEN as gh_cli and
# bypass a user's per-env-var suppression.
source_name = "gh_cli" if source == "gh auth token" else f"env:{source}"
if is_suppressed(source_name): # per-source gate BEFORE the network exchange
return []
if not explicitly_configured():
# Only discovered here (ambient gh CLI login), not selected anywhere: no model is
# routed to it, so the exchange — and its degradation warning on every pool load —
# buys nothing (#114740). Seed the raw token; selecting copilot re-seeds and exchanges.
api_token, enterprise_base_url = token, None
else:
api_token, enterprise_base_url = get_copilot_api_token(token)
# A failed exchange falls back to the RAW token, which the Copilot API routes to the
# "copilot-language-server" integrator whose allowlist omits enterprise-only models
# -> HTTP 400 on every turn. Surface it once.
if api_token == token and not enterprise_base_url:
_warn_raw_degradation_once(token)
return [{"source": source_name, "access_token": api_token, "base_url": enterprise_base_url,
"label": source}]
except Exception as exc: # health: allow BLE001 -- moved verbatim from core (hermes_cli/copilot_auth.py, credential_pool, client_lifecycle, auth.py)
logger.debug("Copilot token seed failed: %s", exc)
return []
def remove_credential_source(self, source: str) -> Optional[tuple[tuple[str, ...], tuple[str, ...]]]:
"""The same token is seeded as gh_cli AND env:<VAR> rows, so suppress every variant or the
duplicates resurrect the entry. gh CLI and shell state are left untouched."""
if source != "gh_cli" and not source.startswith("env:"):
return None
return _POOL_SOURCES, (
"Suppressed all copilot token sources (gh_cli + env vars) — they will not be re-seeded.",
"Note: Your gh CLI / shell environment is unchanged.",
"Run `hermes auth add copilot` to re-enable if needed.",
)
def refresh_runtime_credentials(self, *, reason: str) -> Optional[tuple[str, Optional[str]]]:
refresher = _RUNTIME_REFRESHERS.get(reason)
return refresher() if refresher else None
def credential_refresh_notice(self, *, reason: str) -> str:
if reason == "stale":
return "Copilot credential re-exchanged after model_not_available 400. Retrying request..."
return "Copilot credentials refreshed after 401. Retrying request..." if reason == "unauthorized" else ""
def is_stale_credential_error(self, *, status_code: Optional[int], message: str) -> bool:
"""A 400 that is really a STALE / DEGRADED credential (status 400 AND an integrator /
model-not-supported marker, so a wrong model name never triggers the re-exchange)."""
lowered = (message or "").lower()
if status_code != 400 and "error code: 400" not in lowered:
return False
return any(marker in lowered for marker in _STALE_CREDENTIAL_MARKERS)
def request_rejection_hint(self, *, status_code: Optional[int], base_url: str) -> tuple[str, ...]:
"""GitHub Models free tier caps requests at 8K tokens, under the system prompt + tool schema
floor; compression can't help, so say so."""
from utils import base_url_host_matches
if status_code == 413 and base_url_host_matches(base_url, _GITHUB_MODELS_FREE_TIER_HOST):
return _GITHUB_MODELS_HINT
return ()
def copilot_cli_auth_evidence() -> tuple[bool, Optional[str]]:
"""Copilot CLI token stores readable without spawning it (env tokens, plaintext config, hosts.json)."""
# 1. Supported env tokens — the same vars the Copilot CLI itself honors.
try:
for env_var in COPILOT_ENV_VARS:
val = os.getenv(env_var, "").strip()
if val and validate_copilot_token(val)[0]:
return True, f"env: {env_var}"
except Exception as exc: # health: allow BLE001 -- moved verbatim from core (hermes_cli/copilot_auth.py, credential_pool, client_lifecycle, auth.py)
logger.debug("copilot-acp env token evidence check failed: %s", exc)
# 2. The Copilot CLI's own plaintext token store (written by `copilot login` when no OS keychain
# is available). The file is JSONC — strip //-comment lines before parsing.
try:
cli_config = os.path.expanduser("~/.copilot/config.json")
if os.path.isfile(cli_config):
with open(cli_config, "r", encoding="utf-8-sig", errors="ignore") as fh:
raw = "\n".join(
line for line in fh.read().splitlines() if not line.lstrip().startswith("//"))
tokens = (json.loads(raw) if raw.strip() else {}).get("copilotTokens")
if isinstance(tokens, dict) and any(
isinstance(v, str) and v.strip() for v in tokens.values()):
return True, "~/.copilot/config.json"
except Exception as exc: # health: allow BLE001 -- moved verbatim from core (hermes_cli/copilot_auth.py, credential_pool, client_lifecycle, auth.py)
logger.debug("copilot-acp CLI config evidence check failed: %s", exc)
# 3. Known on-disk GitHub Copilot credential stores (the same files models.py fingerprints).
for cred_path in ("~/.config/github-copilot/hosts.json", "~/.config/github-copilot/apps.json"):
try:
expanded = os.path.expanduser(cred_path)
if os.path.isfile(expanded) and os.path.getsize(expanded) > 2:
return True, cred_path
except OSError:
continue
return False, None
-20
View File
@@ -1,20 +0,0 @@
"""GitHub Copilot's place in the provider tables: picker row, label, group, aliases, models.dev overlay.
Core reads these only through generic ``ProviderProfile`` fields (``PICKER_FIELDS``).
"""
# Both Copilot providers fold under one "GitHub Copilot" row in the interactive pickers.
PICKER_GROUP = ("copilot", "GitHub Copilot", "GitHub token API or copilot --acp process")
PICKER_FIELDS = dict(
display_name="GitHub Copilot",
description="GitHub Copilot (Uses GITHUB_TOKEN or gh auth token)",
picker_after="nvidia",
picker_group=PICKER_GROUP,
catalog_aliases=True,
# The Copilot catalog serves OpenAI, Anthropic, Google and xAI models.
aggregator=True,
# models.dev lists GitHub Copilot under GITHUB_TOKEN only; Hermes also reads these two.
provider_overlay={"extra_env_vars": ("COPILOT_GITHUB_TOKEN", "GH_TOKEN")},
overlay_aliases=("github",),
)
@@ -1,5 +0,0 @@
name: copilot-provider
kind: model-provider
version: 1.0.0
description: GitHub Copilot
author: Nous Research
@@ -1,62 +0,0 @@
"""Copilot / GitHub Models wire quirks on the Responses route, reasoning payloads and reasoning carriers.
Fills the generic ``ProviderProfile`` endpoint seams (providers/base.py, lane L3 block). Everything here
is called per request or per replayed message, so it is pure and answers from static data / caches.
"""
from __future__ import annotations
from typing import Any
from utils import base_url_host_matches
_COPILOT_HOST = "githubcopilot.com" # api.githubcopilot.com plus the api.<plan>.githubcopilot.com variants
# Constructor kwargs for the profile's L3 fields. ``github_responses`` is persisted on encrypted reasoning
# items in session history: renaming it orphans every stored blob (one cold cache + dropped reasoning on resume).
RESPONSES_PROFILE_FIELDS: dict[str, Any] = {
# The backend binds replayed item ids to a connection; a stale id 401s even when short (#32716).
"responses_replay_item_ids": False,
"responses_prompt_cache_key": False,
"responses_issuer_kind": "github_responses",
"responses_trusts_completed_status": False,
"responses_native_compaction": False,
# Reasoning text only comes back when ``summary: auto`` is sent (#46527); ``include`` is never sent.
"responses_profile_reasoning": True,
"message_carrier_opaque": "reasoning_opaque",
"message_carrier_text": "reasoning_text",
# Copilot's Gemini models take tool-call ``extra_content`` signatures back like the native Gemini API.
"replays_tool_call_signatures": True,
}
def _is_copilot_host(base_url: str) -> bool:
return base_url_host_matches(str(base_url or ""), _COPILOT_HOST)
class CopilotResponsesMixin:
"""Endpoint ownership, catalog-clamped reasoning payloads and the ``/chat/completions`` carriers."""
def reasoning_payload(self, model: str | None, reasoning_config: dict | None, *, base_url: str) -> dict | None:
from .catalog import clamp_github_reasoning_effort, github_model_reasoning_efforts
supported = github_model_reasoning_efforts(model)
if not supported:
return None
cfg = reasoning_config if isinstance(reasoning_config, dict) else {}
if cfg.get("enabled") is False:
return None
return {"effort": clamp_github_reasoning_effort(cfg.get("effort"), supported)}
def replay_message_carriers(self, values: dict[str, Any], *, model: str | None, base_url: str) -> dict[str, Any]:
# The editor clients replay reasoning_text only next to the opaque blob it belongs to.
if not _is_copilot_host(base_url) or not values.get("reasoning_opaque"):
return {}
out = {"reasoning_opaque": values["reasoning_opaque"]}
if values.get("reasoning_text"):
out["reasoning_text"] = values["reasoning_text"]
return out
def replays_function_thought_signature(self, *, model: str | None, base_url: str) -> bool:
# Copilot's Gemini 3 variant signs tool calls inside ``function`` and expects them back.
return _is_copilot_host(base_url)
-222
View File
@@ -1,222 +0,0 @@
"""GitHub Copilot setup and sign-in surfaces: the ``hermes model`` flow, the setup effort picker,
and the Desktop Accounts card with its in-app GitHub device-code login.
Core reaches these only through the generic ``ProviderProfile`` setup fields (``SETUP_FIELDS``).
"""
from __future__ import annotations
import contextlib
from typing import Any
# Device-code endpoints live on the GitHub web host; tests point this at a local fake.
_DEVICE_LOGIN_BASE = "https://github.com"
_DEVICE_VERIFICATION_URL = "https://github.com/login/device"
_DEVICE_CODE_EXPIRES_IN = 900 # GitHub's documented device-code lifetime when the reply omits it
_TOKEN_ENV_VAR = "COPILOT_GITHUB_TOKEN"
_DEVICE_ERRORS = {"expired_token": ("expired", "Device code expired. Please try again."),
"access_denied": ("denied", "Authorization was denied.")}
def _copilot_model_list(live_ids) -> list:
"""Live GitHub Copilot ids, or the curated fallback with a warning."""
from hermes_cli.model_setup_flows_common import _say
from hermes_cli.models import _PROVIDER_MODELS
if live_ids:
model_list = [model_id for model_id in live_ids if model_id]
print(f" Found {len(model_list)} model(s) from GitHub Copilot")
return model_list
model_list = _PROVIDER_MODELS.get("copilot", [])
if model_list:
_say(" ⚠ Could not auto-detect models from GitHub Copilot — showing defaults.",
' Use "Enter custom model name" if you do not see your model.')
return model_list
def _copilot_catalog(api_key: str):
"""``(catalog, catalog_ids, normalize)`` for a GitHub token; *normalize* canonicalizes a
model id against the catalog (identity when unknown)."""
from .catalog import fetch_github_model_catalog, normalize_copilot_model_id
catalog = fetch_github_model_catalog(api_key)
ids = [item.get("id", "") for item in catalog if item.get("id")] if catalog else []
def _normalize(mid):
return normalize_copilot_model_id(mid, catalog=catalog, api_key=api_key) or mid
return catalog, ids, _normalize
def _copilot_obtain_token() -> bool:
"""No Copilot token yet: offer device-code login or manual entry. False = stop."""
from hermes_cli.config import save_env_value
from hermes_cli.model_setup_flows_common import _ask, _note_setup_failure, _say
_say("No GitHub token configured for GitHub Copilot.", "", " Supported token types:",
" → OAuth token (gho_*) via `copilot login` or device code flow",
" → Fine-grained PAT (github_pat_*) with Copilot Requests permission",
" → GitHub App token (ghu_*) via environment variable",
" ✗ Classic PAT (ghp_*) NOT supported by Copilot API", "", " Options:",
" 1. Login with GitHub (OAuth device code flow)", " 2. Enter a token manually", " 3. Cancel", "")
choice = _ask(" Choice [1-3]: ", raw=True, cancel_msg="")
if choice is None:
return False
if choice == "1":
try:
from .credentials import copilot_device_code_login
token = copilot_device_code_login()
if not token:
print(" Login cancelled or failed.")
return False
save_env_value(_TOKEN_ENV_VAR, token)
_say(" Copilot token saved.", "")
except Exception as exc:
print(f" Login failed: {exc}")
_note_setup_failure("auth")
return False
return True
if choice == "2":
new_key = _ask(" Token (COPILOT_GITHUB_TOKEN): ", secret=True, cancel_msg="")
if new_key is None:
return False
if not new_key:
print(" Cancelled.")
return False
# Validate token type
with contextlib.suppress(ImportError):
from .credentials import validate_copilot_token
valid, msg = validate_copilot_token(new_key)
if not valid:
print(f" ✗ {msg}")
return False
save_env_value(_TOKEN_ENV_VAR, new_key)
_say(" Token saved.", "")
return True
print(" Cancelled.")
return False
def model_flow_copilot(config, current_model="", args=None):
"""GitHub Copilot flow using env vars, gh CLI, or OAuth device code. The reasoning-effort step
is the shared post-pick one in ``select_provider_and_model`` (Copilot's per-model level set
comes from ``setup_reasoning_efforts`` there)."""
from hermes_cli.auth import PROVIDER_REGISTRY, resolve_api_key_provider_credentials
from hermes_cli.model_setup_flows_common import _persist_model, _pick_model_or_prompt, _say
from hermes_cli.models import fetch_api_models
from .wire import model_api_mode
del config, args
provider_id = "copilot"
pconfig = PROVIDER_REGISTRY[provider_id]
creds = resolve_api_key_provider_credentials(provider_id)
api_key = creds.get("api_key", "")
source = creds.get("source", "")
if not api_key:
if not _copilot_obtain_token():
return
creds = resolve_api_key_provider_credentials(provider_id)
api_key = creds.get("api_key", "")
else:
if source in {"GITHUB_TOKEN", "GH_TOKEN"}:
from hermes_cli.env_loader import format_secret_source_suffix
_say(f" GitHub token: {api_key[:8]}... ✓ ({source}{format_secret_source_suffix(source)})", "")
else:
_say(" GitHub token: ✓ (from `gh auth token`)" if source == "gh auth token" else " GitHub token: ✓", "")
effective_base = pconfig.inference_base_url
catalog, live_models, _normalize = _copilot_catalog(api_key)
if not catalog:
live_models = fetch_api_models(api_key, effective_base)
selected = _pick_model_or_prompt(
_copilot_model_list(live_models), "Model name: ", current_model=_normalize(current_model),
confirm_provider=provider_id, confirm_base_url=effective_base, confirm_api_key=api_key)
if not selected:
print("No change.")
return
selected = _normalize(selected)
_persist_model(selected, provider_id, base_url=effective_base,
api_mode=model_api_mode(selected, catalog=catalog, api_key=api_key))
print(f"Default model set to: {selected} (via {pconfig.name})")
def setup_reasoning_efforts(model: str) -> tuple:
"""Copilot publishes a per-model effort set; ``()`` when the model takes none."""
from .catalog import github_model_reasoning_efforts
return tuple(github_model_reasoning_efforts(model) or ())
# ── Desktop Accounts card + in-app device-code login ──────────────────────────
def account_status() -> dict[str, Any]:
"""Card status from the env-token ladder only (no ``gh`` subprocess, no token exchange: this runs
on every Accounts list). A token here is managed on the Keys tab, so it reads as ``env_var``."""
from hermes_cli.config import get_env_value
from .credentials import COPILOT_ENV_VARS, validate_copilot_token
from hermes_cli.env_loader import format_secret_source_suffix
for var in COPILOT_ENV_VARS:
token = (get_env_value(var) or "").strip()
if token and validate_copilot_token(token)[0]:
return {"logged_in": True, "source": "env_var", "source_label": f"{var}{format_secret_source_suffix(var)}",
"token_preview": None, "expires_at": None, "has_refresh_token": False}
return {"logged_in": False, "source": None}
def start_device_login() -> dict[str, Any]:
"""Ask GitHub for a device code (the same OAuth app as ``copilot_device_code_login``)."""
from .credentials import (
COPILOT_OAUTH_CLIENT_ID, _DEVICE_CODE_POLL_INTERVAL, _DEVICE_CODE_POLL_SAFETY_MARGIN, _post_form)
data = _post_form(f"{_DEVICE_LOGIN_BASE}/login/device/code",
{"client_id": COPILOT_OAUTH_CLIENT_ID, "scope": "read:user"}, 15)
device_code, user_code = data.get("device_code", ""), data.get("user_code", "")
if not device_code or not user_code:
raise RuntimeError("GitHub did not return a device code.")
interval = max(int(data.get("interval") or _DEVICE_CODE_POLL_INTERVAL), 1)
return {"user_code": user_code, "verification_url": data.get("verification_uri") or _DEVICE_VERIFICATION_URL,
"expires_in": int(data.get("expires_in") or _DEVICE_CODE_EXPIRES_IN),
"interval": interval + _DEVICE_CODE_POLL_SAFETY_MARGIN,
"state": {"device_code": device_code, "interval": interval}}
def poll_device_login(state: dict[str, Any]) -> dict[str, Any]:
"""One token poll. A transport blip is just another pending tick, as in the terminal flow."""
from .credentials import COPILOT_OAUTH_CLIENT_ID, _DEVICE_CODE_POLL_SAFETY_MARGIN, _post_form
fields = {"client_id": COPILOT_OAUTH_CLIENT_ID, "device_code": state["device_code"],
"grant_type": "urn:ietf:params:oauth:grant-type:device_code"}
try:
result = _post_form(f"{_DEVICE_LOGIN_BASE}/login/oauth/access_token", fields, 10)
except (OSError, ValueError): # URLError/timeouts are OSError; a garbled body is ValueError
return {"status": "pending"}
if result.get("access_token"):
return {"status": "approved", "credential": result["access_token"]}
error = result.get("error", "")
if error == "slow_down":
# RFC 8628: add 5 seconds to polling interval (or honor a server-supplied one)
server_interval = result.get("interval")
if isinstance(server_interval, (int, float)) and server_interval > 0:
interval = int(server_interval)
else:
interval = int(state.get("interval") or 1) + 5
return {"status": "pending", "interval": interval + _DEVICE_CODE_POLL_SAFETY_MARGIN,
"state": {**state, "interval": interval}}
if not error or error == "authorization_pending":
return {"status": "pending"}
status, message = _DEVICE_ERRORS.get(error, ("error", f"Authorization failed: {error}"))
return {"status": status, "message": message}
def save_device_login(token: str) -> None:
"""Persist the GitHub OAuth token where the terminal device-code login puts it."""
from hermes_cli.config import save_env_value
save_env_value(_TOKEN_ENV_VAR, token)
SETUP_FIELDS: dict[str, Any] = {
"model_setup_flow": model_flow_copilot,
"setup_reasoning_efforts": setup_reasoning_efforts,
"account_card": {"name": "GitHub Copilot", "docs_url": "https://docs.github.com/en/copilot"},
"account_status": account_status,
"start_device_login": start_device_login,
"poll_device_login": poll_device_login,
"save_device_login": save_device_login,
}
-75
View File
@@ -1,75 +0,0 @@
"""GitHub Copilot wire quirks: per-model api_mode, editor/initiator/vision headers, max-tokens param.
Fills the generic ProviderProfile wire seams (``providers/base.py`` L1 block). Every hook here is
pure and answers from the arguments alone, except ``api_mode_for_model`` with an ``api_key``, which
may read the account's cached live catalog to normalize the model id first.
"""
from __future__ import annotations
import re
from typing import Any, Optional
from utils import base_url_host_matches, base_url_hostname
# Copilot's own API host family (individual, business and enterprise subdomains).
_COPILOT_HOST = "githubcopilot.com"
# Hosts that bill a request as premium when it carries ``x-initiator: user``.
_INITIATOR_HOSTS = ("api.githubcopilot.com", "models.github.ai")
def is_copilot_host(base_url: str) -> bool:
return base_url_host_matches(str(base_url or ""), _COPILOT_HOST)
def requires_responses_api(model_id: str) -> bool:
"""opencode's ``shouldUseCopilotResponsesApi``: GPT-5+ uses the Responses API except
``gpt-5-mini``; non-GPT models (Claude, Gemini, ...) use Chat Completions."""
match = re.match(r"^gpt-(\d+)", model_id)
return bool(match) and int(match.group(1)) >= 5 and not model_id.startswith("gpt-5-mini")
def model_api_mode(
model_id: Optional[str], *, catalog: Optional[list[dict[str, Any]]] = None,
api_key: Optional[str] = None) -> str:
"""API mode for a Copilot model from the id pattern (opencode's approach). Copilot's Claude models
go through its OpenAI-compatible chat endpoint, not the native Anthropic adapter: the catalog may
advertise /v1/messages but the Copilot token/header scheme lives in the OpenAI client path."""
from .catalog import fetch_github_model_catalog, normalize_copilot_model_id
if catalog is None and api_key: # fetch once so normalize + endpoint check share it
catalog = fetch_github_model_catalog(api_key=api_key)
normalized = normalize_copilot_model_id(model_id, catalog=catalog, api_key=api_key)
if normalized and requires_responses_api(normalized):
return "codex_responses"
return "chat_completions"
def editor_headers(*, is_vision: bool = False) -> dict[str, str]:
from .credentials import copilot_request_headers
return copilot_request_headers(is_agent_turn=True, is_vision=is_vision)
class CopilotWireMixin:
"""ProviderProfile wire hooks for the Copilot API."""
def api_mode_for_model(self, model: str, *, api_key: str | None = None) -> str | None:
if api_key is None:
return "codex_responses" if requires_responses_api(model) else "chat_completions"
return model_api_mode(model, api_key=api_key)
def client_headers(
self, *, base_url: str, api_key: str = "", has_images: bool = False,
) -> dict[str, str] | None:
return editor_headers(is_vision=has_images) if is_copilot_host(base_url) else None
def request_headers(self, *, base_url: str, model: str = "", user_initiated: bool = False) -> dict[str, str]:
# First call of a user turn bills premium; follow-ups keep the client default ``x-initiator: agent``.
if user_initiated and any(base_url_host_matches(base_url, h) for h in _INITIATOR_HOSTS):
return {"x-initiator": "user"}
return {}
def max_tokens_param(self, *, base_url: str, model: str = "") -> str | None:
host = base_url_hostname(str(base_url or "")) or ""
return "max_completion_tokens" if host.endswith("." + _COPILOT_HOST) else None
+9 -12
View File
@@ -3594,25 +3594,22 @@ class TestCodexAdapterGithubResponsesMessageIdDrop:
{"role": "user", "content": "continue"},
]
def test_drops_message_id_for_github_copilot_host(self):
adapter, captured = self._build_adapter(base_url="https://api.githubcopilot.com")
adapter.create(messages=self._replay_messages())
message_item = next(
item for item in captured["input"] if item.get("type") == "message"
)
def test_drops_message_id_for_connection_scoped_id_host(self):
from providers.base import ProviderProfile
acme = ProviderProfile(name="acme-replay", hostnames=("api.acme-replay.test",), responses_replay_item_ids=False)
adapter, captured = self._build_adapter(base_url="https://api.acme-replay.test")
with patch("providers.profile_for_host", side_effect=lambda url, **_kw: acme if "acme-replay" in str(url) else None):
adapter.create(messages=self._replay_messages())
message_item = next(item for item in captured["input"] if item.get("type") == "message")
assert "id" not in message_item
assert message_item["phase"] == "final_answer"
assert message_item["status"] == "in_progress"
assert message_item["content"] == [{"type": "output_text", "text": "pong"}]
def test_keeps_message_id_for_codex_backend_host(self):
adapter, captured = self._build_adapter(
base_url="https://chatgpt.com/backend-api/codex"
)
adapter, captured = self._build_adapter(base_url="https://chatgpt.com/backend-api/codex")
adapter.create(messages=self._replay_messages())
message_item = next(
item for item in captured["input"] if item.get("type") == "message"
)
message_item = next(item for item in captured["input"] if item.get("type") == "message")
assert message_item["id"] == "msg_short_but_connection_scoped"
-80
View File
@@ -14,9 +14,6 @@ runs when the main provider has no working client.
from __future__ import annotations
from unittest.mock import MagicMock, patch
import providers as _providers
_providers.get_provider_profile("copilot") # imports the copilot plugin package (credentials module)
from plugins.model_providers.copilot import credentials as copilot_credentials
# ── Text aux tasks — _resolve_auto_route ──────────────────────────────────────────
@@ -390,83 +387,6 @@ class TestResolveVisionMainFirst:
assert model == "tencent/hy3:free"
def test_copilot_vision_sets_vision_header(self, monkeypatch):
"""Copilot vision requests include the header required for vision routing."""
monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "ghu_test-token")
captured = {}
def fake_headers(*, is_agent_turn=False, is_vision=False):
captured["is_agent_turn"] = is_agent_turn
captured["is_vision"] = is_vision
return {"Copilot-Vision-Request": "true"} if is_vision else {}
with patch(
"agent.auxiliary_client._read_main_provider", return_value="copilot",
), patch(
"agent.auxiliary_client._read_main_model", return_value="configured-copilot-model",
), patch(
"agent.auxiliary_client._resolve_task_provider_model",
return_value=("auto", None, None, None, None),
), patch(
"agent.auxiliary_client.OpenAI",
) as mock_openai, patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
return_value={
"provider": "copilot",
"api_key": "copilot-api-token",
"base_url": "https://api.githubcopilot.com",
},
), patch.object(copilot_credentials, "copilot_request_headers",
side_effect=fake_headers,
):
mock_client = MagicMock()
mock_openai.return_value = mock_client
from agent.auxiliary_client import resolve_vision_provider_client
provider, client, model = resolve_vision_provider_client()
assert provider == "copilot"
assert client is mock_client
assert model == "configured-copilot-model"
assert captured == {"is_agent_turn": True, "is_vision": True}
assert mock_openai.call_args.kwargs["default_headers"]["Copilot-Vision-Request"] == "true"
def test_text_copilot_does_not_set_vision_header(self, monkeypatch):
"""Text Copilot requests keep the vision-only header off."""
monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "ghu_test-token")
captured = {}
def fake_headers(*, is_agent_turn=False, is_vision=False):
captured["is_agent_turn"] = is_agent_turn
captured["is_vision"] = is_vision
return {"Copilot-Vision-Request": "true"} if is_vision else {}
with patch(
"agent.auxiliary_client.OpenAI",
) as mock_openai, patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
return_value={
"provider": "copilot",
"api_key": "copilot-api-token",
"base_url": "https://api.githubcopilot.com",
},
), patch.object(copilot_credentials, "copilot_request_headers",
side_effect=fake_headers,
):
mock_client = MagicMock()
mock_openai.return_value = mock_client
from agent.auxiliary_client import resolve_provider_client
client, model = resolve_provider_client("copilot", "gpt-5-mini")
assert client is mock_client
assert model == "gpt-5-mini"
assert captured == {"is_agent_turn": True, "is_vision": False}
assert "default_headers" not in mock_openai.call_args.kwargs
# ── Vision — custom provider endpoint credential passthrough ────────────────
@@ -69,14 +69,26 @@ class TestResolveProviderClientMainAlias:
assert client is not None
assert "beans.local" in str(client.base_url)
def test_main_resolves_github_copilot_alias(self, tmp_path):
def test_main_resolves_plugin_provider_alias(self, tmp_path, request):
import hermes_cli.auth
import providers
from providers.base import ProviderProfile
providers.register_provider(ProviderProfile(
name="acme-hub", aliases=("acme-hub-alias",), env_vars=("ACME_HUB_API_KEY",),
base_url="https://api.acme-hub.test/v1"))
request.addfinalizer(lambda: (
providers._REGISTRY.pop("acme-hub", None),
providers._ALIASES.pop("acme-hub-alias", None),
hermes_cli.auth.PROVIDER_REGISTRY.pop("acme-hub", None),
hermes_cli.auth.PROVIDER_REGISTRY.pop("acme-hub-alias", None)))
_write_config(tmp_path, {
"model": {"default": "gpt-5.4", "provider": "github-copilot"},
"model": {"default": "gpt-5.4", "provider": "acme-hub-alias"},
})
with (
patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={
"api_key": "ghu_test_token",
"base_url": "https://api.githubcopilot.com",
"api_key": "acme_test_token",
"base_url": "https://api.acme-hub.test/v1",
}),
patch("agent.auxiliary_client.OpenAI") as mock_openai,
):
-65
View File
@@ -1,65 +0,0 @@
"""Tests for per-turn Copilot x-initiator header injection (issue #3040).
Copilot bills "premium requests" only when a request is marked as
user-initiated via the ``x-initiator: user`` header. Hermes previously sent
``x-initiator: agent`` on every request (client-level default headers), so
user prompts never consumed premium requests and were throttled as agent
traffic. The fix marks the FIRST API call of each user turn as "user" and
lets tool-loop follow-ups keep the "agent" default.
Salvaged from PR #4097 (@tjp2021); adapted to the post-refactor layout
(conversation_loop.py owns the injection site, the codex transport now
accepts extra_headers).
"""
from run_agent import AIAgent
def _tool_defs(*names):
return [
{"type": "function", "function": {"name": n, "description": n, "parameters": {}}}
for n in names
]
class _FakeOpenAI:
def __init__(self, **kw):
self.api_key = kw.get("api_key", "test")
self.base_url = kw.get("base_url", "http://test")
def close(self):
pass
def _make_agent(monkeypatch, base_url, api_mode="chat_completions"):
"""Create an AIAgent pointing at the given base_url."""
monkeypatch.setattr("model_tools.get_tool_definitions", lambda **kw: _tool_defs("web_search"))
monkeypatch.setattr("model_tools.check_toolset_requirements", dict)
monkeypatch.setattr("agent.process_bootstrap.OpenAI", _FakeOpenAI)
return AIAgent(
api_key="test-key",
base_url=base_url,
provider="copilot" if "githubcopilot" in base_url else "openrouter",
api_mode=api_mode,
max_iterations=4,
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
class TestUserInitiatedTurnFlag:
"""_is_user_initiated_turn lifecycle."""
def test_reset_session_clears_flag(self, monkeypatch):
agent = _make_agent(monkeypatch, "https://api.githubcopilot.com")
agent._is_user_initiated_turn = True
agent.reset_session_state()
assert agent._is_user_initiated_turn is False
class TestHeaderValues:
"""copilot_default_headers(is_agent_turn=...) sets x-initiator correctly."""
def test_default_is_agent(self):
from plugins.model_providers.copilot.catalog import copilot_default_headers
assert copilot_default_headers()["x-initiator"] == "agent"
def test_user_turn(self):
from plugins.model_providers.copilot.catalog import copilot_default_headers
assert copilot_default_headers(is_agent_turn=False)["x-initiator"] == "user"
@@ -1,53 +0,0 @@
from unittest.mock import MagicMock, patch
from run_agent import AIAgent
def _make_copilot_agent():
with patch("agent.process_bootstrap.OpenAI") as mock_openai:
mock_openai.return_value = MagicMock()
agent = AIAgent(
api_key="gh-token",
base_url="https://api.githubcopilot.com",
provider="copilot",
model="gpt-5.4",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
return agent
def test_request_client_adds_copilot_vision_header_for_native_image_payload():
agent = _make_copilot_agent()
built_kwargs = []
def fake_create(kwargs, *, reason, shared):
built_kwargs.append(dict(kwargs))
return MagicMock()
api_kwargs = {
"model": "gpt-5.4",
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "What is in this image?"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}},
],
}
],
}
agent.client = object()
with patch.object(agent, "_is_openai_client_closed", return_value=False), patch.object(
agent, "_create_openai_client", side_effect=fake_create
):
agent._create_request_openai_client(reason="test", api_kwargs=api_kwargs)
headers = built_kwargs[-1]["default_headers"]
assert headers["Copilot-Vision-Request"] == "true"
-210
View File
@@ -8,9 +8,6 @@ import time
from datetime import datetime, timezone, UTC
import pytest
import providers as _providers
_providers.get_provider_profile("copilot") # imports the copilot plugin package (credentials module)
from plugins.model_providers.copilot import credentials as copilot_credentials
def _write_auth_store(tmp_path, payload: dict) -> None:
@@ -1389,213 +1386,6 @@ def test_load_pool_does_not_seed_claude_code_when_anthropic_not_configured(tmp_p
assert pool.entries() == []
def test_load_pool_seeds_copilot_via_gh_auth_token(tmp_path, monkeypatch):
"""Copilot credentials from `gh auth token` should be seeded into the pool."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(tmp_path, {"version": 1, "credential_pool": {}})
monkeypatch.setattr(copilot_credentials, "resolve_copilot_token",
lambda: ("gho_fake_token_abc123", "gh auth token"),
)
from agent.credential_pool import load_pool
pool = load_pool("copilot")
assert pool.has_credentials()
entries = pool.entries()
assert len(entries) == 1
assert entries[0].source == "gh_cli"
assert entries[0].access_token == "gho_fake_token_abc123"
assert entries[0].base_url == "https://api.githubcopilot.com"
def test_load_pool_skips_exchange_for_suppressed_copilot(tmp_path, monkeypatch):
"""A suppressed copilot source must NOT run the token exchange.
Regression test: the suppression gate used to sit AFTER
``get_copilot_api_token`` (which retries 3x with backoff, ~13s worst
case), so every pool load — model picker open, /model, agent startup —
burned the full exchange dead time for a source the user had already
removed with ``hermes auth remove copilot gh_cli``. The gate must run
BEFORE the network call.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {},
"suppressed_sources": {"copilot": ["gh_cli"]},
},
)
monkeypatch.setattr(copilot_credentials, "resolve_copilot_token",
lambda: ("gho_fake_token_abc123", "gh auth token"),
)
exchange_called = False
def _boom(token):
nonlocal exchange_called
exchange_called = True
raise AssertionError("exchange must not run for a suppressed source")
monkeypatch.setattr(copilot_credentials, "get_copilot_api_token",
_boom,
)
from agent.credential_pool import load_pool
pool = load_pool("copilot")
assert not exchange_called
assert not pool.has_credentials()
assert pool.entries() == []
def test_load_pool_respects_env_var_copilot_suppression(tmp_path, monkeypatch):
"""Suppressing env:GH_TOKEN must gate a GH_TOKEN-sourced token.
Regression test for the source_name classification: a substring match
(``"gh" in source.lower()``) classified GH_TOKEN/GITHUB_TOKEN as gh_cli,
so a user's env-var-specific suppression was silently bypassed and the
exchange ran anyway.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {},
"suppressed_sources": {"copilot": ["env:GH_TOKEN"]},
},
)
monkeypatch.setattr(copilot_credentials, "resolve_copilot_token",
lambda: ("gho_fake_token_env", "GH_TOKEN"),
)
exchange_called = False
def _boom(token):
nonlocal exchange_called
exchange_called = True
raise AssertionError("exchange must not run for a suppressed env source")
monkeypatch.setattr(copilot_credentials, "get_copilot_api_token",
_boom,
)
from agent.credential_pool import load_pool
pool = load_pool("copilot")
assert not exchange_called
assert pool.entries() == []
def test_load_pool_gh_cli_suppression_does_not_block_env_tokens(tmp_path, monkeypatch):
"""Suppressing gh_cli must NOT swallow an env-var-sourced token.
The inverse of the substring bug: GH_TOKEN misclassified as gh_cli meant
suppressing the CLI path also silently dropped env tokens.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {},
"suppressed_sources": {"copilot": ["gh_cli"]},
},
)
monkeypatch.setattr(copilot_credentials, "resolve_copilot_token",
lambda: ("gho_fake_token_env", "GH_TOKEN"),
)
monkeypatch.setattr(copilot_credentials, "get_copilot_api_token",
lambda token: ("capi_exchanged_token", None),
)
from agent.credential_pool import load_pool
pool = load_pool("copilot")
assert [e.source for e in pool.entries()] == ["env:GH_TOKEN"]
def test_load_pool_skips_resolve_when_all_copilot_sources_suppressed(tmp_path, monkeypatch):
"""With every copilot source suppressed, resolve_copilot_token (which
shells out to ``gh auth token``) must not run at all."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
from plugins.model_providers.copilot.credentials import COPILOT_ENV_VARS
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {},
"suppressed_sources": {
"copilot": ["gh_cli"] + [f"env:{v}" for v in COPILOT_ENV_VARS],
},
},
)
def _boom():
raise AssertionError("resolve_copilot_token must not run when all sources are suppressed")
monkeypatch.setattr(copilot_credentials, "resolve_copilot_token", _boom)
from agent.credential_pool import load_pool
pool = load_pool("copilot")
assert pool.entries() == []
def test_load_pool_copilot_exchange_only_when_selected_and_warns_once(tmp_path, monkeypatch, caplog):
"""An ambient gh-CLI Copilot credential is seeded without the token exchange (and without the
'degraded to RAW token' warning) until copilot is actually selected; once selected, the
degradation is reported once per token, not on every pool load (#114740)."""
import logging
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
_write_auth_store(tmp_path, {"version": 1, "credential_pool": {}})
from agent.credential_pool import load_pool
from plugins.model_providers.copilot.credentials import _reset_raw_degradation_warned
_reset_raw_degradation_warned()
monkeypatch.setattr(copilot_credentials, "resolve_copilot_token", lambda: ("gho_raw_initial", "gh auth token"))
exchanges = []
def degraded_exchange(token):
exchanges.append(token)
return token, None # exchange unavailable -> RAW token, no enterprise URL
monkeypatch.setattr(copilot_credentials, "get_copilot_api_token", degraded_exchange)
def degradation_warnings():
return [r for r in caplog.records if "Copilot token exchange degraded to RAW token" in r.message]
with caplog.at_level(logging.WARNING, logger="agent.credential_pool"):
# Main provider is deepseek; copilot is merely discovered via `gh auth token`.
(tmp_path / "hermes" / "config.yaml").write_text("model:\n provider: deepseek\n default: deepseek-chat\n", encoding="utf-8")
pool = load_pool("copilot")
load_pool("copilot")
assert exchanges == [] and degradation_warnings() == []
assert [e.access_token for e in pool.entries()] == ["gho_raw_initial"] # credential still listed
# The user selects copilot for one auxiliary task: the exchange runs, the degradation is
# reported exactly once across repeated loads.
(tmp_path / "hermes" / "config.yaml").write_text(
"model:\n provider: deepseek\n default: deepseek-chat\nauxiliary:\n approval:\n provider: copilot\n", encoding="utf-8")
from hermes_cli import config as _cfg
_cfg._LOAD_CONFIG_CACHE.clear()
_cfg._RAW_CONFIG_CACHE.clear() # same-second rewrite: the mtime signature may not change
load_pool("copilot")
load_pool("copilot")
assert len(exchanges) == 2 and len(degradation_warnings()) == 1
# A different token is a different degradation: warned again, once.
monkeypatch.setattr(copilot_credentials, "resolve_copilot_token", lambda: ("gho_raw_rotated", "gh auth token"))
load_pool("copilot")
assert len(degradation_warnings()) == 2
def test_load_pool_seeds_qwen_oauth_via_cli_tokens(tmp_path, monkeypatch):
"""Qwen OAuth credentials from ~/.qwen/oauth_creds.json should be seeded into the pool."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
@@ -2,6 +2,7 @@
when primary provider credentials are exhausted."""
import pytest
from unittest.mock import patch, MagicMock
from providers.base import ProviderProfile
from run_agent import AIAgent
@@ -133,25 +134,42 @@ def test_init_openrouter_exhausted_without_chain_keeps_generic_error():
)
def test_init_fallback_recomputes_api_mode_for_copilot_gpt5_mini():
"""#46527: Nous primary (api_mode=codex_responses, no creds) -> Copilot gpt-5-mini
fallback must end up with api_mode=chat_completions, not codex_responses.
class _PerModelWireProfile(ProviderProfile):
"""A relay whose Responses-default profile serves ``gpt-5-mini`` on Chat Completions."""
Copilot's gpt-5-mini is the documented exception that uses Chat Completions,
not the Responses API. Before the fix, ``agent.api_mode`` stayed
def api_mode_for_model(self, model, *, api_key=None):
return "chat_completions" if model == "gpt-5-mini" else None
_RELAY = _PerModelWireProfile(name="acme-relay", base_url="https://api.acme-relay.test",
api_mode="codex_responses")
def test_init_fallback_recomputes_api_mode_for_per_model_profile_exception():
"""#46527: Nous primary (api_mode=codex_responses, no creds) -> a fallback whose profile
serves gpt-5-mini on chat completions must end up with api_mode=chat_completions,
not codex_responses.
The fallback profile's ``api_mode_for_model`` names gpt-5-mini as an exception that uses
Chat Completions, not the Responses API. Before the fix, ``agent.api_mode`` stayed
``"codex_responses"`` after the init-time fallback activated, so every request
for the fallback model went out via the Responses API and silently returned no
reasoning/thinking content.
"""
fb_client = _mock_client(base_url="https://api.githubcopilot.com")
import providers
fb_client = _mock_client(base_url="https://api.acme-relay.test")
real_get_profile = providers.get_provider_profile
def fake_resolve(provider, model=None, raw_codex=False,
explicit_base_url=None, explicit_api_key=None):
if provider == "copilot":
if provider == "acme-relay":
return fb_client, "gpt-5-mini"
return None, None # primary (nous) has no usable credentials
with patch("agent.auxiliary_client.resolve_provider_client", side_effect=fake_resolve), \
patch("providers.get_provider_profile",
side_effect=lambda name: _RELAY if name == "acme-relay" else real_get_profile(name)), \
patch("model_tools.get_tool_definitions", return_value=_make_tool_defs()), \
patch("model_tools.check_toolset_requirements", return_value={}), \
patch("agent.process_bootstrap.OpenAI", return_value=MagicMock()):
@@ -165,10 +183,10 @@ def test_init_fallback_recomputes_api_mode_for_copilot_gpt5_mini():
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
fallback_model=[{"provider": "copilot", "model": "gpt-5-mini"}],
fallback_model=[{"provider": "acme-relay", "model": "gpt-5-mini"}],
)
assert agent.provider == "copilot"
assert agent.provider == "acme-relay"
assert agent.model == "gpt-5-mini"
assert agent._fallback_activated is True
assert agent.api_mode == "chat_completions"
+15 -8
View File
@@ -13,11 +13,16 @@ gates relate, not snapshots of the marker lists.
from types import SimpleNamespace
from typing import Union
from unittest.mock import patch
from agent.agent_runtime_helpers import (
intent_ack_continuation_mode,
looks_like_codex_intermediate_ack,
)
from providers.base import ProviderProfile
# An ACP agent whose binary is ``copilot``: the "on <X> via acpx" cue names only external_process profiles.
_ACP_AGENT = ProviderProfile(name="acme-acp", auth_type="external_process", process_command="copilot")
def _agent(
@@ -156,10 +161,11 @@ def test_pronounless_action_announcements_continue_when_opted_in():
"Exit code 1. Step 2. Checking the actual log.",
"Attempt 1. Attempt 2. Relaunching with corrected arguments.",
)
for announcement in announcements:
assert looks_like_codex_intermediate_ack(
a, user, announcement, msgs, require_workspace=False
), announcement
with patch("providers.list_providers", return_value=[_ACP_AGENT]):
for announcement in announcements:
assert looks_like_codex_intermediate_ack(
a, user, announcement, msgs, require_workspace=False
), announcement
def test_pronounless_action_guardrails_reject_questions_and_finals():
@@ -245,10 +251,11 @@ def test_pronounless_action_guardrails_reject_questions_and_finals():
"Option: 1. Launching it now.",
"Options: 1. Creating the session now.",
)
for final in final_answers:
assert not looks_like_codex_intermediate_ack(
a, user, final, msgs, require_workspace=False
), final
with patch("providers.list_providers", return_value=[_ACP_AGENT]):
for final in final_answers:
assert not looks_like_codex_intermediate_ack(
a, user, final, msgs, require_workspace=False
), final
# ── detector: guardrails that hold regardless of workspace ───────────────────
+4 -4
View File
@@ -883,24 +883,24 @@ class TestModelOverrides:
def test_provider_key_accepts_either_id_space(self):
"""Override keyed by Hermes id resolves for models.dev id and back."""
overrides = {
"copilot": {
"fireworks": {
"my-model": {"context_window": 111111},
},
}
with self._setup_overrides(overrides):
# Caller passes the models.dev id; config keyed by Hermes id.
result = _explicit_model_override("github-copilot", "my-model")
result = _explicit_model_override("fireworks-ai", "my-model")
assert result is not None
assert result["context_window"] == 111111
overrides = {
"github-copilot": {
"fireworks-ai": {
"my-model": {"context_window": 222222},
},
}
with self._setup_overrides(overrides):
# Caller passes the Hermes id; config keyed by models.dev id.
result = _explicit_model_override("copilot", "my-model")
result = _explicit_model_override("fireworks", "my-model")
assert result is not None
assert result["context_window"] == 222222
@@ -1,7 +1,7 @@
"""Cross-writer invariant: every private-credential file is 0600 from the moment its temp file exists.
The credential writers (auth.json, MCP OAuth tokens, secret-source cache, iron-proxy state, the
exchanged-JWT store, the Photon sidecar record, pairing data, the vault blob, the third-party
Photon sidecar record, pairing data, the vault blob, the third-party
credential file, the spawn ledger, the meet-node token) all funnel through ``utils.atomic_json_write`` / ``atomic_write_text`` /
``atomic_write_bytes`` with ``mode=0o600``. The contract under test: the *temp* file is created
with mode 0600 (``O_EXCL``) BEFORE any byte lands and the final file carries 0600 — never
@@ -17,9 +17,6 @@ import sys
from pathlib import Path
import pytest
import providers as _providers
_providers.get_provider_profile("copilot") # imports the copilot plugin package (credentials module)
from plugins.model_providers.copilot import credentials as copilot_credentials
pytestmark = pytest.mark.platforms("posix") # POSIX mode bits not enforced on Windows
_PRIVATE = stat.S_IRUSR | stat.S_IWUSR
@@ -73,8 +70,6 @@ def _writers(home: Path, monkeypatch):
("secret-source cache", lambda: cache.write("k", CachedFetch(secrets={"A": "b"}, fetched_at=1.0), 60, home),
cache.path(home)),
("iron-proxy mappings", lambda: iron_proxy.write_mappings([]), home / "proxy" / "mappings.json"),
("exchanged JWT store", lambda: copilot_credentials._save_jwt_to_disk("fp", "jwt", 9e12, None),
copilot_credentials._jwt_disk_path()),
("photon sidecar record", lambda: photon_adapter._write_runtime_record(1, "tok", 2), photon_record),
("pairing", lambda: pairing._save_json_file(home / "pairing" / "p.json", {"a": 1}), home / "pairing" / "p.json"),
("vault blob", lambda: vault._write_all([]), vault._vault_path),
-9
View File
@@ -83,19 +83,10 @@ def test_a_broken_plugin_falls_through_instead_of_taking_the_turn_down(registere
assert _provider_supplied_client(_agent("seam-boom"), {"api_key": "k"}) is None
def test_copilot_acp_still_gets_its_acp_client_via_its_profile():
from plugins.model_providers.copilot_acp.client import CopilotACPClient
assert isinstance(_build("copilot-acp", "acp://copilot"), CopilotACPClient)
assert isinstance(_build("", "acp://copilot"), CopilotACPClient)
def test_skip_flags_replace_the_isinstance_checks_for_in_and_out_of_tree_clients():
from agent.auxiliary_client import _maybe_wrap_anthropic, _to_async_client
from plugins.model_providers.copilot_acp.client import CopilotACPClient
from agent.gemini_native_adapter import GeminiNativeClient
assert CopilotACPClient.HERMES_SKIP_TRANSPORT_WRAP and CopilotACPClient.HERMES_SKIP_ASYNC_WRAP
assert GeminiNativeClient.HERMES_SKIP_TRANSPORT_WRAP
assert not getattr(GeminiNativeClient, "HERMES_SKIP_ASYNC_WRAP", False)
+1 -32
View File
@@ -1,9 +1,4 @@
"""Gemini text-turn signatures and Copilot reasoning carriers survive capture and reach only their route."""
import copy
from types import SimpleNamespace
from openai.types.chat import ChatCompletion
"""Gemini text-turn signatures survive capture and reach only their route."""
from agent.chat_completion_helpers import build_assistant_message
from agent.gemini_native_adapter import _build_gemini_contents, translate_gemini_response, translate_stream_event
@@ -55,29 +50,3 @@ def test_gemini_native_text_turn_signature_is_captured_and_replayed_on_the_last_
wire = shape_wire_carriers(msgs, model="gemini-3.6-flash", base_url="https://generativelanguage.googleapis.com/v1beta")
contents, _ = _build_gemini_contents(wire, is_gemini3=True)
assert contents[1]["parts"][-1] == {"text": "391", "thoughtSignature": "SIG_TEXT"}
def test_copilot_reasoning_and_function_signature_reach_only_copilot():
resp = ChatCompletion.model_validate({
"id": "c", "object": "chat.completion", "created": 0, "model": "gemini-3.6-pro",
"choices": [{"index": 0, "finish_reason": "tool_calls", "message": {
"role": "assistant", "content": None, "reasoning_text": "plan it", "reasoning_opaque": "OPAQUE",
"tool_calls": [{"id": "t1", "type": "function",
"function": {"name": "f", "arguments": "{}", "thought_signature": "FSIG"}}]}}]})
stored = _stored(resp)
assert stored["reasoning"] == "plan it"
snapshot = copy.deepcopy(stored)
copilot = shape_wire_carriers([stored], model="gemini-3.6-pro", base_url="https://api.githubcopilot.com")[0]
assert (copilot["reasoning_opaque"], copilot["reasoning_text"]) == ("OPAQUE", "plan it")
assert copilot["tool_calls"][0]["function"]["thought_signature"] == "FSIG"
assert "reasoning_details" not in copilot
for base_url in ("https://api.fireworks.ai/inference/v1", "https://openrouter.ai/api/v1"):
other = ChatCompletionsTransport().convert_messages(
shape_wire_carriers([stored], model="claude-sonnet-5", base_url=base_url), model="claude-sonnet-5",
base_url=base_url)[0]
assert not {"reasoning_opaque", "reasoning_text", "extra_content"} & other.keys()
assert "thought_signature" not in other["tool_calls"][0]["function"]
assert all(d.get("type") != "hermes.native_assistant" for d in other.get("reasoning_details") or ())
assert stored == snapshot
-31
View File
@@ -1330,37 +1330,6 @@ class TestBuildApiKwargs:
assert "reasoning" not in kwargs.get("extra_body", {})
def test_reasoning_sent_for_copilot_gpt5(self, agent):
"""Copilot/GitHub Models: GPT-5 reasoning goes in extra_body.reasoning."""
from agent.transports import get_transport
from providers import get_provider_profile
transport = get_transport("chat_completions")
profile = get_provider_profile("copilot")
msgs = [{"role": "user", "content": "hi"}]
kwargs = transport.build_kwargs(
model="gpt-5.4",
messages=msgs,
tools=None,
supports_reasoning=True,
provider_profile=profile,
)
assert kwargs["extra_body"]["reasoning"] == {"effort": "medium"}
def test_core_responses_preserves_supported_xhigh(self, agent, monkeypatch):
"""The core GitHub Responses path must preserve a supported xhigh."""
from plugins.model_providers.copilot import catalog as copilot_catalog
monkeypatch.setattr(copilot_catalog, "github_model_reasoning_efforts",
lambda _model: ["none", "low", "medium", "high", "xhigh"],
)
agent.model, agent.base_url = "gpt-5.5", "https://api.githubcopilot.com"
agent.reasoning_config = {"enabled": True, "effort": "xhigh"}
assert agent._endpoint_reasoning_payload() == {"effort": "xhigh"}
def test_qwen_portal_formats_messages_and_metadata(self, agent):
agent.provider = "qwen-oauth"
agent.base_url = "https://portal.qwen.ai/v1"
+25 -89
View File
@@ -11,9 +11,6 @@ sys.modules.setdefault("fal_client", types.SimpleNamespace())
import run_agent
from agent.conversation_loop import _CODEX_INCOMPLETE_NUDGE
import providers as _providers
_providers.get_provider_profile("copilot") # imports the copilot plugin package (credentials module)
from plugins.model_providers.copilot import credentials as copilot_credentials
@pytest.fixture(autouse=True)
@@ -60,15 +57,28 @@ def _build_agent(monkeypatch):
return agent
def _build_copilot_agent(monkeypatch, *, model="gpt-5.4"):
RELAY_BASE_URL = "https://api.acme-relay.test"
def _build_relay_agent(monkeypatch, *, model="gpt-5.4"):
"""A third-party Responses relay whose profile binds replayed message-item ids to a connection."""
import providers
from providers.base import ProviderProfile
_patch_agent_bootstrap(monkeypatch)
relay = ProviderProfile(name="acme-relay", base_url=RELAY_BASE_URL, api_mode="codex_responses",
responses_replay_item_ids=False)
real_profile_for_host = providers.profile_for_host
monkeypatch.setattr(providers, "profile_for_host",
lambda url, *args, **kwargs: relay if str(url or "").startswith(RELAY_BASE_URL)
else real_profile_for_host(url, *args, **kwargs))
agent = run_agent.AIAgent(
model=model,
provider="copilot",
provider="acme-relay",
api_mode="codex_responses",
base_url="https://api.githubcopilot.com",
api_key="gh-token",
base_url=RELAY_BASE_URL,
api_key="relay-token",
quiet_mode=True,
max_iterations=4,
skip_context_files=True,
@@ -1212,9 +1222,9 @@ def test_codex_preflight_defangs_harmony_tokens_before_and_after_middleware(monk
assert "<|start|>" in captured["instructions"]
def test_copilot_responses_preflight_preserves_harmony_tokens(monkeypatch):
def test_relay_responses_preflight_preserves_harmony_tokens(monkeypatch):
"""Other Responses-compatible providers remain byte-identical."""
agent = _build_copilot_agent(monkeypatch)
agent = _build_relay_agent(monkeypatch)
agent._disable_streaming = True
token = "<\x7cstart\x7c>"
captured = {}
@@ -1233,10 +1243,10 @@ def test_copilot_responses_preflight_preserves_harmony_tokens(monkeypatch):
def test_codex_backend_detection_is_narrow(monkeypatch):
codex = _build_agent(monkeypatch)
copilot = _build_copilot_agent(monkeypatch)
relay = _build_relay_agent(monkeypatch)
assert codex._is_codex_backend() is True
assert copilot._is_codex_backend() is False
assert relay._is_codex_backend() is False
# Exact backend URL detection still works for an explicitly custom route.
codex.provider = "custom"
@@ -1245,9 +1255,9 @@ def test_codex_backend_detection_is_narrow(monkeypatch):
assert codex._is_codex_backend() is False
def test_copilot_final_preflight_sanitizes_both_middleware_layers(monkeypatch):
def test_id_binding_relay_final_preflight_sanitizes_both_middleware_layers(monkeypatch):
"""The dispatch chokepoint must sanitize after every mutable layer."""
agent = _build_copilot_agent(monkeypatch)
agent = _build_relay_agent(monkeypatch)
agent._disable_streaming = True
captured = {}
@@ -1536,80 +1546,6 @@ def test_try_refresh_codex_client_credentials_skips_xai_oauth_when_singleton_dif
assert agent.api_key == pre_refresh_key
def test_try_refresh_copilot_client_credentials_rebuilds_client(monkeypatch):
agent = _build_copilot_agent(monkeypatch)
rebuilt = {"kwargs": None}
class _ExistingClient:
pass
class _RebuiltClient:
pass
def _fake_openai(**kwargs):
rebuilt["kwargs"] = kwargs
return _RebuiltClient()
monkeypatch.setattr(copilot_credentials, "resolve_copilot_token",
lambda: ("gho_new_token", "GH_TOKEN"),
)
# The 401 refresh forces a fresh IDE-token exchange; mock it to a valid
# exchanged token so the test is deterministic and network-free.
monkeypatch.setattr(copilot_credentials, "evict_cached_exchanged_token",
lambda _raw: None,
)
monkeypatch.setattr(copilot_credentials, "get_copilot_api_token",
lambda _raw: ("tid=exchanged-ide-token", None),
)
monkeypatch.setattr("agent.process_bootstrap.OpenAI", _fake_openai)
agent.client = _ExistingClient()
ok = agent._try_refresh_provider_runtime_credentials(reason="unauthorized")
assert ok is True
# The old client is retired by _replace_primary_openai_client (release is
# deferred to GC on current main — no synchronous .close() contract).
# The freshly EXCHANGED IDE token — not the raw ghu_/gho_ token — goes on
# the wire, which is what fixes the "401 IDE token expired" recurrence.
assert rebuilt["kwargs"]["api_key"] == "tid=exchanged-ide-token"
assert rebuilt["kwargs"]["base_url"] == "https://api.githubcopilot.com"
assert rebuilt["kwargs"]["default_headers"]["Copilot-Integration-Id"] == "vscode-chat"
assert isinstance(agent.client, _RebuiltClient)
def test_try_refresh_copilot_client_credentials_falls_back_when_exchange_unavailable(monkeypatch):
"""If the IDE-token re-exchange itself fails (network blip), the refresh
still rebuilds the client on the resolved raw token rather than throwing —
clears stale client state and degrades gracefully."""
agent = _build_copilot_agent(monkeypatch)
rebuilt = {"kwargs": None}
class _RebuiltClient:
pass
def _fake_openai(**kwargs):
rebuilt["kwargs"] = kwargs
return _RebuiltClient()
def _boom(_raw):
raise RuntimeError("exchange endpoint unreachable")
monkeypatch.setattr(copilot_credentials, "resolve_copilot_token",
lambda: ("gho_raw_token", "GH_TOKEN"),
)
monkeypatch.setattr(copilot_credentials, "evict_cached_exchanged_token",
lambda _raw: None,
)
monkeypatch.setattr(copilot_credentials, "get_copilot_api_token", _boom)
monkeypatch.setattr("agent.process_bootstrap.OpenAI", _fake_openai)
ok = agent._try_refresh_provider_runtime_credentials(reason="unauthorized")
assert ok is True
# Exchange failed → falls back to the resolved raw token, client still rebuilt.
assert rebuilt["kwargs"]["api_key"] == "gho_raw_token"
def test_preflight_codex_api_kwargs_strips_optional_function_call_id(monkeypatch):
_build_agent(monkeypatch)
from agent.codex_responses_adapter import _preflight_codex_api_kwargs
@@ -2695,7 +2631,7 @@ def _codex_truncated_tool_call_response():
def test_codex_truncated_tool_call_is_retried_with_boosted_output_budget(monkeypatch):
"""A tool call cut off by max_output_tokens on the Responses wire gets the same
budget-boost retry as chat modes instead of a refused partial turn (#91770)."""
agent = _build_copilot_agent(monkeypatch)
agent = _build_relay_agent(monkeypatch)
agent.max_tokens = 1000
responses = [_codex_truncated_tool_call_response(), _codex_message_response("Done.")]
seen_caps: list = []
@@ -2718,7 +2654,7 @@ def test_codex_truncated_tool_call_is_retried_with_boosted_output_budget(monkeyp
def test_codex_text_only_max_output_incomplete_keeps_codex_continuation(monkeypatch):
"""Text truncation is not rerouted: it stays on the Codex incomplete continuation and
never takes the length path's nudge (no double continuation, #91770)."""
agent = _build_copilot_agent(monkeypatch)
agent = _build_relay_agent(monkeypatch)
responses = [_codex_max_output_incomplete_response("Partial"), _codex_message_response("rest.")]
monkeypatch.setattr(agent, "_interruptible_api_call", lambda api_kwargs: responses.pop(0))
+26 -10
View File
@@ -3,21 +3,37 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
from providers.base import ProviderProfile
def test_copilot_provider_check_accepts_alias_spellings():
"""`/model` and profile configs can leave `github-copilot` / `github` as
the provider spelling; the recovery gates must not silently skip them."""
class _RefreshingProfile(ProviderProfile):
def refresh_runtime_credentials(self, *, reason: str):
return None
def test_refresh_provider_check_accepts_alias_spellings():
"""`/model` and profile configs can leave an alias (any case) as the
provider spelling; the recovery gates must not silently skip them."""
import providers
from agent.conversation_loop import _credential_refresh_profile
providers.list_providers() # discover first so the patched registry restores to the real one
acme = _RefreshingProfile(name="acme-refresh", aliases=("acme-cloud-refresh", "acme"),
base_url="https://api.acme-refresh.test", hostnames=("acme-refresh.test",))
def _owner(provider, base_url=""):
profile = _credential_refresh_profile(SimpleNamespace(provider=provider, base_url=base_url))
return profile.name if profile else None
assert _owner("copilot") == "copilot"
assert _owner("github-copilot") == "copilot"
assert _owner("GitHub-Copilot") == "copilot"
assert _owner("github") == "copilot"
# URL fallback: unnormalized provider but a Copilot base URL.
assert _owner("custom", "https://api.githubcopilot.com") == "copilot"
assert _owner("openrouter", "https://openrouter.ai/api/v1") is None
with patch.dict(providers._REGISTRY, {acme.name: acme}), \
patch.dict(providers._ALIASES, {alias: acme.name for alias in acme.aliases}), \
patch.object(providers, "_PROVIDER_LIST_CACHE", None):
assert _owner("acme-refresh") == "acme-refresh"
assert _owner("acme-cloud-refresh") == "acme-refresh"
assert _owner("ACME-Cloud-Refresh") == "acme-refresh"
assert _owner("acme") == "acme-refresh"
# URL fallback: unnormalized provider but the profile's base URL.
assert _owner("custom", "https://api.acme-refresh.test") == "acme-refresh"
assert _owner("openrouter", "https://openrouter.ai/api/v1") is None
+14 -26
View File
@@ -35,26 +35,6 @@ class TestCodexTransportBasic:
class TestCodexBuildKwargs:
@pytest.mark.parametrize("model", ["gpt-6-astra", "openai/gpt-6-astra"])
@pytest.mark.parametrize("effort, expected", [
("low", "low"), ("medium", "medium"), ("high", "high"),
("xhigh", "xhigh"), ("max", "max"), ("ultra", "max"),
("minimal", "low"), ("none", None),
])
def test_astra_copilot_forwards_configured_reasoning(self, transport, model, effort, expected):
from agent.reasoning_params import ReasoningParamsMixin
from hermes_constants import resolve_reasoning_config
reasoning = resolve_reasoning_config({"agent": {"reasoning_effort": effort}}, model)
agent = SimpleNamespace(model=model, reasoning_config=reasoning, base_url="https://api.githubcopilot.com")
kw = transport.build_kwargs(
model=model, messages=[{"role": "user", "content": "Hi"}],
provider="github-copilot", base_url="https://api.githubcopilot.com",
reasoning_config=reasoning,
profile_reasoning=ReasoningParamsMixin._endpoint_reasoning_payload(agent),
)
assert kw.get("reasoning") == ({"effort": expected, "summary": "auto"} if expected else None)
def test_astra_direct_request_applies_model_contract_after_overrides(self, transport):
kw = transport.build_kwargs(
model="gpt-6-astra",
@@ -223,14 +203,20 @@ class TestCodexBuildKwargs:
)
assert kw1["prompt_cache_key"] != kw2["prompt_cache_key"]
def test_github_responses_drops_message_item_id_end_to_end(self, transport):
# #32716: Copilot binds codex_message_items ids to a backend
def test_id_binding_responses_endpoint_drops_message_item_id_end_to_end(self, transport):
# #32716: an endpoint (Copilot was the first) binds codex_message_items ids to a backend
# "connection" that doesn't survive credential rotation, a gateway
# restart, or load-balancer churn — replaying a stale id gets HTTP
# 401 "input item ID does not belong to this connection", even for
# ids well under the #27038 64-char length cap. build_kwargs must
# resolve the endpoint's replay_item_ids quirk from base_url and thread
# it to the input converter so the id never reaches the request.
from unittest.mock import patch
from providers.base import ProviderProfile
binding = ProviderProfile(name="acme-responses", base_url="https://api.acme-responses.test",
api_mode="codex_responses", responses_replay_item_ids=False)
messages = [
{"role": "system", "content": "You are Hermes."},
{
@@ -248,10 +234,12 @@ class TestCodexBuildKwargs:
],
},
]
kw = transport.build_kwargs(
model="gpt-5.5", messages=messages, tools=[],
base_url="https://api.githubcopilot.com",
)
with patch("providers.profile_for_host",
side_effect=lambda url, *_a, **_kw: binding if "api.acme-responses.test" in url else None):
kw = transport.build_kwargs(
model="gpt-5.5", messages=messages, tools=[],
base_url="https://api.acme-responses.test",
)
message_item = next(item for item in kw["input"] if item.get("type") == "message")
assert "id" not in message_item
assert message_item["phase"] == "final_answer"
@@ -54,7 +54,7 @@ MESSAGES = [
MODELS = {
"nous": "anthropic/claude-opus-5.5", "openai-codex": "gpt-5.5", "openai-api": "gpt-5.5",
"xai-oauth": "grok-4", "qwen-oauth": "qwen3-coder-plus", "lmstudio": "qwen/qwen3-8b",
"copilot": "gpt-5.5", "gemini": "gemini-3-pro", "kimi-coding": "kimi-k3", "kimi-coding-cn": "kimi-k2.5",
"gemini": "gemini-3-pro", "kimi-coding": "kimi-k3", "kimi-coding-cn": "kimi-k2.5",
"stepfun": "step-3", "arcee": "trinity-large", "actual": "actual-1", "minimax": "MiniMax-M3",
"minimax-oauth": "MiniMax-M3", "minimax-cn": "MiniMax-M3", "anthropic": "claude-opus-5.5",
"alibaba": "qwen3.5-plus", "alibaba-coding-plan": "qwen3-coder-plus", "xai": "grok-4",
@@ -84,9 +84,6 @@ BASE_URL_ONLY = {
"base-url:kimi": ("kimi-k3", "https://api.moonshot.ai/v1"),
"base-url:kimi-coding": ("kimi-k3", "https://api.kimi.com/coding/v1"),
"base-url:tokenhub": ("hunyuan-t1", "https://tokenhub.tencentmaas.com/v1"),
"base-url:github-models": ("openai/gpt-5.5", "https://models.github.ai/inference"),
# o-series stays on chat completions and takes the GitHub reasoning branch; gpt-5.5 above goes to Responses.
"base-url:github-models-chat": ("openai/o4-mini", "https://models.github.ai/inference"),
"base-url:nvidia-nim": ("nvidia/nemotron-3-super", "https://integrate.api.nvidia.com/v1"),
"base-url:qwen-portal": ("qwen3-coder-plus", "https://portal.qwen.ai/v1"),
"base-url:lmstudio-port": ("qwen/qwen3-8b", "http://127.0.0.1:1234/v1"),
@@ -217,10 +214,7 @@ def test_provider_wire_matches_snapshot(offline, provider, model, base_url):
@pytest.mark.parametrize("key,model,base_url", BASE_URL_CELLS, ids=[c[0] for c in BASE_URL_CELLS])
def test_base_url_only_wire_matches_snapshot(offline, key, model, base_url):
actual = _wire("", model, base_url)
if key == "base-url:github-models-chat":
assert actual["api_mode"] == "chat_completions", "cell no longer reaches the chat-completions GitHub branch"
_check(key, actual, model, base_url)
_check(key, _wire("", model, base_url), model, base_url)
def test_snapshot_covers_every_provider():
@@ -1,319 +0,0 @@
"""Copilot ACP wire conformance, part 1: the multi-call tool flow, resume, and the process lifecycle.
``provider: copilot-acp`` makes Hermes spawn an external ACP agent (``copilot --acp --stdio``) per model
call and speak the Agent Client Protocol to it over stdio. Here the agent is
``tests/fakes/providers/copilot_acp.py``, a fake that validates every request against the published
ACP schema and replays scripted turns. Everything on the Hermes side is real: the ``hermes chat -q``
process, runtime resolution from ``config.yaml`` + the profile ``.env`` (``HERMES_COPILOT_ACP_COMMAND``
/ ``HERMES_COPILOT_ACP_ARGS``), the ACP client, the agent loop, the ``read_file`` tool and ``state.db``.
Contract under test (documented in ``agent/copilot_acp_client.py`` and the ACP spec):
* each model call is a fresh agent process: ``initialize`` -> ``session/new`` (absolute cwd) ->
model selection via the advertised ``model`` config option -> ``session/prompt``; every request is
schema-valid;
* ACP has no tools channel: Hermes' tools travel in the prompt text, a ``<tool_call>`` block in the
agent's message runs a REAL Hermes tool, and the result is in the next call's prompt;
* ``--resume`` in a new process runs in a new agent process whose prompt carries the persisted history
(turn 1 in order, then the new question), with nothing duplicated;
* agent-side ``session/request_permission`` is never granted (Hermes has no human channel there) and
``fs/read_text_file`` is confined to the session cwd;
* no agent process outlives the CLI, including one that ignores SIGTERM and stdin EOF.
"""
from __future__ import annotations
import contextlib
import os
import signal
import sys
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable
import pytest
from tests.e2e.core._pending_fixes import known_gate
from tests.e2e.core.providers._native_helpers import (
ChatResult,
KnownSymptom,
NativeHome,
assert_no_duplicate_assistant_text,
latest_session,
make_home,
messages,
run_chat,
session_ids,
tool_calls_of,
wait_until,
)
from tests.fakes.providers import copilot_acp as acp
pytest.importorskip("acp.schema", reason="the fake validates against the agent-client-protocol package (acp extra)")
pytestmark = [
pytest.mark.skipif(not sys.platform.startswith("linux"), reason="POSIX launcher script + /proc pid checks"),
pytest.mark.live_system_guard_bypass,
]
MODELS = ["fake-model-a", "fake-model-b"]
CONFIGURED_MODEL = "fake-model-b" # NOT the agent's default: selection must happen on the wire
CANARY = "CANARY-7731-acp"
SECRET = "OUTSIDE-CWD-SECRET-5512"
Q1 = "Read canary.txt and tell me what it says (Q1-marker)."
Q2 = "Now summarise what you found (Q2-marker)."
FINAL_ONE = f"The file says {CANARY} (FINAL-ONE)"
FINAL_TWO = "Summary: the canary was read (FINAL-TWO)"
LATE_TEXT = "LATE-ANSWER-65788"
# Compaction: the turn pins ``--toolsets file`` so the prompt size does not depend on which optional tools
# the host can advertise (browser tools appear only where agent-browser and Chromium exist). With that pin
# the system prompt + tool bridge is ~3.5K estimated tokens and each file read adds ~0.6K, so eight reads
# cross this absolute threshold mid-turn (ACP reports no usage, so Hermes estimates).
COMPACT_THRESHOLD = 5_500
COMPACT_TOOLSETS = ("--toolsets", "file")
COMPACT_FILES = 8
COMPACT_ASK = "Read f1.txt through f8.txt one by one, then say done (COMPACT-ASK)."
SUMMARY = "SUMMARY-ACP-7f3: files f1..fN were read; each is lorem ipsum filler."
FINAL_COMPACT = "All eight files read (FINAL-COMPACT)."
# Red on current main for a tracked, open bug: key -> (the bug's own failure-message pattern, reason).
KNOWN: dict[str, tuple[str, str]] = {
"late_chunk": (r"^late chunk dropped: \d+ model calls, stdout=",
"#65788 agent_message_chunk emitted after the session/prompt result is dropped"),
}
@dataclass
class Scenario:
nh: NativeHome
fake: acp.AcpFake
runs: list[ChatResult] = field(default_factory=list)
extra: dict[str, Any] = field(default_factory=dict)
def _home(root: Path, turns: list[list[dict[str, Any]]], **fake_kw: Any) -> Scenario:
fake = acp.AcpFake(root / "acp", turns, models=MODELS, **fake_kw)
nh = make_home(root, {"provider": "copilot-acp", "default": CONFIGURED_MODEL}, env_file=fake.env())
return Scenario(nh, fake)
def _alive(pid: int) -> bool:
"""True while ``pid`` is a live (non-zombie) process."""
try:
with open(f"/proc/{pid}/stat", encoding="utf-8") as fh:
return fh.read().rsplit(")", 1)[1].split()[0] != "Z"
except (FileNotFoundError, ProcessLookupError, IndexError):
return False
def _reap(fake: acp.AcpFake) -> None:
"""Teardown: SIGKILL any fake agent this scenario spawned that is still alive (by recorded pid)."""
for pid in fake.pids():
if _alive(pid):
with contextlib.suppress(ProcessLookupError): # exited between the check and the kill
os.kill(pid, signal.SIGKILL)
# ── scenarios (independent; run concurrently in the module fixture) ──────────────────────────
def _flow(root: Path) -> Scenario:
"""Turn 1 (two model calls): agent-side permission + a Hermes read_file call, then fs reads + the
answer. Turn 2: ``--resume`` in a new process. The agent ignores SIGTERM and stdin EOF."""
project = NativeHome(root).project
turns = [
[acp.tool_call("agent-native-1", "run shell", kind="execute"), acp.permission("agent-native-1"),
acp.message(acp.hermes_tool_call("call_rf_1", "read_file", {"path": str(project / "canary.txt")}))],
[acp.fs_read(str(project / "canary.txt")), acp.fs_read(str(root / "outside" / "secret.txt")),
acp.thought("REASONING-ONE: the tool result names the canary"), acp.message(FINAL_ONE)],
[acp.message(FINAL_TWO)],
]
sc = _home(root, turns, ignore_sigterm=True)
(sc.nh.project / "canary.txt").write_text(CANARY + "\n", encoding="utf-8")
(root / "outside").mkdir()
(root / "outside" / "secret.txt").write_text(SECRET + "\n", encoding="utf-8")
sc.runs.append(run_chat(sc.nh, Q1))
if sc.runs[0].returncode == 0 and session_ids(sc.nh):
sc.extra["turn1_pids"] = sc.fake.pids()
sc.runs.append(run_chat(sc.nh, Q2, resume=latest_session(sc.nh)))
return sc
def _late(root: Path) -> Scenario:
"""The agent answers ``session/prompt`` first and streams the message chunk 250 ms later."""
sc = _home(root, [[acp.result(), acp.message(LATE_TEXT, delay=0.25)], [acp.message("SECOND-TRY-65788")]])
sc.runs.append(run_chat(sc.nh, "Say the late answer."))
return sc
def _compaction(root: Path) -> Scenario:
"""Eight read_file calls in one turn cross ``compression.threshold_tokens``; the summarizer runs
through the same ACP provider (no tool bridge -> the fake answers ``SUMMARY``)."""
project = NativeHome(root).project
turns = [[acp.thought(f"step {i}"), acp.message(acp.hermes_tool_call(
f"call_f{i}", "read_file", {"path": str(project / f"f{i}.txt")}))] for i in range(1, COMPACT_FILES + 1)]
fake = acp.AcpFake(root / "acp", [*turns, [acp.message(FINAL_COMPACT)]], models=MODELS, aux_text=SUMMARY)
nh = make_home(root, {"provider": "copilot-acp", "default": CONFIGURED_MODEL}, env_file=fake.env(),
extra_config={"compression": {"threshold_tokens": COMPACT_THRESHOLD, "protect_last_n": 4}})
for i in range(1, COMPACT_FILES + 1):
(nh.project / f"f{i}.txt").write_text(f"file {i} " + "lorem ipsum dolor " * 250 + "\n", encoding="utf-8")
sc = Scenario(nh, fake)
sc.runs.append(run_chat(nh, COMPACT_ASK, args=COMPACT_TOOLSETS))
return sc
SCENARIOS: dict[str, Callable[[Path], Scenario]] = {"flow": _flow, "late": _late, "compaction": _compaction}
@pytest.fixture(scope="module")
def outcomes(tmp_path_factory: pytest.TempPathFactory):
base = tmp_path_factory.mktemp("copilot_acp")
with ThreadPoolExecutor(max_workers=len(SCENARIOS)) as pool:
futures = {name: pool.submit(fn, base / name) for name, fn in SCENARIOS.items()}
done = {name: fut.result() for name, fut in futures.items()}
yield done
for sc in done.values():
_reap(sc.fake)
def _flow_ok(outcomes: dict[str, Scenario]) -> Scenario:
sc = outcomes["flow"]
assert len(sc.runs) == 2 and all(r.returncode == 0 for r in sc.runs), "\n\n".join(r.describe() for r in sc.runs)
return sc
def _calls(fake: acp.AcpFake) -> dict[int, list[dict[str, Any]]]:
"""Inbound client requests grouped per agent process (pid), in arrival order."""
grouped: dict[int, list[dict[str, Any]]] = {}
for rec in fake.inbound():
if rec["msg"] and rec["msg"].get("method"):
grouped.setdefault(rec["pid"], []).append(rec)
return grouped
# ── tests ──────────────────────────────────────────────────────────────────────────────────────
def test_hermes_tool_call_round_trips_through_acp_and_persists(outcomes):
"""A ``<tool_call>`` in the agent's message runs Hermes' real read_file; the result reaches the
NEXT call's prompt; the CLI prints the answer; state.db pairs the call and result by id."""
sc = _flow_ok(outcomes)
assert FINAL_ONE in sc.runs[0].stdout, sc.runs[0].describe()
prompts = sc.fake.main_prompts()
assert len(prompts) == 3, f"expected 3 model calls (tool, answer, resumed answer), got {len(prompts)}"
first, second = acp.prompt_text(prompts[0]), acp.prompt_text(prompts[1])
assert '"name": "read_file"' in first, "read_file schema was not offered through the prompt tool bridge"
assert CANARY not in first, "the canary leaked into the prompt before the tool ran"
assert CANARY in second and second.index(Q1) < second.index(CANARY), (
"the read_file result must follow the user turn in the next call's prompt")
rows = messages(sc.nh, latest_session(sc.nh))
calls = [(r, tc) for r in rows if r["role"] == "assistant" for tc in tool_calls_of(r)]
assert [(tc["id"], tc["function"]["name"]) for _, tc in calls] == [("call_rf_1", "read_file")], calls
results = [r for r in rows if r["role"] == "tool"]
assert [r["tool_call_id"] for r in results] == ["call_rf_1"] and CANARY in results[0]["content"], results
finals = [r for r in rows if r["role"] == "assistant" and FINAL_ONE in (r["content"] or "")]
assert len(finals) == 1 and rows.index(finals[0]) > rows.index(results[0]), rows
assert "<tool_call>" not in "".join(r["content"] or "" for r in rows if r["role"] == "assistant"), (
"raw tool-call bridge markup was persisted as assistant text")
assert "REASONING-ONE" in (finals[0].get("reasoning") or ""), "agent_thought_chunk text was not kept as reasoning"
def test_every_request_is_schema_valid_and_selects_the_configured_model(outcomes):
"""Per process: initialize -> session/new (absolute project cwd) -> set_config_option(model) ->
session/prompt on the issued sessionId; zero requests the fake had to reject."""
sc = _flow_ok(outcomes)
assert sc.fake.invalid() == [], f"requests rejected by the ACP schema: {sc.fake.invalid()}"
grouped = _calls(sc.fake)
assert len(grouped) == 3, f"one agent process per model call expected, got {len(grouped)}"
for pid, recs in grouped.items():
methods = [r["msg"]["method"] for r in recs if r["msg"]["method"] not in ("session/cancel",)]
assert methods == ["initialize", "session/new", "session/set_config_option", "session/prompt"], (pid, methods)
init, new, select, prompt = (r["msg"]["params"] for r in recs[:4])
assert init["protocolVersion"] == acp.PROTOCOL_VERSION
assert Path(new["cwd"]) == sc.nh.project.resolve(), new
assert select["value"] == CONFIGURED_MODEL and select["configId"] == "model", select
assert select["sessionId"] == prompt["sessionId"], (select, prompt)
def test_agent_permission_is_never_granted_and_fs_reads_stay_in_cwd(outcomes):
"""The agent's own permission request is refused; fs/read_text_file inside the session cwd returns
the file, outside it returns a JSON-RPC error and no content."""
sc = _flow_ok(outcomes)
outcomes_seen = [r["msg"]["result"]["outcome"] for r in sc.fake.records() if r.get("kind") == "permission_outcome"]
assert len(outcomes_seen) == 1, sc.fake.records()
assert outcomes_seen[0].get("outcome") != "selected", f"Hermes granted an agent-side permission: {outcomes_seen}"
reads = [r for r in sc.fake.records() if r.get("kind") == "fs_read_result"]
assert len(reads) == 2 and not any(r["errors"] for r in reads), reads
inside, outside = reads[0]["msg"], reads[1]["msg"]
assert CANARY in inside["result"]["content"], inside
assert "error" in outside and SECRET not in str(outside), f"fs read escaped the session cwd: {outside}"
def test_resume_reaches_the_agent_with_persisted_history_in_order(outcomes):
"""``--resume`` in a new CLI process: a new agent process whose prompt carries turn 1 (question, tool
result, answer) in order and then the new question; one session, nothing persisted twice. How the
ACP session is opened (seeded ``session/new`` or ``session/load``) is not part of the contract."""
sc = _flow_ok(outcomes)
assert FINAL_TWO in sc.runs[1].stdout, sc.runs[1].describe()
resumed_pids = [pid for pid in _calls(sc.fake) if pid not in sc.extra["turn1_pids"]]
assert len(resumed_pids) == 1, "the resumed turn must run in its own agent process"
text = acp.prompt_text(sc.fake.main_prompts()[-1])
order = [text.find(s) for s in (Q1, CANARY, FINAL_ONE, Q2)]
assert -1 not in order and order == sorted(order), f"resumed prompt lost or reordered history: {order}"
sessions = session_ids(sc.nh)
rows = messages(sc.nh, sessions[-1])
assert len(sessions) == 1 and [r["content"] for r in rows if r["role"] == "user"] == [Q1, Q2], rows
assert_no_duplicate_assistant_text(rows, FINAL_ONE)
assert_no_duplicate_assistant_text(rows, FINAL_TWO)
def test_no_agent_process_outlives_the_cli(outcomes):
"""Every spawned agent is gone once the CLI exits, even one ignoring SIGTERM and stdin EOF."""
sc = _flow_ok(outcomes)
pids = sc.fake.pids()
wedged = {r["pid"] for r in sc.fake.events("signal")}
assert len(pids) == 3 and wedged, "vacuity: the scenario must spawn 3 agents that ignored SIGTERM"
wait_until(lambda: not [p for p in pids if _alive(p)], 10.0, f"agent processes {pids} to exit")
def _transcript(record: dict[str, Any]) -> str:
text = acp.prompt_text(record)
return text[text.find("Conversation transcript:"):]
def test_compaction_in_an_acp_session_keeps_the_next_prompt_valid_and_grounded(outcomes):
"""Auto compaction mid-turn: the summary is produced through the ACP agent itself, the next
main-turn prompt is schema-valid and carries the summary + the user's ask + the protected tail
(latest tool result) while the summarized tool output is gone; the turn completes once."""
sc = outcomes["compaction"]
run = sc.runs[0]
assert run.returncode == 0 and FINAL_COMPACT in run.stdout, run.describe()
assert sc.fake.invalid() == [], f"requests rejected by the ACP schema: {sc.fake.invalid()}"
aux = sc.fake.aux_prompts()
assert aux, "compaction never called the summarizer through the ACP provider"
assert "file 2 lorem" in acp.prompt_text(aux[0]), "the summarizer did not receive the history to compact"
after = [r for r in sc.fake.main_prompts() if r["t"] > aux[0]["t"]]
assert after, "no main-turn call followed the compaction"
final = _transcript(after[-1])
assert SUMMARY in final and COMPACT_ASK in final, "post-compaction prompt lost the summary or the user's ask"
assert f"file {COMPACT_FILES} lorem" in final, "post-compaction prompt lost the latest tool result"
assert "file 2 lorem" not in final, "summarized tool output is still resent after compaction"
rows = messages(sc.nh, latest_session(sc.nh))
assert_no_duplicate_assistant_text(rows, FINAL_COMPACT)
assert any(FINAL_COMPACT in (r["content"] or "") for r in rows if r["role"] == "assistant"), rows
def test_message_chunk_after_prompt_result_reaches_the_user(outcomes):
sc = outcomes["late"]
run = sc.runs[0]
assert run.returncode == 0, run.describe()
assert sc.fake.invalid() == [], sc.fake.invalid()
rows = messages(sc.nh, latest_session(sc.nh))
with known_gate(KNOWN, "late_chunk", raises=KnownSymptom):
if LATE_TEXT not in run.stdout:
raise KnownSymptom(f"late chunk dropped: {len(sc.fake.main_prompts())} model calls, stdout={run.stdout!r}")
assert_no_duplicate_assistant_text(rows, LATE_TEXT)
assert len(sc.fake.main_prompts()) == 1, "a delivered late chunk must not trigger an empty-response retry"
@@ -1,162 +0,0 @@
"""Copilot ACP wire conformance, part 2: ACP error responses and agent crashes.
The agent (``tests/fakes/providers/copilot_acp.py``) answers ``session/prompt`` with JSON-RPC errors from
the ACP / JSON-RPC 2.0 error space or dies mid-stream. Each row drives one real ``hermes chat -q`` and
asserts the retry semantics the user sees:
* transient failures (``-32603`` internal error, a crash after a partial chunk) are retried with a
fresh agent process and the turn then succeeds, with none of the failed attempt's text persisted;
* ``-32000 Authentication required`` is not retried: surfaced once, one model call;
* persistent failures (``-32602`` invalid params, repeated crashes) stop after the configured retry
budget (``agent.api_max_retries: 2``) and are surfaced ONCE — never a loop, never a duplicate;
* no agent process survives the CLI in any row.
"""
from __future__ import annotations
import contextlib
import os
import re
import signal
import subprocess
import sys
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import pytest
from tests.e2e.core._pending_fixes import known_gate
from tests.e2e.core.providers._native_helpers import (
ChatResult,
KnownSymptom,
NativeHome,
latest_session,
make_home,
messages,
run_chat,
wait_until,
)
from tests.fakes.providers import copilot_acp as acp
pytest.importorskip("acp.schema", reason="the fake validates against the agent-client-protocol package (acp extra)")
pytestmark = [
pytest.mark.skipif(not sys.platform.startswith("linux"), reason="POSIX launcher script + /proc pid checks"),
pytest.mark.live_system_guard_bypass,
]
API_MAX_RETRIES = 2 # _native_helpers.make_home pins agent.api_max_retries
OK_TEXT = "RECOVERED-ANSWER-OK"
PARTIAL = "PARTIAL-BEFORE-CRASH "
CRASH_STDERR = "fatal: agent segfaulted (fake)"
# Red on current main for a tracked, open bug: key -> (the bug's own failure-message pattern, reason).
KNOWN: dict[str, tuple[str, str]] = {
"auth_remedy": (r"^remedy 'hermes [^']+' is not implemented for copilot-acp: ",
"#121290 copilot-acp auth failure tells the user to run a hermes command that is not implemented"),
}
@dataclass(frozen=True)
class Row:
turns: list[list[dict[str, Any]]]
succeeds: bool
model_calls: int
visible: str # text the user must see exactly once (answer or the agent's error message)
never_persisted: tuple[str, ...] = ()
ROWS: dict[str, Row] = {
"internal_error_retried": Row(
[[acp.rpc_error(-32603, "Internal error: upstream hiccup")], [acp.message(OK_TEXT)]],
succeeds=True, model_calls=2, visible=OK_TEXT, never_persisted=("upstream hiccup",)),
"crash_mid_stream_retried": Row(
[[acp.message(PARTIAL), acp.crash(3, CRASH_STDERR)], [acp.message(OK_TEXT)]],
succeeds=True, model_calls=2, visible=OK_TEXT, never_persisted=(PARTIAL.strip(),)),
"auth_required_not_retried": Row(
[[acp.rpc_error(-32000, "Authentication required")]] * 3,
succeeds=False, model_calls=1, visible="Authentication required"),
"invalid_params_bounded": Row(
[[acp.rpc_error(-32602, "Invalid params: prompt exceeds agent limit")]] * 4,
succeeds=False, model_calls=API_MAX_RETRIES, visible="prompt exceeds agent limit"),
"crash_every_time_bounded": Row(
[[acp.message(PARTIAL), acp.crash(3, CRASH_STDERR)]] * 4,
succeeds=False, model_calls=API_MAX_RETRIES, visible=CRASH_STDERR, never_persisted=(PARTIAL.strip(),)),
}
@dataclass
class Outcome:
nh: NativeHome
fake: acp.AcpFake
run: ChatResult
extra: dict[str, Any] = field(default_factory=dict)
def _alive(pid: int) -> bool:
try:
with open(f"/proc/{pid}/stat", encoding="utf-8") as fh:
return fh.read().rsplit(")", 1)[1].split()[0] != "Z"
except (FileNotFoundError, ProcessLookupError, IndexError):
return False
def _drive(root: Path, row: Row) -> Outcome:
fake = acp.AcpFake(root / "acp", row.turns)
nh = make_home(root, {"provider": "copilot-acp", "default": "copilot-acp"}, env_file=fake.env())
return Outcome(nh, fake, run_chat(nh, "Answer the question, please."))
@pytest.fixture(scope="module")
def outcomes(tmp_path_factory: pytest.TempPathFactory):
base = tmp_path_factory.mktemp("copilot_acp_errors")
with ThreadPoolExecutor(max_workers=len(ROWS)) as pool:
futures = {name: pool.submit(_drive, base / name, row) for name, row in ROWS.items()}
done = {name: fut.result() for name, fut in futures.items()}
yield done
for out in done.values():
for pid in out.fake.pids():
if _alive(pid):
with contextlib.suppress(ProcessLookupError): # exited between the check and the kill
os.kill(pid, signal.SIGKILL)
@pytest.mark.parametrize("name", list(ROWS))
def test_acp_failure_is_retried_per_semantics_and_surfaced_once(outcomes, name):
row, out = ROWS[name], outcomes[name]
run, fake = out.run, out.fake
assert fake.invalid() == [], f"requests rejected by the ACP schema: {fake.invalid()}"
assert len(fake.main_prompts()) == row.model_calls, (
f"{name}: {len(fake.main_prompts())} model calls, expected {row.model_calls}\n{run.describe()}")
assert (run.returncode == 0) is row.succeeds, run.describe()
assert run.stdout.count(row.visible) == 1, f"{row.visible!r} must be shown exactly once\n{run.describe()}"
rows = messages(out.nh, latest_session(out.nh))
persisted = "\n".join(r["content"] or "" for r in rows if r["role"] == "assistant")
for text in row.never_persisted:
assert text not in persisted, f"failed-attempt text {text!r} was persisted: {rows}"
if row.succeeds:
assert persisted.count(OK_TEXT) == 1, rows
pids = fake.pids()
assert len(pids) == row.model_calls, f"one agent process per model call expected: {pids}"
wait_until(lambda: not [p for p in pids if _alive(p)], 10.0, f"agent processes {pids} to exit")
REMEDY_RE = re.compile(r"`(hermes [^`]+)`")
def test_auth_failure_remedy_is_an_actionable_command(outcomes):
"""The sign-in remedy printed for an ACP ``Authentication required`` must not be a dead end: any
``hermes ...`` command it names has to be implemented for this provider."""
out = outcomes["auth_required_not_retried"]
assert out.run.returncode != 0 and "Authentication required" in out.run.stdout, out.run.describe()
for command in REMEDY_RE.findall(out.run.stdout):
argv = [sys.executable, "-m", "hermes_cli.main", *command.split()[1:]]
proc = subprocess.run(argv, cwd=out.nh.project, env=out.nh.env(), capture_output=True, text=True,
timeout=60, stdin=subprocess.DEVNULL, check=False)
said = (proc.stdout + proc.stderr).lower()
with known_gate(KNOWN, "auth_remedy", raises=KnownSymptom):
if "not implemented" in said:
raise KnownSymptom(f"remedy {command!r} is not implemented for copilot-acp: {said.strip()[:300]}")
@@ -1,242 +0,0 @@
"""Copilot ACP wire conformance, part 3: a long deep-reasoning turn must stream progress while in flight.
The fake agent (``tests/fakes/providers/copilot_acp.py``) streams an early message chunk and then
``agent_thought_chunk`` updates for ~4 s before its final chunk and the ``session/prompt`` result, the
shape of a deep-reasoning tier that thinks for minutes. The contract: chunks the agent has already
emitted reach the user's surface BEFORE the turn completes, so a long turn never looks hung.
Two real surfaces, each timestamped against the fake's own clock (same host):
* the Desktop/TUI event stream (``python -m tui_gateway.entry`` over stdio): ``reasoning.delta`` /
``message.delta`` events carrying the agent's text (#120550);
* ACP composition — Hermes itself served as an ACP agent (``hermes acp``) on top of the copilot-acp
provider: the outer client must get ``session/update`` chunks before the inner turn ends (#101507).
Both are red on main (the ACP client buffers the whole response, then replays it as a stream), so
both are ``known_gate`` cells that XFAIL only on :class:`KnownSymptom` for "no progress before the result".
"""
from __future__ import annotations
import json
import os
import queue
import signal
import subprocess
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable
import pytest
from tests.e2e.core._pending_fixes import known_gate
from tests.e2e.core.providers._native_helpers import TURN_TIMEOUT, KnownSymptom, NativeHome, make_home
from tests.fakes.providers import copilot_acp as acp
pytest.importorskip("acp.schema", reason="the fake validates against the agent-client-protocol package (acp extra)")
pytestmark = [
pytest.mark.skipif(not sys.platform.startswith("linux"), reason="POSIX launcher script + /proc pid checks"),
pytest.mark.live_system_guard_bypass,
]
HEAD, ANSWER, THOUGHT = "DEEP-HEAD ", "DEEP-ANSWER-DONE", "DEEP-THOUGHT"
THOUGHT_STEPS, STEP_S = 8, 0.5
MIN_SPAN_S = THOUGHT_STEPS * STEP_S * 0.75 # vacuity: the agent really spent seconds before its result
MARKERS = (HEAD.strip(), THOUGHT, ANSWER)
# Red on current main for a tracked, open bug: key -> (the bug's own failure-message pattern, reason).
KNOWN: dict[str, tuple[str, str]] = {
"tui_stream": (r"^tui_stream: no agent chunk reached the surface during the [\d.]+s turn",
"#120550 copilot-acp buffers the whole turn: no reasoning/message delta reaches the UI in flight"),
"nested_acp": (r"^nested_acp: no agent chunk reached the surface during the [\d.]+s turn",
"#101507 hermes acp over copilot-acp forwards inner ACP chunks only after the inner turn ends"),
}
def _deep_turn() -> list[dict[str, Any]]:
thoughts = [acp.thought(f"{THOUGHT}-{i} ", delay=STEP_S) for i in range(THOUGHT_STEPS)]
return [acp.message(HEAD, delay=0.5), *thoughts, acp.message(ANSWER, delay=STEP_S), acp.result()]
@dataclass
class Observed:
fake: acp.AcpFake
received: list[tuple[float, dict[str, Any]]] = field(default_factory=list) # (wall clock, message)
final_text: str = ""
returncode: int | None = None
stderr: str = ""
def _pump(proc: subprocess.Popen, sink: queue.Queue[tuple[float, dict[str, Any]] | None]) -> None:
for line in proc.stdout: # type: ignore[union-attr]
try:
sink.put((time.time(), json.loads(line)))
except json.JSONDecodeError:
continue
sink.put(None)
class _LineRpc:
"""Newline-delimited JSON-RPC 2.0 over a child's stdio; keeps every inbound message with its arrival time."""
def __init__(self, proc: subprocess.Popen, obs: Observed, on_request: Callable[[dict[str, Any]], Any]):
self.proc, self.obs, self.on_request = proc, obs, on_request
self.inbox: queue.Queue[tuple[float, dict[str, Any]] | None] = queue.Queue()
self.next_id = 0
threading.Thread(target=_pump, args=(proc, self.inbox), daemon=True).start()
def send(self, msg: dict[str, Any]) -> None:
self.proc.stdin.write(json.dumps(msg) + "\n") # type: ignore[union-attr]
self.proc.stdin.flush() # type: ignore[union-attr]
def until(self, pred: Callable[[dict[str, Any]], bool], timeout: float = TURN_TIMEOUT) -> dict[str, Any]:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
try:
item = self.inbox.get(timeout=0.5)
except queue.Empty:
continue
if item is None:
raise AssertionError(f"child closed stdout (rc={self.proc.poll()})")
self.obs.received.append(item)
msg = item[1]
if "method" in msg and "id" in msg:
self.send({"jsonrpc": "2.0", "id": msg["id"], **self.on_request(msg)})
if pred(msg):
return msg
raise AssertionError(f"timed out after {timeout}s")
def call(self, method: str, params: dict[str, Any], timeout: float = TURN_TIMEOUT) -> Any:
self.next_id += 1
rid = self.next_id
self.send({"jsonrpc": "2.0", "id": rid, "method": method, "params": params})
msg = self.until(lambda m: m.get("id") == rid and "method" not in m, timeout)
assert "error" not in msg, f"{method} failed: {msg['error']}"
return msg.get("result")
def _refuse(msg: dict[str, Any]) -> dict[str, Any]:
return {"error": {"code": -32601, "message": f"test client does not implement {msg['method']}"}}
def _run_child(argv: list[str], nh: NativeHome, fake: acp.AcpFake, drive: Callable[[_LineRpc], str]) -> Observed:
obs = Observed(fake)
stderr_path = nh.root / "child_stderr.log"
with open(stderr_path, "w", encoding="utf-8") as err:
proc = subprocess.Popen(argv, cwd=nh.project, env=nh.env(), stdin=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=err, text=True, bufsize=1)
try:
obs.final_text = drive(_LineRpc(proc, obs, _refuse))
finally:
proc.stdin.close() # type: ignore[union-attr]
try:
proc.wait(timeout=60)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=10)
obs.returncode, obs.stderr = proc.returncode, stderr_path.read_text(encoding="utf-8")[-3000:]
return obs
def _home(root: Path) -> tuple[NativeHome, acp.AcpFake]:
fake = acp.AcpFake(root / "acp", [_deep_turn()])
return make_home(root, {"provider": "copilot-acp", "default": "copilot-acp"}, env_file=fake.env()), fake
def _tui_gateway(root: Path) -> Observed:
nh, fake = _home(root)
def drive(rpc: _LineRpc) -> str:
def event(etype: str) -> Callable[[dict[str, Any]], bool]:
return lambda m: m.get("method") == "event" and (m.get("params") or {}).get("type") == etype
rpc.until(event("gateway.ready"), 120)
sid = rpc.call("session.create", {})["session_id"]
rpc.call("prompt.submit", {"session_id": sid, "text": "Think deeply, then answer."})
done = rpc.until(event("message.complete"))
return str((done["params"].get("payload") or {}).get("text") or "")
return _run_child([sys.executable, "-m", "tui_gateway.entry"], nh, fake, drive)
def _nested_acp(root: Path) -> Observed:
nh, fake = _home(root)
def drive(rpc: _LineRpc) -> str:
rpc.call("initialize", {"protocolVersion": 1, "clientCapabilities": {}, "clientInfo": {"name": "e2e", "version": "1"}}, 120)
sid = rpc.call("session/new", {"cwd": str(nh.project), "mcpServers": []}, 120)["sessionId"]
result = rpc.call("session/prompt", {"sessionId": sid, "prompt": [{"type": "text", "text": "Think deeply, then answer."}]})
assert result.get("stopReason") == "end_turn", result
return "".join(_chunk_text(m) for _, m in rpc.obs.received)
return _run_child([sys.executable, "-m", "hermes_cli.main", "acp"], nh, fake, drive)
def _chunk_text(msg: dict[str, Any]) -> str:
params = msg.get("params") or {}
if msg.get("method") == "session/update": # outer ACP surface
content = (params.get("update") or {}).get("content") or {}
return str(content.get("text") or "") if isinstance(content, dict) else ""
if msg.get("method") == "event" and params.get("type") in ("message.delta", "reasoning.delta"): # TUI surface
return str((params.get("payload") or {}).get("text") or "")
return ""
SURFACES: dict[str, Callable[[Path], Observed]] = {"tui_stream": _tui_gateway, "nested_acp": _nested_acp}
@pytest.fixture(scope="module")
def observed(tmp_path_factory: pytest.TempPathFactory):
base = tmp_path_factory.mktemp("copilot_acp_stream")
with ThreadPoolExecutor(max_workers=len(SURFACES)) as pool:
futures = {name: pool.submit(fn, base / name) for name, fn in SURFACES.items()}
done = {name: fut.result() for name, fut in futures.items()}
yield done
for obs in done.values():
for pid in obs.fake.pids():
if os.path.exists(f"/proc/{pid}"):
try:
os.kill(pid, signal.SIGKILL)
except ProcessLookupError:
pass
def _agent_timeline(fake: acp.AcpFake) -> tuple[float, float]:
"""(first chunk sent, prompt result sent) on the fake agent's clock, for the single main turn."""
outs = [r for r in fake.records() if r["dir"] == "out" and r["turn"] == 0]
first = min(r["t"] for r in outs if (r["msg"] or {}).get("method") == "session/update")
done = min(r["t"] for r in outs if "stopReason" in ((r["msg"] or {}).get("result") or {}))
return first, done
@pytest.mark.parametrize("surface", SURFACES)
def test_long_reasoning_turn_streams_progress_before_it_completes(observed, surface):
obs = observed[surface]
assert obs.fake.invalid() == [], f"requests rejected by the ACP schema: {obs.fake.invalid()}"
assert ANSWER in obs.final_text, f"turn did not complete with the agent's answer: {obs.final_text!r}\n{obs.stderr}"
first, done = _agent_timeline(obs.fake)
assert done - first >= MIN_SPAN_S, f"vacuity: the agent streamed for only {done - first:.2f}s"
seen_at = _first_marker_arrival(obs.received)
with known_gate(KNOWN, surface, raises=KnownSymptom):
if seen_at is None or seen_at >= done - 0.3:
late = [round(t - done, 2) for t, m in obs.received if _chunk_text(m)]
raise KnownSymptom(f"{surface}: no agent chunk reached the surface during the {done - first:.1f}s turn; "
f"chunk arrivals relative to the result: {late}")
assert seen_at >= first, "a chunk cannot arrive before the agent sent it"
def _first_marker_arrival(received: list[tuple[float, dict[str, Any]]]) -> float | None:
"""Arrival time of the delta that first completes one of the agent's markers (surfaces may re-split
chunks, so match on the accumulated text, not per delta)."""
buffer = ""
for t, msg in received:
buffer += _chunk_text(msg)
if any(k in buffer for k in MARKERS):
return t
return None
-465
View File
@@ -1,465 +0,0 @@
#!/usr/bin/env python3
"""Fake ACP agent executable standing in for ``copilot --acp --stdio`` (provider ``copilot-acp``).
Hermes' ``copilot-acp`` provider spawns an external agent process per model call and speaks the
Agent Client Protocol to it: JSON-RPC 2.0, one JSON object per line over stdio
(https://agentclientprotocol.com/protocol/overview). This module is that process. It
* answers ``<cmd> --help`` with a usage text advertising ``--acp`` (Hermes probes it before spawning);
* validates every client request against the published ACP schema (the ``agent-client-protocol``
package's pydantic models, ``acp.schema``) plus the spec rules the models do not encode
(initialize-first, absolute ``cwd``, no custom root fields, known ``sessionId``) and REJECTS
malformed ones with a JSON-RPC ``-32602 Invalid params`` / ``-32600`` error, as an agent would;
* builds every message it sends from the same schema models, so a shape drift fails here first;
* replays a scripted turn per main-turn ``session/prompt`` (thought / message chunks with delays,
``tool_call`` / ``tool_call_update``, ``session/request_permission``, ``fs/read_text_file``,
the ``{stopReason}`` result, JSON-RPC errors, a hard crash, chunks AFTER the result);
* appends every inbound and outbound message, with wall-clock time and pid, to
``<state>/transcript.jsonl`` (shared by every process of one scenario).
Test-side API: :class:`AcpFake` (write the script, build the launcher, read the transcript) and the
action builders (:func:`thought`, :func:`message`, ...). Run as a script it is the agent itself:
``copilot_acp.py --acp --stdio --state <dir>``.
"""
from __future__ import annotations
import fcntl
import json
import os
import signal
import sys
import time
from pathlib import Path
from typing import Any
from pydantic import ValidationError
PROTOCOL_VERSION = 1
TOOLS_MARKER = "Available tools"
AUX_ANSWER = "fake-acp auxiliary answer"
USAGE = """usage: copilot [options]
Options:
--acp Run as an Agent Client Protocol server
--stdio Use stdio transport (with --acp)
--state <dir> (fake) scenario directory holding script.json / transcript.jsonl
-h, --help Show help
"""
# ── test-side API ────────────────────────────────────────────────────────────────────────────
def thought(text: str, delay: float = 0.0) -> dict[str, Any]:
return {"type": "thought", "text": text, "delay": delay}
def message(text: str, delay: float = 0.0) -> dict[str, Any]:
return {"type": "message", "text": text, "delay": delay}
def tool_call(call_id: str, title: str, kind: str = "other", status: str = "pending") -> dict[str, Any]:
return {"type": "tool_call", "id": call_id, "title": title, "kind": kind, "status": status}
def tool_update(call_id: str, status: str = "completed", text: str = "") -> dict[str, Any]:
return {"type": "tool_update", "id": call_id, "status": status, "text": text}
def permission(call_id: str, title: str = "run a command") -> dict[str, Any]:
return {"type": "permission", "id": call_id, "title": title}
def fs_read(path: str) -> dict[str, Any]:
return {"type": "fs_read", "path": path}
def result(stop_reason: str = "end_turn") -> dict[str, Any]:
return {"type": "result", "stopReason": stop_reason}
def rpc_error(code: int, msg: str, data: Any = None) -> dict[str, Any]:
return {"type": "error", "code": code, "message": msg, "data": data}
def crash(exit_code: int = 1, stderr: str = "fatal: agent crashed") -> dict[str, Any]:
return {"type": "crash", "code": exit_code, "stderr": stderr}
def hermes_tool_call(call_id: str, name: str, args: dict[str, Any]) -> str:
"""Text a model behind ACP emits to call a Hermes tool (ACP has no OpenAI tools channel)."""
body = {"id": call_id, "type": "function", "function": {"name": name, "arguments": json.dumps(args)}}
return f"<tool_call>{json.dumps(body)}</tool_call>"
class AcpFake:
"""One scenario directory: ``script.json`` in, ``transcript.jsonl`` out, plus a launcher."""
def __init__(self, state_dir: Path, turns: list[list[dict[str, Any]]], *, models: list[str] | None = None,
current_model: str | None = None, ignore_sigterm: bool = False, load_session: bool = True,
aux_text: str = AUX_ANSWER):
self.state_dir = Path(state_dir)
self.state_dir.mkdir(parents=True, exist_ok=True)
script = {"turns": turns, "models": models or [], "current_model": current_model,
"ignore_sigterm": ignore_sigterm, "load_session": load_session, "aux_text": aux_text}
(self.state_dir / "script.json").write_text(json.dumps(script), encoding="utf-8")
self.launcher = self.state_dir / "copilot"
self.launcher.write_text(
f'#!/bin/sh\nexec "{sys.executable}" "{Path(__file__).resolve()}" "$@"\n', encoding="utf-8")
self.launcher.chmod(0o755)
def env(self) -> dict[str, str]:
"""Env vars that point Hermes' copilot-acp client at this fake."""
return {"HERMES_COPILOT_ACP_COMMAND": str(self.launcher),
"HERMES_COPILOT_ACP_ARGS": f"--acp --stdio --state {self.state_dir}"}
def records(self) -> list[dict[str, Any]]:
path = self.state_dir / "transcript.jsonl"
if not path.exists():
return []
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
def inbound(self, method: str | None = None) -> list[dict[str, Any]]:
"""Client->agent messages (requests, notifications, responses), optionally one method."""
return [r for r in self.records() if r["dir"] == "in" and (method is None or r["msg"].get("method") == method)]
def main_prompts(self) -> list[dict[str, Any]]:
"""Main-turn ``session/prompt`` records (those carrying Hermes' tool bridge)."""
return [r for r in self.inbound("session/prompt") if r.get("main")]
def aux_prompts(self) -> list[dict[str, Any]]:
"""Auxiliary ``session/prompt`` records (no tool bridge: compaction summaries, titles)."""
return [r for r in self.inbound("session/prompt") if not r.get("main")]
def invalid(self) -> list[dict[str, Any]]:
return [r for r in self.records() if r["dir"] == "in" and r.get("errors")]
def pids(self) -> list[int]:
return sorted({r["pid"] for r in self.records() if r["dir"] == "spawn"})
def events(self, kind: str) -> list[dict[str, Any]]:
return [r for r in self.records() if r["dir"] == kind]
def prompt_text(record: dict[str, Any]) -> str:
return "".join(b.get("text", "") for b in record["msg"]["params"].get("prompt", []) if b.get("type") == "text")
# ── agent process ────────────────────────────────────────────────────────────────────────────
class Agent:
"""One spawned agent process (one Hermes model call)."""
def __init__(self, state_dir: Path):
from acp import schema # validation/building uses the published ACP models
self.s = schema
self.state_dir = state_dir
self.script = json.loads((state_dir / "script.json").read_text(encoding="utf-8"))
self.initialized = False
self.client_caps: dict[str, Any] = {}
self.sessions: set[str] = set()
self.turn: int | None = None
self.next_id = 1000
self.request_schemas = {
"initialize": schema.InitializeRequest,
"session/new": schema.NewSessionRequest,
"session/load": schema.LoadSessionRequest,
"session/prompt": schema.PromptRequest,
"session/set_model": schema.SetSessionModelRequest,
"session/set_config_option": schema.SetSessionConfigOptionSelectRequest,
"session/cancel": schema.CancelNotification,
}
self.handlers = {
"initialize": self._initialize, "session/new": self._new_session, "session/load": self._load_session,
"session/set_model": self._ack, "session/set_config_option": self._set_config_option,
"session/prompt": self._prompt,
}
# transcript + wire ------------------------------------------------------------------------
def log(self, direction: str, msg: Any = None, **extra: Any) -> None:
rec = {"t": time.time(), "pid": os.getpid(), "turn": self.turn, "dir": direction, "msg": msg, **extra}
with open(self.state_dir / "transcript.jsonl", "a", encoding="utf-8") as fh:
fh.write(json.dumps(rec) + "\n")
def send(self, msg: dict[str, Any]) -> None:
self.log("out", msg)
sys.stdout.write(json.dumps(msg) + "\n")
sys.stdout.flush()
def dump(self, model: Any) -> dict[str, Any]:
return model.model_dump(by_alias=True, exclude_none=True, mode="json")
def reply(self, msg_id: Any, result_model: Any) -> None:
body = result_model if isinstance(result_model, dict) or result_model is None else self.dump(result_model)
self.send({"jsonrpc": "2.0", "id": msg_id, "result": body})
def error(self, msg_id: Any, code: int, text: str, data: Any = None) -> None:
err = {"code": code, "message": text, **({"data": data} if data is not None else {})}
self.send({"jsonrpc": "2.0", "id": msg_id, "error": err})
def update(self, session_id: str, update_model: Any) -> None:
note = self.s.SessionNotification(session_id=session_id, update=update_model)
self.send({"jsonrpc": "2.0", "method": "session/update", "params": self.dump(note)})
def read(self) -> dict[str, Any] | None:
line = sys.stdin.readline()
if not line:
return None
try:
msg = json.loads(line)
except json.JSONDecodeError as exc:
self.log("in", line.rstrip("\n"), errors=[f"parse error: {exc}"])
self.error(None, -32700, "Parse error")
return {}
return msg
# validation -------------------------------------------------------------------------------
def validate(self, msg: dict[str, Any]) -> list[str]:
"""Spec violations of one client request/notification (empty list = valid)."""
errors: list[str] = []
if msg.get("jsonrpc") != "2.0":
errors.append("jsonrpc must be '2.0'")
method = msg.get("method")
if "id" in msg and not isinstance(msg["id"], (int, str)):
errors.append("id must be a string or integer")
model = self.request_schemas.get(method)
if model is None:
return errors
params = msg.get("params")
try:
parsed = model.model_validate(params)
except ValidationError as exc:
return errors + [f"schema: {e['loc']} {e['msg']}" for e in exc.errors()]
allowed = {f.alias or name for name, f in model.model_fields.items()}
errors += [f"custom root field {k!r} (spec: use _meta)" for k in sorted(set(params) - allowed)]
if method != "initialize" and not self.initialized:
errors.append("request before initialize")
if method in ("session/new", "session/load") and not os.path.isabs(parsed.cwd):
errors.append("cwd must be an absolute path")
sid = getattr(parsed, "session_id", None)
if method not in ("session/load",) and sid is not None and sid not in self.sessions:
errors.append(f"unknown sessionId {sid!r}")
return errors
# request handlers -------------------------------------------------------------------------
def _initialize(self, msg_id: Any, params: dict[str, Any]) -> None:
self.initialized = True
self.client_caps = params.get("clientCapabilities") or {}
caps = self.s.AgentCapabilities(load_session=bool(self.script.get("load_session")))
self.reply(msg_id, self.s.InitializeResponse(
protocol_version=PROTOCOL_VERSION, agent_capabilities=caps, auth_methods=[],
agent_info=self.s.Implementation(name="fake-copilot", title="Fake Copilot", version="1.0.0")))
def _session_response(self, session_id: str) -> Any:
models = self.script.get("models") or []
options = None
if models:
current = self.script.get("current_model") or models[0]
options = [self.s.SessionConfigOptionSelect(
id="model", name="Model", category="model", type="select", current_value=current,
options=[self.s.SessionConfigSelectOption(value=m, name=m) for m in models])]
return self.s.NewSessionResponse(session_id=session_id, config_options=options)
def _new_session(self, msg_id: Any, params: dict[str, Any]) -> None:
session_id = f"fake-sess-{os.getpid()}-{len(self.sessions) + 1}"
self.sessions.add(session_id)
self.reply(msg_id, self._session_response(session_id))
def _load_session(self, msg_id: Any, params: dict[str, Any]) -> None:
self.sessions.add(params["sessionId"])
self.reply(msg_id, self.s.LoadSessionResponse())
def _ack(self, msg_id: Any, params: dict[str, Any]) -> None:
self.reply(msg_id, {})
def _set_config_option(self, msg_id: Any, params: dict[str, Any]) -> None:
models = self.script.get("models") or []
if params.get("configId") != "model" or params.get("value") not in models:
self.error(msg_id, -32602, "Invalid params", {"reason": "unknown config option or value"})
return
self.script["current_model"] = params["value"]
self.reply(msg_id, self.s.SetSessionConfigOptionResponse(config_options=self._session_response("x").config_options))
def _claim_turn(self) -> int:
"""Next scripted turn index, shared across every process of the scenario (file lock)."""
with open(self.state_dir / "turn.counter", "a+", encoding="utf-8") as fh:
fcntl.flock(fh, fcntl.LOCK_EX)
fh.seek(0)
index = int(fh.read().strip() or 0)
fh.seek(0)
fh.truncate()
fh.write(str(index + 1))
return index
def _prompt(self, msg_id: Any, params: dict[str, Any]) -> None:
text = "".join(b.get("text", "") for b in params["prompt"] if b.get("type") == "text")
session_id = params["sessionId"]
if TOOLS_MARKER not in text: # auxiliary call (title/summary): never consumes a scripted turn
self.update(session_id, self._chunk("agent_message_chunk", self.script.get("aux_text") or AUX_ANSWER))
self.reply(msg_id, self.s.PromptResponse(stop_reason="end_turn"))
return
turns = self.script.get("turns") or []
index = self._claim_turn()
self.turn = index
self.log("turn", {"index": index})
actions = turns[index] if index < len(turns) else [message("(fake-acp: script exhausted)")]
replied = False
for action in actions:
replied = self._run_action(action, msg_id, session_id) or replied
if not replied:
self.reply(msg_id, self.s.PromptResponse(stop_reason="end_turn"))
# scripted actions -------------------------------------------------------------------------
def _chunk(self, kind: str, text: str) -> Any:
cls = {"agent_message_chunk": self.s.AgentMessageChunk, "agent_thought_chunk": self.s.AgentThoughtChunk}[kind]
return cls(session_update=kind, content=self.s.TextContentBlock(type="text", text=text))
def _run_action(self, action: dict[str, Any], msg_id: Any, session_id: str) -> bool:
"""Perform one scripted action; True when it answered the pending ``session/prompt``."""
if action.get("delay"):
time.sleep(float(action["delay"]))
return bool(self._actions[action["type"]](self, action, msg_id, session_id))
def _a_thought(self, action, msg_id, session_id):
self.update(session_id, self._chunk("agent_thought_chunk", action["text"]))
def _a_message(self, action, msg_id, session_id):
self.update(session_id, self._chunk("agent_message_chunk", action["text"]))
def _a_tool_call(self, action, msg_id, session_id):
self.update(session_id, self.s.ToolCallStart(
session_update="tool_call", tool_call_id=action["id"], title=action["title"], kind=action["kind"],
status=action["status"]))
def _a_tool_update(self, action, msg_id, session_id):
content = None
if action.get("text"):
content = [self.s.ContentToolCallContent(
type="content", content=self.s.TextContentBlock(type="text", text=action["text"]))]
self.update(session_id, self.s.ToolCallProgress(
session_update="tool_call_update", tool_call_id=action["id"], status=action["status"], content=content))
def _a_permission(self, action, msg_id, session_id):
req = self.s.RequestPermissionRequest(
session_id=session_id,
tool_call=self.s.ToolCallUpdate(tool_call_id=action["id"], title=action["title"], kind="execute"),
options=[self.s.PermissionOption(option_id="allow-once", name="Allow once", kind="allow_once"),
self.s.PermissionOption(option_id="reject-once", name="Reject", kind="reject_once")])
response = self._client_request("session/request_permission", self.dump(req))
self._check_response(response, self.s.RequestPermissionResponse, "permission_outcome")
def _a_fs_read(self, action, msg_id, session_id):
if not (self.client_caps.get("fs") or {}).get("readTextFile"):
self.log("fs_skipped", {"reason": "client did not advertise fs.readTextFile"})
return
req = self.s.ReadTextFileRequest(session_id=session_id, path=action["path"])
response = self._client_request("fs/read_text_file", self.dump(req))
self._check_response(response, self.s.ReadTextFileResponse, "fs_read_result")
def _a_result(self, action, msg_id, session_id):
self.reply(msg_id, self.s.PromptResponse(stop_reason=action["stopReason"]))
return True
def _a_error(self, action, msg_id, session_id):
self.error(msg_id, int(action["code"]), action["message"], action.get("data"))
return True
def _a_crash(self, action, msg_id, session_id):
self.log("crash", {"code": action["code"]})
sys.stderr.write(action["stderr"] + "\n")
sys.stderr.flush()
os._exit(int(action["code"]))
_actions = {"thought": _a_thought, "message": _a_message, "tool_call": _a_tool_call, "tool_update": _a_tool_update,
"permission": _a_permission, "fs_read": _a_fs_read, "result": _a_result, "error": _a_error,
"crash": _a_crash}
def _client_request(self, method: str, params: dict[str, Any]) -> dict[str, Any] | None:
self.next_id += 1
req_id = self.next_id
self.send({"jsonrpc": "2.0", "id": req_id, "method": method, "params": params})
while (msg := self.read()) is not None:
if msg.get("id") == req_id and "method" not in msg:
return msg
self.dispatch(msg)
return None
def _check_response(self, response: dict[str, Any] | None, model: Any, kind: str) -> None:
errors: list[str] = []
if response is None:
errors.append("client closed stdin before answering")
elif "result" in response:
try:
model.model_validate(response["result"])
except ValidationError as exc:
errors += [f"schema: {e['loc']} {e['msg']}" for e in exc.errors()]
elif not isinstance((response or {}).get("error"), dict):
errors.append("response carries neither result nor error")
self.log("in", response, kind=kind, errors=errors)
# loop -------------------------------------------------------------------------------------
def dispatch(self, msg: dict[str, Any]) -> None:
method = msg.get("method")
errors = self.validate(msg) if method else ["unsolicited response"]
main = method == "session/prompt" and TOOLS_MARKER in "".join(
b.get("text", "") for b in ((msg.get("params") or {}).get("prompt") or []) if isinstance(b, dict))
self.log("in", msg, errors=errors, main=main)
if "id" not in msg or not method:
return # notification (session/cancel) or stray response
if errors:
code = -32600 if errors == ["request before initialize"] else -32602
self.error(msg["id"], code, "Invalid params" if code == -32602 else "Invalid request", {"errors": errors})
return
handler = self.handlers.get(method)
if handler is None:
self.error(msg["id"], -32601, f"Method not found: {method}")
return
handler(msg["id"], msg.get("params") or {})
def serve(self) -> int:
self.log("spawn", {"argv": sys.argv[1:], "cwd": os.getcwd()})
while (msg := self.read()) is not None:
if msg:
self.dispatch(msg)
if self.script.get("ignore_sigterm"):
# A wedged agent: ignores SIGTERM AND stdin EOF; only SIGKILL ends it.
self.log("wedged", {"reason": "stdin closed; ignoring EOF"})
while True:
time.sleep(3600)
self.log("exit", {"reason": "stdin closed"})
return 0
def _install_signals(agent: Agent) -> None:
def _on_term(signum, _frame):
if agent.script.get("ignore_sigterm"):
agent.log("signal", {"signal": signum, "ignored": True})
return
agent.log("exit", {"reason": f"signal {signum}"})
os._exit(0)
signal.signal(signal.SIGTERM, _on_term)
def main(argv: list[str]) -> int:
if not argv or "-h" in argv or "--help" in argv:
sys.stdout.write(USAGE)
return 0
if "--acp" not in argv or "--state" not in argv:
sys.stderr.write("error: unknown option; this fake only runs with --acp --stdio --state <dir>\n")
return 1
agent = Agent(Path(argv[argv.index("--state") + 1]))
_install_signals(agent)
return agent.serve()
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
-512
View File
@@ -2272,355 +2272,6 @@
]
}
},
"base-url:github-models": {
"api_mode": "codex_responses",
"base_url": "https://models.github.ai/inference",
"high": {
"input": [
{
"content": "list files",
"role": "user",
"type": "message"
},
{
"arguments": "{\"command\": \"ls\"}",
"call_id": "call_1",
"name": "terminal",
"type": "function_call"
},
{
"call_id": "call_1",
"output": "a.txt\nb.txt",
"type": "function_call_output"
},
{
"content": "thanks, now summarize",
"role": "user",
"type": "message"
}
],
"instructions": "You are a test agent.",
"model": "openai/gpt-5.5",
"parallel_tool_calls": true,
"reasoning": {
"effort": "high",
"summary": "auto"
},
"store": false,
"timeout": 1800.0,
"tool_choice": "auto",
"tools": [
{
"description": "Run a shell command.",
"name": "terminal",
"parameters": {
"properties": {
"command": {
"type": "string"
}
},
"required": [
"command"
],
"type": "object"
},
"strict": false,
"type": "function"
}
]
},
"model": "openai/gpt-5.5",
"off": {
"input": [
{
"content": "list files",
"role": "user",
"type": "message"
},
{
"arguments": "{\"command\": \"ls\"}",
"call_id": "call_1",
"name": "terminal",
"type": "function_call"
},
{
"call_id": "call_1",
"output": "a.txt\nb.txt",
"type": "function_call_output"
},
{
"content": "thanks, now summarize",
"role": "user",
"type": "message"
}
],
"instructions": "You are a test agent.",
"model": "openai/gpt-5.5",
"parallel_tool_calls": true,
"store": false,
"timeout": 1800.0,
"tool_choice": "auto",
"tools": [
{
"description": "Run a shell command.",
"name": "terminal",
"parameters": {
"properties": {
"command": {
"type": "string"
}
},
"required": [
"command"
],
"type": "object"
},
"strict": false,
"type": "function"
}
]
},
"unset": {
"input": [
{
"content": "list files",
"role": "user",
"type": "message"
},
{
"arguments": "{\"command\": \"ls\"}",
"call_id": "call_1",
"name": "terminal",
"type": "function_call"
},
{
"call_id": "call_1",
"output": "a.txt\nb.txt",
"type": "function_call_output"
},
{
"content": "thanks, now summarize",
"role": "user",
"type": "message"
}
],
"instructions": "You are a test agent.",
"model": "openai/gpt-5.5",
"parallel_tool_calls": true,
"reasoning": {
"effort": "medium",
"summary": "auto"
},
"store": false,
"timeout": 1800.0,
"tool_choice": "auto",
"tools": [
{
"description": "Run a shell command.",
"name": "terminal",
"parameters": {
"properties": {
"command": {
"type": "string"
}
},
"required": [
"command"
],
"type": "object"
},
"strict": false,
"type": "function"
}
]
}
},
"base-url:github-models-chat": {
"api_mode": "chat_completions",
"base_url": "https://models.github.ai/inference",
"high": {
"extra_body": {
"reasoning": {
"effort": "high"
}
},
"messages": [
{
"content": "You are a test agent.",
"role": "system"
},
{
"content": "list files",
"role": "user"
},
{
"content": "",
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{\"command\": \"ls\"}",
"name": "terminal"
},
"id": "call_1",
"type": "function"
}
]
},
{
"content": "a.txt\nb.txt",
"role": "tool",
"tool_call_id": "call_1"
},
{
"content": "thanks, now summarize",
"role": "user"
}
],
"model": "openai/o4-mini",
"timeout": 1800.0,
"tools": [
{
"function": {
"description": "Run a shell command.",
"name": "terminal",
"parameters": {
"properties": {
"command": {
"type": "string"
}
},
"required": [
"command"
],
"type": "object"
}
},
"type": "function"
}
]
},
"model": "openai/o4-mini",
"off": {
"messages": [
{
"content": "You are a test agent.",
"role": "system"
},
{
"content": "list files",
"role": "user"
},
{
"content": "",
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{\"command\": \"ls\"}",
"name": "terminal"
},
"id": "call_1",
"type": "function"
}
]
},
{
"content": "a.txt\nb.txt",
"role": "tool",
"tool_call_id": "call_1"
},
{
"content": "thanks, now summarize",
"role": "user"
}
],
"model": "openai/o4-mini",
"timeout": 1800.0,
"tools": [
{
"function": {
"description": "Run a shell command.",
"name": "terminal",
"parameters": {
"properties": {
"command": {
"type": "string"
}
},
"required": [
"command"
],
"type": "object"
}
},
"type": "function"
}
]
},
"unset": {
"extra_body": {
"reasoning": {
"effort": "medium"
}
},
"messages": [
{
"content": "You are a test agent.",
"role": "system"
},
{
"content": "list files",
"role": "user"
},
{
"content": "",
"role": "assistant",
"tool_calls": [
{
"function": {
"arguments": "{\"command\": \"ls\"}",
"name": "terminal"
},
"id": "call_1",
"type": "function"
}
]
},
{
"content": "a.txt\nb.txt",
"role": "tool",
"tool_call_id": "call_1"
},
{
"content": "thanks, now summarize",
"role": "user"
}
],
"model": "openai/o4-mini",
"timeout": 1800.0,
"tools": [
{
"function": {
"description": "Run a shell command.",
"name": "terminal",
"parameters": {
"properties": {
"command": {
"type": "string"
}
},
"required": [
"command"
],
"type": "object"
}
},
"type": "function"
}
]
}
},
"base-url:kimi": {
"api_mode": "chat_completions",
"base_url": "https://api.moonshot.ai/v1",
@@ -5132,169 +4783,6 @@
]
}
},
"copilot": {
"api_mode": "codex_responses",
"base_url": "https://api.githubcopilot.com",
"high": {
"input": [
{
"content": "list files",
"role": "user",
"type": "message"
},
{
"arguments": "{\"command\": \"ls\"}",
"call_id": "call_1",
"name": "terminal",
"type": "function_call"
},
{
"call_id": "call_1",
"output": "a.txt\nb.txt",
"type": "function_call_output"
},
{
"content": "thanks, now summarize",
"role": "user",
"type": "message"
}
],
"instructions": "You are a test agent.",
"model": "gpt-5.5",
"parallel_tool_calls": true,
"reasoning": {
"effort": "high",
"summary": "auto"
},
"store": false,
"timeout": 1800.0,
"tool_choice": "auto",
"tools": [
{
"description": "Run a shell command.",
"name": "terminal",
"parameters": {
"properties": {
"command": {
"type": "string"
}
},
"required": [
"command"
],
"type": "object"
},
"strict": false,
"type": "function"
}
]
},
"model": "gpt-5.5",
"off": {
"input": [
{
"content": "list files",
"role": "user",
"type": "message"
},
{
"arguments": "{\"command\": \"ls\"}",
"call_id": "call_1",
"name": "terminal",
"type": "function_call"
},
{
"call_id": "call_1",
"output": "a.txt\nb.txt",
"type": "function_call_output"
},
{
"content": "thanks, now summarize",
"role": "user",
"type": "message"
}
],
"instructions": "You are a test agent.",
"model": "gpt-5.5",
"parallel_tool_calls": true,
"store": false,
"timeout": 1800.0,
"tool_choice": "auto",
"tools": [
{
"description": "Run a shell command.",
"name": "terminal",
"parameters": {
"properties": {
"command": {
"type": "string"
}
},
"required": [
"command"
],
"type": "object"
},
"strict": false,
"type": "function"
}
]
},
"unset": {
"input": [
{
"content": "list files",
"role": "user",
"type": "message"
},
{
"arguments": "{\"command\": \"ls\"}",
"call_id": "call_1",
"name": "terminal",
"type": "function_call"
},
{
"call_id": "call_1",
"output": "a.txt\nb.txt",
"type": "function_call_output"
},
{
"content": "thanks, now summarize",
"role": "user",
"type": "message"
}
],
"instructions": "You are a test agent.",
"model": "gpt-5.5",
"parallel_tool_calls": true,
"reasoning": {
"effort": "medium",
"summary": "auto"
},
"store": false,
"timeout": 1800.0,
"tool_choice": "auto",
"tools": [
{
"description": "Run a shell command.",
"name": "terminal",
"parameters": {
"properties": {
"command": {
"type": "string"
}
},
"required": [
"command"
],
"type": "object"
},
"strict": false,
"type": "function"
}
]
}
},
"custom": {
"api_mode": "chat_completions",
"base_url": "http://127.0.0.1:8080/v1",
+12 -4
View File
@@ -74,12 +74,20 @@ def _same_provider_override(monkeypatch, provider, model, api_mode, base_url, se
def test_same_provider_override_runs_on_the_targets_wire(monkeypatch):
"""Copilot serves GPT-5 on the Responses API and Claude on chat completions."""
"""A provider serving GPT-5 on the Responses API and Claude on chat completions (``api_mode_for_model``)."""
from providers.base import ProviderProfile
class _PerModelWire(ProviderProfile):
def api_mode_for_model(self, model, *, api_key=None):
return "codex_responses" if model.startswith("gpt-5") else "chat_completions"
acme = _PerModelWire(name="acme-wire", base_url="https://api.acme-wire.test")
monkeypatch.setattr("providers.get_provider_profile", lambda name: acme if name == "acme-wire" else None)
runtime = _same_provider_override(
monkeypatch, "copilot", "gpt-5.4", "codex_responses", "https://api.githubcopilot.com",
{"base_url": "https://api.githubcopilot.com", "api_mode": "chat_completions"})
monkeypatch, "acme-wire", "gpt-5.4", "codex_responses", "https://api.acme-wire.test",
{"base_url": "https://api.acme-wire.test", "api_mode": "chat_completions"})
assert runtime["api_mode"] == "codex_responses"
assert (runtime["base_url"], runtime["api_key"]) == ("https://api.githubcopilot.com", "tok-session")
assert (runtime["base_url"], runtime["api_key"]) == ("https://api.acme-wire.test", "tok-session")
def test_same_provider_override_reshapes_the_opencode_endpoint_for_its_wire(monkeypatch):
+18 -82
View File
@@ -15,7 +15,6 @@ from hermes_cli.auth import (
STEPFUN_STEP_PLAN_INTL_BASE_URL,
_resolve_kimi_base_url,
)
from plugins.model_providers.copilot.credentials import _try_gh_cli_token
# =============================================================================
@@ -32,10 +31,6 @@ from plugins.model_providers.copilot.credentials import _try_gh_cli_token
# missing HF_TOKEN/DEEPINFRA_API_KEY, which made the auto-detection tests
# env-dependent (they failed on any machine with HF_TOKEN exported).
from hermes_cli.auth import PROVIDER_REGISTRY as _REGISTRY
import providers as _providers
_providers.get_provider_profile("copilot") # imports the copilot plugin package (credentials module)
from plugins.model_providers.copilot import catalog as copilot_catalog
from plugins.model_providers.copilot import credentials as copilot_credentials
_EXTRA_ENV_VARS = (
# Checked directly in resolve_provider("auto"), not via the registry.
@@ -113,15 +108,6 @@ class TestResolveProvider:
assert parse_model_input("chatgpt-codex:gpt-5.5", "openrouter") == ("openai-codex", "gpt-5.5")
assert _normalize_provider("chatgpt") == "openai-codex"
def test_alias_github_copilot(self):
assert resolve_provider("github-copilot") == "copilot"
def test_alias_github_copilot_acp(self):
assert resolve_provider("github-copilot-acp") == "copilot-acp"
assert resolve_provider("copilot-acp-agent") == "copilot-acp"
def test_alias_hf(self):
assert resolve_provider("hf") == "huggingface"
@@ -194,36 +180,6 @@ class TestApiKeyProviderStatus:
class TestResolveApiKeyProviderCredentials:
def test_try_gh_cli_token_uses_homebrew_path_when_not_on_path(self, monkeypatch, tmp_path):
from plugins.model_providers.copilot.credentials import _invalidate_gh_cli_token_cache
from hermes_platform.resolver import known_dirs
_invalidate_gh_cli_token_cache()
brew = tmp_path / "homebrew" / "bin"
brew.mkdir(parents=True)
gh = brew / "gh"
gh.write_text("#!/bin/sh\n", encoding="utf-8")
gh.chmod(0o755)
monkeypatch.setenv("PATH", str(tmp_path / "empty"))
monkeypatch.setattr(known_dirs, "homebrew_dirs", lambda: (str(brew),))
monkeypatch.setattr(known_dirs, "user_local_bin", lambda: ())
calls = []
class _Result:
returncode = 0
stdout = "gh-cli-secret\n"
def _fake_run(cmd, **kwargs):
calls.append(cmd)
return _Result()
monkeypatch.setattr(copilot_credentials.subprocess, "run", _fake_run)
assert _try_gh_cli_token() == "gh-cli-secret"
assert calls == [[str(gh), "auth", "token"]]
def test_resolve_stepfun_with_key(self, monkeypatch):
monkeypatch.setenv("STEPFUN_API_KEY", "stepfun-secret-key")
creds = resolve_api_key_provider_credentials("stepfun")
@@ -263,52 +219,33 @@ class TestRuntimeProviderResolution:
assert result["provider"] == "kimi-coding"
assert result["api_key"] == "auto-kimi-key"
def test_runtime_copilot_uses_gh_cli_token(self, monkeypatch):
monkeypatch.setattr(copilot_credentials, "_try_gh_cli_token", lambda: "gho_cli_secret")
from hermes_cli.runtime_provider import resolve_runtime_provider
result = resolve_runtime_provider(requested="copilot")
assert result["provider"] == "copilot"
assert result["api_mode"] == "chat_completions"
assert result["api_key"] == "gho_cli_secret"
assert result["base_url"] == "https://api.githubcopilot.com"
def test_runtime_external_process_provider_uses_process_runtime(self, monkeypatch, tmp_path):
import providers
from hermes_cli import auth
from providers.base import ProviderProfile
def test_runtime_copilot_uses_responses_for_gpt_5_4(self, monkeypatch):
monkeypatch.setattr(copilot_credentials, "_try_gh_cli_token", lambda: "gho_cli_secret")
monkeypatch.setattr(
"hermes_cli.runtime_provider._get_model_config",
lambda: {"provider": "copilot", "default": "gpt-5.4"},
)
monkeypatch.setattr(copilot_catalog, "fetch_github_model_catalog",
lambda api_key=None, timeout=5.0: [
{
"id": "gpt-5.4",
"supported_endpoints": ["/responses"],
"capabilities": {"type": "chat"},
}
],
)
from hermes_cli.runtime_provider import resolve_runtime_provider
result = resolve_runtime_provider(requested="copilot")
assert result["provider"] == "copilot"
assert result["api_mode"] == "codex_responses"
def test_runtime_copilot_acp_uses_process_runtime(self, monkeypatch, tmp_path):
cli = tmp_path / ("copilot.exe" if os.name == "nt" else "copilot")
profile = ProviderProfile(name="acme-acp", auth_type="external_process", base_url="acp://acme",
process_command="acme", process_args=("--acp", "--stdio"),
process_args_env_var="HERMES_ACME_ACP_ARGS")
monkeypatch.setitem(providers._REGISTRY, profile.name, profile)
monkeypatch.setattr(providers, "_PROVIDER_LIST_CACHE", None)
monkeypatch.setattr(auth, "PROVIDER_REGISTRY", dict(auth.PROVIDER_REGISTRY))
auth.PROVIDER_REGISTRY[profile.name] = auth.ProviderConfig(
profile.name, profile.name, profile.auth_type, inference_base_url=profile.base_url)
cli = tmp_path / ("acme.exe" if os.name == "nt" else "acme")
cli.write_text("", encoding="utf-8")
cli.chmod(0o755)
monkeypatch.setenv("PATH", str(tmp_path))
monkeypatch.setenv("HERMES_COPILOT_ACP_ARGS", "--acp --stdio --debug")
monkeypatch.setenv("HERMES_ACME_ACP_ARGS", "--acp --stdio --debug")
from hermes_cli.runtime_provider import resolve_runtime_provider
result = resolve_runtime_provider(requested="copilot-acp")
result = resolve_runtime_provider(requested="acme-acp")
assert result["provider"] == "copilot-acp"
assert result["provider"] == "acme-acp"
assert result["api_mode"] == "chat_completions"
assert result["api_key"] == "copilot-acp"
assert result["base_url"] == "acp://copilot"
assert result["api_key"] == "acme-acp"
assert result["base_url"] == "acp://acme"
assert os.path.samefile(result["command"], cli)
assert result["args"] == ["--acp", "--stdio", "--debug"]
@@ -327,7 +264,6 @@ class TestHasAnyProviderConfigured:
hermes_home.mkdir()
monkeypatch.setattr(config_module, "get_env_path", lambda: hermes_home / ".env")
monkeypatch.setattr(config_module, "get_hermes_home", lambda: hermes_home)
monkeypatch.setattr(copilot_credentials, "resolve_copilot_token", lambda: ("", ""))
# Clear all provider env vars so earlier checks don't short-circuit
_all_vars = {"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY",
"ANTHROPIC_TOKEN", "OPENAI_BASE_URL"}
-41
View File
@@ -9,9 +9,6 @@ from unittest.mock import patch
import pytest
import hermes_yaml as yaml
import providers as _providers
_providers.get_provider_profile("copilot") # imports the copilot plugin package (credentials module)
from plugins.model_providers.copilot import credentials as copilot_credentials
def _write_auth_store(tmp_path, payload: dict) -> None:
@@ -915,44 +912,6 @@ def test_seed_from_singletons_respects_hermes_pkce_suppression(tmp_path, monkeyp
assert "hermes_pkce" not in active
def test_auth_remove_copilot_suppresses_all_variants(tmp_path, monkeypatch):
"""Removing any copilot source must suppress gh_cli + all env:* variants
so the duplicate-seed paths don't resurrect the credential.
"""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir(parents=True, exist_ok=True)
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# The copilot pool entry is no longer persisted directly in auth.json —
# `(copilot, gh_cli)` is borrowed and stripped by
# sanitize_borrowed_credential_payload (PR #31416, May 2026). Tokens are
# hydrated at runtime via resolve_copilot_token(). Mock that path so the
# pool has an entry to remove.
_write_auth_store(
tmp_path,
{
"version": 1,
"credential_pool": {"copilot": []},
},
)
from types import SimpleNamespace
from hermes_cli.auth import is_source_suppressed
from hermes_cli.auth_commands import auth_remove_command
with patch.object(copilot_credentials, "resolve_copilot_token",
return_value=("ghp_fake", "gh"),
), patch.object(copilot_credentials, "get_copilot_api_token",
return_value=("ghu_fake_api", None),
):
auth_remove_command(SimpleNamespace(provider="copilot", target="1"))
assert is_source_suppressed("copilot", "gh_cli")
assert is_source_suppressed("copilot", "env:COPILOT_GITHUB_TOKEN")
assert is_source_suppressed("copilot", "env:GH_TOKEN")
assert is_source_suppressed("copilot", "env:GITHUB_TOKEN")
def test_auth_remove_env_seeded_dotenv_with_bom_no_shell_hint(tmp_path, monkeypatch, capsys):
"""A Notepad-edited .env carries a UTF-8 BOM. The dotenv-vs-shell
detector must still see the first variable as living in .env (and not
@@ -1,166 +0,0 @@
"""Tests for GitHub Copilot entries shown in the /model picker."""
import os
from unittest.mock import patch
import pytest
from hermes_cli.model_switch import list_authenticated_providers
from hermes_cli import model_switch_providers
from hermes_cli import models
from hermes_cli.models import provider_model_ids
import providers as _providers
_providers.get_provider_profile("copilot") # imports the copilot plugin package (catalog module)
from plugins.model_providers.copilot import catalog as copilot_catalog
@patch.dict(os.environ, {"GH_TOKEN": "test-key"}, clear=False)
def test_copilot_picker_uses_live_catalog_when_available():
live_models = ["gpt-5.4", "claude-sonnet-4.6", "gemini-3.1-pro-preview"]
with patch("agent.models_dev.fetch_models_dev", return_value={}), \
patch.object(copilot_catalog, "_resolve_copilot_catalog_api_key", return_value="gh-token"), \
patch.object(copilot_catalog, "_fetch_github_models", return_value=live_models):
providers = list_authenticated_providers(current_provider="openrouter", max_models=50)
copilot = next((p for p in providers if p["slug"] == "copilot"), None)
assert copilot is not None
assert copilot["models"] == live_models
assert copilot["total_models"] == len(live_models)
# --- copilot-acp: external_process availability (#63662) -------------------
#
# copilot-acp holds no API key, OAuth token, or credential-pool entry by
# design — the spawned `copilot --acp --stdio` subprocess brings its own auth.
# The picker loop used to filter it out unconditionally (has_creds never had
# an external_process branch), so the provider was invisible in every picker
# even with a perfectly resolvable executable.
@pytest.fixture()
def _no_other_copilot_creds(monkeypatch):
"""Make sure copilot-acp visibility comes ONLY from executable resolution:
no env tokens, no configured ACP endpoint, no auth-store entry, no seeded
credential pool."""
# COPILOT_ACP_BASE_URL is not a credential, but an `acp+tcp://` value marks
# the provider configured with no executable at all (hermes_cli/auth.py), so
# a host that sets it would decide the outcome instead of the test.
for var in ("GH_TOKEN", "GITHUB_TOKEN", "HERMES_COPILOT_ACP_COMMAND",
"COPILOT_CLI_PATH", "COPILOT_ACP_BASE_URL"):
monkeypatch.delenv(var, raising=False)
from hermes_cli import auth
from hermes_cli import model_switch
monkeypatch.setattr(auth, "_load_auth_store", dict)
monkeypatch.setattr(model_switch_providers, "_credential_pool_is_usable", lambda *a, **k: False)
def test_copilot_acp_listed_when_executable_resolves(tmp_path, monkeypatch, _no_other_copilot_creds):
fake = tmp_path / ("copilot.exe" if os.name == "nt" else "copilot")
fake.write_text("", encoding="utf-8")
fake.chmod(0o755)
monkeypatch.setenv("HERMES_COPILOT_ACP_COMMAND", str(fake))
with patch("agent.models_dev.fetch_models_dev", return_value={}), \
patch.object(copilot_catalog, "_resolve_copilot_catalog_api_key", return_value=None), \
patch.object(copilot_catalog, "_fetch_github_models", return_value=[]):
providers = list_authenticated_providers(current_provider="openrouter", max_models=50)
acp = next((p for p in providers if p["slug"] == "copilot-acp"), None)
assert acp is not None, "copilot-acp must be listed when its executable resolves"
assert acp["models"], "copilot-acp row must offer at least the curated fallback models"
def test_copilot_acp_hidden_when_executable_missing(monkeypatch, _no_other_copilot_creds):
# `copilot` may genuinely be installed on a dev machine — force the
# resolution miss so the test pins behaviour, not the host's PATH.
from hermes_cli import auth
monkeypatch.setattr(auth.shutil, "which", lambda *_a, **_k: None)
with patch("agent.models_dev.fetch_models_dev", return_value={}), \
patch.object(copilot_catalog, "_resolve_copilot_catalog_api_key", return_value=None), \
patch.object(copilot_catalog, "_fetch_github_models", return_value=[]):
providers = list_authenticated_providers(current_provider="openrouter", max_models=50)
assert all(p["slug"] != "copilot-acp" for p in providers), \
"copilot-acp must stay hidden when no executable resolves"
_ACP_CREDS = {"api_key": "copilot-acp", "base_url": "acp://copilot", "command": "copilot", "args": ["--acp", "--stdio"]}
def _acp_catalog():
import providers
providers.get_provider_profile("copilot-acp") # discovery imports the bundled plugin package
from plugins.model_providers.copilot_acp import catalog
return catalog
@pytest.fixture()
def _fresh_acp_memo(monkeypatch):
monkeypatch.setattr(_acp_catalog(), "_session_memo", None)
yield
# Don't leak a memoized (possibly failed) probe into other tests in this process.
monkeypatch.setattr(_acp_catalog(), "_session_memo", None)
def _copilot_acp_client():
import providers
providers.get_provider_profile("copilot-acp") # discovery imports the bundled plugin package
from plugins.model_providers.copilot_acp.client import CopilotACPClient
return CopilotACPClient
@pytest.mark.parametrize(
("session_probe", "github_token", "github_models", "expected"),
[
# Signed-in session, no GitHub token anywhere: the session list wins, the API is never asked.
({"return_value": ["auto", "gpt-5.6-sol", "claude-sonnet-5"]}, "", [], ["auto", "gpt-5.6-sol", "claude-sonnet-5"]),
# Session probe fails: token-based GitHub discovery is still the next source.
({"side_effect": TimeoutError("probe timeout")}, "catalog-token", ["api-fallback-model"], ["api-fallback-model"]),
],
ids=["session-wins-without-token", "github-fallback-when-probe-fails"],
)
def test_copilot_acp_catalog_prefers_authenticated_session(
_fresh_acp_memo, session_probe, github_token, github_models, expected):
with patch("hermes_cli.auth.resolve_external_process_provider_credentials", return_value=_ACP_CREDS), \
patch.object(_copilot_acp_client(), "list_models", **session_probe) as list_models, \
patch.object(copilot_catalog, "_resolve_copilot_catalog_api_key", return_value=github_token), \
patch.object(copilot_catalog, "_fetch_github_models", return_value=github_models) as github:
assert provider_model_ids("copilot-acp", force_refresh=True) == expected
list_models.assert_called_once()
assert github.called is bool(github_token)
def test_copilot_acp_session_probe_is_memoized_across_model_switch_validation(_fresh_acp_memo):
"""``/model`` validation reads the catalog uncached on every switch; each miss spawns the CLI.
A run of switches must pay one probe, and a failed probe must not be retried per switch."""
from hermes_cli.models_validate import validate_requested_model
with patch("hermes_cli.auth.resolve_external_process_provider_credentials", return_value=_ACP_CREDS), \
patch.object(_copilot_acp_client(), "list_models", return_value=["gpt-5.6-terra"]) as list_models, \
patch.object(copilot_catalog, "_resolve_copilot_catalog_api_key", return_value=""), \
patch.object(copilot_catalog, "_fetch_github_models", return_value=[]):
for _ in range(3):
verdict = validate_requested_model("gpt-5.6-terra", "copilot-acp", api_key="copilot-acp", base_url="acp://copilot")
assert verdict["accepted"] and verdict["recognized"]
assert list_models.call_count == 1
_acp_catalog()._session_memo = None # (teardown in _fresh_acp_memo restores it)
with patch("hermes_cli.auth.resolve_external_process_provider_credentials", return_value=_ACP_CREDS), \
patch.object(_copilot_acp_client(), "list_models", side_effect=RuntimeError("not signed in")) as list_models, \
patch.object(copilot_catalog, "_resolve_copilot_catalog_api_key", return_value=""), \
patch.object(copilot_catalog, "_fetch_github_models", return_value=[]):
for _ in range(3):
provider_model_ids("copilot-acp")
assert list_models.call_count == 1
@@ -1,28 +0,0 @@
"""Tests for Copilot model API-mode routing."""
from __future__ import annotations
import providers as _providers
_providers.get_provider_profile("copilot") # imports the copilot plugin package (wire module)
from plugins.model_providers.copilot import wire as copilot_wire
def test_copilot_claude_stays_on_chat_completions_even_if_catalog_lists_messages():
copilot_model_api_mode = copilot_wire.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():
copilot_model_api_mode = copilot_wire.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"
@@ -1,96 +0,0 @@
"""Tests for Copilot runtime api_mode resolution."""
from __future__ import annotations
from types import SimpleNamespace
import pytest
import providers as _providers
_providers.get_provider_profile("copilot") # imports the copilot plugin package (wire module)
from plugins.model_providers.copilot import wire as copilot_wire
def test_copilot_runtime_api_mode_still_uses_default_without_target(monkeypatch):
from hermes_cli import runtime_provider as rp
monkeypatch.setattr(
copilot_wire, "model_api_mode",
lambda model, api_key=None: "codex_responses" if str(model).startswith("gpt-5") else "chat_completions",
)
assert rp._profile_runtime_api_mode(
"copilot", {"provider": "copilot", "default": "gpt-5.5"},
"token",
) == "codex_responses"
@pytest.mark.parametrize("credential_source", ["pool", "explicit", "env", "hermes-auth-store"])
@pytest.mark.parametrize(
("configured_model", "target_model", "expected_mode"),
[
("gpt-5.5", "claude-opus-4.8", "chat_completions"),
("gpt-5.5", "gemini-3-pro-preview", "chat_completions"),
("claude-opus-4.8", "gpt-5.5", "codex_responses"),
],
)
def test_resolver_routes_copilot_by_target_model_for_every_credential_path(
monkeypatch,
credential_source,
configured_model,
target_model,
expected_mode,
):
"""The public resolver must propagate the target through every auth path."""
import providers
from hermes_cli import runtime_provider as rp
providers.get_provider_profile("copilot") # imports the copilot plugin package (catalog module)
from plugins.model_providers.copilot import catalog as copilot_catalog
model_cfg = {"provider": "copilot", "default": configured_model}
monkeypatch.setattr(rp, "_get_model_config", lambda: model_cfg)
monkeypatch.setattr(rp, "resolve_provider", lambda *_args, **_kwargs: "copilot")
# Keep this a real Copilot model_api_mode decision without making a live
# catalog request. The API-mode contract is determined by the model family.
monkeypatch.setattr(copilot_catalog, "fetch_github_model_catalog", lambda **_kwargs: [])
kwargs = {"requested": "copilot", "target_model": target_model}
if credential_source == "pool":
entry = SimpleNamespace(
runtime_api_key="pool-token",
access_token="pool-token",
runtime_base_url="https://api.githubcopilot.com",
base_url="https://api.githubcopilot.com",
source="pool",
)
pool = SimpleNamespace(
has_credentials=lambda: True,
select=lambda **_kw: entry,
)
monkeypatch.setattr(rp, "load_pool", lambda _provider: pool)
elif credential_source == "explicit":
kwargs["explicit_api_key"] = "explicit-token"
monkeypatch.setattr(
rp,
"load_pool",
lambda _provider: pytest.fail("explicit credentials must bypass the pool"),
)
else:
monkeypatch.setattr(rp, "load_pool", lambda _provider: None)
monkeypatch.setattr(
rp,
"resolve_api_key_provider_credentials",
lambda _provider: {
"api_key": f"{credential_source}-token",
"base_url": "https://api.githubcopilot.com",
"source": credential_source,
},
)
runtime = rp.resolve_runtime_provider(**kwargs)
assert runtime["provider"] == "copilot"
assert runtime["api_mode"] == expected_mode
assert runtime["source"] == credential_source
@@ -1,57 +0,0 @@
"""Regression tests for #46527: Copilot primary api_mode resolution must not
honor a stale/incompatible explicit ``api_mode``.
The gateway resolves and snapshots the primary provider/model at startup via
``resolve_runtime_provider`` -> ``_profile_runtime_api_mode``. When the config
carries a stale ``api_mode: codex_responses`` (e.g. the Copilot OAuth setup
wrote it for the ``gpt-5.4-mini`` it initially selects, then the user switched
to ``gpt-5-mini``) the old code honored the stale ``codex_responses`` verbatim.
That dispatches gpt-5-mini through the Responses API — which Copilot serves on
chat completions only — and the response comes back with no reasoning/thinking
content (the symptom in #46527).
"""
from functools import partial
from hermes_cli.runtime_provider import _profile_runtime_api_mode
_copilot_runtime_api_mode = partial(_profile_runtime_api_mode, "copilot")
def test_stale_codex_responses_vetoed_for_gpt5_mini():
"""gpt-5-mini + stale api_mode=codex_responses must resolve to chat_completions."""
cfg = {"provider": "copilot", "default": "gpt-5-mini", "api_mode": "codex_responses"}
assert _copilot_runtime_api_mode(cfg, api_key="x") == "chat_completions"
def test_explicit_codex_responses_kept_for_gpt5_variant():
"""gpt-5.4-mini legitimately uses the Responses API on Copilot — an explicit
codex_responses must still be honored (no over-correction)."""
cfg = {"provider": "copilot", "default": "gpt-5.4-mini", "api_mode": "codex_responses"}
assert _copilot_runtime_api_mode(cfg, api_key="x") == "codex_responses"
def test_no_explicit_mode_derives_chat_completions_for_gpt5_mini():
"""Without an explicit api_mode, gpt-5-mini derives chat_completions (unchanged)."""
cfg = {"provider": "copilot", "default": "gpt-5-mini"}
assert _copilot_runtime_api_mode(cfg, api_key="x") == "chat_completions"
def test_explicit_chat_completions_unchanged():
"""An explicit chat_completions stays chat_completions for gpt-5-mini."""
cfg = {"provider": "copilot", "default": "gpt-5-mini", "api_mode": "chat_completions"}
assert _copilot_runtime_api_mode(cfg, api_key="x") == "chat_completions"
def test_stale_mode_from_different_provider_not_honored():
"""When the config's provider doesn't match copilot, the explicit mode is
not honored at all (existing _provider_supports_explicit_api_mode guard),
so gpt-5-mini still derives chat_completions."""
cfg = {"provider": "nous", "default": "gpt-5-mini", "api_mode": "codex_responses"}
assert _copilot_runtime_api_mode(cfg, api_key="x") == "chat_completions"
def test_target_model_beats_stale_default_mode():
"""A /model switch to gpt-5-mini must not inherit a stale codex_responses
recorded for the persisted default."""
cfg = {"provider": "copilot", "default": "gpt-5.4-mini", "api_mode": "codex_responses"}
assert _copilot_runtime_api_mode(cfg, api_key="x", target_model="gpt-5-mini") == "chat_completions"
@@ -1,8 +1,7 @@
"""Regression tests for the #91912 salvage — credential-pool handlers off-loop
and the bounded Copilot token exchange.
"""Regression tests for the #91912 salvage — credential-pool handlers off-loop.
The 2026-08-22 incident: ``GET /api/credentials/pool`` ran ``load_pool()`` on
the uvicorn event loop; for the copilot provider that reaches
the uvicorn event loop; for a token-exchange provider that reaches
``urllib.request.urlopen`` whose ``timeout`` does not bound ``getaddrinfo``,
so a networkless host froze the whole dashboard backend for 17 minutes.
"""
@@ -10,159 +9,10 @@ so a networkless host froze the whole dashboard backend for 17 minutes.
from __future__ import annotations
import threading
import time
from unittest.mock import patch
import pytest
import hermes_cli.web_routers.ops as _rt_ops
import providers as _providers
_providers.get_provider_profile("copilot") # imports the copilot plugin package (credentials module)
from plugins.model_providers.copilot import credentials as copilot_credentials
# ---------------------------------------------------------------------------
# _urlopen_bounded
# ---------------------------------------------------------------------------
class TestUrlopenBounded:
def test_reraises_worker_exception(self):
with patch("urllib.request.urlopen", side_effect=OSError("boom")):
with pytest.raises(OSError, match="boom"):
copilot_credentials._urlopen_bounded("req", 1.0)
def test_hard_cap_fires_on_hung_resolver_and_closes_late_response(self, monkeypatch):
"""A urlopen that hangs past timeout + grace must raise TimeoutError
promptly, and when the abandoned worker later *succeeds* it must close
the response instead of leaking the socket."""
monkeypatch.setattr(copilot_credentials, "_DNS_GRACE_SECONDS", 0.05)
release = threading.Event()
closed = threading.Event()
class _LateResponse:
def close(self):
closed.set()
def hung_urlopen(req, timeout):
release.wait(timeout=5)
return _LateResponse()
with patch("urllib.request.urlopen", side_effect=hung_urlopen):
started = time.monotonic()
with pytest.raises(TimeoutError, match="hard cap"):
copilot_credentials._urlopen_bounded("req", 0.05)
elapsed = time.monotonic() - started
assert elapsed < 2.0
release.set()
assert closed.wait(timeout=2), "late response was not closed"
# ---------------------------------------------------------------------------
# single-flight exchange
# ---------------------------------------------------------------------------
class TestExchangeSingleFlight:
@pytest.fixture(autouse=True)
def _clean_caches(self, monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
copilot_credentials._jwt_cache.clear()
copilot_credentials._exchange_failure_cache.clear()
copilot_credentials._exchange_locks.clear()
yield
copilot_credentials._jwt_cache.clear()
copilot_credentials._exchange_failure_cache.clear()
copilot_credentials._exchange_locks.clear()
def test_concurrent_callers_share_one_exchange(self, monkeypatch):
"""N concurrent callers for the same token must perform ONE network
exchange; the rest wait on the lock and hit the populated cache."""
monkeypatch.setattr(copilot_credentials, "_load_jwt_from_disk", lambda fp: None)
monkeypatch.setattr(copilot_credentials, "_save_jwt_to_disk", lambda *a, **k: None)
calls = []
gate = threading.Event()
class _Resp:
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def read(self):
return b'{"token": "tid=1;exp=9", "expires_at": 4102444800}'
def fake_bounded(req, timeout):
calls.append(threading.get_ident())
gate.wait(timeout=5) # hold the first exchange open while others queue
return _Resp()
monkeypatch.setattr(copilot_credentials, "_urlopen_bounded", fake_bounded)
results = []
threads = [
threading.Thread(target=lambda: results.append(copilot_credentials.exchange_copilot_token("ghu_" + "x" * 30)))
for _ in range(8)
]
for t in threads:
t.start()
time.sleep(0.2) # let every caller reach the lock
assert len(calls) == 1
gate.set()
for t in threads:
t.join(timeout=5)
assert len(calls) == 1
assert len(results) == 8
assert {r[0] for r in results} == {"tid=1;exp=9"}
def test_waiters_observe_negative_cache_after_failed_exchange(self, monkeypatch):
"""When the single in-flight exchange fails, queued callers must not
each start their own exchange — they see the negative cache."""
monkeypatch.setattr(copilot_credentials, "_load_jwt_from_disk", lambda fp: None)
monkeypatch.setattr(copilot_credentials, "_EXCHANGE_MAX_ATTEMPTS", 1)
calls = []
gate = threading.Event()
def fake_bounded(req, timeout):
calls.append(1)
gate.wait(timeout=5)
raise TimeoutError("hard cap")
monkeypatch.setattr(copilot_credentials, "_urlopen_bounded", fake_bounded)
errors = []
def run():
try:
copilot_credentials.exchange_copilot_token("ghu_" + "y" * 30)
except ValueError as exc:
errors.append(str(exc))
threads = [threading.Thread(target=run) for _ in range(5)]
for t in threads:
t.start()
time.sleep(0.2)
gate.set()
for t in threads:
t.join(timeout=5)
assert len(calls) == 1
assert len(errors) == 5
assert any("recently failed" in e for e in errors)
def test_negative_cache_short_circuits_before_taking_the_lock(self, monkeypatch):
"""While one exchange holds the lock, a caller whose fingerprint is
already in the failure cache must raise immediately rather than park
an executor thread behind the holder."""
fp = copilot_credentials._token_fingerprint("ghu_" + "z" * 30)
copilot_credentials._exchange_failure_cache[fp] = time.time() + 60
lock = copilot_credentials._exchange_lock_for(fp)
lock.acquire() # simulate an in-flight holder
try:
started = time.monotonic()
with pytest.raises(ValueError, match="recently failed"):
copilot_credentials.exchange_copilot_token("ghu_" + "z" * 30)
assert time.monotonic() - started < 0.5
finally:
lock.release()
# ---------------------------------------------------------------------------
# web_server credential-pool handlers off the loop
@@ -1,16 +1,15 @@
"""Tests for external-process provider auth status and Accounts-tab wiring.
Covers the copilot-acp fix class:
Covers the external-process (ACP) fix class, driven through a fake profile:
* ``get_auth_status()`` dispatches on ``auth_type == "external_process"``
(not a hardcoded slug), so future ACP-style providers inherit the
behaviour automatically.
* ``auth_verified``/``auth_source`` carry positive credential evidence
(env token or on-disk GitHub Copilot credential store) while remaining
(the profile's ``external_auth_evidence()`` answer) while remaining
honest — no evidence means unknown, never "signed out".
* The Accounts-tab sign-in ``cli_command`` reflects the executable the
user actually configured (``HERMES_COPILOT_ACP_COMMAND`` /
``COPILOT_CLI_PATH``), and its default is a valid Copilot CLI
invocation (``copilot login`` — ``copilot /login`` is not a command).
user actually configured (the profile's ``process_command_env_vars``),
and its default is the profile's own CLI invocation.
"""
import os
@@ -22,110 +21,129 @@ from hermes_cli.auth import (
get_external_process_provider_status,
resolve_external_process_provider_credentials,
)
import providers as _providers
_providers.get_provider_profile("copilot") # imports the copilot plugin package (credentials module)
from plugins.model_providers.copilot import credentials as copilot_credentials
from providers.base import ProviderProfile
class _AcmeAcpProfile(ProviderProfile):
"""An ACP CLI whose login evidence is whatever the test sets on ``evidence``."""
evidence = None
def external_auth_evidence(self):
return self.evidence
@pytest.fixture()
def _clean_copilot_env(tmp_path, monkeypatch):
"""Neutralize host state so tests pin behaviour, not this machine."""
def acme_acp(tmp_path, monkeypatch):
"""Register a fake external-process profile and neutralize host state so tests pin behaviour."""
import providers
from pathlib import Path
from hermes_cli import auth
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("USERPROFILE", str(tmp_path))
for var in (
"COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN",
"HERMES_COPILOT_ACP_COMMAND", "COPILOT_CLI_PATH",
"HERMES_COPILOT_ACP_ARGS", "COPILOT_ACP_BASE_URL",
):
for var in ("HERMES_ACME_ACP_COMMAND", "HERMES_ACME_ACP_ARGS", "ACME_ACP_BASE_URL"):
monkeypatch.delenv(var, raising=False)
profile = _AcmeAcpProfile(name="acme-acp", auth_type="external_process", base_url="acp://acme",
process_command="acme", process_args=("--acp", "--stdio"),
process_command_env_vars=("HERMES_ACME_ACP_COMMAND",),
process_args_env_var="HERMES_ACME_ACP_ARGS",
base_url_env_var="ACME_ACP_BASE_URL")
monkeypatch.setitem(providers._REGISTRY, profile.name, profile)
monkeypatch.setattr(providers, "_PROVIDER_LIST_CACHE", None)
monkeypatch.setattr(auth, "PROVIDER_REGISTRY", dict(auth.PROVIDER_REGISTRY))
auth.PROVIDER_REGISTRY[profile.name] = auth.ProviderConfig(
profile.name, profile.name, profile.auth_type, inference_base_url=profile.base_url,
base_url_env_var=profile.base_url_env_var)
return profile
# --- get_auth_status dispatches on auth_type, not slug ----------------------
def test_get_auth_status_dispatches_external_process_by_auth_type(
tmp_path, monkeypatch, _clean_copilot_env
tmp_path, monkeypatch, acme_acp
):
fake = tmp_path / ("copilot.exe" if os.name == "nt" else "copilot")
fake = tmp_path / ("acme.exe" if os.name == "nt" else "acme")
fake.write_text("", encoding="utf-8")
fake.chmod(0o755)
monkeypatch.setenv("HERMES_COPILOT_ACP_COMMAND", str(fake))
monkeypatch.setenv("HERMES_ACME_ACP_COMMAND", str(fake))
# Point HOME somewhere empty so on-disk credential stores don't leak in.
monkeypatch.setenv("HOME", str(tmp_path))
status = get_auth_status("copilot-acp")
status = get_auth_status("acme-acp")
# The external_process status shape, not the {"logged_in": False}
# fallthrough — proves the dispatcher reached the right branch.
assert status.get("provider") == "copilot-acp"
assert status.get("provider") == "acme-acp"
assert status.get("configured") is True
assert status.get("resolved_command") == str(fake)
assert "auth_verified" in status
def test_external_process_status_finds_user_local_cli_outside_backend_path(
tmp_path, monkeypatch, _clean_copilot_env
tmp_path, monkeypatch, acme_acp
):
"""Desktop SSH inventory must discover a CLI installed in the user's local bin."""
local_bin = tmp_path / ".local" / "bin"
local_bin.mkdir(parents=True)
cli = local_bin / "copilot"
cli = local_bin / "acme"
cli.write_text("", encoding="utf-8")
cli.chmod(0o755)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("PATH", str(tmp_path / "backend-bin"))
status = get_external_process_provider_status("copilot-acp")
status = get_external_process_provider_status("acme-acp")
assert status["configured"] is True
assert status["resolved_command"] == str(cli)
assert resolve_external_process_provider_credentials("copilot-acp")["command"] == str(cli)
assert resolve_external_process_provider_credentials("acme-acp")["command"] == str(cli)
@pytest.mark.platforms("windows")
def test_external_process_status_finds_user_profile_local_cli_as_native_path(
tmp_path, monkeypatch, _clean_copilot_env
tmp_path, monkeypatch, acme_acp
):
"""The Windows user-local hit is one native path, not a mixed-separator join."""
local_bin = tmp_path / ".local" / "bin"
local_bin.mkdir(parents=True)
cli = local_bin / "copilot.exe"
cli = local_bin / "acme.exe"
cli.write_text("", encoding="utf-8")
monkeypatch.setenv("PATH", str(tmp_path / "backend-bin"))
status = get_external_process_provider_status("copilot-acp")
status = get_external_process_provider_status("acme-acp")
resolved = status["resolved_command"]
assert status["configured"] is True
assert "/" not in resolved and os.path.samefile(resolved, cli)
assert resolve_external_process_provider_credentials("copilot-acp")["command"] == resolved
assert resolve_external_process_provider_credentials("acme-acp")["command"] == resolved
def test_external_process_status_resolves_relative_configured_path_from_cwd(
tmp_path, monkeypatch, _clean_copilot_env
tmp_path, monkeypatch, acme_acp
):
"""An operator's relative command path keeps resolving against the launch cwd."""
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
cli = bin_dir / ("copilot.exe" if os.name == "nt" else "copilot")
cli = bin_dir / ("acme.exe" if os.name == "nt" else "acme")
cli.write_text("", encoding="utf-8")
cli.chmod(0o755)
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("PATH", str(tmp_path / "backend-bin"))
monkeypatch.setenv("HERMES_COPILOT_ACP_COMMAND", os.path.join(".", "bin", cli.name))
monkeypatch.setenv("HERMES_ACME_ACP_COMMAND", os.path.join(".", "bin", cli.name))
status = get_external_process_provider_status("copilot-acp")
status = get_external_process_provider_status("acme-acp")
assert status["configured"] is True
assert status["resolved_command"] == str(cli)
assert resolve_external_process_provider_credentials("copilot-acp")["command"] == str(cli)
assert resolve_external_process_provider_credentials("acme-acp")["command"] == str(cli)
@pytest.mark.platforms("posix")
@pytest.mark.parametrize("preferred", ["claude-prefix", "user-local", "path", "explicit"])
def test_external_process_claude_resolution_preserves_precedence(
tmp_path, monkeypatch, _clean_copilot_env, preferred
tmp_path, monkeypatch, acme_acp, preferred
):
"""Generic discovery must retain Claude's extra prefixes and command precedence."""
candidates = {}
@@ -145,15 +163,15 @@ def test_external_process_claude_resolution_preserves_precedence(
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("PATH", str(tmp_path / "backend-bin"))
monkeypatch.setenv(
"HERMES_COPILOT_ACP_COMMAND",
"HERMES_ACME_ACP_COMMAND",
str(candidates[preferred]) if preferred == "explicit" else "claude",
)
status = get_external_process_provider_status("copilot-acp")
status = get_external_process_provider_status("acme-acp")
assert status["configured"] is True
assert status["resolved_command"] == str(candidates[preferred])
assert resolve_external_process_provider_credentials("copilot-acp")["command"] == str(candidates[preferred])
assert resolve_external_process_provider_credentials("acme-acp")["command"] == str(candidates[preferred])
def test_external_process_status_rejects_wrong_auth_type():
@@ -166,217 +184,77 @@ def test_external_process_status_rejects_wrong_auth_type():
# --- auth_verified: positive evidence only ----------------------------------
def test_auth_verified_false_without_evidence(tmp_path, monkeypatch, _clean_copilot_env):
monkeypatch.setenv("HOME", str(tmp_path)) # no ~/.config/github-copilot
status = get_external_process_provider_status("copilot-acp")
def test_auth_verified_false_without_evidence(tmp_path, monkeypatch, acme_acp):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("PATH", str(tmp_path / "backend-bin")) # no binary, and the profile offers no evidence
status = get_external_process_provider_status("acme-acp")
assert status["auth_verified"] is False
assert status["auth_source"] is None
def test_auth_verified_from_supported_env_token(tmp_path, monkeypatch, _clean_copilot_env):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("GH_TOKEN", "gho_" + "x" * 36) # supported OAuth prefix
status = get_external_process_provider_status("copilot-acp")
assert status["auth_verified"] is True
assert status["auth_source"] == "env: GH_TOKEN"
def test_classic_pat_is_not_login_evidence(tmp_path, monkeypatch, _clean_copilot_env):
# ghp_* classic PATs are rejected by the Copilot API — presence of one
# must not be presented as a working login.
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("GH_TOKEN", "ghp_" + "x" * 36)
status = get_external_process_provider_status("copilot-acp")
assert status["auth_verified"] is False
def test_auth_verified_from_on_disk_credential_store(tmp_path, monkeypatch, _clean_copilot_env):
monkeypatch.setenv("HOME", str(tmp_path))
store = tmp_path / ".config" / "github-copilot"
store.mkdir(parents=True)
(store / "hosts.json").write_text(
'{"github.com": {"oauth_token": "gho_test"}}', encoding="utf-8"
)
status = get_external_process_provider_status("copilot-acp")
assert status["auth_verified"] is True
assert status["auth_source"] == "~/.config/github-copilot/hosts.json"
def test_empty_credential_store_is_not_evidence(tmp_path, monkeypatch, _clean_copilot_env):
monkeypatch.setenv("HOME", str(tmp_path))
store = tmp_path / ".config" / "github-copilot"
store.mkdir(parents=True)
(store / "hosts.json").write_text("{}", encoding="utf-8") # logged out
status = get_external_process_provider_status("copilot-acp")
assert status["auth_verified"] is False
def test_auth_verified_from_copilot_cli_plaintext_store(tmp_path, monkeypatch, _clean_copilot_env):
# `copilot login` without an OS keychain writes the token into
# ~/.copilot/config.json (JSONC, with //-comment header lines).
monkeypatch.setenv("HOME", str(tmp_path))
cfg_dir = tmp_path / ".copilot"
cfg_dir.mkdir()
(cfg_dir / "config.json").write_text(
"// User settings belong in settings.json.\n"
"// This file is managed automatically.\n"
"{\n"
' "copilotTokens": {"https://github.com:someuser": "gho_test"},\n'
' "lastLoggedInUser": {"host": "https://github.com", "login": "someuser"}\n'
"}\n",
encoding="utf-8",
)
status = get_external_process_provider_status("copilot-acp")
assert status["auth_verified"] is True
assert status["auth_source"] == "~/.copilot/config.json"
def test_copilot_cli_store_without_tokens_is_not_evidence(tmp_path, monkeypatch, _clean_copilot_env):
# A config.json exists after first launch even before any login —
# its presence alone must not read as signed-in.
monkeypatch.setenv("HOME", str(tmp_path))
cfg_dir = tmp_path / ".copilot"
cfg_dir.mkdir()
(cfg_dir / "config.json").write_text(
'// managed\n{"firstLaunchAt": "2026-01-01T00:00:00Z", "copilotTokens": {}}\n',
encoding="utf-8",
)
status = get_external_process_provider_status("copilot-acp")
assert status["auth_verified"] is False
# --- desktop picker explicit-only filter ------------------------------------
def test_explicit_filter_keeps_signed_in_external_process_row(tmp_path, monkeypatch, _clean_copilot_env):
def test_explicit_filter_keeps_signed_in_external_process_row(tmp_path, monkeypatch, acme_acp):
# A verified CLI login leaves no trace in active_provider/config/env —
# the explicit-only desktop filter must treat it like the Anthropic OAuth
# carve-out and keep the row.
from hermes_cli.inventory import _filter_explicit_provider_rows
monkeypatch.setenv("HOME", str(tmp_path))
cfg_dir = tmp_path / ".copilot"
cfg_dir.mkdir()
(cfg_dir / "config.json").write_text(
'{"copilotTokens": {"https://github.com:u": "gho_test"}}', encoding="utf-8"
)
monkeypatch.setattr(acme_acp, "evidence", (True, "~/.acme/credentials.json"), raising=False)
class _Ctx:
current_provider = "nous"
rows = [{"slug": "copilot-acp", "models": ["gpt-5.4"]}]
rows = [{"slug": "acme-acp", "models": ["gpt-5.4"]}]
kept = _filter_explicit_provider_rows(rows, _Ctx())
assert any(r["slug"] == "copilot-acp" for r in kept), \
"signed-in copilot-acp must survive the explicit-only picker filter"
assert any(r["slug"] == "acme-acp" for r in kept), \
"signed-in acme-acp must survive the explicit-only picker filter"
def test_explicit_filter_drops_unverified_external_process_row(tmp_path, monkeypatch, _clean_copilot_env):
def test_explicit_filter_drops_unverified_external_process_row(tmp_path, monkeypatch, acme_acp):
# Merely having the executable on PATH is ambient discovery, not an
# explicit configuration — the desktop filter keeps its narrower contract.
# explicit configuration — the desktop filter keeps its narrower contract
# for a profile whose CLI exposes readable token stores (none here).
from hermes_cli.inventory import _filter_explicit_provider_rows
monkeypatch.setenv("HOME", str(tmp_path)) # no credential stores
monkeypatch.setattr(acme_acp, "evidence", (False, None), raising=False)
class _Ctx:
current_provider = "nous"
rows = [{"slug": "copilot-acp", "models": ["gpt-5.4"]}]
rows = [{"slug": "acme-acp", "models": ["gpt-5.4"]}]
kept = _filter_explicit_provider_rows(rows, _Ctx())
assert all(r["slug"] != "copilot-acp" for r in kept)
assert all(r["slug"] != "acme-acp" for r in kept)
# --- Accounts-tab cli_command ------------------------------------------------
def test_cli_command_reflects_configured_executable(tmp_path, monkeypatch, _clean_copilot_env):
def test_cli_command_reflects_configured_executable(tmp_path, monkeypatch, acme_acp):
from hermes_cli.web_server_oauth import _external_process_cli_command
fake = tmp_path / ("copilot.exe" if os.name == "nt" else "copilot")
fake = tmp_path / ("acme.exe" if os.name == "nt" else "acme")
fake.write_text("", encoding="utf-8")
fake.chmod(0o755)
monkeypatch.setenv("HERMES_COPILOT_ACP_COMMAND", str(fake))
monkeypatch.setenv("HERMES_ACME_ACP_COMMAND", str(fake))
rendered = _external_process_cli_command("copilot-acp", "copilot login")
rendered = _external_process_cli_command("acme-acp", "acme login")
assert rendered == f"{fake} login"
def test_cli_command_untouched_for_non_external_providers(_clean_copilot_env):
def test_cli_command_untouched_for_non_external_providers(acme_acp):
from hermes_cli.web_server_oauth import _external_process_cli_command
assert _external_process_cli_command("nous", "hermes auth add nous") == "hermes auth add nous"
def test_cli_command_default_when_no_override(monkeypatch, _clean_copilot_env):
def test_cli_command_default_when_no_override(monkeypatch, acme_acp):
from hermes_cli.web_server_oauth import _external_process_cli_command
assert _external_process_cli_command("copilot-acp", "copilot login") == "copilot login"
# --- live catalog key from the Copilot CLI store -----------------------------
def test_catalog_key_resolves_from_copilot_cli_store(tmp_path, monkeypatch, _clean_copilot_env):
# A user whose ONLY credential is `copilot login` must still get the live
# model catalog — otherwise the picker silently falls back to the stale
# curated list (visibly wrong vs. what their subscription serves).
from unittest.mock import patch as mock_patch
from plugins.model_providers.copilot import catalog
monkeypatch.setenv("HOME", str(tmp_path))
cfg_dir = tmp_path / ".copilot"
cfg_dir.mkdir()
(cfg_dir / "config.json").write_text(
"// managed\n"
'{"copilotTokens": {"https://github.com:u": "gho_' + "x" * 36 + '"}}\n',
encoding="utf-8",
)
with mock_patch.object(
catalog, "_resolve_copilot_catalog_api_key", wraps=catalog._resolve_copilot_catalog_api_key
), mock_patch.object(copilot_credentials, "exchange_copilot_token",
return_value=("exchanged-api-token", 0.0, None),
), mock_patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
side_effect=Exception("no env creds"),
), mock_patch(
"hermes_cli.auth.read_credential_pool", return_value=[]
):
key = catalog._resolve_copilot_catalog_api_key()
assert key == "exchanged-api-token"
def test_catalog_key_empty_when_cli_store_absent(tmp_path, monkeypatch, _clean_copilot_env):
from unittest.mock import patch as mock_patch
from plugins.model_providers.copilot import catalog
monkeypatch.setenv("HOME", str(tmp_path)) # no ~/.copilot at all
with mock_patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
side_effect=Exception("no env creds"),
), mock_patch(
"hermes_cli.auth.read_credential_pool", return_value=[]
):
key = catalog._resolve_copilot_catalog_api_key()
assert key == ""
assert _external_process_cli_command("acme-acp", "acme login") == "acme login"
@@ -4,7 +4,7 @@ The known-provider gate, the binary, the argv and the env var names used to be
spelled out for one vendor, so a profile registered from a plugin died with
"Unknown provider" before any client was built. This registers a provider the
way a standalone package does — before importing ``hermes_cli`` — and walks the
real resolution path, asserting ``copilot-acp`` is unchanged alongside it.
real resolution path.
"""
from __future__ import annotations
@@ -46,7 +46,7 @@ register_provider(
def fake_cli(tmp_path, monkeypatch):
bindir = tmp_path / "bin"
bindir.mkdir()
for name in ("acme-cli", "copilot", "custom-acme"):
for name in ("acme-cli", "custom-acme"):
exe = bindir / (name + ".exe" if os.name == "nt" else name)
exe.write_text("#!/bin/sh\nexit 0\n")
exe.chmod(exe.stat().st_mode | stat.S_IEXEC)
@@ -73,16 +73,3 @@ def test_an_out_of_tree_external_process_provider_resolves_end_to_end(fake_cli,
runtime = resolve_runtime_provider(requested="acme", target_model="acme")
assert (runtime["provider"], runtime["base_url"], runtime["source"]) == ("acme-acp", "acp://acme", "process")
def test_copilot_acp_launch_details_are_unchanged(fake_cli, monkeypatch):
from hermes_cli.auth import resolve_external_process_provider_credentials
from hermes_cli.runtime_provider import resolve_runtime_provider
creds = resolve_external_process_provider_credentials("copilot-acp")
assert Path(creds["command"]) == fake_cli / ("copilot.exe" if os.name == "nt" else "copilot")
assert (creds["args"], creds["api_key"], creds["base_url"]) == (["--acp", "--stdio"], "copilot-acp", "acp://copilot")
monkeypatch.setenv("COPILOT_CLI_PATH", str(fake_cli / ("custom-acme.exe" if os.name == "nt" else "custom-acme")))
assert resolve_external_process_provider_credentials("copilot-acp")["command"] == str(fake_cli / ("custom-acme.exe" if os.name == "nt" else "custom-acme"))
assert resolve_runtime_provider(requested="copilot-acp", target_model="x")["base_url"] == "acp://copilot"
@@ -265,34 +265,47 @@ def test_process_supplied_catalog_root_remains_supported(catalog_env: Path):
assert get_entry("demo") is not None
def _copilot_acp_launch_resolvers():
"""(command, args) resolvers of the copilot-acp client's env-driven launch fallback."""
import providers
def _acp_launch_resolvers(monkeypatch: pytest.MonkeyPatch):
"""(command, args) resolvers of an external-process provider's env-driven launch fallback.
providers.get_provider_profile("copilot-acp") # discovery imports the bundled plugin package
from plugins.model_providers.copilot_acp.client import CopilotACPClient
The profile is registered like a plugin's, so its launch env names are executable authority."""
import providers
from agent.acp_client import ACPClient
from providers.base import ProviderProfile
acme = ProviderProfile(name="acme-acp", auth_type="external_process", base_url="acp://acme",
process_command="acme", process_command_env_vars=("HERMES_ACME_ACP_COMMAND",),
process_args_env_var="HERMES_ACME_ACP_ARGS")
real_list = providers.list_providers
monkeypatch.setattr(providers, "list_providers", lambda: [*real_list(), acme])
class _AcmeACPClient(ACPClient):
default_command = "acme"
default_args = ("--acp", "--stdio")
command_env_vars = acme.process_command_env_vars
args_env_var = acme.process_args_env_var
def launch():
return CopilotACPClient(acp_cwd=os.getcwd())
return _AcmeACPClient(acp_cwd=os.getcwd())
return (lambda: launch()._acp_command), (lambda: launch()._acp_args)
def test_rejected_copilot_controls_do_not_change_live_resolvers(
def test_rejected_acp_launch_controls_do_not_change_live_resolvers(
client: TestClient,
catalog_env: Path,
monkeypatch: pytest.MonkeyPatch,
):
_resolve_command, _resolve_args = _copilot_acp_launch_resolvers()
_resolve_command, _resolve_args = _acp_launch_resolvers(monkeypatch)
monkeypatch.setenv("HERMES_COPILOT_ACP_COMMAND", "/opt/trusted/copilot")
monkeypatch.setenv("HERMES_COPILOT_ACP_ARGS", "--acp --stdio")
monkeypatch.setenv("HERMES_ACME_ACP_COMMAND", "/opt/trusted/acme")
monkeypatch.setenv("HERMES_ACME_ACP_ARGS", "--acp --stdio")
expected_command = _resolve_command()
expected_args = _resolve_args()
attempts = {
"HERMES_COPILOT_ACP_COMMAND": "/tmp/attacker-command",
"HERMES_COPILOT_ACP_ARGS": "--malicious-transport",
"HERMES_ACME_ACP_COMMAND": "/tmp/attacker-command",
"HERMES_ACME_ACP_ARGS": "--malicious-transport",
}
for key, value in attempts.items():
response = client.put(
@@ -311,18 +324,18 @@ def test_rejected_copilot_controls_do_not_change_live_resolvers(
assert "--malicious-transport" not in env_text
def test_preexisting_copilot_controls_remain_usable(
def test_preexisting_acp_launch_controls_remain_usable(
catalog_env: Path,
monkeypatch: pytest.MonkeyPatch,
):
_resolve_command, _resolve_args = _copilot_acp_launch_resolvers()
_resolve_command, _resolve_args = _acp_launch_resolvers(monkeypatch)
from hermes_cli.env_loader import load_hermes_dotenv
monkeypatch.setenv("HERMES_COPILOT_ACP_COMMAND", "parent-placeholder")
monkeypatch.setenv("HERMES_COPILOT_ACP_ARGS", "--parent-placeholder")
monkeypatch.setenv("HERMES_ACME_ACP_COMMAND", "parent-placeholder")
monkeypatch.setenv("HERMES_ACME_ACP_ARGS", "--parent-placeholder")
(catalog_env / ".env").write_text(
"HERMES_COPILOT_ACP_COMMAND=/opt/operator/copilot\n"
"HERMES_COPILOT_ACP_ARGS=--acp --stdio --operator-mode\n",
"HERMES_ACME_ACP_COMMAND=/opt/operator/acme\n"
"HERMES_ACME_ACP_ARGS=--acp --stdio --operator-mode\n",
encoding="utf-8",
)
@@ -331,7 +344,7 @@ def test_preexisting_copilot_controls_remain_usable(
load_external_secrets=False,
)
assert _resolve_command() == "/opt/operator/copilot"
assert _resolve_command() == "/opt/operator/acme"
assert _resolve_args() == ["--acp", "--stdio", "--operator-mode"]
@@ -12,11 +12,6 @@ from unittest.mock import patch
import pytest
import hermes_cli.models as mod
import providers as _providers
_providers.get_provider_profile("copilot") # imports the copilot plugin package (catalog module)
from plugins.model_providers.copilot import catalog as copilot_catalog
from plugins.model_providers.copilot_acp import catalog as acp_catalog
@pytest.fixture(autouse=True)
@@ -39,15 +34,15 @@ STATIC = ["gpt-5.4", "gpt-4o", "claude-sonnet-4.6"]
def test_fallback_does_not_overwrite_the_same_credentials_live_row():
# 2h-old live row (past the 1h TTL, inside the SWR window) + a live fetch that degrades to the
# curated list: the account's real catalog must survive, in the cache AND in the return value.
cache = {"copilot": _row(LIVE, age_seconds=7200)}
cache = {"deepinfra": _row(LIVE, age_seconds=7200)}
with patch.object(mod, "_load_provider_models_cache", return_value=cache), \
patch.object(mod, "_credential_fingerprint", return_value="fp"), \
patch.object(mod, "_save_provider_models_cache") as save, \
patch.object(mod, "provider_model_ids", return_value=mod.CuratedFallbackModels(STATIC)):
out = mod.cached_provider_model_ids("copilot", force_refresh=True)
out = mod.cached_provider_model_ids("deepinfra", force_refresh=True)
assert out == LIVE
assert cache["copilot"]["models"] == LIVE
assert cache["deepinfra"]["models"] == LIVE
save.assert_not_called()
@@ -59,32 +54,22 @@ def test_fallback_row_is_recorded_as_fallback_and_never_served_stale():
patch.object(mod, "_credential_fingerprint", return_value="fp"), \
patch.object(mod, "_save_provider_models_cache"), \
patch.object(mod, "provider_model_ids", return_value=mod.CuratedFallbackModels(STATIC)):
assert mod.cached_provider_model_ids("copilot") == STATIC
assert cache["copilot"]["fallback"] is True
assert mod.cached_provider_model_ids("deepinfra") == STATIC
assert cache["deepinfra"]["fallback"] is True
# Same row, now past the fallback TTL: the live fetch runs instead of the stale-serve path,
# and a real catalog replaces the fallback row.
cache["copilot"]["at"] = time.time() - mod._PROVIDER_MODELS_FALLBACK_TTL - 1
cache["deepinfra"]["at"] = time.time() - mod._PROVIDER_MODELS_FALLBACK_TTL - 1
with patch.object(mod, "_load_provider_models_cache", return_value=cache), \
patch.object(mod, "_credential_fingerprint", return_value="fp"), \
patch.object(mod, "_save_provider_models_cache"), \
patch.object(mod, "_spawn_swr_refresh") as spawn, \
patch.object(mod, "provider_model_ids", return_value=list(LIVE)) as live:
assert mod.cached_provider_model_ids("copilot") == LIVE
assert mod.cached_provider_model_ids("deepinfra") == LIVE
live.assert_called_once()
spawn.assert_not_called()
assert cache["copilot"]["models"] == LIVE
assert "fallback" not in cache["copilot"]
def test_copilot_catalog_marks_the_static_list_as_fallback_on_a_failed_live_fetch():
with patch.object(acp_catalog, "session_models", return_value=None), \
patch.object(copilot_catalog, "_resolve_copilot_catalog_api_key", return_value="tok"), \
patch.object(copilot_catalog, "_fetch_github_models", side_effect=OSError("503")):
for slug in ("copilot", "copilot-acp"):
rows = mod.provider_model_ids(slug)
assert isinstance(rows, mod.CuratedFallbackModels), slug
assert rows == list(mod._PROVIDER_MODELS["copilot"])
assert cache["deepinfra"]["models"] == LIVE
assert "fallback" not in cache["deepinfra"]
def test_codex_cold_fallback_retries_after_short_ttl(monkeypatch, tmp_path):
@@ -1,71 +0,0 @@
"""Regression tests for Copilot api_mode recomputation during /model switch.
When switching models within the Copilot provider (e.g. GPT-5 → Claude),
the stale api_mode from resolve_runtime_provider must be overridden with
a fresh value computed from the *new* model. Without the fix, Claude
requests went through the Responses API and failed with
``unsupported_api_for_model``.
"""
from unittest.mock import patch
from hermes_cli.model_switch import switch_model
_MOCK_VALIDATION = {
"accepted": True,
"persist": True,
"recognized": True,
"message": None,
}
def _run_copilot_switch(
raw_input: str,
current_provider: str = "copilot",
current_model: str = "gpt-5.4",
explicit_provider: str = "",
runtime_api_mode: str = "codex_responses",
):
"""Run switch_model with Copilot mocks and return the result."""
with (
patch("hermes_cli.model_switch.resolve_alias", return_value=None),
patch("hermes_cli.model_switch.list_provider_models", return_value=[]),
patch(
"hermes_cli.runtime_provider.resolve_runtime_provider",
return_value={
"api_key": "ghu_test_token",
"base_url": "https://api.githubcopilot.com",
"api_mode": runtime_api_mode,
},
),
patch(
"hermes_cli.models_validate.validate_requested_model",
return_value=_MOCK_VALIDATION,
),
patch("hermes_cli.model_switch.get_model_info", return_value=None),
patch("hermes_cli.model_switch.get_model_capabilities", return_value=None),
patch("hermes_cli.models.detect_provider_for_model", return_value=None),
):
return switch_model(
raw_input=raw_input,
current_provider=current_provider,
current_model=current_model,
explicit_provider=explicit_provider,
)
def test_same_provider_copilot_switch_recomputes_api_mode():
"""GPT-5 → Claude on copilot: api_mode must flip to chat_completions."""
result = _run_copilot_switch(
raw_input="claude-opus-4.6",
current_provider="copilot",
current_model="gpt-5.4",
)
assert result.success, f"switch_model failed: {result.error_message}"
assert result.new_model == "claude-opus-4.6"
assert result.target_provider == "copilot"
assert result.api_mode == "chat_completions"
+1 -34
View File
@@ -4,10 +4,6 @@ import pytest
from unittest.mock import MagicMock, patch
from hermes_cli.models import azure_foundry_model_api_mode, curated_models_for_provider, fetch_api_models, normalize_provider, opencode_model_api_mode, parse_model_input, probe_api_models, provider_model_ids
import providers as _providers
_providers.get_provider_profile("copilot") # imports the copilot plugin package (wire module)
from plugins.model_providers.copilot.wire import model_api_mode as copilot_model_api_mode
from hermes_cli.models_local import fetch_lmstudio_models
from hermes_cli.models_validate import validate_requested_model
@@ -88,7 +84,6 @@ class TestNormalizeProvider:
assert normalize_provider("kimi") == "kimi-coding"
assert normalize_provider("moonshot") == "kimi-coding"
assert normalize_provider("step") == "stepfun"
assert normalize_provider("github-copilot") == "copilot"
# -- provider_model_ids ------------------------------------------------------
@@ -193,36 +188,8 @@ class TestFetchApiModels:
assert probe["resolved_base_url"] == base + "/v1"
assert probe["used_fallback"] is True
def test_probe_api_models_uses_copilot_catalog(self):
class _Resp:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return b'{"data": [{"id": "gpt-5.4", "model_picker_enabled": true, "supported_endpoints": ["/responses"], "capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}}}, {"id": "claude-sonnet-4.6", "model_picker_enabled": true, "supported_endpoints": ["/chat/completions"], "capabilities": {"type": "chat", "supports": {"reasoning_effort": ["low", "medium", "high"]}}}, {"id": "text-embedding-3-small", "model_picker_enabled": true, "capabilities": {"type": "embedding"}}]}'
with patch("hermes_cli.models._urlopen_model_catalog_request", return_value=_Resp()) as mock_urlopen:
probe = probe_api_models("gh-token", "https://api.githubcopilot.com")
assert mock_urlopen.call_args[0][0].full_url == "https://api.githubcopilot.com/models"
assert probe["models"] == ["gpt-5.4", "claude-sonnet-4.6"]
assert probe["resolved_base_url"] == "https://api.githubcopilot.com"
assert probe["used_fallback"] is False
class TestCopilotNormalization:
def test_copilot_api_mode_gpt5_uses_responses(self):
"""GPT-5+ models should use Responses API (matching opencode)."""
assert copilot_model_api_mode("gpt-5.4") == "codex_responses"
assert copilot_model_api_mode("gpt-5.4-mini") == "codex_responses"
assert copilot_model_api_mode("gpt-5.3-codex") == "codex_responses"
assert copilot_model_api_mode("gpt-5.2-codex") == "codex_responses"
assert copilot_model_api_mode("gpt-5.2") == "codex_responses"
class TestModelApiModeNormalization:
def test_opencode_go_api_modes_match_docs(self):
assert opencode_model_api_mode("opencode-go", "glm-5.1") == "chat_completions"
@@ -19,7 +19,7 @@ class _RecordingProfile:
return ["relay-only-model"]
@pytest.mark.parametrize("provider", ["openai-codex", "copilot", "nous", "opencode-zen", "openrouter"])
@pytest.mark.parametrize("provider", ["openai-codex", "nous", "opencode-zen", "openrouter"])
def test_canonical_url_preserves_native_catalog_but_other_paths_stay_relays(monkeypatch, provider):
from hermes_cli.config import atomic_config_write
from hermes_constants import get_hermes_home
@@ -81,29 +81,6 @@ def test_deepinfra_catalog_is_fetched_with_each_profiles_key(homes, monkeypatch)
assert models._fetch_deepinfra_models() == ["di/model-B"]
def test_copilot_context_cache_hit_requires_same_api_key(homes, monkeypatch):
import providers
from hermes_cli import models
providers.get_provider_profile("copilot") # imports the copilot plugin package (catalog module)
from plugins.model_providers.copilot import catalog
monkeypatch.setattr(catalog, "_copilot_context_cache", {})
monkeypatch.setattr(catalog, "_copilot_context_cache_time", 0.0)
monkeypatch.setattr(catalog, "_github_model_catalog_cache", None)
def transport(req, *, timeout, **kw):
limit = 111 if req.headers.get("Authorization", "").endswith("copilot-A") else 222
return _json_resp({"data": [{"id": "gpt-x", "model_picker_enabled": True,
"supported_endpoints": ["/chat/completions"],
"capabilities": {"type": "chat", "limits": {"max_prompt_tokens": limit}}}]})
monkeypatch.setattr(models, "_urlopen_model_catalog_request", transport)
assert catalog.get_copilot_model_context("gpt-x", api_key="copilot-A") == 111
assert catalog.get_copilot_model_context("gpt-x", api_key="copilot-B") == 222
assert catalog.get_copilot_model_context("gpt-x", api_key="copilot-A") == 111
def test_nous_reasoning_caps_follow_each_profiles_portal(homes, monkeypatch):
a, b = homes
(a / ".env").write_text("NOUS_INFERENCE_BASE_URL=https://portal-a.example/v1\n", encoding="utf-8")
@@ -1,7 +1,7 @@
"""Test that overlay providers with mismatched models.dev keys resolve correctly.
HERMES_OVERLAYS keys may be models.dev IDs (e.g. "github-copilot") while
_PROVIDER_MODELS and config.yaml use Hermes IDs ("copilot"). The slug
HERMES_OVERLAYS keys may be models.dev IDs (e.g. "kimi-for-coding") while
_PROVIDER_MODELS and config.yaml use Hermes IDs ("kimi-coding"). The slug
resolution in list_authenticated_providers() Section 2 must bridge this gap.
Covers: #5223, #6492
@@ -14,21 +14,21 @@ from unittest.mock import patch
from hermes_cli.model_switch import list_authenticated_providers
# -- Copilot slug resolution (env var path) ----------------------------------
# -- Overlay slug resolution (env var path) ----------------------------------
@patch.dict(os.environ, {"COPILOT_GITHUB_TOKEN": "fake-ghu"}, clear=False)
def test_copilot_uses_hermes_slug():
"""github-copilot overlay should resolve to slug='copilot' with curated models."""
providers = list_authenticated_providers(current_provider="copilot")
@patch.dict(os.environ, {"KIMI_API_KEY": "fake-kimi"}, clear=False)
def test_overlay_provider_uses_hermes_slug():
"""kimi-for-coding overlay should resolve to slug='kimi-coding' with curated models."""
providers = list_authenticated_providers(current_provider="kimi-coding")
copilot = next((p for p in providers if p["slug"] == "copilot"), None)
assert copilot is not None, "copilot should appear when COPILOT_GITHUB_TOKEN is set"
assert copilot["total_models"] > 0, "copilot should have curated models"
assert copilot["is_current"] is True
kimi = next((p for p in providers if p["slug"] == "kimi-coding"), None)
assert kimi is not None, "kimi-coding should appear when KIMI_API_KEY is set"
assert kimi["total_models"] > 0, "kimi-coding should have curated models"
assert kimi["is_current"] is True
# Must NOT appear under the models.dev key
gh_copilot = next((p for p in providers if p["slug"] == "github-copilot"), None)
assert gh_copilot is None, "github-copilot slug should not appear (resolved to copilot)"
kimi_md = next((p for p in providers if p["slug"] == "kimi-for-coding"), None)
assert kimi_md is None, "kimi-for-coding slug should not appear (resolved to kimi-coding)"
@@ -140,6 +140,12 @@ def test_any_external_process_plugin_counts_as_signed_in_when_its_binary_resolve
status = auth.get_external_process_provider_status("example-acp")
assert status["auth_verified"] is True and status["auth_source"].startswith("command: ")
assert [r["slug"] for r in _filter_explicit_provider_rows(rows, _Ctx())] == ["example-acp"]
# Bundled Copilot keeps its token-store evidence: a resolving binary alone is not a sign-in there.
assert auth._external_process_auth_evidence("copilot-acp", str(tmp_path / "copilot")) == \
providers.get_provider_profile("copilot-acp").external_auth_evidence()
# A profile with its own token-store evidence keeps it: a resolving binary alone is not a sign-in there.
class _TokenStoreProfile(ProviderProfile):
def external_auth_evidence(self):
return (False, None)
plugin(_TokenStoreProfile(name="example-acp-tokens", auth_type="external_process", base_url="acp://tokens",
process_command="example-acp-tokens-bin"))
assert auth._external_process_auth_evidence("example-acp-tokens", str(tmp_path / "example-acp-tokens-bin")) == \
providers.get_provider_profile("example-acp-tokens").external_auth_evidence()
-17
View File
@@ -24,23 +24,6 @@ def test_profileless_providers_still_present():
assert by[slug].label, f"{slug} has empty label despite canonical fallback"
assert by[slug].description, f"{slug} has empty description despite fallback"
def test_copilot_surfaces_as_a_provider_with_its_own_token_var():
"""Regression for the reported bug: a GitHub Copilot login showed up under
tools, never as a provider, because the shared GITHUB_TOKEN is tool-category.
Copilot authenticates via the `copilot`/api_key path, so it belongs on the
keys tab — but its PRIMARY credential var must be the provider-owned
COPILOT_GITHUB_TOKEN, not the shared tool-category GITHUB_TOKEN. That is what
lets the desktop render Copilot as its own provider card.
"""
by = provider_catalog_by_slug()
assert "copilot" in by
d = by["copilot"]
assert d.tab == "keys"
assert d.api_key_env_vars, "Copilot must expose a credential env var"
assert d.api_key_env_vars[0] == "COPILOT_GITHUB_TOKEN", (
"Copilot's primary var must be the provider-owned token, not shared GITHUB_TOKEN"
)
def test_api_key_providers_expose_a_credential_env_var():
"""Every keys-tab provider that authenticates via a pasted API key must
@@ -1,628 +0,0 @@
"""Focused regressions for the Copilot ACP shim safety layer."""
from __future__ import annotations
import io
import json
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
import providers
providers.get_provider_profile("copilot-acp") # discovery imports the bundled plugin package
from plugins.model_providers.copilot_acp.client import CopilotACPClient
class _FakeProcess:
def __init__(self) -> None:
self.stdin = io.StringIO()
class CopilotACPClientSafetyTests(unittest.TestCase):
def setUp(self) -> None:
self.client = CopilotACPClient(acp_cwd="/tmp")
def test_stream_true_preserves_tool_call_deltas(self) -> None:
tool_response = (
"<tool_call>"
'{"id":"call_read","type":"function",'
'"function":{"name":"read_file","arguments":"{\\"path\\":\\"README.md\\"}"}}'
"</tool_call>"
)
with patch.object(self.client, "_run_prompt", return_value=(tool_response, "")):
stream = self.client._create_chat_completion(
model="copilot-acp",
messages=[{"role": "user", "content": "read README.md"}],
stream=True,
)
chunks = list(stream)
delta = chunks[0].choices[0].delta
self.assertIsNone(delta.content)
self.assertEqual(chunks[0].choices[0].finish_reason, "tool_calls")
self.assertEqual(len(delta.tool_calls), 1)
tool_delta = delta.tool_calls[0]
self.assertEqual(tool_delta.index, 0)
self.assertEqual(tool_delta.id, "call_read")
self.assertEqual(tool_delta.function.name, "read_file")
self.assertEqual(
json.loads(tool_delta.function.arguments),
{"path": "README.md"},
)
self.assertEqual(chunks[1].choices, [])
def _dispatch(self, message: dict, *, cwd: str) -> dict:
process = _FakeProcess()
handled = self.client._handle_server_message(
message,
process=process,
cwd=cwd,
text_parts=[],
reasoning_parts=[],
)
self.assertTrue(handled)
payload = process.stdin.getvalue().strip()
self.assertTrue(payload)
return json.loads(payload)
def test_read_text_file_redacts_sensitive_content(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
secret_file = root / "config.env"
secret_file.write_text("OPENAI_API_KEY=sk-proj-abc123def456ghi789jkl012", encoding="utf-8")
# agent.redact snapshots HERMES_REDACT_SECRETS at import time into
# _REDACT_ENABLED, so patching os.environ is a no-op. Flip the
# module-level constant directly for the duration of the call.
with patch("agent.redact._REDACT_ENABLED", True):
response = self._dispatch(
{
"jsonrpc": "2.0",
"id": 3,
"method": "fs/read_text_file",
"params": {"path": str(secret_file)},
},
cwd=str(root),
)
content = ((response.get("result") or {}).get("content") or "")
self.assertNotIn("abc123def456", content)
self.assertIn("OPENAI_API_KEY=", content)
def test_fs_read_text_file_decodes_as_utf8_under_non_utf8_locale(self) -> None:
"""Regression for #18637 (bug 2): fs/read_text_file used
``path.read_text()`` with no explicit encoding, so on Windows
GBK/CP932/CP949 locales the Copilot read_file tool crashed on any
source file with non-ASCII content (e.g. a CJK comment, an em dash,
or UTF-8 BOM)."""
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
target = root / "note.md"
target.write_text("# 中文标题\nem dash — here\n", encoding="utf-8")
original_read_text = Path.read_text
def strict_read_text(self, encoding=None, errors=None, **kwargs):
# The repo encoding policy makes reads BOM-tolerant, so both
# UTF-8 family codecs satisfy this regression guard.
if self == target and encoding not in ("utf-8", "utf-8-sig"):
raise UnicodeDecodeError(
"gbk", b"\x94", 0, 1, "illegal multibyte sequence"
)
return original_read_text(
self, encoding=encoding, errors=errors, **kwargs
)
with patch.object(Path, "read_text", strict_read_text):
response = self._dispatch(
{
"jsonrpc": "2.0",
"id": 10,
"method": "fs/read_text_file",
"params": {"path": str(target)},
},
cwd=str(root),
)
self.assertNotIn("error", response)
content = ((response.get("result") or {}).get("content") or "")
self.assertIn("中文标题", content)
self.assertIn("em dash —", content)
def test_write_text_file_respects_safe_root(self) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
safe_root = root / "workspace"
safe_root.mkdir()
outside = root / "outside.txt"
with patch.dict(os.environ, {"HERMES_WRITE_SAFE_ROOT": str(safe_root)}, clear=False):
response = self._dispatch(
{
"jsonrpc": "2.0",
"id": 5,
"method": "fs/write_text_file",
"params": {
"path": str(outside),
"content": "should-not-write",
},
},
cwd=str(root),
)
self.assertIn("error", response)
self.assertIn("HERMES_WRITE_SAFE_ROOT", str(response["error"]))
self.assertFalse(outside.exists())
if __name__ == "__main__":
unittest.main()
# ── HOME env propagation tests (from PR #11285) ─────────────────────
from unittest.mock import patch as _patch
import pytest
def _make_home_client(tmp_path):
return CopilotACPClient(
api_key="copilot-acp",
base_url="acp://copilot",
acp_command="copilot",
acp_args=["--acp", "--stdio"],
acp_cwd=str(tmp_path),
)
def _fake_popen_capture(captured):
def _fake(cmd, **kwargs):
captured["cmd"] = cmd
captured["kwargs"] = kwargs
raise FileNotFoundError("copilot not found")
return _fake
def test_run_prompt_preserves_real_home_when_profile_home_available(monkeypatch, tmp_path):
hermes_home = tmp_path / "hermes"
(hermes_home / "home").mkdir(parents=True)
real_home = tmp_path / "real-home"
real_home.mkdir()
monkeypatch.setenv("HOME", str(real_home))
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
# Hermeticity: an ambient HERMES_REAL_HOME (exported by Hermes' own
# terminal contract on dev boxes) outranks HOME in the candidate ladder,
# and an ambient TERMINAL_HOME_MODE would change the policy under test.
monkeypatch.delenv("HERMES_REAL_HOME", raising=False)
monkeypatch.delenv("TERMINAL_HOME_MODE", raising=False)
# Hermeticity: get_subprocess_home()'s auto mode prefers the profile home
# when is_container() is True — on a containerized CI runner that real
# probe flips the resolution this test asserts. The host/VM branch is the
# contract under test; pin containment off.
monkeypatch.setattr("hermes_constants.is_container", lambda: False)
captured = {}
client = _make_home_client(tmp_path)
# Hermeticity: the --acp support probe (PR #87308) calls subprocess.run
# before Popen; stub it inconclusive so no real CLI on the host box can
# flip the resolution this test asserts.
with _patch("agent.acp_client.subprocess.run", side_effect=FileNotFoundError):
with _patch("agent.acp_client.subprocess.Popen", side_effect=_fake_popen_capture(captured)):
with pytest.raises(RuntimeError, match="Could not start Copilot ACP command"):
client._run_prompt("hello", timeout_seconds=1)
assert captured["kwargs"]["env"]["HOME"] == str(real_home)
assert captured["kwargs"]["env"]["HERMES_REAL_HOME"] == str(real_home)
def test_run_prompt_passes_home_when_parent_env_is_clean(monkeypatch, tmp_path):
monkeypatch.delenv("HOME", raising=False)
monkeypatch.delenv("HERMES_HOME", raising=False)
captured = {}
client = _make_home_client(tmp_path)
# Hermeticity: the --acp support probe (PR #87308) calls subprocess.run
# before Popen; stub it inconclusive so no real CLI on the host box can
# flip the resolution this test asserts.
with _patch("agent.acp_client.subprocess.run", side_effect=FileNotFoundError):
with _patch("agent.acp_client.subprocess.Popen", side_effect=_fake_popen_capture(captured)):
with pytest.raises(RuntimeError, match="Could not start Copilot ACP command"):
client._run_prompt("hello", timeout_seconds=1)
assert "env" in captured["kwargs"]
assert captured["kwargs"]["env"]["HOME"]
# ── --acp support probe tests (PR #87308 / issue #87309) ────────────
import subprocess as _subprocess
from agent.acp_client import _ACP_PROBE_CACHE, _acp_supported
@pytest.fixture(autouse=True)
def _clear_probe_cache():
_ACP_PROBE_CACHE.clear()
yield
_ACP_PROBE_CACHE.clear()
def _completed(returncode=0, stdout=""):
return _subprocess.CompletedProcess(args=[], returncode=returncode, stdout=stdout, stderr="")
def test_probe_true_when_help_advertises_acp():
with _patch(
"agent.acp_client.subprocess.run",
return_value=_completed(stdout="Usage: copilot [--acp] [--stdio]"),
):
assert _acp_supported("copilot", ["--acp", "--stdio"]) is True
def test_probe_false_when_help_lacks_acp_and_run_prompt_fast_fails(tmp_path):
client = _make_home_client(tmp_path)
with _patch(
"agent.acp_client.subprocess.run",
return_value=_completed(stdout="Usage: claude [--print] [--model]"),
):
with pytest.raises(RuntimeError, match="ACP transport not supported"):
client._run_prompt("hello", timeout_seconds=1)
def test_probe_inconclusive_falls_through_to_spawn_error(tmp_path):
"""Missing binary: probe must NOT mask the established spawn error."""
client = _make_home_client(tmp_path)
with _patch(
"agent.acp_client.subprocess.run",
side_effect=FileNotFoundError("copilot not found"),
):
with _patch(
"agent.acp_client.subprocess.Popen",
side_effect=FileNotFoundError("copilot not found"),
):
with pytest.raises(RuntimeError, match="Could not start Copilot ACP command"):
client._run_prompt("hello", timeout_seconds=1)
def test_probe_skipped_for_custom_args_without_acp():
with _patch("agent.acp_client.subprocess.run") as run_mock:
assert _acp_supported("mycli", ["--custom-transport"]) is True
run_mock.assert_not_called()
# --- session/set_model: honor the picker-selected model ----------------------
#
# `copilot --acp` validates but IGNORES the `--model` spawn flag; the ACP
# session runs the CLI's own default unless the client issues the ACP-native
# `session/set_model` call. Without it, picking gpt-5.6-terra in Hermes
# visibly answers as the CLI's default model.
# --- session model selection -------------------------------------------------
def _session_with_config_options():
return {
"sessionId": "s1",
"configOptions": [
{
"id": "model",
"category": "model",
"type": "select",
"currentValue": "auto",
"options": [
{"value": "auto", "name": "Auto"},
{"value": "gpt-5.6-terra", "name": "GPT-5.6 Terra"},
{
"value": "claude-fable-5",
"name": "Claude Fable 5",
"_meta": {"copilotEnablement": "disabled"},
},
],
}
],
}
def _model_selection_request(session, requested_model):
"""The generic selector with the Copilot client's placeholder id and enablement hook."""
from agent.acp_client import _model_selection_request as select
return select(session, requested_model, placeholder=CopilotACPClient.placeholder_model,
disabled=CopilotACPClient(acp_cwd="/tmp").model_entry_disabled)
def test_model_selection_prefers_stable_config_option():
assert _model_selection_request(
_session_with_config_options(), "gpt-5.6-terra"
) == (
"session/set_config_option",
{"sessionId": "s1", "configId": "model", "value": "gpt-5.6-terra"},
)
def test_model_selection_rejects_disabled_config_option():
assert _model_selection_request(
_session_with_config_options(), "claude-fable-5"
) is None
def test_model_selection_rejects_unknown_config_option():
assert _model_selection_request(
_session_with_config_options(), "not-served-here"
) is None
def test_model_selection_falls_back_to_legacy_extension():
legacy_session = {
"sessionId": "s1",
"models": {
"availableModels": [
{"modelId": "auto"},
{"modelId": "gpt-5.6-terra"},
]
},
}
assert _model_selection_request(legacy_session, "gpt-5.6-terra") == (
"session/set_model",
{"sessionId": "s1", "modelId": "gpt-5.6-terra"},
)
def test_model_selection_skips_provider_virtual_slug():
assert _model_selection_request(
_session_with_config_options(), "copilot-acp"
) is None
def test_run_prompt_receives_picker_model():
# _create_chat_completion must forward `model` into _run_prompt — the
# original wiring dropped it, reducing the selection to prompt text.
client = CopilotACPClient(acp_cwd="/tmp")
seen = {}
def fake_run_prompt(prompt_text, *, timeout_seconds, model=None):
seen["model"] = model
return "ok", ""
with patch.object(CopilotACPClient, "_run_prompt", side_effect=fake_run_prompt):
client._create_chat_completion(
model="gpt-5.6-terra", messages=[{"role": "user", "content": "hi"}]
)
assert seen["model"] == "gpt-5.6-terra"
def test_list_models_reads_enabled_session_config_options(tmp_path):
server = tmp_path / "fake_copilot_acp.py"
server.write_text(
"""import json
import sys
for line in sys.stdin:
request = json.loads(line)
method = request.get("method")
if method == "initialize":
result = {"protocolVersion": 1}
elif method == "session/new":
result = {
"sessionId": "catalog-session",
"configOptions": [{
"id": "model",
"category": "model",
"options": [
{"value": "auto"},
{"value": "gpt-5.6-terra"},
{"value": "gpt-5.6-terra"},
{"value": "claude-fable-5", "_meta": {"copilotEnablement": "disabled"}},
],
}],
"models": {"availableModels": [{"modelId": "stale-legacy-model"}]},
}
else:
result = {}
print(json.dumps({"jsonrpc": "2.0", "id": request["id"], "result": result}), flush=True)
""",
encoding="utf-8",
)
client = CopilotACPClient(
command=sys.executable,
args=[str(server)],
acp_cwd=str(tmp_path),
)
assert client.list_models(timeout_seconds=30) == ["auto", "gpt-5.6-terra"]
assert client.is_closed is True
def test_model_discovery_does_not_allow_file_requests(tmp_path):
target = tmp_path / "should-not-be-read.txt"
target.write_text("private", encoding="utf-8")
server = tmp_path / "fake_copilot_acp_fs_request.py"
server.write_text(
f"""import json
import sys
initialize = json.loads(sys.stdin.readline())
print(json.dumps({{"jsonrpc": "2.0", "id": initialize["id"], "result": {{"protocolVersion": 1}}}}), flush=True)
session = json.loads(sys.stdin.readline())
print(json.dumps({{"jsonrpc": "2.0", "id": 99, "method": "fs/read_text_file", "params": {{"path": {str(target)!r}}}}}), flush=True)
file_response = json.loads(sys.stdin.readline())
assert file_response["error"]["code"] == -32601
print(json.dumps({{"jsonrpc": "2.0", "id": session["id"], "result": {{"sessionId": "catalog-session", "configOptions": [{{"id": "model", "options": [{{"value": "gpt-5.6-sol"}}]}}]}}}}), flush=True)
""",
encoding="utf-8",
)
client = CopilotACPClient(
command=sys.executable,
args=[str(server)],
acp_cwd=str(tmp_path),
)
assert client.list_models(timeout_seconds=30) == ["gpt-5.6-sol"]
# --- concurrent sessions on a shared client ---------------------------------
#
# Aux clients are cached per provider config and served to every concurrent
# caller, so one CopilotACPClient can run several ACP sessions at once. Each
# session must reap ITS OWN child on exit: reaping whatever most recently
# claimed shared state kills a sibling's live process and leaks the session's
# own.
_FAKE_ACP_SERVER = """import json
import sys
import time
for line in sys.stdin:
request = json.loads(line)
method = request.get("method")
if method == "initialize":
result = {"protocolVersion": 1}
elif method == "session/new":
result = {"sessionId": "s1"}
elif method == "session/prompt":
time.sleep(0.4)
print(json.dumps({"jsonrpc": "2.0", "method": "session/update", "params": {
"update": {"sessionUpdate": "agent_message_chunk", "content": {"text": "done"}},
}}), flush=True)
result = {"stopReason": "end_turn"}
else:
result = {}
print(json.dumps({"jsonrpc": "2.0", "id": request["id"], "result": result}), flush=True)
"""
def _recording_client(tmp_path, spawned):
server = tmp_path / "fake_copilot_acp.py"
server.write_text(_FAKE_ACP_SERVER, encoding="utf-8")
client = CopilotACPClient(command=sys.executable, args=[str(server)], acp_cwd=str(tmp_path))
real_spawn = client._spawn
def record_spawn():
proc = real_spawn()
spawned.append(proc)
return proc
client._spawn = record_spawn
return client
def test_overlapping_sessions_reap_their_own_process(tmp_path):
spawned = []
client = _recording_client(tmp_path, spawned)
session_a = client._session(30)
session_b = client._session(30)
session_a.__enter__()
session_b.__enter__()
proc_a, proc_b = spawned
session_a.__exit__(None, None, None)
leaked = proc_a.poll() is None
killed = proc_b.poll() is not None
assert not leaked and not killed, (
f"session A teardown: own child leaked={leaked}, sibling process killed={killed}"
)
assert client.is_closed is False, "a shared client is not closed while a sibling session is live"
session_b.__exit__(None, None, None)
assert proc_b.poll() is not None
assert client.is_closed is True, "the last session to drain still flips is_closed for single-session callers"
def test_close_terminates_every_live_session_process(tmp_path):
spawned = []
client = _recording_client(tmp_path, spawned)
session_a = client._session(30)
session_b = client._session(30)
session_a.__enter__()
session_b.__enter__()
client.close()
assert all(proc.poll() is not None for proc in spawned)
_CRASHING_ACP_CLI = """
import subprocess, sys
# Hand stderr to a straggler that writes the crash text after this process has exited, so
# poll() reports the exit before the client has read a single stderr line.
subprocess.Popen([sys.executable, "-c",
"import sys, time; time.sleep(0.3); print('fatal: agent segfaulted', file=sys.stderr)"])
sys.exit(3)
"""
def test_cli_death_is_reported_as_a_crash_not_a_timeout(tmp_path):
"""A CLI that dies must surface its crash text even when stderr lags the exit: the old path
raised TimeoutError there, which the agent loop retries on a different (larger) budget."""
server = tmp_path / "crashing_acp.py"
server.write_text(_CRASHING_ACP_CLI, encoding="utf-8")
client = CopilotACPClient(command=sys.executable, args=[str(server)], acp_cwd=str(tmp_path))
try:
with client._session(30):
pass
except RuntimeError as exc:
assert "exited early: fatal: agent segfaulted" in str(exc)
else:
raise AssertionError("session on a dead CLI must raise")
def test_aiagent_uses_copilot_acp_client():
from unittest.mock import MagicMock
from run_agent import AIAgent
tool_defs = [{"type": "function", "function": {
"name": "web_search", "description": "web_search tool", "parameters": {"type": "object", "properties": {}}}}]
client_module = sys.modules[CopilotACPClient.__module__]
with (
patch("model_tools.get_tool_definitions", return_value=tool_defs),
patch("model_tools.check_toolset_requirements", return_value={}),
patch("agent.process_bootstrap.OpenAI") as mock_openai,
patch.object(client_module, "CopilotACPClient") as mock_acp_client,
):
acp_client = MagicMock()
mock_acp_client.return_value = acp_client
agent = AIAgent(
api_key="copilot-acp",
base_url="acp://copilot",
provider="copilot-acp",
acp_command="/usr/local/bin/copilot",
acp_args=["--acp", "--stdio"],
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
assert agent.client is acp_client
mock_openai.assert_not_called()
mock_acp_client.assert_called_once()
assert mock_acp_client.call_args.kwargs["base_url"] == "acp://copilot"
assert mock_acp_client.call_args.kwargs["api_key"] == "copilot-acp"
assert mock_acp_client.call_args.kwargs["command"] == "/usr/local/bin/copilot"
assert mock_acp_client.call_args.kwargs["args"] == ["--acp", "--stdio"]
@@ -1,75 +0,0 @@
"""Tests for gh-copilot CLI deprecation detection and GitHub Models Azure URL mapping."""
import pytest
import providers
providers.get_provider_profile("copilot-acp") # discovery imports the bundled plugin package
from plugins.model_providers.copilot_acp.client import (
is_gh_copilot_deprecation_message as _is_gh_copilot_deprecation_message)
class TestDeprecationPatternDetection:
"""Verify that stderr from the deprecated `gh copilot` extension is caught
without false-positiving on the new `@github/copilot` CLI."""
_REAL_DEPRECATION_STDERR = (
"The gh-copilot extension has been deprecated in favor of the newer "
"GitHub Copilot CLI.\nFor more information, visit:\n"
"- Copilot CLI: https://github.com/github/copilot-cli\n"
"- Deprecation announcement: https://github.blog/changelog/"
"2025-09-25-upcoming-deprecation-of-gh-copilot-cli-extension\n"
"No commands will be executed."
)
def test_real_deprecation_message_matches(self):
assert _is_gh_copilot_deprecation_message(self._REAL_DEPRECATION_STDERR)
@pytest.mark.parametrize(
"stderr_text",
[
# The deprecation banner uses both halves of the fingerprint.
"The gh-copilot extension has been deprecated.",
"gh-copilot: no commands will be executed.",
# Mixed casing — match is case-insensitive.
"The GH-Copilot Extension HAS BEEN DEPRECATED.",
],
)
def test_genuine_deprecation_variants_match(self, stderr_text: str):
assert _is_gh_copilot_deprecation_message(stderr_text)
@pytest.mark.parametrize(
"stderr_text",
[
# Generic errors — no fingerprint at all.
"Error: connection refused",
"",
# The NEW @github/copilot CLI's repo is github.com/github/copilot-cli.
# Its stderr can legitimately mention "copilot-cli" or "deprecation"
# in unrelated contexts; neither alone should trip the detector.
"copilot-cli: failed to authenticate with the API",
"warning: the --foo flag is scheduled for deprecation in v3",
"See https://github.com/github/copilot-cli/issues for support",
# Half the fingerprint without the other half.
"gh-copilot: command not found",
"extension has been deprecated (some other extension)",
],
)
def test_does_not_false_positive(self, stderr_text: str):
assert not _is_gh_copilot_deprecation_message(stderr_text)
class TestGitHubModelsAzureUrl:
"""Verify that the Azure GitHub Models URL is recognised."""
def test_is_github_models_base_url_recognises_azure(self):
from plugins.model_providers.copilot.catalog import _is_github_models_base_url
assert _is_github_models_base_url("https://models.inference.ai.azure.com")
assert _is_github_models_base_url("https://models.inference.ai.azure.com/v1/chat")
def test_is_github_models_base_url_still_recognises_github_ai(self):
from plugins.model_providers.copilot.catalog import _is_github_models_base_url
assert _is_github_models_base_url("https://models.github.ai/inference")
@@ -1,160 +0,0 @@
"""Tests for plugins.model_providers.copilot.credentials — Copilot token validation and resolution."""
import pytest
from unittest.mock import patch
import providers as _providers
_providers.get_provider_profile("copilot") # imports the copilot plugin package (credentials module)
from plugins.model_providers.copilot import credentials as copilot_credentials
class TestTokenValidation:
"""Token type validation."""
def test_classic_pat_rejected(self):
from plugins.model_providers.copilot.credentials import validate_copilot_token
valid, _msg = validate_copilot_token("ghp_abcdefghijklmnop1234")
assert valid is False
@pytest.mark.parametrize("token", ["gho_abcdefghijklmnop1234", "github_pat_abcdefghijklmnop1234", "ghu_abcdefghijklmnop1234"])
def test_supported_token_families_accepted(self, token):
from plugins.model_providers.copilot.credentials import validate_copilot_token
assert validate_copilot_token(token) == (True, "OK")
def test_arbitrary_string_rejected(self):
"""A non-GitHub value in GITHUB_TOKEN must fail validation instead of reaching the API (#12650)."""
from plugins.model_providers.copilot.credentials import validate_copilot_token
valid, _msg = validate_copilot_token("not_a_github_token")
assert valid is False
class TestResolveToken:
"""Token resolution with env var priority."""
def test_gh_token_second_priority(self, monkeypatch):
from plugins.model_providers.copilot.credentials import resolve_copilot_token
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
monkeypatch.setenv("GH_TOKEN", "gho_gh_second")
monkeypatch.setenv("GITHUB_TOKEN", "gho_github_third")
token, source = resolve_copilot_token()
assert token == "gho_gh_second"
assert source == "GH_TOKEN"
def test_gh_cli_classic_pat_raises(self, monkeypatch):
from plugins.model_providers.copilot.credentials import resolve_copilot_token
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
with patch.object(copilot_credentials, "_try_gh_cli_token", return_value="ghp_classic"):
with pytest.raises(ValueError):
resolve_copilot_token()
def test_invalid_env_var_skips_gh_cli_fallback(self, monkeypatch):
"""When an env var is set but holds an unsupported classic PAT,
resolve_copilot_token must NOT fall back to ``gh auth token``.
The user explicitly exported a token; silently substituting one
from the gh CLI credential store is surprising and the subprocess
call adds up to 5s of latency on Windows cold starts (#60800).
Only fall back to the CLI when NO Copilot env var is set at all.
"""
from plugins.model_providers.copilot.credentials import resolve_copilot_token
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.setenv("GITHUB_TOKEN", "ghp_classic_pat_nope")
with patch.object(copilot_credentials, "_try_gh_cli_token") as mock_cli:
token, source = resolve_copilot_token()
assert token == ""
assert source == ""
mock_cli.assert_not_called()
def test_all_env_vars_invalid_skips_gh_cli_fallback(self, monkeypatch):
"""All three env vars set to classic PATs → no gh CLI call."""
from plugins.model_providers.copilot.credentials import resolve_copilot_token
monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "ghp_one")
monkeypatch.setenv("GH_TOKEN", "ghp_two")
monkeypatch.setenv("GITHUB_TOKEN", "ghp_three")
with patch.object(copilot_credentials, "_try_gh_cli_token") as mock_cli:
token, source = resolve_copilot_token()
assert token == ""
assert source == ""
mock_cli.assert_not_called()
class TestGhCliTokenCache:
"""The gh-CLI probe result is cached — a miss must not re-spawn gh.
Regression: /api/model/options ran `gh auth token` four times per build;
with no gh credential store each probe blocked its full 5s timeout, so the
Desktop Models/Providers settings pages took 20s per open and exceeded the
renderer's 15s IPC budget (Aug 2026 desktop audit).
"""
def _reset(self):
from plugins.model_providers.copilot.credentials import _invalidate_gh_cli_token_cache
_invalidate_gh_cli_token_cache()
def test_miss_is_cached_and_probe_runs_once(self):
self._reset()
with patch.object(copilot_credentials, "_probe_gh_cli_token", return_value=None) as probe:
assert copilot_credentials._try_gh_cli_token() is None
assert copilot_credentials._try_gh_cli_token() is None
assert copilot_credentials._try_gh_cli_token() is None
assert probe.call_count == 1
self._reset()
def test_ttl_expiry_reprobes(self, monkeypatch):
self._reset()
clock = {"now": 1000.0}
monkeypatch.setattr(copilot_credentials.time, "monotonic", lambda: clock["now"])
with patch.object(copilot_credentials, "_probe_gh_cli_token", return_value=None) as probe:
copilot_credentials._try_gh_cli_token()
clock["now"] += copilot_credentials._GH_CLI_TOKEN_CACHE_TTL_SECONDS + 1
copilot_credentials._try_gh_cli_token()
assert probe.call_count == 2
self._reset()
class TestRequestHeaders:
"""Copilot API header generation."""
def test_default_headers_include_openai_intent(self):
from plugins.model_providers.copilot.credentials import copilot_request_headers
headers = copilot_request_headers()
assert headers["Openai-Intent"] == "conversation-edits"
assert headers["User-Agent"] == "HermesAgent/1.0"
assert "Editor-Version" in headers
def test_no_vision_header_by_default(self):
from plugins.model_providers.copilot.credentials import copilot_request_headers
headers = copilot_request_headers()
assert "Copilot-Vision-Request" not in headers
class TestCopilotDefaultHeaders:
"""The catalog module's copilot_default_headers uses copilot_credentials."""
def test_param_passthrough_both_values(self):
"""is_agent_turn param correctly maps to x-initiator for both True and False."""
from plugins.model_providers.copilot.catalog import copilot_default_headers
for is_agent, expected in [(True, "agent"), (False, "user")]:
headers = copilot_default_headers(is_agent_turn=is_agent)
assert headers["x-initiator"] == expected, (
f"is_agent_turn={is_agent} should produce x-initiator={expected!r}, "
f"got {headers['x-initiator']!r}"
)
class TestEnvVarOrder:
"""PROVIDER_REGISTRY has correct env var order."""
def test_copilot_env_vars_include_copilot_github_token(self):
from hermes_cli.auth import PROVIDER_REGISTRY
copilot = PROVIDER_REGISTRY["copilot"]
assert "COPILOT_GITHUB_TOKEN" in copilot.api_key_env_vars
# COPILOT_GITHUB_TOKEN should be first
assert copilot.api_key_env_vars[0] == "COPILOT_GITHUB_TOKEN"
@@ -1,66 +0,0 @@
"""Catalog-API-key fallback for the Copilot ``/model`` picker.
Regression for #16708: when the user's only Copilot credential is a
``gho_*`` token (typically obtained via device-code login) stored in
``auth.json`` under ``credential_pool.copilot[]`` — placed there by
``hermes auth add copilot`` or by ``_seed_from_env`` when the env var
is set in ``~/.hermes/.env`` — the picker was silently dropping back to
a stale hardcoded list because ``_resolve_copilot_catalog_api_key``
only consulted env vars / ``gh auth token`` and never read the
credential pool.
"""
from unittest.mock import patch
import providers as _providers
_providers.get_provider_profile("copilot") # imports the copilot plugin package (credentials module)
from plugins.model_providers.copilot import credentials as copilot_credentials
from plugins.model_providers.copilot.catalog import _resolve_copilot_catalog_api_key
class TestCopilotCatalogApiKeyResolution:
def test_falls_back_to_pool_oauth_token(self):
"""Empty env → walk credential_pool.copilot[] for an OAuth access_token."""
with patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
return_value={"api_key": ""},
), patch(
"hermes_cli.auth.read_credential_pool",
return_value=[{"access_token": "gho_abc123"}],
), patch.object(copilot_credentials, "exchange_copilot_token",
return_value=("tid_exchanged_xyz", 1234567890.0),
):
assert _resolve_copilot_catalog_api_key() == "tid_exchanged_xyz"
def test_skips_pool_entry_that_fails_to_exchange(self):
"""If the first entry won't exchange, try the next — an unsupported pool[0]
must not wedge a later valid entry (Copilot review #16868 finding)."""
attempts: list[str] = []
def fake_exchange(raw_token: str):
attempts.append(raw_token)
if raw_token == "gho_unsupported_account":
raise ValueError("Copilot token exchange failed: HTTP 401")
return ("tid_from_second", 1234567890.0)
with patch(
"hermes_cli.auth.resolve_api_key_provider_credentials",
return_value={"api_key": ""},
), patch(
"hermes_cli.auth.read_credential_pool",
return_value=[
{"access_token": "gho_unsupported_account"},
{"access_token": "gho_valid_token"},
],
), patch.object(copilot_credentials, "exchange_copilot_token",
side_effect=fake_exchange,
):
assert _resolve_copilot_catalog_api_key() == "tid_from_second"
assert attempts == ["gho_unsupported_account", "gho_valid_token"]
@@ -1,222 +0,0 @@
"""Tests for Copilot live /models context-window resolution."""
from __future__ import annotations
import time
from unittest.mock import patch
import pytest
import providers as _providers
_providers.get_provider_profile("copilot") # imports the copilot plugin package (catalog module)
from plugins.model_providers.copilot import catalog as copilot_catalog
from plugins.model_providers.copilot.catalog import get_copilot_model_context
# Sample catalog items mimicking the Copilot /models API response
_SAMPLE_CATALOG = [
{
"id": "claude-opus-4.6-1m",
"capabilities": {
"type": "chat",
"limits": {"max_prompt_tokens": 1000000, "max_output_tokens": 64000},
},
},
{
"id": "gpt-4.1",
"capabilities": {
"type": "chat",
"limits": {"max_prompt_tokens": 128000, "max_output_tokens": 32768},
},
},
{
"id": "claude-sonnet-4",
"capabilities": {
"type": "chat",
"limits": {"max_prompt_tokens": 200000, "max_output_tokens": 64000},
},
},
{
"id": "model-without-limits",
"capabilities": {"type": "chat"},
},
{
"id": "model-zero-limit",
"capabilities": {
"type": "chat",
"limits": {"max_prompt_tokens": 0},
},
},
]
@pytest.fixture(autouse=True)
def _clear_cache():
"""Reset module-level cache before each test."""
from plugins.model_providers.copilot import catalog as mod
mod._copilot_context_cache = {}
mod._copilot_context_cache_time = 0.0
yield
mod._copilot_context_cache = {}
mod._copilot_context_cache_time = 0.0
class TestGetCopilotModelContext:
"""Tests for get_copilot_model_context()."""
@patch.object(copilot_catalog, "fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
def test_returns_max_prompt_tokens(self, mock_fetch):
assert get_copilot_model_context("claude-opus-4.6-1m") == 1_000_000
assert get_copilot_model_context("gpt-4.1") == 128_000
@patch.object(copilot_catalog, "fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
def test_cache_expires(self, mock_fetch):
from plugins.model_providers.copilot import catalog as mod
get_copilot_model_context("gpt-4.1")
assert mock_fetch.call_count == 1
# Expire the cache
mod._copilot_context_cache_time = time.time() - 7200
get_copilot_model_context("gpt-4.1")
assert mock_fetch.call_count == 2
@patch("hermes_cli.models._urlopen_model_catalog_request")
def test_fetch_github_model_catalog_uses_short_lived_cache(self, mock_urlopen):
import json as _json
from plugins.model_providers.copilot import catalog as mod
mod._github_model_catalog_cache = None
mod._github_model_catalog_cache_key = None
mod._github_model_catalog_cache_time = 0.0
payload = {
"data": [
{
"id": "gpt-4.1",
"model_picker_enabled": True,
"supported_endpoints": ["/chat/completions"],
}
]
}
class _Resp:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def read(self):
return _json.dumps(payload).encode()
mock_urlopen.return_value = _Resp()
first = mod.fetch_github_model_catalog(api_key="token")
second = mod.fetch_github_model_catalog(api_key="token")
assert [item["id"] for item in first] == ["gpt-4.1"]
assert [item["id"] for item in second] == ["gpt-4.1"]
assert mock_urlopen.call_count == 1
# Cached copies are independent — mutating the result must not
# poison the cache.
second[0]["id"] = "mutated"
third = mod.fetch_github_model_catalog(api_key="token")
assert [item["id"] for item in third] == ["gpt-4.1"]
assert mock_urlopen.call_count == 1
@patch("hermes_cli.models._urlopen_model_catalog_request")
def test_fetch_github_model_catalog_cache_expires_after_ttl(self, mock_urlopen):
import json as _json
import time as _time
from plugins.model_providers.copilot import catalog as mod
mod._github_model_catalog_cache = None
mod._github_model_catalog_cache_key = None
mod._github_model_catalog_cache_time = 0.0
payload = {
"data": [
{
"id": "gpt-4.1",
"model_picker_enabled": True,
"supported_endpoints": ["/chat/completions"],
}
]
}
class _Resp:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def read(self):
return _json.dumps(payload).encode()
mock_urlopen.return_value = _Resp()
mod.fetch_github_model_catalog(api_key="token")
assert mock_urlopen.call_count == 1
# Age the entry past the TTL (monotonic clock) — next call re-fetches.
mod._github_model_catalog_cache_time = (
_time.monotonic() - mod._GITHUB_MODEL_CATALOG_CACHE_TTL - 1
)
mod.fetch_github_model_catalog(api_key="token")
assert mock_urlopen.call_count == 2
@patch("hermes_cli.models._urlopen_model_catalog_request")
def test_fetch_github_model_catalog_cache_misses_on_credential_change(self, mock_urlopen):
import json as _json
from plugins.model_providers.copilot import catalog as mod
mod._github_model_catalog_cache = None
mod._github_model_catalog_cache_key = None
mod._github_model_catalog_cache_time = 0.0
payload = {
"data": [
{
"id": "gpt-4.1",
"model_picker_enabled": True,
"supported_endpoints": ["/chat/completions"],
}
]
}
class _Resp:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def read(self):
return _json.dumps(payload).encode()
mock_urlopen.return_value = _Resp()
mod.fetch_github_model_catalog(api_key="token-a")
assert mock_urlopen.call_count == 1
# A different token must not be served the previous account's catalog.
mod.fetch_github_model_catalog(api_key="token-b")
assert mock_urlopen.call_count == 2
@patch.object(copilot_catalog, "fetch_github_model_catalog", return_value=[])
def test_returns_none_for_empty_catalog(self, mock_fetch):
assert get_copilot_model_context("gpt-4.1") is None
class TestModelMetadataCopilotIntegration:
"""Test that get_model_context_length() uses Copilot live API for copilot provider."""
@patch.object(copilot_catalog, "fetch_github_model_catalog", return_value=_SAMPLE_CATALOG)
def test_copilot_provider_uses_live_api(self, mock_fetch):
from agent.model_metadata import get_model_context_length
ctx = get_model_context_length("claude-opus-4.6-1m", provider="copilot")
assert ctx == 1_000_000
@@ -1,40 +0,0 @@
"""Regression for #110597: Copilot custom/enterprise (BYOK) model ids must survive normalization.
GitHub's enterprise custom-model (BYOK) catalog exposes Copilot ids shaped ``owner/sub/model``
— that is, TWO slashes. With no candidate matching the (unreachable) catalog,
``normalize_copilot_model_id`` stripped the FIRST path segment and returned the result:
``acme-github-copilot/GLM/glm-5.2`` became ``GLM/glm-5.2``, the CLI's second normalization pass
stripped again to ``glm-5.2``, and the Copilot API answered HTTP 400 ``model_not_supported``.
Every such call failed, for both custom models a tenant had configured.
"""
import pytest
import providers as _providers
_providers.get_provider_profile("copilot") # imports the copilot plugin package (catalog module)
from plugins.model_providers.copilot.catalog import normalize_copilot_model_id
# ``owner/sub/model`` — the shape an enterprise BYOK custom model has (tenant name sanitized).
CUSTOM_MODEL_IDS = [
"acme-github-copilot/QDeepseekV4/deepseek-flash",
"acme-github-copilot/GLM/glm-5.2",
"acme-github-copilot/HUAWEI/glm-5.2",
]
@pytest.mark.parametrize("model_id", CUSTOM_MODEL_IDS)
def test_two_slash_custom_model_id_is_passed_through(model_id):
"""A two-slash id IS a Copilot id — normalize nothing, catalog or no catalog."""
# ``hermes_cli/model_normalize.py`` calls this without an api_key, so the catalog is always
# empty on the runtime path that produced the 400.
assert normalize_copilot_model_id(model_id, catalog=[], api_key=None) == model_id
assert normalize_copilot_model_id(
model_id, catalog=[{"id": model_id}], api_key=None) == model_id
def test_single_vendor_prefix_still_folds_to_the_bare_id():
"""Contract preserved for the one-slash case this stripping exists for (#6879)."""
assert normalize_copilot_model_id(
"anthropic/claude-sonnet-4.6", catalog=[], api_key=None) == "claude-sonnet-4.6"
@@ -1,51 +0,0 @@
"""Copilot endpoint identity, owned by the copilot plugin's profile hooks.
The premium-request ``x-initiator: user`` header (issue #3040) goes only to the Copilot API and
GitHub Models hosts (the copilot profile's ``request_headers``), and host-based routing must match
URL *hostnames*, never substrings (``providers.profile_for_host``): a proxy path or a lookalike
domain that merely contains a Copilot host is not Copilot.
"""
from __future__ import annotations
import pytest
@pytest.fixture
def copilot_profile():
import providers
profile = providers.get_provider_profile("copilot")
assert profile is not None
return profile
def _marks_user_turn(profile, url: str) -> bool:
return "x-initiator" in profile.request_headers(base_url=url, user_initiated=True)
def _is_copilot_host(url: str) -> bool:
from providers import profile_for_host
profile = profile_for_host(url)
return profile is not None and profile.name == "copilot"
class TestInitiatorHosts:
"""The x-initiator hook targets GitHub Copilot endpoints only."""
def test_standard_copilot_url(self, copilot_profile):
assert _marks_user_turn(copilot_profile, "https://api.githubcopilot.com") is True
def test_github_models_url(self, copilot_profile):
assert _marks_user_turn(copilot_profile, "https://models.github.ai/inference") is True
def test_openrouter_url(self, copilot_profile):
assert _marks_user_turn(copilot_profile, "https://openrouter.ai/api/v1") is False
def test_copilot_host_identity_is_host_anchored():
assert _is_copilot_host("https://api.githubcopilot.com/v1") is True
assert _is_copilot_host("https://proxy.test/api.githubcopilot.com/v1") is False
assert _is_copilot_host("https://models.github.ai/inference") is True
assert _is_copilot_host("https://models.github.ai.evil.com/v1") is False
@@ -1,39 +0,0 @@
"""Copilot selected at runtime exchanges the raw GitHub token held in its pool row
(``ProviderProfile.prepare_pool_entry``), moved from tests/hermes_cli/test_runtime_provider_resolution.py."""
from __future__ import annotations
import json
import providers as _providers
from hermes_cli import runtime_provider as rp
_providers.get_provider_profile("copilot") # imports the copilot plugin package (credentials module)
from plugins.model_providers.copilot import credentials as copilot_credentials
from plugins.model_providers.copilot import wire as copilot_wire
def test_runtime_selected_copilot_exchanges_ambient_pool_token(tmp_path, monkeypatch):
"""Copilot picked at runtime without a config write (`/model copilot/<m> --session`,
`--provider copilot`) must still hand the EXCHANGED token and the enterprise base_url to the
client: the seeder leaves an ambient gh-CLI credential raw while copilot is not configured
(#114740), and a raw token 400s on enterprise-only models."""
hermes_home = tmp_path / "hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
(hermes_home / "auth.json").write_text(json.dumps({"version": 1, "credential_pool": {}}))
(hermes_home / "config.yaml").write_text("model:\n provider: deepseek\n default: deepseek-chat\n")
from hermes_cli import config as _cfg
_cfg._LOAD_CONFIG_CACHE.clear()
_cfg._RAW_CONFIG_CACHE.clear()
monkeypatch.setattr(copilot_credentials, "resolve_copilot_token", lambda: ("ghu_raw_gh_token", "gh auth token"))
monkeypatch.setattr(copilot_credentials, "get_copilot_api_token",
lambda tok: ("tid=exchanged;exp=1", "https://api.enterprise.ghe.example"))
monkeypatch.setattr(copilot_wire, "model_api_mode", lambda *a, **k: "chat_completions")
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "copilot")
resolved = rp.resolve_runtime_provider(requested="copilot", target_model="gpt-4.1")
assert resolved["provider"] == "copilot"
assert resolved["api_key"] == "tid=exchanged;exp=1"
assert resolved["base_url"].startswith("https://api.enterprise.ghe.example")
@@ -1,143 +0,0 @@
"""Unit tests for the Copilot provider profile's reasoning-effort wiring.
GitHub Copilot serves different models with different supported reasoning-effort
sets (the live ``/models`` catalog reports them per model). The profile must
forward the requested effort when the catalog lists it as supported, and only
downgrade to the nearest weaker supported level when it does not, rather than
unconditionally collapsing ``xhigh`` to ``high`` (which silently capped models
that actually support the higher level).
These tests pin that contract without going live, by stubbing the catalog
lookup ``github_model_reasoning_efforts`` or passing it an explicit ``catalog=``.
"""
from __future__ import annotations
import pytest
@pytest.fixture
def copilot_profile():
"""Resolve the registered Copilot profile.
Importing ``model_tools`` triggers plugin discovery, which registers the
Copilot profile. Going through ``get_provider_profile`` keeps the test
honest: if the registered class is ever swapped for a plain
``ProviderProfile`` the assertions below collapse.
"""
import model_tools
import providers
profile = providers.get_provider_profile("copilot")
assert profile is not None, "copilot provider profile must be registered"
return profile
def _patch_efforts(monkeypatch, efforts):
"""Stub the catalog lookup the profile and main-agent path call for supported efforts."""
import providers
providers.get_provider_profile("copilot") # imports the copilot plugin package (catalog module)
from plugins.model_providers.copilot import catalog as copilot_catalog
monkeypatch.setattr(copilot_catalog, "github_model_reasoning_efforts", lambda model: list(efforts))
class TestCopilotReasoningEffortClamp:
def test_supported_effort_forwarded_verbatim(self, copilot_profile, monkeypatch):
"""xhigh is forwarded unchanged when the catalog lists it."""
_patch_efforts(monkeypatch, ["minimal", "low", "medium", "high", "xhigh"])
extra_body, _ = copilot_profile.build_api_kwargs_extras(
model="gpt-5.5",
reasoning_config={"effort": "xhigh"},
supports_reasoning=True,
)
assert extra_body["reasoning"] == {"effort": "xhigh"}
def test_xhigh_downgrades_to_high_when_unsupported(self, copilot_profile, monkeypatch):
"""A model whose catalog lacks xhigh gets the nearest weaker level."""
_patch_efforts(monkeypatch, ["low", "medium", "high"])
extra_body, _ = copilot_profile.build_api_kwargs_extras(
model="o-series-model",
reasoning_config={"effort": "xhigh"},
supports_reasoning=True,
)
assert extra_body["reasoning"] == {"effort": "high"}
def test_minimal_downgrades_to_low_when_unsupported(self, copilot_profile, monkeypatch):
_patch_efforts(monkeypatch, ["low", "medium", "high"])
extra_body, _ = copilot_profile.build_api_kwargs_extras(
model="o-series-model",
reasoning_config={"effort": "minimal"},
supports_reasoning=True,
)
assert extra_body["reasoning"] == {"effort": "low"}
def test_unsupported_effort_falls_back_to_medium(self, copilot_profile, monkeypatch):
"""An effort not in the set, with no specific rule, falls to medium."""
_patch_efforts(monkeypatch, ["low", "medium", "high"])
extra_body, _ = copilot_profile.build_api_kwargs_extras(
model="some-model",
reasoning_config={"effort": "garbage"},
supports_reasoning=True,
)
assert extra_body["reasoning"] == {"effort": "medium"}
def test_falls_back_to_first_supported_when_no_medium(self, copilot_profile, monkeypatch):
"""If medium isn't supported either, pick the first supported level."""
_patch_efforts(monkeypatch, ["low", "high"])
extra_body, _ = copilot_profile.build_api_kwargs_extras(
model="weird-model",
reasoning_config={"effort": "xhigh"},
supports_reasoning=True,
)
# xhigh not supported, high IS supported → high wins via the xhigh rule.
assert extra_body["reasoning"] == {"effort": "high"}
def test_first_supported_when_no_rule_matches(self, copilot_profile, monkeypatch):
_patch_efforts(monkeypatch, ["low", "high"])
extra_body, _ = copilot_profile.build_api_kwargs_extras(
model="weird-model",
reasoning_config={"effort": "garbage"},
supports_reasoning=True,
)
assert extra_body["reasoning"] == {"effort": "low"}
class TestCopilotOfflineAstraEfforts:
"""With no live catalog entry, Astra still advertises its own ladder (exact slugs only),
and a structured catalog entry keeps precedence over the offline fallback."""
@pytest.mark.parametrize("model, expected", [
("gpt-6-astra", ["low", "medium", "high", "xhigh", "max"]),
("openai/gpt-6-astra", ["low", "medium", "high", "xhigh", "max"]),
("gpt-6-astra-pro", []), # speed-tier / unknown suffixes stay off the Astra ladder
])
def test_offline_fallback_uses_exact_astra_slugs(self, model, expected):
from plugins.model_providers.copilot.catalog import github_model_reasoning_efforts
assert github_model_reasoning_efforts(model, catalog=[]) == expected
def test_structured_catalog_beats_offline_fallback(self):
from plugins.model_providers.copilot.catalog import github_model_reasoning_efforts
catalog = [{"id": "gpt-6-astra", "capabilities": {
"type": "chat", "supports": {"reasoning_effort": ["low", "high"]}}}]
assert github_model_reasoning_efforts("gpt-6-astra", catalog=catalog) == ["low", "high"]
@pytest.mark.parametrize("effort, expected", [
("xhigh", "high"), ("max", "high"), ("ultra", "high"), ("minimal", "minimal"),
("garbage", "medium"), (" HIGH ", "high"),
])
def test_main_agent_github_clamp_never_escalates(monkeypatch, effort, expected):
"""The main-agent GitHub path shares the profile's clamp: unsupported levels step down to
the nearest weaker listed one (max/ultra no longer fall all the way to medium)."""
from types import SimpleNamespace
from agent.reasoning_params import ReasoningParamsMixin
_patch_efforts(monkeypatch, ["minimal", "low", "medium", "high"])
agent = SimpleNamespace(model="gpt-5.4", reasoning_config={"enabled": True, "effort": effort},
base_url="https://api.githubcopilot.com")
assert ReasoningParamsMixin._endpoint_reasoning_payload(agent) == {"effort": expected}
@@ -1,17 +0,0 @@
"""Copilot's credential-refresh status lines keep main's wording (``ProviderProfile.credential_refresh_notice``)."""
import providers
def test_copilot_refresh_notices_match_the_pre_seam_text():
profile = providers.get_provider_profile("copilot")
assert profile.credential_refresh_notice(reason="unauthorized") == (
"Copilot credentials refreshed after 401. Retrying request...")
assert profile.credential_refresh_notice(reason="stale") == (
"Copilot credential re-exchanged after model_not_available 400. Retrying request...")
def test_profiles_without_a_notice_get_the_generic_wording():
from providers.base import ProviderProfile
assert ProviderProfile(name="plain").credential_refresh_notice(reason="unauthorized") == ""
@@ -1,88 +0,0 @@
"""Copilot's in-app device-code login hooks (``plugins/model-providers/copilot/setup.py``) against a
local fake of GitHub's device endpoints: the codes, the RFC 8628 poll answers and where the token lands."""
from __future__ import annotations
import json
import sys
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import parse_qs
import pytest
import providers
class _FakeGitHub(BaseHTTPRequestHandler):
replies: list[dict] = []
seen: list[dict] = []
def log_message(self, *args):
pass
def do_POST(self):
fields = parse_qs(self.rfile.read(int(self.headers["Content-Length"])).decode())
type(self).seen.append({"path": self.path, **{k: v[0] for k, v in fields.items()}})
body = json.dumps(type(self).replies.pop(0)).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(body)
@pytest.fixture
def github(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
for var in ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"):
monkeypatch.delenv(var, raising=False)
server = HTTPServer(("127.0.0.1", 0), _FakeGitHub)
threading.Thread(target=server.serve_forever, daemon=True).start()
_FakeGitHub.replies, _FakeGitHub.seen = [], []
profile = providers.get_provider_profile("copilot")
setup = sys.modules[f"{type(profile).__module__}.setup"]
monkeypatch.setattr(setup, "_DEVICE_LOGIN_BASE", f"http://127.0.0.1:{server.server_port}")
yield profile, _FakeGitHub
server.shutdown()
def test_start_and_poll_follow_the_terminal_flow(github):
profile, fake = github
fake.replies = [
{"device_code": "dev-1", "user_code": "ABCD-1234", "verification_uri": "https://github.com/login/device",
"expires_in": 900, "interval": 5},
{"error": "authorization_pending"},
{"error": "slow_down", "interval": 12},
{"error": "slow_down"},
{"access_token": "gho_fake"},
]
login = profile.start_device_login()
assert {k: login[k] for k in ("user_code", "verification_url", "expires_in", "interval")} == {
"user_code": "ABCD-1234", "verification_url": "https://github.com/login/device",
"expires_in": 900, "interval": 8} # GitHub's interval + the terminal flow's 3 s safety margin
state = login["state"]
assert profile.poll_device_login(state) == {"status": "pending"}
slowed = profile.poll_device_login(state)
assert (slowed["status"], slowed["interval"]) == ("pending", 15) # server-supplied interval honoured
state = slowed["state"]
assert profile.poll_device_login(state)["interval"] == 20 # +5 s without one (RFC 8628)
assert profile.poll_device_login(state) == {"status": "approved", "credential": "gho_fake"}
assert [s["path"] for s in fake.seen] == ["/login/device/code"] + ["/login/oauth/access_token"] * 4
assert {s["device_code"] for s in fake.seen[1:]} == {"dev-1"}
@pytest.mark.parametrize(("error", "status"), [("expired_token", "expired"), ("access_denied", "denied"),
("unsupported_grant_type", "error")])
def test_terminal_poll_errors_end_the_login(github, error, status):
profile, fake = github
fake.replies = [{"error": error}]
assert profile.poll_device_login({"device_code": "dev-1", "interval": 5})["status"] == status
def test_saved_token_is_what_the_card_reports(github, tmp_path):
profile, _fake = github
assert profile.account_status()["logged_in"] is False
profile.save_device_login("gho_saved")
assert "COPILOT_GITHUB_TOKEN=gho_saved" in (tmp_path / ".env").read_text()
status = profile.account_status()
assert (status["logged_in"], status["source"], status["source_label"]) == (True, "env_var", "COPILOT_GITHUB_TOKEN")
@@ -1,235 +0,0 @@
"""Tests for Copilot token exchange (raw GitHub token → Copilot API token)."""
from __future__ import annotations
import json
import time
from unittest.mock import MagicMock, patch
import pytest
import providers as _providers
_providers.get_provider_profile("copilot") # imports the copilot plugin package (credentials module)
from plugins.model_providers.copilot import credentials as copilot_credentials
@pytest.fixture(autouse=True)
def _clear_jwt_cache():
"""Reset the module-level JWT + failure caches before each test."""
mod = copilot_credentials
mod._jwt_cache.clear()
mod._exchange_failure_cache.clear()
yield
mod._jwt_cache.clear()
mod._exchange_failure_cache.clear()
class TestExchangeCopilotToken:
"""Tests for exchange_copilot_token()."""
def _mock_urlopen(self, token="tid=abc;exp=123;sku=copilot_individual", expires_at=None):
"""Create a mock urlopen context manager returning a token response."""
if expires_at is None:
expires_at = time.time() + 1800
resp_data = json.dumps({"token": token, "expires_at": expires_at}).encode()
mock_resp = MagicMock()
mock_resp.read.return_value = resp_data
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
return mock_resp
@patch("urllib.request.urlopen")
def test_exchanges_token_successfully(self, mock_urlopen):
from plugins.model_providers.copilot.credentials import exchange_copilot_token
mock_urlopen.return_value = self._mock_urlopen(token="tid=abc;exp=999")
api_token, expires_at, base_url = exchange_copilot_token("gho_test123")
assert api_token == "tid=abc;exp=999"
assert isinstance(expires_at, float)
assert base_url is None # no proxy-ep in this token
# Verify request was made with correct headers
call_args = mock_urlopen.call_args
req = call_args[0][0]
assert req.get_header("Authorization") == "token gho_test123"
assert "GitHubCopilotChat" in req.get_header("User-agent")
@patch("urllib.request.urlopen")
def test_raises_on_empty_token(self, mock_urlopen):
from plugins.model_providers.copilot.credentials import exchange_copilot_token
resp_data = json.dumps({"token": "", "expires_at": 0}).encode()
mock_resp = MagicMock()
mock_resp.read.return_value = resp_data
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
mock_urlopen.return_value = mock_resp
with pytest.raises(ValueError, match="empty token"):
exchange_copilot_token("gho_test123")
class TestCallerIntegration:
"""Test that callers correctly use token exchange."""
@patch.object(copilot_credentials, "resolve_copilot_token", return_value=("gho_raw", "GH_TOKEN"))
@patch.object(copilot_credentials, "get_copilot_api_token", return_value=("exchanged_jwt", None))
def test_auth_resolve_uses_exchange(self, mock_exchange, mock_resolve):
from hermes_cli.auth import _resolve_api_key_provider_secret
# Create a minimal pconfig mock
pconfig = MagicMock()
token, source = _resolve_api_key_provider_secret("copilot", pconfig)
assert token == "exchanged_jwt"
assert source == "GH_TOKEN"
mock_exchange.assert_called_once_with("gho_raw")
class TestDeriveBaseUrlFromProxyEp:
"""Tests for _derive_base_url_from_proxy_ep()."""
def test_extracts_enterprise_url(self):
from plugins.model_providers.copilot.credentials import _derive_base_url_from_proxy_ep
token = "tid=abc;exp=999;proxy-ep=proxy.enterprise.githubcopilot.com;sku=copilot_enterprise"
assert _derive_base_url_from_proxy_ep(token) == "https://api.enterprise.githubcopilot.com"
class TestJwtDiskStoreBounds:
"""The on-disk JWT store must go through one bounded read everywhere."""
def _store_path(self, tmp_path, monkeypatch):
mod = copilot_credentials
path = tmp_path / mod._JWT_DISK_FILENAME
monkeypatch.setattr(mod, "_jwt_disk_path", lambda: path)
return path
def test_read_jwt_store_rejects_oversized_file(self, tmp_path, monkeypatch):
mod = copilot_credentials
path = self._store_path(tmp_path, monkeypatch)
path.write_text("x" * (mod._JWT_DISK_MAX_BYTES + 1))
assert mod._read_jwt_store(path) is None
# Load path treats it as unusable → caller re-exchanges.
assert mod._load_jwt_from_disk("deadbeef") is None
def test_read_jwt_store_rejects_non_dict_and_malformed(self, tmp_path, monkeypatch):
mod = copilot_credentials
path = self._store_path(tmp_path, monkeypatch)
path.write_text("[1, 2, 3]")
assert mod._read_jwt_store(path) is None
path.write_text("{not json")
assert mod._read_jwt_store(path) is None
def test_evict_ignores_oversized_store(self, tmp_path, monkeypatch):
"""Eviction on an oversized store must not parse or rewrite it."""
mod = copilot_credentials
path = self._store_path(tmp_path, monkeypatch)
blob = "x" * (mod._JWT_DISK_MAX_BYTES + 1)
path.write_text(blob)
mod.evict_cached_exchanged_token("gho_whatever")
# Untouched — bounded read refused it before any rewrite.
assert path.read_text() == blob
def test_save_discards_oversized_store_instead_of_merging(self, tmp_path, monkeypatch):
"""Saving over a corrupt/oversized store starts fresh rather than
re-serializing the oversized content back out."""
import json as _json
import time as _time
mod = copilot_credentials
path = self._store_path(tmp_path, monkeypatch)
path.write_text("x" * (mod._JWT_DISK_MAX_BYTES + 1))
mod._save_jwt_to_disk("fp1", "tid=fresh", _time.time() + 1800, None)
store = _json.loads(path.read_text())
assert set(store) == {"fp1"}
assert store["fp1"]["api_token"] == "tid=fresh"
class TestExchangeFailureFastPath:
"""Auth rejections must not sleep, and failures must not repeat network hits.
Regression tests for the /model picker stall: a 403-rejected token made
every load_pool("copilot") burn ~4.5s in retry backoff, turning provider
discovery (picker open, delegation spawns, dashboard) into a 7s wait.
"""
def _http_error(self, code):
import urllib.error
return urllib.error.HTTPError(
url="https://api.github.com/copilot_internal/v2/token",
code=code, msg="err", hdrs=None, fp=None,
)
@patch("time.sleep")
@patch("urllib.request.urlopen")
def test_403_fails_fast_without_retry_or_sleep(self, mock_urlopen, mock_sleep):
from plugins.model_providers.copilot.credentials import exchange_copilot_token
mock_urlopen.side_effect = self._http_error(403)
with pytest.raises(ValueError):
exchange_copilot_token("gho_rejected")
assert mock_urlopen.call_count == 1 # no retries on auth rejection
mock_sleep.assert_not_called()
@patch("time.sleep")
@patch("urllib.request.urlopen")
def test_negative_cache_skips_network_on_second_call(self, mock_urlopen, mock_sleep):
from plugins.model_providers.copilot.credentials import exchange_copilot_token
mock_urlopen.side_effect = self._http_error(403)
with pytest.raises(ValueError):
exchange_copilot_token("gho_rejected")
with pytest.raises(ValueError, match="recently failed"):
exchange_copilot_token("gho_rejected")
assert mock_urlopen.call_count == 1 # second call never hit the network
@patch("time.sleep")
@patch("urllib.request.urlopen")
def test_transient_failure_still_retries_then_caches(self, mock_urlopen, mock_sleep):
mod = copilot_credentials
from plugins.model_providers.copilot.credentials import exchange_copilot_token, _token_fingerprint
mock_urlopen.side_effect = OSError("network unreachable")
with pytest.raises(ValueError):
exchange_copilot_token("gho_flaky")
assert mock_urlopen.call_count == mod._EXCHANGE_MAX_ATTEMPTS
fp = _token_fingerprint("gho_flaky")
until = mod._exchange_failure_cache.get(fp, 0)
# Transient TTL, not the 30-min permanent one.
assert 0 < until - time.time() <= mod._EXCHANGE_FAILURE_TTL_TRANSIENT_SECONDS + 1
@patch("time.sleep")
@patch("urllib.request.urlopen")
def test_success_clears_negative_cache(self, mock_urlopen, mock_sleep):
mod = copilot_credentials
from plugins.model_providers.copilot.credentials import exchange_copilot_token, _token_fingerprint
fp = _token_fingerprint("gho_recovering")
# Simulate an expired negative-cache entry so the call proceeds.
mod._exchange_failure_cache[fp] = time.time() - 1
resp_data = json.dumps(
{"token": "tid=ok;exp=1", "expires_at": time.time() + 1800}
).encode()
mock_resp = MagicMock()
mock_resp.read.return_value = resp_data
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
mock_urlopen.return_value = mock_resp
api_token, _, _ = exchange_copilot_token("gho_recovering")
assert api_token == "tid=ok;exp=1"
assert fp not in mod._exchange_failure_cache
def test_evict_clears_negative_cache(self):
mod = copilot_credentials
from plugins.model_providers.copilot.credentials import evict_cached_exchanged_token, _token_fingerprint
fp = _token_fingerprint("gho_stale")
mod._exchange_failure_cache[fp] = time.time() + 999
evict_cached_exchanged_token("gho_stale")
assert fp not in mod._exchange_failure_cache
@@ -75,4 +75,4 @@ def test_other_plugins_in_the_flat_directory_are_left_to_the_plugin_manager(herm
from providers import get_provider_profile, list_providers
assert not [p for p in list_providers() if p.name in ("other-standalone", "manifestless", "broken-manifest")]
assert get_provider_profile("copilot-acp") is not None # bundled set still intact
assert get_provider_profile("openrouter") is not None # bundled set still intact
@@ -5,22 +5,23 @@ from types import SimpleNamespace
import pytest
from providers import register_provider
from providers.base import ProviderProfile
from tools.delegate_tool_config import _resolve_child_runtime
@pytest.mark.parametrize("external", [True, False])
def test_pinned_command_retains_selected_external_provider(external):
def test_pinned_command_retains_selected_external_provider(external, process_profiles):
profile = ProviderProfile(name="test-process-provider", display_name="Test", auth_type="external_process" if external else "api_key")
register_provider(profile)
fallback = ProviderProfile(name="fallback-acp", auth_type="external_process", process_command="agent-cli",
process_command_fallback=True)
process_profiles(profile, fallback)
parent = SimpleNamespace(model="test-model", provider=profile.name, base_url="process://test", api_mode="chat_completions", acp_args=[])
result = _resolve_child_runtime(
parent, {}, "external-process", model=None, override_provider=profile.name,
override_base_url=None, override_api_key=None, override_api_mode=None,
override_acp_command=sys.executable, override_acp_args=[],
)
assert result["provider"] == (profile.name if external else "copilot-acp")
assert result["provider"] == (profile.name if external else fallback.name)
assert result["requested_provider"] == result["provider"]
assert result["acp_command"] == sys.executable
+17 -3
View File
@@ -296,12 +296,26 @@ def test_profile_route_is_refused_under_a_managed_worker_config_snapshot(two_pro
def test_profile_pinned_to_a_missing_acp_command_is_refused_at_routing(two_profiles, monkeypatch):
"""Same rule as ``delegation.provider`` (#80450): a profile whose provider is pinned to an ACP binary
that is not on PATH refuses the spawn, naming the profile, instead of building a broken child."""
import providers
from hermes_cli import auth
from providers.base import ProviderProfile
_, coder = two_profiles
_set_config(coder, model={"provider": "copilot-acp", "default": "coder-model"})
profile = ProviderProfile(name="acme-acp", auth_type="external_process", base_url="acp://acme",
process_command="acme", process_args=("--acp", "--stdio"),
process_command_env_vars=("HERMES_ACME_ACP_COMMAND",),
base_url_env_var="ACME_ACP_BASE_URL")
monkeypatch.setitem(providers._REGISTRY, profile.name, profile)
monkeypatch.setattr(providers, "_PROVIDER_LIST_CACHE", None)
monkeypatch.setattr(auth, "PROVIDER_REGISTRY", dict(auth.PROVIDER_REGISTRY))
auth.PROVIDER_REGISTRY[profile.name] = auth.ProviderConfig(
profile.name, profile.name, profile.auth_type, inference_base_url=profile.base_url,
base_url_env_var=profile.base_url_env_var)
_set_config(coder, model={"provider": "acme-acp", "default": "coder-model"})
# An acp+tcp endpoint keeps the configured command unresolved (the runtime resolver only refuses a missing
# binary when it would spawn one); the delegation pin rule still refuses it.
monkeypatch.setenv("HERMES_COPILOT_ACP_COMMAND", "hermes-test-missing-acp-binary")
monkeypatch.setenv("COPILOT_ACP_BASE_URL", "acp+tcp://127.0.0.1:9")
monkeypatch.setenv("HERMES_ACME_ACP_COMMAND", "hermes-test-missing-acp-binary")
monkeypatch.setenv("ACME_ACP_BASE_URL", "acp+tcp://127.0.0.1:9")
(route,), error = _resolve_coder_task()
assert error is None and route.creds == {}
+2 -58
View File
@@ -2,10 +2,9 @@
The Desktop/TUI cold start could stall the event loop for ~14s because
synchronous CPU-bound work ran on the loop thread during the window
between ``HERMES_BACKEND_READY`` and the first prompt. Three fixes:
between ``HERMES_BACKEND_READY`` and the first prompt. Fix 1 (the Copilot
``gh auth token`` skip) is tested with the github-copilot plugin; here:
1. ``copilot_credentials.resolve_copilot_token`` skips the ``gh auth token``
subprocess when a Copilot env var is explicitly set (even if invalid).
2. ``tui_gateway.ws.handle_ws`` runs ``resolve_skin()`` via
``asyncio.to_thread`` so the loop is not blocked by config/skin init.
3. ``web_server_lifecycle._warm_gateway_module`` pre-imports the heavy module
@@ -13,61 +12,6 @@ between ``HERMES_BACKEND_READY`` and the first prompt. Three fixes:
import on the loop thread.
"""
from unittest.mock import patch
import providers as _providers
_providers.get_provider_profile("copilot") # imports the copilot plugin package (credentials module)
from plugins.model_providers.copilot import credentials as copilot_credentials
# ─── Fix 1: copilot_auth skips gh CLI when env var is set ──────────────
class TestCopilotAuthSkipsGhCli:
"""resolve_copilot_token must not call _try_gh_cli_token when any
Copilot env var is set, even if the token is an unsupported classic PAT.
See test_copilot_credentials.py::TestResolveToken for the full env-var-priority
suite; these tests focus on the #60800 cold-start regression — the
gh CLI subprocess adds up to 5s on Windows and should not fire when
the user already expressed token intent via an env var.
"""
def test_invalid_env_var_skips_gh_cli(self, monkeypatch):
from plugins.model_providers.copilot.credentials import resolve_copilot_token
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.setenv("GITHUB_TOKEN", "ghp_classic_pat_nope")
with patch.object(copilot_credentials, "_try_gh_cli_token") as mock_cli:
token, source = resolve_copilot_token()
assert token == ""
assert source == ""
mock_cli.assert_not_called()
def test_valid_env_var_skips_gh_cli(self, monkeypatch):
"""A valid token in an env var should return immediately — no CLI."""
from plugins.model_providers.copilot.credentials import resolve_copilot_token
monkeypatch.setenv("GITHUB_TOKEN", "gho_valid_oauth_token")
with patch.object(copilot_credentials, "_try_gh_cli_token") as mock_cli:
token, source = resolve_copilot_token()
assert token == "gho_valid_oauth_token"
assert source == "GITHUB_TOKEN"
mock_cli.assert_not_called()
def test_no_env_vars_falls_back_to_gh_cli(self, monkeypatch):
"""When NO env var is set, the gh CLI fallback must still fire."""
from plugins.model_providers.copilot.credentials import resolve_copilot_token
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
with patch.object(copilot_credentials, "_try_gh_cli_token",
return_value="gho_from_cli",
) as mock_cli:
token, source = resolve_copilot_token()
assert token == "gho_from_cli"
assert source == "gh auth token"
mock_cli.assert_called_once()
# ─── Fix 2: resolve_skin runs via to_thread in handle_ws ───────────────