From 5d8d184d8611b73514ad2d11ad7e823b4445ab03 Mon Sep 17 00:00:00 2001 From: Praveen Kumar Pranjal Date: Tue, 8 Sep 2026 03:27:07 +0530 Subject: [PATCH] feat: add Cloudflare Workers AI provider (closes #3411) - Add Cloudflare Workers AI provider supporting chat completion, streaming, embeddings, and list models - Wire Cloudflare provider into core factory, schemas, and test accounts - Add documentation, OpenAPI schemas, and configuration definitions - Wire Cloudflare into UI dashboard providers, icon definitions, and placeholders - Update CI/CD workflows and harden-runner allowlists for Cloudflare endpoints - Adapt to upstream dev changes: add VideoEdit stub, update HandleOpenAIEmbeddingRequest signature, and preserve all upstream providers --- .github/workflows/pr-tests.yml | 2 + .github/workflows/release-pipeline.yml | 32 ++ .../workflows/scripts/test-docker-image.sh | 16 + .github/workflows/snyk.yml | 4 +- core/bifrost.go | 3 + core/internal/llmtests/account.go | 32 ++ core/providers/cloudflare/cachedcontents.go | 34 ++ core/providers/cloudflare/cloudflare.go | 514 ++++++++++++++++++ core/providers/cloudflare/cloudflare_test.go | 231 ++++++++ core/providers/cloudflare/errors.go | 56 ++ core/providers/cloudflare/models.go | 98 ++++ core/providers/cloudflare/types.go | 50 ++ core/schemas/bifrost.go | 3 + docs/docs.json | 1 + docs/openapi/openapi.json | 1 + .../supported-providers/cloudflare.mdx | 167 ++++++ transports/config.schema.json | 5 + .../fragments/networkFormFragment.tsx | 7 +- ui/lib/constants/config.ts | 2 + ui/lib/constants/icons.tsx | 24 + ui/lib/constants/logs.ts | 3 + 21 files changed, 1280 insertions(+), 5 deletions(-) create mode 100644 core/providers/cloudflare/cachedcontents.go create mode 100644 core/providers/cloudflare/cloudflare.go create mode 100644 core/providers/cloudflare/cloudflare_test.go create mode 100644 core/providers/cloudflare/errors.go create mode 100644 core/providers/cloudflare/models.go create mode 100644 core/providers/cloudflare/types.go create mode 100644 docs/providers/supported-providers/cloudflare.mdx diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 715d79e3dc3..61bea21ecb6 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -132,6 +132,8 @@ jobs: SGL_API_KEY: ${{ secrets.SGL_API_KEY }} CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} SARVAM_API_KEY: ${{ secrets.SARVAM_API_KEY }} + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} DATABRICKS_WORKSPACE_URL: ${{ secrets.DATABRICKS_WORKSPACE_URL }} GITHUB_COPILOT_APP_ID: ${{ secrets.GITHUB_COPILOT_APP_ID }} diff --git a/.github/workflows/release-pipeline.yml b/.github/workflows/release-pipeline.yml index d923b679473..83ff46eb454 100644 --- a/.github/workflows/release-pipeline.yml +++ b/.github/workflows/release-pipeline.yml @@ -323,6 +323,7 @@ jobs: aiplatform.googleapis.com:443 api.anthropic.com:443 api.cerebras.ai:443 + api.cloudflare.com:443 api.cohere.ai:443 api.deepseek.com:443 api.elevenlabs.io:443 @@ -440,6 +441,8 @@ jobs: CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} SARVAM_API_KEY: ${{ secrets.SARVAM_API_KEY }} + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} DATABRICKS_WORKSPACE_URL: ${{ secrets.DATABRICKS_WORKSPACE_URL }} GITHUB_COPILOT_APP_ID: ${{ secrets.GITHUB_COPILOT_APP_ID }} @@ -539,6 +542,7 @@ jobs: aiplatform.googleapis.com:443 api.anthropic.com:443 api.cerebras.ai:443 + api.cloudflare.com:443 api.cohere.ai:443 api.deepseek.com:443 api.elevenlabs.io:443 @@ -667,6 +671,8 @@ jobs: CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} SARVAM_API_KEY: ${{ secrets.SARVAM_API_KEY }} + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} DATABRICKS_WORKSPACE_URL: ${{ secrets.DATABRICKS_WORKSPACE_URL }} GITHUB_COPILOT_APP_ID: ${{ secrets.GITHUB_COPILOT_APP_ID }} @@ -922,6 +928,7 @@ jobs: 7defe2860d5ee49a1e667e1eeea34b25.r2.cloudflarestorage.com:443 api.anthropic.com:443 api.cerebras.ai:443 + api.cloudflare.com:443 api.cohere.ai:443 api.deepseek.com:443 api.elevenlabs.io:443 @@ -1154,6 +1161,7 @@ jobs: _grpc_config.cluster.qdrant.io:443 _grpc_config.localhost:443 api.cerebras.ai:443 + api.cloudflare.com:443 api.cohere.ai:443 api.deepseek.com:443 api.elevenlabs.io:443 @@ -1233,6 +1241,8 @@ jobs: SGL_API_KEY: ${{ secrets.SGL_API_KEY }} CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} SARVAM_API_KEY: ${{ secrets.SARVAM_API_KEY }} + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} DATABRICKS_WORKSPACE_URL: ${{ secrets.DATABRICKS_WORKSPACE_URL }} COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} @@ -1272,6 +1282,7 @@ jobs: _https._tcp.motd.ubuntu.com:443 _https._tcp.packages.microsoft.com:443 api.cerebras.ai:443 + api.cloudflare.com:443 api.cohere.ai:443 api.deepseek.com:443 api.elevenlabs.io:443 @@ -1360,6 +1371,8 @@ jobs: SGL_API_KEY: ${{ secrets.SGL_API_KEY }} CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} SARVAM_API_KEY: ${{ secrets.SARVAM_API_KEY }} + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} DATABRICKS_WORKSPACE_URL: ${{ secrets.DATABRICKS_WORKSPACE_URL }} COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} @@ -1405,6 +1418,7 @@ jobs: 172.38.0.12:8080 172.38.0.12:8301 api.cerebras.ai:443 + api.cloudflare.com:443 api.cohere.ai:443 api.deepseek.com:443 api.elevenlabs.io:443 @@ -1516,6 +1530,7 @@ jobs: 172.38.0.12:8301 api.anthropic.com:443 api.cerebras.ai:443 + api.cloudflare.com:443 api.cohere.ai:443 api.deepseek.com:443 api.elevenlabs.io:443 @@ -1621,6 +1636,7 @@ jobs: iojs.org:443 api.anthropic.com:443 api.cerebras.ai:443 + api.cloudflare.com:443 api.cohere.ai:443 api.deepseek.com:443 api.elevenlabs.io:443 @@ -1732,6 +1748,8 @@ jobs: CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} SARVAM_API_KEY: ${{ secrets.SARVAM_API_KEY }} + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} DATABRICKS_WORKSPACE_URL: ${{ secrets.DATABRICKS_WORKSPACE_URL }} GITHUB_COPILOT_APP_ID: ${{ secrets.GITHUB_COPILOT_APP_ID }} @@ -1793,6 +1811,7 @@ jobs: 127.0.0.1:8080 172.38.0.11:5432 api.cerebras.ai:443 + api.cloudflare.com:443 api.cohere.ai:443 api.deepseek.com:443 api.elevenlabs.io:443 @@ -1890,6 +1909,7 @@ jobs: 127.0.0.1:8000 127.0.0.1:8080 api.cerebras.ai:443 + api.cloudflare.com:443 api.cohere.ai:443 api.deepseek.com:443 api.elevenlabs.io:443 @@ -1992,6 +2012,7 @@ jobs: 172.28.0.16:5432 api.anthropic.com:443 api.cerebras.ai:443 + api.cloudflare.com:443 api.cohere.ai:443 api.deepseek.com:443 api.elevenlabs.io:443 @@ -2082,6 +2103,8 @@ jobs: PERPLEXITY_API_KEY: ${{ secrets.PERPLEXITY_API_KEY }} CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} SARVAM_API_KEY: ${{ secrets.SARVAM_API_KEY }} + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} DATABRICKS_WORKSPACE_URL: ${{ secrets.DATABRICKS_WORKSPACE_URL }} GITHUB_COPILOT_APP_ID: ${{ secrets.GITHUB_COPILOT_APP_ID }} @@ -2142,6 +2165,7 @@ jobs: 172.28.0.16:5432 api.anthropic.com:443 api.cerebras.ai:443 + api.cloudflare.com:443 api.cohere.ai:443 api.deepseek.com:443 api.elevenlabs.io:443 @@ -2232,6 +2256,8 @@ jobs: PERPLEXITY_API_KEY: ${{ secrets.PERPLEXITY_API_KEY }} CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} SARVAM_API_KEY: ${{ secrets.SARVAM_API_KEY }} + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} DATABRICKS_WORKSPACE_URL: ${{ secrets.DATABRICKS_WORKSPACE_URL }} GITHUB_COPILOT_APP_ID: ${{ secrets.GITHUB_COPILOT_APP_ID }} @@ -2357,6 +2383,8 @@ jobs: SGL_API_KEY: ${{ secrets.SGL_API_KEY }} CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} SARVAM_API_KEY: ${{ secrets.SARVAM_API_KEY }} + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} DATABRICKS_WORKSPACE_URL: ${{ secrets.DATABRICKS_WORKSPACE_URL }} COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} @@ -2477,6 +2505,8 @@ jobs: SGL_API_KEY: ${{ secrets.SGL_API_KEY }} CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} SARVAM_API_KEY: ${{ secrets.SARVAM_API_KEY }} + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} DATABRICKS_WORKSPACE_URL: ${{ secrets.DATABRICKS_WORKSPACE_URL }} COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} @@ -2613,6 +2643,8 @@ jobs: SGL_API_KEY: ${{ secrets.SGL_API_KEY }} CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} SARVAM_API_KEY: ${{ secrets.SARVAM_API_KEY }} + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} DATABRICKS_TOKEN: ${{ secrets.DATABRICKS_TOKEN }} DATABRICKS_WORKSPACE_URL: ${{ secrets.DATABRICKS_WORKSPACE_URL }} COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} diff --git a/.github/workflows/scripts/test-docker-image.sh b/.github/workflows/scripts/test-docker-image.sh index 7b4538d603a..c4df778460b 100755 --- a/.github/workflows/scripts/test-docker-image.sh +++ b/.github/workflows/scripts/test-docker-image.sh @@ -157,6 +157,10 @@ cat > "$CONFIG_FILE" << 'CONFIGEOF' "keys": [{ "name": "Cerebras API Key", "value": "env.CEREBRAS_API_KEY", "weight": 1 }], "network_config": { "default_request_timeout_in_seconds": 300 } }, + "cloudflare": { + "keys": [{ "name": "Cloudflare API Key", "value": "env.CLOUDFLARE_API_KEY", "weight": 1 }], + "network_config": { "base_url": "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/ai", "default_request_timeout_in_seconds": 300 } + }, "openrouter": { "keys": [{ "name": "OpenRouter API Key", "value": "env.OPENROUTER_API_KEY", "weight": 1 }], "network_config": { "default_request_timeout_in_seconds": 300 } @@ -223,6 +227,16 @@ cat > "$CONFIG_FILE" << 'CONFIGEOF' } CONFIGEOF +# The heredoc above is single-quoted, which is correct for `env.XXX` strings +# (those are resolved by Bifrost at runtime, not by the shell). The Cloudflare +# base_url is the one exception because it's a plain string field that needs +# the runtime account id substituted in. Do that here so it doesn't matter +# whether the heredoc is single- or double-quoted. +if [ -n "${CLOUDFLARE_ACCOUNT_ID:-}" ]; then + # Use a non-/ delimiter for sed so the URL's slashes don't need escaping. + sed -i.bak "s|\$CLOUDFLARE_ACCOUNT_ID|${CLOUDFLARE_ACCOUNT_ID}|g" "$CONFIG_FILE" && rm -f "$CONFIG_FILE.bak" +fi + echo "Config file created at: $CONFIG_FILE" # Run the Bifrost container connected to the docker-compose network @@ -249,6 +263,8 @@ docker run -d \ -e GROQ_API_KEY="${GROQ_API_KEY:-}" \ -e PERPLEXITY_API_KEY="${PERPLEXITY_API_KEY:-}" \ -e CEREBRAS_API_KEY="${CEREBRAS_API_KEY:-}" \ + -e CLOUDFLARE_API_KEY="${CLOUDFLARE_API_KEY:-}" \ + -e CLOUDFLARE_ACCOUNT_ID="${CLOUDFLARE_ACCOUNT_ID:-}" \ -e OPENROUTER_API_KEY="${OPENROUTER_API_KEY:-}" \ -e PARASAIL_API_KEY="${PARASAIL_API_KEY:-}" \ -e AZURE_API_KEY="${AZURE_API_KEY:-}" \ diff --git a/.github/workflows/snyk.yml b/.github/workflows/snyk.yml index 7721b7b02c8..639f0a2c7be 100644 --- a/.github/workflows/snyk.yml +++ b/.github/workflows/snyk.yml @@ -47,7 +47,7 @@ jobs: node-version: "25" - name: Install uv - uses: step-security/setup-uv@ccf0a26ce9117d9e99292b0ce953ea5d9ffe778e # v7.3.0 + uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0 with: version: "0.11.0" python-version: "3.11" @@ -120,7 +120,7 @@ jobs: node-version: "25" - name: Install uv - uses: step-security/setup-uv@ccf0a26ce9117d9e99292b0ce953ea5d9ffe778e # v7.3.0 + uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0 with: version: "0.11.0" python-version: "3.11" diff --git a/core/bifrost.go b/core/bifrost.go index 1bec37ac470..449074bf12b 100644 --- a/core/bifrost.go +++ b/core/bifrost.go @@ -26,6 +26,7 @@ import ( "github.com/maximhq/bifrost/core/providers/bedrock" "github.com/maximhq/bifrost/core/providers/bedrockmantle" "github.com/maximhq/bifrost/core/providers/cerebras" + "github.com/maximhq/bifrost/core/providers/cloudflare" "github.com/maximhq/bifrost/core/providers/cohere" "github.com/maximhq/bifrost/core/providers/databricks" "github.com/maximhq/bifrost/core/providers/deepseek" @@ -4553,6 +4554,8 @@ func (bifrost *Bifrost) createBaseProvider(providerKey schemas.ModelProvider, co return sarvam.NewSarvamProvider(config, bifrost.logger) case schemas.Databricks: return databricks.NewDatabricksProvider(config, bifrost.logger) + case schemas.Cloudflare: + return cloudflare.NewCloudflareProvider(config, bifrost.logger) default: return nil, fmt.Errorf("unsupported provider: %s", targetProviderKey) } diff --git a/core/internal/llmtests/account.go b/core/internal/llmtests/account.go index 5f074b8c8c5..72a3f2b392a 100644 --- a/core/internal/llmtests/account.go +++ b/core/internal/llmtests/account.go @@ -196,6 +196,7 @@ func (account *ComprehensiveTestAccount) GetConfiguredProviders() ([]schemas.Mod schemas.Wafer, schemas.Databricks, schemas.GithubCopilot, + schemas.Cloudflare, ProviderOpenAICustom, }, nil } @@ -527,6 +528,15 @@ func (account *ComprehensiveTestAccount) GetKeysForProvider(ctx context.Context, }, }, }, nil + case schemas.Cloudflare: + return []schemas.Key{ + { + Value: *schemas.NewSecretVar("env.CLOUDFLARE_API_KEY"), + Models: []string{"*"}, + Weight: 1.0, + UseForBatchAPI: bifrost.Ptr(true), + }, + }, nil case schemas.Gemini: return []schemas.Key{ { @@ -944,6 +954,28 @@ func (account *ComprehensiveTestAccount) GetConfigForProvider(providerKey schema BufferSize: 10, }, }, nil + case schemas.Cloudflare: + // Workers AI's OpenAI-compat URL embeds the account id, so the test + // account composes BaseURL from CLOUDFLARE_ACCOUNT_ID. The provider + // keeps the base URL at `/ai` and appends `/v1/...` per request, so + // the trailing `/v1` is intentionally NOT included here — adding it + // would produce `…/ai/v1/v1/chat/completions` and 404 every call. + // When the env var is unset, NewCloudflareProvider returns an error + // and the gated TestCloudflare in cloudflare_test.go skips before + // reaching here. + return &schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + BaseURL: fmt.Sprintf("https://api.cloudflare.com/client/v4/accounts/%s/ai", os.Getenv("CLOUDFLARE_ACCOUNT_ID")), + DefaultRequestTimeoutInSeconds: 120, + MaxRetries: 10, + RetryBackoffInitial: 5 * time.Second, + RetryBackoffMax: 3 * time.Minute, + }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: Concurrency, + BufferSize: 10, + }, + }, nil case schemas.VLLM: return &schemas.ProviderConfig{ NetworkConfig: schemas.NetworkConfig{ diff --git a/core/providers/cloudflare/cachedcontents.go b/core/providers/cloudflare/cachedcontents.go new file mode 100644 index 00000000000..b25b2323d58 --- /dev/null +++ b/core/providers/cloudflare/cachedcontents.go @@ -0,0 +1,34 @@ +package cloudflare + +import ( + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + "github.com/maximhq/bifrost/core/schemas" +) + +// CachedContentCreate is unsupported on CloudflareProvider. Only Gemini and Vertex AI +// implement the cached-content lifecycle (Google AI Studio + Vertex AI named +// caches). Other providers either lack named cache management entirely or +// handle caching implicitly via per-message cache_control markers. +func (provider *CloudflareProvider) CachedContentCreate(ctx *schemas.BifrostContext, key schemas.Key, request *schemas.BifrostCachedContentCreateRequest) (*schemas.BifrostCachedContentCreateResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.CachedContentCreateRequest, provider.GetProviderKey()) +} + +// CachedContentList is unsupported on CloudflareProvider (see CachedContentCreate). +func (provider *CloudflareProvider) CachedContentList(ctx *schemas.BifrostContext, keys []schemas.Key, request *schemas.BifrostCachedContentListRequest) (*schemas.BifrostCachedContentListResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.CachedContentListRequest, provider.GetProviderKey()) +} + +// CachedContentRetrieve is unsupported on CloudflareProvider (see CachedContentCreate). +func (provider *CloudflareProvider) CachedContentRetrieve(ctx *schemas.BifrostContext, keys []schemas.Key, request *schemas.BifrostCachedContentRetrieveRequest) (*schemas.BifrostCachedContentRetrieveResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.CachedContentRetrieveRequest, provider.GetProviderKey()) +} + +// CachedContentUpdate is unsupported on CloudflareProvider (see CachedContentCreate). +func (provider *CloudflareProvider) CachedContentUpdate(ctx *schemas.BifrostContext, keys []schemas.Key, request *schemas.BifrostCachedContentUpdateRequest) (*schemas.BifrostCachedContentUpdateResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.CachedContentUpdateRequest, provider.GetProviderKey()) +} + +// CachedContentDelete is unsupported on CloudflareProvider (see CachedContentCreate). +func (provider *CloudflareProvider) CachedContentDelete(ctx *schemas.BifrostContext, keys []schemas.Key, request *schemas.BifrostCachedContentDeleteRequest) (*schemas.BifrostCachedContentDeleteResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.CachedContentDeleteRequest, provider.GetProviderKey()) +} diff --git a/core/providers/cloudflare/cloudflare.go b/core/providers/cloudflare/cloudflare.go new file mode 100644 index 00000000000..b4f9c99806a --- /dev/null +++ b/core/providers/cloudflare/cloudflare.go @@ -0,0 +1,514 @@ +// Package cloudflare implements the Cloudflare Workers AI provider. +// +// Workers AI exposes an OpenAI-compatible surface for chat completions and +// embeddings under the per-account path +// +// https://api.cloudflare.com/client/v4/accounts/{account_id}/ai +// +// so a caller MUST supply that fully-qualified URL via NetworkConfig.BaseURL — +// there is no global default that omits the account id. The provider appends +// `/v1/chat/completions` and `/v1/embeddings` for inference requests. Model +// discovery uses Cloudflare's native `/models/search` endpoint, which shares +// the same account-scoped base URL. The trailing `/v1` is intentionally NOT +// part of the base URL. +// +// See: https://developers.cloudflare.com/workers-ai/configuration/open-ai-compatibility/ +package cloudflare + +import ( + "context" + "fmt" + "maps" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/maximhq/bifrost/core/providers/openai" + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + schemas "github.com/maximhq/bifrost/core/schemas" + "github.com/valyala/fasthttp" +) + +// CloudflareProvider implements the Provider interface for Cloudflare Workers AI. +type CloudflareProvider struct { + logger schemas.Logger // Logger for provider operations + client *fasthttp.Client // HTTP client for unary API requests (ReadTimeout bounds overall response) + streamingClient *fasthttp.Client // HTTP client for streaming API requests (no ReadTimeout; idle governed by NewIdleTimeoutReader) + networkConfig schemas.NetworkConfig // Network configuration including extra headers + sendBackRawRequest bool // Whether to include raw request in BifrostResponse + sendBackRawResponse bool // Whether to include raw response in BifrostResponse +} + +// NewCloudflareProvider creates a new Cloudflare Workers AI provider instance. +// +// Workers AI's OpenAI-compatible base URL embeds the account id, so the caller +// MUST provide the full URL (e.g. +// "https://api.cloudflare.com/client/v4/accounts//ai") via +// NetworkConfig.BaseURL. Construction fails if it is empty. +func NewCloudflareProvider(config *schemas.ProviderConfig, logger schemas.Logger) (*CloudflareProvider, error) { + config.CheckAndSetDefaults() + + // Normalise the user-supplied URL once so that surrounding whitespace + // can't sneak past the empty check and end up in request URLs. + baseURL := strings.TrimSpace(config.NetworkConfig.BaseURL) + if baseURL == "" { + return nil, fmt.Errorf("network_config.base_url is required for cloudflare; set it to https://api.cloudflare.com/client/v4/accounts//ai") + } + + requestTimeout := time.Second * time.Duration(config.NetworkConfig.DefaultRequestTimeoutInSeconds) + client := &fasthttp.Client{ + ReadTimeout: requestTimeout, + WriteTimeout: requestTimeout, + MaxConnsPerHost: config.NetworkConfig.MaxConnsPerHost, + MaxIdleConnDuration: 30 * time.Second, + MaxConnWaitTimeout: requestTimeout, + MaxConnDuration: time.Second * time.Duration(schemas.DefaultMaxConnDurationInSeconds), + ConnPoolStrategy: fasthttp.FIFO, + } + + // Configure proxy, dialer and TLS to match the rest of the OpenAI-compat + // providers (Cerebras, Groq, etc.). Streaming uses a sibling client with no + // ReadTimeout so long SSE responses are governed by the per-stream idle + // timeout instead of the overall request timeout. + client = providerUtils.ConfigureProxy(client, config.ProxyConfig, logger) + client = providerUtils.ConfigureDialer(client, config.NetworkConfig.AllowPrivateNetwork) + client = providerUtils.ConfigureTLS(client, config.NetworkConfig, logger) + streamingClient := providerUtils.BuildStreamingClient(client) + + config.NetworkConfig.BaseURL = strings.TrimRight(baseURL, "/") + networkConfig := config.NetworkConfig + if networkConfig.ExtraHeaders != nil { + networkConfig.ExtraHeaders = maps.Clone(networkConfig.ExtraHeaders) + } + + return &CloudflareProvider{ + logger: logger, + client: client, + streamingClient: streamingClient, + networkConfig: networkConfig, + sendBackRawRequest: config.SendBackRawRequest, + sendBackRawResponse: config.SendBackRawResponse, + }, nil +} + +// GetProviderKey returns the provider identifier for Cloudflare. +func (provider *CloudflareProvider) GetProviderKey() schemas.ModelProvider { + return schemas.Cloudflare +} + +// listModelsByKey fetches and converts Cloudflare's account-scoped model catalog. +func (provider *CloudflareProvider) listModelsByKey(ctx *schemas.BifrostContext, key schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + const perPage = 50 + + cloudflareResponse := CloudflareListModelsResponse{ + Result: make([]CloudflareModel, 0), + } + var totalLatency time.Duration + var rawResponses []interface{} + + for page := 1; page <= schemas.MaxPaginationRequests; page++ { + endpoint, err := url.Parse(provider.networkConfig.BaseURL + providerUtils.GetPathFromContext(ctx, "/models/search")) + if err != nil { + return nil, &schemas.BifrostError{ + IsBifrostError: true, + Error: &schemas.ErrorField{ + Message: fmt.Sprintf("invalid Cloudflare models URL: %v", err), + }, + } + } + query := endpoint.Query() + query.Set("page", strconv.Itoa(page)) + query.Set("per_page", strconv.Itoa(perPage)) + endpoint.RawQuery = query.Encode() + + req := fasthttp.AcquireRequest() + resp := fasthttp.AcquireResponse() + providerUtils.SetExtraHeaders(ctx, req, provider.networkConfig.ExtraHeaders, nil) + req.SetRequestURI(endpoint.String()) + req.Header.SetMethod(http.MethodGet) + req.Header.SetContentType("application/json") + if key.Value.GetValue() != "" { + req.Header.Set("Authorization", "Bearer "+key.Value.GetValue()) + } + + latency, bifrostErr, wait := providerUtils.MakeRequestWithContext(ctx, provider.client, req, resp) + wait() + totalLatency += latency + if bifrostErr != nil { + fasthttp.ReleaseRequest(req) + fasthttp.ReleaseResponse(resp) + return nil, bifrostErr + } + + if resp.StatusCode() != fasthttp.StatusOK { + bifrostErr = providerUtils.SetErrorLatency(ParseCloudflareError(resp), totalLatency) + fasthttp.ReleaseRequest(req) + fasthttp.ReleaseResponse(resp) + return nil, bifrostErr + } + + responseBody := append([]byte(nil), resp.Body()...) + fasthttp.ReleaseRequest(req) + fasthttp.ReleaseResponse(resp) + + var pageResponse CloudflareListModelsResponse + _, rawResponse, bifrostErr := providerUtils.HandleProviderResponse( + responseBody, + &pageResponse, + nil, + false, + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + ) + if bifrostErr != nil { + return nil, providerUtils.SetErrorLatency(bifrostErr, totalLatency) + } + if !pageResponse.Success { + return nil, providerUtils.SetErrorLatency(pageResponse.ToBifrostError(), totalLatency) + } + + cloudflareResponse.Result = append(cloudflareResponse.Result, pageResponse.Result...) + if rawResponse != nil { + rawResponses = append(rawResponses, rawResponse) + } + + if pageResponse.ResultInfo != nil && pageResponse.ResultInfo.TotalPages > 0 { + if page >= pageResponse.ResultInfo.TotalPages { + break + } + } else if len(pageResponse.Result) < perPage { + break + } + } + + response := cloudflareResponse.ToBifrostListModelsResponse( + provider.GetProviderKey(), + key.Models, + key.BlacklistedModels, + key.Aliases, + request.Unfiltered, + ) + response.ExtraFields.Latency = totalLatency.Milliseconds() + if len(rawResponses) == 1 { + response.ExtraFields.RawResponse = rawResponses[0] + } else if len(rawResponses) > 1 { + response.ExtraFields.RawResponse = rawResponses + } + + return response, nil +} + +// ListModels performs model discovery through Cloudflare's native Models Search API. +func (provider *CloudflareProvider) ListModels(ctx *schemas.BifrostContext, keys []schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + return providerUtils.HandleMultipleListModelsRequests(ctx, keys, request, provider.listModelsByKey) +} + +// ChatCompletion performs a chat completion request to Cloudflare Workers AI. +func (provider *CloudflareProvider) ChatCompletion(ctx *schemas.BifrostContext, key schemas.Key, request *schemas.BifrostChatRequest) (*schemas.BifrostChatResponse, *schemas.BifrostError) { + return openai.HandleOpenAIChatCompletionRequest( + ctx, + provider.client, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/chat/completions"), + request, + openai.BearerAuthHeader(key), + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + nil, + nil, + nil, + provider.logger, + ) +} + +// ChatCompletionStream performs a streaming chat completion request to +// Cloudflare Workers AI using the OpenAI-compatible SSE format. +func (provider *CloudflareProvider) ChatCompletionStream(ctx *schemas.BifrostContext, postHookRunner schemas.PostHookRunner, postHookSpanFinalizer func(context.Context), key schemas.Key, request *schemas.BifrostChatRequest) (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) { + return openai.HandleOpenAIChatCompletionStreaming( + ctx, + provider.streamingClient, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/chat/completions"), + request, + openai.BearerAuthHeader(key), + provider.networkConfig.ExtraHeaders, + provider.networkConfig.StreamIdleTimeoutInSeconds, + providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + postHookRunner, + nil, + nil, + nil, + nil, + nil, + nil, + provider.logger, + postHookSpanFinalizer, + ) +} + +// Embedding performs an embedding request to Cloudflare's OpenAI-compatible +// /v1/embeddings endpoint. +func (provider *CloudflareProvider) Embedding(ctx *schemas.BifrostContext, key schemas.Key, request *schemas.BifrostEmbeddingRequest) (*schemas.BifrostEmbeddingResponse, *schemas.BifrostError) { + return openai.HandleOpenAIEmbeddingRequest( + ctx, + provider.client, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/embeddings"), + request, + openai.BearerAuthHeader(key), + provider.networkConfig.ExtraHeaders, + provider.GetProviderKey(), + providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + nil, + nil, + provider.logger, + ) +} + +// Responses delegates to ChatCompletion and reshapes the result into the +// Responses API envelope, mirroring the pattern other OpenAI-compat providers +// use when the upstream lacks a native /v1/responses endpoint. +func (provider *CloudflareProvider) Responses(ctx *schemas.BifrostContext, key schemas.Key, request *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + chatResponse, err := provider.ChatCompletion(ctx, key, request.ToChatRequest()) + if err != nil { + return nil, err + } + return chatResponse.ToBifrostResponsesResponse(), nil +} + +// ResponsesStream delegates to ChatCompletionStream for the same reason as +// Responses. +func (provider *CloudflareProvider) ResponsesStream(ctx *schemas.BifrostContext, postHookRunner schemas.PostHookRunner, postHookSpanFinalizer func(context.Context), key schemas.Key, request *schemas.BifrostResponsesRequest) (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) { + ctx.SetValue(schemas.BifrostContextKeyIsResponsesToChatCompletionFallback, true) + return provider.ChatCompletionStream( + ctx, + postHookRunner, + postHookSpanFinalizer, + key, + request.ToChatRequest(), + ) +} + +// TextCompletion is not supported by Cloudflare's OpenAI-compatible surface; +// only chat-style /v1/chat/completions is exposed. +func (provider *CloudflareProvider) TextCompletion(ctx *schemas.BifrostContext, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (*schemas.BifrostTextCompletionResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TextCompletionRequest, provider.GetProviderKey()) +} + +// TextCompletionStream is not supported by the Cloudflare provider (see TextCompletion). +func (provider *CloudflareProvider) TextCompletionStream(ctx *schemas.BifrostContext, postHookRunner schemas.PostHookRunner, postHookSpanFinalizer func(context.Context), key schemas.Key, request *schemas.BifrostTextCompletionRequest) (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TextCompletionStreamRequest, provider.GetProviderKey()) +} + +// Speech is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) Speech(ctx *schemas.BifrostContext, key schemas.Key, request *schemas.BifrostSpeechRequest) (*schemas.BifrostSpeechResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechRequest, provider.GetProviderKey()) +} + +// SpeechStream is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) SpeechStream(ctx *schemas.BifrostContext, postHookRunner schemas.PostHookRunner, postHookSpanFinalizer func(context.Context), key schemas.Key, request *schemas.BifrostSpeechRequest) (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.SpeechStreamRequest, provider.GetProviderKey()) +} + +// Transcription is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) Transcription(ctx *schemas.BifrostContext, key schemas.Key, request *schemas.BifrostTranscriptionRequest) (*schemas.BifrostTranscriptionResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionRequest, provider.GetProviderKey()) +} + +// TranscriptionStream is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) TranscriptionStream(ctx *schemas.BifrostContext, postHookRunner schemas.PostHookRunner, postHookSpanFinalizer func(context.Context), key schemas.Key, request *schemas.BifrostTranscriptionRequest) (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.TranscriptionStreamRequest, provider.GetProviderKey()) +} + +// Rerank is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) Rerank(ctx *schemas.BifrostContext, key schemas.Key, request *schemas.BifrostRerankRequest) (*schemas.BifrostRerankResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.RerankRequest, provider.GetProviderKey()) +} + +// OCR is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) OCR(ctx *schemas.BifrostContext, key schemas.Key, request *schemas.BifrostOCRRequest) (*schemas.BifrostOCRResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.OCRRequest, provider.GetProviderKey()) +} + +// ImageGeneration is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) ImageGeneration(ctx *schemas.BifrostContext, key schemas.Key, request *schemas.BifrostImageGenerationRequest) (*schemas.BifrostImageGenerationResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.ImageGenerationRequest, provider.GetProviderKey()) +} + +// ImageGenerationStream is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) ImageGenerationStream(ctx *schemas.BifrostContext, postHookRunner schemas.PostHookRunner, postHookSpanFinalizer func(context.Context), key schemas.Key, request *schemas.BifrostImageGenerationRequest) (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.ImageGenerationStreamRequest, provider.GetProviderKey()) +} + +// ImageEdit is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) ImageEdit(ctx *schemas.BifrostContext, key schemas.Key, request *schemas.BifrostImageEditRequest) (*schemas.BifrostImageGenerationResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.ImageEditRequest, provider.GetProviderKey()) +} + +// ImageEditStream is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) ImageEditStream(ctx *schemas.BifrostContext, postHookRunner schemas.PostHookRunner, postHookSpanFinalizer func(context.Context), key schemas.Key, request *schemas.BifrostImageEditRequest) (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.ImageEditStreamRequest, provider.GetProviderKey()) +} + +// ImageVariation is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) ImageVariation(ctx *schemas.BifrostContext, key schemas.Key, request *schemas.BifrostImageVariationRequest) (*schemas.BifrostImageGenerationResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.ImageVariationRequest, provider.GetProviderKey()) +} + +// VideoGeneration is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) VideoGeneration(_ *schemas.BifrostContext, _ schemas.Key, _ *schemas.BifrostVideoGenerationRequest) (*schemas.BifrostVideoGenerationResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.VideoGenerationRequest, provider.GetProviderKey()) +} + +// VideoEdit is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) VideoEdit(_ *schemas.BifrostContext, _ schemas.Key, _ *schemas.BifrostVideoEditRequest) (*schemas.BifrostVideoEditResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.VideoEditRequest, provider.GetProviderKey()) +} + +// VideoRetrieve is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) VideoRetrieve(_ *schemas.BifrostContext, _ schemas.Key, _ *schemas.BifrostVideoRetrieveRequest) (*schemas.BifrostVideoGenerationResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.VideoRetrieveRequest, provider.GetProviderKey()) +} + +// VideoDownload is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) VideoDownload(_ *schemas.BifrostContext, _ schemas.Key, _ *schemas.BifrostVideoDownloadRequest) (*schemas.BifrostVideoDownloadResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.VideoDownloadRequest, provider.GetProviderKey()) +} + +// VideoDelete is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) VideoDelete(_ *schemas.BifrostContext, _ schemas.Key, _ *schemas.BifrostVideoDeleteRequest) (*schemas.BifrostVideoDeleteResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.VideoDeleteRequest, provider.GetProviderKey()) +} + +// VideoList is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) VideoList(_ *schemas.BifrostContext, _ schemas.Key, _ *schemas.BifrostVideoListRequest) (*schemas.BifrostVideoListResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.VideoListRequest, provider.GetProviderKey()) +} + +// VideoRemix is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) VideoRemix(_ *schemas.BifrostContext, _ schemas.Key, _ *schemas.BifrostVideoRemixRequest) (*schemas.BifrostVideoGenerationResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.VideoRemixRequest, provider.GetProviderKey()) +} + +// FileUpload is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) FileUpload(_ *schemas.BifrostContext, _ schemas.Key, _ *schemas.BifrostFileUploadRequest) (*schemas.BifrostFileUploadResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.FileUploadRequest, provider.GetProviderKey()) +} + +// FileList is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) FileList(_ *schemas.BifrostContext, _ []schemas.Key, _ *schemas.BifrostFileListRequest) (*schemas.BifrostFileListResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.FileListRequest, provider.GetProviderKey()) +} + +// FileRetrieve is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) FileRetrieve(_ *schemas.BifrostContext, _ []schemas.Key, _ *schemas.BifrostFileRetrieveRequest) (*schemas.BifrostFileRetrieveResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.FileRetrieveRequest, provider.GetProviderKey()) +} + +// FileDelete is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) FileDelete(_ *schemas.BifrostContext, _ []schemas.Key, _ *schemas.BifrostFileDeleteRequest) (*schemas.BifrostFileDeleteResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.FileDeleteRequest, provider.GetProviderKey()) +} + +// FileContent is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) FileContent(_ *schemas.BifrostContext, _ []schemas.Key, _ *schemas.BifrostFileContentRequest) (*schemas.BifrostFileContentResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.FileContentRequest, provider.GetProviderKey()) +} + +// BatchCreate is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) BatchCreate(_ *schemas.BifrostContext, _ schemas.Key, _ *schemas.BifrostBatchCreateRequest) (*schemas.BifrostBatchCreateResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.BatchCreateRequest, provider.GetProviderKey()) +} + +// BatchList is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) BatchList(_ *schemas.BifrostContext, _ []schemas.Key, _ *schemas.BifrostBatchListRequest) (*schemas.BifrostBatchListResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.BatchListRequest, provider.GetProviderKey()) +} + +// BatchRetrieve is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) BatchRetrieve(_ *schemas.BifrostContext, _ []schemas.Key, _ *schemas.BifrostBatchRetrieveRequest) (*schemas.BifrostBatchRetrieveResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.BatchRetrieveRequest, provider.GetProviderKey()) +} + +// BatchCancel is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) BatchCancel(_ *schemas.BifrostContext, _ []schemas.Key, _ *schemas.BifrostBatchCancelRequest) (*schemas.BifrostBatchCancelResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.BatchCancelRequest, provider.GetProviderKey()) +} + +// BatchDelete is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) BatchDelete(_ *schemas.BifrostContext, _ []schemas.Key, _ *schemas.BifrostBatchDeleteRequest) (*schemas.BifrostBatchDeleteResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.BatchDeleteRequest, provider.GetProviderKey()) +} + +// BatchResults is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) BatchResults(_ *schemas.BifrostContext, _ []schemas.Key, _ *schemas.BifrostBatchResultsRequest) (*schemas.BifrostBatchResultsResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.BatchResultsRequest, provider.GetProviderKey()) +} + +// CountTokens is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) CountTokens(_ *schemas.BifrostContext, _ schemas.Key, _ *schemas.BifrostResponsesRequest) (*schemas.BifrostCountTokensResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.CountTokensRequest, provider.GetProviderKey()) +} + +// Compaction is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) Compaction(_ *schemas.BifrostContext, _ schemas.Key, _ *schemas.BifrostCompactionRequest) (*schemas.BifrostCompactionResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.CompactionRequest, provider.GetProviderKey()) +} + +// ContainerCreate is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) ContainerCreate(_ *schemas.BifrostContext, _ schemas.Key, _ *schemas.BifrostContainerCreateRequest) (*schemas.BifrostContainerCreateResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.ContainerCreateRequest, provider.GetProviderKey()) +} + +// ContainerList is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) ContainerList(_ *schemas.BifrostContext, _ []schemas.Key, _ *schemas.BifrostContainerListRequest) (*schemas.BifrostContainerListResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.ContainerListRequest, provider.GetProviderKey()) +} + +// ContainerRetrieve is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) ContainerRetrieve(_ *schemas.BifrostContext, _ []schemas.Key, _ *schemas.BifrostContainerRetrieveRequest) (*schemas.BifrostContainerRetrieveResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.ContainerRetrieveRequest, provider.GetProviderKey()) +} + +// ContainerDelete is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) ContainerDelete(_ *schemas.BifrostContext, _ []schemas.Key, _ *schemas.BifrostContainerDeleteRequest) (*schemas.BifrostContainerDeleteResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.ContainerDeleteRequest, provider.GetProviderKey()) +} + +// ContainerFileCreate is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) ContainerFileCreate(_ *schemas.BifrostContext, _ schemas.Key, _ *schemas.BifrostContainerFileCreateRequest) (*schemas.BifrostContainerFileCreateResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.ContainerFileCreateRequest, provider.GetProviderKey()) +} + +// ContainerFileList is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) ContainerFileList(_ *schemas.BifrostContext, _ []schemas.Key, _ *schemas.BifrostContainerFileListRequest) (*schemas.BifrostContainerFileListResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.ContainerFileListRequest, provider.GetProviderKey()) +} + +// ContainerFileRetrieve is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) ContainerFileRetrieve(_ *schemas.BifrostContext, _ []schemas.Key, _ *schemas.BifrostContainerFileRetrieveRequest) (*schemas.BifrostContainerFileRetrieveResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.ContainerFileRetrieveRequest, provider.GetProviderKey()) +} + +// ContainerFileContent is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) ContainerFileContent(_ *schemas.BifrostContext, _ []schemas.Key, _ *schemas.BifrostContainerFileContentRequest) (*schemas.BifrostContainerFileContentResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.ContainerFileContentRequest, provider.GetProviderKey()) +} + +// ContainerFileDelete is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) ContainerFileDelete(_ *schemas.BifrostContext, _ []schemas.Key, _ *schemas.BifrostContainerFileDeleteRequest) (*schemas.BifrostContainerFileDeleteResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.ContainerFileDeleteRequest, provider.GetProviderKey()) +} + +// Passthrough is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) Passthrough(_ *schemas.BifrostContext, _ schemas.Key, _ *schemas.BifrostPassthroughRequest) (*schemas.BifrostPassthroughResponse, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.PassthroughRequest, provider.GetProviderKey()) +} + +// PassthroughStream is not supported by the Cloudflare provider. +func (provider *CloudflareProvider) PassthroughStream(_ *schemas.BifrostContext, _ schemas.PostHookRunner, _ func(context.Context), _ schemas.Key, _ *schemas.BifrostPassthroughRequest) (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) { + return nil, providerUtils.NewUnsupportedOperationError(schemas.PassthroughStreamRequest, provider.GetProviderKey()) +} diff --git a/core/providers/cloudflare/cloudflare_test.go b/core/providers/cloudflare/cloudflare_test.go new file mode 100644 index 00000000000..cef32663dd7 --- /dev/null +++ b/core/providers/cloudflare/cloudflare_test.go @@ -0,0 +1,231 @@ +package cloudflare_test + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "sync/atomic" + "testing" + + "github.com/maximhq/bifrost/core/internal/llmtests" + "github.com/maximhq/bifrost/core/providers/cloudflare" + "github.com/maximhq/bifrost/core/schemas" +) + +// TestCloudflare runs the comprehensive provider test suite against Cloudflare +// Workers AI. Skips when CLOUDFLARE_API_KEY or CLOUDFLARE_ACCOUNT_ID is not +// set so CI can still pass without those secrets configured. +func TestCloudflare(t *testing.T) { + t.Parallel() + if strings.TrimSpace(os.Getenv("CLOUDFLARE_API_KEY")) == "" { + t.Skip("Skipping Cloudflare tests because CLOUDFLARE_API_KEY is not set") + } + if strings.TrimSpace(os.Getenv("CLOUDFLARE_ACCOUNT_ID")) == "" { + t.Skip("Skipping Cloudflare tests because CLOUDFLARE_ACCOUNT_ID is not set") + } + + client, ctx, cancel, err := llmtests.SetupTest() + if err != nil { + t.Fatalf("Error initializing test setup: %v", err) + } + defer cancel() + defer client.Shutdown() + + testConfig := llmtests.ComprehensiveTestConfig{ + Provider: schemas.Cloudflare, + ChatModel: "@cf/meta/llama-3.1-8b-instruct", + Fallbacks: []schemas.Fallback{ + {Provider: schemas.Cloudflare, Model: "@cf/meta/llama-3.1-8b-instruct"}, + }, + EmbeddingModel: "@cf/baai/bge-large-en-v1.5", + Scenarios: llmtests.TestScenarios{ + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: false, // not all Workers AI models support tools; keep narrow for first cut + ToolCallsStreaming: false, + TextCompletion: false, // /v1/completions is not part of the Workers AI OpenAI-compat surface + TextCompletionStream: false, + ImageURL: false, + ImageBase64: false, + Embedding: true, + ListModels: false, // Workers AI lists per-account; defer until we add an account-scoped fixture + }, + } + + t.Run("CloudflareTests", func(t *testing.T) { + llmtests.RunAllComprehensiveTests(t, client, ctx, testConfig) + }) +} + +// TestCloudflareRequiresBaseURL exercises the constructor's contract that +// Cloudflare's OpenAI-compat surface needs the per-account URL because there +// is no global default that omits the account id. +func TestCloudflareRequiresBaseURL(t *testing.T) { + t.Parallel() + + // No NetworkConfig.BaseURL set → must error. + provider, err := cloudflare.NewCloudflareProvider(&schemas.ProviderConfig{}, nil) + if err == nil { + t.Fatalf("expected error when base URL is empty, got provider=%v", provider) + } + if !strings.Contains(err.Error(), "base_url") { + t.Fatalf("expected base_url error message, got %q", err.Error()) + } + + // Whitespace-only BaseURL is treated identically to empty. + provider, err = cloudflare.NewCloudflareProvider(&schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{BaseURL: " "}, + }, nil) + if err == nil { + t.Fatalf("expected error when base URL is whitespace, got provider=%v", provider) + } + + // A real-looking URL succeeds; trailing slash is normalized away. + provider, err = cloudflare.NewCloudflareProvider(&schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + BaseURL: "https://api.cloudflare.com/client/v4/accounts/abc123/ai/", + }, + }, nil) + if err != nil { + t.Fatalf("unexpected error with valid base URL: %v", err) + } + if provider == nil { + t.Fatal("expected non-nil provider") + } + if provider.GetProviderKey() != schemas.Cloudflare { + t.Fatalf("expected provider key %q, got %q", schemas.Cloudflare, provider.GetProviderKey()) + } + + // Surrounding whitespace is also normalised — must not survive into the + // stored config (would otherwise produce malformed request URLs). + provider, err = cloudflare.NewCloudflareProvider(&schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + BaseURL: " https://api.cloudflare.com/client/v4/accounts/abc123/ai/ ", + }, + }, nil) + if err != nil { + t.Fatalf("unexpected error with whitespace-padded base URL: %v", err) + } + if provider == nil { + t.Fatal("expected non-nil provider for whitespace-padded base URL") + } +} + +// TestCloudflareListModels verifies the native model-search route, pagination, +// authentication, and conversion of Cloudflare model metadata. +func TestCloudflareListModels(t *testing.T) { + t.Parallel() + + var requestCount atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET request, got %s", r.Method) + } + if r.URL.Path != "/ai/models/search" { + t.Errorf("expected /ai/models/search, got %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer test-token" { + t.Errorf("expected bearer authorization header, got %q", r.Header.Get("Authorization")) + } + if r.URL.Query().Get("per_page") != "50" { + t.Errorf("expected per_page=50, got %q", r.URL.Query().Get("per_page")) + } + + page, err := strconv.Atoi(r.URL.Query().Get("page")) + if err != nil || page < 1 || page > 2 { + t.Errorf("unexpected page query %q", r.URL.Query().Get("page")) + w.WriteHeader(http.StatusBadRequest) + return + } + requestCount.Add(1) + + modelName := "@cf/meta/llama-3.1-8b-instruct" + if page == 2 { + modelName = "@cf/baai/bge-large-en-v1.5" + } + response := cloudflare.CloudflareListModelsResponse{ + Success: true, + Result: []cloudflare.CloudflareModel{ + { + ID: "model-" + strconv.Itoa(page), + Name: modelName, + Description: "Workers AI model", + Properties: []cloudflare.CloudflareModelProperty{ + {PropertyID: "context_window", Value: json.RawMessage(`"7968"`)}, + }, + }, + }, + ResultInfo: &cloudflare.CloudflareResultInfo{ + Page: page, + PerPage: 50, + TotalCount: 2, + TotalPages: 2, + }, + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + t.Errorf("failed to encode response: %v", err) + } + })) + defer server.Close() + + provider, err := cloudflare.NewCloudflareProvider(&schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + BaseURL: server.URL + "/ai", + AllowPrivateNetwork: true, + }, + }, nil) + if err != nil { + t.Fatalf("failed to create provider: %v", err) + } + + ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + key := schemas.Key{ + ID: "key-1", + Value: *schemas.NewSecretVar("test-token"), + Models: schemas.WhiteList{"*"}, + } + response, bifrostErr := provider.ListModels(ctx, []schemas.Key{key}, &schemas.BifrostListModelsRequest{ + Provider: schemas.Cloudflare, + Unfiltered: true, + }) + if bifrostErr != nil { + t.Fatalf("list models failed: %v", bifrostErr) + } + if requestCount.Load() != 2 { + t.Fatalf("expected two paginated requests, got %d", requestCount.Load()) + } + if len(response.Data) != 2 { + t.Fatalf("expected two models, got %d", len(response.Data)) + } + var llamaModel *schemas.Model + var bgeModel *schemas.Model + for i := range response.Data { + if response.Data[i].ID == "cloudflare/@cf/meta/llama-3.1-8b-instruct" { + llamaModel = &response.Data[i] + } + if response.Data[i].ID == "cloudflare/@cf/baai/bge-large-en-v1.5" { + bgeModel = &response.Data[i] + } + } + if llamaModel == nil { + t.Fatal("expected converted Llama model") + } + if bgeModel == nil { + t.Fatal("expected converted BGE model from page two") + } + if llamaModel.ContextLength == nil || *llamaModel.ContextLength != 7968 { + t.Errorf("expected context length 7968, got %v", llamaModel.ContextLength) + } + if llamaModel.OwnedBy == nil || *llamaModel.OwnedBy != "meta" { + t.Errorf("expected owner meta, got %v", llamaModel.OwnedBy) + } + if len(response.KeyStatuses) != 1 || response.KeyStatuses[0].Status != schemas.KeyStatusSuccess { + t.Errorf("expected successful key status, got %+v", response.KeyStatuses) + } +} diff --git a/core/providers/cloudflare/errors.go b/core/providers/cloudflare/errors.go new file mode 100644 index 00000000000..37efc643486 --- /dev/null +++ b/core/providers/cloudflare/errors.go @@ -0,0 +1,56 @@ +package cloudflare + +import ( + "fmt" + "strconv" + "strings" + + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + "github.com/maximhq/bifrost/core/schemas" + "github.com/valyala/fasthttp" +) + +// ParseCloudflareError parses Cloudflare's standard API error envelope. +func ParseCloudflareError(resp *fasthttp.Response) *schemas.BifrostError { + var errorResponse CloudflareListModelsResponse + bifrostErr := providerUtils.HandleProviderAPIError(resp, &errorResponse) + if bifrostErr == nil { + return nil + } + return applyCloudflareError(bifrostErr, errorResponse.Errors) +} + +// ToBifrostError converts a successful HTTP response carrying success=false into an error. +func (response *CloudflareListModelsResponse) ToBifrostError() *schemas.BifrostError { + bifrostErr := &schemas.BifrostError{ + IsBifrostError: false, + Error: &schemas.ErrorField{}, + } + if response == nil { + bifrostErr.Error.Message = "Cloudflare model search failed" + return bifrostErr + } + return applyCloudflareError(bifrostErr, response.Errors) +} + +func applyCloudflareError(bifrostErr *schemas.BifrostError, errors []CloudflareAPIError) *schemas.BifrostError { + if bifrostErr.Error == nil { + bifrostErr.Error = &schemas.ErrorField{} + } + if len(errors) > 0 { + if strings.TrimSpace(errors[0].Message) != "" { + bifrostErr.Error.Message = errors[0].Message + } + if errors[0].Code != 0 { + bifrostErr.Error.Code = schemas.Ptr(strconv.Itoa(errors[0].Code)) + } + } + if strings.TrimSpace(bifrostErr.Error.Message) == "" { + if bifrostErr.StatusCode != nil { + bifrostErr.Error.Message = fmt.Sprintf("Cloudflare API error (status %d)", *bifrostErr.StatusCode) + } else { + bifrostErr.Error.Message = "Cloudflare model search failed" + } + } + return bifrostErr +} diff --git a/core/providers/cloudflare/models.go b/core/providers/cloudflare/models.go new file mode 100644 index 00000000000..204f60189cb --- /dev/null +++ b/core/providers/cloudflare/models.go @@ -0,0 +1,98 @@ +package cloudflare + +import ( + "strconv" + "strings" + + "github.com/bytedance/sonic" + providerUtils "github.com/maximhq/bifrost/core/providers/utils" + "github.com/maximhq/bifrost/core/schemas" +) + +// ToBifrostListModelsResponse converts Cloudflare's native model catalog to Bifrost models. +func (response *CloudflareListModelsResponse) ToBifrostListModelsResponse(providerKey schemas.ModelProvider, allowedModels schemas.WhiteList, blacklistedModels schemas.BlackList, aliases schemas.KeyAliases, unfiltered bool) *schemas.BifrostListModelsResponse { + if response == nil { + return nil + } + + bifrostResponse := &schemas.BifrostListModelsResponse{ + Data: make([]schemas.Model, 0, len(response.Result)), + } + pipeline := &providerUtils.ListModelsPipeline{ + AllowedModels: allowedModels, + BlacklistedModels: blacklistedModels, + Aliases: aliases, + Unfiltered: unfiltered, + ProviderKey: providerKey, + MatchFns: providerUtils.DefaultMatchFns(), + } + if pipeline.ShouldEarlyExit() { + return bifrostResponse + } + + included := make(map[string]bool) + for _, model := range response.Result { + if strings.TrimSpace(model.Name) == "" { + continue + } + for _, result := range pipeline.FilterModel(model.Name) { + entry := schemas.Model{ + ID: string(providerKey) + "/" + result.ResolvedID, + Name: schemas.Ptr(model.Name), + Description: schemas.Ptr(model.Description), + ContextLength: model.contextLength(), + OwnedBy: cloudflareModelOwner(model.Name), + ProviderExtra: marshalCloudflareModel(model), + } + if result.AliasValue != "" { + entry.Alias = schemas.Ptr(result.AliasValue) + } + bifrostResponse.Data = append(bifrostResponse.Data, entry) + included[strings.ToLower(result.ResolvedID)] = true + } + } + + bifrostResponse.Data = append(bifrostResponse.Data, pipeline.BackfillModels(included)...) + return bifrostResponse +} + +func (model CloudflareModel) contextLength() *int { + for _, property := range model.Properties { + if property.PropertyID != "context_window" { + continue + } + + var numericValue int + if err := sonic.Unmarshal(property.Value, &numericValue); err == nil && numericValue > 0 { + return schemas.Ptr(numericValue) + } + + var stringValue string + if err := sonic.Unmarshal(property.Value, &stringValue); err == nil { + if parsed, err := strconv.Atoi(stringValue); err == nil && parsed > 0 { + return schemas.Ptr(parsed) + } + } + } + return nil +} + +func cloudflareModelOwner(modelName string) *string { + const prefix = "@cf/" + if !strings.HasPrefix(modelName, prefix) { + return nil + } + owner, _, found := strings.Cut(strings.TrimPrefix(modelName, prefix), "/") + if !found || owner == "" { + return nil + } + return schemas.Ptr(owner) +} + +func marshalCloudflareModel(model CloudflareModel) []byte { + raw, err := sonic.Marshal(model) + if err != nil { + return nil + } + return raw +} diff --git a/core/providers/cloudflare/types.go b/core/providers/cloudflare/types.go new file mode 100644 index 00000000000..454ef92342f --- /dev/null +++ b/core/providers/cloudflare/types.go @@ -0,0 +1,50 @@ +package cloudflare + +import "encoding/json" + +// CloudflareListModelsResponse is Cloudflare's standard API envelope for model search. +type CloudflareListModelsResponse struct { + Success bool `json:"success"` + Errors []CloudflareAPIError `json:"errors"` + Messages []json.RawMessage `json:"messages"` + Result []CloudflareModel `json:"result"` + ResultInfo *CloudflareResultInfo `json:"result_info,omitempty"` +} + +// CloudflareAPIError is an error entry in Cloudflare's API envelope. +type CloudflareAPIError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +// CloudflareResultInfo describes the pagination state returned by the API. +type CloudflareResultInfo struct { + Page int `json:"page"` + PerPage int `json:"per_page"` + Count int `json:"count"` + TotalCount int `json:"total_count"` + TotalPages int `json:"total_pages"` +} + +// CloudflareModel is the subset of Workers AI model metadata surfaced by Bifrost. +type CloudflareModel struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Task CloudflareModelTask `json:"task"` + CreatedAt string `json:"created_at"` + Properties []CloudflareModelProperty `json:"properties"` +} + +// CloudflareModelTask identifies the task family associated with a model. +type CloudflareModelTask struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +// CloudflareModelProperty is a provider-defined model metadata field. +type CloudflareModelProperty struct { + PropertyID string `json:"property_id"` + Value json.RawMessage `json:"value"` +} diff --git a/core/schemas/bifrost.go b/core/schemas/bifrost.go index 72756da091b..f61100beb8c 100644 --- a/core/schemas/bifrost.go +++ b/core/schemas/bifrost.go @@ -75,6 +75,7 @@ const ( Wafer ModelProvider = "wafer" GithubCopilot ModelProvider = "github-copilot" Databricks ModelProvider = "databricks" + Cloudflare ModelProvider = "cloudflare" ) // SupportedBaseProviders is the list of base providers allowed for custom providers. @@ -86,6 +87,7 @@ var SupportedBaseProviders = []ModelProvider{ OpenAI, HuggingFace, Replicate, + Cloudflare, } // StandardProviders is the list of all built-in (non-custom) providers. @@ -122,6 +124,7 @@ var StandardProviders = []ModelProvider{ Wafer, GithubCopilot, Databricks, + Cloudflare, } // RequestType represents the type of request being made to a provider. diff --git a/docs/docs.json b/docs/docs.json index b6fdeb8230a..22cb048741f 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -143,6 +143,7 @@ "providers/supported-providers/bedrock", "providers/supported-providers/bedrock-mantle", "providers/supported-providers/cerebras", + "providers/supported-providers/cloudflare", "providers/supported-providers/cohere", "providers/supported-providers/databricks", "providers/supported-providers/deepseek", diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index 510e9257c07..1035d630a33 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -93604,6 +93604,7 @@ "bedrock", "bedrock_mantle", "cerebras", + "cloudflare", "cohere", "deepseek", "gemini", diff --git a/docs/providers/supported-providers/cloudflare.mdx b/docs/providers/supported-providers/cloudflare.mdx new file mode 100644 index 00000000000..e6f559cce01 --- /dev/null +++ b/docs/providers/supported-providers/cloudflare.mdx @@ -0,0 +1,167 @@ +--- +title: "Cloudflare Workers AI" +description: "Cloudflare Workers AI conversion guide - OpenAI-compatible format, chat completions, embeddings, streaming, and per-account base URL" +icon: "c" +--- + +## Overview + +Cloudflare Workers AI is an **OpenAI-compatible provider** that exposes a subset of the OpenAI surface (chat completions and embeddings) under a per-account base URL. Bifrost delegates all functionality to the shared OpenAI provider implementation. Key characteristics: + +- **OpenAI-compatible chat** - `/v1/chat/completions` accepts the standard OpenAI request shape and returns the standard response shape. +- **Streaming support** - Server-Sent Events for chat completions, parsed by the shared OpenAI streaming handler. +- **Embeddings** - `/v1/embeddings` is supported via the shared OpenAI embedding handler. +- **Per-account base URL** - Workers AI's URL embeds the account id, so `network_config.base_url` is required at provider construction. + +### Supported Operations + +| Operation | Non-Streaming | Streaming | Endpoint | +|-----------|---------------|-----------|----------| +| Chat Completions | ✅ | ✅ | `/v1/chat/completions` | +| Responses API | ✅ | ✅ | `/v1/chat/completions` (converted internally) | +| Embeddings | ✅ | ❌ | `/v1/embeddings` | +| List Models | ✅ | - | `/v1/models` | +| Text Completions | ❌ | ❌ | - | +| Image Generation | ❌ | ❌ | - | +| Speech (TTS) | ❌ | ❌ | - | +| Transcriptions (STT) | ❌ | ❌ | - | +| Files | ❌ | ❌ | - | +| Batch | ❌ | ❌ | - | + + +**Unsupported Operations** (❌): legacy text completions, image generation, speech, transcriptions, files, and batch are not part of the Workers AI OpenAI-compat surface. These return `UnsupportedOperationError`. + + +--- + +## Setup & Configuration + +Configure Cloudflare Workers AI with both an API token and the account-scoped base URL. + + + + +1. Navigate to **Models** > **Model Providers**. Click **Add New Provider** and select **Cloudflare Workers AI**. +2. Open **Network Configuration** and set **Base URL** to `https://api.cloudflare.com/client/v4/accounts//ai`. +3. Keep **Max Connections** at `5000` unless you need a lower account-specific limit. Set **Stream Idle Timeout** to the maximum acceptable gap between streamed chunks (the default is `30` seconds). +4. Save the network configuration. Bifrost rejects the Cloudflare provider when the Base URL is empty. +5. Click **Add Key**, provide a name, and paste your Cloudflare API token directly or use an environment variable such as `env.CLOUDFLARE_API_KEY`. +6. Set **Allowed Models** to **All Models** or add the Workers AI model identifiers you want to expose, then save the key. + + + + +```json +{ + "providers": { + "cloudflare": { + "keys": [ + { + "name": "cloudflare-key-1", + "value": "env.CLOUDFLARE_API_KEY", + "models": ["*"], + "weight": 1.0 + } + ], + "network_config": { + "base_url": "https://api.cloudflare.com/client/v4/accounts//ai", + "max_conns_per_host": 5000, + "stream_idle_timeout_in_seconds": 30 + } + } + } +} +``` + + + +Refer to the API documentation for [Provider Keys Management](https://docs.getbifrost.ai/api-reference/providers/create-a-key-for-a-provider). Set the provider's `network_config.base_url` before creating or using its key. Bifrost sends the configured token as `Authorization: Bearer `; the token needs `Account → Workers AI → Read` permission for the target account. + + + +## Network Configuration + +**HTTP Settings:** + +- **Base URL**: required, no default. Set it to `https://api.cloudflare.com/client/v4/accounts//ai`. The account id is the same one shown on the Cloudflare dashboard. The provider appends `/v1/chat/completions`, `/v1/embeddings`, and `/v1/models` per request, matching the convention used by every other OpenAI-compat provider in this repo, so the trailing `/v1` is intentionally NOT part of the base URL. +- **Max Connections**: 5000 per host (matches the rest of the OpenAI-compat providers) +- **Idle Timeout**: 30 seconds for idle connections; streaming uses the per-stream idle timeout from `network_config.stream_idle_timeout_in_seconds`. + +`NewCloudflareProvider` returns an error if `network_config.base_url` is empty, since there is no global default that omits the account id. + +## Authentication + +Workers AI accepts a Cloudflare API token as a bearer credential: + +``` +Authorization: Bearer +``` + +The token must have permission to call Workers AI on the target account. Issue tokens from **Cloudflare dashboard → My Profile → API Tokens** and scope them to `Account → Workers AI → Read`. + +--- + +# 1. Chat Completions + +## Request Parameters + +Cloudflare delegates to the shared OpenAI handler, so all standard OpenAI chat completion parameters are forwarded as-is. For full parameter reference and behavior, see [OpenAI Chat Completions](/providers/supported-providers/openai#1-chat-completions). + +Cloudflare supports the standard OpenAI message types and streaming format. Tool calling is model-dependent — many Workers AI catalog entries advertise `function_calling: true` and many do not. Test the specific model you plan to route to before relying on tool calls. + +--- + +# 2. Responses API + +Bifrost converts Responses API format to Chat Completions internally, then converts the response back: + +``` +BifrostResponsesRequest + → ToChatRequest() + → ChatCompletion + → ToBifrostResponsesResponse() +``` + +Same parameter support as Chat Completions, with response shape differences (output items instead of message content). + +--- + +# 3. Embeddings + +Cloudflare's `/v1/embeddings` accepts the standard OpenAI embedding request shape. Use any catalog model that advertises the `embedding` task, e.g. `@cf/baai/bge-large-en-v1.5`. + +--- + +# 4. List Models + +Lists the Workers AI models enabled on the account. The list is account-scoped — different accounts can return different sets. + +--- + +## Unsupported Features + +| Feature | Reason | +|---------|--------| +| Text Completions | Not part of the Workers AI OpenAI-compat surface (only `/v1/chat/completions` and `/v1/embeddings` are exposed). | +| Image Generation | Workers AI exposes image models via `/ai/run`, not via the OpenAI-compat surface. | +| Speech/TTS | Same as above. | +| Transcription/STT | Same as above. | +| Batch Operations | Not offered. | +| File Management | Not offered. | + +--- + +## Caveats + + +**Severity**: Medium +**Behavior**: `NewCloudflareProvider` returns an error when `network_config.base_url` is empty. +**Impact**: The provider cannot construct a sensible default because Workers AI URLs embed the account id (`/accounts//ai`). Provide it explicitly or via env (e.g. `https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/ai`). +**Code**: `core/providers/cloudflare/cloudflare.go:NewCloudflareProvider` + + + +**Severity**: Low +**Behavior**: Tool calls are passed through to the upstream model. Some Workers AI catalog entries advertise `function_calling: true`, others do not. +**Impact**: A model that does not advertise function-calling will either ignore the `tools` field or return a plain text reply. Verify the chosen model before relying on tool calls. + diff --git a/transports/config.schema.json b/transports/config.schema.json index 0bdff81e93a..ec963b9b429 100644 --- a/transports/config.schema.json +++ b/transports/config.schema.json @@ -457,6 +457,9 @@ "wafer": { "$ref": "#/$defs/provider" }, + "cloudflare": { + "$ref": "#/$defs/provider" + }, "vllm": { "$ref": "#/$defs/provider_with_vllm_config" }, @@ -2655,6 +2658,7 @@ "vertex", "cerebras", "deepseek", + "cloudflare", "vllm", "parasail", "perplexity", @@ -8609,6 +8613,7 @@ "perplexity", "cerebras", "deepseek", + "cloudflare", "gemini", "openrouter", "elevenlabs", diff --git a/ui/app/workspace/providers/fragments/networkFormFragment.tsx b/ui/app/workspace/providers/fragments/networkFormFragment.tsx index 06b3c859595..94fe7bbd3b9 100644 --- a/ui/app/workspace/providers/fragments/networkFormFragment.tsx +++ b/ui/app/workspace/providers/fragments/networkFormFragment.tsx @@ -101,7 +101,7 @@ export function NetworkFormFragment({ provider }: NetworkFormFragmentProps) { }, [form.formState.isDirty, dispatch]); const onSubmit = (data: NetworkOnlyFormSchema) => { - const requiresBaseUrl = isCustomProvider; + const requiresBaseUrl = isCustomProvider || provider.name === "cloudflare"; if (requiresBaseUrl && (data.network_config?.base_url ?? "").trim() === "") { if ((provider.network_config?.base_url ?? "").trim() !== "") { toast.error("You can't remove network configuration for this provider."); @@ -176,8 +176,9 @@ export function NetworkFormFragment({ provider }: NetworkFormFragmentProps) { // HTTP/2 PING keepalives only apply when HTTP/2 is enforced const enforceHTTP2 = form.watch("network_config.enforce_http2"); - const baseURLRequired = isCustomProvider; + const baseURLRequired = isCustomProvider || provider.name === "cloudflare"; const hideBaseURL = provider.name === "vllm" || provider.name === "ollama" || provider.name === "sgl"; + const canRemoveConfiguration = !hideBaseURL && !baseURLRequired; return (
@@ -604,7 +605,7 @@ export function NetworkFormFragment({ provider }: NetworkFormFragmentProps) { {/* Form Actions */}
- {!hideBaseURL && ( + {canRemoveConfiguration && (