Skip to content

Commit 420af0e

Browse files
authored
feat(auth): add Alibaba DashScope provider (#78)
* feat(auth): add Alibaba DashScope provider * feat(auth): update MiniMax M3 catalog * test(auth): decouple Alibaba login discovery tests * style(auth): format Alibaba auth tests * fix(test): use robust header assertion in Alibaba discovery test * fix(test): satisfy pyright on header assertion type
1 parent e02ffc6 commit 420af0e

10 files changed

Lines changed: 848 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ GitHub Releases page; `0.8.0` is the new starting line.
1515

1616
## Unreleased
1717

18+
- **Alibaba DashScope provider and MiniMax M3 catalog.** `/login alibaba` now configures Alibaba Cloud Model Studio / DashScope Token Plan models, including workspace-compatible endpoints and native GLM thinking behavior. MiniMax API-key login now defaults to MiniMax M3 with its larger context and multimodal capabilities.
1819
- **Security review vulnerability intelligence.** `pythinker security-scan` can now parse dependency manifests, query OSV package advisories, look up CVE intelligence from NVD/EPSS/CISA KEV/GitHub/vendor feeds, and carry those leads into security-review prompts and reports as evidence-checked context.
1920

2021
## 0.34.0 (2026-06-03)

src/pythinker_code/auth/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
ALIBABA_PLATFORM_ID = "alibaba"
34
PYTHINKER_CODE_PLATFORM_ID = "pythinker-code"
45
OPENAI_API_PLATFORM_ID = "openai"
56
OPENAI_CHATGPT_PLATFORM_ID = "openai-chatgpt"
@@ -14,6 +15,7 @@
1415
ZAI_PLATFORM_ID = "z-ai"
1516

1617
__all__ = [
18+
"ALIBABA_PLATFORM_ID",
1719
"ANTHROPIC_PLATFORM_ID",
1820
"DEEPSEEK_PLATFORM_ID",
1921
"LM_STUDIO_PLATFORM_ID",

src/pythinker_code/auth/alibaba.py

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from collections.abc import AsyncIterator
5+
from dataclasses import dataclass
6+
from typing import Any, cast
7+
8+
import aiohttp
9+
from pydantic import SecretStr
10+
11+
from pythinker_code.auth import ALIBABA_PLATFORM_ID
12+
from pythinker_code.auth.oauth import OAuthEvent
13+
from pythinker_code.auth.platforms import managed_provider_key
14+
from pythinker_code.config import Config, LLMModel, LLMProvider, save_config
15+
from pythinker_code.llm import ModelCapability
16+
from pythinker_code.thinking import apply_login_thinking_defaults
17+
from pythinker_code.utils.aiohttp import new_client_session
18+
19+
ALIBABA_BASE_URL = "https://dashscope-us.aliyuncs.com/compatible-mode/v1"
20+
ALIBABA_PROVIDER_KEY = managed_provider_key(ALIBABA_PLATFORM_ID)
21+
ALIBABA_DEFAULT_MODEL_ALIAS = f"{ALIBABA_PLATFORM_ID}/qwen3.6-plus"
22+
ALIBABA_MODEL_DISCOVERY_TIMEOUT = aiohttp.ClientTimeout(total=15, sock_connect=8, sock_read=10)
23+
24+
25+
@dataclass(frozen=True, slots=True)
26+
class AlibabaModel:
27+
model_id: str
28+
alias_suffix: str
29+
display_name: str
30+
provider_key: str = ALIBABA_PROVIDER_KEY
31+
max_context_size: int = 131_072
32+
capabilities: frozenset[ModelCapability] | None = None
33+
34+
@property
35+
def alias(self) -> str:
36+
return f"{ALIBABA_PLATFORM_ID}/{self.alias_suffix}"
37+
38+
39+
ALIBABA_MODELS: tuple[AlibabaModel, ...] = (
40+
AlibabaModel(
41+
model_id="qwen3.7-max",
42+
alias_suffix="qwen3.7-max",
43+
display_name="Qwen3.7 Max",
44+
max_context_size=262_144,
45+
capabilities=frozenset[ModelCapability]({"thinking"}),
46+
),
47+
AlibabaModel(
48+
model_id="qwen3.6-plus",
49+
alias_suffix="qwen3.6-plus",
50+
display_name="Qwen3.6 Plus",
51+
max_context_size=1_000_000,
52+
capabilities=frozenset[ModelCapability]({"thinking", "image_in"}),
53+
),
54+
AlibabaModel(
55+
model_id="qwen3.6-flash",
56+
alias_suffix="qwen3.6-flash",
57+
display_name="Qwen3.6 Flash",
58+
max_context_size=1_000_000,
59+
capabilities=frozenset[ModelCapability]({"thinking", "image_in"}),
60+
),
61+
AlibabaModel(
62+
model_id="deepseek-v4-pro",
63+
alias_suffix="deepseek-v4-pro",
64+
display_name="DeepSeek V4 Pro",
65+
max_context_size=128_000,
66+
capabilities=frozenset[ModelCapability]({"thinking"}),
67+
),
68+
AlibabaModel(
69+
model_id="deepseek-v4-flash",
70+
alias_suffix="deepseek-v4-flash",
71+
display_name="DeepSeek V4 Flash",
72+
max_context_size=128_000,
73+
capabilities=frozenset[ModelCapability]({"thinking"}),
74+
),
75+
AlibabaModel(
76+
model_id="deepseek-v3.2",
77+
alias_suffix="deepseek-v3.2",
78+
display_name="DeepSeek V3.2",
79+
max_context_size=128_000,
80+
capabilities=frozenset[ModelCapability]({"thinking"}),
81+
),
82+
AlibabaModel(
83+
model_id="kimi-k2.6",
84+
alias_suffix="kimi-k2.6",
85+
display_name="Kimi K2.6",
86+
max_context_size=262_144,
87+
capabilities=frozenset[ModelCapability]({"thinking", "image_in"}),
88+
),
89+
AlibabaModel(
90+
model_id="kimi-k2.5",
91+
alias_suffix="kimi-k2.5",
92+
display_name="Kimi K2.5",
93+
max_context_size=262_144,
94+
capabilities=frozenset[ModelCapability]({"thinking", "image_in"}),
95+
),
96+
AlibabaModel(
97+
model_id="glm-5.1",
98+
alias_suffix="glm-5.1",
99+
display_name="GLM-5.1",
100+
max_context_size=262_144,
101+
capabilities=frozenset[ModelCapability]({"always_thinking"}),
102+
),
103+
AlibabaModel(
104+
model_id="glm-5",
105+
alias_suffix="glm-5",
106+
display_name="GLM-5",
107+
max_context_size=262_144,
108+
capabilities=frozenset[ModelCapability]({"always_thinking"}),
109+
),
110+
AlibabaModel(
111+
model_id="MiniMax-M2.5",
112+
alias_suffix="minimax-m2.5",
113+
display_name="MiniMax M2.5",
114+
max_context_size=204_800,
115+
capabilities=frozenset[ModelCapability]({"thinking"}),
116+
),
117+
)
118+
119+
120+
def get_alibaba_api_key_from_env() -> str | None:
121+
for env_var in ("DASHSCOPE_API_KEY", "ALIBABA_API_KEY"):
122+
value = os.getenv(env_var)
123+
if value and value.strip():
124+
return value.strip()
125+
return None
126+
127+
128+
def _normalize_alibaba_base_url(value: str) -> str:
129+
base_url = value.strip().rstrip("/")
130+
if not base_url:
131+
return ALIBABA_BASE_URL
132+
if "://" not in base_url:
133+
base_url = f"https://{base_url}"
134+
if base_url.endswith("/api/v1"):
135+
return f"{base_url.removesuffix('/api/v1')}/compatible-mode/v1"
136+
if base_url.endswith("/compatible-mode/v1"):
137+
return base_url
138+
return f"{base_url}/compatible-mode/v1"
139+
140+
141+
def get_alibaba_base_url_from_env() -> str:
142+
for env_var in ("DASHSCOPE_BASE_URL", "ALIBABA_BASE_URL"):
143+
value = os.getenv(env_var)
144+
if value and value.strip():
145+
return _normalize_alibaba_base_url(value)
146+
return ALIBABA_BASE_URL
147+
148+
149+
def _apply_alibaba_config(
150+
config: Config,
151+
api_key: SecretStr,
152+
models: tuple[AlibabaModel, ...] = ALIBABA_MODELS,
153+
) -> None:
154+
config.providers[ALIBABA_PROVIDER_KEY] = LLMProvider(
155+
type="openai_legacy",
156+
base_url=get_alibaba_base_url_from_env(),
157+
api_key=api_key,
158+
)
159+
160+
provider_keys = {ALIBABA_PROVIDER_KEY}
161+
for key, model in list(config.models.items()):
162+
if model.provider in provider_keys:
163+
del config.models[key]
164+
165+
for model in models:
166+
config.models[model.alias] = LLMModel(
167+
provider=model.provider_key,
168+
model=model.model_id,
169+
max_context_size=model.max_context_size,
170+
capabilities=set(model.capabilities) if model.capabilities else None,
171+
display_name=model.display_name,
172+
)
173+
174+
fallback = next(
175+
(m.alias for m in models),
176+
next(iter(config.models), ""),
177+
)
178+
if ALIBABA_DEFAULT_MODEL_ALIAS in config.models:
179+
config.default_model = ALIBABA_DEFAULT_MODEL_ALIAS
180+
else:
181+
config.default_model = fallback
182+
apply_login_thinking_defaults(config, thinking=True, effort="high")
183+
184+
185+
def _model_by_id() -> dict[str, AlibabaModel]:
186+
return {model.model_id: model for model in ALIBABA_MODELS}
187+
188+
189+
def _parse_discovered_models(data: object) -> tuple[AlibabaModel, ...]:
190+
if not isinstance(data, dict):
191+
return ()
192+
d = cast(dict[str, Any], data)
193+
items = d.get("data")
194+
if not isinstance(items, list):
195+
return ()
196+
catalog = _model_by_id()
197+
results: list[AlibabaModel] = []
198+
for raw_item in cast(list[object], items):
199+
if not isinstance(raw_item, dict):
200+
continue
201+
item = cast(dict[str, Any], raw_item)
202+
model_id = item.get("id")
203+
if not isinstance(model_id, str) or model_id not in catalog:
204+
continue
205+
base = catalog[model_id]
206+
ctx = item.get("context_length")
207+
max_ctx = base.max_context_size
208+
if isinstance(ctx, int) and ctx > 0:
209+
max_ctx = ctx
210+
display_name_raw = item.get("display_name")
211+
display_name = (
212+
display_name_raw
213+
if isinstance(display_name_raw, str) and display_name_raw
214+
else base.display_name
215+
)
216+
results.append(
217+
AlibabaModel(
218+
model_id=base.model_id,
219+
alias_suffix=base.alias_suffix,
220+
display_name=display_name,
221+
provider_key=base.provider_key,
222+
max_context_size=max_ctx,
223+
capabilities=base.capabilities,
224+
)
225+
)
226+
return tuple(results)
227+
228+
229+
async def _discover_alibaba_models(api_key: str) -> tuple[AlibabaModel, ...]:
230+
async with (
231+
new_client_session(timeout=ALIBABA_MODEL_DISCOVERY_TIMEOUT) as session,
232+
session.get(
233+
f"{get_alibaba_base_url_from_env()}/models",
234+
headers={"Authorization": f"Bearer {api_key}"},
235+
raise_for_status=True,
236+
) as response,
237+
):
238+
payload = await response.json(content_type=None)
239+
return _parse_discovered_models(payload)
240+
241+
242+
async def login_alibaba_api_key(
243+
config: Config, api_key: str | None = None
244+
) -> AsyncIterator[OAuthEvent]:
245+
if not config.is_from_default_location:
246+
yield OAuthEvent(
247+
"error",
248+
"Login requires the default config file; restart without --config/--config-file.",
249+
)
250+
return
251+
252+
resolved_key = (api_key or get_alibaba_api_key_from_env() or "").strip()
253+
if not resolved_key:
254+
yield OAuthEvent("error", "Alibaba API key is required.")
255+
return
256+
257+
models = ALIBABA_MODELS
258+
try:
259+
discovered = await _discover_alibaba_models(resolved_key)
260+
if discovered:
261+
models = discovered
262+
except aiohttp.ClientResponseError as exc:
263+
if exc.status in {401, 403}:
264+
yield OAuthEvent("error", "Invalid Alibaba API key; the key was not saved.")
265+
return
266+
yield OAuthEvent(
267+
"info",
268+
"Alibaba model listing is unavailable; using the built-in model list.",
269+
)
270+
except (aiohttp.ClientError, TimeoutError, ValueError):
271+
yield OAuthEvent(
272+
"info",
273+
"Alibaba model listing is unavailable; using the built-in model list.",
274+
)
275+
276+
_apply_alibaba_config(config, SecretStr(resolved_key), models=models)
277+
save_config(config)
278+
yield OAuthEvent("success", f"Alibaba configured with model {config.default_model}.")
279+
280+
281+
async def logout_alibaba(config: Config) -> AsyncIterator[OAuthEvent]:
282+
if not config.is_from_default_location:
283+
yield OAuthEvent(
284+
"error",
285+
"Logout requires the default config file; restart without --config/--config-file.",
286+
)
287+
return
288+
289+
provider_keys = {ALIBABA_PROVIDER_KEY}
290+
config.providers.pop(ALIBABA_PROVIDER_KEY, None)
291+
for key, model in list(config.models.items()):
292+
if model.provider in provider_keys:
293+
del config.models[key]
294+
295+
if config.default_model not in config.models:
296+
config.default_model = next(iter(config.models), "")
297+
save_config(config)
298+
yield OAuthEvent("success", "Logged out of Alibaba successfully.")

src/pythinker_code/auth/minimax.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,16 @@
2020
MINIMAX_ANTHROPIC_MODELS_URL = f"{MINIMAX_ANTHROPIC_BASE_URL}/v1/models"
2121
MINIMAX_OPENAI_MODELS_URL = f"{MINIMAX_OPENAI_BASE_URL}/models"
2222
MINIMAX_ANTHROPIC_PROVIDER_KEY = "managed:minimax-anthropic"
23-
MINIMAX_DEFAULT_MODEL_ALIAS = "minimax/m2.7"
24-
MINIMAX_DEFAULT_CONTEXT = 192_000
23+
MINIMAX_DEFAULT_MODEL_ALIAS = "minimax/m3"
24+
# Fallback context size for models without explicit specification (non-M3 models)
25+
MINIMAX_DEFAULT_CONTEXT = 204_800
2526
MINIMAX_TOKEN_PLAN_KEY_PREFIX = "sk-cp-"
2627
MINIMAX_MODEL_DISCOVERY_TIMEOUT = aiohttp.ClientTimeout(total=15, sock_connect=8, sock_read=10)
2728
MINIMAX_NATIVE_THINKING_CAPABILITIES: frozenset[ModelCapability] = frozenset({"always_thinking"})
29+
MINIMAX_M3_CAPABILITIES: frozenset[ModelCapability] = frozenset(
30+
{"always_thinking", "image_in", "video_in"}
31+
)
32+
MINIMAX_M3_CONTEXT_SIZE = 1_000_000
2833

2934

3035
@dataclass(frozen=True, slots=True)
@@ -42,6 +47,13 @@ def alias(self) -> str:
4247

4348

4449
MINIMAX_MODELS: tuple[MiniMaxModel, ...] = (
50+
MiniMaxModel(
51+
"MiniMax-M3",
52+
"m3",
53+
"MiniMax M3",
54+
max_context_size=MINIMAX_M3_CONTEXT_SIZE,
55+
capabilities=MINIMAX_M3_CAPABILITIES,
56+
),
4557
MiniMaxModel("MiniMax-M2.7", "m2.7", "MiniMax M2.7"),
4658
MiniMaxModel("MiniMax-M2.7-highspeed", "m2.7-highspeed", "MiniMax M2.7 High-Speed"),
4759
MiniMaxModel("MiniMax-M2.5", "m2.5", "MiniMax M2.5"),

0 commit comments

Comments
 (0)