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.
This commit is contained in:
chengyongru 2026-06-11 21:49:36 +08:00 committed by Xubin Ren
parent 09d24e6c25
commit b2d00a4ce0
3 changed files with 126 additions and 4 deletions

View File

@ -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)

View File

@ -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,
)

View File

@ -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(
{