Browse Source

feat: Qianwen (qwen/dashscope) provider integration

Adds full Qianwen support to lambdagent + agentpaas:

lambdagent/providers/openai_compat_provider.py
- Add "qwen" as a first-class alias for "dashscope" (DashScope endpoint)
  in both _PROVIDER_DEFAULTS and PRESETS
- Accumulate token usage in chat() (was previously discarded)
- New get_usage() → {input_tokens, output_tokens, ..., provider:"qwen"}
  — same interface as ClaudeCodeProvider so agentpaas cost tracking
  works without any changes to _execute_agent
- New reset_session() to clear counters between runs

lambdagent/providers/__init__.py
- Register "qwen" in create_provider() factory
- Update docstring

agentpaas/engine/sandbox.py  (_compute_cost_usd)
- Dispatch on usage["provider"] for per-provider pricing
- Add Qianwen (qwen/dashscope) pricing table:
  ~$5.56/M input, ~$16.67/M output (qwen-max conservative rates)

agentpaas/config.py
- _load_dotenv(): load ~/.agentpaas/.env before config is read so
  DASHSCOPE_API_KEY and other provider keys injected by the Setup
  Wizard are available without requiring a shell reload/reexport

Usage in agent YAML:
  model:
    provider: qwen        # or dashscope
    name: qwen-max        # or qwen-plus / qwen-turbo / qwen-long
    temperature: 0.1
    maxTokens: 8192

Note: requires DASHSCOPE_API_KEY in ~/.agentpaas/.env or env.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
kenny67nju 4 months ago
parent
commit
ee09e2625e

+ 27 - 0
agentpaas/src/agentpaas/config.py

@@ -34,6 +34,33 @@ from typing import Optional
 # precisely so it can carry the data_dir choice itself across restarts.
 _CONFIG_JSON = os.path.join(os.path.expanduser("~"), ".agentpaas", "config.json")
 
+# ── Bootstrap: load ~/.agentpaas/.env into os.environ ────────────────────────
+# This file stores user API keys (DASHSCOPE_API_KEY, OPENAI_API_KEY, …) set
+# via the Setup Wizard or `agentpaas providers set` CLI. We load it here —
+# once, before any config value is read — so all downstream code can simply
+# call os.environ.get("DASHSCOPE_API_KEY", "") without needing python-dotenv.
+# Format: KEY=value, one per line; comments (#) and blank lines are ignored;
+# values may be optionally quoted with ' or ".
+def _load_dotenv(path: str = "") -> None:
+    env_path = path or os.path.join(os.path.expanduser("~"), ".agentpaas", ".env")
+    try:
+        with open(env_path) as _f:
+            for _line in _f:
+                _line = _line.strip()
+                if not _line or _line.startswith("#") or "=" not in _line:
+                    continue
+                _k, _v = _line.split("=", 1)
+                _k = _k.strip()
+                _v = _v.strip().strip('"').strip("'")
+                if _k and _k not in os.environ:  # don't override explicit env
+                    os.environ[_k] = _v
+    except FileNotFoundError:
+        pass  # no .env yet — fine, wizard will create it later
+    except Exception:
+        pass  # never crash startup due to a malformed .env
+
+_load_dotenv()
+
 
 def _data_dir_from_config_json() -> str:
     """Read a persisted data_dir override from ~/.agentpaas/config.json.

+ 27 - 7
agentpaas/src/agentpaas/engine/sandbox.py

@@ -109,14 +109,34 @@ def create_run_workspace(
 
 
 def _compute_cost_usd(usage: dict) -> float:
-    """Estimate USD cost from Claude token usage dict.
-
-    Pricing (claude-sonnet-4-6, effective 2026-06):
-      Input tokens:          $3.00 / 1M
-      Output tokens:        $15.00 / 1M
-      Cache read tokens:     $0.30 / 1M
-      Cache creation tokens: $3.75 / 1M
+    """Estimate USD cost from a token-usage dict.
+
+    Dispatches on usage["provider"] when present so non-Claude providers
+    use the correct pricing table.  Falls back to Claude Sonnet 4.6 pricing.
+
+    ── Claude Sonnet 4.6 (default, 2026-06) ──────────────────────────────
+      Input:          $3.00 / 1M
+      Output:        $15.00 / 1M
+      Cache read:     $0.30 / 1M
+      Cache create:   $3.75 / 1M
+
+    ── Qwen / DashScope (dashscope, qwen) — 2026-06 approximate USD ──────
+      qwen-max   Input: ¥0.04/1K → ~$5.56/M   Output: ¥0.12/1K → ~$16.67/M
+      qwen-plus  Input: ¥0.0008/1K → ~$0.11/M  Output: ¥0.002/1K → ~$0.28/M
+      qwen-turbo Input: ¥0.0003/1K → ~$0.04/M  Output: ¥0.0006/1K → ~$0.08/M
+      (Uses qwen-max rates as conservative default; CNY/USD ≈ 7.2)
     """
+    provider = usage.get("provider", "claude")
+
+    if provider in ("dashscope", "qwen"):
+        # Conservative: use qwen-max rates regardless of actual model tier.
+        # $5.56/M input, $16.67/M output (¥0.04 and ¥0.12 per 1K ÷ 7.2)
+        return (
+            usage.get("input_tokens", 0)  * 5.56  / 1_000_000
+            + usage.get("output_tokens", 0) * 16.67 / 1_000_000
+        )
+
+    # Default: Claude pricing
     return (
         usage.get("input_tokens", 0)                 * 3.00  / 1_000_000
         + usage.get("output_tokens", 0)              * 15.00 / 1_000_000

+ 5 - 4
lambdagent/src/lambdagent/providers/__init__.py

@@ -18,8 +18,9 @@ def create_provider(provider_name: str, **kwargs) -> LLMProvider:
     Factory: create provider by name.
 
     Args:
-        provider_name: One of 'anthropic', 'openai', 'dashscope', 'deepseek',
-                       'moonshot', 'ollama', 'zhipu', 'claude-code'.
+        provider_name: One of 'anthropic', 'openai', 'dashscope', 'qwen' (alias
+                       for dashscope), 'deepseek', 'moonshot', 'ollama', 'zhipu',
+                       'claude-code'.
         **kwargs: Provider-specific arguments (api_key, base_url, model, etc.)
                   Also accepts ProviderConfig fields: temperature, max_tokens, timeout.
 
@@ -46,7 +47,7 @@ def create_provider(provider_name: str, **kwargs) -> LLMProvider:
         )
         return ClaudeCodeProvider(config)
 
-    if provider_name in ("openai", "dashscope", "deepseek", "moonshot", "ollama", "zhipu"):
+    if provider_name in ("openai", "dashscope", "qwen", "deepseek", "moonshot", "ollama", "zhipu"):
         preset = OpenAICompatProvider.PRESETS.get(provider_name, ("", "gpt-4o", ""))
         config = ProviderConfig(
             model=kwargs.pop("model", preset[1]),
@@ -61,7 +62,7 @@ def create_provider(provider_name: str, **kwargs) -> LLMProvider:
 
     raise ValueError(
         f"Unknown provider: {provider_name}. "
-        f"Supported: anthropic, openai, dashscope, deepseek, moonshot, ollama, zhipu, claude-code"
+        f"Supported: anthropic, openai, dashscope, qwen, deepseek, moonshot, ollama, zhipu, claude-code"
     )
 
 

+ 54 - 17
lambdagent/src/lambdagent/providers/openai_compat_provider.py

@@ -1,12 +1,16 @@
 """
 lambdagent.providers.openai_compat_provider — OpenAI-compatible API provider.
 
-Covers: OpenAI, Ollama, DashScope, DeepSeek, Moonshot, Zhipu, and any
+Covers: OpenAI, Ollama, DashScope/Qwen, DeepSeek, Moonshot, Zhipu, and any
 OpenAI-compatible endpoint.
 
 L02: Added chat_typed() for ChatMessage/ChatResponse support,
 overrides provider_name and default_model properties. Added PRESETS
 class attribute for easy provider lookup.
+
+L03: Token-usage accumulation in chat() so get_usage() / reset_session()
+work the same way as ClaudeCodeProvider, enabling cost tracking in agentpaas.
+"qwen" added as a friendly alias for "dashscope".
 """
 from __future__ import annotations
 
@@ -18,14 +22,16 @@ from typing import Dict, List
 from .base import LLMProvider, ProviderConfig, ProviderError, ChatMessage, ChatResponse
 
 
-# Default base URLs and env keys per provider
+# Default base URLs and env keys per provider.
+# "qwen" is a friendly alias for DashScope (Alibaba Cloud).
 _PROVIDER_DEFAULTS = {
-    "openai": ("https://api.openai.com/v1", "OPENAI_API_KEY"),
-    "ollama": ("http://127.0.0.1:11434/v1", ""),
-    "dashscope": ("https://dashscope.aliyuncs.com/compatible-mode/v1", "DASHSCOPE_API_KEY"),
-    "deepseek": ("https://api.deepseek.com/v1", "DEEPSEEK_API_KEY"),
-    "moonshot": ("https://api.moonshot.cn/v1", "MOONSHOT_API_KEY"),
-    "zhipu": ("https://open.bigmodel.cn/api/paas/v4", "ZHIPU_API_KEY"),
+    "openai":     ("https://api.openai.com/v1",                              "OPENAI_API_KEY"),
+    "ollama":     ("http://127.0.0.1:11434/v1",                              ""),
+    "dashscope":  ("https://dashscope.aliyuncs.com/compatible-mode/v1",      "DASHSCOPE_API_KEY"),
+    "qwen":       ("https://dashscope.aliyuncs.com/compatible-mode/v1",      "DASHSCOPE_API_KEY"),
+    "deepseek":   ("https://api.deepseek.com/v1",                            "DEEPSEEK_API_KEY"),
+    "moonshot":   ("https://api.moonshot.cn/v1",                             "MOONSHOT_API_KEY"),
+    "zhipu":      ("https://open.bigmodel.cn/api/paas/v4",                   "ZHIPU_API_KEY"),
 }
 
 
@@ -34,12 +40,13 @@ class OpenAICompatProvider(LLMProvider):
 
     # L02: Provider configs: name -> (base_url, default_model, api_key_env)
     PRESETS = {
-        "openai": ("https://api.openai.com/v1", "gpt-4o", "OPENAI_API_KEY"),
-        "dashscope": ("https://dashscope.aliyuncs.com/compatible-mode/v1", "qwen-max", "DASHSCOPE_API_KEY"),
-        "deepseek": ("https://api.deepseek.com/v1", "deepseek-chat", "DEEPSEEK_API_KEY"),
-        "moonshot": ("https://api.moonshot.cn/v1", "moonshot-v1-128k", "MOONSHOT_API_KEY"),
-        "ollama": ("http://127.0.0.1:11434/v1", "qwen2.5:7b", ""),
-        "zhipu": ("https://open.bigmodel.cn/api/paas/v4", "glm-4", "ZHIPU_API_KEY"),
+        "openai":    ("https://api.openai.com/v1",                         "gpt-4o",            "OPENAI_API_KEY"),
+        "dashscope": ("https://dashscope.aliyuncs.com/compatible-mode/v1", "qwen-max",          "DASHSCOPE_API_KEY"),
+        "qwen":      ("https://dashscope.aliyuncs.com/compatible-mode/v1", "qwen-max",          "DASHSCOPE_API_KEY"),
+        "deepseek":  ("https://api.deepseek.com/v1",                       "deepseek-chat",     "DEEPSEEK_API_KEY"),
+        "moonshot":  ("https://api.moonshot.cn/v1",                        "moonshot-v1-128k",  "MOONSHOT_API_KEY"),
+        "ollama":    ("http://127.0.0.1:11434/v1",                         "qwen2.5:7b",        ""),
+        "zhipu":     ("https://open.bigmodel.cn/api/paas/v4",              "glm-4",             "ZHIPU_API_KEY"),
     }
 
     def __init__(self, config: ProviderConfig, provider_name: str = "openai",
@@ -61,6 +68,11 @@ class OpenAICompatProvider(LLMProvider):
         if model:
             self.config.model = model
 
+        # L03: Token-usage accumulator — mirrors ClaudeCodeProvider.get_usage()
+        # so agentpaas cost tracking works for any provider.
+        self._usage_input  = 0
+        self._usage_output = 0
+
     def chat(self, messages: List[Dict[str, str]]) -> str:
         url = f"{self.base_url.rstrip('/')}/chat/completions"
 
@@ -71,9 +83,7 @@ class OpenAICompatProvider(LLMProvider):
             "max_tokens": self.config.max_tokens,
         }, ensure_ascii=False).encode("utf-8")
 
-        headers = {
-            "Content-Type": "application/json",
-        }
+        headers = {"Content-Type": "application/json"}
         if self.api_key:
             headers["Authorization"] = f"Bearer {self.api_key}"
 
@@ -82,6 +92,10 @@ class OpenAICompatProvider(LLMProvider):
         try:
             with urllib.request.urlopen(req, timeout=self.config.timeout) as resp:
                 data = json.loads(resp.read())
+                # L03: accumulate usage so get_usage() returns session totals
+                usage = data.get("usage", {})
+                self._usage_input  += usage.get("prompt_tokens", 0)
+                self._usage_output += usage.get("completion_tokens", 0)
                 return data["choices"][0]["message"]["content"].strip()
         except urllib.error.HTTPError as e:
             error_body = e.read().decode()[:500]
@@ -93,6 +107,29 @@ class OpenAICompatProvider(LLMProvider):
         except Exception as e:
             raise ProviderError(f"{self._provider_name} error: {e}", self._provider_name)
 
+    # ── L03: Session-level usage API (matches ClaudeCodeProvider interface) ──
+
+    def get_usage(self) -> dict:
+        """Return accumulated token usage for this session.
+
+        Keys match ClaudeCodeProvider.get_usage() so _execute_agent's cost
+        tracking works without any changes. Cache fields are zero (not
+        applicable to OpenAI-compatible endpoints).
+        """
+        return {
+            "input_tokens":                 self._usage_input,
+            "output_tokens":                self._usage_output,
+            "cache_creation_input_tokens":  0,
+            "cache_read_input_tokens":      0,
+            # Provider tag lets _compute_cost_usd apply the right pricing table
+            "provider": self._provider_name,
+        }
+
+    def reset_session(self) -> None:
+        """Reset per-session usage counters (called between runs)."""
+        self._usage_input  = 0
+        self._usage_output = 0
+
     # ── L02: Unified interface overrides ──
 
     def chat_typed(self, messages: List[ChatMessage],