377 lines
15 KiB
Go
377 lines
15 KiB
Go
package openai
|
|
|
|
import (
|
|
"strings"
|
|
|
|
"github.com/maximhq/bifrost/core/providers/utils"
|
|
"github.com/maximhq/bifrost/core/schemas"
|
|
)
|
|
|
|
// ToBifrostResponsesRequest converts an OpenAI responses request to Bifrost format
|
|
func (resp *OpenAIResponsesRequest) ToBifrostResponsesRequest(ctx *schemas.BifrostContext) *schemas.BifrostResponsesRequest {
|
|
if resp == nil {
|
|
return nil
|
|
}
|
|
|
|
defaultProvider := schemas.OpenAI
|
|
|
|
// for requests coming from azure sdk without provider prefix, we need to set the default provider to azure
|
|
if ctx != nil {
|
|
if isAzureUser, ok := ctx.Value(schemas.BifrostContextKeyIsAzureUserAgent).(bool); ok && isAzureUser {
|
|
defaultProvider = schemas.Azure
|
|
}
|
|
}
|
|
|
|
provider, model := schemas.ParseModelString(resp.Model, utils.CheckAndSetDefaultProvider(ctx, defaultProvider))
|
|
|
|
input := resp.Input.OpenAIResponsesRequestInputArray
|
|
if len(input) == 0 {
|
|
input = []schemas.ResponsesMessage{
|
|
{
|
|
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
|
|
Content: &schemas.ResponsesMessageContent{ContentStr: resp.Input.OpenAIResponsesRequestInputStr},
|
|
},
|
|
}
|
|
}
|
|
|
|
return &schemas.BifrostResponsesRequest{
|
|
Provider: provider,
|
|
Model: model,
|
|
Input: input,
|
|
Params: &resp.ResponsesParameters,
|
|
Fallbacks: schemas.ParseFallbacks(resp.Fallbacks),
|
|
}
|
|
}
|
|
|
|
// ToOpenAIResponsesRequest converts a Bifrost responses request to OpenAI format
|
|
func ToOpenAIResponsesRequest(bifrostReq *schemas.BifrostResponsesRequest) *OpenAIResponsesRequest {
|
|
if bifrostReq == nil || bifrostReq.Input == nil {
|
|
return nil
|
|
}
|
|
|
|
var messages []schemas.ResponsesMessage
|
|
// OpenAI models (except for gpt-oss) do not support reasoning content blocks, so we need to convert them to summaries, if there are any
|
|
// OpenAI also doesn't support compaction content blocks, so we need to convert them to text blocks
|
|
messages = make([]schemas.ResponsesMessage, 0, len(bifrostReq.Input))
|
|
for _, message := range bifrostReq.Input {
|
|
// First, check if message has compaction content blocks and convert them to text
|
|
if message.Content != nil && len(message.Content.ContentBlocks) > 0 {
|
|
hasCompaction := false
|
|
for _, block := range message.Content.ContentBlocks {
|
|
if block.Type == schemas.ResponsesOutputMessageContentTypeCompaction {
|
|
hasCompaction = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if hasCompaction {
|
|
// Create a new message with converted content blocks
|
|
newMessage := message
|
|
newContentBlocks := make([]schemas.ResponsesMessageContentBlock, 0, len(message.Content.ContentBlocks))
|
|
|
|
for _, block := range message.Content.ContentBlocks {
|
|
if block.Type == schemas.ResponsesOutputMessageContentTypeCompaction {
|
|
// Convert compaction block to text block
|
|
if block.ResponsesOutputMessageContentCompaction != nil && block.ResponsesOutputMessageContentCompaction.Summary != "" {
|
|
newContentBlocks = append(newContentBlocks, schemas.ResponsesMessageContentBlock{
|
|
Type: schemas.ResponsesOutputMessageContentTypeText,
|
|
Text: schemas.Ptr(block.ResponsesOutputMessageContentCompaction.Summary),
|
|
})
|
|
}
|
|
// If summary is empty, skip the block entirely
|
|
} else {
|
|
// Keep non-compaction blocks as-is
|
|
newContentBlocks = append(newContentBlocks, block)
|
|
}
|
|
}
|
|
|
|
// Only update if we have blocks remaining after conversion
|
|
if len(newContentBlocks) > 0 {
|
|
newMessage.Content = &schemas.ResponsesMessageContent{
|
|
ContentBlocks: newContentBlocks,
|
|
}
|
|
message = newMessage
|
|
} else {
|
|
// If all blocks were compaction with empty summaries, skip message
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
if message.ResponsesReasoning != nil {
|
|
isGptOss := strings.Contains(bifrostReq.Model, "gpt-oss")
|
|
isReasoning := isOpenAIReasoningModel(bifrostReq.Model)
|
|
|
|
// For non-gpt-oss models, skip reasoning-only messages that have content blocks but no summaries.
|
|
// For non-reasoning models (e.g., gpt-4o), also skip when EncryptedContent is present since
|
|
// these models don't produce encrypted reasoning — any encrypted content is cross-provider
|
|
// (e.g., Gemini ThoughtSignatures) and cannot be decrypted by OpenAI.
|
|
if len(message.ResponsesReasoning.Summary) == 0 &&
|
|
message.Content != nil &&
|
|
len(message.Content.ContentBlocks) > 0 &&
|
|
!isGptOss &&
|
|
(message.ResponsesReasoning.EncryptedContent == nil || !isReasoning) {
|
|
continue
|
|
}
|
|
|
|
// If the message has summaries but no content blocks and the model is gpt-oss, then convert the summaries to content blocks
|
|
if len(message.ResponsesReasoning.Summary) > 0 && isGptOss &&
|
|
(message.Content == nil || len(message.Content.ContentBlocks) == 0) {
|
|
var newMessage schemas.ResponsesMessage
|
|
newMessage.ID = message.ID
|
|
newMessage.Type = message.Type
|
|
newMessage.Status = message.Status
|
|
newMessage.Role = message.Role
|
|
|
|
// Convert summaries to content blocks
|
|
contentBlocks := make([]schemas.ResponsesMessageContentBlock, 0, len(message.ResponsesReasoning.Summary))
|
|
for _, summary := range message.ResponsesReasoning.Summary {
|
|
contentBlocks = append(contentBlocks, schemas.ResponsesMessageContentBlock{
|
|
Type: schemas.ResponsesOutputMessageContentTypeReasoning,
|
|
Text: schemas.Ptr(summary.Text),
|
|
})
|
|
}
|
|
newMessage.Content = &schemas.ResponsesMessageContent{
|
|
ContentBlocks: contentBlocks,
|
|
}
|
|
messages = append(messages, newMessage)
|
|
} else {
|
|
// Clone the embedded pointer to avoid mutating the original input
|
|
reasoningCopy := *message.ResponsesReasoning
|
|
message.ResponsesReasoning = &reasoningCopy
|
|
// OpenAI's Responses API does not accept 'role' on reasoning items
|
|
message.Role = nil
|
|
// Strip cross-provider encrypted content that non-reasoning models cannot decrypt.
|
|
// Reasoning models (o1/o3/o4/GPT-5) may use EncryptedContent for multi-turn state.
|
|
if !isReasoning {
|
|
message.ResponsesReasoning.EncryptedContent = nil
|
|
}
|
|
messages = append(messages, message)
|
|
}
|
|
} else if message.ResponsesToolMessage != nil &&
|
|
message.ResponsesToolMessage.Action != nil &&
|
|
message.ResponsesToolMessage.Action.ResponsesComputerToolCallAction != nil {
|
|
action := message.ResponsesToolMessage.Action.ResponsesComputerToolCallAction
|
|
if action.Type == "zoom" || action.Region != nil {
|
|
// Copy action and modify
|
|
newAction := *action
|
|
newAction.Region = nil
|
|
if newAction.Type == "zoom" {
|
|
newAction.Type = "screenshot"
|
|
}
|
|
|
|
actionStructCopy := *message.ResponsesToolMessage.Action
|
|
actionStructCopy.ResponsesComputerToolCallAction = &newAction
|
|
|
|
toolMsgCopy := *message.ResponsesToolMessage
|
|
toolMsgCopy.Action = &actionStructCopy
|
|
|
|
message.ResponsesToolMessage = &toolMsgCopy
|
|
}
|
|
|
|
messages = append(messages, message)
|
|
} else {
|
|
messages = append(messages, message)
|
|
}
|
|
}
|
|
// Updating params
|
|
params := bifrostReq.Params
|
|
// Create the responses request with properly mapped parameters
|
|
req := &OpenAIResponsesRequest{
|
|
Model: bifrostReq.Model,
|
|
Input: OpenAIResponsesRequestInput{
|
|
OpenAIResponsesRequestInputArray: messages,
|
|
},
|
|
}
|
|
|
|
if params != nil {
|
|
req.ResponsesParameters = *params
|
|
if req.ResponsesParameters.MaxOutputTokens != nil && *req.ResponsesParameters.MaxOutputTokens < MinMaxCompletionTokens {
|
|
req.ResponsesParameters.MaxOutputTokens = schemas.Ptr(MinMaxCompletionTokens)
|
|
}
|
|
// Drop user field if it exceeds OpenAI's 64 character limit
|
|
req.ResponsesParameters.User = SanitizeUserField(req.ResponsesParameters.User)
|
|
|
|
// Handle reasoning parameter: OpenAI uses effort-based reasoning
|
|
// Priority: effort (native) > max_tokens (estimated)
|
|
if req.ResponsesParameters.Reasoning != nil {
|
|
// Clone the Reasoning pointer to avoid mutating the original params
|
|
reasoningCopy := *req.ResponsesParameters.Reasoning
|
|
req.ResponsesParameters.Reasoning = &reasoningCopy
|
|
if req.ResponsesParameters.Reasoning.Effort != nil {
|
|
// Native field is provided, use it (and clear max_tokens)
|
|
effort := *req.ResponsesParameters.Reasoning.Effort
|
|
// Convert "minimal" to "low"; cap "xhigh"/"max" to "high" — OpenAI tops out at high.
|
|
switch effort {
|
|
case "minimal":
|
|
req.ResponsesParameters.Reasoning.Effort = schemas.Ptr("low")
|
|
case "xhigh", "max":
|
|
req.ResponsesParameters.Reasoning.Effort = schemas.Ptr("high")
|
|
}
|
|
// Clear max_tokens since OpenAI doesn't use it
|
|
req.ResponsesParameters.Reasoning.MaxTokens = nil
|
|
} else if req.ResponsesParameters.Reasoning.MaxTokens != nil {
|
|
// Estimate effort from max_tokens
|
|
maxTokens := *req.ResponsesParameters.Reasoning.MaxTokens
|
|
maxOutputTokens := utils.GetMaxOutputTokensOrDefault(req.Model, DefaultCompletionMaxTokens)
|
|
if req.ResponsesParameters.MaxOutputTokens != nil {
|
|
maxOutputTokens = *req.ResponsesParameters.MaxOutputTokens
|
|
}
|
|
effort := utils.GetReasoningEffortFromBudgetTokens(maxTokens, MinReasoningMaxTokens, maxOutputTokens)
|
|
req.ResponsesParameters.Reasoning.Effort = schemas.Ptr(effort)
|
|
// Clear max_tokens since OpenAI doesn't use it
|
|
req.ResponsesParameters.Reasoning.MaxTokens = nil
|
|
}
|
|
|
|
// summary:"none" is Anthropic-specific (maps to display:"omitted"); strip it for OpenAI.
|
|
if req.ResponsesParameters.Reasoning.Summary != nil && *req.ResponsesParameters.Reasoning.Summary == "none" {
|
|
req.ResponsesParameters.Reasoning.Summary = nil
|
|
}
|
|
|
|
// Handle xAI-specific parameter filtering
|
|
// Only grok-3-mini supports reasoning_effort
|
|
if bifrostReq.Provider == schemas.XAI &&
|
|
schemas.IsGrokReasoningModel(bifrostReq.Model) &&
|
|
!strings.Contains(bifrostReq.Model, "grok-3-mini") {
|
|
// Clear reasoning_effort for non-grok-3-mini xAI reasoning models
|
|
req.ResponsesParameters.Reasoning.Effort = nil
|
|
}
|
|
|
|
// Handle OpenAI-specific parameter filtering
|
|
// Only o1/o3 series models support reasoning.effort
|
|
// Regular models like gpt-4o, gpt-4, gpt-3.5-turbo don't support it
|
|
if bifrostReq.Provider == schemas.OpenAI && !isOpenAIReasoningModel(bifrostReq.Model) {
|
|
// Clear reasoning for non-reasoning OpenAI models to avoid API errors
|
|
req.ResponsesParameters.Reasoning = nil
|
|
}
|
|
}
|
|
|
|
// Strip top_p for OpenAI reasoning models (o1/o3 series) which reject it
|
|
// GPT-5.x accept top_p when reasoning.effort is "none" (defaults to "none" when omitted)
|
|
if isOpenAIReasoningModel(bifrostReq.Model) {
|
|
stripTopP := true
|
|
_, parsedModel := schemas.ParseModelString(bifrostReq.Model, schemas.OpenAI)
|
|
modelLower := strings.ToLower(parsedModel)
|
|
effort := ""
|
|
if req.ResponsesParameters.Reasoning != nil &&
|
|
req.ResponsesParameters.Reasoning.Effort != nil {
|
|
effort = *req.ResponsesParameters.Reasoning.Effort
|
|
}
|
|
// GPT-5.x: reasoning defaults to "none" when omitted, and top_p is allowed in that case
|
|
// Exception: -pro and -codex variants always reason (no "none" mode), so top_p must be stripped
|
|
if strings.HasPrefix(modelLower, "gpt-5.") &&
|
|
(effort == "" || effort == "none") &&
|
|
!strings.Contains(modelLower, "-pro") &&
|
|
!strings.Contains(modelLower, "-codex") {
|
|
stripTopP = false
|
|
}
|
|
if stripTopP {
|
|
req.ResponsesParameters.TopP = nil
|
|
}
|
|
}
|
|
|
|
// Normalize function tool parameters for deterministic JSON serialization.
|
|
// We must copy the Tools slice since it shares the backing array with bifrostReq.Params.Tools.
|
|
if len(req.Tools) > 0 {
|
|
normalizedTools := make([]schemas.ResponsesTool, len(req.Tools))
|
|
copy(normalizedTools, req.Tools)
|
|
for i, tool := range normalizedTools {
|
|
if tool.Type == schemas.ResponsesToolTypeFunction &&
|
|
tool.ResponsesToolFunction != nil &&
|
|
tool.ResponsesToolFunction.Parameters != nil {
|
|
funcCopy := *tool.ResponsesToolFunction
|
|
funcCopy.Parameters = tool.ResponsesToolFunction.Parameters.Normalized()
|
|
normalizedTools[i].ResponsesToolFunction = &funcCopy
|
|
}
|
|
}
|
|
req.Tools = normalizedTools
|
|
}
|
|
|
|
// Filter out tools that OpenAI doesn't support
|
|
req.filterUnsupportedTools()
|
|
}
|
|
|
|
if bifrostReq.Params != nil {
|
|
req.ExtraParams = bifrostReq.Params.ExtraParams
|
|
}
|
|
return req
|
|
}
|
|
|
|
// filterUnsupportedTools removes tool types that OpenAI doesn't support
|
|
func (resp *OpenAIResponsesRequest) filterUnsupportedTools() {
|
|
if len(resp.Tools) == 0 {
|
|
return
|
|
}
|
|
|
|
// Define OpenAI-supported tool types
|
|
supportedTypes := map[schemas.ResponsesToolType]bool{
|
|
schemas.ResponsesToolTypeFunction: true,
|
|
schemas.ResponsesToolTypeFileSearch: true,
|
|
schemas.ResponsesToolTypeComputerUsePreview: true,
|
|
schemas.ResponsesToolTypeWebSearch: true,
|
|
schemas.ResponsesToolTypeWebFetch: true,
|
|
schemas.ResponsesToolTypeMCP: true,
|
|
schemas.ResponsesToolTypeCodeInterpreter: true,
|
|
schemas.ResponsesToolTypeImageGeneration: true,
|
|
schemas.ResponsesToolTypeLocalShell: true,
|
|
schemas.ResponsesToolTypeCustom: true,
|
|
schemas.ResponsesToolTypeWebSearchPreview: true,
|
|
schemas.ResponsesToolTypeMemory: true,
|
|
schemas.ResponsesToolTypeToolSearch: true,
|
|
}
|
|
|
|
// Filter tools to only include supported types
|
|
filteredTools := make([]schemas.ResponsesTool, 0, len(resp.Tools))
|
|
for _, tool := range resp.Tools {
|
|
if supportedTypes[tool.Type] {
|
|
// check for computer use preview
|
|
if tool.Type == schemas.ResponsesToolTypeComputerUsePreview && tool.ResponsesToolComputerUsePreview != nil && tool.ResponsesToolComputerUsePreview.EnableZoom != nil {
|
|
newTool := tool
|
|
newComputerUse := &schemas.ResponsesToolComputerUsePreview{
|
|
DisplayHeight: tool.ResponsesToolComputerUsePreview.DisplayHeight,
|
|
DisplayWidth: tool.ResponsesToolComputerUsePreview.DisplayWidth,
|
|
Environment: tool.ResponsesToolComputerUsePreview.Environment,
|
|
// EnableZoom is intentionally omitted (nil) - OpenAI doesn't support it
|
|
}
|
|
newTool.ResponsesToolComputerUsePreview = newComputerUse
|
|
filteredTools = append(filteredTools, newTool)
|
|
} else if tool.Type == schemas.ResponsesToolTypeWebSearch && tool.ResponsesToolWebSearch != nil {
|
|
// Create a proper deep copy with new nested pointers to avoid mutating the original
|
|
newTool := tool
|
|
newWebSearch := &schemas.ResponsesToolWebSearch{}
|
|
|
|
// MaxUses is intentionally omitted (nil) - OpenAI doesn't support it
|
|
|
|
// Handle Filters: OpenAI doesn't support BlockedDomains or TimeRangeFilter
|
|
if tool.ResponsesToolWebSearch.Filters != nil {
|
|
hasAllowedDomains := len(tool.ResponsesToolWebSearch.Filters.AllowedDomains) > 0
|
|
|
|
if hasAllowedDomains {
|
|
// Keep only AllowedDomains (copy the slice to avoid sharing)
|
|
newWebSearch.Filters = &schemas.ResponsesToolWebSearchFilters{
|
|
AllowedDomains: append([]string(nil), tool.ResponsesToolWebSearch.Filters.AllowedDomains...),
|
|
// BlockedDomains and TimeRangeFilter are intentionally omitted - OpenAI doesn't support it
|
|
}
|
|
}
|
|
// If only blocked domains or both empty, Filters stays nil
|
|
}
|
|
|
|
// Copy other fields if they exist
|
|
if tool.ResponsesToolWebSearch.UserLocation != nil {
|
|
newWebSearch.UserLocation = tool.ResponsesToolWebSearch.UserLocation
|
|
}
|
|
if tool.ResponsesToolWebSearch.SearchContextSize != nil {
|
|
newWebSearch.SearchContextSize = tool.ResponsesToolWebSearch.SearchContextSize
|
|
}
|
|
|
|
newTool.ResponsesToolWebSearch = newWebSearch
|
|
filteredTools = append(filteredTools, newTool)
|
|
} else {
|
|
filteredTools = append(filteredTools, tool)
|
|
}
|
|
}
|
|
}
|
|
resp.Tools = filteredTools
|
|
}
|
|
|