Skip to content
Open
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
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 12 additions & 4 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, 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/)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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": "<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 @@ -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
Expand Down Expand Up @@ -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

Expand Down
40 changes: 40 additions & 0 deletions examples/deepseek_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
"""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()
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>=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"]
Expand Down
98 changes: 98 additions & 0 deletions src/llm_bridge/providers/deepseek.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
"""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"),
)
4 changes: 2 additions & 2 deletions src/llm_bridge/providers/openai_sdk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions src/llm_bridge/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

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