From 9789307dd691d469881b45ad7e0a7f655bab110d Mon Sep 17 00:00:00 2001 From: PiEgg Date: Thu, 19 Feb 2026 13:30:02 +0800 Subject: [PATCH 1/2] Fix Codex provider routing for GitHub Copilot models --- nanobot/config/schema.py | 25 +++++++++++++- nanobot/providers/litellm_provider.py | 13 +++++++- nanobot/providers/openai_codex_provider.py | 2 +- nanobot/providers/registry.py | 16 ++++++++- tests/test_commands.py | 38 ++++++++++++++++++++++ 5 files changed, 90 insertions(+), 4 deletions(-) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index ce9634c..9558072 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -287,11 +287,34 @@ class Config(BaseSettings): from nanobot.providers.registry import PROVIDERS model_lower = (model or self.agents.defaults.model).lower() + model_normalized = model_lower.replace("-", "_") + model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" + normalized_prefix = model_prefix.replace("-", "_") + + def _matches_model_prefix(spec_name: str) -> bool: + if not model_prefix: + return False + return normalized_prefix == spec_name + + def _keyword_matches(keyword: str) -> bool: + keyword_lower = keyword.lower() + return ( + keyword_lower in model_lower + or keyword_lower.replace("-", "_") in model_normalized + ) + + # Explicit provider prefix in model name wins over generic keyword matches. + # This prevents `github-copilot/...codex` from being treated as OpenAI Codex. + for spec in PROVIDERS: + p = getattr(self.providers, spec.name, None) + if p and _matches_model_prefix(spec.name): + if spec.is_oauth or p.api_key: + return p, spec.name # Match by keyword (order follows PROVIDERS registry) for spec in PROVIDERS: p = getattr(self.providers, spec.name, None) - if p and any(kw in model_lower for kw in spec.keywords): + if p and any(_keyword_matches(kw) for kw in spec.keywords): if spec.is_oauth or p.api_key: return p, spec.name diff --git a/nanobot/providers/litellm_provider.py b/nanobot/providers/litellm_provider.py index 8cc4e35..3fec618 100644 --- a/nanobot/providers/litellm_provider.py +++ b/nanobot/providers/litellm_provider.py @@ -88,10 +88,21 @@ class LiteLLMProvider(LLMProvider): # Standard mode: auto-prefix for known providers spec = find_by_model(model) if spec and spec.litellm_prefix: + model = self._canonicalize_explicit_prefix(model, spec.name, spec.litellm_prefix) if not any(model.startswith(s) for s in spec.skip_prefixes): model = f"{spec.litellm_prefix}/{model}" - + return model + + @staticmethod + def _canonicalize_explicit_prefix(model: str, spec_name: str, canonical_prefix: str) -> str: + """Normalize explicit provider prefixes like `github-copilot/...`.""" + if "/" not in model: + return model + prefix, remainder = model.split("/", 1) + if prefix.lower().replace("-", "_") != spec_name: + return model + return f"{canonical_prefix}/{remainder}" def _apply_model_overrides(self, model: str, kwargs: dict[str, Any]) -> None: """Apply model-specific parameter overrides from the registry.""" diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index 5067438..2336e71 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -80,7 +80,7 @@ class OpenAICodexProvider(LLMProvider): def _strip_model_prefix(model: str) -> str: - if model.startswith("openai-codex/"): + if model.startswith("openai-codex/") or model.startswith("openai_codex/"): return model.split("/", 1)[1] return model diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 49b735c..d986fe6 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -384,10 +384,24 @@ def find_by_model(model: str) -> ProviderSpec | None: """Match a standard provider by model-name keyword (case-insensitive). Skips gateways/local — those are matched by api_key/api_base instead.""" model_lower = model.lower() + model_normalized = model_lower.replace("-", "_") + model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" + normalized_prefix = model_prefix.replace("-", "_") + + # Prefer explicit provider prefix in model name. for spec in PROVIDERS: if spec.is_gateway or spec.is_local: continue - if any(kw in model_lower for kw in spec.keywords): + if model_prefix and normalized_prefix == spec.name: + return spec + + for spec in PROVIDERS: + if spec.is_gateway or spec.is_local: + continue + if any( + kw in model_lower or kw.replace("-", "_") in model_normalized + for kw in spec.keywords + ): return spec return None diff --git a/tests/test_commands.py b/tests/test_commands.py index f5495fd..044d113 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -6,6 +6,10 @@ import pytest from typer.testing import CliRunner from nanobot.cli.commands import app +from nanobot.config.schema import Config +from nanobot.providers.litellm_provider import LiteLLMProvider +from nanobot.providers.openai_codex_provider import _strip_model_prefix +from nanobot.providers.registry import find_by_model runner = CliRunner() @@ -90,3 +94,37 @@ def test_onboard_existing_workspace_safe_create(mock_paths): assert "Created workspace" not in result.stdout assert "Created AGENTS.md" in result.stdout assert (workspace_dir / "AGENTS.md").exists() + + +def test_config_matches_github_copilot_codex_with_hyphen_prefix(): + config = Config() + config.agents.defaults.model = "github-copilot/gpt-5.3-codex" + + assert config.get_provider_name() == "github_copilot" + + +def test_config_matches_openai_codex_with_hyphen_prefix(): + config = Config() + config.agents.defaults.model = "openai-codex/gpt-5.1-codex" + + assert config.get_provider_name() == "openai_codex" + + +def test_find_by_model_prefers_explicit_prefix_over_generic_codex_keyword(): + spec = find_by_model("github-copilot/gpt-5.3-codex") + + assert spec is not None + assert spec.name == "github_copilot" + + +def test_litellm_provider_canonicalizes_github_copilot_hyphen_prefix(): + provider = LiteLLMProvider(default_model="github-copilot/gpt-5.3-codex") + + resolved = provider._resolve_model("github-copilot/gpt-5.3-codex") + + assert resolved == "github_copilot/gpt-5.3-codex" + + +def test_openai_codex_strip_prefix_supports_hyphen_and_underscore(): + assert _strip_model_prefix("openai-codex/gpt-5.1-codex") == "gpt-5.1-codex" + assert _strip_model_prefix("openai_codex/gpt-5.1-codex") == "gpt-5.1-codex" From b11f0ce6a9bc8159ed1a7a6937c7c93c80a54733 Mon Sep 17 00:00:00 2001 From: Re-bin Date: Thu, 19 Feb 2026 17:39:44 +0000 Subject: [PATCH 2/2] fix: prefer explicit provider prefix over keyword match to fix Codex routing --- nanobot/config/schema.py | 21 ++++++--------------- nanobot/providers/registry.py | 16 +++++----------- 2 files changed, 11 insertions(+), 26 deletions(-) diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 9558072..6a1257e 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -291,30 +291,21 @@ class Config(BaseSettings): model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" normalized_prefix = model_prefix.replace("-", "_") - def _matches_model_prefix(spec_name: str) -> bool: - if not model_prefix: - return False - return normalized_prefix == spec_name + def _kw_matches(kw: str) -> bool: + kw = kw.lower() + return kw in model_lower or kw.replace("-", "_") in model_normalized - def _keyword_matches(keyword: str) -> bool: - keyword_lower = keyword.lower() - return ( - keyword_lower in model_lower - or keyword_lower.replace("-", "_") in model_normalized - ) - - # Explicit provider prefix in model name wins over generic keyword matches. - # This prevents `github-copilot/...codex` from being treated as OpenAI Codex. + # Explicit provider prefix wins — prevents `github-copilot/...codex` matching openai_codex. for spec in PROVIDERS: p = getattr(self.providers, spec.name, None) - if p and _matches_model_prefix(spec.name): + if p and model_prefix and normalized_prefix == spec.name: if spec.is_oauth or p.api_key: return p, spec.name # Match by keyword (order follows PROVIDERS registry) for spec in PROVIDERS: p = getattr(self.providers, spec.name, None) - if p and any(_keyword_matches(kw) for kw in spec.keywords): + if p and any(_kw_matches(kw) for kw in spec.keywords): if spec.is_oauth or p.api_key: return p, spec.name diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index d986fe6..3071793 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -387,21 +387,15 @@ def find_by_model(model: str) -> ProviderSpec | None: model_normalized = model_lower.replace("-", "_") model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else "" normalized_prefix = model_prefix.replace("-", "_") + std_specs = [s for s in PROVIDERS if not s.is_gateway and not s.is_local] - # Prefer explicit provider prefix in model name. - for spec in PROVIDERS: - if spec.is_gateway or spec.is_local: - continue + # Prefer explicit provider prefix — prevents `github-copilot/...codex` matching openai_codex. + for spec in std_specs: if model_prefix and normalized_prefix == spec.name: return spec - for spec in PROVIDERS: - if spec.is_gateway or spec.is_local: - continue - if any( - kw in model_lower or kw.replace("-", "_") in model_normalized - for kw in spec.keywords - ): + for spec in std_specs: + if any(kw in model_lower or kw.replace("-", "_") in model_normalized for kw in spec.keywords): return spec return None