From b2d00a4ce0f155273e483680c0b28bf25bdee926 Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Thu, 11 Jun 2026 21:49:36 +0800 Subject: [PATCH] fix: strip dynamic custom provider route prefixes maintainer edit: preserve provider-prefix CLI routing for named custom providers by stripping only the matched dynamic route prefix before sending the model id to OpenAI-compatible endpoints. This keeps ordinary namespaced model ids intact when the provider is selected explicitly. --- nanobot/providers/openai_compat_provider.py | 27 +++++- nanobot/providers/registry.py | 3 + tests/cli/test_commands.py | 100 ++++++++++++++++++++ 3 files changed, 126 insertions(+), 4 deletions(-) diff --git a/nanobot/providers/openai_compat_provider.py b/nanobot/providers/openai_compat_provider.py index 3a2ba2fbe..5aa91e078 100644 --- a/nanobot/providers/openai_compat_provider.py +++ b/nanobot/providers/openai_compat_provider.py @@ -18,6 +18,7 @@ from typing import TYPE_CHECKING, Any from urllib.parse import urlparse from loguru import logger +from pydantic.alias_generators import to_snake from nanobot.providers.base import ( LLMProvider, @@ -93,6 +94,10 @@ def _model_slug(model_name: str) -> str: return model_name.lower().rsplit("/", 1)[-1] +def _provider_prefix_key(name: str) -> str: + return to_snake(name.replace("-", "_")).lower() + + def _requires_max_completion_tokens(model_name: str) -> bool: """Return True for models that reject ``max_tokens`` (GPT-5 family, o-series).""" slug = _model_slug(model_name) @@ -592,6 +597,22 @@ class OpenAICompatProvider(LLMProvider): # Build kwargs # ------------------------------------------------------------------ + def _request_model_name(self, model_name: str) -> str: + spec = self._spec + if not spec or "/" not in model_name: + return model_name + if spec.strip_model_prefix: + return model_name.split("/")[-1] + + route_prefixes = getattr(spec, "strip_model_prefixes", ()) + if not isinstance(route_prefixes, tuple) or not route_prefixes: + return model_name + model_prefix, routed_model = model_name.split("/", 1) + model_prefix_key = _provider_prefix_key(model_prefix) + if any(_provider_prefix_key(prefix) == model_prefix_key for prefix in route_prefixes): + return routed_model + return model_name + @staticmethod def _supports_temperature( model_name: str, @@ -625,8 +646,7 @@ class OpenAICompatProvider(LLMProvider): if any(model_name.lower().startswith(k) for k in ("anthropic/", "claude")): messages, tools = self._apply_cache_control(messages, tools) - if spec and spec.strip_model_prefix: - model_name = model_name.split("/")[-1] + model_name = self._request_model_name(model_name) kwargs: dict[str, Any] = { "model": model_name, @@ -830,8 +850,7 @@ class OpenAICompatProvider(LLMProvider): ) -> dict[str, Any]: """Build a Responses API body for direct OpenAI requests.""" model_name = model or self.default_model - if self._spec and self._spec.strip_model_prefix: - model_name = model_name.split("/")[-1] + model_name = self._request_model_name(model_name) sanitized_messages = self._sanitize_messages(self._sanitize_empty_content(messages)) instructions, input_items = convert_messages(sanitized_messages) diff --git a/nanobot/providers/registry.py b/nanobot/providers/registry.py index 940a675f0..c3d792d94 100644 --- a/nanobot/providers/registry.py +++ b/nanobot/providers/registry.py @@ -49,6 +49,7 @@ class ProviderSpec: # gateway behavior strip_model_prefix: bool = False # strip "provider/" before sending to gateway + strip_model_prefixes: tuple[str, ...] = () # strip only when the first model segment matches supports_max_completion_tokens: bool = False # per-model param overrides, e.g. (("kimi-k2.5", {"temperature": 1.0}),) @@ -550,6 +551,7 @@ def find_by_name(name: str) -> ProviderSpec | None: def create_dynamic_spec(name: str) -> ProviderSpec: """Create a dynamic ProviderSpec for custom user-defined providers.""" normalized = to_snake(name.replace("-", "_")) + strip_prefixes = tuple(dict.fromkeys((name, normalized))) return ProviderSpec( name=normalized, keywords=(), @@ -557,4 +559,5 @@ def create_dynamic_spec(name: str) -> ProviderSpec: display_name=name.title(), backend="openai_compat", is_direct=True, + strip_model_prefixes=strip_prefixes, ) diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index a108ea268..114b90df3 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -688,6 +688,106 @@ def test_make_provider_treats_dynamic_custom_provider_as_direct(): assert kwargs["base_url"] == "https://example.com/v1" +def test_make_provider_strips_dynamic_custom_route_prefix_from_request_model(): + config = Config.model_validate( + { + "agents": {"defaults": {"provider": "auto", "model": "my-company-api/gpt-4o-mini"}}, + "providers": { + "my-company-api": { + "apiBase": "https://example.com/v1", + } + }, + } + ) + + provider = make_provider(config) + + kwargs = provider._build_kwargs( + messages=[{"role": "user", "content": "hi"}], + tools=None, + model=None, + max_tokens=16, + temperature=0.1, + reasoning_effort=None, + tool_choice=None, + ) + body = provider._build_responses_body( + messages=[{"role": "user", "content": "hi"}], + tools=None, + model=None, + max_tokens=16, + temperature=0.1, + reasoning_effort=None, + tool_choice=None, + ) + + assert config.get_provider_name() == "my-company-api" + assert kwargs["model"] == "gpt-4o-mini" + assert body["model"] == "gpt-4o-mini" + + +def test_make_provider_preserves_namespaced_model_for_forced_dynamic_provider(): + config = Config.model_validate( + { + "agents": { + "defaults": { + "provider": "my-company-api", + "model": "openai/gpt-4o-mini", + } + }, + "providers": { + "my-company-api": { + "apiBase": "https://example.com/v1", + } + }, + } + ) + + provider = make_provider(config) + kwargs = provider._build_kwargs( + messages=[{"role": "user", "content": "hi"}], + tools=None, + model=None, + max_tokens=16, + temperature=0.1, + reasoning_effort=None, + tool_choice=None, + ) + + assert kwargs["model"] == "openai/gpt-4o-mini" + + +def test_make_provider_strips_dynamic_custom_route_prefix_once(): + config = Config.model_validate( + { + "agents": { + "defaults": { + "provider": "auto", + "model": "my-company-api/openai/gpt-4o-mini", + } + }, + "providers": { + "my-company-api": { + "apiBase": "https://example.com/v1", + } + }, + } + ) + + provider = make_provider(config) + kwargs = provider._build_kwargs( + messages=[{"role": "user", "content": "hi"}], + tools=None, + model=None, + max_tokens=16, + temperature=0.1, + reasoning_effort=None, + tool_choice=None, + ) + + assert kwargs["model"] == "openai/gpt-4o-mini" + + def test_make_provider_rejects_dynamic_custom_provider_without_api_base(): config = Config.model_validate( {