Repository: HKUDS/nanobot (python)

Issue:
GitHub Copilot login seems not work

I installed a nanobot with uv in my VM.

Today I updated the nanobot to the latest version. And I login to github copilot succeed.

```bash
 nanobot provider login github-copilot
🐈 OAuth Login - Github Copilot

Starting GitHub Copilot device flow...

✓ Authenticated with GitHub Copilot
```

But after I config the  provider model to `github_copilot/gpt-5.3-codex` and restart the nanobot, I got the error:

<img width="469" height="54" alt="Image" src="https://github.com/user-attachments/assets/48a1d729-da05-402b-82b1-4d6883a41e8f" />

<img width="493" height="222" alt="Image" src="https://github.com/user-attachments/assets/7a5acba8-7065-4266-8b84-4f2149848578" />


Patch:
diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py
index ce9634c3ba..9558072861 100644
--- a/nanobot/config/schema.py
+++ b/nanobot/config/schema.py
@@ -287,11 +287,34 @@ def _match_provider(self, model: str | None = None) -> tuple["ProviderConfig | N
         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 8cc4e3599d..3fec618b00 100644
--- a/nanobot/providers/litellm_provider.py
+++ b/nanobot/providers/litellm_provider.py
@@ -88,10 +88,21 @@ def _resolve_model(self, model: str) -> str:
         # 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 5067438abd..2336e71ed9 100644
--- a/nanobot/providers/openai_codex_provider.py
+++ b/nanobot/providers/openai_codex_provider.py
@@ -80,7 +80,7 @@ def get_default_model(self) -> str:
 
 
 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 49b735c58a..d986fe62a7 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 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 for kw in spec.keywords):
+        if any(
+            kw in model_lower or kw.replace("-", "_") in model_normalized
+            for kw in spec.keywords
+        ):
             return spec
     return None
 


Test patch:
diff --git a/tests/test_commands.py b/tests/test_commands.py
index f5495fdec4..044d113166 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -6,6 +6,10 @@
 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"