From 2af938fb7ae500918226be6d4acbd384f5ab18b8 Mon Sep 17 00:00:00 2001 From: Samyabrata Maji <116789799+sammaji@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:54:45 +0530 Subject: [PATCH 1/2] fix: adds sgl anthropic compatible apis via key level setting --- core/providers/anthropic/requestbuilder.go | 3 +- core/providers/anthropic/utils.go | 8 + core/providers/sgl/sgl.go | 144 +++++++++++++++++- core/schemas/account.go | 5 +- framework/configstore/clientconfig.go | 17 ++- framework/configstore/migrations.go | 32 +++- framework/configstore/rdb.go | 8 +- framework/configstore/tables/key.go | 12 ++ transports/bifrost-http/lib/config.go | 4 +- transports/config.schema.json | 127 ++++++++++++++- .../fragments/apiKeysFormFragment.tsx | 23 +++ .../providers/fragments/deploymentsTable.tsx | 28 ++++ ui/lib/types/config.ts | 2 + ui/lib/types/schemas.ts | 2 + 14 files changed, 399 insertions(+), 16 deletions(-) diff --git a/core/providers/anthropic/requestbuilder.go b/core/providers/anthropic/requestbuilder.go index 20950d7cc2c..301e4a9706b 100644 --- a/core/providers/anthropic/requestbuilder.go +++ b/core/providers/anthropic/requestbuilder.go @@ -124,6 +124,7 @@ var AnthropicProviderRequestDefaultsMap = map[schemas.ModelProvider]AnthropicPro RemapToolVersions: true, InjectBetaHeadersIntoBody: true, }, + schemas.SGL: {}, } // BuildAnthropicResponsesRequestBody is the single implementation of the @@ -617,4 +618,4 @@ func BuildAnthropicChatRequestBody(ctx *schemas.BifrostContext, request *schemas } return jsonBody, nil -} +} \ No newline at end of file diff --git a/core/providers/anthropic/utils.go b/core/providers/anthropic/utils.go index 2b9a2fc9658..ce6ff0e0305 100644 --- a/core/providers/anthropic/utils.go +++ b/core/providers/anthropic/utils.go @@ -3353,3 +3353,11 @@ func IsClaudeCodeRequest(ctx *schemas.BifrostContext) bool { } return false } + +// ResolveUseAnthropicEndpoints reports whether the request should be routed through Anthropic-compatible endpoints +func ResolveUseAnthropicEndpoints(ctx *schemas.BifrostContext, key schemas.Key) bool { + if ra := schemas.GetResolvedAlias(ctx); ra != nil && ra.Config != nil && ra.Config.UseAnthropicEndpoints != nil { + return *ra.Config.UseAnthropicEndpoints + } + return key.UseAnthropicEndpoints != nil && *key.UseAnthropicEndpoints +} \ No newline at end of file diff --git a/core/providers/sgl/sgl.go b/core/providers/sgl/sgl.go index 558c51fd23a..a89e750de77 100644 --- a/core/providers/sgl/sgl.go +++ b/core/providers/sgl/sgl.go @@ -7,12 +7,17 @@ import ( "strings" "time" + "github.com/maximhq/bifrost/core/providers/anthropic" "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" ) +// sglAnthropicVersion is the Anthropic API version sent as the "anthropic-version" header on +// SGLang's Anthropic-compatible endpoint. +const sglAnthropicVersion = "2023-06-01" + // SGLProvider implements the Provider interface for SGL's API. type SGLProvider struct { logger schemas.Logger // Logger for provider operations @@ -80,6 +85,13 @@ func (provider *SGLProvider) getBaseURL(key schemas.Key) string { return "" } +// anthropicHeaders builds the auth and version headers for SGL's Anthropic-compatible endpoint. +func anthropicHeaders(key schemas.Key) map[string]string { + headers := openai.BearerAuthHeader(key) + headers["anthropic-version"] = sglAnthropicVersion + return headers +} + // baseURLOrError returns the resolved base URL or a BifrostError when none is configured. func (provider *SGLProvider) baseURLOrError(key schemas.Key) (string, *schemas.BifrostError) { u := provider.getBaseURL(key) @@ -124,7 +136,10 @@ func (provider *SGLProvider) ListModels(ctx *schemas.BifrostContext, keys []sche ) } -// TextCompletion performs a text completion request to the SGL API. +// TextCompletion performs a text completion request to the SGL API. UseAnthropicEndpoints has +// no effect here: Anthropic's legacy text-completions surface has no reusable shared handler +// and SGLang's Anthropic-compatible layer doesn't expose one either, so this always uses the +// OpenAI-compatible /v1/completions endpoint. func (provider *SGLProvider) TextCompletion(ctx *schemas.BifrostContext, key schemas.Key, request *schemas.BifrostTextCompletionRequest) (*schemas.BifrostTextCompletionResponse, *schemas.BifrostError) { ctx.SetValue(schemas.BifrostContextKeyPassthroughExtraParams, true) baseURL, bifrostErr := provider.baseURLOrError(key) @@ -176,13 +191,35 @@ func (provider *SGLProvider) TextCompletionStream(ctx *schemas.BifrostContext, p ) } -// ChatCompletion performs a chat completion request to the SGL API. +// ChatCompletion performs a chat completion request to the SGL API. When the key (or the +// resolved alias) has UseAnthropicEndpoints set, the request is routed through SGLang's +// Anthropic-compatible Messages endpoint instead of its OpenAI-compatible one. func (provider *SGLProvider) ChatCompletion(ctx *schemas.BifrostContext, key schemas.Key, request *schemas.BifrostChatRequest) (*schemas.BifrostChatResponse, *schemas.BifrostError) { ctx.SetValue(schemas.BifrostContextKeyPassthroughExtraParams, true) baseURL, bifrostErr := provider.baseURLOrError(key) if bifrostErr != nil { return nil, bifrostErr } + + if anthropic.ResolveUseAnthropicEndpoints(ctx, key) { + return anthropic.HandleAnthropicChatCompletionRequest( + ctx, + provider.client, + baseURL+providerUtils.GetPathFromContext(ctx, "/v1/messages"), + request, + anthropic.AnthropicRequestBuildConfig{ + Provider: provider.GetProviderKey(), + BetaHeaderOverrides: provider.networkConfig.BetaHeaderOverrides, + ShouldSendBackRawRequest: provider.sendBackRawRequest, + ShouldSendBackRawResponse: provider.sendBackRawResponse, + }, + anthropicHeaders(key), + provider.networkConfig.ExtraHeaders, + nil, + provider.logger, + ) + } + return openai.HandleOpenAIChatCompletionRequest( ctx, provider.client, @@ -210,6 +247,39 @@ func (provider *SGLProvider) ChatCompletionStream(ctx *schemas.BifrostContext, p if bifrostErr != nil { return nil, bifrostErr } + + if anthropic.ResolveUseAnthropicEndpoints(ctx, key) { + url := baseURL + providerUtils.GetPathFromContext(ctx, "/v1/messages") + jsonData, bifrostErr := anthropic.BuildAnthropicChatRequestBody(ctx, request, anthropic.AnthropicRequestBuildConfig{ + Provider: provider.GetProviderKey(), + IsStreaming: true, + BetaHeaderOverrides: provider.networkConfig.BetaHeaderOverrides, + ShouldSendBackRawRequest: provider.sendBackRawRequest, + ShouldSendBackRawResponse: provider.sendBackRawResponse, + }) + if bifrostErr != nil { + return nil, bifrostErr + } + return anthropic.HandleAnthropicChatCompletionStreaming( + ctx, + provider.streamingClient, + url, + jsonData, + anthropicHeaders(key), + provider.networkConfig.ExtraHeaders, + provider.networkConfig.StreamIdleTimeoutInSeconds, + provider.networkConfig.BetaHeaderOverrides, + providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + postHookRunner, + nil, + nil, + provider.logger, + postHookSpanFinalizer, + ) + } + return openai.HandleOpenAIChatCompletionStreaming( ctx, provider.streamingClient, @@ -233,8 +303,36 @@ func (provider *SGLProvider) ChatCompletionStream(ctx *schemas.BifrostContext, p ) } -// Responses performs a responses request to the SGL API. +// Responses performs a responses request to the SGL API. When the key (or the resolved alias) +// has UseAnthropicEndpoints set, the request is routed through SGLang's Anthropic-compatible +// Messages endpoint directly (rather than falling back through ChatCompletion), since the +// Anthropic responses handler operates on the Responses-shaped request natively. func (provider *SGLProvider) Responses(ctx *schemas.BifrostContext, key schemas.Key, request *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + if anthropic.ResolveUseAnthropicEndpoints(ctx, key) { + ctx.SetValue(schemas.BifrostContextKeyPassthroughExtraParams, true) + baseURL, bifrostErr := provider.baseURLOrError(key) + if bifrostErr != nil { + return nil, bifrostErr + } + return anthropic.HandleAnthropicResponsesRequest( + ctx, + provider.client, + baseURL+providerUtils.GetPathFromContext(ctx, "/v1/messages"), + request, + anthropic.AnthropicRequestBuildConfig{ + Provider: provider.GetProviderKey(), + ValidateTools: true, + BetaHeaderOverrides: provider.networkConfig.BetaHeaderOverrides, + ShouldSendBackRawRequest: provider.sendBackRawRequest, + ShouldSendBackRawResponse: provider.sendBackRawResponse, + }, + anthropicHeaders(key), + provider.networkConfig.ExtraHeaders, + nil, + provider.logger, + ) + } + chatResponse, err := provider.ChatCompletion(ctx, key, request.ToChatRequest()) if err != nil { return nil, err @@ -247,6 +345,44 @@ func (provider *SGLProvider) Responses(ctx *schemas.BifrostContext, key schemas. // ResponsesStream performs a streaming responses request to the SGL API. func (provider *SGLProvider) ResponsesStream(ctx *schemas.BifrostContext, postHookRunner schemas.PostHookRunner, postHookSpanFinalizer func(context.Context), key schemas.Key, request *schemas.BifrostResponsesRequest) (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) { + if anthropic.ResolveUseAnthropicEndpoints(ctx, key) { + ctx.SetValue(schemas.BifrostContextKeyPassthroughExtraParams, true) + baseURL, bifrostErr := provider.baseURLOrError(key) + if bifrostErr != nil { + return nil, bifrostErr + } + url := baseURL + providerUtils.GetPathFromContext(ctx, "/v1/messages") + jsonData, bifrostErr := anthropic.BuildAnthropicResponsesRequestBody(ctx, request, anthropic.AnthropicRequestBuildConfig{ + Provider: provider.GetProviderKey(), + IsStreaming: true, + ValidateTools: true, + BetaHeaderOverrides: provider.networkConfig.BetaHeaderOverrides, + ShouldSendBackRawRequest: provider.sendBackRawRequest, + ShouldSendBackRawResponse: provider.sendBackRawResponse, + }) + if bifrostErr != nil { + return nil, bifrostErr + } + return anthropic.HandleAnthropicResponsesStream( + ctx, + provider.streamingClient, + url, + jsonData, + anthropicHeaders(key), + provider.networkConfig.ExtraHeaders, + provider.networkConfig.StreamIdleTimeoutInSeconds, + provider.networkConfig.BetaHeaderOverrides, + providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + postHookRunner, + nil, + nil, + provider.logger, + postHookSpanFinalizer, + ) + } + ctx.SetValue(schemas.BifrostContextKeyIsResponsesToChatCompletionFallback, true) return provider.ChatCompletionStream( ctx, @@ -480,4 +616,4 @@ func (provider *SGLProvider) Passthrough(_ *schemas.BifrostContext, _ schemas.Ke func (provider *SGLProvider) 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()) -} +} \ No newline at end of file diff --git a/core/schemas/account.go b/core/schemas/account.go index d6e34077bcf..436a2734c5d 100644 --- a/core/schemas/account.go +++ b/core/schemas/account.go @@ -141,6 +141,7 @@ type Key struct { SGLKeyConfig *SGLKeyConfig `json:"sgl_key_config,omitempty"` // SGLang-specific key configuration Enabled *bool `json:"enabled,omitempty"` // Whether the key is active (default:true) UseForBatchAPI *bool `json:"use_for_batch_api,omitempty"` // Whether this key can be used for batch API operations (default:false for new keys, migrated keys default to true) + UseAnthropicEndpoints *bool `json:"use_anthropic_endpoints,omitempty"` // Whether to use anthropic endpoints for this key ConfigHash string `json:"config_hash,omitempty"` // Hash of config.json version, used for change detection Status KeyStatusType `json:"status,omitempty"` // Status of key Description string `json:"description,omitempty"` // Description of key @@ -229,7 +230,8 @@ type AliasConfig struct { // top-level (rather than inside each provider sub-config) so the flat "project_id" // JSON key does not collide between embedded sub-configs — Go/sonic silently drop // a field name shared by multiple same-depth anonymous structs. - ProjectID *SecretVar `json:"project_id,omitempty"` + ProjectID *SecretVar `json:"project_id,omitempty"` + UseAnthropicEndpoints *bool `json:"use_anthropic_endpoints,omitempty"` // Whether to use anthropic endpoints for this alias *AzureAliasCfg *VertexAliasCfg @@ -247,6 +249,7 @@ func (ac AliasConfig) isLegacyShape() bool { ac.Description == "" && ac.Region == nil && ac.ProjectID == nil && + ac.UseAnthropicEndpoints == nil && ac.AzureAliasCfg == nil && ac.VertexAliasCfg == nil && ac.BedrockAliasCfg == nil && diff --git a/framework/configstore/clientconfig.go b/framework/configstore/clientconfig.go index 9c2e08bd07f..dd818f760af 100644 --- a/framework/configstore/clientconfig.go +++ b/framework/configstore/clientconfig.go @@ -13,7 +13,6 @@ import ( "time" "github.com/bytedance/sonic" - bifrost "github.com/maximhq/bifrost/core" "github.com/maximhq/bifrost/core/schemas" "github.com/maximhq/bifrost/framework/configstore/tables" ) @@ -523,7 +522,13 @@ func (p *ProviderConfig) Redacted() *ProviderConfig { if key.UseForBatchAPI != nil { redactedConfig.Keys[i].UseForBatchAPI = key.UseForBatchAPI } else { - redactedConfig.Keys[i].UseForBatchAPI = bifrost.Ptr(false) + redactedConfig.Keys[i].UseForBatchAPI = new(false) + } + // Add back use anthropic endpoints + if key.UseAnthropicEndpoints != nil { + redactedConfig.Keys[i].UseAnthropicEndpoints = key.UseAnthropicEndpoints + } else { + redactedConfig.Keys[i].UseAnthropicEndpoints = new(false) } // Add model discovery status and error @@ -853,6 +858,14 @@ func GenerateKeyHash(key schemas.Key) (string, error) { if useForBatchAPI { hash.Write([]byte("useForBatchAPI:true")) } + // Hash UseAnthropicEndpoints (nil = default false for new keys) + useAnthropicEndpoints := false + if key.UseAnthropicEndpoints != nil { + useAnthropicEndpoints = *key.UseAnthropicEndpoints + } + if useAnthropicEndpoints { + hash.Write([]byte("useAnthropicEndpoints:true")) + } return hex.EncodeToString(hash.Sum(nil)), nil } diff --git a/framework/configstore/migrations.go b/framework/configstore/migrations.go index 20f12d03a88..6e22c990ff1 100644 --- a/framework/configstore/migrations.go +++ b/framework/configstore/migrations.go @@ -451,6 +451,7 @@ var configstoreMigrationSteps = []migrationStep{ {IDs: []string{"add_webhook_jobs_table"}, run: migrationAddWebhookJobsTable}, {IDs: []string{"add_webhook_config_client_column"}, run: migrationAddWebhookConfigClientColumn}, {IDs: []string{"add_oauth_config_resource_column"}, run: migrationAddOauthConfigResourceColumn}, + {IDs: []string{"add_use_anthropic_endpoints_column"}, run: migrationAddUseAnthropicEndpointsColumn}, } // quoteSQLiteIdentifier quotes a SQLite identifier, escaping any double quotes. @@ -3144,6 +3145,34 @@ func migrationAddUseForBatchAPIColumnAndS3BucketsConfig(ctx context.Context, db return nil } +// migrationAddUseAnthropicEndpointsColumn adds the use_anthropic_endpoints column to the config_keys table. +func migrationAddUseAnthropicEndpointsColumn(ctx context.Context, db *gorm.DB, logger schemas.Logger) error { + migrationName := "add_use_anthropic_endpoints_column" + logger.Info("[configstore] starting migration %s", migrationName) + defer logger.Info("[configstore] finished migration %s", migrationName) + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: migrationName, + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + if err := addColumnIfNotExists(tx, logger, &tables.TableKey{}, "use_anthropic_endpoints"); err != nil { + return fmt.Errorf("failed to add use_anthropic_endpoints column: %w", err) + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + if err := dropColumnIfExists(tx, logger, &tables.TableKey{}, "use_anthropic_endpoints"); err != nil { + return fmt.Errorf("failed to drop use_anthropic_endpoints column: %w", err) + } + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error running add_use_anthropic_endpoints_column migration: %s", err.Error()) + } + return nil +} + // migrationAddHeaderFilterConfigJSONColumn adds the header_filter_config_json column to the config_client table func migrationAddHeaderFilterConfigJSONColumn(ctx context.Context, db *gorm.DB, logger schemas.Logger) error { migrationName := "add_header_filter_config_json_column" @@ -7771,7 +7800,6 @@ func migrationAddMCPClientDiscoveredToolsColumns(ctx context.Context, db *gorm.D }}) if err := m.Migrate(); err != nil { return fmt.Errorf("error running add_mcp_client_discovered_tools_columns migration: %s", err.Error()) - } return nil } @@ -7875,7 +7903,6 @@ func migrationAddFlexTierPricingColumns(ctx context.Context, db *gorm.DB, logger }}) if err := m.Migrate(); err != nil { return fmt.Errorf("error while running flex tier pricing columns migration: %s", err.Error()) - } return nil } @@ -8304,7 +8331,6 @@ func migrationNormalizeOtelTraceType(ctx context.Context, db *gorm.DB, logger sc }}) if err := m.Migrate(); err != nil { return fmt.Errorf("error running normalize_otel_trace_type migration: %s", err.Error()) - } return nil } diff --git a/framework/configstore/rdb.go b/framework/configstore/rdb.go index 39dd6530de0..6dbc084b265 100644 --- a/framework/configstore/rdb.go +++ b/framework/configstore/rdb.go @@ -152,6 +152,7 @@ func schemaKeyFromTableKey(dbKey tables.TableKey) schemas.Key { Weight: getWeight(dbKey.Weight), Enabled: dbKey.Enabled, UseForBatchAPI: dbKey.UseForBatchAPI, + UseAnthropicEndpoints: dbKey.UseAnthropicEndpoints, AzureKeyConfig: dbKey.AzureKeyConfig, VertexKeyConfig: dbKey.VertexKeyConfig, BedrockKeyConfig: dbKey.BedrockKeyConfig, @@ -180,6 +181,7 @@ func tableKeyFromSchemaKey(provider tables.TableProvider, key schemas.Key) (tabl Weight: &key.Weight, Enabled: key.Enabled, UseForBatchAPI: key.UseForBatchAPI, + UseAnthropicEndpoints: key.UseAnthropicEndpoints, AzureKeyConfig: key.AzureKeyConfig, VertexKeyConfig: key.VertexKeyConfig, BedrockKeyConfig: key.BedrockKeyConfig, @@ -717,6 +719,7 @@ func (s *RDBConfigStore) UpdateProvidersConfig(ctx context.Context, providers ma Weight: &key.Weight, Enabled: key.Enabled, UseForBatchAPI: key.UseForBatchAPI, + UseAnthropicEndpoints: key.UseAnthropicEndpoints, AzureKeyConfig: key.AzureKeyConfig, VertexKeyConfig: key.VertexKeyConfig, BedrockKeyConfig: key.BedrockKeyConfig, @@ -947,6 +950,7 @@ func (s *RDBConfigStore) UpdateProvider(ctx context.Context, provider schemas.Mo Weight: &key.Weight, Enabled: key.Enabled, UseForBatchAPI: key.UseForBatchAPI, + UseAnthropicEndpoints: key.UseAnthropicEndpoints, AzureKeyConfig: key.AzureKeyConfig, VertexKeyConfig: key.VertexKeyConfig, BedrockKeyConfig: key.BedrockKeyConfig, @@ -1088,6 +1092,7 @@ func (s *RDBConfigStore) AddProvider(ctx context.Context, provider schemas.Model Weight: &key.Weight, Enabled: key.Enabled, UseForBatchAPI: key.UseForBatchAPI, + UseAnthropicEndpoints: key.UseAnthropicEndpoints, AzureKeyConfig: key.AzureKeyConfig, VertexKeyConfig: key.VertexKeyConfig, BedrockKeyConfig: key.BedrockKeyConfig, @@ -1976,6 +1981,7 @@ func (s *RDBConfigStore) GetProtectedMCPLibrarySlugs(ctx context.Context) ([]str } return slugs, nil } + func (s *RDBConfigStore) GetMCPClientByID(ctx context.Context, id string) (*tables.TableMCPClient, error) { var mcpClient tables.TableMCPClient if err := s.DB().WithContext(ctx).Where("client_id = ?", id).First(&mcpClient).Error; err != nil { @@ -7781,4 +7787,4 @@ func (s *RDBConfigStore) DeleteWebhookJob(ctx context.Context, id, runnerID stri return fmt.Errorf("webhook job not found or no longer owned by caller") } return nil -} +} \ No newline at end of file diff --git a/framework/configstore/tables/key.go b/framework/configstore/tables/key.go index e8986b46d3b..98bda98fbd7 100644 --- a/framework/configstore/tables/key.go +++ b/framework/configstore/tables/key.go @@ -84,6 +84,10 @@ type TableKey struct { // Batch API configuration UseForBatchAPI *bool `gorm:"default:false" json:"use_for_batch_api,omitempty"` // Whether this key can be used for batch API operations + // UseAnthropicEndpoints routes inference through the provider's Anthropic-compatible + // endpoints instead of its OpenAI-compatible ones. + UseAnthropicEndpoints *bool `gorm:"default:false" json:"use_anthropic_endpoints,omitempty"` + Status string `gorm:"type:varchar(50);default:'unknown'" json:"status"` Description string `gorm:"type:text" json:"description,omitempty"` @@ -136,6 +140,10 @@ func (k *TableKey) BeforeSave(tx *gorm.DB) error { useForBatchAPI := false // DB default k.UseForBatchAPI = &useForBatchAPI } + if k.UseAnthropicEndpoints == nil { + useAnthropicEndpoints := false // DB default + k.UseAnthropicEndpoints = &useAnthropicEndpoints + } // IMPORTANT: All *SecretVar fields assigned from provider config structs (AzureKeyConfig, // VertexKeyConfig, BedrockKeyConfig) MUST be value-copied before assignment. The caller // may retain the config struct pointer; if BeforeSave (or future encryption) mutates a @@ -656,6 +664,10 @@ func (k *TableKey) AfterFind(tx *gorm.DB) error { useForBatchAPI := false // DB default k.UseForBatchAPI = &useForBatchAPI } + if k.UseAnthropicEndpoints == nil { + useAnthropicEndpoints := false // DB default + k.UseAnthropicEndpoints = &useAnthropicEndpoints + } // Reconstruct Azure config if fields are present if k.AzureEndpoint != nil || k.AzureClientID != nil || k.AzureClientSecret != nil || k.AzureTenantID != nil || (k.AzureScopesJSON != nil && *k.AzureScopesJSON != "") { var scopes []string diff --git a/transports/bifrost-http/lib/config.go b/transports/bifrost-http/lib/config.go index daf6e2b001e..2af7779d1c6 100644 --- a/transports/bifrost-http/lib/config.go +++ b/transports/bifrost-http/lib/config.go @@ -1556,6 +1556,7 @@ func mergeProviderKeys(provider schemas.ModelProvider, fileKeys, dbKeys []schema SGLKeyConfig: dbKey.SGLKeyConfig, Enabled: dbKey.Enabled, UseForBatchAPI: dbKey.UseForBatchAPI, + UseAnthropicEndpoints: dbKey.UseAnthropicEndpoints, }) if err != nil { logger.Warn("failed to generate key hash for db key %s (%s): %v, falling back to name comparison", dbKey.Name, provider, err) @@ -1638,6 +1639,7 @@ func reconcileProviderKeys(provider schemas.ModelProvider, fileKeys, dbKeys []sc SGLKeyConfig: dbKey.SGLKeyConfig, Enabled: dbKey.Enabled, UseForBatchAPI: dbKey.UseForBatchAPI, + UseAnthropicEndpoints: dbKey.UseAnthropicEndpoints, }) if err != nil { logger.Warn("failed to generate key hash for db key %s (%s): %v", dbKey.Name, provider, err) @@ -6645,4 +6647,4 @@ func DeepCopy[T any](in T) (T, error) { } err = sonic.Unmarshal(b, &out) return out, err -} +} \ No newline at end of file diff --git a/transports/config.schema.json b/transports/config.schema.json index b2748521476..9cf7ca279c3 100644 --- a/transports/config.schema.json +++ b/transports/config.schema.json @@ -420,7 +420,7 @@ "$ref": "#/$defs/provider" }, "deepseek": { - "$ref": "#/$defs/provider" + "$ref": "#/$defs/provider_with_deepseek_config" }, "vllm": { "$ref": "#/$defs/provider_with_vllm_config" @@ -429,7 +429,7 @@ "$ref": "#/$defs/provider" }, "fireworks": { - "$ref": "#/$defs/provider" + "$ref": "#/$defs/provider_with_fireworks_config" }, "nebius": { "$ref": "#/$defs/provider" @@ -3813,6 +3813,10 @@ "use_deployments_endpoint": { "type": "boolean", "description": "Replicate: use the deployments endpoint instead of the predictions endpoint for this alias." + }, + "use_anthropic_endpoints": { + "type": "boolean", + "description": "Routes chat completions and responses requests through Anthropic-compatible endpoints." } }, "required": ["model_id"], @@ -4067,12 +4071,51 @@ }, "required": ["url"], "additionalProperties": false + }, + "use_anthropic_endpoints": { + "type": "boolean", + "description": "Routes chat completions and responses requests through Anthropic-compatible endpoints.", + "default": false } }, "required": ["sgl_key_config"] } ] }, + "deepseek_key": { + "allOf": [ + { + "$ref": "#/$defs/base_key" + }, + { + "type": "object", + "properties": { + "use_anthropic_endpoints": { + "type": "boolean", + "description": "Routes chat completions and responses requests through Anthropic-compatible endpoints.", + "default": false + } + } + } + ] + }, + "fireworks_key": { + "allOf": [ + { + "$ref": "#/$defs/base_key" + }, + { + "type": "object", + "properties": { + "use_anthropic_endpoints": { + "type": "boolean", + "description": "Routes chat completions and responses requests through Anthropic-compatible endpoints.", + "default": false + } + } + } + ] + }, "azure_key": { "allOf": [ { @@ -4513,6 +4556,84 @@ }, "additionalProperties": false }, + "provider_with_deepseek_config": { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": { + "$ref": "#/$defs/deepseek_key" + }, + "minItems": 1, + "description": "API keys for this provider" + }, + "network_config": { + "$ref": "#/$defs/network_config" + }, + "concurrency_and_buffer_size": { + "$ref": "#/$defs/concurrency_and_buffer_size" + }, + "proxy_config": { + "$ref": "#/$defs/proxy_config" + }, + "send_back_raw_request": { + "type": "boolean", + "description": "Include raw request in BifrostResponse (default: false)" + }, + "send_back_raw_response": { + "type": "boolean", + "description": "Include raw response in BifrostResponse (default: false)" + }, + "store_raw_request_response": { + "type": "boolean", + "description": "Capture raw request/response for internal logging only; strip from API responses returned to clients (default: false)" + }, + "custom_provider_config": { + "$ref": "#/$defs/custom_provider_config" + } + }, + "required": ["keys"], + "additionalProperties": false + }, + "provider_with_fireworks_config": { + "type": "object", + "properties": { + "keys": { + "type": "array", + "items": { + "$ref": "#/$defs/fireworks_key" + }, + "minItems": 1, + "description": "API keys for this provider" + }, + "network_config": { + "$ref": "#/$defs/network_config" + }, + "concurrency_and_buffer_size": { + "$ref": "#/$defs/concurrency_and_buffer_size" + }, + "proxy_config": { + "$ref": "#/$defs/proxy_config" + }, + "send_back_raw_request": { + "type": "boolean", + "description": "Include raw request in BifrostResponse (default: false)" + }, + "send_back_raw_response": { + "type": "boolean", + "description": "Include raw response in BifrostResponse (default: false)" + }, + "store_raw_request_response": { + "type": "boolean", + "description": "Capture raw request/response for internal logging only; strip from API responses returned to clients (default: false)" + }, + "custom_provider_config": { + "$ref": "#/$defs/custom_provider_config" + } + }, + "required": ["keys"], + "additionalProperties": false + }, "mcp_client_config": { "type": "object", "properties": { @@ -7035,4 +7156,4 @@ } } } -} +} \ No newline at end of file diff --git a/ui/app/workspace/providers/fragments/apiKeysFormFragment.tsx b/ui/app/workspace/providers/fragments/apiKeysFormFragment.tsx index 36e8ac9508c..91fdcc47a2f 100644 --- a/ui/app/workspace/providers/fragments/apiKeysFormFragment.tsx +++ b/ui/app/workspace/providers/fragments/apiKeysFormFragment.tsx @@ -60,6 +60,8 @@ export function ApiKeyFormFragment({ control, providerName, baseProviderType, fo const isVLLM = effectiveProvider === "vllm"; const isOllama = effectiveProvider === "ollama"; const isSGL = effectiveProvider === "sgl"; + const isDeepseek = effectiveProvider === "deepseek"; + const isFireworks = effectiveProvider === "fireworks"; const isKeylessProvider = isOllama || isSGL; const supportsBatchAPI = BATCH_SUPPORTED_PROVIDERS.includes(effectiveProvider); @@ -766,6 +768,27 @@ export function ApiKeyFormFragment({ control, providerName, baseProviderType, fo /> )} + {(isSGL || isDeepseek || isFireworks) && ( +
+ ( + +
+ Use Anthropic Endpoints + + Routes chat completions and responses requests through Anthropic-compatible endpoints. + +
+ + + +
+ )} + /> +
+ )} {isBedrock && (
diff --git a/ui/app/workspace/providers/fragments/deploymentsTable.tsx b/ui/app/workspace/providers/fragments/deploymentsTable.tsx index d87f6881cb6..1121bb56beb 100644 --- a/ui/app/workspace/providers/fragments/deploymentsTable.tsx +++ b/ui/app/workspace/providers/fragments/deploymentsTable.tsx @@ -292,6 +292,28 @@ function ReplicateSection({ config, onChange, disabled }: ProviderSectionProps) ); } +function UseAnthropicEndpointsToggleSection({ config, onChange, disabled, providerName }: ProviderSectionProps & { providerName: string }) { + return ( +
+ +
+
+ +

+ Route chat completions and responses requests through Anthropic-compatible endpoints. +

+
+ onChange({ use_anthropic_endpoints: checked ? true : undefined })} + disabled={disabled} + /> +
+
+ ); +} + function ProviderSection({ providerName, ...props }: ProviderSectionProps & { providerName: string }) { switch (providerName) { case "azure": @@ -304,6 +326,12 @@ function ProviderSection({ providerName, ...props }: ProviderSectionProps & { pr return ; case "replicate": return ; + case "sgl": + return ; + case "deepseek": + return ; + case "fireworks": + return ; default: return null; } diff --git a/ui/lib/types/config.ts b/ui/lib/types/config.ts index cf368bc8f95..ce180e40525 100644 --- a/ui/lib/types/config.ts +++ b/ui/lib/types/config.ts @@ -73,6 +73,7 @@ export interface AliasConfig { inference_profile_arn?: SecretVar; // Replicate overrides use_deployments_endpoint?: boolean; + use_anthropic_endpoints?: boolean; } // AzureKeyConfig matching Go's schemas.AzureKeyConfig @@ -217,6 +218,7 @@ export interface ModelProviderKey { weight: number; enabled?: boolean; use_for_batch_api?: boolean; + use_anthropic_endpoints?: boolean; aliases?: Record; azure_key_config?: AzureKeyConfig; vertex_key_config?: VertexKeyConfig; diff --git a/ui/lib/types/schemas.ts b/ui/lib/types/schemas.ts index 88523d61067..acd0e10d252 100644 --- a/ui/lib/types/schemas.ts +++ b/ui/lib/types/schemas.ts @@ -302,6 +302,7 @@ const aliasConfigObjectSchema = z.object({ inference_profile_arn: secretVarSchema.optional(), // Replicate overrides use_deployments_endpoint: z.boolean().optional(), + use_anthropic_endpoints: z.boolean().optional(), }); // The Go server emits the legacy string wire shape (`{"my-alias": "model-id"}`) @@ -348,6 +349,7 @@ export const modelProviderKeySchema = z ollama_key_config: ollamaKeyConfigSchema.optional(), sgl_key_config: sglKeyConfigSchema.optional(), use_for_batch_api: z.boolean().optional(), + use_anthropic_endpoints: z.boolean().optional(), enabled: z.boolean().optional(), }) .refine( From e6b494bcbde7597ef5042dd69ff76c6728408622 Mon Sep 17 00:00:00 2001 From: Samyabrata Maji <116789799+sammaji@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:26:50 +0530 Subject: [PATCH 2/2] fix: use anthropic endpoints in chat completion and responses in deepseek --- core/providers/anthropic/chat.go | 7 + core/providers/anthropic/requestbuilder.go | 1 + core/providers/anthropic/responses.go | 7 + core/providers/anthropic/types.go | 17 +- core/providers/deepseek/deepseek.go | 172 +++++++- .../deepseek/deepseek_anthropic_test.go | 380 ++++++++++++++++++ core/providers/deepseek/deepseek_test.go | 37 +- plugins/compat/conversion.go | 38 -- 8 files changed, 598 insertions(+), 61 deletions(-) create mode 100644 core/providers/deepseek/deepseek_anthropic_test.go diff --git a/core/providers/anthropic/chat.go b/core/providers/anthropic/chat.go index 4b433ab9345..d67a953d4f2 100644 --- a/core/providers/anthropic/chat.go +++ b/core/providers/anthropic/chat.go @@ -582,6 +582,13 @@ func ToAnthropicChatRequest(ctx *schemas.BifrostContext, bifrostReq *schemas.Bif } } + // DeepSeek rejects a forced tool_choice while thinking is enabled (which is + // the default). Force thinking off when tool_choice pins a specific tool. + if bifrostReq.Provider == schemas.DeepSeek && anthropicReq.ToolChoice != nil && + anthropicReq.ToolChoice.Type == "tool" { + anthropicReq.Thinking = &AnthropicThinking{Type: "disabled"} + } + // Convert service tier if bifrostReq.Params.ServiceTier != nil { mapped := MapBifrostServiceTierToAnthropicRequest(*bifrostReq.Params.ServiceTier) diff --git a/core/providers/anthropic/requestbuilder.go b/core/providers/anthropic/requestbuilder.go index 301e4a9706b..ecdabc16bc5 100644 --- a/core/providers/anthropic/requestbuilder.go +++ b/core/providers/anthropic/requestbuilder.go @@ -112,6 +112,7 @@ var AnthropicProviderRequestDefaultsMap = map[schemas.ModelProvider]AnthropicPro schemas.BedrockMantle: { RemapToolVersions: true, }, + schemas.DeepSeek: {}, // Vertex publisher endpoint: model + region in URL, anthropic_version // required, beta headers in body (not HTTP), cache_control.scope stripped // at marshal time, tool versions remapped. diff --git a/core/providers/anthropic/responses.go b/core/providers/anthropic/responses.go index b7e8b63940e..e717776df20 100644 --- a/core/providers/anthropic/responses.go +++ b/core/providers/anthropic/responses.go @@ -3736,6 +3736,13 @@ func ToAnthropicResponsesRequest(ctx *schemas.BifrostContext, bifrostReq *schema anthropicReq.ToolChoice = anthropicToolChoice } } + + // DeepSeek rejects a forced tool_choice while thinking is on. Force thinking + // off when tool_choice pins a specific tool. + if bifrostReq.Provider == schemas.DeepSeek && anthropicReq.ToolChoice != nil && + anthropicReq.ToolChoice.Type == "tool" { + anthropicReq.Thinking = &AnthropicThinking{Type: "disabled"} + } } if bifrostReq.Input != nil { diff --git a/core/providers/anthropic/types.go b/core/providers/anthropic/types.go index 91b8ea897be..0dde2c7f8dd 100644 --- a/core/providers/anthropic/types.go +++ b/core/providers/anthropic/types.go @@ -257,6 +257,21 @@ var ProviderFeatures = map[schemas.ModelProvider]ProviderFeatureSupport{ // FastMode, InferenceGeo, AdvisorTool, TaskBudgets — not documented on Az-platform; leave off. ServiceTier: true, }, + schemas.DeepSeek: { + WebSearch: true, + WebSearchDynamic: true, + ContainerBasic: true, + ContextManagementField: true, + Compaction: true, + ContextEditing: true, + PromptCachingScope: true, + AdvancedToolUse: true, + InputExamples: true, + EagerInputStreaming: true, + StructuredOutputs: true, + InterleavedThinking: true, + ServiceTier: true, + }, } // ==================== REQUEST TYPES ==================== @@ -1861,4 +1876,4 @@ func parseAnthropicFileTimestamp(timestamp string) int64 { // AnthropicCountTokensResponse models the payload returned by Anthropic's count tokens endpoint. type AnthropicCountTokensResponse struct { InputTokens int `json:"input_tokens"` -} \ No newline at end of file +} diff --git a/core/providers/deepseek/deepseek.go b/core/providers/deepseek/deepseek.go index bba9d74e71d..663748b2d59 100644 --- a/core/providers/deepseek/deepseek.go +++ b/core/providers/deepseek/deepseek.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "github.com/maximhq/bifrost/core/providers/anthropic" "github.com/maximhq/bifrost/core/providers/openai" providerUtils "github.com/maximhq/bifrost/core/providers/utils" schemas "github.com/maximhq/bifrost/core/schemas" @@ -60,6 +61,69 @@ func NewDeepSeekProvider(config *schemas.ProviderConfig, logger schemas.Logger) }, nil } +func (provider *DeepSeekProvider) anthropicHeaders(key schemas.Key) map[string]string { + headers := map[string]string{} + if key.Value.GetValue() != "" { + headers["x-api-key"] = key.Value.GetValue() + } + return headers +} + +// disableThinkingForForcedToolChoice disables thinking when it would otherwise be +// rejected by DeepSeek's OpenAI-compatible endpoint. This covers two distinct cases: +// +// 1. A forced tool_choice ("required"/"any", or the struct form pinning a specific +// function/custom/allowed_tools call) — DeepSeek rejects a forced tool_choice while +// thinking is enabled (the default). +// 2. A conversation that already contains an assistant turn without reasoning_content +// (e.g. synthetic/injected history, or a turn produced while thinking was off) — +// DeepSeek requires prior reasoning_content to be replayed once thinking is on, so if +// any assistant turn is missing it, thinking must stay off for the whole request. +func disableThinkingForForcedToolChoice(request *schemas.BifrostChatRequest) { + if request.Params == nil { + return + } + + disable := false + + if tc := request.Params.ToolChoice; tc != nil { + switch { + case tc.ChatToolChoiceStr != nil: + switch schemas.ChatToolChoiceType(*tc.ChatToolChoiceStr) { + case schemas.ChatToolChoiceTypeRequired, schemas.ChatToolChoiceTypeAny: + disable = true + } + case tc.ChatToolChoiceStruct != nil: + switch tc.ChatToolChoiceStruct.Type { + case schemas.ChatToolChoiceTypeRequired, schemas.ChatToolChoiceTypeAny, + schemas.ChatToolChoiceTypeFunction, schemas.ChatToolChoiceTypeCustom, + schemas.ChatToolChoiceTypeAllowedTools: + disable = true + } + } + } + + if !disable { + for _, msg := range request.Input { + if msg.Role != schemas.ChatMessageRoleAssistant { + continue + } + if msg.ChatAssistantMessage == nil || msg.ChatAssistantMessage.Reasoning == nil { + disable = true + break + } + } + } + + if !disable { + return + } + if request.Params.ExtraParams == nil { + request.Params.ExtraParams = make(map[string]any, 1) + } + request.Params.ExtraParams["thinking"] = map[string]any{"type": "disabled"} +} + // GetProviderKey returns the provider identifier for DeepSeek. func (provider *DeepSeekProvider) GetProviderKey() schemas.ModelProvider { return schemas.DeepSeek @@ -126,9 +190,28 @@ func (provider *DeepSeekProvider) TextCompletionStream(ctx *schemas.BifrostConte ) } -// ChatCompletion performs a chat completion request to the DeepSeek API. +// ChatCompletion performs a chat completion request to DeepSeek's Anthropic-compatible API. func (provider *DeepSeekProvider) ChatCompletion(ctx *schemas.BifrostContext, key schemas.Key, request *schemas.BifrostChatRequest) (*schemas.BifrostChatResponse, *schemas.BifrostError) { + if anthropic.ResolveUseAnthropicEndpoints(ctx, key) { + return anthropic.HandleAnthropicChatCompletionRequest( + ctx, + provider.client, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/anthropic/v1/messages"), + request, + anthropic.AnthropicRequestBuildConfig{ + Provider: schemas.DeepSeek, + ShouldSendBackRawRequest: provider.sendBackRawRequest, + ShouldSendBackRawResponse: provider.sendBackRawResponse, + }, + provider.anthropicHeaders(key), + provider.networkConfig.ExtraHeaders, + nil, + provider.logger, + ) + } + ctx.SetValue(schemas.BifrostContextKeyPassthroughExtraParams, true) + disableThinkingForForcedToolChoice(request) return openai.HandleOpenAIChatCompletionRequest( ctx, provider.client, @@ -146,12 +229,43 @@ func (provider *DeepSeekProvider) ChatCompletion(ctx *schemas.BifrostContext, ke ) } -// ChatCompletionStream performs a streaming chat completion request to the DeepSeek API. +// ChatCompletionStream performs a streaming chat completion request to DeepSeek's Anthropic-compatible API. // It supports real-time streaming of responses using Server-Sent Events (SSE). -// Uses DeepSeek's OpenAI-compatible streaming format. // Returns a channel containing BifrostStreamChunk objects representing the stream or an error if the request fails. func (provider *DeepSeekProvider) ChatCompletionStream(ctx *schemas.BifrostContext, postHookRunner schemas.PostHookRunner, postHookSpanFinalizer func(context.Context), key schemas.Key, request *schemas.BifrostChatRequest) (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) { + if anthropic.ResolveUseAnthropicEndpoints(ctx, key) { + jsonData, bifrostErr := anthropic.BuildAnthropicChatRequestBody(ctx, request, anthropic.AnthropicRequestBuildConfig{ + Provider: schemas.DeepSeek, + IsStreaming: true, + ShouldSendBackRawRequest: provider.sendBackRawRequest, + ShouldSendBackRawResponse: provider.sendBackRawResponse, + }) + if bifrostErr != nil { + return nil, bifrostErr + } + + return anthropic.HandleAnthropicChatCompletionStreaming( + ctx, + provider.streamingClient, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/anthropic/v1/messages"), + jsonData, + provider.anthropicHeaders(key), + provider.networkConfig.ExtraHeaders, + provider.networkConfig.StreamIdleTimeoutInSeconds, + provider.networkConfig.BetaHeaderOverrides, + providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + schemas.DeepSeek, + postHookRunner, + nil, + nil, + provider.logger, + postHookSpanFinalizer, + ) + } + ctx.SetValue(schemas.BifrostContextKeyPassthroughExtraParams, true) + disableThinkingForForcedToolChoice(request) return openai.HandleOpenAIChatCompletionStreaming( ctx, provider.streamingClient, @@ -175,7 +289,26 @@ func (provider *DeepSeekProvider) ChatCompletionStream(ctx *schemas.BifrostConte ) } +// Responses performs a Responses API request against DeepSeek's Anthropic-compatible endpoint. func (provider *DeepSeekProvider) Responses(ctx *schemas.BifrostContext, key schemas.Key, request *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { + if anthropic.ResolveUseAnthropicEndpoints(ctx, key) { + return anthropic.HandleAnthropicResponsesRequest( + ctx, + provider.client, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/anthropic/v1/messages"), + request, + anthropic.AnthropicRequestBuildConfig{ + Provider: schemas.DeepSeek, + ShouldSendBackRawRequest: provider.sendBackRawRequest, + ShouldSendBackRawResponse: provider.sendBackRawResponse, + }, + provider.anthropicHeaders(key), + provider.networkConfig.ExtraHeaders, + nil, + provider.logger, + ) + } + chatResponse, err := provider.ChatCompletion(ctx, key, request.ToChatRequest()) if err != nil { return nil, err @@ -186,8 +319,39 @@ func (provider *DeepSeekProvider) Responses(ctx *schemas.BifrostContext, key sch return response, nil } -// ResponsesStream performs a streaming responses request to the DeepSeek API. +// ResponsesStream performs a streaming Responses API request to DeepSeek's Anthropic-compatible endpoint. func (provider *DeepSeekProvider) ResponsesStream(ctx *schemas.BifrostContext, postHookRunner schemas.PostHookRunner, postHookSpanFinalizer func(context.Context), key schemas.Key, request *schemas.BifrostResponsesRequest) (chan *schemas.BifrostStreamChunk, *schemas.BifrostError) { + if anthropic.ResolveUseAnthropicEndpoints(ctx, key) { + jsonData, bifrostErr := anthropic.BuildAnthropicResponsesRequestBody(ctx, request, anthropic.AnthropicRequestBuildConfig{ + Provider: schemas.DeepSeek, + IsStreaming: true, + ShouldSendBackRawRequest: provider.sendBackRawRequest, + ShouldSendBackRawResponse: provider.sendBackRawResponse, + }) + if bifrostErr != nil { + return nil, bifrostErr + } + + return anthropic.HandleAnthropicResponsesStream( + ctx, + provider.streamingClient, + provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/anthropic/v1/messages"), + jsonData, + provider.anthropicHeaders(key), + provider.networkConfig.ExtraHeaders, + provider.networkConfig.StreamIdleTimeoutInSeconds, + provider.networkConfig.BetaHeaderOverrides, + providerUtils.ShouldSendBackRawRequest(ctx, provider.sendBackRawRequest), + providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), + provider.GetProviderKey(), + postHookRunner, + nil, + nil, + provider.logger, + postHookSpanFinalizer, + ) + } + ctx.SetValue(schemas.BifrostContextKeyIsResponsesToChatCompletionFallback, true) return provider.ChatCompletionStream( ctx, diff --git a/core/providers/deepseek/deepseek_anthropic_test.go b/core/providers/deepseek/deepseek_anthropic_test.go new file mode 100644 index 00000000000..e256850960c --- /dev/null +++ b/core/providers/deepseek/deepseek_anthropic_test.go @@ -0,0 +1,380 @@ +package deepseek_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/maximhq/bifrost/core/internal/llmtests" + deepseek "github.com/maximhq/bifrost/core/providers/deepseek" + schemas "github.com/maximhq/bifrost/core/schemas" +) + +type testLogger struct{} + +func (l testLogger) Debug(string, ...any) {} +func (l testLogger) Info(string, ...any) {} +func (l testLogger) Warn(string, ...any) {} +func (l testLogger) Error(string, ...any) {} +func (l testLogger) Fatal(string, ...any) {} +func (l testLogger) SetLevel(schemas.LogLevel) {} +func (l testLogger) SetOutputType(schemas.LoggerOutputType) {} +func (l testLogger) LogHTTPRequest(schemas.LogLevel, string) schemas.LogEventBuilder { + return schemas.NoopLogEvent +} + +func newTestDeepSeekProvider(baseURL string) (*deepseek.DeepSeekProvider, error) { + return deepseek.NewDeepSeekProvider(&schemas.ProviderConfig{ + NetworkConfig: schemas.NetworkConfig{ + BaseURL: baseURL, + DefaultRequestTimeoutInSeconds: 5, + StreamIdleTimeoutInSeconds: 5, + MaxConnsPerHost: 1, + }, + ConcurrencyAndBufferSize: schemas.ConcurrencyAndBufferSize{ + Concurrency: 1, + BufferSize: 1, + }, + }, testLogger{}) +} + +func newAnthropicResponse() string { + return `{"id":"msg_1","type":"message","role":"assistant","model":"deepseek-chat","content":[{"type":"text","text":"hello"}],"stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":1}}` +} + +func TestChatCompletion_UsesAnthropicEndpoint(t *testing.T) { + t.Parallel() + + var captured map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/anthropic/v1/messages" { + t.Fatalf("path = %q, want /anthropic/v1/messages", r.URL.Path) + } + if got := r.Header.Get("x-api-key"); got != "test-api-key" { + t.Fatalf("x-api-key = %q, want test-api-key", got) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if err := json.Unmarshal(body, &captured); err != nil { + t.Fatalf("decode body: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, newAnthropicResponse()) + })) + defer server.Close() + + provider, err := newTestDeepSeekProvider(server.URL) + if err != nil { + t.Fatalf("NewDeepSeekProvider: %v", err) + } + + ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + msg := "hello" + resp, bifrostErr := provider.ChatCompletion(ctx, schemas.Key{Value: schemas.SecretVar{Val: "test-api-key"}, UseAnthropicEndpoints: new(true)}, &schemas.BifrostChatRequest{ + Provider: schemas.DeepSeek, + Model: "deepseek-v4-flash", + Input: []schemas.ChatMessage{{ + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ContentStr: &msg}, + }}, + }) + if bifrostErr != nil { + t.Fatalf("ChatCompletion: %v", bifrostErr.Error.Message) + } + if resp == nil || len(resp.Choices) == 0 { + t.Fatalf("expected chat response, got %#v", resp) + } + if _, ok := captured["messages"]; !ok { + t.Fatalf("outbound body missing messages: %#v", captured) + } +} + +func TestResponses_UsesAnthropicEndpointAndKeepsWebSearch(t *testing.T) { + t.Parallel() + + var captured map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/anthropic/v1/messages" { + t.Fatalf("path = %q, want /anthropic/v1/messages", r.URL.Path) + } + if got := r.Header.Get("x-api-key"); got != "test-api-key" { + t.Fatalf("x-api-key = %q, want test-api-key", got) + } + + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if err := json.Unmarshal(body, &captured); err != nil { + t.Fatalf("decode body: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, newAnthropicResponse()) + })) + defer server.Close() + + provider, err := newTestDeepSeekProvider(server.URL) + if err != nil { + t.Fatalf("NewDeepSeekProvider: %v", err) + } + + ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + resp, bifrostErr := provider.Responses(ctx, schemas.Key{Value: schemas.SecretVar{Val: "test-api-key"}, UseAnthropicEndpoints: new(true)}, &schemas.BifrostResponsesRequest{ + Provider: schemas.DeepSeek, + Model: "deepseek-v4-pro", + Input: []schemas.ResponsesMessage{{ + Type: new(schemas.ResponsesMessageTypeMessage), + Role: new(schemas.ResponsesInputMessageRoleUser), + Content: &schemas.ResponsesMessageContent{ + ContentStr: new("hello"), + }, + }}, + Params: &schemas.ResponsesParameters{ + Tools: []schemas.ResponsesTool{{Type: schemas.ResponsesToolTypeWebSearch}}, + }, + }) + if bifrostErr != nil { + t.Fatalf("Responses: %v", bifrostErr.Error.Message) + } + if resp == nil || len(resp.Output) == 0 { + t.Fatalf("expected responses payload, got %#v", resp) + } + tools, ok := captured["tools"].([]any) + if !ok || len(tools) == 0 { + t.Fatalf("outbound body missing tools: %#v", captured) + } + toolJSON, _ := json.Marshal(tools[0]) + if !json.Valid(toolJSON) || !strings.Contains(string(toolJSON), "web_search") { + t.Fatalf("outbound tool body did not preserve web search: %s", toolJSON) + } +} + +func TestChatCompletion_DisablesThinkingForForcedToolChoice(t *testing.T) { + t.Parallel() + + var captured map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if err := json.Unmarshal(body, &captured); err != nil { + t.Fatalf("decode body: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, newAnthropicResponse()) + })) + defer server.Close() + + provider, err := newTestDeepSeekProvider(server.URL) + if err != nil { + t.Fatalf("NewDeepSeekProvider: %v", err) + } + + chatTool := llmtests.GetSampleChatTool(llmtests.SampleToolTypeTime) + if chatTool == nil { + t.Fatal("GetSampleChatTool returned nil") + } + + ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + msg := "get the current time in UTC" + _, bifrostErr := provider.ChatCompletion(ctx, schemas.Key{Value: schemas.SecretVar{Val: "test-api-key"}, UseAnthropicEndpoints: new(true)}, &schemas.BifrostChatRequest{ + Provider: schemas.DeepSeek, + Model: "deepseek-v4-flash", + Input: []schemas.ChatMessage{{ + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ContentStr: &msg}, + }}, + Params: &schemas.ChatParameters{ + Tools: []schemas.ChatTool{*chatTool}, + ToolChoice: &schemas.ChatToolChoice{ + ChatToolChoiceStruct: &schemas.ChatToolChoiceStruct{ + Type: schemas.ChatToolChoiceTypeFunction, + Function: &schemas.ChatToolChoiceFunction{ + Name: string(llmtests.SampleToolTypeTime), + }, + }, + }, + }, + }) + if bifrostErr != nil { + t.Fatalf("ChatCompletion: %v", bifrostErr.Error.Message) + } + + thinking, ok := captured["thinking"].(map[string]any) + if !ok { + t.Fatalf("expected thinking block in outbound body, got %#v", captured) + } + if got := thinking["type"]; got != "disabled" { + t.Fatalf("thinking.type = %v, want disabled", got) + } +} + +func TestResponses_DisablesThinkingForForcedToolChoice(t *testing.T) { + t.Parallel() + + var captured map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if err := json.Unmarshal(body, &captured); err != nil { + t.Fatalf("decode body: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, newAnthropicResponse()) + })) + defer server.Close() + + provider, err := newTestDeepSeekProvider(server.URL) + if err != nil { + t.Fatalf("NewDeepSeekProvider: %v", err) + } + + responsesTool := llmtests.GetSampleResponsesTool(llmtests.SampleToolTypeTime) + if responsesTool == nil { + t.Fatal("GetSampleResponsesTool returned nil") + } + + ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + _, bifrostErr := provider.Responses(ctx, schemas.Key{Value: schemas.SecretVar{Val: "test-api-key"}, UseAnthropicEndpoints: new(true)}, &schemas.BifrostResponsesRequest{ + Provider: schemas.DeepSeek, + Model: "deepseek-v4-pro", + Input: []schemas.ResponsesMessage{{ + Type: new(schemas.ResponsesMessageTypeMessage), + Role: new(schemas.ResponsesInputMessageRoleUser), + Content: &schemas.ResponsesMessageContent{ + ContentStr: new("get the current time in UTC"), + }, + }}, + Params: &schemas.ResponsesParameters{ + Tools: []schemas.ResponsesTool{*responsesTool}, + ToolChoice: &schemas.ResponsesToolChoice{ + ResponsesToolChoiceStruct: &schemas.ResponsesToolChoiceStruct{ + Type: schemas.ResponsesToolChoiceTypeFunction, + Name: new(string(llmtests.SampleToolTypeTime)), + }, + }, + }, + }) + if bifrostErr != nil { + t.Fatalf("Responses: %v", bifrostErr.Error.Message) + } + + thinking, ok := captured["thinking"].(map[string]any) + if !ok { + t.Fatalf("expected thinking block in outbound body, got %#v", captured) + } + if got := thinking["type"]; got != "disabled" { + t.Fatalf("thinking.type = %v, want disabled", got) + } +} + +func TestChatCompletion_DefaultsToOpenAIEndpoint(t *testing.T) { + t.Parallel() + + var capturedPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + if got := r.Header.Get("Authorization"); got != "Bearer test-api-key" { + t.Fatalf("Authorization = %q, want Bearer test-api-key", got) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"id":"chatcmpl_1","object":"chat.completion","model":"deepseek-v4-flash","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`) + })) + defer server.Close() + + provider, err := newTestDeepSeekProvider(server.URL) + if err != nil { + t.Fatalf("NewDeepSeekProvider: %v", err) + } + + ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + msg := "hello" + resp, bifrostErr := provider.ChatCompletion(ctx, schemas.Key{Value: schemas.SecretVar{Val: "test-api-key"}}, &schemas.BifrostChatRequest{ + Provider: schemas.DeepSeek, + Model: "deepseek-v4-flash", + Input: []schemas.ChatMessage{{ + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ContentStr: &msg}, + }}, + }) + if bifrostErr != nil { + t.Fatalf("ChatCompletion: %v", bifrostErr.Error.Message) + } + if resp == nil || len(resp.Choices) == 0 { + t.Fatalf("expected chat response, got %#v", resp) + } + if capturedPath != "/chat/completions" { + t.Fatalf("path = %q, want /chat/completions", capturedPath) + } +} + +func TestChatCompletion_OpenAIEndpointDisablesThinkingForRequiredToolChoice(t *testing.T) { + t.Parallel() + + var captured map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if err := json.Unmarshal(body, &captured); err != nil { + t.Fatalf("decode body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"id":"chatcmpl_1","object":"chat.completion","model":"deepseek-v4-flash","choices":[{"index":0,"message":{"role":"assistant","content":"hello"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`) + })) + defer server.Close() + + provider, err := newTestDeepSeekProvider(server.URL) + if err != nil { + t.Fatalf("NewDeepSeekProvider: %v", err) + } + + chatTool := llmtests.GetSampleChatTool(llmtests.SampleToolTypeTime) + if chatTool == nil { + t.Fatal("GetSampleChatTool returned nil") + } + + ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + msg := "get the current time in UTC" + requiredChoice := string(schemas.ChatToolChoiceTypeRequired) + _, bifrostErr := provider.ChatCompletion(ctx, schemas.Key{Value: schemas.SecretVar{Val: "test-api-key"}}, &schemas.BifrostChatRequest{ + Provider: schemas.DeepSeek, + Model: "deepseek-v4-flash", + Input: []schemas.ChatMessage{{ + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ContentStr: &msg}, + }}, + Params: &schemas.ChatParameters{ + Tools: []schemas.ChatTool{*chatTool}, + ToolChoice: &schemas.ChatToolChoice{ + ChatToolChoiceStr: &requiredChoice, + }, + }, + }) + if bifrostErr != nil { + t.Fatalf("ChatCompletion: %v", bifrostErr.Error.Message) + } + + thinking, ok := captured["thinking"].(map[string]any) + if !ok { + t.Fatalf("expected thinking block in outbound body, got %#v", captured) + } + if got := thinking["type"]; got != "disabled" { + t.Fatalf("thinking.type = %v, want disabled", got) + } +} diff --git a/core/providers/deepseek/deepseek_test.go b/core/providers/deepseek/deepseek_test.go index 76d173cbf23..38f07628b4f 100644 --- a/core/providers/deepseek/deepseek_test.go +++ b/core/providers/deepseek/deepseek_test.go @@ -34,27 +34,28 @@ func TestDeepseek(t *testing.T) { EmbeddingModel: "", // DeepSeek doesn't support embedding ReasoningModel: "deepseek-v4-pro", Scenarios: llmtests.TestScenarios{ - TextCompletion: true, - TextCompletionStream: true, - SimpleChat: true, - CompletionStream: true, - MultiTurnConversation: true, - ToolCalls: true, - ToolCallsStreaming: true, - MultipleToolCalls: false, - End2EndToolCalling: true, - AutomaticFunctionCall: true, - ImageURL: false, - ImageBase64: false, - MultipleImages: false, - CompleteEnd2End: true, - Embedding: false, - ListModels: true, - Reasoning: true, + TextCompletion: true, + TextCompletionStream: true, + SimpleChat: true, + CompletionStream: true, + MultiTurnConversation: true, + ToolCalls: true, + ToolCallsStreaming: true, + MultipleToolCalls: true, + MultipleToolCallsStreaming: true, + End2EndToolCalling: true, + AutomaticFunctionCall: true, + ImageURL: false, + ImageBase64: false, + MultipleImages: false, + CompleteEnd2End: true, + Embedding: false, + ListModels: true, + Reasoning: true, }, } t.Run("DeepSeekTests", func(t *testing.T) { llmtests.RunAllComprehensiveTests(t, client, ctx, testConfig) }) -} +} \ No newline at end of file diff --git a/plugins/compat/conversion.go b/plugins/compat/conversion.go index 80932405870..7ea01ae03a1 100644 --- a/plugins/compat/conversion.go +++ b/plugins/compat/conversion.go @@ -11,45 +11,7 @@ func applyParameterConversion(req *schemas.BifrostRequest) { } if req.ResponsesRequest != nil { flattenNamespaceTools(req.ResponsesRequest) - disableThinkingWithToolChoiceForResponses(req.ResponsesRequest) } - if req.ChatRequest != nil { - disableThinkingWithToolChoice(req.ChatRequest) - } -} - -// disableThinkingWithToolChoice disables thinking when tool_choice forces a tool call. -func disableThinkingWithToolChoice(req *schemas.BifrostChatRequest) { - if req.Provider != schemas.DeepSeek || req.Params == nil || req.Params.ToolChoice == nil { - return - } - tc := req.Params.ToolChoice - if tc.ChatToolChoiceStr != nil && *tc.ChatToolChoiceStr == string(schemas.ChatToolChoiceTypeRequired) { - req.Params.ExtraParams = disableThinking(req.Params.ExtraParams) - } -} - -// disableThinkingWithToolChoiceForResponses disables thinking when tool_choice forces a tool call. -func disableThinkingWithToolChoiceForResponses(req *schemas.BifrostResponsesRequest) { - if req.Provider != schemas.DeepSeek || req.Params == nil || req.Params.ToolChoice == nil { - return - } - tc := req.Params.ToolChoice - if tc.ResponsesToolChoiceStr != nil && *tc.ResponsesToolChoiceStr == string(schemas.ResponsesToolChoiceTypeRequired) { - req.Params.ExtraParams = disableThinking(req.Params.ExtraParams) - } -} - -// disableThinking sets thinking {"type": "disabled"} in extraParams, overwriting -// any caller-provided value. DeepSeek models run with thinking enabled by default, -// and thinking mode rejects forced tool_choice — so a forced tool call requires -// thinking off. -func disableThinking(extraParams map[string]any) map[string]any { - if extraParams == nil { - extraParams = make(map[string]any, 1) - } - extraParams["thinking"] = map[string]any{"type": "disabled"} - return extraParams } // flattenNamespaceTools expands namespace scoped tools into a flat list of tools.