diff --git a/README.md b/README.md index daca4ad..ec8ba36 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # llm_bridge -> **Open source by Santander AI Lab.** A tiny, vendor-neutral **LLM client library** — one interface for **OpenAI, DeepSeek, AWS Bedrock and Google Gemini** (or bring your own AI backend). +> **Open source by Santander AI Lab.** A tiny, vendor-neutral **LLM client library** — one interface for **OpenAI, DeepSeek, Alibaba Qwen, AWS Bedrock and Google Gemini** (or bring your own AI backend). [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) [![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://www.python.org/downloads/) @@ -15,7 +15,8 @@ Part of [**Santander AI Open Source**](https://github.com/SantanderAI) — open A tiny, **vendor-neutral wrapper for any LLM backend**. One small interface, pluggable providers. Write your application against `LLMClient` once and switch -between OpenAI, DeepSeek, AWS Bedrock, Google Gemini, a local server, or your own +between OpenAI, DeepSeek, Alibaba Qwen, AWS Bedrock, Google Gemini, a local +server, or your own internal backend — without touching your code. - **Canonical interface + thin SDK adapters.** One contract (`LLMClient`) with @@ -34,6 +35,7 @@ internal backend — without touching your code. pip install llm-bridge # core only (no vendor SDKs) pip install "llm-bridge[openai]" # + OpenAI SDK pip install "llm-bridge[deepseek]" # + OpenAI SDK for DeepSeek +pip install "llm-bridge[qwen]" # + OpenAI SDK for Qwen/DashScope pip install "llm-bridge[aws]" # + AWS Bedrock (boto3) pip install "llm-bridge[google]" # + Google Gemini (google-genai) pip install "llm-bridge[all]" # everything @@ -51,6 +53,7 @@ print(llm.complete("Hello!").content) # Switch provider by changing one dict — your code stays the same. llm = create_llm({"provider": "openai", "model": "gpt-4o-mini"}) # needs [openai] + OPENAI_API_KEY llm = create_llm({"provider": "deepseek", "model": "deepseek-v4-pro"}) # needs [deepseek] + DEEPSEEK_API_KEY +llm = create_llm({"provider": "qwen", "model": "qwen-plus"}) # needs [qwen] + DASHSCOPE_API_KEY llm = create_llm({"provider": "bedrock", "model": ""}) # needs [aws] + AWS creds llm = create_llm({"provider": "google", "model": "gemini-2.5-flash"}) # needs [google] + GOOGLE_API_KEY @@ -85,11 +88,13 @@ print(llm.complete("Hi").content) | Bring your own | `callable` | none | | OpenAI (and OpenAI-compatible) | `openai` | `[openai]` | | DeepSeek | `deepseek` | `[deepseek]` | +| Alibaba Qwen | `qwen` | `[qwen]` | | AWS Bedrock (Converse) | `bedrock`, `aws` | `[aws]` | | Google Gemini | `google`, `gemini` | `[google]` | Credentials are read from environment variables (`OPENAI_API_KEY`, -`DEEPSEEK_API_KEY`, `GOOGLE_API_KEY`/`GEMINI_API_KEY`, standard AWS credential chain). Never +`DEEPSEEK_API_KEY`, `DASHSCOPE_API_KEY`, `GOOGLE_API_KEY`/`GEMINI_API_KEY`, +standard AWS credential chain). Never hardcode secrets. The `openai` provider also targets any **OpenAI-compatible** endpoint (vLLM, @@ -100,6 +105,11 @@ The `deepseek` provider uses DeepSeek's OpenAI-compatible API via the OpenAI SDK, defaults to `https://api.deepseek.com`, and accepts `base_url` or `DEEPSEEK_BASE_URL` for compatible endpoints. +The `qwen` provider uses Alibaba Model Studio/DashScope's OpenAI-compatible API +via the OpenAI SDK. It defaults to +`https://dashscope-intl.aliyuncs.com/compatible-mode/v1` and accepts `base_url` +or `DASHSCOPE_BASE_URL` for other regions or workspaces. + ## The interface ```python @@ -132,13 +142,13 @@ Implement `LLMClient`, expose `build(config) -> LLMClient`, and register it in See [`examples/`](examples): `mock_example.py`, `callable_example.py`, `openai_example.py`, `deepseek_example.py`, `bedrock_example.py`, -`google_example.py`. +`qwen_example.py`, `bedrock_example.py`, `google_example.py`. ## Requirements - Python 3.9+ - No required runtime dependencies for the core (`mock`, `callable`). -- Optional vendor SDKs are installed on demand via extras (`[openai]`, `[aws]`, `[google]`, `[all]`). +- Optional vendor SDKs are installed on demand via extras (`[openai]`, `[deepseek]`, `[qwen]`, `[aws]`, `[google]`, `[all]`). ## Contributing diff --git a/examples/qwen_example.py b/examples/qwen_example.py new file mode 100644 index 0000000..1443beb --- /dev/null +++ b/examples/qwen_example.py @@ -0,0 +1,40 @@ +# Copyright (c) 2026 Santander Group +# SPDX-License-Identifier: Apache-2.0 +"""Alibaba Qwen provider. + +Requires: + pip install "llm-bridge[qwen]" + export DASHSCOPE_API_KEY=... + +Run: + python examples/qwen_example.py +""" + +import os + +from llm_bridge import create_llm + + +def main() -> None: + if not os.environ.get("DASHSCOPE_API_KEY"): + print("Set DASHSCOPE_API_KEY to run this example.") + return + + try: + llm = create_llm( + { + "provider": "qwen", + "model": os.environ.get("QWEN_MODEL", "qwen-plus"), + } + ) + except ImportError as exc: + print(exc) + return + + resp = llm.complete("Say hello in one short sentence.", system="You are friendly and concise.") + print(resp.content) + print("tokens:", resp.total_tokens) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index d91fad8..d1b45b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [] [project.optional-dependencies] openai = ["openai>=2.44.0"] deepseek = ["openai>=2.44.0"] +qwen = ["openai>=2.44.0"] aws = ["boto3>=1.43.38"] google = ["google-genai>=2.10.0"] all = ["openai>=2.44.0", "boto3>=1.43.38", "google-genai>=2.10.0"] diff --git a/src/llm_bridge/providers/qwen.py b/src/llm_bridge/providers/qwen.py new file mode 100644 index 0000000..dce2596 --- /dev/null +++ b/src/llm_bridge/providers/qwen.py @@ -0,0 +1,98 @@ +# Copyright (c) 2026 Santander Group +# SPDX-License-Identifier: Apache-2.0 +"""Alibaba Qwen provider using DashScope's OpenAI-compatible API. + +Optional dependency — requires ``pip install llm-bridge[qwen]``. + +Configuration (config keys, with environment fallbacks): + model (required) e.g. "qwen-plus" + api_key DASHSCOPE_API_KEY + base_url DASHSCOPE_BASE_URL (optional; defaults to DashScope international) +""" + +from __future__ import annotations + +import os +import time +from typing import Any, Dict, List, Optional, cast + +from llm_bridge.base import LLMClient, LLMResponse, Message + +DEFAULT_BASE_URL = "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" + + +class QwenClient(LLMClient): + """Chat client backed by Alibaba Qwen through DashScope.""" + + def __init__( + self, + model: str, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + ): + if not model: + raise ValueError("qwen provider requires 'model'.") + self._model = model + + try: + from openai import OpenAI + except ImportError as exc: # pragma: no cover + raise ImportError( + "The 'qwen' provider requires the openai SDK. " + "Install it with: pip install llm-bridge[qwen]" + ) from exc + + self._client = OpenAI( + api_key=api_key or os.environ.get("DASHSCOPE_API_KEY"), + base_url=base_url or os.environ.get("DASHSCOPE_BASE_URL") or DEFAULT_BASE_URL, + ) + + @property + def model(self) -> str: + return self._model + + @property + def provider(self) -> str: + return "qwen" + + def chat( + self, + messages: List[Message], + *, + temperature: float = 0.7, + max_tokens: int = 1024, + **kwargs: Any, + ) -> LLMResponse: + start = time.perf_counter() * 1000 + resp = self._client.chat.completions.create( + model=self._model, + messages=cast(Any, messages), + temperature=temperature, + max_tokens=max_tokens, + **kwargs, + ) + latency = time.perf_counter() * 1000 - start + + choice = resp.choices[0] + usage = getattr(resp, "usage", None) + return LLMResponse( + content=choice.message.content or "", + model=getattr(resp, "model", self._model), + prompt_tokens=getattr(usage, "prompt_tokens", 0) if usage else 0, + completion_tokens=getattr(usage, "completion_tokens", 0) if usage else 0, + finish_reason=choice.finish_reason or "stop", + latency_ms=latency, + raw=resp, + ) + + +def build(config: Dict[str, Any]) -> QwenClient: + """Build a :class:`QwenClient` from a config dict.""" + model = config.get("model") + if not model: + raise ValueError("qwen provider requires 'model'.") + return QwenClient( + model=model, + api_key=config.get("api_key"), + base_url=config.get("base_url"), + ) diff --git a/src/llm_bridge/registry.py b/src/llm_bridge/registry.py index b554e48..2794d9b 100644 --- a/src/llm_bridge/registry.py +++ b/src/llm_bridge/registry.py @@ -4,7 +4,7 @@ Dependency-free providers (``mock``, ``callable``) are registered eagerly. Providers that wrap an official vendor SDK (``openai``, ``bedrock``, -``google``, ``deepseek``) are registered with lazy builders, so importing +``google``, ``deepseek``, ``qwen``) are registered with lazy builders, so importing ``llm_bridge`` never pulls in a vendor SDK. """ @@ -91,6 +91,11 @@ def _deepseek(cfg: Dict[str, Any]) -> LLMClient: return build(cfg) + def _qwen(cfg: Dict[str, Any]) -> LLMClient: + from llm_bridge.providers.qwen import build + + return build(cfg) + def _bedrock(cfg: Dict[str, Any]) -> LLMClient: from llm_bridge.providers.bedrock import build @@ -103,6 +108,7 @@ def _google(cfg: Dict[str, Any]) -> LLMClient: register_provider("openai", _openai) register_provider("deepseek", _deepseek) + register_provider("qwen", _qwen) register_provider("bedrock", _bedrock) register_provider("aws", _bedrock) # alias register_provider("google", _google) diff --git a/tests/test_cloud_providers.py b/tests/test_cloud_providers.py index a99df0d..7dcf939 100644 --- a/tests/test_cloud_providers.py +++ b/tests/test_cloud_providers.py @@ -151,6 +151,79 @@ def test_deepseek_base_url_override(fake_deepseek_openai): assert fake_deepseek_openai["init"]["base_url"] == "https://example.test/deepseek" +# --------------------------------------------------------------------------- # +# Alibaba Qwen +# --------------------------------------------------------------------------- # +@pytest.fixture +def fake_qwen_openai(monkeypatch): + captured: dict[str, Any] = {} + + class _Msg: + content = "qwen-reply" + + class _Choice: + message = _Msg() + finish_reason = "stop" + + class _Usage: + prompt_tokens = 9 + completion_tokens = 4 + + class _Resp: + model = "qwen-plus" + choices = [_Choice()] + usage = _Usage() + + class _Completions: + def create(self, **kwargs): + captured.update(kwargs) + return _Resp() + + class _Chat: + completions = _Completions() + + class OpenAI: + def __init__(self, **kwargs): + captured["init"] = kwargs + self.chat = _Chat() + + mod = types.ModuleType("openai") + mod.OpenAI = OpenAI + monkeypatch.setitem(sys.modules, "openai", mod) + return captured + + +def test_qwen_chat_maps_response(fake_qwen_openai): + llm = create_llm({"provider": "qwen", "model": "qwen-plus", "api_key": "dashscope-key"}) + assert llm.provider == "qwen" + resp = llm.chat(MESSAGES, temperature=0.1, max_tokens=64, top_p=0.8) + assert resp.content == "qwen-reply" + assert resp.model == "qwen-plus" + assert resp.prompt_tokens == 9 + assert resp.completion_tokens == 4 + assert resp.total_tokens == 13 + assert fake_qwen_openai["init"]["api_key"] == "dashscope-key" + assert ( + fake_qwen_openai["init"]["base_url"] + == "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" + ) + assert fake_qwen_openai["model"] == "qwen-plus" + assert fake_qwen_openai["messages"] == MESSAGES + assert fake_qwen_openai["top_p"] == 0.8 + + +def test_qwen_base_url_override(fake_qwen_openai): + create_llm( + { + "provider": "qwen", + "model": "qwen-plus", + "api_key": "x", + "base_url": "https://example.test/qwen", + } + ) + assert fake_qwen_openai["init"]["base_url"] == "https://example.test/qwen" + + # --------------------------------------------------------------------------- # # AWS Bedrock # --------------------------------------------------------------------------- # diff --git a/tests/test_registry.py b/tests/test_registry.py index 6d2aad2..0f57bc8 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -14,6 +14,7 @@ def test_builtins_registered(): "callable", "openai", "deepseek", + "qwen", "bedrock", "aws", "google", @@ -37,7 +38,9 @@ def test_overrides_apply(): assert llm.model == "custom" -@pytest.mark.parametrize("provider", ["openai", "deepseek", "bedrock", "aws", "google", "gemini"]) +@pytest.mark.parametrize( + "provider", ["openai", "deepseek", "qwen", "bedrock", "aws", "google", "gemini"] +) def test_cloud_provider_validates_model_before_sdk(provider): # build() validates required fields before importing any optional SDK, # so this raises ValueError regardless of whether the SDK is installed.