From c008cfe9207dbdd88716bc8c6e73488647ca6068 Mon Sep 17 00:00:00 2001 From: "Louis.Anson" Date: Mon, 14 Sep 2026 13:38:14 +0800 Subject: [PATCH] fix(copilot): pass two-slash custom-model ids through unchanged Copilot enterprise custom models (BYOK) expose catalog ids shaped owner/sub/model. normalize_copilot_model_id() only tried the full id and the id minus its FIRST segment, and returned the stripped form when neither matched the (unreachable) catalog - corrupting those ids into sub/model, which the CLI's second normalization pass then stripped again to model. The Copilot API answered HTTP 400 model_not_supported on every call. Only accept the strip guess when the remainder is itself a flat id: a result that still contains "/" cannot be a Copilot id. Fixes #110597 --- hermes_cli/models.py | 7 +++- .../test_copilot_custom_model_ids.py | 37 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 tests/hermes_cli/test_copilot_custom_model_ids.py diff --git a/hermes_cli/models.py b/hermes_cli/models.py index d00b63c74f..8f9099987f 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -2051,7 +2051,12 @@ def normalize_copilot_model_id( return candidate if "/" in raw: - return raw.split("/", 1)[1].strip() + 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 diff --git a/tests/hermes_cli/test_copilot_custom_model_ids.py b/tests/hermes_cli/test_copilot_custom_model_ids.py new file mode 100644 index 0000000000..20e83ba399 --- /dev/null +++ b/tests/hermes_cli/test_copilot_custom_model_ids.py @@ -0,0 +1,37 @@ +"""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 + +from hermes_cli.models 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"