From 81d54e0aec45cb50e26f41154562b0b408035650 Mon Sep 17 00:00:00 2001 From: Michael Sitarzewski Date: Sun, 21 Jun 2026 23:20:08 -0500 Subject: [PATCH] feat(providers): add Cloudflare Workers AI provider with Zhipu GLM-5.2 Adds a "cloudflare" provider that talks to Cloudflare Workers AI's OpenAI-compatible endpoint, with GLM-5.2 (@cf/zai-org/glm-5.2) in the catalog. Generalizes the existing OpenAI adapter so it can serve any OpenAI-compatible host under its own provider id without colliding with real OpenAI. - OpenAIProvider gains an optional provider_id and resolves its model catalog/capabilities from that id (was hardcoded to "openai") - catalog.py: cloudflare capabilities + GLM-5.2 entry (262k context, $1.40/$4.40 per Mtok, per the Workers AI model page) - _setup_providers: generic branch registers any base_url+key provider as an OpenAI-compatible adapter under its config name - loader: CLOUDFLARE_ACCOUNT_ID + CLOUDFLARE_WORKERS_AI_TOKEN env vars configure the provider and build the account's Workers AI base URL - .env.example documents both vars Validated end-to-end against live Cloudflare: text generation, JSON mode (used by COMMIT/follow-ups), token accounting, and `duh models` listing. 1672 Python tests pass, mypy + ruff clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01EkrekgzMAQko92UkjnXhHL --- .env.example | 6 ++++ src/duh/cli/app.py | 11 +++++++ src/duh/config/loader.py | 27 +++++++++++++++++ src/duh/providers/catalog.py | 19 ++++++++++++ src/duh/providers/openai.py | 27 ++++++++++------- tests/unit/test_cli.py | 22 ++++++++++++++ tests/unit/test_config.py | 40 +++++++++++++++++++++++++ tests/unit/test_providers_openai.py | 45 +++++++++++++++++++++++++++++ 8 files changed, 187 insertions(+), 10 deletions(-) diff --git a/.env.example b/.env.example index 4020e5e..5b230db 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/src/duh/cli/app.py b/src/duh/cli/app.py index 92672a3..7c3fbc4 100644 --- a/src/duh/cli/app.py +++ b/src/duh/cli/app.py @@ -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 diff --git a/src/duh/config/loader.py b/src/duh/config/loader.py index 9eca418..45678e8 100644 --- a/src/duh/config/loader.py +++ b/src/duh/config/loader.py @@ -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, @@ -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") diff --git a/src/duh/providers/catalog.py b/src/duh/providers/catalog.py index 2bdcedb..93b5a3a 100644 --- a/src/duh/providers/catalog.py +++ b/src/duh/providers/catalog.py @@ -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 ───────────────────────── @@ -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) ─────────── diff --git a/src/duh/providers/openai.py b/src/duh/providers/openai.py index 7675629..0833dba 100644 --- a/src/duh/providers/openai.py +++ b/src/duh/providers/openai.py @@ -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: @@ -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( @@ -287,13 +294,13 @@ 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"], @@ -301,10 +308,10 @@ def _resolve_model_info(self, model_id: str) -> ModelInfo: ) # 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, diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 955cf4c..b9c8e58 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -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) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 6c4fa67..c88d45f 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -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 diff --git a/tests/unit/test_providers_openai.py b/tests/unit/test_providers_openai.py index 9317e9d..d98c63a 100644 --- a/tests/unit/test_providers_openai.py +++ b/tests/unit/test_providers_openai.py @@ -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