Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -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/)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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": "<bedrock-model-id>"}) # needs [aws] + AWS creds
llm = create_llm({"provider": "google", "model": "gemini-2.5-flash"}) # needs [google] + GOOGLE_API_KEY

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
40 changes: 40 additions & 0 deletions examples/qwen_example.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
98 changes: 98 additions & 0 deletions src/llm_bridge/providers/qwen.py
Original file line number Diff line number Diff line change
@@ -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"),
)
8 changes: 7 additions & 1 deletion src/llm_bridge/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""

Expand Down Expand Up @@ -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

Expand All @@ -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)
Expand Down
73 changes: 73 additions & 0 deletions tests/test_cloud_providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# --------------------------------------------------------------------------- #
Expand Down
5 changes: 4 additions & 1 deletion tests/test_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def test_builtins_registered():
"callable",
"openai",
"deepseek",
"qwen",
"bedrock",
"aws",
"google",
Expand All @@ -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.
Expand Down
Loading