mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
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",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ───────────────────
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,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
|
||||
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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 ───────────────
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user