diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c425d12..4c8b734 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,8 +31,9 @@ pip install -e ".[dev]" pytest ``` -Tests run fully offline using the `mock` and `callable` providers — no network -access, no credentials, and no vendor SDKs required. +Tests run fully offline using the `mock` and `callable` providers, plus fake SDK +modules for cloud adapters — no network access, no credentials, and no real +vendor SDKs required. ## Adding a new provider diff --git a/README.md b/README.md index bb80cf6..daca4ad 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, 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, 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,7 @@ 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, AWS Bedrock, Google Gemini, a local server, or your own +between OpenAI, DeepSeek, 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 @@ -33,6 +33,7 @@ internal backend — without touching your code. ```bash 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[aws]" # + AWS Bedrock (boto3) pip install "llm-bridge[google]" # + Google Gemini (google-genai) pip install "llm-bridge[all]" # everything @@ -49,6 +50,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": "bedrock", "model": ""}) # needs [aws] + AWS creds llm = create_llm({"provider": "google", "model": "gemini-2.5-flash"}) # needs [google] + GOOGLE_API_KEY @@ -82,17 +84,22 @@ print(llm.complete("Hi").content) | Mock (offline) | `mock` | none | | Bring your own | `callable` | none | | OpenAI (and OpenAI-compatible) | `openai` | `[openai]` | +| DeepSeek | `deepseek` | `[deepseek]` | | AWS Bedrock (Converse) | `bedrock`, `aws` | `[aws]` | | Google Gemini | `google`, `gemini` | `[google]` | Credentials are read from environment variables (`OPENAI_API_KEY`, -`GOOGLE_API_KEY`/`GEMINI_API_KEY`, standard AWS credential chain). Never +`DEEPSEEK_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, Ollama, Azure OpenAI, or an internal gateway): pass a `base_url` (or set `OPENAI_BASE_URL`). For local servers without auth, set a dummy `OPENAI_API_KEY`. +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 interface ```python @@ -124,7 +131,8 @@ Implement `LLMClient`, expose `build(config) -> LLMClient`, and register it in ## Examples See [`examples/`](examples): `mock_example.py`, `callable_example.py`, -`openai_example.py`, `bedrock_example.py`, `google_example.py`. +`openai_example.py`, `deepseek_example.py`, `bedrock_example.py`, +`google_example.py`. ## Requirements diff --git a/examples/deepseek_example.py b/examples/deepseek_example.py new file mode 100644 index 0000000..c10f949 --- /dev/null +++ b/examples/deepseek_example.py @@ -0,0 +1,40 @@ +# Copyright (c) 2026 Santander Group +# SPDX-License-Identifier: Apache-2.0 +"""DeepSeek provider. + +Requires: + pip install "llm-bridge[deepseek]" + export DEEPSEEK_API_KEY=... + +Run: + python examples/deepseek_example.py +""" + +import os + +from llm_bridge import create_llm + + +def main() -> None: + if not os.environ.get("DEEPSEEK_API_KEY"): + print("Set DEEPSEEK_API_KEY to run this example.") + return + + try: + llm = create_llm( + { + "provider": "deepseek", + "model": os.environ.get("DEEPSEEK_MODEL", "deepseek-v4-pro"), + } + ) + 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 2147620..66db8a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [] [project.optional-dependencies] openai = ["openai>=1.0"] +deepseek = ["openai>=1.0"] aws = ["boto3>=1.34"] google = ["google-genai>=0.3"] all = ["openai>=1.0", "boto3>=1.34", "google-genai>=0.3"] diff --git a/src/llm_bridge/providers/deepseek.py b/src/llm_bridge/providers/deepseek.py new file mode 100644 index 0000000..e952cc2 --- /dev/null +++ b/src/llm_bridge/providers/deepseek.py @@ -0,0 +1,98 @@ +# Copyright (c) 2026 Santander Group +# SPDX-License-Identifier: Apache-2.0 +"""DeepSeek provider using the OpenAI-compatible API. + +Optional dependency — requires ``pip install llm-bridge[deepseek]``. + +Configuration (config keys, with environment fallbacks): + model (required) e.g. "deepseek-v4-pro" + api_key DEEPSEEK_API_KEY + base_url DEEPSEEK_BASE_URL (optional; defaults to https://api.deepseek.com) +""" + +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://api.deepseek.com" + + +class DeepSeekClient(LLMClient): + """Chat client backed by DeepSeek's OpenAI-compatible API.""" + + def __init__( + self, + model: str, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + ): + if not model: + raise ValueError("deepseek provider requires 'model'.") + self._model = model + + try: + from openai import OpenAI + except ImportError as exc: # pragma: no cover + raise ImportError( + "The 'deepseek' provider requires the openai SDK. " + "Install it with: pip install llm-bridge[deepseek]" + ) from exc + + self._client = OpenAI( + api_key=api_key or os.environ.get("DEEPSEEK_API_KEY"), + base_url=base_url or os.environ.get("DEEPSEEK_BASE_URL") or DEFAULT_BASE_URL, + ) + + @property + def model(self) -> str: + return self._model + + @property + def provider(self) -> str: + return "deepseek" + + 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]) -> DeepSeekClient: + """Build a :class:`DeepSeekClient` from a config dict.""" + model = config.get("model") + if not model: + raise ValueError("deepseek provider requires 'model'.") + return DeepSeekClient( + model=model, + api_key=config.get("api_key"), + base_url=config.get("base_url"), + ) diff --git a/src/llm_bridge/providers/openai_sdk.py b/src/llm_bridge/providers/openai_sdk.py index 45ce873..cf8de7c 100644 --- a/src/llm_bridge/providers/openai_sdk.py +++ b/src/llm_bridge/providers/openai_sdk.py @@ -15,7 +15,7 @@ import os import time -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, cast from llm_bridge.base import LLMClient, LLMResponse, Message @@ -65,7 +65,7 @@ def chat( start = time.perf_counter() * 1000 resp = self._client.chat.completions.create( model=self._model, - messages=messages, + messages=cast(Any, messages), temperature=temperature, max_tokens=max_tokens, **kwargs, diff --git a/src/llm_bridge/registry.py b/src/llm_bridge/registry.py index a880ac8..b554e48 100644 --- a/src/llm_bridge/registry.py +++ b/src/llm_bridge/registry.py @@ -4,8 +4,8 @@ Dependency-free providers (``mock``, ``callable``) are registered eagerly. Providers that wrap an official vendor SDK (``openai``, ``bedrock``, -``google``) are registered with lazy builders, so importing ``llm_bridge`` -never pulls in a vendor SDK. +``google``, ``deepseek``) are registered with lazy builders, so importing +``llm_bridge`` never pulls in a vendor SDK. """ from __future__ import annotations @@ -86,6 +86,11 @@ def _openai(cfg: Dict[str, Any]) -> LLMClient: return build(cfg) + def _deepseek(cfg: Dict[str, Any]) -> LLMClient: + from llm_bridge.providers.deepseek import build + + return build(cfg) + def _bedrock(cfg: Dict[str, Any]) -> LLMClient: from llm_bridge.providers.bedrock import build @@ -97,6 +102,7 @@ def _google(cfg: Dict[str, Any]) -> LLMClient: return build(cfg) register_provider("openai", _openai) + register_provider("deepseek", _deepseek) 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 4bee042..a99df0d 100644 --- a/tests/test_cloud_providers.py +++ b/tests/test_cloud_providers.py @@ -79,6 +79,78 @@ def test_openai_chat_maps_response(fake_openai): assert fake_openai["messages"] == MESSAGES +# --------------------------------------------------------------------------- # +# DeepSeek +# --------------------------------------------------------------------------- # +@pytest.fixture +def fake_deepseek_openai(monkeypatch): + captured: dict[str, Any] = {} + + class _Msg: + content = "deepseek-reply" + + class _Choice: + message = _Msg() + finish_reason = "stop" + + class _Usage: + prompt_tokens = 13 + completion_tokens = 8 + + class _Resp: + model = "deepseek-v4-pro" + 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_deepseek_chat_maps_response(fake_deepseek_openai): + llm = create_llm( + {"provider": "deepseek", "model": "deepseek-v4-pro", "api_key": "deepseek-key"} + ) + assert llm.provider == "deepseek" + resp = llm.chat(MESSAGES, temperature=0.1, max_tokens=64, reasoning_effort="high") + assert resp.content == "deepseek-reply" + assert resp.model == "deepseek-v4-pro" + assert resp.prompt_tokens == 13 + assert resp.completion_tokens == 8 + assert resp.total_tokens == 21 + assert fake_deepseek_openai["init"]["api_key"] == "deepseek-key" + assert fake_deepseek_openai["init"]["base_url"] == "https://api.deepseek.com" + assert fake_deepseek_openai["model"] == "deepseek-v4-pro" + assert fake_deepseek_openai["messages"] == MESSAGES + assert fake_deepseek_openai["reasoning_effort"] == "high" + + +def test_deepseek_base_url_override(fake_deepseek_openai): + create_llm( + { + "provider": "deepseek", + "model": "deepseek-v4-flash", + "api_key": "x", + "base_url": "https://example.test/deepseek", + } + ) + assert fake_deepseek_openai["init"]["base_url"] == "https://example.test/deepseek" + + # --------------------------------------------------------------------------- # # AWS Bedrock # --------------------------------------------------------------------------- # diff --git a/tests/test_registry.py b/tests/test_registry.py index 8489c87..6d2aad2 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -13,6 +13,7 @@ def test_builtins_registered(): "mock", "callable", "openai", + "deepseek", "bedrock", "aws", "google", @@ -36,7 +37,7 @@ def test_overrides_apply(): assert llm.model == "custom" -@pytest.mark.parametrize("provider", ["openai", "bedrock", "aws", "google", "gemini"]) +@pytest.mark.parametrize("provider", ["openai", "deepseek", "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.