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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ GOOGLE_API_KEY=
PERPLEXITY_API_KEY=
# MISTRAL_API_KEY=

# Cloudflare Workers AI (OpenAI-compatible) — enables the "cloudflare" provider
# (e.g. Zhipu GLM-5.2). Both must be set. ACCOUNT_ID is an identifier (in the
# URL); the token is a Workers AI API token with the "Workers AI: Run" permission.
# CLOUDFLARE_ACCOUNT_ID=
# CLOUDFLARE_WORKERS_AI_TOKEN=

# JWT secret for auth tokens (required in production; auto-generated in dev if empty)
DUH_JWT_SECRET=

Expand Down
11 changes: 11 additions & 0 deletions src/duh/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,17 @@ async def _setup_providers(config: DuhConfig) -> ProviderManager:

perplexity_prov = PerplexityProvider(api_key=prov_config.api_key)
await pm.register(perplexity_prov) # type: ignore[arg-type]
elif prov_config.base_url is not None and prov_config.api_key is not None:
# Generic OpenAI-compatible host (e.g. Cloudflare Workers AI,
# Groq, Together) registered under its own provider id.
from duh.providers.openai import OpenAIProvider

compat_prov = OpenAIProvider(
api_key=prov_config.api_key,
base_url=prov_config.base_url,
provider_id=name,
)
await pm.register(compat_prov) # type: ignore[arg-type]

return pm

Expand Down
27 changes: 27 additions & 0 deletions src/duh/config/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,30 @@ def _resolve_api_keys(config: DuhConfig) -> None:
provider.api_key = os.environ.get(provider.api_key_env)


def _resolve_cloudflare(config: DuhConfig) -> None:
"""Configure a Cloudflare Workers AI provider from env vars (in-place).

When both ``CLOUDFLARE_ACCOUNT_ID`` and ``CLOUDFLARE_WORKERS_AI_TOKEN`` are
set, register a ``cloudflare`` provider pointing at the account's
OpenAI-compatible Workers AI endpoint. Env always wins when both are set.
"""
from duh.config.schema import ProviderConfig

account = os.environ.get("CLOUDFLARE_ACCOUNT_ID")
token = os.environ.get("CLOUDFLARE_WORKERS_AI_TOKEN")
if not account or not token:
return
base_url = f"https://api.cloudflare.com/client/v4/accounts/{account}/ai/v1"
existing = config.providers.get("cloudflare")
if existing is not None:
existing.api_key = token
existing.base_url = base_url
else:
config.providers["cloudflare"] = ProviderConfig(
api_key=token, base_url=base_url
)


def load_config(
path: str | Path | None = None,
overrides: dict[str, Any] | None = None,
Expand Down Expand Up @@ -143,6 +167,9 @@ def load_config(
# Resolve API keys from env vars
_resolve_api_keys(config)

# Configure Cloudflare Workers AI provider from env vars (if present)
_resolve_cloudflare(config)

# Auto-generate a dev JWT secret if none is configured
if not config.auth.jwt_secret:
env_secret = os.environ.get("DUH_JWT_SECRET")
Expand Down
19 changes: 19 additions & 0 deletions src/duh/providers/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,13 @@
| ModelCapability.JSON_MODE
| ModelCapability.WEB_SEARCH
),
# Cloudflare Workers AI (OpenAI-compatible endpoint), e.g. Zhipu GLM
"cloudflare": (
ModelCapability.TEXT
| ModelCapability.STREAMING
| ModelCapability.SYSTEM_PROMPT
| ModelCapability.JSON_MODE
),
}

# ── Model catalog keyed by provider_id ─────────────────────────
Expand Down Expand Up @@ -262,6 +269,18 @@
"output_cost_per_mtok": 8.0,
},
],
"cloudflare": [
{
# Zhipu GLM-5.2 served on Cloudflare Workers AI. Pricing/context per
# the Workers AI model page (max_output not published; conservative).
"model_id": "@cf/zai-org/glm-5.2",
"display_name": "GLM-5.2 (Cloudflare)",
"context_window": 262_144,
"max_output_tokens": 32_768,
"input_cost_per_mtok": 1.40,
"output_cost_per_mtok": 4.40,
},
],
}

# ── OpenAI reasoning models (no temperature support) ───────────
Expand Down
27 changes: 17 additions & 10 deletions src/duh/providers/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,14 @@ def __init__(
*,
base_url: str | None = None,
client: openai.AsyncOpenAI | None = None,
provider_id: str = PROVIDER_ID,
) -> None:
# provider_id lets one adapter serve any OpenAI-compatible host under a
# distinct id (e.g. "cloudflare" for Workers AI) so it can run alongside
# real OpenAI without colliding. Models/caps come from that id's catalog.
self._provider_id = provider_id
self._known_models = MODEL_CATALOG.get(provider_id, _KNOWN_MODELS)
self._caps = PROVIDER_CAPS.get(provider_id, _DEFAULT_CAPS)
if client is not None:
self._client = client
else:
Expand All @@ -117,21 +124,21 @@ def __init__(

@property
def provider_id(self) -> str:
return PROVIDER_ID
return self._provider_id

async def list_models(self) -> list[ModelInfo]:
return [
ModelInfo(
provider_id=PROVIDER_ID,
provider_id=self._provider_id,
model_id=m["model_id"],
display_name=m["display_name"],
capabilities=_DEFAULT_CAPS,
capabilities=self._caps,
context_window=m["context_window"],
max_output_tokens=m["max_output_tokens"],
input_cost_per_mtok=m["input_cost_per_mtok"],
output_cost_per_mtok=m["output_cost_per_mtok"],
)
for m in _KNOWN_MODELS
for m in self._known_models
]

async def send(
Expand Down Expand Up @@ -287,24 +294,24 @@ async def health_check(self) -> bool:

def _resolve_model_info(self, model_id: str) -> ModelInfo:
"""Look up ModelInfo for a model_id, or create a generic one."""
for m in _KNOWN_MODELS:
for m in self._known_models:
if m["model_id"] == model_id:
return ModelInfo(
provider_id=PROVIDER_ID,
provider_id=self._provider_id,
model_id=model_id,
display_name=m["display_name"],
capabilities=_DEFAULT_CAPS,
capabilities=self._caps,
context_window=m["context_window"],
max_output_tokens=m["max_output_tokens"],
input_cost_per_mtok=m["input_cost_per_mtok"],
output_cost_per_mtok=m["output_cost_per_mtok"],
)
# Unknown model — return generic info
return ModelInfo(
provider_id=PROVIDER_ID,
provider_id=self._provider_id,
model_id=model_id,
display_name=f"OpenAI ({model_id})",
capabilities=_DEFAULT_CAPS,
display_name=f"{self._provider_id} ({model_id})",
capabilities=self._caps,
context_window=200_000,
max_output_tokens=4096,
input_cost_per_mtok=0.0,
Expand Down
22 changes: 22 additions & 0 deletions tests/unit/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -673,3 +673,25 @@ async def fake_ask(
assert "SQLite" in result.output or "repository" in result.output
assert "Confidence:" in result.output
assert "Cost:" in result.output


# ── Generic OpenAI-compatible provider setup ─────────────────────


class TestSetupProvidersCloudflare:
async def test_registers_cloudflare_glm(self) -> None:
"""A config with a base_url+key provider registers it under its id."""
from duh.cli.app import _setup_providers
from duh.config.schema import DuhConfig, ProviderConfig

config = DuhConfig()
# Other default providers have no api_key -> skipped by _setup_providers.
config.providers["cloudflare"] = ProviderConfig(
api_key="tok",
base_url="https://api.cloudflare.com/client/v4/accounts/acct/ai/v1",
)

pm = await _setup_providers(config)
models = pm.list_all_models()
cf = [m for m in models if m.provider_id == "cloudflare"]
assert any(m.model_id == "@cf/zai-org/glm-5.2" for m in cf)
40 changes: 40 additions & 0 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,3 +313,43 @@ def test_merge_preserves_non_overlapping(self, tmp_path, monkeypatch):
cfg = load_config()
assert cfg.cost.hard_limit == 50.0
assert cfg.general.max_rounds == 6


class TestCloudflareEnv:
def _isolate(self, tmp_path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.delenv("DUH_CONFIG", raising=False)
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)

def test_cloudflare_configured_from_env(self, tmp_path, monkeypatch):
"""Both CLOUDFLARE_* env vars -> a cloudflare provider is configured."""
self._isolate(tmp_path, monkeypatch)
monkeypatch.setenv("CLOUDFLARE_ACCOUNT_ID", "acct123")
monkeypatch.setenv("CLOUDFLARE_WORKERS_AI_TOKEN", "tok-secret")

cfg = load_config()
assert "cloudflare" in cfg.providers
prov = cfg.providers["cloudflare"]
assert prov.api_key == "tok-secret"
assert prov.base_url == (
"https://api.cloudflare.com/client/v4/accounts/acct123/ai/v1"
)

def test_cloudflare_absent_without_env(self, tmp_path, monkeypatch):
"""Missing either var -> no cloudflare provider."""
self._isolate(tmp_path, monkeypatch)
monkeypatch.delenv("CLOUDFLARE_ACCOUNT_ID", raising=False)
monkeypatch.delenv("CLOUDFLARE_WORKERS_AI_TOKEN", raising=False)

cfg = load_config()
assert "cloudflare" not in cfg.providers

def test_cloudflare_requires_both(self, tmp_path, monkeypatch):
"""Only the account ID (no token) -> not configured."""
self._isolate(tmp_path, monkeypatch)
monkeypatch.setenv("CLOUDFLARE_ACCOUNT_ID", "acct123")
monkeypatch.delenv("CLOUDFLARE_WORKERS_AI_TOKEN", raising=False)

cfg = load_config()
assert "cloudflare" not in cfg.providers
45 changes: 45 additions & 0 deletions tests/unit/test_providers_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,3 +436,48 @@ def test_base_url_sets_default_api_key(self):
# If AsyncOpenAI raises for missing key, this test fails.
provider = OpenAIProvider(base_url="http://localhost:11434/v1")
assert provider.provider_id == PROVIDER_ID


# ─── Custom provider id (OpenAI-compatible hosts) ─────────────


class TestCustomProviderId:
def test_default_provider_id_unchanged(self):
"""Omitting provider_id keeps the adapter reporting 'openai'."""
provider = OpenAIProvider(client=_make_client())
assert provider.provider_id == PROVIDER_ID

def test_custom_provider_id_reported(self):
"""A custom provider_id is reported as-is."""
provider = OpenAIProvider(client=_make_client(), provider_id="cloudflare")
assert provider.provider_id == "cloudflare"

async def test_serves_cloudflare_catalog(self):
"""Cloudflare provider lists GLM-5.2 with its real pricing."""
provider = OpenAIProvider(client=_make_client(), provider_id="cloudflare")
models = await provider.list_models()
ids = {m.model_id for m in models}
assert "@cf/zai-org/glm-5.2" in ids
glm = next(m for m in models if m.model_id == "@cf/zai-org/glm-5.2")
assert glm.provider_id == "cloudflare"
assert glm.input_cost_per_mtok == 1.40
assert glm.output_cost_per_mtok == 4.40
assert glm.context_window == 262_144

async def test_cost_resolution_for_glm(self):
"""send() resolves GLM cost from the cloudflare catalog, not $0."""
client = _make_client()
provider = OpenAIProvider(client=client, provider_id="cloudflare")
resp = await provider.send(
[PromptMessage(role="user", content="hi")], "@cf/zai-org/glm-5.2"
)
assert resp.model_info.provider_id == "cloudflare"
assert resp.model_info.input_cost_per_mtok == 1.40
assert resp.model_info.output_cost_per_mtok == 4.40

def test_unknown_provider_falls_back_to_openai_catalog(self):
"""An unrecognized provider_id falls back to the openai catalog."""
provider = OpenAIProvider(client=_make_client(), provider_id="mystery")
assert provider.provider_id == "mystery"
# No catalog for "mystery" -> falls back to openai's known models.
assert provider._known_models is not None
Loading