diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 70bb7e3605..601b340760 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -131,6 +131,8 @@ jobs: ELEVENLABS_API_KEY: ${{ secrets.ELEVENLABS_API_KEY }} SGL_API_KEY: ${{ secrets.SGL_API_KEY }} CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }} VERTEX_CREDENTIALS: ${{ secrets.VERTEX_CREDENTIALS }} diff --git a/.github/workflows/release-pipeline.yml b/.github/workflows/release-pipeline.yml index 0c05cac39b..60058e23f4 100644 --- a/.github/workflows/release-pipeline.yml +++ b/.github/workflows/release-pipeline.yml @@ -152,6 +152,7 @@ jobs: allowed-endpoints: > api.anthropic.com:443 api.cerebras.ai:443 + api.cloudflare.com:443 api.cohere.ai:443 api.elevenlabs.io:443 api.fireworks.ai:443 @@ -221,6 +222,8 @@ jobs: PERPLEXITY_API_KEY: ${{ secrets.PERPLEXITY_API_KEY }} SGL_API_KEY: ${{ secrets.SGL_API_KEY }} CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }} VERTEX_CREDENTIALS: ${{ secrets.VERTEX_CREDENTIALS }} @@ -347,6 +350,8 @@ jobs: PERPLEXITY_API_KEY: ${{ secrets.PERPLEXITY_API_KEY }} SGL_API_KEY: ${{ secrets.SGL_API_KEY }} CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }} VERTEX_CREDENTIALS: ${{ secrets.VERTEX_CREDENTIALS }} @@ -449,6 +454,8 @@ jobs: PERPLEXITY_API_KEY: ${{ secrets.PERPLEXITY_API_KEY }} SGL_API_KEY: ${{ secrets.SGL_API_KEY }} CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }} VERTEX_CREDENTIALS: ${{ secrets.VERTEX_CREDENTIALS }} @@ -613,6 +620,7 @@ jobs: 172.38.0.2:5432 api.anthropic.com:443 api.cerebras.ai:443 + api.cloudflare.com:443 api.cohere.ai:443 api.elevenlabs.io:443 api.github.com:443 @@ -692,6 +700,8 @@ jobs: GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} PERPLEXITY_API_KEY: ${{ secrets.PERPLEXITY_API_KEY }} CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} PARASAIL_API_KEY: ${{ secrets.PARASAIL_API_KEY }} ELEVENLABS_API_KEY: ${{ secrets.ELEVENLABS_API_KEY }} @@ -733,6 +743,7 @@ jobs: allowed-endpoints: > api.anthropic.com:443 api.cerebras.ai:443 + api.cloudflare.com:443 api.cohere.ai:443 api.elevenlabs.io:443 api.github.com:443 @@ -802,6 +813,8 @@ jobs: GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} PERPLEXITY_API_KEY: ${{ secrets.PERPLEXITY_API_KEY }} CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} PARASAIL_API_KEY: ${{ secrets.PARASAIL_API_KEY }} ELEVENLABS_API_KEY: ${{ secrets.ELEVENLABS_API_KEY }} @@ -843,6 +856,7 @@ jobs: allowed-endpoints: > api.anthropic.com:443 api.cerebras.ai:443 + api.cloudflare.com:443 api.cohere.ai:443 api.elevenlabs.io:443 api.github.com:443 @@ -912,6 +926,8 @@ jobs: GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} PERPLEXITY_API_KEY: ${{ secrets.PERPLEXITY_API_KEY }} CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} PARASAIL_API_KEY: ${{ secrets.PARASAIL_API_KEY }} ELEVENLABS_API_KEY: ${{ secrets.ELEVENLABS_API_KEY }} @@ -1021,6 +1037,8 @@ jobs: PERPLEXITY_API_KEY: ${{ secrets.PERPLEXITY_API_KEY }} SGL_API_KEY: ${{ secrets.SGL_API_KEY }} CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }} VERTEX_CREDENTIALS: ${{ secrets.VERTEX_CREDENTIALS }} @@ -1130,6 +1148,8 @@ jobs: PERPLEXITY_API_KEY: ${{ secrets.PERPLEXITY_API_KEY }} SGL_API_KEY: ${{ secrets.SGL_API_KEY }} CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }} VERTEX_CREDENTIALS: ${{ secrets.VERTEX_CREDENTIALS }} @@ -1255,6 +1275,8 @@ jobs: PERPLEXITY_API_KEY: ${{ secrets.PERPLEXITY_API_KEY }} SGL_API_KEY: ${{ secrets.SGL_API_KEY }} CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }} + CLOUDFLARE_API_KEY: ${{ secrets.CLOUDFLARE_API_KEY }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }} FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }} VERTEX_CREDENTIALS: ${{ secrets.VERTEX_CREDENTIALS }} diff --git a/.github/workflows/scripts/test-docker-image.sh b/.github/workflows/scripts/test-docker-image.sh index ac115394bf..34c83e700d 100755 --- a/.github/workflows/scripts/test-docker-image.sh +++ b/.github/workflows/scripts/test-docker-image.sh @@ -151,6 +151,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 } @@ -218,6 +222,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 @@ -244,6 +258,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/core/bifrost.go b/core/bifrost.go index a696c15235..7e9f07a446 100644 --- a/core/bifrost.go +++ b/core/bifrost.go @@ -23,6 +23,7 @@ import ( "github.com/maximhq/bifrost/core/providers/azure" "github.com/maximhq/bifrost/core/providers/bedrock" "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/elevenlabs" "github.com/maximhq/bifrost/core/providers/fireworks" @@ -3900,6 +3901,8 @@ func (bifrost *Bifrost) createBaseProvider(providerKey schemas.ModelProvider, co return perplexity.NewPerplexityProvider(config, bifrost.logger) case schemas.Cerebras: return cerebras.NewCerebrasProvider(config, bifrost.logger) + case schemas.Cloudflare: + return cloudflare.NewCloudflareProvider(config, bifrost.logger) case schemas.Gemini: return gemini.NewGeminiProvider(config, bifrost.logger), nil case schemas.OpenRouter: diff --git a/core/changelog.md b/core/changelog.md index e69de29bb2..4d430b099b 100644 --- a/core/changelog.md +++ b/core/changelog.md @@ -0,0 +1,7 @@ +- fix: idle timeout panic in the streaming idle-timeout reader +- fix: short-circuit `IdleTimeoutReader` reads when the connection is already closed (#3672) +- fix: preserve tool call stop reason in Anthropic streaming fallback (#3640) (thanks [@dicnunz](https://github.com/dicnunz)!) +- fix: correct start-time setting for accurate TTFT metric value (#3668) +- fix: map Vertex traffic type to Bifrost service tier (#3662) +- fix: ListModels for keyless providers (#3655) +- fix: remove manual `type: custom` for Anthropic tools (#3652) diff --git a/core/internal/llmtests/account.go b/core/internal/llmtests/account.go index e922c6e134..3b5f23def3 100644 --- a/core/internal/llmtests/account.go +++ b/core/internal/llmtests/account.go @@ -169,6 +169,7 @@ func (account *ComprehensiveTestAccount) GetConfiguredProviders() ([]schemas.Mod schemas.Elevenlabs, schemas.Perplexity, schemas.Cerebras, + schemas.Cloudflare, schemas.Gemini, schemas.OpenRouter, schemas.HuggingFace, @@ -431,6 +432,15 @@ func (account *ComprehensiveTestAccount) GetKeysForProvider(ctx context.Context, UseForBatchAPI: bifrost.Ptr(true), }, }, nil + case schemas.Cloudflare: + return []schemas.Key{ + { + Value: *schemas.NewEnvVar("env.CLOUDFLARE_API_KEY"), + Models: []string{"*"}, + Weight: 1.0, + UseForBatchAPI: bifrost.Ptr(true), + }, + }, nil case schemas.Gemini: return []schemas.Key{ { @@ -737,6 +747,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 0000000000..b25b2323d5 --- /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 0000000000..c9e5790190 --- /dev/null +++ b/core/providers/cloudflare/cloudflare.go @@ -0,0 +1,405 @@ +// 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`, `/v1/embeddings`, and `/v1/models` per request, +// matching the convention used by every other OpenAI-compat provider in this +// repo (Cerebras, Groq, etc.), so 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" + "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) + client = providerUtils.ConfigureTLS(client, config.NetworkConfig, logger) + streamingClient := providerUtils.BuildStreamingClient(client) + + config.NetworkConfig.BaseURL = strings.TrimRight(baseURL, "/") + + return &CloudflareProvider{ + logger: logger, + client: client, + streamingClient: streamingClient, + networkConfig: config.NetworkConfig, + sendBackRawRequest: config.SendBackRawRequest, + sendBackRawResponse: config.SendBackRawResponse, + }, nil +} + +// GetProviderKey returns the provider identifier for Cloudflare. +func (provider *CloudflareProvider) GetProviderKey() schemas.ModelProvider { + return schemas.Cloudflare +} + +// ListModels performs a list models request to Cloudflare's OpenAI-compatible +// /v1/models endpoint. +func (provider *CloudflareProvider) ListModels(ctx *schemas.BifrostContext, keys []schemas.Key, request *schemas.BifrostListModelsRequest) (*schemas.BifrostListModelsResponse, *schemas.BifrostError) { + return openai.HandleOpenAIListModelsRequest( + ctx, + provider.client, + request, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/models"), + keys, + provider.networkConfig.ExtraHeaders, + provider.GetProviderKey(), + providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + ) +} + +// 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, + key, + provider.networkConfig.ExtraHeaders, + providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + 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) { + var authHeader map[string]string + if key.Value.GetValue() != "" { + authHeader = map[string]string{"Authorization": "Bearer " + key.Value.GetValue()} + } + return openai.HandleOpenAIChatCompletionStreaming( + ctx, + provider.streamingClient, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/chat/completions"), + request, + authHeader, + provider.networkConfig.ExtraHeaders, + provider.networkConfig.StreamIdleTimeoutInSeconds, + providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + schemas.Cloudflare, + postHookRunner, + 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, + key, + provider.networkConfig.ExtraHeaders, + provider.GetProviderKey(), + providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + 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()) +} + +// 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()) +} + +// 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 0000000000..76e9136f16 --- /dev/null +++ b/core/providers/cloudflare/cloudflare_test.go @@ -0,0 +1,111 @@ +package cloudflare_test + +import ( + "os" + "strings" + "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") + } +} diff --git a/core/schemas/bifrost.go b/core/schemas/bifrost.go index 7be0acd810..1fd41c3648 100644 --- a/core/schemas/bifrost.go +++ b/core/schemas/bifrost.go @@ -48,6 +48,7 @@ const ( Parasail ModelProvider = "parasail" Perplexity ModelProvider = "perplexity" Cerebras ModelProvider = "cerebras" + Cloudflare ModelProvider = "cloudflare" Gemini ModelProvider = "gemini" OpenRouter ModelProvider = "openrouter" Elevenlabs ModelProvider = "elevenlabs" @@ -64,6 +65,7 @@ const ( var SupportedBaseProviders = []ModelProvider{ Anthropic, Bedrock, + Cloudflare, Cohere, Gemini, OpenAI, @@ -77,6 +79,7 @@ var StandardProviders = []ModelProvider{ Azure, Bedrock, Cerebras, + Cloudflare, Cohere, Gemini, Groq, diff --git a/docs/docs.json b/docs/docs.json index 2ea11332b6..43dea97dc0 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -133,6 +133,7 @@ "providers/supported-providers/azure", "providers/supported-providers/bedrock", "providers/supported-providers/cerebras", + "providers/supported-providers/cloudflare", "providers/supported-providers/cohere", "providers/supported-providers/databricks", "providers/supported-providers/elevenlabs", diff --git a/docs/openapi/openapi.json b/docs/openapi/openapi.json index e329f80d06..7437c7587c 100644 --- a/docs/openapi/openapi.json +++ b/docs/openapi/openapi.json @@ -48553,6 +48553,7 @@ "perplexity", "replicate", "cerebras", + "cloudflare", "gemini", "openrouter", "elevenlabs", diff --git a/docs/providers/supported-providers/cloudflare.mdx b/docs/providers/supported-providers/cloudflare.mdx new file mode 100644 index 0000000000..b670eb6562 --- /dev/null +++ b/docs/providers/supported-providers/cloudflare.mdx @@ -0,0 +1,122 @@ +--- +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`. + + +--- + +## 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/framework/changelog.md b/framework/changelog.md index e69de29bb2..c5ae6b3bb2 100644 --- a/framework/changelog.md +++ b/framework/changelog.md @@ -0,0 +1,5 @@ +- feat: `created_by` user attribution column for virtual keys (#3672) +- feat: `blacklisted_models` column for virtual key provider configs (#3653) +- fix: add monotonic `inc_number` log cursor so node usage reconciliation does not skip late async log writes (#3664) +- revert: `access_profile_id` direct access profile assignment on virtual keys (#3669) +- chore: drop the `access_profile_id` column from `governance_virtual_keys` (#3670) diff --git a/plugins/compat/changelog.md b/plugins/compat/changelog.md index e69de29bb2..82ae1ff69a 100644 --- a/plugins/compat/changelog.md +++ b/plugins/compat/changelog.md @@ -0,0 +1 @@ +- chore: upgraded core to v1.5.12 and framework to v1.3.12 diff --git a/plugins/governance/changelog.md b/plugins/governance/changelog.md index e69de29bb2..1b1dafa9b9 100644 --- a/plugins/governance/changelog.md +++ b/plugins/governance/changelog.md @@ -0,0 +1,2 @@ +- feat: virtual key blocked-models enforcement — reject requests when the requested model is blocked at the VK provider-config level (#3653) +- fix: clear stale `governanceRejectedContextKey` on an allow decision so successful fallback retries count toward budgets and rate limits (#3645) diff --git a/plugins/jsonparser/changelog.md b/plugins/jsonparser/changelog.md index e69de29bb2..82ae1ff69a 100644 --- a/plugins/jsonparser/changelog.md +++ b/plugins/jsonparser/changelog.md @@ -0,0 +1 @@ +- chore: upgraded core to v1.5.12 and framework to v1.3.12 diff --git a/plugins/logging/changelog.md b/plugins/logging/changelog.md index e69de29bb2..c38e9977c4 100644 --- a/plugins/logging/changelog.md +++ b/plugins/logging/changelog.md @@ -0,0 +1 @@ +- feat: stamp MCP tool logs with governance ownership (user, team, customer, and business unit IDs) from the request context diff --git a/plugins/maxim/changelog.md b/plugins/maxim/changelog.md index e69de29bb2..82ae1ff69a 100644 --- a/plugins/maxim/changelog.md +++ b/plugins/maxim/changelog.md @@ -0,0 +1 @@ +- chore: upgraded core to v1.5.12 and framework to v1.3.12 diff --git a/plugins/mocker/changelog.md b/plugins/mocker/changelog.md index e69de29bb2..82ae1ff69a 100644 --- a/plugins/mocker/changelog.md +++ b/plugins/mocker/changelog.md @@ -0,0 +1 @@ +- chore: upgraded core to v1.5.12 and framework to v1.3.12 diff --git a/plugins/otel/changelog.md b/plugins/otel/changelog.md index e69de29bb2..82ae1ff69a 100644 --- a/plugins/otel/changelog.md +++ b/plugins/otel/changelog.md @@ -0,0 +1 @@ +- chore: upgraded core to v1.5.12 and framework to v1.3.12 diff --git a/plugins/prompts/changelog.md b/plugins/prompts/changelog.md index e69de29bb2..82ae1ff69a 100644 --- a/plugins/prompts/changelog.md +++ b/plugins/prompts/changelog.md @@ -0,0 +1 @@ +- chore: upgraded core to v1.5.12 and framework to v1.3.12 diff --git a/plugins/semanticcache/changelog.md b/plugins/semanticcache/changelog.md index e69de29bb2..82ae1ff69a 100644 --- a/plugins/semanticcache/changelog.md +++ b/plugins/semanticcache/changelog.md @@ -0,0 +1 @@ +- chore: upgraded core to v1.5.12 and framework to v1.3.12 diff --git a/plugins/telemetry/changelog.md b/plugins/telemetry/changelog.md index e69de29bb2..82ae1ff69a 100644 --- a/plugins/telemetry/changelog.md +++ b/plugins/telemetry/changelog.md @@ -0,0 +1 @@ +- chore: upgraded core to v1.5.12 and framework to v1.3.12 diff --git a/tests/e2e/api/collections/bifrost-api-management.postman_collection.json b/tests/e2e/api/collections/bifrost-api-management.postman_collection.json index d84e383100..e510b4835d 100644 --- a/tests/e2e/api/collections/bifrost-api-management.postman_collection.json +++ b/tests/e2e/api/collections/bifrost-api-management.postman_collection.json @@ -82,7 +82,7 @@ "}", "", "// Handle 405 for unimplemented cache endpoints", - "var unimplementedEndpoints = ['Clear Cache by Request ID', 'Clear Cache by Key'];", + "var unimplementedEndpoints = ['Clear Cache by Cache ID (Coverage Probe)', 'Clear Cache by Key (Coverage Probe)'];", "if (unimplementedEndpoints.indexOf(requestName) !== -1 && code === 405) {", " pass = true;", "}", @@ -233,11 +233,9 @@ " 'Get Version': true", "};", "var rawTextShapes = {", - " 'Clear Cache by Request ID': true,", - " 'Clear Cache by Key': true,", - " 'Per User OAuth Register (Coverage Probe)': true,", - " 'Per User OAuth Authorize (Coverage Probe)': true,", - " 'Per User OAuth Token (Coverage Probe)': true", + " 'Clear Cache by Cache ID (Coverage Probe)': true,", + " 'Clear Cache by Key (Coverage Probe)': true,", + " 'OAuth Callback (Coverage Probe)': true", "};", "pm.test('Response structure matches handler contract', function () {", " var body = parseJSONOrNull();", @@ -2156,7 +2154,7 @@ "name": "Cache", "item": [ { - "name": "Clear Cache by Request ID", + "name": "Clear Cache by Cache ID (Coverage Probe)", "request": { "method": "DELETE", "header": [], @@ -2175,7 +2173,7 @@ } }, { - "name": "Clear Cache by Key", + "name": "Clear Cache by Key (Coverage Probe)", "request": { "method": "DELETE", "header": [], @@ -2770,64 +2768,12 @@ } }, { - "name": "Per User OAuth Register (Coverage Probe)", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": { - "raw": "{{base_url}}/api/oauth/per-user/register", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "oauth", - "per-user", - "register" - ] - }, - "body": { - "mode": "raw", - "raw": "{\"redirect_uris\":[\"http://localhost/callback\"],\"client_name\":\"coverage-probe\"}" - } - } - }, - { - "name": "Per User OAuth Authorize (Coverage Probe)", + "name": "Per User OAuth Flow Detail (Coverage Probe)", "request": { "method": "GET", "header": [], "url": { - "raw": "{{base_url}}/api/oauth/per-user/authorize?client_id=coverage-probe-client&redirect_uri=http%3A%2F%2Flocalhost%2Fcallback&response_type=code&state=coverage-probe-state", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "oauth", - "per-user", - "authorize?client_id=coverage-probe-client&redirect_uri=http%3A%2F%2Flocalhost%2Fcallback&response_type=code&state=coverage-probe-state" - ] - } - } - }, - { - "name": "Per User OAuth Token (Coverage Probe)", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": { - "raw": "{{base_url}}/api/oauth/per-user/token", + "raw": "{{base_url}}/api/oauth/per-user/flows/coverage-probe-flow", "host": [ "{{base_url}}" ], @@ -2835,134 +2781,19 @@ "api", "oauth", "per-user", - "token" + "flows", + "coverage-probe-flow" ] - }, - "body": { - "mode": "raw", - "raw": "{\"grant_type\":\"authorization_code\",\"code\":\"coverage-probe-code\",\"redirect_uri\":\"http://localhost/callback\"}" } } }, { - "name": "Per User OAuth Upstream Authorize (Coverage Probe)", + "name": "Per User OAuth Flow Start (Coverage Probe)", "request": { "method": "GET", "header": [], "url": { - "raw": "{{base_url}}/api/oauth/per-user/upstream/authorize?state=coverage-probe-state", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "oauth", - "per-user", - "upstream", - "authorize?state=coverage-probe-state" - ] - } - } - }, - { - "name": "Per User Consent VK (Coverage Probe)", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": { - "raw": "{{base_url}}/api/oauth/per-user/consent/vk", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "oauth", - "per-user", - "consent", - "vk" - ] - }, - "body": { - "mode": "raw", - "raw": "{\"state\":\"coverage-probe-state\",\"virtual_key\":\"coverage-probe-vk\"}" - } - } - }, - { - "name": "Per User Consent User ID (Coverage Probe)", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": { - "raw": "{{base_url}}/api/oauth/per-user/consent/user-id", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "oauth", - "per-user", - "consent", - "user-id" - ] - }, - "body": { - "mode": "raw", - "raw": "{\"state\":\"coverage-probe-state\",\"user_id\":\"coverage-probe-user\"}" - } - } - }, - { - "name": "Per User Consent Skip (Coverage Probe)", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": { - "raw": "{{base_url}}/api/oauth/per-user/consent/skip", - "host": [ - "{{base_url}}" - ], - "path": [ - "api", - "oauth", - "per-user", - "consent", - "skip" - ] - }, - "body": { - "mode": "raw", - "raw": "{\"state\":\"coverage-probe-state\"}" - } - } - }, - { - "name": "Per User Consent Submit (Coverage Probe)", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "url": { - "raw": "{{base_url}}/api/oauth/per-user/consent/submit", + "raw": "{{base_url}}/api/oauth/per-user/flows/coverage-probe-flow/start", "host": [ "{{base_url}}" ], @@ -2970,13 +2801,10 @@ "api", "oauth", "per-user", - "consent", - "submit" + "flows", + "coverage-probe-flow", + "start" ] - }, - "body": { - "mode": "raw", - "raw": "{\"state\":\"coverage-probe-state\"}" } } }, diff --git a/tests/e2e/features/virtual-keys/pages/virtual-keys.page.ts b/tests/e2e/features/virtual-keys/pages/virtual-keys.page.ts index 08c06be7e0..593c915154 100644 --- a/tests/e2e/features/virtual-keys/pages/virtual-keys.page.ts +++ b/tests/e2e/features/virtual-keys/pages/virtual-keys.page.ts @@ -213,6 +213,14 @@ export class VirtualKeysPage extends BasePage { .catch(() => {}); } + private async preserveBudgetUsageIfPrompted(): Promise { + const dialog = this.page.getByTestId("vk-budget-reset-dialog"); + const isVisible = await dialog.waitFor({ state: 'visible', timeout: 1000 }).then(() => true).catch(() => false); + if (!isVisible) return; + await this.page.getByTestId("vk-budget-reset-preserve-btn").click(); + await dialog.waitFor({ state: 'hidden', timeout: 3000 }) + } + /** * Check if a virtual key exists in the table */ @@ -471,6 +479,7 @@ export class VirtualKeysPage extends BasePage { // Save changes by clicking the save button await this.saveBtn.click(); + await this.preserveBudgetUsageIfPrompted(); await this.waitForSheetClosedAfterSave(); @@ -865,4 +874,4 @@ export class VirtualKeysPage extends BasePage { } } } -} +} \ No newline at end of file diff --git a/tests/e2e/features/virtual-keys/virtual-keys.spec.ts b/tests/e2e/features/virtual-keys/virtual-keys.spec.ts index 77d5a3f6e1..a555926a94 100644 --- a/tests/e2e/features/virtual-keys/virtual-keys.spec.ts +++ b/tests/e2e/features/virtual-keys/virtual-keys.spec.ts @@ -458,7 +458,13 @@ test.describe('Virtual Key Management', () => { expect(currentValue).toBe(oldValue) }) - test('should bulk rotate selected virtual keys only', async ({ virtualKeysPage }) => { + // TODO: Re-enable once the UI bug is fixed. + // Bug: selection state resets when the search input filters out an already-selected + // row (i.e. selected keys not present in the current search results lose their + // checked state). Because `bulkRotateVirtualKeys` searches per-name to tick each + // checkbox, the first key gets unselected when the search narrows to the second, + // so only the last selection ends up being rotated. + test.fixme('should bulk rotate selected virtual keys only', async ({ virtualKeysPage }) => { const selectedOne = `Bulk Rotate One ${Date.now()}` const selectedTwo = `Bulk Rotate Two ${Date.now()}` const unselected = `Bulk Rotate Unselected ${Date.now()}` diff --git a/transports/changelog.md b/transports/changelog.md index e69de29bb2..5aa94e82e9 100644 --- a/transports/changelog.md +++ b/transports/changelog.md @@ -0,0 +1,22 @@ +## ✨ Features + +- **Virtual Key Blocked Models** — Block specific models at the virtual key provider-config level; blocked models take priority over allowed models and are enforced by governance (#3653) +- **Virtual Key Ownership** — Virtual keys now capture and display a `created_by` user attribution (#3672) +- **MCP Log Attribution** — MCP tool logs are stamped with user, team, customer, and business unit IDs so MCP usage can be traced like LLM usage +- **Team & Business Unit Filters** — Added team and business unit filters across the dashboard and logs views (#3650) +- **Sticky Time Filters** — Time filter selections are preserved when navigating between sidebar items (#3647) + +## 🐞 Fixed + +- **Idle Timeout Panic** — Fixed a panic in the streaming idle-timeout reader and added a guard to skip reads once the connection is closed (#3672) +- **Anthropic Streaming** — Preserve the tool-call stop reason in the Anthropic streaming fallback (#3640) (thanks [@dicnunz](https://github.com/dicnunz)!) +- **TTFT Metric** — Fixed the request start-time setting so the time-to-first-token metric is accurate (#3668) +- **Vertex Service Tier** — Map the Vertex traffic type to the correct Bifrost service tier (#3662) +- **Keyless Providers** — Fixed `ListModels` for providers configured without an API key (#3655) +- **Anthropic Tools** — Stopped forcing `type: custom` on Anthropic tool definitions (#3652) +- **Node Usage Reconciliation** — Added a monotonic log cursor so reconciliation no longer skips late async log writes (#3664) +- **Fallback Budget Tracking** — Clear the stale governance rejection flag on allow so successful fallback retries count toward budgets and rate limits (#3645) +- **Virtual Keys Table** — Table now fills available height with a sticky header and scrollable body (#3676) +- **Sheet Layout** — Removed save/cancel icons and fixed sheet layout growth in routing rule and virtual key sheets (#3675) +- **Toast Click-Through** — Toasts remain clickable above modal overlays (#3674) +- **Direct Access Control** — Reverted the virtual key `access_profile_id` direct access profile assignment shipped in v1.5.3; the `access_profile_id` column has been dropped (#3669, #3670) diff --git a/transports/config.schema.json b/transports/config.schema.json index 801324d387..811d974424 100644 --- a/transports/config.schema.json +++ b/transports/config.schema.json @@ -308,6 +308,9 @@ "cerebras": { "$ref": "#/$defs/provider" }, + "cloudflare": { + "$ref": "#/$defs/provider" + }, "vllm": { "$ref": "#/$defs/provider_with_vllm_config" }, @@ -1421,6 +1424,7 @@ "openrouter", "vertex", "cerebras", + "cloudflare", "vllm", "parasail", "perplexity", @@ -4341,6 +4345,7 @@ "parasail", "perplexity", "cerebras", + "cloudflare", "gemini", "openrouter", "elevenlabs", diff --git a/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx b/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx index 9418e17343..796a6fe3cb 100644 --- a/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx +++ b/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx @@ -22,7 +22,11 @@ import { ProviderLabels, ProviderName } from "@/lib/constants/logs"; import { VirtualKey } from "@/lib/types/governance"; import { cn } from "@/lib/utils"; import { supportsCalendarAlignment } from "@/lib/constants/governance"; -import { calculateUsagePercentage, formatCurrency, parseResetPeriod } from "@/lib/utils/governance"; +import { + calculateUsagePercentage, + formatCurrency, + parseResetPeriod, +} from "@/lib/utils/governance"; import ManagedVirtualKeyNotice from "@enterprise/components/access-profiles/managedVirtualKeyNotice"; import { formatDistanceToNow } from "date-fns"; import { Users } from "lucide-react"; @@ -49,12 +53,17 @@ function UsageLine({
- {format(current)} / {format(max)} + {format(current)} /{" "} + {format(max)} 80 ? "text-amber-500" : "text-muted-foreground", + exhausted + ? "text-red-500" + : pct > 80 + ? "text-amber-500" + : "text-muted-foreground", )} > {pct}% @@ -62,7 +71,10 @@ function UsageLine({
); @@ -73,7 +85,10 @@ interface VirtualKeyDetailSheetProps { onClose: () => void; } -export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKeyDetailSheetProps) { +export default function VirtualKeyDetailSheet({ + virtualKey, + onClose, +}: VirtualKeyDetailSheetProps) { const { assignedUsers, isManagedByProfile, @@ -101,10 +116,12 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe // Rate limits exhausted (displayRateLimit?.token_current_usage && displayRateLimit?.token_max_limit && - displayRateLimit.token_current_usage >= displayRateLimit.token_max_limit) || + displayRateLimit.token_current_usage >= + displayRateLimit.token_max_limit) || (displayRateLimit?.request_current_usage && displayRateLimit?.request_max_limit && - displayRateLimit.request_current_usage >= displayRateLimit.request_max_limit); + displayRateLimit.request_current_usage >= + displayRateLimit.request_max_limit); return ( @@ -112,7 +129,8 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe {virtualKey.name} - {virtualKey.description || "Virtual key details and usage information"} + {virtualKey.description || + "Virtual key details and usage information"} @@ -141,10 +159,18 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe
- {virtualKey.is_active ? (isExhausted ? "Exhausted" : "Active") : "Inactive"} + {virtualKey.is_active + ? isExhausted + ? "Exhausted" + : "Active" + : "Inactive"}
@@ -159,7 +185,9 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe
- Last Updated + + Last Updated +
{formatDistanceToNow(new Date(virtualKey.updated_at), { addSuffix: true, @@ -169,9 +197,15 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe {entityInfo.type !== "None" && (
- Assigned To + + Assigned To +
- + {entityInfo.type} {entityInfo.name} @@ -188,14 +222,18 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe

Provider Configurations

- {!virtualKey.provider_configs || virtualKey.provider_configs.length === 0 ? ( + {!virtualKey.provider_configs || + virtualKey.provider_configs.length === 0 ? ( No providers configured (deny-by-default) ) : (
{virtualKey.provider_configs.map((config, index) => ( -
+
{/* Provider Header */}
@@ -205,7 +243,8 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe className="h-5 w-5" /> - {ProviderLabels[config.provider as ProviderName] || config.provider} + {ProviderLabels[config.provider as ProviderName] || + config.provider}
@@ -224,10 +263,15 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe All Models - ) : config.allowed_models && config.allowed_models.length > 0 ? ( + ) : config.allowed_models && + config.allowed_models.length > 0 ? (
{config.allowed_models.map((model) => ( - + {model} ))} @@ -253,7 +297,11 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe config.blacklisted_models.length > 0 ? (
{config.blacklisted_models.map((model) => ( - + {model} ))} @@ -278,7 +326,11 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe ) : config.keys && config.keys.length > 0 ? (
{config.keys.map((key) => ( - + {key.name} ))} @@ -296,7 +348,9 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe <>
-

Provider Budgets

+

+ Provider Budgets +

{config.budgets.map((b, bIdx) => (
- Resets {parseResetPeriod(b.reset_duration)} + Resets{" "} + {parseResetPeriod(b.reset_duration)} {virtualKey.calendar_aligned && - supportsCalendarAlignment(b.reset_duration) && + supportsCalendarAlignment( + b.reset_duration, + ) && " (calendar)"} {b.last_reset ? ( Last reset{" "} - {formatDistanceToNow(new Date(b.last_reset), { - addSuffix: true, - })} + {formatDistanceToNow( + new Date(b.last_reset), + { + addSuffix: true, + }, + )} ) : null}
@@ -331,7 +391,9 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe <>
-

Provider Rate Limits

+

+ Provider Rate Limits +

{/* Token Limits */} {config.rate_limit.token_max_limit != null ? ( @@ -340,7 +402,9 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe TOKEN LIMITS n.toLocaleString()} /> @@ -348,11 +412,13 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe Resets{" "} {parseResetPeriod( - config.rate_limit.token_reset_duration || "", + config.rate_limit + .token_reset_duration || "", )} {virtualKey.calendar_aligned && supportsCalendarAlignment( - config.rate_limit.token_reset_duration || "", + config.rate_limit + .token_reset_duration || "", ) && " (calendar)"} @@ -360,7 +426,9 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe Last reset{" "} {formatDistanceToNow( - new Date(config.rate_limit.token_last_reset), + new Date( + config.rate_limit.token_last_reset, + ), { addSuffix: true }, )} @@ -376,7 +444,9 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe REQUEST LIMITS n.toLocaleString()} /> @@ -384,11 +454,13 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe Resets{" "} {parseResetPeriod( - config.rate_limit.request_reset_duration || "", + config.rate_limit + .request_reset_duration || "", )} {virtualKey.calendar_aligned && supportsCalendarAlignment( - config.rate_limit.request_reset_duration || "", + config.rate_limit + .request_reset_duration || "", ) && " (calendar)"} @@ -396,7 +468,10 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe Last reset{" "} {formatDistanceToNow( - new Date(config.rate_limit.request_last_reset), + new Date( + config.rate_limit + .request_last_reset, + ), { addSuffix: true }, )} @@ -427,7 +502,8 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe

MCP Client Configurations

- {!virtualKey.mcp_configs || virtualKey.mcp_configs.length === 0 ? ( + {!virtualKey.mcp_configs || + virtualKey.mcp_configs.length === 0 ? ( No MCP clients configured (deny-by-default) @@ -442,17 +518,26 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe {virtualKey.mcp_configs.map((config, index) => ( - - {config.mcp_client?.name || "Unknown Client"} + + + {config.mcp_client?.name || "Unknown Client"} + {config.tools_to_execute?.includes("*") ? ( All Tools - ) : config.tools_to_execute && config.tools_to_execute.length > 0 ? ( + ) : config.tools_to_execute && + config.tools_to_execute.length > 0 ? (
{config.tools_to_execute.map((tool) => ( - + {tool} ))} @@ -514,7 +599,9 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe ))}
) : ( -

No budget limits configured

+

+ No budget limits configured +

)}
@@ -542,17 +629,25 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe />
- Resets {parseResetPeriod(displayRateLimit.token_reset_duration || "")} + Resets{" "} + {parseResetPeriod( + displayRateLimit.token_reset_duration || "", + )} {virtualKey.calendar_aligned && - supportsCalendarAlignment(displayRateLimit.token_reset_duration || "") && + supportsCalendarAlignment( + displayRateLimit.token_reset_duration || "", + ) && " (calendar)"} {displayRateLimit.token_last_reset ? ( Last reset{" "} - {formatDistanceToNow(new Date(displayRateLimit.token_last_reset), { - addSuffix: true, - })} + {formatDistanceToNow( + new Date(displayRateLimit.token_last_reset), + { + addSuffix: true, + }, + )} ) : null}
@@ -570,7 +665,10 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe />
- Resets {parseResetPeriod(displayRateLimit.request_reset_duration || "")} + Resets{" "} + {parseResetPeriod( + displayRateLimit.request_reset_duration || "", + )} {virtualKey.calendar_aligned && supportsCalendarAlignment( displayRateLimit.request_reset_duration || "", @@ -580,9 +678,12 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe {displayRateLimit.request_last_reset ? ( Last reset{" "} - {formatDistanceToNow(new Date(displayRateLimit.request_last_reset), { - addSuffix: true, - })} + {formatDistanceToNow( + new Date(displayRateLimit.request_last_reset), + { + addSuffix: true, + }, + )} ) : null}
@@ -591,15 +692,19 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe {displayRateLimit.token_max_limit == null && displayRateLimit.request_max_limit == null && ( -

No rate limits configured

+

+ No rate limits configured +

)}
) : ( -

No rate limits configured

+

+ No rate limits configured +

)}
); -} \ No newline at end of file +} diff --git a/ui/lib/constants/config.ts b/ui/lib/constants/config.ts index a3778f093a..1245957b3a 100644 --- a/ui/lib/constants/config.ts +++ b/ui/lib/constants/config.ts @@ -30,6 +30,7 @@ export const ModelPlaceholders = { azure: "e.g. gpt-4, gpt-35-turbo (must match alias keys)", bedrock: "e.g. claude-v2, titan-text-express-v1, ai21-j2-mid", cerebras: "e.g. cerebras-2, cerebras-2-vision", + cloudflare: "e.g. @cf/meta/llama-3.1-8b-instruct, @cf/baai/bge-large-en-v1.5", cohere: "e.g. command-r, command-r-plus", gemini: "e.g. gemini-1.5-pro, gemini-1.5-flash", groq: "e.g. llama3-70b-8192, mixtral-8x7b-32768", @@ -56,6 +57,7 @@ export const isKeyRequiredByProvider: Record = { azure: true, bedrock: true, cerebras: true, + cloudflare: true, cohere: true, gemini: true, groq: true, diff --git a/ui/lib/constants/icons.tsx b/ui/lib/constants/icons.tsx index c108453faf..f6843cf412 100644 --- a/ui/lib/constants/icons.tsx +++ b/ui/lib/constants/icons.tsx @@ -136,6 +136,31 @@ export const ProviderIcons = { ); }, + cloudflare: ({ size = "md", className = "" }: IconProps) => { + const resolvedSize = resolveSize(size); + return ( + + Cloudflare + {/* Simple cloud silhouette in Cloudflare's primary brand orange. */} + + + + ); + }, + cohere: ({ size = "md", className = "" }: IconProps) => { const resolvedSize = resolveSize(size); return ( diff --git a/ui/lib/constants/logs.ts b/ui/lib/constants/logs.ts index 32b42ec405..08bfb00647 100644 --- a/ui/lib/constants/logs.ts +++ b/ui/lib/constants/logs.ts @@ -4,6 +4,7 @@ export const KnownProvidersNames = [ "azure", "bedrock", "cerebras", + "cloudflare", "cohere", "gemini", "groq", @@ -35,6 +36,7 @@ export const ProviderNames: readonly ProviderName[] = KnownProvidersNames; export const EmbeddingSupportedProviders: readonly ProviderName[] = [ "azure", "bedrock", + "cloudflare", "cohere", "fireworks", "gemini", @@ -113,6 +115,7 @@ export const ProviderLabels: Record = { perplexity: "Perplexity", sgl: "SGLang", cerebras: "Cerebras", + cloudflare: "Cloudflare Workers AI", gemini: "Gemini", openrouter: "OpenRouter", huggingface: "HuggingFace",