diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 5a8d8365c..fc5c460d6 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -656,19 +656,21 @@ def refresh_hermes_oauth_token() -> Optional[str]: # --------------------------------------------------------------------------- -def normalize_model_name(model: str) -> str: +def normalize_model_name(model: str, preserve_dots: bool = False) -> str: """Normalize a model name for the Anthropic API. - Strips 'anthropic/' prefix (OpenRouter format, case-insensitive) - Converts dots to hyphens in version numbers (OpenRouter uses dots, - Anthropic uses hyphens: claude-opus-4.6 → claude-opus-4-6) + Anthropic uses hyphens: claude-opus-4.6 → claude-opus-4-6), unless + preserve_dots is True (e.g. for Alibaba/DashScope: qwen3.5-plus). """ lower = model.lower() if lower.startswith("anthropic/"): model = model[len("anthropic/"):] - # OpenRouter uses dots for version separators (claude-opus-4.6), - # Anthropic uses hyphens (claude-opus-4-6). Convert dots to hyphens. - model = model.replace(".", "-") + if not preserve_dots: + # OpenRouter uses dots for version separators (claude-opus-4.6), + # Anthropic uses hyphens (claude-opus-4-6). Convert dots to hyphens. + model = model.replace(".", "-") return model @@ -1006,16 +1008,20 @@ def build_anthropic_kwargs( reasoning_config: Optional[Dict[str, Any]], tool_choice: Optional[str] = None, is_oauth: bool = False, + preserve_dots: bool = False, ) -> Dict[str, Any]: """Build kwargs for anthropic.messages.create(). When *is_oauth* is True, applies Claude Code compatibility transforms: system prompt prefix, tool name prefixing, and prompt sanitization. + + When *preserve_dots* is True, model name dots are not converted to hyphens + (for Alibaba/DashScope anthropic-compatible endpoints: qwen3.5-plus). """ system, anthropic_messages = convert_messages_to_anthropic(messages) anthropic_tools = convert_tools_to_anthropic(tools) if tools else [] - model = normalize_model_name(model) + model = normalize_model_name(model, preserve_dots=preserve_dots) effective_max_tokens = max_tokens or 16384 # ── OAuth: Claude Code identity ────────────────────────────────── diff --git a/run_agent.py b/run_agent.py index ae555e419..c55a79c49 100644 --- a/run_agent.py +++ b/run_agent.py @@ -681,7 +681,10 @@ class AIAgent: if self.api_mode == "anthropic_messages": from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token - effective_key = api_key or resolve_anthropic_token() or "" + # Alibaba/DashScope use their own API key; do not fall back to ANTHROPIC_TOKEN (Fixes #1739 401). + _base = (base_url or "").lower() + _is_alibaba_dashscope = (self.provider == "alibaba") or ("dashscope" in _base) or ("aliyuncs" in _base) + effective_key = (api_key or "") if _is_alibaba_dashscope else (api_key or resolve_anthropic_token() or "") self.api_key = effective_key self._anthropic_api_key = effective_key self._anthropic_base_url = base_url @@ -2333,7 +2336,7 @@ class AIAgent: # Alibaba Coding Plan API always returns "glm-4.7" as model name regardless # of the requested model. Inject explicit model identity into the system prompt # so the agent can correctly report which model it is (workaround for API bug). - if self.provider in ("alibaba-coding-plan", "alibaba-coding-plan-anthropic"): + if self.provider == "alibaba": _model_short = self.model.split("/")[-1] if "/" in self.model else self.model prompt_parts.append( f"You are powered by the model named {_model_short}. " @@ -3337,6 +3340,10 @@ class AIAgent: def _try_refresh_anthropic_client_credentials(self) -> bool: if self.api_mode != "anthropic_messages" or not hasattr(self, "_anthropic_api_key"): return False + # Alibaba/DashScope use their own API key; do not refresh from ANTHROPIC_TOKEN (Fixes #1739 401). + _base = (getattr(self, "_anthropic_base_url", None) or "").lower() + if (self.provider == "alibaba") or ("dashscope" in _base) or ("aliyuncs" in _base): + return False try: from agent.anthropic_adapter import resolve_anthropic_token, build_anthropic_client @@ -3940,6 +3947,13 @@ class AIAgent: ) return transformed + def _anthropic_preserve_dots(self) -> bool: + """True when using Alibaba/DashScope anthropic-compatible endpoint (model names keep dots, e.g. qwen3.5-plus).""" + if (getattr(self, "provider", "") or "").lower() == "alibaba": + return True + base = (getattr(self, "base_url", "") or "").lower() + return "dashscope" in base or "aliyuncs" in base + def _build_api_kwargs(self, api_messages: list) -> dict: """Build the keyword arguments dict for the active API mode.""" if self.api_mode == "anthropic_messages": @@ -3952,6 +3966,7 @@ class AIAgent: max_tokens=self.max_tokens, reasoning_config=self.reasoning_config, is_oauth=getattr(self, "_is_anthropic_oauth", False), + preserve_dots=self._anthropic_preserve_dots(), ) if self.api_mode == "codex_responses": @@ -4413,6 +4428,7 @@ class AIAgent: model=self.model, messages=api_messages, tools=[memory_tool_def], max_tokens=5120, reasoning_config=None, + preserve_dots=self._anthropic_preserve_dots(), ) response = self._anthropic_messages_create(ant_kwargs) elif not _aux_available: @@ -5221,7 +5237,8 @@ class AIAgent: from agent.anthropic_adapter import build_anthropic_kwargs as _bak, normalize_anthropic_response as _nar _ant_kw = _bak(model=self.model, messages=api_messages, tools=None, max_tokens=self.max_tokens, reasoning_config=self.reasoning_config, - is_oauth=getattr(self, '_is_anthropic_oauth', False)) + is_oauth=getattr(self, '_is_anthropic_oauth', False), + preserve_dots=self._anthropic_preserve_dots()) summary_response = self._anthropic_messages_create(_ant_kw) _msg, _ = _nar(summary_response, strip_tool_prefix=getattr(self, '_is_anthropic_oauth', False)) final_response = (_msg.content or "").strip() @@ -5252,7 +5269,8 @@ class AIAgent: from agent.anthropic_adapter import build_anthropic_kwargs as _bak2, normalize_anthropic_response as _nar2 _ant_kw2 = _bak2(model=self.model, messages=api_messages, tools=None, is_oauth=getattr(self, '_is_anthropic_oauth', False), - max_tokens=self.max_tokens, reasoning_config=self.reasoning_config) + max_tokens=self.max_tokens, reasoning_config=self.reasoning_config, + preserve_dots=self._anthropic_preserve_dots()) retry_response = self._anthropic_messages_create(_ant_kw2) _retry_msg, _ = _nar2(retry_response, strip_tool_prefix=getattr(self, '_is_anthropic_oauth', False)) final_response = (_retry_msg.content or "").strip() diff --git a/tests/test_anthropic_adapter.py b/tests/test_anthropic_adapter.py index c908be69e..abbad79f3 100644 --- a/tests/test_anthropic_adapter.py +++ b/tests/test_anthropic_adapter.py @@ -450,6 +450,12 @@ class TestNormalizeModelName: assert normalize_model_name("claude-opus-4-6") == "claude-opus-4-6" assert normalize_model_name("claude-opus-4-5-20251101") == "claude-opus-4-5-20251101" + def test_preserve_dots_for_alibaba_dashscope(self): + """Alibaba/DashScope use dots in model names (e.g. qwen3.5-plus). Fixes #1739.""" + assert normalize_model_name("qwen3.5-plus", preserve_dots=True) == "qwen3.5-plus" + assert normalize_model_name("anthropic/qwen3.5-plus", preserve_dots=True) == "qwen3.5-plus" + assert normalize_model_name("qwen3.5-flash", preserve_dots=True) == "qwen3.5-flash" + # --------------------------------------------------------------------------- # Tool conversion