feat: publish v1.0.0-rc.36-custom.1
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 9m20s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 55m7s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
Publish Docker image (Multi-arch) / Build & push (amd64) (push) Failing after 9m20s
Release (Linux, macOS, Windows) / Linux Release (push) Failing after 55m7s
Publish Docker image (Multi-arch) / Build & push (arm64) (push) Canceled after 0s
Release (Linux, macOS, Windows) / macOS Release (push) Canceled after 0s
Release (Linux, macOS, Windows) / Windows Release (push) Canceled after 0s
Publish Docker image (Multi-arch) / Create multi-arch manifests (push) Canceled after 0s
This commit is contained in:
commit
bb7968b612
2381 files changed
+551494
No files matched your search
@@ -0,0 +1,263 @@
|
||||
package oairesponses
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
|
||||
func openAIResponsesRequestFromAny(request any) (*dto.OpenAIResponsesRequest, error) {
|
||||
responsesRequest, ok := request.(*dto.OpenAIResponsesRequest)
|
||||
if !ok {
|
||||
if value, ok := request.(dto.OpenAIResponsesRequest); ok {
|
||||
responsesRequest = &value
|
||||
}
|
||||
}
|
||||
if responsesRequest == nil {
|
||||
return nil, fmt.Errorf("expected OpenAI responses request, got %T", request)
|
||||
}
|
||||
return responsesRequest, nil
|
||||
}
|
||||
|
||||
func OpenAIResponsesRequestFromAny(request any) (*dto.OpenAIResponsesRequest, error) {
|
||||
return openAIResponsesRequestFromAny(request)
|
||||
}
|
||||
|
||||
func responsesInputItems(raw []byte) ([]map[string]any, error) {
|
||||
if !rawJSONPresent(raw) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch kitutil.GetJsonType(raw) {
|
||||
case "string":
|
||||
input, err := responsesJSONString(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid input string: %w", err)
|
||||
}
|
||||
return []map[string]any{
|
||||
{
|
||||
"role": "user",
|
||||
"content": input,
|
||||
},
|
||||
}, nil
|
||||
case "array":
|
||||
var items []map[string]any
|
||||
if err := kitutil.Unmarshal(raw, &items); err != nil {
|
||||
return nil, fmt.Errorf("invalid input array: %w", err)
|
||||
}
|
||||
return items, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported responses input type %q", kitutil.GetJsonType(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func InputItems(raw []byte) ([]map[string]any, error) {
|
||||
return responsesInputItems(raw)
|
||||
}
|
||||
|
||||
func responsesContentParts(content any) ([]map[string]any, error) {
|
||||
switch typed := content.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case string:
|
||||
return []map[string]any{{"type": "input_text", "text": typed}}, nil
|
||||
case []map[string]any:
|
||||
return typed, nil
|
||||
case []any:
|
||||
parts := make([]map[string]any, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
switch part := item.(type) {
|
||||
case string:
|
||||
parts = append(parts, map[string]any{"type": "input_text", "text": part})
|
||||
case map[string]any:
|
||||
parts = append(parts, part)
|
||||
default:
|
||||
raw, err := kitutil.Marshal(part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts = append(parts, map[string]any{"type": "input_text", "text": string(raw)})
|
||||
}
|
||||
}
|
||||
return parts, nil
|
||||
default:
|
||||
raw, err := kitutil.Marshal(typed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []map[string]any{{"type": "input_text", "text": string(raw)}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func ContentParts(content any) ([]map[string]any, error) {
|
||||
return responsesContentParts(content)
|
||||
}
|
||||
|
||||
func responsesRequestFunctionDeclarations(raw []byte) ([]dto.FunctionRequest, error) {
|
||||
if !rawJSONPresent(raw) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var tools []map[string]any
|
||||
if err := kitutil.Unmarshal(raw, &tools); err != nil {
|
||||
return nil, fmt.Errorf("invalid tools: %w", err)
|
||||
}
|
||||
|
||||
functions := make([]dto.FunctionRequest, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
if strings.TrimSpace(kitutil.Interface2String(tool["type"])) != "function" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(kitutil.Interface2String(tool["name"]))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
functions = append(functions, dto.FunctionRequest{
|
||||
Name: name,
|
||||
Description: kitutil.Interface2String(tool["description"]),
|
||||
Parameters: tool["parameters"],
|
||||
})
|
||||
}
|
||||
return functions, nil
|
||||
}
|
||||
|
||||
func RequestFunctionDeclarations(raw []byte) ([]dto.FunctionRequest, error) {
|
||||
return responsesRequestFunctionDeclarations(raw)
|
||||
}
|
||||
|
||||
func responsesReasoningEffort(req *dto.OpenAIResponsesRequest) string {
|
||||
if req == nil || req.Reasoning == nil {
|
||||
return ""
|
||||
}
|
||||
return req.Reasoning.Effort
|
||||
}
|
||||
|
||||
func ReasoningEffort(req *dto.OpenAIResponsesRequest) string {
|
||||
return responsesReasoningEffort(req)
|
||||
}
|
||||
|
||||
func responsesObjectValue(value any, fallbackKey string) map[string]any {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return map[string]any{}
|
||||
case map[string]any:
|
||||
return typed
|
||||
case string:
|
||||
var object map[string]any
|
||||
if err := kitutil.Unmarshal([]byte(typed), &object); err == nil {
|
||||
return object
|
||||
}
|
||||
var array []any
|
||||
if err := kitutil.Unmarshal([]byte(typed), &array); err == nil {
|
||||
return map[string]any{fallbackKey: array}
|
||||
}
|
||||
return map[string]any{fallbackKey: typed}
|
||||
case []any:
|
||||
return map[string]any{fallbackKey: typed}
|
||||
default:
|
||||
return map[string]any{fallbackKey: typed}
|
||||
}
|
||||
}
|
||||
|
||||
func ObjectValue(value any, fallbackKey string) map[string]any {
|
||||
return responsesObjectValue(value, fallbackKey)
|
||||
}
|
||||
|
||||
func responsesGeminiResponseMap(value any) map[string]any {
|
||||
switch typed := value.(type) {
|
||||
case nil:
|
||||
return map[string]any{}
|
||||
case map[string]any:
|
||||
return typed
|
||||
case string:
|
||||
var object map[string]any
|
||||
if err := kitutil.Unmarshal([]byte(typed), &object); err == nil {
|
||||
return object
|
||||
}
|
||||
var array []any
|
||||
if err := kitutil.Unmarshal([]byte(typed), &array); err == nil {
|
||||
return map[string]any{"result": array}
|
||||
}
|
||||
return map[string]any{"content": typed}
|
||||
case []any:
|
||||
return map[string]any{"result": typed}
|
||||
default:
|
||||
return map[string]any{"content": typed}
|
||||
}
|
||||
}
|
||||
|
||||
func GeminiResponseMap(value any) map[string]any {
|
||||
return responsesGeminiResponseMap(value)
|
||||
}
|
||||
|
||||
func responsesParallelToolCalls(raw []byte) *bool {
|
||||
if !rawJSONPresent(raw) || kitutil.GetJsonType(raw) != "boolean" {
|
||||
return nil
|
||||
}
|
||||
var parallelToolCalls bool
|
||||
if err := kitutil.Unmarshal(raw, ¶llelToolCalls); err != nil {
|
||||
return nil
|
||||
}
|
||||
return ¶llelToolCalls
|
||||
}
|
||||
|
||||
func ParallelToolCalls(raw []byte) *bool {
|
||||
return responsesParallelToolCalls(raw)
|
||||
}
|
||||
|
||||
func ContentPartToFileSource(part map[string]any) types.FileSource {
|
||||
partType := strings.TrimSpace(kitutil.Interface2String(part["type"]))
|
||||
var data string
|
||||
var mimeType string
|
||||
|
||||
switch partType {
|
||||
case "input_image":
|
||||
data, mimeType = responsesPartDataAndMime(part, "image_url", "url")
|
||||
case "input_file":
|
||||
data, mimeType = responsesPartDataAndMime(part, "file", "file_data", "file_url", "url")
|
||||
case "input_audio":
|
||||
data, mimeType = responsesPartDataAndMime(part, "input_audio", "data", "url")
|
||||
if mimeType == "" {
|
||||
if payload, ok := part["input_audio"].(map[string]any); ok {
|
||||
if format := strings.TrimSpace(kitutil.Interface2String(payload["format"])); format != "" {
|
||||
mimeType = "audio/" + format
|
||||
}
|
||||
}
|
||||
}
|
||||
case "input_video":
|
||||
data, mimeType = responsesPartDataAndMime(part, "video_url", "url")
|
||||
}
|
||||
if data == "" {
|
||||
return nil
|
||||
}
|
||||
return types.NewFileSourceFromData(data, mimeType)
|
||||
}
|
||||
|
||||
func responsesPartDataAndMime(part map[string]any, keys ...string) (string, string) {
|
||||
mimeType := strings.TrimSpace(kitutil.Interface2String(part["mime_type"]))
|
||||
for _, key := range keys {
|
||||
value, ok := part[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if typed != "" {
|
||||
return typed, mimeType
|
||||
}
|
||||
case map[string]any:
|
||||
if mimeType == "" {
|
||||
mimeType = strings.TrimSpace(kitutil.Interface2String(typed["mime_type"]))
|
||||
}
|
||||
for _, nestedKey := range []string{"url", "file_data", "file_url", "data"} {
|
||||
if data := strings.TrimSpace(kitutil.Interface2String(typed[nestedKey])); data != "" {
|
||||
return data, mimeType
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", mimeType
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package oairesponses
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
|
||||
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
|
||||
)
|
||||
|
||||
func convertOpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Meta, request any) (any, error) {
|
||||
responsesRequest, err := OpenAIResponsesRequestFromAny(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return OpenAIResponsesRequestToClaudeMessages(c, info, responsesRequest)
|
||||
}
|
||||
|
||||
func OpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Meta, req *dto.OpenAIResponsesRequest) (*dto.ClaudeRequest, error) {
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("request is nil")
|
||||
}
|
||||
if req.Model == "" {
|
||||
return nil, fmt.Errorf("model is required")
|
||||
}
|
||||
if err := ValidateRequestChatUnsupportedFields(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
claudeRequest := &dto.ClaudeRequest{
|
||||
Model: req.Model,
|
||||
Temperature: req.Temperature,
|
||||
TopP: req.TopP,
|
||||
Stream: req.Stream,
|
||||
}
|
||||
if req.MaxOutputTokens != nil && *req.MaxOutputTokens > 0 {
|
||||
claudeRequest.MaxTokens = kitutil.GetPointer(*req.MaxOutputTokens)
|
||||
}
|
||||
functions, err := RequestFunctionDeclarations(req.Tools)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(functions) > 0 {
|
||||
claudeRequest.Tools = responsesFunctionDeclarationsToClaudeTools(functions)
|
||||
}
|
||||
|
||||
toolChoice, err := RequestToolChoiceToChat(req.ToolChoice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if toolChoice != nil || RawJSONPresent(req.ParallelToolCalls) {
|
||||
claudeRequest.ToolChoice = sharedclaude.MapOpenAIToolChoice(toolChoice, ParallelToolCalls(req.ParallelToolCalls))
|
||||
}
|
||||
sourceReasoning, err := reasoning.FromOpenAIResponses(req)
|
||||
if err != nil {
|
||||
return nil, reasoning.AsClientError(err)
|
||||
}
|
||||
if err := sharedclaude.ApplyReasoning(c, claudeRequest, info, sourceReasoning, true); err != nil {
|
||||
return nil, reasoning.AsClientError(err)
|
||||
}
|
||||
if claudeRequest.MaxTokens == nil {
|
||||
if defaultMaxTokens, configured := convmeta.OptionsOf(info).Claude.DefaultMaxTokensFor(claudeRequest.Model); configured {
|
||||
value := uint(defaultMaxTokens)
|
||||
claudeRequest.MaxTokens = &value
|
||||
}
|
||||
}
|
||||
|
||||
systemMessages := make([]dto.ClaudeMediaMessage, 0)
|
||||
if RawJSONPresent(req.Instructions) {
|
||||
instructions, err := JSONString(req.Instructions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid instructions: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(instructions) != "" {
|
||||
systemMessages = append(systemMessages, dto.ClaudeMediaMessage{
|
||||
Type: "text",
|
||||
Text: kitutil.GetPointer(instructions),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
inputItems, err := InputItems(req.Input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range inputItems {
|
||||
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
|
||||
switch itemType {
|
||||
case ResponsesInputTypeFunctionCall:
|
||||
claudeRequest.Messages = appendClaudeToolUse(claudeRequest.Messages, responsesFunctionCallItemToClaudeToolUse(item, "arguments"))
|
||||
case ResponsesInputTypeCustomToolCall:
|
||||
claudeRequest.Messages = appendClaudeToolUse(claudeRequest.Messages, responsesFunctionCallItemToClaudeToolUse(item, "input"))
|
||||
case ResponsesInputTypeFunctionCallOutput, ResponsesInputTypeCustomToolOutput:
|
||||
claudeRequest.Messages = appendClaudeToolResult(claudeRequest.Messages, responsesFunctionOutputItemToClaudeToolResult(item))
|
||||
default:
|
||||
sourceRole := strings.TrimSpace(kitutil.Interface2String(item["role"]))
|
||||
role := responsesClaudeRole(sourceRole)
|
||||
parts, err := responsesInputContentToClaudeMediaMessages(c, item["content"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sourceRole == "" && len(parts) == 0 {
|
||||
continue
|
||||
}
|
||||
if role == "system" {
|
||||
for _, part := range parts {
|
||||
if part.Type == "text" {
|
||||
systemMessages = append(systemMessages, part)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
parts = []dto.ClaudeMediaMessage{
|
||||
{
|
||||
Type: "text",
|
||||
Text: kitutil.GetPointer("..."),
|
||||
},
|
||||
}
|
||||
}
|
||||
claudeRequest.Messages = append(claudeRequest.Messages, dto.ClaudeMessage{
|
||||
Role: role,
|
||||
Content: parts,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(systemMessages) > 0 {
|
||||
claudeRequest.System = systemMessages
|
||||
}
|
||||
if len(claudeRequest.Messages) > 0 || len(systemMessages) > 0 {
|
||||
claudeRequest.Messages = ensureClaudeMessagesStartWithUser(claudeRequest.Messages)
|
||||
}
|
||||
// Checked last so every injection path has had its chance to satisfy the
|
||||
// required field.
|
||||
if claudeRequest.MaxTokens == nil {
|
||||
return nil, sharedclaude.ErrMissingMaxTokens
|
||||
}
|
||||
return claudeRequest, nil
|
||||
}
|
||||
|
||||
func responsesFunctionDeclarationsToClaudeTools(functions []dto.FunctionRequest) []any {
|
||||
tools := make([]any, 0, len(functions))
|
||||
for _, function := range functions {
|
||||
tools = append(tools, &dto.Tool{
|
||||
Name: function.Name,
|
||||
Description: function.Description,
|
||||
InputSchema: sharedclaude.FunctionParametersToInputSchema(function.Parameters),
|
||||
})
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
func responsesInputContentToClaudeMediaMessages(c context.Context, content any) ([]dto.ClaudeMediaMessage, error) {
|
||||
contentParts, err := ContentParts(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parts := make([]dto.ClaudeMediaMessage, 0, len(contentParts))
|
||||
for _, contentPart := range contentParts {
|
||||
partType := strings.TrimSpace(kitutil.Interface2String(contentPart["type"]))
|
||||
switch partType {
|
||||
case "input_text", "output_text", "text":
|
||||
text := kitutil.Interface2String(contentPart["text"])
|
||||
if text != "" {
|
||||
parts = append(parts, dto.ClaudeMediaMessage{
|
||||
Type: "text",
|
||||
Text: kitutil.GetPointer(text),
|
||||
})
|
||||
}
|
||||
case "input_image", "input_file", "input_audio", "input_video":
|
||||
source := ContentPartToFileSource(contentPart)
|
||||
if source == nil {
|
||||
continue
|
||||
}
|
||||
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting Responses input for Claude")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get file data failed: %s", err.Error())
|
||||
}
|
||||
claudePart := dto.ClaudeMediaMessage{
|
||||
Source: &dto.ClaudeMessageSource{
|
||||
Type: "base64",
|
||||
MediaType: mimeType,
|
||||
Data: base64Data,
|
||||
},
|
||||
}
|
||||
if strings.HasPrefix(mimeType, "application/pdf") {
|
||||
claudePart.Type = "document"
|
||||
} else {
|
||||
claudePart.Type = "image"
|
||||
}
|
||||
parts = append(parts, claudePart)
|
||||
}
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func responsesFunctionCallItemToClaudeToolUse(item map[string]any, inputKey string) dto.ClaudeMediaMessage {
|
||||
return dto.ClaudeMediaMessage{
|
||||
Type: "tool_use",
|
||||
Id: CallID(item),
|
||||
Name: strings.TrimSpace(kitutil.Interface2String(item["name"])),
|
||||
Input: ObjectValue(item[inputKey], inputKey),
|
||||
}
|
||||
}
|
||||
|
||||
func responsesFunctionOutputItemToClaudeToolResult(item map[string]any) dto.ClaudeMediaMessage {
|
||||
return dto.ClaudeMediaMessage{
|
||||
Type: "tool_result",
|
||||
ToolUseId: CallID(item),
|
||||
Content: responsesToolOutputValue(item["output"]),
|
||||
}
|
||||
}
|
||||
|
||||
func responsesToolOutputValue(value any) any {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func appendClaudeToolUse(messages []dto.ClaudeMessage, toolUse dto.ClaudeMediaMessage) []dto.ClaudeMessage {
|
||||
if len(messages) > 0 && messages[len(messages)-1].Role == "assistant" {
|
||||
last := messages[len(messages)-1]
|
||||
parts := claudeMessageContentParts(last.Content)
|
||||
parts = append(parts, toolUse)
|
||||
last.Content = parts
|
||||
messages[len(messages)-1] = last
|
||||
return messages
|
||||
}
|
||||
return append(messages, dto.ClaudeMessage{
|
||||
Role: "assistant",
|
||||
Content: []dto.ClaudeMediaMessage{toolUse},
|
||||
})
|
||||
}
|
||||
|
||||
func appendClaudeToolResult(messages []dto.ClaudeMessage, toolResult dto.ClaudeMediaMessage) []dto.ClaudeMessage {
|
||||
if len(messages) > 0 && messages[len(messages)-1].Role == "user" {
|
||||
last := messages[len(messages)-1]
|
||||
parts := claudeMessageContentParts(last.Content)
|
||||
parts = append(parts, toolResult)
|
||||
last.Content = parts
|
||||
messages[len(messages)-1] = last
|
||||
return messages
|
||||
}
|
||||
return append(messages, dto.ClaudeMessage{
|
||||
Role: "user",
|
||||
Content: []dto.ClaudeMediaMessage{toolResult},
|
||||
})
|
||||
}
|
||||
|
||||
func claudeMessageContentParts(content any) []dto.ClaudeMediaMessage {
|
||||
switch typed := content.(type) {
|
||||
case []dto.ClaudeMediaMessage:
|
||||
return typed
|
||||
case string:
|
||||
if typed == "" {
|
||||
return nil
|
||||
}
|
||||
return []dto.ClaudeMediaMessage{
|
||||
{
|
||||
Type: "text",
|
||||
Text: kitutil.GetPointer(typed),
|
||||
},
|
||||
}
|
||||
default:
|
||||
parts, _ := kitutil.Any2Type[[]dto.ClaudeMediaMessage](content)
|
||||
return parts
|
||||
}
|
||||
}
|
||||
|
||||
func responsesClaudeRole(role string) string {
|
||||
switch role {
|
||||
case "assistant":
|
||||
return "assistant"
|
||||
case "system", "developer":
|
||||
return "system"
|
||||
default:
|
||||
return "user"
|
||||
}
|
||||
}
|
||||
|
||||
func ensureClaudeMessagesStartWithUser(messages []dto.ClaudeMessage) []dto.ClaudeMessage {
|
||||
if len(messages) > 0 && messages[0].Role == "user" {
|
||||
return messages
|
||||
}
|
||||
return append([]dto.ClaudeMessage{
|
||||
{
|
||||
Role: "user",
|
||||
Content: []dto.ClaudeMediaMessage{
|
||||
{
|
||||
Type: "text",
|
||||
Text: kitutil.GetPointer("..."),
|
||||
},
|
||||
},
|
||||
},
|
||||
}, messages...)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package oairesponses
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/reasonmap"
|
||||
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
)
|
||||
|
||||
func ResponsesResponseToClaudeMessagesResponse(resp *dto.OpenAIResponsesResponse) (*dto.ClaudeResponse, *dto.Usage, error) {
|
||||
if resp == nil {
|
||||
return nil, nil, errors.New("response is nil")
|
||||
}
|
||||
|
||||
usage := UsageFromResponsesUsage(resp.Usage)
|
||||
claudeResponse := &dto.ClaudeResponse{
|
||||
Id: resp.ID,
|
||||
Type: "message",
|
||||
Role: "assistant",
|
||||
Model: resp.Model,
|
||||
Usage: sharedclaude.UsageFromOpenAI(usage),
|
||||
}
|
||||
sawToolCall := false
|
||||
for index := range resp.Output {
|
||||
output := resp.Output[index]
|
||||
if output.Type == responsesOutputTypeMessage && output.Role != "" && output.Role != "assistant" {
|
||||
continue
|
||||
}
|
||||
switch output.Type {
|
||||
case responsesOutputTypeReasoning:
|
||||
if thinking := reasoningOutputText(&output); thinking != "" {
|
||||
claudeResponse.Content = append(claudeResponse.Content, dto.ClaudeMediaMessage{
|
||||
Type: "thinking",
|
||||
Thinking: kitutil.GetPointer(thinking),
|
||||
})
|
||||
}
|
||||
case responsesOutputTypeMessage:
|
||||
for _, content := range output.Content {
|
||||
if content.Type != "output_text" {
|
||||
continue
|
||||
}
|
||||
block := dto.ClaudeMediaMessage{Type: "text", Text: kitutil.GetPointer(content.Text)}
|
||||
if citations := responsesAnnotationsToClaude(content.Annotations, content.Text); len(citations) > 0 {
|
||||
block.Citations, _ = kitutil.Marshal(citations)
|
||||
}
|
||||
claudeResponse.Content = append(claudeResponse.Content, block)
|
||||
}
|
||||
case responsesOutputTypeFunctionCall, responsesOutputTypeCustomToolCall:
|
||||
sawToolCall = true
|
||||
callID := strings.TrimSpace(output.CallId)
|
||||
if callID == "" {
|
||||
callID = strings.TrimSpace(output.ID)
|
||||
}
|
||||
claudeResponse.Content = append(claudeResponse.Content, dto.ClaudeMediaMessage{
|
||||
Type: "tool_use",
|
||||
Id: callID,
|
||||
Name: output.Name,
|
||||
Input: responsesArgumentsToClaudeInput(output.ArgumentsString()),
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(claudeResponse.Content) == 0 {
|
||||
claudeResponse.Content = []dto.ClaudeMediaMessage{{Type: "text", Text: kitutil.GetPointer("")}}
|
||||
}
|
||||
claudeResponse.StopReason = responsesClaudeStopReason(resp, sawToolCall)
|
||||
return claudeResponse, usage, nil
|
||||
}
|
||||
|
||||
func responsesArgumentsToClaudeInput(arguments string) map[string]any {
|
||||
input := make(map[string]any)
|
||||
if strings.TrimSpace(arguments) == "" {
|
||||
return input
|
||||
}
|
||||
if err := kitutil.Unmarshal([]byte(arguments), &input); err == nil && input != nil {
|
||||
return input
|
||||
}
|
||||
return map[string]any{"input": arguments}
|
||||
}
|
||||
|
||||
func responsesClaudeStopReason(resp *dto.OpenAIResponsesResponse, sawToolCall bool) string {
|
||||
if finishReason, ok := ResponsesFinishReasonFromStatus(resp); ok {
|
||||
return reasonmap.OpenAIFinishReasonToClaudeStopReason(finishReason)
|
||||
}
|
||||
if sawToolCall {
|
||||
return "tool_use"
|
||||
}
|
||||
return "end_turn"
|
||||
}
|
||||
|
||||
func responsesAnnotationsToClaude(annotations []any, text string) []json.RawMessage {
|
||||
citations := make([]json.RawMessage, 0, len(annotations))
|
||||
for _, rawAnnotation := range annotations {
|
||||
annotation, err := kitutil.Any2Type[map[string]any](rawAnnotation)
|
||||
if err != nil || strings.TrimSpace(kitutil.Interface2String(annotation["type"])) != "url_citation" {
|
||||
continue
|
||||
}
|
||||
citation := annotation
|
||||
if nested, ok := annotation["url_citation"].(map[string]any); ok {
|
||||
citation = nested
|
||||
}
|
||||
url := strings.TrimSpace(kitutil.Interface2String(citation["url"]))
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
converted := map[string]any{
|
||||
"type": "web_search_result_location",
|
||||
"url": url,
|
||||
"title": strings.TrimSpace(kitutil.Interface2String(citation["title"])),
|
||||
}
|
||||
if citedText := kitutil.Interface2String(citation["cited_text"]); citedText != "" {
|
||||
converted["cited_text"] = citedText
|
||||
} else if citedText := responsesCitedText(text, citation); citedText != "" {
|
||||
converted["cited_text"] = citedText
|
||||
}
|
||||
if encryptedIndex := kitutil.Interface2String(citation["encrypted_index"]); encryptedIndex != "" {
|
||||
converted["encrypted_index"] = encryptedIndex
|
||||
}
|
||||
if converted["title"] == "" {
|
||||
delete(converted, "title")
|
||||
}
|
||||
encoded, err := kitutil.Marshal(converted)
|
||||
if err == nil {
|
||||
citations = append(citations, encoded)
|
||||
}
|
||||
}
|
||||
return citations
|
||||
}
|
||||
|
||||
func responsesCitedText(text string, citation map[string]any) string {
|
||||
start, startOK := responsesAnnotationIndex(citation["start_index"])
|
||||
end, endOK := responsesAnnotationIndex(citation["end_index"])
|
||||
if !startOK || !endOK || start < 0 || end <= start || end > utf8.RuneCountInString(text) {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(text)
|
||||
return string(runes[start:end])
|
||||
}
|
||||
|
||||
func responsesAnnotationIndex(value any) (int, bool) {
|
||||
switch number := value.(type) {
|
||||
case float64:
|
||||
return int(number), number >= 0 && number == float64(int(number))
|
||||
case int:
|
||||
return number, number >= 0
|
||||
case json.Number:
|
||||
parsed, err := number.Int64()
|
||||
return int(parsed), err == nil && parsed >= 0
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
package oairesponses
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
)
|
||||
|
||||
const responsesEventOutputTextDone = "response.output_text.done"
|
||||
|
||||
type ResponsesToClaudeStreamState struct {
|
||||
ID string
|
||||
Model string
|
||||
Usage *dto.Usage
|
||||
|
||||
sentMessageStart bool
|
||||
done bool
|
||||
sawToolCall bool
|
||||
nextBlockIndex int
|
||||
blocks []*responsesClaudeStreamBlock
|
||||
byOutputIndex map[int]*responsesClaudeStreamBlock
|
||||
byItemID map[string]*responsesClaudeStreamBlock
|
||||
lastByKind map[string]*responsesClaudeStreamBlock
|
||||
usageText strings.Builder
|
||||
}
|
||||
|
||||
type responsesClaudeStreamBlock struct {
|
||||
Index int
|
||||
Kind string
|
||||
ItemID string
|
||||
CallID string
|
||||
Name string
|
||||
Started bool
|
||||
Stopped bool
|
||||
Value strings.Builder
|
||||
SentBytes int
|
||||
AnnotationCount int
|
||||
NeedsReasoningBreak bool
|
||||
}
|
||||
|
||||
func NewResponsesToClaudeStreamState(id string, model string) *ResponsesToClaudeStreamState {
|
||||
return &ResponsesToClaudeStreamState{
|
||||
ID: strings.TrimSpace(id),
|
||||
Model: strings.TrimSpace(model),
|
||||
byOutputIndex: make(map[int]*responsesClaudeStreamBlock),
|
||||
byItemID: make(map[string]*responsesClaudeStreamBlock),
|
||||
lastByKind: make(map[string]*responsesClaudeStreamBlock),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ResponsesToClaudeStreamState) UsageText() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.usageText.String()
|
||||
}
|
||||
|
||||
func (s *ResponsesToClaudeStreamState) Done() bool {
|
||||
return s != nil && s.done
|
||||
}
|
||||
|
||||
func (s *ResponsesToClaudeStreamState) SetUsage(usage *dto.Usage) {
|
||||
if s != nil && usage != nil {
|
||||
s.Usage = usage
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ResponsesToClaudeStreamState) StreamUsage() *dto.Usage {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.Usage
|
||||
}
|
||||
|
||||
func (s *ResponsesToClaudeStreamState) SetStreamUsage(usage *dto.Usage) {
|
||||
s.SetUsage(usage)
|
||||
}
|
||||
|
||||
func (s *ResponsesToClaudeStreamState) ConvertChunk(event *dto.ResponsesStreamResponse, estimatedInputTokens int) ([]*dto.ClaudeResponse, *dto.Usage, error) {
|
||||
if s == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
if event == nil || s.done {
|
||||
return nil, s.Usage, nil
|
||||
}
|
||||
|
||||
s.applyResponseMetadata(event.Response)
|
||||
switch event.Type {
|
||||
case responsesEventCreated:
|
||||
return s.ensureMessageStart(estimatedInputTokens), s.Usage, nil
|
||||
case responsesEventReasoningSummaryDelta, responsesEventReasoningTextDelta:
|
||||
block, err := s.ensureBlock(event, "thinking")
|
||||
if err != nil {
|
||||
return nil, s.Usage, err
|
||||
}
|
||||
delta := event.Delta
|
||||
if block.NeedsReasoningBreak && delta != "" {
|
||||
delta = separatedResponsesDelta(delta)
|
||||
block.NeedsReasoningBreak = false
|
||||
}
|
||||
return s.appendDelta(block, delta, estimatedInputTokens), s.Usage, nil
|
||||
case responsesEventReasoningSummaryDone, responsesEventReasoningTextDone:
|
||||
block, err := s.ensureBlock(event, "thinking")
|
||||
if err != nil {
|
||||
return nil, s.Usage, err
|
||||
}
|
||||
var responses []*dto.ClaudeResponse
|
||||
if event.Text != nil {
|
||||
responses = append(responses, s.mergeFinalValue(block, *event.Text, estimatedInputTokens)...)
|
||||
}
|
||||
if block.Value.Len() > 0 {
|
||||
block.NeedsReasoningBreak = true
|
||||
}
|
||||
return responses, s.Usage, nil
|
||||
case responsesEventOutputTextDelta:
|
||||
block, err := s.ensureBlock(event, "text")
|
||||
if err != nil {
|
||||
return nil, s.Usage, err
|
||||
}
|
||||
return s.appendDelta(block, event.Delta, estimatedInputTokens), s.Usage, nil
|
||||
case responsesEventOutputTextDone:
|
||||
block, err := s.ensureBlock(event, "text")
|
||||
if err != nil {
|
||||
return nil, s.Usage, err
|
||||
}
|
||||
if event.Text == nil {
|
||||
return nil, s.Usage, nil
|
||||
}
|
||||
return s.mergeFinalValue(block, *event.Text, estimatedInputTokens), s.Usage, nil
|
||||
case responsesEventOutputTextAnnotationAdded:
|
||||
block, err := s.ensureBlock(event, "text")
|
||||
if err != nil {
|
||||
return nil, s.Usage, err
|
||||
}
|
||||
var annotation any
|
||||
if err := kitutil.Unmarshal(event.Annotation, &annotation); err != nil {
|
||||
return nil, s.Usage, fmt.Errorf("invalid Responses stream annotation: %w", err)
|
||||
}
|
||||
return s.appendAnnotations(block, []any{annotation}, estimatedInputTokens, false), s.Usage, nil
|
||||
case responsesEventOutputItemAdded, responsesEventOutputItemDone:
|
||||
responses, err := s.applyOutputItem(event, estimatedInputTokens, event.Type == responsesEventOutputItemDone)
|
||||
return responses, s.Usage, err
|
||||
case responsesEventFunctionArgsDelta, responsesEventCustomToolInputDelta:
|
||||
block, err := s.ensureBlock(event, "tool_use")
|
||||
if err != nil {
|
||||
return nil, s.Usage, err
|
||||
}
|
||||
return s.appendDelta(block, event.Delta, estimatedInputTokens), s.Usage, nil
|
||||
case responsesEventFunctionArgsDone, responsesEventCustomToolInputDone:
|
||||
block, err := s.ensureBlock(event, "tool_use")
|
||||
if err != nil {
|
||||
return nil, s.Usage, err
|
||||
}
|
||||
if event.Arguments == nil {
|
||||
return nil, s.Usage, nil
|
||||
}
|
||||
return s.mergeFinalValue(block, *event.Arguments, estimatedInputTokens), s.Usage, nil
|
||||
case responsesEventCompleted, responsesEventDone, responsesEventIncomplete:
|
||||
responses, err := s.finish(event.Response, estimatedInputTokens)
|
||||
return responses, s.Usage, err
|
||||
case responsesEventFailed, responsesEventError:
|
||||
message := strings.TrimSpace(event.Message)
|
||||
if message == "" {
|
||||
message = event.Type
|
||||
}
|
||||
return nil, s.Usage, fmt.Errorf("responses stream error: %s", message)
|
||||
default:
|
||||
return nil, s.Usage, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ResponsesToClaudeStreamState) Finalize(estimatedInputTokens int) ([]*dto.ClaudeResponse, error) {
|
||||
if s == nil || s.done {
|
||||
return nil, nil
|
||||
}
|
||||
return s.finish(nil, estimatedInputTokens)
|
||||
}
|
||||
|
||||
func (s *ResponsesToClaudeStreamState) applyResponseMetadata(response *dto.OpenAIResponsesResponse) {
|
||||
if s == nil || response == nil {
|
||||
return
|
||||
}
|
||||
if response.ID != "" {
|
||||
s.ID = response.ID
|
||||
}
|
||||
if response.Model != "" {
|
||||
s.Model = response.Model
|
||||
}
|
||||
if response.Usage != nil {
|
||||
s.Usage = dto.MergeUsageNonZero(s.Usage, UsageFromResponsesUsage(response.Usage))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ResponsesToClaudeStreamState) ensureMessageStart(estimatedInputTokens int) []*dto.ClaudeResponse {
|
||||
if s.sentMessageStart {
|
||||
return nil
|
||||
}
|
||||
s.sentMessageStart = true
|
||||
inputTokens := estimatedInputTokens
|
||||
if s.Usage != nil {
|
||||
if usage := sharedclaude.UsageFromOpenAI(s.Usage); usage != nil {
|
||||
inputTokens = usage.InputTokens
|
||||
}
|
||||
}
|
||||
message := &dto.ClaudeMediaMessage{
|
||||
Id: s.ID,
|
||||
Type: "message",
|
||||
Role: "assistant",
|
||||
Model: s.Model,
|
||||
Usage: &dto.ClaudeUsage{InputTokens: inputTokens},
|
||||
}
|
||||
message.SetContent(make([]any, 0))
|
||||
return []*dto.ClaudeResponse{{Type: "message_start", Message: message}}
|
||||
}
|
||||
|
||||
func (s *ResponsesToClaudeStreamState) ensureBlock(event *dto.ResponsesStreamResponse, kind string) (*responsesClaudeStreamBlock, error) {
|
||||
block := s.findBlock(event)
|
||||
if block == nil {
|
||||
if last := s.lastByKind[kind]; last != nil && !last.Stopped && event.OutputIndex == nil && responseStreamEventItemID(event) == "" {
|
||||
block = last
|
||||
}
|
||||
}
|
||||
if block == nil {
|
||||
block = &responsesClaudeStreamBlock{Index: s.nextBlockIndex, Kind: kind}
|
||||
s.nextBlockIndex++
|
||||
s.blocks = append(s.blocks, block)
|
||||
}
|
||||
if block.Kind == "" {
|
||||
block.Kind = kind
|
||||
}
|
||||
if block.Kind != kind {
|
||||
return nil, fmt.Errorf("Responses output item changed from %s to %s", block.Kind, kind)
|
||||
}
|
||||
s.applyBlockMetadata(block, event)
|
||||
s.lastByKind[kind] = block
|
||||
return block, nil
|
||||
}
|
||||
|
||||
func (s *ResponsesToClaudeStreamState) findBlock(event *dto.ResponsesStreamResponse) *responsesClaudeStreamBlock {
|
||||
if event == nil {
|
||||
return nil
|
||||
}
|
||||
if event.OutputIndex != nil {
|
||||
if block := s.byOutputIndex[*event.OutputIndex]; block != nil {
|
||||
return block
|
||||
}
|
||||
}
|
||||
if itemID := responseStreamEventItemID(event); itemID != "" {
|
||||
return s.byItemID[itemID]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ResponsesToClaudeStreamState) applyBlockMetadata(block *responsesClaudeStreamBlock, event *dto.ResponsesStreamResponse) {
|
||||
if block == nil || event == nil {
|
||||
return
|
||||
}
|
||||
if event.OutputIndex != nil {
|
||||
s.byOutputIndex[*event.OutputIndex] = block
|
||||
}
|
||||
if itemID := responseStreamEventItemID(event); itemID != "" {
|
||||
block.ItemID = itemID
|
||||
s.byItemID[itemID] = block
|
||||
}
|
||||
if event.Item == nil {
|
||||
return
|
||||
}
|
||||
if callID := strings.TrimSpace(event.Item.CallId); callID != "" {
|
||||
block.CallID = callID
|
||||
} else if block.CallID == "" {
|
||||
block.CallID = strings.TrimSpace(event.Item.ID)
|
||||
}
|
||||
if name := strings.TrimSpace(event.Item.Name); name != "" {
|
||||
block.Name = name
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ResponsesToClaudeStreamState) startBlock(block *responsesClaudeStreamBlock, estimatedInputTokens int) []*dto.ClaudeResponse {
|
||||
if block == nil || block.Started || block.Stopped {
|
||||
return nil
|
||||
}
|
||||
var content dto.ClaudeMediaMessage
|
||||
switch block.Kind {
|
||||
case "text":
|
||||
content = dto.ClaudeMediaMessage{Type: "text", Text: kitutil.GetPointer("")}
|
||||
case "thinking":
|
||||
content = dto.ClaudeMediaMessage{Type: "thinking", Thinking: kitutil.GetPointer("")}
|
||||
case "tool_use":
|
||||
if block.Name == "" {
|
||||
return nil
|
||||
}
|
||||
callID := block.CallID
|
||||
if callID == "" {
|
||||
callID = block.ItemID
|
||||
}
|
||||
content = dto.ClaudeMediaMessage{Type: "tool_use", Id: callID, Name: block.Name, Input: map[string]any{}}
|
||||
s.sawToolCall = true
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
block.Started = true
|
||||
responses := s.ensureMessageStart(estimatedInputTokens)
|
||||
index := block.Index
|
||||
responses = append(responses, &dto.ClaudeResponse{Type: "content_block_start", Index: &index, ContentBlock: &content})
|
||||
return responses
|
||||
}
|
||||
|
||||
func (s *ResponsesToClaudeStreamState) appendDelta(block *responsesClaudeStreamBlock, delta string, estimatedInputTokens int) []*dto.ClaudeResponse {
|
||||
if block == nil || block.Stopped || delta == "" {
|
||||
return nil
|
||||
}
|
||||
block.Value.WriteString(delta)
|
||||
return s.flushBlock(block, estimatedInputTokens)
|
||||
}
|
||||
|
||||
func (s *ResponsesToClaudeStreamState) mergeFinalValue(block *responsesClaudeStreamBlock, finalValue string, estimatedInputTokens int) []*dto.ClaudeResponse {
|
||||
if block == nil || block.Stopped {
|
||||
return nil
|
||||
}
|
||||
current := block.Value.String()
|
||||
if current == "" {
|
||||
block.Value.WriteString(finalValue)
|
||||
} else if strings.HasPrefix(finalValue, current) {
|
||||
block.Value.WriteString(finalValue[len(current):])
|
||||
}
|
||||
return s.flushBlock(block, estimatedInputTokens)
|
||||
}
|
||||
|
||||
func (s *ResponsesToClaudeStreamState) flushBlock(block *responsesClaudeStreamBlock, estimatedInputTokens int) []*dto.ClaudeResponse {
|
||||
if block == nil || block.Stopped {
|
||||
return nil
|
||||
}
|
||||
responses := s.startBlock(block, estimatedInputTokens)
|
||||
if !block.Started {
|
||||
return responses
|
||||
}
|
||||
value := block.Value.String()
|
||||
if block.SentBytes >= len(value) {
|
||||
return responses
|
||||
}
|
||||
delta := value[block.SentBytes:]
|
||||
block.SentBytes = len(value)
|
||||
s.usageText.WriteString(delta)
|
||||
index := block.Index
|
||||
media := &dto.ClaudeMediaMessage{}
|
||||
switch block.Kind {
|
||||
case "text":
|
||||
media.Type = "text_delta"
|
||||
media.Text = &delta
|
||||
case "thinking":
|
||||
media.Type = "thinking_delta"
|
||||
media.Thinking = &delta
|
||||
case "tool_use":
|
||||
media.Type = "input_json_delta"
|
||||
media.PartialJson = &delta
|
||||
}
|
||||
responses = append(responses, &dto.ClaudeResponse{Type: "content_block_delta", Index: &index, Delta: media})
|
||||
return responses
|
||||
}
|
||||
|
||||
func (s *ResponsesToClaudeStreamState) stopBlock(block *responsesClaudeStreamBlock, estimatedInputTokens int) []*dto.ClaudeResponse {
|
||||
if block == nil || block.Stopped {
|
||||
return nil
|
||||
}
|
||||
responses := s.flushBlock(block, estimatedInputTokens)
|
||||
responses = append(responses, s.startBlock(block, estimatedInputTokens)...)
|
||||
if !block.Started {
|
||||
return responses
|
||||
}
|
||||
block.Stopped = true
|
||||
index := block.Index
|
||||
return append(responses, &dto.ClaudeResponse{Type: "content_block_stop", Index: &index})
|
||||
}
|
||||
|
||||
func (s *ResponsesToClaudeStreamState) applyOutputItem(event *dto.ResponsesStreamResponse, estimatedInputTokens int, stop bool) ([]*dto.ClaudeResponse, error) {
|
||||
if event == nil || event.Item == nil {
|
||||
return nil, nil
|
||||
}
|
||||
item := event.Item
|
||||
var kind string
|
||||
switch item.Type {
|
||||
case responsesOutputTypeReasoning:
|
||||
kind = "thinking"
|
||||
case responsesOutputTypeMessage:
|
||||
if item.Role != "" && item.Role != "assistant" {
|
||||
return nil, nil
|
||||
}
|
||||
kind = "text"
|
||||
case responsesOutputTypeFunctionCall, responsesOutputTypeCustomToolCall:
|
||||
kind = "tool_use"
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
block, err := s.ensureBlock(event, kind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var responses []*dto.ClaudeResponse
|
||||
switch kind {
|
||||
case "thinking":
|
||||
responses = append(responses, s.mergeFinalValue(block, reasoningOutputText(item), estimatedInputTokens)...)
|
||||
case "text":
|
||||
var text strings.Builder
|
||||
var annotations []any
|
||||
for _, content := range item.Content {
|
||||
if content.Type != "output_text" {
|
||||
continue
|
||||
}
|
||||
text.WriteString(content.Text)
|
||||
annotations = append(annotations, content.Annotations...)
|
||||
}
|
||||
responses = append(responses, s.mergeFinalValue(block, text.String(), estimatedInputTokens)...)
|
||||
responses = append(responses, s.appendAnnotations(block, annotations, estimatedInputTokens, true)...)
|
||||
case "tool_use":
|
||||
responses = append(responses, s.mergeFinalValue(block, item.ArgumentsString(), estimatedInputTokens)...)
|
||||
}
|
||||
if stop {
|
||||
responses = append(responses, s.stopBlock(block, estimatedInputTokens)...)
|
||||
}
|
||||
return responses, nil
|
||||
}
|
||||
|
||||
func (s *ResponsesToClaudeStreamState) appendAnnotations(block *responsesClaudeStreamBlock, annotations []any, estimatedInputTokens int, snapshot bool) []*dto.ClaudeResponse {
|
||||
if block == nil || block.Kind != "text" || block.Stopped || len(annotations) == 0 {
|
||||
return nil
|
||||
}
|
||||
remaining := annotations
|
||||
if snapshot {
|
||||
if len(annotations) <= block.AnnotationCount {
|
||||
return nil
|
||||
}
|
||||
remaining = annotations[block.AnnotationCount:]
|
||||
block.AnnotationCount = len(annotations)
|
||||
} else {
|
||||
block.AnnotationCount += len(annotations)
|
||||
}
|
||||
citations := responsesAnnotationsToClaude(remaining, block.Value.String())
|
||||
if len(citations) == 0 {
|
||||
return nil
|
||||
}
|
||||
responses := s.startBlock(block, estimatedInputTokens)
|
||||
index := block.Index
|
||||
for _, citation := range citations {
|
||||
responses = append(responses, &dto.ClaudeResponse{
|
||||
Type: "content_block_delta",
|
||||
Index: &index,
|
||||
Delta: &dto.ClaudeMediaMessage{Type: "citations_delta", Citation: citation},
|
||||
})
|
||||
}
|
||||
return responses
|
||||
}
|
||||
|
||||
func (s *ResponsesToClaudeStreamState) finish(response *dto.OpenAIResponsesResponse, estimatedInputTokens int) ([]*dto.ClaudeResponse, error) {
|
||||
if s.done {
|
||||
return nil, nil
|
||||
}
|
||||
s.applyResponseMetadata(response)
|
||||
responses := make([]*dto.ClaudeResponse, 0)
|
||||
if response != nil {
|
||||
for outputIndex := range response.Output {
|
||||
index := outputIndex
|
||||
item := response.Output[outputIndex]
|
||||
event := &dto.ResponsesStreamResponse{OutputIndex: &index, ItemID: item.ID, Item: &item}
|
||||
itemResponses, err := s.applyOutputItem(event, estimatedInputTokens, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
responses = append(responses, itemResponses...)
|
||||
}
|
||||
}
|
||||
for _, block := range s.blocks {
|
||||
responses = append(responses, s.stopBlock(block, estimatedInputTokens)...)
|
||||
}
|
||||
responses = append(responses, s.ensureMessageStart(estimatedInputTokens)...)
|
||||
stopReason := responsesClaudeStopReason(response, s.sawToolCall)
|
||||
usage := sharedclaude.UsageFromOpenAI(s.Usage)
|
||||
responses = append(responses,
|
||||
&dto.ClaudeResponse{
|
||||
Type: "message_delta",
|
||||
Usage: usage,
|
||||
Delta: &dto.ClaudeMediaMessage{StopReason: &stopReason},
|
||||
},
|
||||
&dto.ClaudeResponse{Type: "message_stop"},
|
||||
)
|
||||
s.done = true
|
||||
return responses, nil
|
||||
}
|
||||
|
||||
func separatedResponsesDelta(delta string) string {
|
||||
if strings.HasPrefix(delta, "\n\n") {
|
||||
return delta
|
||||
}
|
||||
if strings.HasPrefix(delta, "\n") {
|
||||
return "\n" + delta
|
||||
}
|
||||
return "\n\n" + delta
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package oairesponses
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestResponsesToClaudeStreamDoesNotRepeatBlocksFromDoneAndCompletedEvents(t *testing.T) {
|
||||
state := NewResponsesToClaudeStreamState("", "")
|
||||
arguments := `{"q":"x"}`
|
||||
argumentRaw, err := kitutil.Marshal(arguments)
|
||||
require.NoError(t, err)
|
||||
statusRaw, err := kitutil.Marshal("completed")
|
||||
require.NoError(t, err)
|
||||
|
||||
reasoningItem := dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeReasoning,
|
||||
ID: "rs_1",
|
||||
Summary: []dto.ResponsesReasoningSummaryPart{{Type: "summary_text", Text: "plan"}},
|
||||
}
|
||||
messageItem := dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeMessage,
|
||||
ID: "msg_1",
|
||||
Role: "assistant",
|
||||
Content: []dto.ResponsesOutputContent{{Type: "output_text", Text: "hello"}},
|
||||
}
|
||||
toolItem := dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeFunctionCall,
|
||||
ID: "fc_1",
|
||||
CallId: "call_1",
|
||||
Name: "lookup",
|
||||
Arguments: argumentRaw,
|
||||
}
|
||||
|
||||
events := []*dto.ResponsesStreamResponse{
|
||||
{Type: responsesEventCreated, Response: &dto.OpenAIResponsesResponse{ID: "resp_1", Model: "gpt-test"}},
|
||||
{Type: responsesEventOutputItemAdded, OutputIndex: kitutil.GetPointer(0), ItemID: reasoningItem.ID, Item: &dto.ResponsesOutput{Type: reasoningItem.Type, ID: reasoningItem.ID}},
|
||||
{Type: responsesEventReasoningSummaryDelta, OutputIndex: kitutil.GetPointer(0), ItemID: reasoningItem.ID, Delta: "plan"},
|
||||
{Type: responsesEventReasoningSummaryDone, OutputIndex: kitutil.GetPointer(0), ItemID: reasoningItem.ID, Text: kitutil.GetPointer("plan")},
|
||||
{Type: responsesEventOutputItemDone, OutputIndex: kitutil.GetPointer(0), ItemID: reasoningItem.ID, Item: &reasoningItem},
|
||||
{Type: responsesEventOutputItemAdded, OutputIndex: kitutil.GetPointer(1), ItemID: messageItem.ID, Item: &dto.ResponsesOutput{Type: messageItem.Type, ID: messageItem.ID, Role: "assistant"}},
|
||||
{Type: responsesEventOutputTextDelta, OutputIndex: kitutil.GetPointer(1), ItemID: messageItem.ID, Delta: "hello"},
|
||||
{Type: responsesEventOutputTextDone, OutputIndex: kitutil.GetPointer(1), ItemID: messageItem.ID, Text: kitutil.GetPointer("hello")},
|
||||
{Type: responsesEventOutputItemDone, OutputIndex: kitutil.GetPointer(1), ItemID: messageItem.ID, Item: &messageItem},
|
||||
{Type: responsesEventOutputItemAdded, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Item: &dto.ResponsesOutput{Type: toolItem.Type, ID: toolItem.ID, CallId: toolItem.CallId, Name: toolItem.Name}},
|
||||
{Type: responsesEventFunctionArgsDelta, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Delta: `{"q":`},
|
||||
{Type: responsesEventFunctionArgsDelta, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Delta: `"x"}`},
|
||||
{Type: responsesEventFunctionArgsDone, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Arguments: &arguments},
|
||||
{Type: responsesEventOutputItemDone, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Item: &toolItem},
|
||||
{
|
||||
Type: responsesEventCompleted,
|
||||
Response: &dto.OpenAIResponsesResponse{
|
||||
ID: "resp_1",
|
||||
Model: "gpt-test",
|
||||
Status: statusRaw,
|
||||
Output: []dto.ResponsesOutput{reasoningItem, messageItem, toolItem},
|
||||
Usage: &dto.Usage{InputTokens: 11, OutputTokens: 7, TotalTokens: 18},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var output []*dto.ClaudeResponse
|
||||
for _, event := range events {
|
||||
converted, _, err := state.ConvertChunk(event, 9)
|
||||
require.NoError(t, err)
|
||||
output = append(output, converted...)
|
||||
}
|
||||
|
||||
starts := responsesOfType(output, "content_block_start")
|
||||
stops := responsesOfType(output, "content_block_stop")
|
||||
require.Len(t, responsesOfType(output, "message_start"), 1)
|
||||
require.Len(t, starts, 3)
|
||||
require.Len(t, stops, 3)
|
||||
require.Len(t, responsesOfType(output, "message_delta"), 1)
|
||||
require.Len(t, responsesOfType(output, "message_stop"), 1)
|
||||
assert.Equal(t, []int{0, 1, 2}, []int{starts[0].GetIndex(), starts[1].GetIndex(), starts[2].GetIndex()})
|
||||
assert.Equal(t, []string{"thinking", "text", "tool_use"}, []string{starts[0].ContentBlock.Type, starts[1].ContentBlock.Type, starts[2].ContentBlock.Type})
|
||||
assert.Equal(t, "plan", joinedClaudeDeltas(output, "thinking_delta"))
|
||||
assert.Equal(t, "hello", joinedClaudeDeltas(output, "text_delta"))
|
||||
assert.Equal(t, arguments, joinedClaudeDeltas(output, "input_json_delta"))
|
||||
messageDelta := responsesOfType(output, "message_delta")[0]
|
||||
require.NotNil(t, messageDelta.Delta.StopReason)
|
||||
assert.Equal(t, "tool_use", *messageDelta.Delta.StopReason)
|
||||
|
||||
finalized, err := state.Finalize(9)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, finalized)
|
||||
repeated, _, err := state.ConvertChunk(events[len(events)-1], 9)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, repeated)
|
||||
}
|
||||
|
||||
func responsesOfType(responses []*dto.ClaudeResponse, responseType string) []*dto.ClaudeResponse {
|
||||
filtered := make([]*dto.ClaudeResponse, 0)
|
||||
for _, response := range responses {
|
||||
if response != nil && response.Type == responseType {
|
||||
filtered = append(filtered, response)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func joinedClaudeDeltas(responses []*dto.ClaudeResponse, deltaType string) string {
|
||||
var result strings.Builder
|
||||
for _, response := range responses {
|
||||
if response == nil || response.Type != "content_block_delta" || response.Delta == nil || response.Delta.Type != deltaType {
|
||||
continue
|
||||
}
|
||||
switch deltaType {
|
||||
case "thinking_delta":
|
||||
if response.Delta.Thinking != nil {
|
||||
result.WriteString(*response.Delta.Thinking)
|
||||
}
|
||||
case "text_delta":
|
||||
if response.Delta.Text != nil {
|
||||
result.WriteString(*response.Delta.Text)
|
||||
}
|
||||
case "input_json_delta":
|
||||
if response.Delta.PartialJson != nil {
|
||||
result.WriteString(*response.Delta.PartialJson)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package oairesponses
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"context"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
|
||||
sharedgemini "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/gemini"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
|
||||
)
|
||||
|
||||
func convertOpenAIResponsesRequestToGeminiChat(c context.Context, info convmeta.Meta, request any) (any, error) {
|
||||
responsesRequest, err := OpenAIResponsesRequestFromAny(request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prepared, err := PrepareOpenAIResponsesRequest(*responsesRequest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return OpenAIResponsesRequestToGeminiChat(c, &prepared, info)
|
||||
}
|
||||
|
||||
func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIResponsesRequest, info convmeta.Meta) (*dto.GeminiChatRequest, error) {
|
||||
opts := convmeta.OptionsOf(info)
|
||||
if req == nil {
|
||||
return nil, fmt.Errorf("request is nil")
|
||||
}
|
||||
if req.Model == "" {
|
||||
return nil, fmt.Errorf("model is required")
|
||||
}
|
||||
if err := ValidateRequestChatUnsupportedFields(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
geminiRequest := &dto.GeminiChatRequest{
|
||||
GenerationConfig: dto.GeminiChatGenerationConfig{
|
||||
Temperature: req.Temperature,
|
||||
},
|
||||
}
|
||||
if req.TopP != nil {
|
||||
geminiRequest.GenerationConfig.TopP = kitutil.GetPointer(*req.TopP)
|
||||
}
|
||||
if req.MaxOutputTokens != nil {
|
||||
geminiRequest.GenerationConfig.MaxOutputTokens = kitutil.GetPointer(*req.MaxOutputTokens)
|
||||
}
|
||||
|
||||
upstreamModelName := req.Model
|
||||
if modelName := convmeta.UpstreamModelName(info); modelName != "" {
|
||||
upstreamModelName = modelName
|
||||
}
|
||||
if opts.Gemini.SupportsImagineModel(upstreamModelName) {
|
||||
geminiRequest.GenerationConfig.ResponseModalities = []string{"TEXT", "IMAGE"}
|
||||
}
|
||||
if err := applyResponsesTextToGemini(req.Text, geminiRequest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reasoningIntent, err := reasoning.FromOpenAIResponses(req)
|
||||
if err != nil {
|
||||
return nil, reasoning.AsClientError(err)
|
||||
}
|
||||
var reasoningPivot dto.GeneralOpenAIRequest
|
||||
if err := reasoning.ApplyToOpenAIChat(&reasoningPivot, reasoningIntent); err != nil {
|
||||
return nil, reasoning.AsClientError(err)
|
||||
}
|
||||
reasoningPivot.Model = req.Model
|
||||
reasoningPivot.MaxCompletionTokens = req.MaxOutputTokens
|
||||
if err := sharedgemini.ApplyThinkingConfig(geminiRequest, info, reasoningPivot); err != nil {
|
||||
return nil, reasoning.AsClientError(err)
|
||||
}
|
||||
|
||||
var safetySettings []dto.GeminiChatSafetySettings
|
||||
for _, category := range sharedgemini.SafetySettingCategories {
|
||||
threshold := opts.Gemini.SafetySettingFor(category)
|
||||
if threshold == "" {
|
||||
continue
|
||||
}
|
||||
safetySettings = append(safetySettings, dto.GeminiChatSafetySettings{
|
||||
Category: category,
|
||||
Threshold: threshold,
|
||||
})
|
||||
}
|
||||
if len(safetySettings) > 0 {
|
||||
geminiRequest.SafetySettings = safetySettings
|
||||
}
|
||||
|
||||
functions, err := RequestFunctionDeclarations(req.Tools)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range functions {
|
||||
if params, ok := functions[i].Parameters.(map[string]any); ok {
|
||||
if props, hasProps := params["properties"].(map[string]any); hasProps && len(props) == 0 {
|
||||
functions[i].Parameters = nil
|
||||
continue
|
||||
}
|
||||
}
|
||||
functions[i].Parameters = sharedgemini.CleanFunctionParameters(functions[i].Parameters)
|
||||
}
|
||||
if len(functions) > 0 {
|
||||
geminiRequest.SetTools([]dto.GeminiChatTool{
|
||||
{FunctionDeclarations: functions},
|
||||
})
|
||||
}
|
||||
|
||||
toolChoice, err := RequestToolChoiceToChat(req.ToolChoice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if toolChoice != nil {
|
||||
geminiRequest.ToolConfig = sharedgemini.OpenAIToolChoiceToConfig(toolChoice)
|
||||
}
|
||||
|
||||
systemTexts := make([]string, 0)
|
||||
if RawJSONPresent(req.Instructions) {
|
||||
instructions, err := JSONString(req.Instructions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid instructions: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(instructions) != "" {
|
||||
systemTexts = append(systemTexts, instructions)
|
||||
}
|
||||
}
|
||||
|
||||
inputItems, err := InputItems(req.Input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
callNames := make(map[string]string)
|
||||
for _, item := range inputItems {
|
||||
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
|
||||
switch itemType {
|
||||
case ResponsesInputTypeFunctionCall:
|
||||
part, callID, err := responsesFunctionCallItemToGeminiPart(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sharedgemini.AttachFunctionCallThoughtSignature(opts, &part)
|
||||
if callID != "" {
|
||||
callNames[callID] = part.FunctionCall.FunctionName
|
||||
}
|
||||
appendGeminiContentPart(geminiRequest, "model", part)
|
||||
case ResponsesInputTypeFunctionCallOutput:
|
||||
part, err := responsesFunctionOutputItemToGeminiPart(item, callNames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appendGeminiContentPart(geminiRequest, "user", part)
|
||||
default:
|
||||
role := responsesGeminiRole(item)
|
||||
parts, err := responsesInputContentToGeminiParts(c, item["content"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if role == "system" {
|
||||
for _, part := range parts {
|
||||
if part.Text != "" {
|
||||
systemTexts = append(systemTexts, part.Text)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
geminiRequest.Contents = append(geminiRequest.Contents, dto.GeminiChatContent{
|
||||
Role: role,
|
||||
Parts: parts,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(systemTexts) > 0 {
|
||||
geminiRequest.SystemInstructions = &dto.GeminiChatContent{
|
||||
Parts: []dto.GeminiPart{{Text: strings.Join(systemTexts, "\n")}},
|
||||
}
|
||||
}
|
||||
|
||||
return geminiRequest, nil
|
||||
}
|
||||
|
||||
func applyResponsesTextToGemini(raw []byte, geminiRequest *dto.GeminiChatRequest) error {
|
||||
responseFormat, err := RequestTextToChatResponseFormat(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if responseFormat == nil || (responseFormat.Type != "json_schema" && responseFormat.Type != "json_object") {
|
||||
return nil
|
||||
}
|
||||
|
||||
geminiRequest.GenerationConfig.ResponseMimeType = "application/json"
|
||||
if len(responseFormat.JsonSchema) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var jsonSchema dto.FormatJsonSchema
|
||||
if err := kitutil.Unmarshal(responseFormat.JsonSchema, &jsonSchema); err != nil {
|
||||
return nil
|
||||
}
|
||||
geminiRequest.GenerationConfig.ResponseSchema = sharedgemini.RemoveAdditionalProperties(jsonSchema.Schema, 0)
|
||||
return nil
|
||||
}
|
||||
|
||||
func responsesInputContentToGeminiParts(c context.Context, content any) ([]dto.GeminiPart, error) {
|
||||
contentParts, err := ContentParts(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parts := make([]dto.GeminiPart, 0, len(contentParts))
|
||||
for _, contentPart := range contentParts {
|
||||
nextParts, err := responsesContentPartToGeminiParts(c, contentPart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts = append(parts, nextParts...)
|
||||
}
|
||||
return parts, nil
|
||||
}
|
||||
|
||||
func responsesContentPartToGeminiParts(c context.Context, part map[string]any) ([]dto.GeminiPart, error) {
|
||||
partType := strings.TrimSpace(kitutil.Interface2String(part["type"]))
|
||||
switch partType {
|
||||
case "input_text", "output_text", "text":
|
||||
text := kitutil.Interface2String(part["text"])
|
||||
if text == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return []dto.GeminiPart{{Text: text}}, nil
|
||||
case "input_image", "input_file", "input_audio", "input_video":
|
||||
source := ContentPartToFileSource(part)
|
||||
if source == nil {
|
||||
return nil, nil
|
||||
}
|
||||
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting Responses input for Gemini")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get file data from '%s' failed: %w", source.GetIdentifier(), err)
|
||||
}
|
||||
if _, ok := sharedgemini.SupportedMimeTypes[strings.ToLower(mimeType)]; !ok {
|
||||
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", mimeType, source.GetIdentifier(), sharedgemini.SupportedMimeTypesList())
|
||||
}
|
||||
return []dto.GeminiPart{
|
||||
{
|
||||
InlineData: &dto.GeminiInlineData{
|
||||
MimeType: mimeType,
|
||||
Data: base64Data,
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func responsesFunctionCallItemToGeminiPart(item map[string]any) (dto.GeminiPart, string, error) {
|
||||
name := strings.TrimSpace(kitutil.Interface2String(item["name"]))
|
||||
if name == "" {
|
||||
return dto.GeminiPart{}, "", fmt.Errorf("function_call item is missing name")
|
||||
}
|
||||
callID := CallID(item)
|
||||
return dto.GeminiPart{
|
||||
FunctionCall: &dto.FunctionCall{
|
||||
ID: callID,
|
||||
FunctionName: name,
|
||||
Arguments: ObjectValue(item["arguments"], "arguments"),
|
||||
},
|
||||
}, callID, nil
|
||||
}
|
||||
|
||||
func responsesFunctionOutputItemToGeminiPart(item map[string]any, callNames map[string]string) (dto.GeminiPart, error) {
|
||||
callID := CallID(item)
|
||||
name := strings.TrimSpace(kitutil.Interface2String(item["name"]))
|
||||
if name == "" {
|
||||
name = callNames[callID]
|
||||
}
|
||||
response := &dto.GeminiFunctionResponse{
|
||||
Name: name,
|
||||
Response: GeminiResponseMap(item["output"]),
|
||||
}
|
||||
if callID != "" {
|
||||
id, err := kitutil.Marshal(callID)
|
||||
if err != nil {
|
||||
return dto.GeminiPart{}, fmt.Errorf("failed to marshal function response ID: %w", err)
|
||||
}
|
||||
response.ID = id
|
||||
}
|
||||
return dto.GeminiPart{
|
||||
FunctionResponse: response,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func appendGeminiContentPart(req *dto.GeminiChatRequest, role string, part dto.GeminiPart) {
|
||||
if len(req.Contents) > 0 && req.Contents[len(req.Contents)-1].Role == role {
|
||||
if role == "model" && part.FunctionCall != nil {
|
||||
parts := req.Contents[len(req.Contents)-1].Parts
|
||||
insertAt := 0
|
||||
for insertAt < len(parts) && parts[insertAt].FunctionCall != nil {
|
||||
insertAt++
|
||||
}
|
||||
parts = append(parts, dto.GeminiPart{})
|
||||
copy(parts[insertAt+1:], parts[insertAt:])
|
||||
parts[insertAt] = part
|
||||
req.Contents[len(req.Contents)-1].Parts = parts
|
||||
return
|
||||
}
|
||||
req.Contents[len(req.Contents)-1].Parts = append(req.Contents[len(req.Contents)-1].Parts, part)
|
||||
return
|
||||
}
|
||||
req.Contents = append(req.Contents, dto.GeminiChatContent{
|
||||
Role: role,
|
||||
Parts: []dto.GeminiPart{part},
|
||||
})
|
||||
}
|
||||
|
||||
func responsesGeminiRole(item map[string]any) string {
|
||||
switch strings.TrimSpace(kitutil.Interface2String(item["role"])) {
|
||||
case "assistant":
|
||||
return "model"
|
||||
case "system", "developer":
|
||||
return "system"
|
||||
case "model":
|
||||
return "model"
|
||||
default:
|
||||
return "user"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package oairesponses
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
)
|
||||
|
||||
const (
|
||||
geminiResponsesInputTypeCustomToolCall = "custom_tool_call"
|
||||
geminiResponsesInputTypeCustomToolCallOutput = "custom_tool_call_output"
|
||||
geminiResponsesInputTypeFunctionCallOutput = "function_call_output"
|
||||
)
|
||||
|
||||
const (
|
||||
ResponsesInputTypeCustomToolCallOutput = geminiResponsesInputTypeCustomToolCallOutput
|
||||
)
|
||||
|
||||
func PrepareOpenAIResponsesRequest(request dto.OpenAIResponsesRequest) (dto.OpenAIResponsesRequest, error) {
|
||||
tools, err := filterGeminiResponsesTools(request.Tools)
|
||||
if err != nil {
|
||||
return request, err
|
||||
}
|
||||
request.Tools = tools
|
||||
|
||||
input, err := filterGeminiResponsesInput(request.Input)
|
||||
if err != nil {
|
||||
return request, err
|
||||
}
|
||||
request.Input = input
|
||||
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func filterGeminiResponsesTools(raw []byte) ([]byte, error) {
|
||||
if !geminiRawJSONPresent(raw) || kitutil.GetJsonType(raw) != "array" {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
var tools []map[string]any
|
||||
if err := kitutil.Unmarshal(raw, &tools); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filtered := make([]map[string]any, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
if strings.TrimSpace(kitutil.Interface2String(tool["type"])) != "function" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, tool)
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return kitutil.Marshal(filtered)
|
||||
}
|
||||
|
||||
func filterGeminiResponsesInput(raw []byte) ([]byte, error) {
|
||||
if !geminiRawJSONPresent(raw) || kitutil.GetJsonType(raw) != "array" {
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
var items []map[string]any
|
||||
if err := kitutil.Unmarshal(raw, &items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
skippedCustomCallIDs := make(map[string]struct{})
|
||||
for _, item := range items {
|
||||
if strings.TrimSpace(kitutil.Interface2String(item["type"])) != geminiResponsesInputTypeCustomToolCall {
|
||||
continue
|
||||
}
|
||||
if callID := strings.TrimSpace(kitutil.Interface2String(item["call_id"])); callID != "" {
|
||||
skippedCustomCallIDs[callID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
filtered := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
|
||||
switch itemType {
|
||||
case geminiResponsesInputTypeCustomToolCall, geminiResponsesInputTypeCustomToolCallOutput:
|
||||
continue
|
||||
case geminiResponsesInputTypeFunctionCallOutput:
|
||||
if _, ok := skippedCustomCallIDs[strings.TrimSpace(kitutil.Interface2String(item["call_id"]))]; ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
|
||||
return kitutil.Marshal(filtered)
|
||||
}
|
||||
|
||||
func geminiRawJSONPresent(raw []byte) bool {
|
||||
if len(raw) == 0 {
|
||||
return false
|
||||
}
|
||||
return kitutil.GetJsonType(raw) != "null"
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
package oairesponses
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
|
||||
)
|
||||
|
||||
const (
|
||||
responsesInputTypeFunctionCall = "function_call"
|
||||
responsesInputTypeFunctionCallOutput = "function_call_output"
|
||||
responsesInputTypeCustomToolCall = "custom_tool_call"
|
||||
responsesInputTypeCustomToolOutput = "custom_tool_call_output"
|
||||
)
|
||||
|
||||
const (
|
||||
ResponsesInputTypeFunctionCall = responsesInputTypeFunctionCall
|
||||
ResponsesInputTypeFunctionCallOutput = responsesInputTypeFunctionCallOutput
|
||||
ResponsesInputTypeCustomToolCall = responsesInputTypeCustomToolCall
|
||||
ResponsesInputTypeCustomToolOutput = responsesInputTypeCustomToolOutput
|
||||
)
|
||||
|
||||
func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (*dto.GeneralOpenAIRequest, error) {
|
||||
if req == nil {
|
||||
return nil, errors.New("request is nil")
|
||||
}
|
||||
if req.Model == "" {
|
||||
return nil, errors.New("model is required")
|
||||
}
|
||||
if err := validateResponsesRequestChatUnsupportedFields(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
messages, err := responsesRequestMessagesToChat(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tools, err := responsesRequestToolsToChat(req.Tools)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
toolChoice, err := responsesRequestToolChoiceToChat(req.ToolChoice)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
responseFormat, err := responsesRequestTextToChatResponseFormat(req.Text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := &dto.GeneralOpenAIRequest{
|
||||
Model: req.Model,
|
||||
Messages: messages,
|
||||
Stream: req.Stream,
|
||||
StreamOptions: req.StreamOptions,
|
||||
MaxCompletionTokens: req.MaxOutputTokens,
|
||||
Temperature: req.Temperature,
|
||||
TopP: req.TopP,
|
||||
TopLogProbs: req.TopLogProbs,
|
||||
ResponseFormat: responseFormat,
|
||||
Tools: tools,
|
||||
ToolChoice: toolChoice,
|
||||
User: req.User,
|
||||
Store: req.Store,
|
||||
Metadata: req.Metadata,
|
||||
SafetyIdentifier: req.SafetyIdentifier,
|
||||
PromptCacheRetention: req.PromptCacheRetention,
|
||||
EnableThinking: req.EnableThinking,
|
||||
ThinkingBudget: req.ThinkingBudget,
|
||||
}
|
||||
|
||||
out.FrequencyPenalty, err = responsesRawFloat(req.FrequencyPenalty)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid frequency_penalty: %w", err)
|
||||
}
|
||||
out.PresencePenalty, err = responsesRawFloat(req.PresencePenalty)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid presence_penalty: %w", err)
|
||||
}
|
||||
|
||||
if reasoningIntent, err := reasoning.FromOpenAIResponses(req); err != nil {
|
||||
return nil, reasoning.AsClientError(err)
|
||||
} else if err := reasoning.ApplyToOpenAIChat(out, reasoningIntent); err != nil {
|
||||
return nil, reasoning.AsClientError(err)
|
||||
}
|
||||
if req.ServiceTier != "" {
|
||||
out.ServiceTier, _ = kitutil.Marshal(req.ServiceTier)
|
||||
}
|
||||
if len(req.ParallelToolCalls) > 0 && kitutil.GetJsonType(req.ParallelToolCalls) == "boolean" {
|
||||
var parallelToolCalls bool
|
||||
if err := kitutil.Unmarshal(req.ParallelToolCalls, ¶llelToolCalls); err == nil {
|
||||
out.ParallelTooCalls = ¶llelToolCalls
|
||||
}
|
||||
}
|
||||
if len(req.PromptCacheKey) > 0 && kitutil.GetJsonType(req.PromptCacheKey) == "string" {
|
||||
var promptCacheKey string
|
||||
if err := kitutil.Unmarshal(req.PromptCacheKey, &promptCacheKey); err == nil {
|
||||
out.PromptCacheKey = promptCacheKey
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validateResponsesRequestChatUnsupportedFields(req *dto.OpenAIResponsesRequest) error {
|
||||
unsupported := make([]string, 0, 4)
|
||||
if rawJSONPresent(req.Conversation) {
|
||||
unsupported = append(unsupported, "conversation")
|
||||
}
|
||||
if strings.TrimSpace(req.PreviousResponseID) != "" {
|
||||
unsupported = append(unsupported, "previous_response_id")
|
||||
}
|
||||
if rawJSONPresent(req.Prompt) {
|
||||
unsupported = append(unsupported, "prompt")
|
||||
}
|
||||
if rawJSONPresent(req.ContextManagement) {
|
||||
unsupported = append(unsupported, "context_management")
|
||||
}
|
||||
if len(unsupported) > 0 {
|
||||
return fmt.Errorf("responses to chat conversion does not support stateful fields: %s", strings.Join(unsupported, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateRequestChatUnsupportedFields(req *dto.OpenAIResponsesRequest) error {
|
||||
return validateResponsesRequestChatUnsupportedFields(req)
|
||||
}
|
||||
|
||||
func responsesRequestMessagesToChat(req *dto.OpenAIResponsesRequest) ([]dto.Message, error) {
|
||||
messages := make([]dto.Message, 0)
|
||||
if rawJSONPresent(req.Instructions) {
|
||||
instructions, err := responsesJSONString(req.Instructions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid instructions: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(instructions) != "" {
|
||||
messages = append(messages, dto.Message{Role: "system", Content: instructions})
|
||||
}
|
||||
}
|
||||
|
||||
if !rawJSONPresent(req.Input) {
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
switch kitutil.GetJsonType(req.Input) {
|
||||
case "string":
|
||||
input, err := responsesJSONString(req.Input)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid input string: %w", err)
|
||||
}
|
||||
messages = append(messages, dto.Message{Role: "user", Content: input})
|
||||
return messages, nil
|
||||
case "array":
|
||||
var items []map[string]any
|
||||
if err := kitutil.Unmarshal(req.Input, &items); err != nil {
|
||||
return nil, fmt.Errorf("invalid input array: %w", err)
|
||||
}
|
||||
for _, item := range items {
|
||||
nextMessages, err := responsesInputItemToChatMessages(item, messages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
messages = nextMessages
|
||||
}
|
||||
return messages, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported responses input type %q", kitutil.GetJsonType(req.Input))
|
||||
}
|
||||
}
|
||||
|
||||
func responsesInputItemToChatMessages(item map[string]any, messages []dto.Message) ([]dto.Message, error) {
|
||||
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
|
||||
switch itemType {
|
||||
case responsesInputTypeFunctionCall:
|
||||
toolCall, err := responsesFunctionCallItemToChatToolCall(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return appendToolCallToLastAssistant(messages, toolCall), nil
|
||||
case responsesInputTypeCustomToolCall:
|
||||
toolCall, err := responsesCustomToolCallItemToChatToolCall(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return appendToolCallToLastAssistant(messages, toolCall), nil
|
||||
case responsesInputTypeFunctionCallOutput:
|
||||
callID := strings.TrimSpace(kitutil.Interface2String(item["call_id"]))
|
||||
content := responseToolOutputToChatContent(item["output"])
|
||||
return append(messages, dto.Message{Role: "tool", ToolCallId: callID, Content: content}), nil
|
||||
}
|
||||
|
||||
role := strings.TrimSpace(kitutil.Interface2String(item["role"]))
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
content, err := responsesInputContentToChatContent(item["content"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(messages, dto.Message{Role: role, Content: content}), nil
|
||||
}
|
||||
|
||||
func responsesInputContentToChatContent(content any) (any, error) {
|
||||
if content == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
switch value := content.(type) {
|
||||
case string:
|
||||
return value, nil
|
||||
case []any:
|
||||
return responsesContentPartsToChatContent(value)
|
||||
case []map[string]any:
|
||||
parts := make([]any, 0, len(value))
|
||||
for _, part := range value {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
return responsesContentPartsToChatContent(parts)
|
||||
default:
|
||||
return content, nil
|
||||
}
|
||||
}
|
||||
|
||||
func responsesContentPartsToChatContent(parts []any) (any, error) {
|
||||
chatParts := make([]any, 0, len(parts))
|
||||
var textOnly strings.Builder
|
||||
onlyText := true
|
||||
|
||||
for _, rawPart := range parts {
|
||||
part, ok := rawPart.(map[string]any)
|
||||
if !ok {
|
||||
onlyText = false
|
||||
chatParts = append(chatParts, rawPart)
|
||||
continue
|
||||
}
|
||||
|
||||
partType := strings.TrimSpace(kitutil.Interface2String(part["type"]))
|
||||
switch partType {
|
||||
case "input_text", "output_text", "text":
|
||||
text := kitutil.Interface2String(part["text"])
|
||||
textOnly.WriteString(text)
|
||||
chatParts = append(chatParts, map[string]any{
|
||||
"type": dto.ContentTypeText,
|
||||
"text": text,
|
||||
})
|
||||
case "input_image":
|
||||
onlyText = false
|
||||
chatParts = append(chatParts, map[string]any{
|
||||
"type": dto.ContentTypeImageURL,
|
||||
"image_url": responsesImagePartToChatImageURL(part),
|
||||
})
|
||||
case "input_file":
|
||||
onlyText = false
|
||||
chatParts = append(chatParts, map[string]any{
|
||||
"type": dto.ContentTypeFile,
|
||||
"file": responsesFilePartToChatFile(part),
|
||||
})
|
||||
case "input_audio":
|
||||
onlyText = false
|
||||
chatParts = append(chatParts, map[string]any{
|
||||
"type": dto.ContentTypeInputAudio,
|
||||
"input_audio": responsesPartPayload(part, "input_audio"),
|
||||
})
|
||||
case "input_video":
|
||||
onlyText = false
|
||||
chatParts = append(chatParts, map[string]any{
|
||||
"type": dto.ContentTypeVideoUrl,
|
||||
"video_url": responsesVideoPartToChatVideoURL(part),
|
||||
})
|
||||
default:
|
||||
onlyText = false
|
||||
chatParts = append(chatParts, part)
|
||||
}
|
||||
}
|
||||
|
||||
if onlyText {
|
||||
return textOnly.String(), nil
|
||||
}
|
||||
return chatParts, nil
|
||||
}
|
||||
|
||||
func responsesFunctionCallItemToChatToolCall(item map[string]any) (dto.ToolCallRequest, error) {
|
||||
name := strings.TrimSpace(kitutil.Interface2String(item["name"]))
|
||||
if name == "" {
|
||||
return dto.ToolCallRequest{}, errors.New("function_call item is missing name")
|
||||
}
|
||||
return dto.ToolCallRequest{
|
||||
ID: responsesCallID(item),
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: name,
|
||||
Arguments: responsesArgumentsString(item["arguments"]),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func responsesCustomToolCallItemToChatToolCall(item map[string]any) (dto.ToolCallRequest, error) {
|
||||
raw, err := kitutil.Marshal(item)
|
||||
if err != nil {
|
||||
return dto.ToolCallRequest{}, err
|
||||
}
|
||||
return dto.ToolCallRequest{
|
||||
ID: responsesCallID(item),
|
||||
Type: dto.CustomType,
|
||||
Custom: raw,
|
||||
Function: dto.FunctionRequest{
|
||||
Name: strings.TrimSpace(kitutil.Interface2String(item["name"])),
|
||||
Arguments: responsesArgumentsString(item["input"]),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func appendToolCallToLastAssistant(messages []dto.Message, toolCall dto.ToolCallRequest) []dto.Message {
|
||||
if len(messages) == 0 || messages[len(messages)-1].Role != "assistant" {
|
||||
messages = append(messages, dto.Message{Role: "assistant"})
|
||||
}
|
||||
|
||||
idx := len(messages) - 1
|
||||
toolCalls := messages[idx].ParseToolCalls()
|
||||
toolCalls = append(toolCalls, toolCall)
|
||||
toolCallsRaw, _ := kitutil.Marshal(toolCalls)
|
||||
messages[idx].ToolCalls = toolCallsRaw
|
||||
return messages
|
||||
}
|
||||
|
||||
func responsesRequestToolsToChat(raw json.RawMessage) ([]dto.ToolCallRequest, error) {
|
||||
if !rawJSONPresent(raw) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var tools []map[string]any
|
||||
if err := kitutil.Unmarshal(raw, &tools); err != nil {
|
||||
return nil, fmt.Errorf("invalid tools: %w", err)
|
||||
}
|
||||
|
||||
out := make([]dto.ToolCallRequest, 0, len(tools))
|
||||
for _, tool := range tools {
|
||||
toolType := strings.TrimSpace(kitutil.Interface2String(tool["type"]))
|
||||
if toolType == "function" {
|
||||
out = append(out, dto.ToolCallRequest{
|
||||
Type: "function",
|
||||
Function: dto.FunctionRequest{
|
||||
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
|
||||
Description: kitutil.Interface2String(tool["description"]),
|
||||
Parameters: tool["parameters"],
|
||||
},
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
rawTool, err := kitutil.Marshal(tool)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, dto.ToolCallRequest{
|
||||
Type: toolType,
|
||||
Custom: rawTool,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func responsesRequestToolChoiceToChat(raw json.RawMessage) (any, error) {
|
||||
if !rawJSONPresent(raw) {
|
||||
return nil, nil
|
||||
}
|
||||
if kitutil.GetJsonType(raw) == "string" {
|
||||
var choice string
|
||||
if err := kitutil.Unmarshal(raw, &choice); err != nil {
|
||||
return nil, fmt.Errorf("invalid tool_choice: %w", err)
|
||||
}
|
||||
return choice, nil
|
||||
}
|
||||
|
||||
var choice map[string]any
|
||||
if err := kitutil.Unmarshal(raw, &choice); err != nil {
|
||||
return nil, fmt.Errorf("invalid tool_choice: %w", err)
|
||||
}
|
||||
if kitutil.Interface2String(choice["type"]) == "function" {
|
||||
name := strings.TrimSpace(kitutil.Interface2String(choice["name"]))
|
||||
if name != "" {
|
||||
return map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": name,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return choice, nil
|
||||
}
|
||||
|
||||
func RequestToolChoiceToChat(raw json.RawMessage) (any, error) {
|
||||
return responsesRequestToolChoiceToChat(raw)
|
||||
}
|
||||
|
||||
func responsesRequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) {
|
||||
if !rawJSONPresent(raw) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var textConfig map[string]any
|
||||
if err := kitutil.Unmarshal(raw, &textConfig); err != nil {
|
||||
return nil, fmt.Errorf("invalid text config: %w", err)
|
||||
}
|
||||
format, ok := textConfig["format"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
formatType := strings.TrimSpace(kitutil.Interface2String(format["type"]))
|
||||
if formatType == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
out := &dto.ResponseFormat{Type: formatType}
|
||||
if formatType == "json_schema" {
|
||||
schemaRaw, err := kitutil.Marshal(format)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.JsonSchema = schemaRaw
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func RequestTextToChatResponseFormat(raw json.RawMessage) (*dto.ResponseFormat, error) {
|
||||
return responsesRequestTextToChatResponseFormat(raw)
|
||||
}
|
||||
|
||||
func responsesImagePartToChatImageURL(part map[string]any) any {
|
||||
if imageURL, ok := part["image_url"]; ok {
|
||||
return imageURL
|
||||
}
|
||||
imageURL := map[string]any{}
|
||||
for _, key := range []string{"url", "file_id", "detail"} {
|
||||
if value, ok := part[key]; ok {
|
||||
imageURL[key] = value
|
||||
}
|
||||
}
|
||||
if len(imageURL) == 0 {
|
||||
return part
|
||||
}
|
||||
return imageURL
|
||||
}
|
||||
|
||||
func responsesFilePartToChatFile(part map[string]any) any {
|
||||
if file, ok := part["file"]; ok {
|
||||
return file
|
||||
}
|
||||
file := map[string]any{}
|
||||
for _, key := range []string{"file_id", "file_data", "filename", "file_url"} {
|
||||
if value, ok := part[key]; ok {
|
||||
file[key] = value
|
||||
}
|
||||
}
|
||||
if len(file) == 0 {
|
||||
return part
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
func responsesVideoPartToChatVideoURL(part map[string]any) any {
|
||||
if videoURL, ok := part["video_url"]; ok {
|
||||
if videoURLMap, ok := videoURL.(map[string]any); ok {
|
||||
if url := kitutil.Interface2String(videoURLMap["url"]); url != "" {
|
||||
return url
|
||||
}
|
||||
}
|
||||
return videoURL
|
||||
}
|
||||
if url := kitutil.Interface2String(part["url"]); url != "" {
|
||||
return url
|
||||
}
|
||||
return responsesPartPayload(part, "video_url")
|
||||
}
|
||||
|
||||
func responsesPartPayload(part map[string]any, key string) any {
|
||||
if value, ok := part[key]; ok {
|
||||
return value
|
||||
}
|
||||
payload := make(map[string]any, len(part))
|
||||
for k, value := range part {
|
||||
if k == "type" {
|
||||
continue
|
||||
}
|
||||
payload[k] = value
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func responsesCallID(item map[string]any) string {
|
||||
callID := strings.TrimSpace(kitutil.Interface2String(item["call_id"]))
|
||||
if callID != "" {
|
||||
return callID
|
||||
}
|
||||
return strings.TrimSpace(kitutil.Interface2String(item["id"]))
|
||||
}
|
||||
|
||||
func CallID(item map[string]any) string {
|
||||
return responsesCallID(item)
|
||||
}
|
||||
|
||||
func responsesArgumentsString(value any) string {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
raw, err := kitutil.Marshal(v)
|
||||
if err != nil {
|
||||
return kitutil.Interface2String(v)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
}
|
||||
|
||||
func responseToolOutputToChatContent(value any) any {
|
||||
switch v := value.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
raw, err := kitutil.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
}
|
||||
|
||||
func responsesRawFloat(raw json.RawMessage) (*float64, error) {
|
||||
if !rawJSONPresent(raw) {
|
||||
return nil, nil
|
||||
}
|
||||
var value float64
|
||||
if err := kitutil.Unmarshal(raw, &value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &value, nil
|
||||
}
|
||||
|
||||
func responsesJSONString(raw json.RawMessage) (string, error) {
|
||||
if kitutil.GetJsonType(raw) != "string" {
|
||||
return string(raw), nil
|
||||
}
|
||||
var value string
|
||||
if err := kitutil.Unmarshal(raw, &value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func rawJSONPresent(raw json.RawMessage) bool {
|
||||
if len(raw) == 0 {
|
||||
return false
|
||||
}
|
||||
return kitutil.GetJsonType(raw) != "null"
|
||||
}
|
||||
|
||||
func JSONString(raw json.RawMessage) (string, error) {
|
||||
return responsesJSONString(raw)
|
||||
}
|
||||
|
||||
func RawJSONPresent(raw json.RawMessage) bool {
|
||||
return rawJSONPresent(raw)
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package oairesponses
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestInstructionsAndScalarInput(t *testing.T) {
|
||||
stream := true
|
||||
temperature := 0.0
|
||||
topP := 0.9
|
||||
maxOutputTokens := uint(128)
|
||||
parallelToolCalls := true
|
||||
|
||||
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Instructions: mustRawMessage(t, "system rules"),
|
||||
Input: mustRawMessage(t, "hello"),
|
||||
Stream: &stream,
|
||||
StreamOptions: &dto.StreamOptions{IncludeUsage: true},
|
||||
MaxOutputTokens: &maxOutputTokens,
|
||||
Temperature: &temperature,
|
||||
TopP: &topP,
|
||||
User: mustRawMessage(t, "user-1"),
|
||||
Store: mustRawMessage(t, false),
|
||||
Metadata: mustRawMessage(t, map[string]any{"trace": "abc"}),
|
||||
ParallelToolCalls: mustRawMessage(t, parallelToolCalls),
|
||||
PromptCacheKey: mustRawMessage(t, "cache-key"),
|
||||
PromptCacheRetention: mustRawMessage(t, "24h"),
|
||||
Reasoning: &dto.Reasoning{Effort: "medium"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "gpt-test", got.Model)
|
||||
require.Len(t, got.Messages, 2)
|
||||
assert.Equal(t, dto.Message{Role: "system", Content: "system rules"}, got.Messages[0])
|
||||
assert.Equal(t, dto.Message{Role: "user", Content: "hello"}, got.Messages[1])
|
||||
assert.Same(t, &stream, got.Stream)
|
||||
require.NotNil(t, got.StreamOptions)
|
||||
assert.True(t, got.StreamOptions.IncludeUsage)
|
||||
assert.Equal(t, maxOutputTokens, lo.FromPtr(got.MaxCompletionTokens))
|
||||
assert.Equal(t, 0.0, lo.FromPtr(got.Temperature))
|
||||
assert.Equal(t, 0.9, lo.FromPtr(got.TopP))
|
||||
assert.True(t, lo.FromPtr(got.ParallelTooCalls))
|
||||
assert.Equal(t, "cache-key", got.PromptCacheKey)
|
||||
assert.Equal(t, "medium", got.ReasoningEffort)
|
||||
assert.Equal(t, `"user-1"`, string(got.User))
|
||||
assert.Equal(t, `false`, string(got.Store))
|
||||
assert.Equal(t, "abc", gjson.GetBytes(got.Metadata, "trace").String())
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestPreservesQwenThinkingBudget(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
budget json.RawMessage
|
||||
want int64
|
||||
}{
|
||||
{name: "positive budget", budget: json.RawMessage(`128`), want: 128},
|
||||
{name: "zero budget", budget: json.RawMessage(`0`), want: 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
|
||||
Model: "qwen-plus",
|
||||
Input: mustRawMessage(t, "hello"),
|
||||
EnableThinking: json.RawMessage(`true`),
|
||||
ThinkingBudget: tt.budget,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.budget, got.ThinkingBudget)
|
||||
|
||||
encoded, err := kitutil.Marshal(got)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, gjson.GetBytes(encoded, "enable_thinking").Bool())
|
||||
value := gjson.GetBytes(encoded, "thinking_budget")
|
||||
assert.True(t, value.Exists())
|
||||
assert.Equal(t, tt.want, value.Int())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestMultimodalInput(t *testing.T) {
|
||||
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{
|
||||
"role": "user",
|
||||
"content": []map[string]any{
|
||||
{"type": "input_text", "text": "look"},
|
||||
{"type": "input_image", "image_url": "https://example.test/a.png", "detail": "low"},
|
||||
{"type": "input_file", "file_id": "file_1", "filename": "a.txt"},
|
||||
{"type": "input_audio", "input_audio": map[string]any{"data": "abc", "format": "wav"}},
|
||||
{"type": "input_video", "video_url": map[string]any{"url": "https://example.test/v.mp4"}},
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, got.Messages, 1)
|
||||
assert.Equal(t, "user", got.Messages[0].Role)
|
||||
parts := got.Messages[0].ParseContent()
|
||||
require.Len(t, parts, 5)
|
||||
assert.Equal(t, dto.ContentTypeText, parts[0].Type)
|
||||
assert.Equal(t, "look", parts[0].Text)
|
||||
assert.Equal(t, dto.ContentTypeImageURL, parts[1].Type)
|
||||
assert.Equal(t, "https://example.test/a.png", parts[1].GetImageMedia().Url)
|
||||
assert.Equal(t, dto.ContentTypeFile, parts[2].Type)
|
||||
assert.Equal(t, "file_1", parts[2].GetFile().FileId)
|
||||
assert.Equal(t, dto.ContentTypeInputAudio, parts[3].Type)
|
||||
assert.Equal(t, "wav", parts[3].GetInputAudio().Format)
|
||||
assert.Equal(t, dto.ContentTypeVideoUrl, parts[4].Type)
|
||||
assert.Equal(t, "https://example.test/v.mp4", parts[4].GetVideoUrl().Url)
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestAssistantTextAndFunctionCallCoexist(t *testing.T) {
|
||||
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": []map[string]any{
|
||||
{"type": "output_text", "text": "I will call."},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_1",
|
||||
"name": "lookup",
|
||||
"arguments": map[string]any{"q": "x"},
|
||||
},
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_1",
|
||||
"output": map[string]any{"ok": true},
|
||||
},
|
||||
}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, got.Messages, 2)
|
||||
assert.Equal(t, "assistant", got.Messages[0].Role)
|
||||
assert.Equal(t, "I will call.", got.Messages[0].StringContent())
|
||||
toolCalls := got.Messages[0].ParseToolCalls()
|
||||
require.Len(t, toolCalls, 1)
|
||||
assert.Equal(t, "call_1", toolCalls[0].ID)
|
||||
assert.Equal(t, "function", toolCalls[0].Type)
|
||||
assert.Equal(t, "lookup", toolCalls[0].Function.Name)
|
||||
assert.JSONEq(t, `{"q":"x"}`, toolCalls[0].Function.Arguments)
|
||||
assert.Equal(t, "tool", got.Messages[1].Role)
|
||||
assert.Equal(t, "call_1", got.Messages[1].ToolCallId)
|
||||
assert.JSONEq(t, `{"ok":true}`, got.Messages[1].StringContent())
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestOnlyFunctionCallCreatesAssistant(t *testing.T) {
|
||||
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_1",
|
||||
"name": "lookup",
|
||||
"arguments": `{"q":"x"}`,
|
||||
},
|
||||
}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, got.Messages, 1)
|
||||
assert.Equal(t, "assistant", got.Messages[0].Role)
|
||||
assert.Nil(t, got.Messages[0].Content)
|
||||
toolCalls := got.Messages[0].ParseToolCalls()
|
||||
require.Len(t, toolCalls, 1)
|
||||
assert.Equal(t, `{"q":"x"}`, toolCalls[0].Function.Arguments)
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestToolsToolChoiceAndTextFormat(t *testing.T) {
|
||||
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Input: mustRawMessage(t, "hello"),
|
||||
Tools: mustRawMessage(t, []map[string]any{
|
||||
{
|
||||
"type": "function",
|
||||
"name": "lookup",
|
||||
"description": "Lookup data",
|
||||
"parameters": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"q": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
ToolChoice: mustRawMessage(t, map[string]any{
|
||||
"type": "function",
|
||||
"name": "lookup",
|
||||
}),
|
||||
Text: mustRawMessage(t, map[string]any{
|
||||
"format": map[string]any{
|
||||
"type": "json_schema",
|
||||
"name": "answer",
|
||||
"schema": map[string]any{"type": "object"},
|
||||
"strict": true,
|
||||
},
|
||||
}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, got.Tools, 1)
|
||||
assert.Equal(t, "function", got.Tools[0].Type)
|
||||
assert.Equal(t, "lookup", got.Tools[0].Function.Name)
|
||||
assert.Equal(t, "Lookup data", got.Tools[0].Function.Description)
|
||||
assert.Equal(t, "object", got.Tools[0].Function.Parameters.(map[string]any)["type"])
|
||||
assert.Equal(t, map[string]any{
|
||||
"type": "function",
|
||||
"function": map[string]any{
|
||||
"name": "lookup",
|
||||
},
|
||||
}, got.ToolChoice)
|
||||
require.NotNil(t, got.ResponseFormat)
|
||||
assert.Equal(t, "json_schema", got.ResponseFormat.Type)
|
||||
assert.Equal(t, "answer", gjson.GetBytes(got.ResponseFormat.JsonSchema, "name").String())
|
||||
assert.True(t, gjson.GetBytes(got.ResponseFormat.JsonSchema, "strict").Bool())
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestCustomToolCallPreservesRawShape(t *testing.T) {
|
||||
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
{
|
||||
"type": "custom_tool_call",
|
||||
"call_id": "call_custom",
|
||||
"name": "apply_patch",
|
||||
"input": "patch body",
|
||||
},
|
||||
}),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, got.Messages, 1)
|
||||
toolCalls := got.Messages[0].ParseToolCalls()
|
||||
require.Len(t, toolCalls, 1)
|
||||
assert.Equal(t, dto.CustomType, toolCalls[0].Type)
|
||||
assert.Equal(t, "call_custom", toolCalls[0].ID)
|
||||
assert.Equal(t, "apply_patch", toolCalls[0].Function.Name)
|
||||
assert.Equal(t, "patch body", toolCalls[0].Function.Arguments)
|
||||
assert.Equal(t, "custom_tool_call", gjson.GetBytes(toolCalls[0].Custom, "type").String())
|
||||
assert.Equal(t, "patch body", gjson.GetBytes(toolCalls[0].Custom, "input").String())
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestRejectsStatefulFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req *dto.OpenAIResponsesRequest
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "conversation",
|
||||
req: &dto.OpenAIResponsesRequest{Model: "gpt-test", Conversation: mustRawMessage(t, "conv_1")},
|
||||
want: "conversation",
|
||||
},
|
||||
{
|
||||
name: "previous response",
|
||||
req: &dto.OpenAIResponsesRequest{Model: "gpt-test", PreviousResponseID: "resp_1"},
|
||||
want: "previous_response_id",
|
||||
},
|
||||
{
|
||||
name: "prompt",
|
||||
req: &dto.OpenAIResponsesRequest{Model: "gpt-test", Prompt: mustRawMessage(t, map[string]any{"id": "pmpt_1"})},
|
||||
want: "prompt",
|
||||
},
|
||||
{
|
||||
name: "context management",
|
||||
req: &dto.OpenAIResponsesRequest{Model: "gpt-test", ContextManagement: mustRawMessage(t, map[string]any{"type": "auto"})},
|
||||
want: "context_management",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := ResponsesRequestToChatCompletionsRequest(tt.req)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tt.want)
|
||||
assert.Contains(t, err.Error(), "stateful fields")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestPreservesPenalties(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
frequencyRaw json.RawMessage
|
||||
frequencyWant *float64
|
||||
presenceRaw json.RawMessage
|
||||
presenceWant *float64
|
||||
}{
|
||||
{
|
||||
name: "positive values",
|
||||
frequencyRaw: json.RawMessage(`0.5`),
|
||||
frequencyWant: lo.ToPtr(0.5),
|
||||
presenceRaw: json.RawMessage(`1.5`),
|
||||
presenceWant: lo.ToPtr(1.5),
|
||||
},
|
||||
{
|
||||
name: "explicit zero values",
|
||||
frequencyRaw: json.RawMessage(`0.0`),
|
||||
frequencyWant: lo.ToPtr(0.0),
|
||||
presenceRaw: json.RawMessage(`0.0`),
|
||||
presenceWant: lo.ToPtr(0.0),
|
||||
},
|
||||
{
|
||||
name: "unset stays nil",
|
||||
frequencyRaw: nil,
|
||||
presenceRaw: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Input: mustRawMessage(t, "hello"),
|
||||
FrequencyPenalty: tt.frequencyRaw,
|
||||
PresencePenalty: tt.presenceRaw,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.frequencyWant, got.FrequencyPenalty)
|
||||
assert.Equal(t, tt.presenceWant, got.PresencePenalty)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesRequestToChatCompletionsRequestRejectsMalformedPenalty(t *testing.T) {
|
||||
_, err := ResponsesRequestToChatCompletionsRequest(&dto.OpenAIResponsesRequest{
|
||||
Model: "gpt-test",
|
||||
Input: mustRawMessage(t, "hello"),
|
||||
FrequencyPenalty: json.RawMessage(`"not-a-number"`),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "frequency_penalty")
|
||||
}
|
||||
|
||||
func mustRawMessage(t *testing.T, value any) []byte {
|
||||
t.Helper()
|
||||
raw, err := kitutil.Marshal(value)
|
||||
require.NoError(t, err)
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package oairesponses
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
)
|
||||
|
||||
const (
|
||||
responsesEventCreated = "response.created"
|
||||
responsesEventCompleted = "response.completed"
|
||||
responsesEventDone = "response.done"
|
||||
responsesEventIncomplete = "response.incomplete"
|
||||
responsesEventFailed = "response.failed"
|
||||
responsesEventError = "response.error"
|
||||
responsesEventOutputTextDelta = "response.output_text.delta"
|
||||
responsesEventOutputTextAnnotationAdded = "response.output_text.annotation.added"
|
||||
responsesEventOutputItemAdded = "response.output_item.added"
|
||||
responsesEventOutputItemDone = "response.output_item.done"
|
||||
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
|
||||
responsesEventFunctionArgsDone = "response.function_call_arguments.done"
|
||||
responsesEventCustomToolInputDelta = "response.custom_tool_call_input.delta"
|
||||
responsesEventCustomToolInputDone = "response.custom_tool_call_input.done"
|
||||
responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta"
|
||||
responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done"
|
||||
responsesEventReasoningTextDelta = "response.reasoning_text.delta"
|
||||
responsesEventReasoningTextDone = "response.reasoning_text.done"
|
||||
responsesOutputTypeFunctionCall = "function_call"
|
||||
responsesOutputTypeCustomToolCall = "custom_tool_call"
|
||||
responsesOutputTypeMessage = "message"
|
||||
responsesOutputTypeReasoning = "reasoning"
|
||||
responsesIncompleteReasonContentFilter = "content_filter"
|
||||
responsesIncompleteReasonMaxTokens = "max_output_tokens"
|
||||
)
|
||||
|
||||
func ResponsesFinishReasonFromStatus(resp *dto.OpenAIResponsesResponse) (string, bool) {
|
||||
if resp == nil {
|
||||
return "", false
|
||||
}
|
||||
|
||||
status := responseStatusString(resp)
|
||||
if status != "incomplete" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
reason := ""
|
||||
if resp.IncompleteDetails != nil {
|
||||
reason = strings.TrimSpace(resp.IncompleteDetails.Reason)
|
||||
}
|
||||
if reason == responsesIncompleteReasonContentFilter {
|
||||
return "content_filter", true
|
||||
}
|
||||
return "length", true
|
||||
}
|
||||
|
||||
func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesResponse, id string) (*dto.OpenAITextResponse, *dto.Usage, error) {
|
||||
if resp == nil {
|
||||
return nil, nil, errors.New("response is nil")
|
||||
}
|
||||
|
||||
text := ExtractOutputTextFromResponses(resp)
|
||||
reasoning := ExtractReasoningTextFromResponses(resp)
|
||||
|
||||
usage := UsageFromResponsesUsage(resp.Usage)
|
||||
|
||||
created := resp.CreatedAt
|
||||
|
||||
var toolCalls []dto.ToolCallResponse
|
||||
if len(resp.Output) > 0 {
|
||||
for _, out := range resp.Output {
|
||||
if !isResponsesToolOutputType(out.Type) {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(out.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
callId := strings.TrimSpace(out.CallId)
|
||||
if callId == "" {
|
||||
callId = strings.TrimSpace(out.ID)
|
||||
}
|
||||
toolCalls = append(toolCalls, dto.ToolCallResponse{
|
||||
ID: callId,
|
||||
Type: "function",
|
||||
Function: dto.FunctionResponse{
|
||||
Name: name,
|
||||
Arguments: out.ArgumentsString(),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
finishReason := "stop"
|
||||
if mappedReason, ok := ResponsesFinishReasonFromStatus(resp); ok {
|
||||
finishReason = mappedReason
|
||||
} else if len(toolCalls) > 0 {
|
||||
finishReason = "tool_calls"
|
||||
}
|
||||
|
||||
msg := dto.Message{
|
||||
Role: "assistant",
|
||||
Content: text,
|
||||
}
|
||||
if annotations, err := responsesAnnotationsToChat(resp); err != nil {
|
||||
return nil, nil, err
|
||||
} else if len(annotations) > 0 {
|
||||
msg.Annotations = annotations
|
||||
}
|
||||
if reasoning != "" {
|
||||
msg.ReasoningContent = &reasoning
|
||||
}
|
||||
if len(toolCalls) > 0 {
|
||||
msg.SetToolCalls(toolCalls)
|
||||
}
|
||||
|
||||
out := &dto.OpenAITextResponse{
|
||||
Id: id,
|
||||
Object: "chat.completion",
|
||||
Created: created,
|
||||
Model: resp.Model,
|
||||
Choices: []dto.OpenAITextResponseChoice{
|
||||
{
|
||||
Index: 0,
|
||||
Message: msg,
|
||||
FinishReason: finishReason,
|
||||
},
|
||||
},
|
||||
Usage: *usage,
|
||||
}
|
||||
|
||||
return out, usage, nil
|
||||
}
|
||||
|
||||
func responsesAnnotationsToChat(resp *dto.OpenAIResponsesResponse) ([]byte, error) {
|
||||
annotations := make([]any, 0)
|
||||
for _, output := range resp.Output {
|
||||
if output.Type != responsesOutputTypeMessage {
|
||||
continue
|
||||
}
|
||||
for _, content := range output.Content {
|
||||
for _, annotation := range content.Annotations {
|
||||
converted, err := responseAnnotationToChat(annotation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
annotations = append(annotations, converted)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(annotations) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return kitutil.Marshal(annotations)
|
||||
}
|
||||
|
||||
func responseAnnotationToChat(annotation any) (map[string]any, error) {
|
||||
value, ok := annotation.(map[string]any)
|
||||
if !ok {
|
||||
converted, err := kitutil.Any2Type[map[string]any](annotation)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid Responses annotation: %w", err)
|
||||
}
|
||||
value = converted
|
||||
}
|
||||
if strings.TrimSpace(kitutil.Interface2String(value["type"])) != "url_citation" {
|
||||
return value, nil
|
||||
}
|
||||
citation := make(map[string]any, len(value)-1)
|
||||
for key, item := range value {
|
||||
if key != "type" {
|
||||
citation[key] = item
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"type": "url_citation",
|
||||
"url_citation": citation,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func UsageFromResponsesUsage(src *dto.Usage) *dto.Usage {
|
||||
return usageFromResponsesUsage(src, true)
|
||||
}
|
||||
|
||||
// NormalizeResponsesUsage maps Responses usage into the shared accounting
|
||||
// shape without creating a BillingUsage snapshot. Native Responses handlers
|
||||
// use it so passthrough traffic preserves an existing snapshot but does not
|
||||
// introduce a conversion sidecar solely for local settlement.
|
||||
func NormalizeResponsesUsage(src *dto.Usage) *dto.Usage {
|
||||
return usageFromResponsesUsage(src, false)
|
||||
}
|
||||
|
||||
func usageFromResponsesUsage(src *dto.Usage, createBillingSnapshot bool) *dto.Usage {
|
||||
usage := &dto.Usage{}
|
||||
if src == nil {
|
||||
return usage
|
||||
}
|
||||
usage.UsageSemantic = src.UsageSemantic
|
||||
usage.UsageSource = src.UsageSource
|
||||
usage.BillingUsage = dto.CloneBillingUsage(src.BillingUsage)
|
||||
if usage.BillingUsage == nil && createBillingSnapshot {
|
||||
usage.BillingUsage = dto.NewOpenAIResponsesBillingUsage(src)
|
||||
}
|
||||
usage.Cost = src.Cost
|
||||
if src.InputTokens != 0 {
|
||||
usage.PromptTokens = src.InputTokens
|
||||
usage.InputTokens = src.InputTokens
|
||||
}
|
||||
if src.OutputTokens != 0 {
|
||||
usage.CompletionTokens = src.OutputTokens
|
||||
usage.OutputTokens = src.OutputTokens
|
||||
}
|
||||
if src.TotalTokens != 0 {
|
||||
usage.TotalTokens = src.TotalTokens
|
||||
} else {
|
||||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||||
}
|
||||
if src.InputTokensDetails != nil {
|
||||
usage.PromptTokensDetails.CachedTokens = src.InputTokensDetails.CachedTokens
|
||||
usage.PromptTokensDetails.CachedCreationTokens = src.InputTokensDetails.CachedCreationTokens
|
||||
usage.PromptTokensDetails.CacheWriteTokens = src.InputTokensDetails.CacheWriteTokens
|
||||
usage.PromptTokensDetails.TextTokens = src.InputTokensDetails.TextTokens
|
||||
usage.PromptTokensDetails.ImageTokens = src.InputTokensDetails.ImageTokens
|
||||
usage.PromptTokensDetails.AudioTokens = src.InputTokensDetails.AudioTokens
|
||||
}
|
||||
if src.CompletionTokenDetails.ReasoningTokens != 0 ||
|
||||
src.CompletionTokenDetails.TextTokens != 0 ||
|
||||
src.CompletionTokenDetails.AudioTokens != 0 ||
|
||||
src.CompletionTokenDetails.ImageTokens != 0 {
|
||||
usage.CompletionTokenDetails.ReasoningTokens = src.CompletionTokenDetails.ReasoningTokens
|
||||
usage.CompletionTokenDetails.TextTokens = src.CompletionTokenDetails.TextTokens
|
||||
usage.CompletionTokenDetails.AudioTokens = src.CompletionTokenDetails.AudioTokens
|
||||
usage.CompletionTokenDetails.ImageTokens = src.CompletionTokenDetails.ImageTokens
|
||||
}
|
||||
usage.ClaudeCacheCreation5mTokens = src.ClaudeCacheCreation5mTokens
|
||||
usage.ClaudeCacheCreation1hTokens = src.ClaudeCacheCreation1hTokens
|
||||
return usage
|
||||
}
|
||||
|
||||
func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
|
||||
if resp == nil || len(resp.Output) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Prefer assistant message outputs.
|
||||
for _, out := range resp.Output {
|
||||
if out.Type != "message" {
|
||||
continue
|
||||
}
|
||||
if out.Role != "" && out.Role != "assistant" {
|
||||
continue
|
||||
}
|
||||
var outputText strings.Builder
|
||||
for _, c := range out.Content {
|
||||
if c.Type == "output_text" && c.Text != "" {
|
||||
outputText.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
appendSeparatedText(&sb, outputText.String())
|
||||
}
|
||||
if sb.Len() > 0 {
|
||||
return sb.String()
|
||||
}
|
||||
for _, out := range resp.Output {
|
||||
var outputText strings.Builder
|
||||
for _, c := range out.Content {
|
||||
if c.Text != "" {
|
||||
outputText.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
appendSeparatedText(&sb, outputText.String())
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func ExtractReasoningTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
|
||||
if resp == nil || len(resp.Output) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, out := range resp.Output {
|
||||
if out.Type != responsesOutputTypeReasoning {
|
||||
continue
|
||||
}
|
||||
appendSeparatedText(&sb, reasoningOutputText(&out))
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func reasoningOutputText(output *dto.ResponsesOutput) string {
|
||||
if output == nil {
|
||||
return ""
|
||||
}
|
||||
var text strings.Builder
|
||||
hasContentText := false
|
||||
for _, part := range output.Content {
|
||||
if part.Text != "" {
|
||||
hasContentText = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasContentText {
|
||||
for _, part := range output.Content {
|
||||
appendSeparatedText(&text, part.Text)
|
||||
}
|
||||
return text.String()
|
||||
}
|
||||
for _, part := range output.Summary {
|
||||
appendSeparatedText(&text, part.Text)
|
||||
}
|
||||
return text.String()
|
||||
}
|
||||
|
||||
func appendSeparatedText(builder *strings.Builder, text string) {
|
||||
if builder == nil || text == "" {
|
||||
return
|
||||
}
|
||||
if builder.Len() > 0 {
|
||||
current := builder.String()
|
||||
trailingNewlines := 0
|
||||
for index := len(current) - 1; index >= 0 && trailingNewlines < 2 && current[index] == '\n'; index-- {
|
||||
trailingNewlines++
|
||||
}
|
||||
leadingNewlines := 0
|
||||
for leadingNewlines < len(text) && leadingNewlines < 2 && text[leadingNewlines] == '\n' {
|
||||
leadingNewlines++
|
||||
}
|
||||
for missing := 2 - trailingNewlines - leadingNewlines; missing > 0; missing-- {
|
||||
builder.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
builder.WriteString(text)
|
||||
}
|
||||
|
||||
func responseStatusString(resp *dto.OpenAIResponsesResponse) string {
|
||||
if resp == nil || len(resp.Status) == 0 {
|
||||
return ""
|
||||
}
|
||||
var status string
|
||||
_ = kitutil.Unmarshal(resp.Status, &status)
|
||||
return strings.TrimSpace(status)
|
||||
}
|
||||
|
||||
func ensureIncompleteResponse(resp *dto.OpenAIResponsesResponse) *dto.OpenAIResponsesResponse {
|
||||
if resp == nil {
|
||||
resp = &dto.OpenAIResponsesResponse{}
|
||||
}
|
||||
if len(resp.Status) == 0 {
|
||||
resp.Status = []byte(`"incomplete"`)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func isResponsesToolOutputType(outputType string) bool {
|
||||
return outputType == responsesOutputTypeFunctionCall || outputType == responsesOutputTypeCustomToolCall
|
||||
}
|
||||
|
||||
func responseStreamEventItemID(event *dto.ResponsesStreamResponse) string {
|
||||
if event == nil {
|
||||
return ""
|
||||
}
|
||||
if event.Item != nil {
|
||||
if itemID := strings.TrimSpace(event.Item.ID); itemID != "" {
|
||||
return itemID
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(event.ItemID)
|
||||
}
|
||||
|
||||
func fallbackToolKey(itemID string, callID string, outputIndex *int) string {
|
||||
if outputIndex != nil {
|
||||
return fmt.Sprintf("output:%d", *outputIndex)
|
||||
}
|
||||
if strings.TrimSpace(itemID) != "" {
|
||||
return "item:" + strings.TrimSpace(itemID)
|
||||
}
|
||||
if strings.TrimSpace(callID) != "" {
|
||||
return "call:" + strings.TrimSpace(callID)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fallbackCallID(event *dto.ResponsesStreamResponse) string {
|
||||
if event == nil {
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(event.ItemID) != "" {
|
||||
return strings.TrimSpace(event.ItemID)
|
||||
}
|
||||
if event.OutputIndex != nil {
|
||||
return fmt.Sprintf("call_output_%d", *event.OutputIndex)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,547 @@
|
||||
package oairesponses
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestResponsesResponseToChatCompletionsPreservesTextAndToolCalls(t *testing.T) {
|
||||
resp := &dto.OpenAIResponsesResponse{
|
||||
ID: "resp_1",
|
||||
CreatedAt: 123,
|
||||
Model: "gpt-test",
|
||||
Status: []byte(`"completed"`),
|
||||
Output: []dto.ResponsesOutput{
|
||||
{
|
||||
Type: responsesOutputTypeMessage,
|
||||
Role: "assistant",
|
||||
Content: []dto.ResponsesOutputContent{
|
||||
{Type: "output_text", Text: "I will call a tool."},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: responsesOutputTypeFunctionCall,
|
||||
ID: "fc_1",
|
||||
CallId: "call_1",
|
||||
Name: "lookup",
|
||||
Arguments: []byte(`{"q":"x"}`),
|
||||
},
|
||||
},
|
||||
Usage: &dto.Usage{InputTokens: 3, OutputTokens: 4, TotalTokens: 7},
|
||||
}
|
||||
|
||||
chat, usage, err := ResponsesResponseToChatCompletionsResponse(resp, "chatcmpl_1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, usage)
|
||||
|
||||
require.Len(t, chat.Choices, 1)
|
||||
assert.Equal(t, "tool_calls", chat.Choices[0].FinishReason)
|
||||
assert.Equal(t, "I will call a tool.", chat.Choices[0].Message.StringContent())
|
||||
toolCalls := chat.Choices[0].Message.ParseToolCalls()
|
||||
require.Len(t, toolCalls, 1)
|
||||
assert.Equal(t, "call_1", toolCalls[0].ID)
|
||||
assert.Equal(t, "lookup", toolCalls[0].Function.Name)
|
||||
assert.Equal(t, `{"q":"x"}`, toolCalls[0].Function.Arguments)
|
||||
assert.Equal(t, 7, usage.TotalTokens)
|
||||
}
|
||||
|
||||
func TestResponsesResponseToChatCompletionsPreservesReasoningSummary(t *testing.T) {
|
||||
resp := &dto.OpenAIResponsesResponse{
|
||||
ID: "resp_1",
|
||||
Model: "gpt-test",
|
||||
Status: []byte(`"completed"`),
|
||||
Output: []dto.ResponsesOutput{
|
||||
{
|
||||
Type: responsesOutputTypeReasoning,
|
||||
Summary: []dto.ResponsesReasoningSummaryPart{
|
||||
{Type: "summary_text", Text: "first summary"},
|
||||
{Type: "summary_text", Text: "second summary"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: responsesOutputTypeMessage,
|
||||
Role: "assistant",
|
||||
Content: []dto.ResponsesOutputContent{
|
||||
{Type: "output_text", Text: "final"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
chat, _, err := ResponsesResponseToChatCompletionsResponse(resp, "chatcmpl_1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "first summary\n\nsecond summary", chat.Choices[0].Message.GetReasoningContent())
|
||||
assert.Equal(t, "final", chat.Choices[0].Message.StringContent())
|
||||
}
|
||||
|
||||
func TestResponsesResponseToChatCompletionsSeparatesInterleavedOutputItems(t *testing.T) {
|
||||
resp := &dto.OpenAIResponsesResponse{
|
||||
ID: "resp_1",
|
||||
Model: "gpt-test",
|
||||
Status: []byte(`"completed"`),
|
||||
Output: interleavedReasoningAndTextOutput(),
|
||||
}
|
||||
|
||||
chat, _, err := ResponsesResponseToChatCompletionsResponse(resp, "chatcmpl_1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "**Planning file inspection**\n\n**Clarifying environment task requirements**", chat.Choices[0].Message.GetReasoningContent())
|
||||
assert.Equal(t, "I’ll inspect the starter repository.\n\nWhat would you like me to build?", chat.Choices[0].Message.StringContent())
|
||||
}
|
||||
|
||||
func TestResponsesFinishReasonFromIncompleteStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
reason string
|
||||
want string
|
||||
}{
|
||||
{name: "max output", reason: responsesIncompleteReasonMaxTokens, want: "length"},
|
||||
{name: "content filter", reason: responsesIncompleteReasonContentFilter, want: "content_filter"},
|
||||
{name: "unknown", reason: "other", want: "length"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := ResponsesFinishReasonFromStatus(&dto.OpenAIResponsesResponse{
|
||||
Status: []byte(`"incomplete"`),
|
||||
IncompleteDetails: &dto.IncompleteDetails{Reason: tt.reason},
|
||||
})
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesStreamEventToChatChunksUsesOutputIndexForToolArguments(t *testing.T) {
|
||||
state := newTestResponsesStreamState()
|
||||
outputIndex := 1
|
||||
|
||||
var chunks []dto.ChatCompletionsStreamResponse
|
||||
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{Type: responsesEventCreated})...)
|
||||
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{Type: responsesEventOutputTextDelta, Delta: "text before tool"})...)
|
||||
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
||||
Type: responsesEventFunctionArgsDelta,
|
||||
OutputIndex: &outputIndex,
|
||||
Delta: `{"cmd":"ls"}`,
|
||||
})...)
|
||||
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemAdded,
|
||||
OutputIndex: &outputIndex,
|
||||
Item: &dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeFunctionCall,
|
||||
ID: "fc_1",
|
||||
CallId: "call_1",
|
||||
Name: "exec",
|
||||
},
|
||||
})...)
|
||||
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
||||
Type: responsesEventCompleted,
|
||||
Response: &dto.OpenAIResponsesResponse{
|
||||
Status: []byte(`"completed"`),
|
||||
Usage: &dto.Usage{InputTokens: 1, OutputTokens: 2, TotalTokens: 3},
|
||||
},
|
||||
})...)
|
||||
|
||||
require.Len(t, chunks, 4)
|
||||
assert.Equal(t, "assistant", chunks[0].Choices[0].Delta.Role)
|
||||
assert.Equal(t, "text before tool", chunks[1].Choices[0].Delta.GetContentString())
|
||||
tool := chunks[2].Choices[0].Delta.ToolCalls[0]
|
||||
require.NotNil(t, tool.Index)
|
||||
assert.Equal(t, 0, *tool.Index)
|
||||
assert.Equal(t, "call_1", tool.ID)
|
||||
assert.Equal(t, "exec", tool.Function.Name)
|
||||
assert.Equal(t, `{"cmd":"ls"}`, tool.Function.Arguments)
|
||||
require.NotNil(t, chunks[3].Choices[0].FinishReason)
|
||||
assert.Equal(t, "tool_calls", *chunks[3].Choices[0].FinishReason)
|
||||
assert.Equal(t, 3, state.Usage.TotalTokens)
|
||||
}
|
||||
|
||||
func TestResponsesStreamEventToChatChunksDoesNotDuplicatePendingArgsWithOutputIndexAndItemID(t *testing.T) {
|
||||
state := newTestResponsesStreamState()
|
||||
outputIndex := 1
|
||||
|
||||
var chunks []dto.ChatCompletionsStreamResponse
|
||||
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{Type: responsesEventCreated})...)
|
||||
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
||||
Type: responsesEventFunctionArgsDelta,
|
||||
OutputIndex: &outputIndex,
|
||||
ItemID: "fc_1",
|
||||
Delta: `{"q":"x"}`,
|
||||
})...)
|
||||
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemAdded,
|
||||
OutputIndex: &outputIndex,
|
||||
ItemID: "fc_1",
|
||||
Item: &dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeFunctionCall,
|
||||
ID: "fc_1",
|
||||
CallId: "call_1",
|
||||
Name: "lookup",
|
||||
},
|
||||
})...)
|
||||
|
||||
require.Len(t, chunks, 2)
|
||||
tool := chunks[1].Choices[0].Delta.ToolCalls[0]
|
||||
assert.Equal(t, "call_1", tool.ID)
|
||||
assert.Equal(t, "lookup", tool.Function.Name)
|
||||
assert.Equal(t, `{"q":"x"}`, tool.Function.Arguments)
|
||||
assert.Empty(t, state.pendingArgsByOutputIndex)
|
||||
assert.Empty(t, state.pendingArgsByItemID)
|
||||
}
|
||||
|
||||
func TestResponsesStreamEventToChatChunksDrainsItemOnlyPendingArgsWhenOutputIndexArrives(t *testing.T) {
|
||||
state := newTestResponsesStreamState()
|
||||
outputIndex := 1
|
||||
|
||||
var chunks []dto.ChatCompletionsStreamResponse
|
||||
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{Type: responsesEventCreated})...)
|
||||
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
||||
Type: responsesEventFunctionArgsDelta,
|
||||
ItemID: "fc_1",
|
||||
Delta: `{"q":"x"}`,
|
||||
})...)
|
||||
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemAdded,
|
||||
OutputIndex: &outputIndex,
|
||||
ItemID: "fc_1",
|
||||
Item: &dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeFunctionCall,
|
||||
CallId: "call_1",
|
||||
Name: "lookup",
|
||||
},
|
||||
})...)
|
||||
|
||||
require.Len(t, chunks, 2)
|
||||
tool := chunks[1].Choices[0].Delta.ToolCalls[0]
|
||||
assert.Equal(t, "call_1", tool.ID)
|
||||
assert.Equal(t, "lookup", tool.Function.Name)
|
||||
assert.Equal(t, `{"q":"x"}`, tool.Function.Arguments)
|
||||
assert.Empty(t, state.pendingArgsByOutputIndex)
|
||||
assert.Empty(t, state.pendingArgsByItemID)
|
||||
}
|
||||
|
||||
func TestResponsesStreamEventToChatChunksCustomToolAndReasoning(t *testing.T) {
|
||||
state := newTestResponsesStreamState()
|
||||
outputIndex := 0
|
||||
|
||||
chunks := mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
||||
Type: responsesEventReasoningTextDelta,
|
||||
Delta: "thinking",
|
||||
})
|
||||
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemAdded,
|
||||
OutputIndex: &outputIndex,
|
||||
Item: &dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeCustomToolCall,
|
||||
ID: "ct_1",
|
||||
CallId: "call_custom",
|
||||
Name: "apply_patch",
|
||||
},
|
||||
})...)
|
||||
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
||||
Type: responsesEventCustomToolInputDelta,
|
||||
OutputIndex: &outputIndex,
|
||||
Delta: "patch body",
|
||||
})...)
|
||||
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
||||
Type: responsesEventIncomplete,
|
||||
Response: &dto.OpenAIResponsesResponse{
|
||||
IncompleteDetails: &dto.IncompleteDetails{Reason: responsesIncompleteReasonContentFilter},
|
||||
},
|
||||
})...)
|
||||
|
||||
require.Len(t, chunks, 5)
|
||||
assert.Equal(t, "thinking", chunks[1].Choices[0].Delta.GetReasoningContent())
|
||||
assert.Equal(t, "apply_patch", chunks[2].Choices[0].Delta.ToolCalls[0].Function.Name)
|
||||
assert.Equal(t, "patch body", chunks[3].Choices[0].Delta.ToolCalls[0].Function.Arguments)
|
||||
require.NotNil(t, chunks[4].Choices[0].FinishReason)
|
||||
assert.Equal(t, "content_filter", *chunks[4].Choices[0].FinishReason)
|
||||
}
|
||||
|
||||
func TestResponsesStreamEventToChatChunksUsesTerminalDoneOutput(t *testing.T) {
|
||||
state := newTestResponsesStreamState()
|
||||
chunks := mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
||||
Type: responsesEventDone,
|
||||
Response: &dto.OpenAIResponsesResponse{
|
||||
Status: []byte(`"completed"`),
|
||||
Output: []dto.ResponsesOutput{
|
||||
{
|
||||
Type: responsesOutputTypeMessage,
|
||||
Role: "assistant",
|
||||
Content: []dto.ResponsesOutputContent{
|
||||
{Type: "output_text", Text: "terminal text"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: responsesOutputTypeFunctionCall,
|
||||
ID: "fc_1",
|
||||
CallId: "call_1",
|
||||
Name: "lookup",
|
||||
Arguments: []byte(`{"q":"x"}`),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.Len(t, chunks, 4)
|
||||
assert.Equal(t, "assistant", chunks[0].Choices[0].Delta.Role)
|
||||
assert.Equal(t, "terminal text", chunks[1].Choices[0].Delta.GetContentString())
|
||||
tool := chunks[2].Choices[0].Delta.ToolCalls[0]
|
||||
assert.Equal(t, "lookup", tool.Function.Name)
|
||||
assert.Equal(t, `{"q":"x"}`, tool.Function.Arguments)
|
||||
require.NotNil(t, chunks[3].Choices[0].FinishReason)
|
||||
assert.Equal(t, "tool_calls", *chunks[3].Choices[0].FinishReason)
|
||||
}
|
||||
|
||||
func TestResponsesStreamEventToChatChunksDoesNotResendToolOnTerminalOutput(t *testing.T) {
|
||||
state := newTestResponsesStreamState()
|
||||
outputIndex := 0
|
||||
|
||||
var chunks []dto.ChatCompletionsStreamResponse
|
||||
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{Type: responsesEventCreated})...)
|
||||
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemAdded,
|
||||
OutputIndex: &outputIndex,
|
||||
Item: &dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeFunctionCall,
|
||||
ID: "fc_1",
|
||||
CallId: "call_1",
|
||||
Name: "lookup",
|
||||
},
|
||||
})...)
|
||||
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
||||
Type: responsesEventFunctionArgsDelta,
|
||||
OutputIndex: &outputIndex,
|
||||
Delta: `{"q":"x"}`,
|
||||
})...)
|
||||
chunks = append(chunks, mustStreamChunks(t, state, &dto.ResponsesStreamResponse{
|
||||
Type: responsesEventCompleted,
|
||||
Response: &dto.OpenAIResponsesResponse{
|
||||
Status: []byte(`"completed"`),
|
||||
Output: []dto.ResponsesOutput{
|
||||
{
|
||||
Type: responsesOutputTypeFunctionCall,
|
||||
ID: "fc_1",
|
||||
CallId: "call_1",
|
||||
Name: "lookup",
|
||||
Arguments: []byte(`{"q":"x"}`),
|
||||
},
|
||||
},
|
||||
},
|
||||
})...)
|
||||
|
||||
var totalArgs strings.Builder
|
||||
toolIndexes := map[int]bool{}
|
||||
var finishReason string
|
||||
for _, chunk := range chunks {
|
||||
for _, choice := range chunk.Choices {
|
||||
for _, tc := range choice.Delta.ToolCalls {
|
||||
require.NotNil(t, tc.Index)
|
||||
toolIndexes[*tc.Index] = true
|
||||
totalArgs.WriteString(tc.Function.Arguments)
|
||||
}
|
||||
if choice.FinishReason != nil {
|
||||
finishReason = *choice.FinishReason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, map[int]bool{0: true}, toolIndexes)
|
||||
assert.Equal(t, `{"q":"x"}`, totalArgs.String())
|
||||
assert.Equal(t, "tool_calls", finishReason)
|
||||
}
|
||||
|
||||
func TestFinalizeResponsesToChatStreamFlushesPendingDeltaOnlyArguments(t *testing.T) {
|
||||
state := newTestResponsesStreamState()
|
||||
outputIndex := 2
|
||||
_, err := ResponsesStreamEventToChatChunks(&dto.ResponsesStreamResponse{
|
||||
Type: responsesEventFunctionArgsDelta,
|
||||
OutputIndex: &outputIndex,
|
||||
Delta: `{"pending":true}`,
|
||||
}, state)
|
||||
require.NoError(t, err)
|
||||
|
||||
chunks := FinalizeResponsesToChatStream(state)
|
||||
require.Len(t, chunks, 3)
|
||||
tool := chunks[1].Choices[0].Delta.ToolCalls[0]
|
||||
assert.Equal(t, "call_output_2", tool.ID)
|
||||
assert.Equal(t, `{"pending":true}`, tool.Function.Arguments)
|
||||
require.NotNil(t, chunks[2].Choices[0].FinishReason)
|
||||
assert.Equal(t, "tool_calls", *chunks[2].Choices[0].FinishReason)
|
||||
}
|
||||
|
||||
func TestResponsesStreamEventToChatChunksFailedEventReturnsError(t *testing.T) {
|
||||
_, err := ResponsesStreamEventToChatChunks(&dto.ResponsesStreamResponse{Type: responsesEventFailed}, newTestResponsesStreamState())
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestResponsesBufferedAccumulatorSupplementsEmptyTerminalOutput(t *testing.T) {
|
||||
acc := NewResponsesBufferedAccumulator()
|
||||
outputIndex := 1
|
||||
acc.ProcessEvent(&dto.ResponsesStreamResponse{Type: responsesEventOutputTextDelta, Delta: "buffered text"})
|
||||
acc.ProcessEvent(&dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemAdded,
|
||||
OutputIndex: &outputIndex,
|
||||
Item: &dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeFunctionCall,
|
||||
ID: "fc_1",
|
||||
CallId: "call_1",
|
||||
Name: "lookup",
|
||||
},
|
||||
})
|
||||
acc.ProcessEvent(&dto.ResponsesStreamResponse{
|
||||
Type: responsesEventFunctionArgsDelta,
|
||||
OutputIndex: &outputIndex,
|
||||
Delta: `{"q":"x"}`,
|
||||
})
|
||||
|
||||
resp := &dto.OpenAIResponsesResponse{
|
||||
Status: []byte(`"completed"`),
|
||||
Model: "gpt-test",
|
||||
}
|
||||
acc.SupplementResponseOutput(resp)
|
||||
|
||||
chat, _, err := ResponsesResponseToChatCompletionsResponse(resp, "chatcmpl_1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "buffered text", chat.Choices[0].Message.StringContent())
|
||||
toolCalls := chat.Choices[0].Message.ParseToolCalls()
|
||||
require.Len(t, toolCalls, 1)
|
||||
assert.Equal(t, `{"q":"x"}`, toolCalls[0].Function.Arguments)
|
||||
}
|
||||
|
||||
func TestResponsesBufferedAccumulatorDoesNotDuplicatePendingArgsWithOutputIndexAndItemID(t *testing.T) {
|
||||
acc := NewResponsesBufferedAccumulator()
|
||||
outputIndex := 1
|
||||
acc.ProcessEvent(&dto.ResponsesStreamResponse{
|
||||
Type: responsesEventFunctionArgsDelta,
|
||||
OutputIndex: &outputIndex,
|
||||
ItemID: "fc_1",
|
||||
Delta: `{"q":"x"}`,
|
||||
})
|
||||
acc.ProcessEvent(&dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemAdded,
|
||||
OutputIndex: &outputIndex,
|
||||
ItemID: "fc_1",
|
||||
Item: &dto.ResponsesOutput{
|
||||
Type: responsesOutputTypeFunctionCall,
|
||||
ID: "fc_1",
|
||||
CallId: "call_1",
|
||||
Name: "lookup",
|
||||
},
|
||||
})
|
||||
|
||||
resp := &dto.OpenAIResponsesResponse{
|
||||
Status: []byte(`"completed"`),
|
||||
Model: "gpt-test",
|
||||
}
|
||||
acc.SupplementResponseOutput(resp)
|
||||
|
||||
chat, _, err := ResponsesResponseToChatCompletionsResponse(resp, "chatcmpl_1")
|
||||
require.NoError(t, err)
|
||||
toolCalls := chat.Choices[0].Message.ParseToolCalls()
|
||||
require.Len(t, toolCalls, 1)
|
||||
assert.Equal(t, `{"q":"x"}`, toolCalls[0].Function.Arguments)
|
||||
assert.Empty(t, acc.pendingByOutputIndex)
|
||||
assert.Empty(t, acc.pendingByItemID)
|
||||
}
|
||||
|
||||
func TestResponsesBufferedAccumulatorPreservesInterleavedReasoningAndTextItems(t *testing.T) {
|
||||
acc := NewResponsesBufferedAccumulator()
|
||||
events := []dto.ResponsesStreamResponse{
|
||||
bufferedOutputItemAdded(0, "rs_1", responsesOutputTypeReasoning),
|
||||
{Type: responsesEventReasoningSummaryDelta, OutputIndex: intPointer(0), ItemID: "rs_1", Delta: "**Planning file inspection**"},
|
||||
bufferedOutputItemAdded(1, "msg_1", responsesOutputTypeMessage),
|
||||
{Type: responsesEventOutputTextDelta, OutputIndex: intPointer(1), ItemID: "msg_1", Delta: "I’ll inspect the starter repository."},
|
||||
bufferedOutputItemAdded(2, "rs_2", responsesOutputTypeReasoning),
|
||||
{Type: responsesEventReasoningSummaryDelta, OutputIndex: intPointer(2), ItemID: "rs_2", Delta: "**Clarifying environment task requirements**"},
|
||||
bufferedOutputItemAdded(3, "msg_2", responsesOutputTypeMessage),
|
||||
{Type: responsesEventOutputTextDelta, OutputIndex: intPointer(3), ItemID: "msg_2", Delta: "What would you like me to build?"},
|
||||
}
|
||||
for index := range events {
|
||||
acc.ProcessEvent(&events[index])
|
||||
}
|
||||
|
||||
output := acc.BuildOutput()
|
||||
require.Len(t, output, 4)
|
||||
assert.Equal(t, []string{
|
||||
responsesOutputTypeReasoning,
|
||||
responsesOutputTypeMessage,
|
||||
responsesOutputTypeReasoning,
|
||||
responsesOutputTypeMessage,
|
||||
}, []string{output[0].Type, output[1].Type, output[2].Type, output[3].Type})
|
||||
assert.Equal(t, "**Planning file inspection**", output[0].Summary[0].Text)
|
||||
assert.Equal(t, "I’ll inspect the starter repository.", output[1].Content[0].Text)
|
||||
assert.Equal(t, "**Clarifying environment task requirements**", output[2].Summary[0].Text)
|
||||
assert.Equal(t, "What would you like me to build?", output[3].Content[0].Text)
|
||||
}
|
||||
|
||||
func TestResponsesStreamTerminalOutputPreservesInterleavedReasoningAndTextItems(t *testing.T) {
|
||||
state := newTestResponsesStreamState()
|
||||
chunks, err := ResponsesStreamEventToChatChunks(&dto.ResponsesStreamResponse{
|
||||
Type: responsesEventCompleted,
|
||||
Response: &dto.OpenAIResponsesResponse{
|
||||
Status: []byte(`"completed"`),
|
||||
Output: interleavedReasoningAndTextOutput(),
|
||||
},
|
||||
}, state)
|
||||
require.NoError(t, err)
|
||||
|
||||
var deltas []string
|
||||
for _, chunk := range chunks {
|
||||
if len(chunk.Choices) == 0 {
|
||||
continue
|
||||
}
|
||||
delta := chunk.Choices[0].Delta
|
||||
if delta.ReasoningContent != nil {
|
||||
deltas = append(deltas, "thinking:"+*delta.ReasoningContent)
|
||||
}
|
||||
if delta.Content != nil && *delta.Content != "" {
|
||||
deltas = append(deltas, "text:"+*delta.Content)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, []string{
|
||||
"thinking:**Planning file inspection**",
|
||||
"text:I’ll inspect the starter repository.",
|
||||
"thinking:**Clarifying environment task requirements**",
|
||||
"text:What would you like me to build?",
|
||||
}, deltas)
|
||||
}
|
||||
|
||||
func bufferedOutputItemAdded(outputIndex int, itemID string, itemType string) dto.ResponsesStreamResponse {
|
||||
return dto.ResponsesStreamResponse{
|
||||
Type: responsesEventOutputItemAdded,
|
||||
OutputIndex: &outputIndex,
|
||||
ItemID: itemID,
|
||||
Item: &dto.ResponsesOutput{ID: itemID, Type: itemType},
|
||||
}
|
||||
}
|
||||
|
||||
func interleavedReasoningAndTextOutput() []dto.ResponsesOutput {
|
||||
return []dto.ResponsesOutput{
|
||||
{Type: responsesOutputTypeReasoning, Summary: []dto.ResponsesReasoningSummaryPart{{Type: "summary_text", Text: "**Planning file inspection**"}}},
|
||||
{Type: responsesOutputTypeMessage, Role: "assistant", Content: []dto.ResponsesOutputContent{{Type: "output_text", Text: "I’ll inspect the starter repository."}}},
|
||||
{Type: responsesOutputTypeReasoning, Summary: []dto.ResponsesReasoningSummaryPart{{Type: "summary_text", Text: "**Clarifying environment task requirements**"}}},
|
||||
{Type: responsesOutputTypeMessage, Role: "assistant", Content: []dto.ResponsesOutputContent{{Type: "output_text", Text: "What would you like me to build?"}}},
|
||||
}
|
||||
}
|
||||
|
||||
func intPointer(value int) *int {
|
||||
return &value
|
||||
}
|
||||
|
||||
func newTestResponsesStreamState() *ResponsesToChatStreamState {
|
||||
state := NewResponsesToChatStreamState("gpt-test", false)
|
||||
state.ID = "chatcmpl_test"
|
||||
state.Created = 123
|
||||
return state
|
||||
}
|
||||
|
||||
func mustStreamChunks(t *testing.T, state *ResponsesToChatStreamState, event *dto.ResponsesStreamResponse) []dto.ChatCompletionsStreamResponse {
|
||||
t.Helper()
|
||||
chunks, err := ResponsesStreamEventToChatChunks(event, state)
|
||||
require.NoError(t, err)
|
||||
return chunks
|
||||
}
|
||||
@@ -0,0 +1,934 @@
|
||||
package oairesponses
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
)
|
||||
|
||||
type ResponsesToChatStreamState struct {
|
||||
ID string
|
||||
Model string
|
||||
Created int64
|
||||
IncludeUsage bool
|
||||
|
||||
Usage *dto.Usage
|
||||
|
||||
sentStart bool
|
||||
finalized bool
|
||||
hasSentText bool
|
||||
sentAnnotationCount int
|
||||
sawToolCall bool
|
||||
hasSentReasoning bool
|
||||
needsReasoningSummaryBreak bool
|
||||
nextToolIndex int
|
||||
toolByKey map[string]*responsesStreamTool
|
||||
outputIndexToKey map[int]string
|
||||
itemIDToKey map[string]string
|
||||
callIDToKey map[string]string
|
||||
pendingArgsByOutputIndex map[int]string
|
||||
pendingArgsByItemID map[string]string
|
||||
usageText strings.Builder
|
||||
}
|
||||
|
||||
type responsesStreamTool struct {
|
||||
Key string
|
||||
CallID string
|
||||
ItemID string
|
||||
Name string
|
||||
Arguments string
|
||||
Index int
|
||||
Sent bool
|
||||
NameSent bool
|
||||
ArgsSentAt int
|
||||
}
|
||||
|
||||
func NewResponsesToChatStreamState(model string, includeUsage bool) *ResponsesToChatStreamState {
|
||||
return &ResponsesToChatStreamState{
|
||||
Model: model,
|
||||
Created: time.Now().Unix(),
|
||||
IncludeUsage: includeUsage,
|
||||
Usage: &dto.Usage{},
|
||||
toolByKey: make(map[string]*responsesStreamTool),
|
||||
outputIndexToKey: make(map[int]string),
|
||||
itemIDToKey: make(map[string]string),
|
||||
callIDToKey: make(map[string]string),
|
||||
pendingArgsByOutputIndex: make(map[int]string),
|
||||
pendingArgsByItemID: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) StreamUsage() *dto.Usage {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return s.Usage
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) SetStreamUsage(usage *dto.Usage) {
|
||||
if s != nil && usage != nil {
|
||||
s.Usage = usage
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) UsageText() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.usageText.String()
|
||||
}
|
||||
|
||||
func ResponsesStreamEventToChatChunks(event *dto.ResponsesStreamResponse, state *ResponsesToChatStreamState) ([]dto.ChatCompletionsStreamResponse, error) {
|
||||
if event == nil || state == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case responsesEventCreated:
|
||||
state.applyResponseMetadata(event.Response)
|
||||
return state.ensureStart(), nil
|
||||
case responsesEventReasoningSummaryDelta, responsesEventReasoningTextDelta:
|
||||
return state.reasoningDelta(event.Delta), nil
|
||||
case responsesEventReasoningSummaryDone, responsesEventReasoningTextDone:
|
||||
if state.hasSentReasoning {
|
||||
state.needsReasoningSummaryBreak = true
|
||||
}
|
||||
return nil, nil
|
||||
case responsesEventOutputTextDelta:
|
||||
return state.textDelta(event.Delta), nil
|
||||
case responsesEventOutputTextAnnotationAdded:
|
||||
return state.annotationRawDelta(event.Annotation)
|
||||
case responsesEventOutputItemAdded, responsesEventOutputItemDone:
|
||||
if event.Item == nil || !isResponsesToolOutputType(event.Item.Type) {
|
||||
return nil, nil
|
||||
}
|
||||
return state.toolItem(event), nil
|
||||
case responsesEventFunctionArgsDelta, responsesEventCustomToolInputDelta:
|
||||
return state.toolArgumentsDelta(event), nil
|
||||
case responsesEventFunctionArgsDone, responsesEventCustomToolInputDone:
|
||||
return state.flushPendingTool(event), nil
|
||||
case responsesEventCompleted, responsesEventDone, responsesEventIncomplete:
|
||||
response := event.Response
|
||||
if event.Type == responsesEventIncomplete {
|
||||
response = ensureIncompleteResponse(response)
|
||||
}
|
||||
state.applyResponseMetadata(response)
|
||||
chunks, err := state.terminalOutputChunks(response)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chunks = append(chunks, state.finalize(response)...)
|
||||
return chunks, nil
|
||||
case responsesEventFailed, responsesEventError:
|
||||
return nil, fmt.Errorf("responses stream error: %s", event.Type)
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
func FinalizeResponsesToChatStream(state *ResponsesToChatStreamState) []dto.ChatCompletionsStreamResponse {
|
||||
if state == nil {
|
||||
return nil
|
||||
}
|
||||
return state.finalize(nil)
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) applyResponseMetadata(response *dto.OpenAIResponsesResponse) {
|
||||
if response == nil {
|
||||
return
|
||||
}
|
||||
if response.ID != "" && s.ID == "" {
|
||||
s.ID = response.ID
|
||||
}
|
||||
if response.Model != "" {
|
||||
s.Model = response.Model
|
||||
}
|
||||
if response.CreatedAt != 0 {
|
||||
s.Created = int64(response.CreatedAt)
|
||||
}
|
||||
if response.Usage != nil {
|
||||
s.Usage = dto.MergeUsageNonZero(s.Usage, UsageFromResponsesUsage(response.Usage))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) ensureStart() []dto.ChatCompletionsStreamResponse {
|
||||
if s.sentStart {
|
||||
return nil
|
||||
}
|
||||
s.sentStart = true
|
||||
return []dto.ChatCompletionsStreamResponse{s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{
|
||||
Role: "assistant",
|
||||
Content: kitutil.GetPointer(""),
|
||||
}, nil)}
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) textDelta(delta string) []dto.ChatCompletionsStreamResponse {
|
||||
if delta == "" {
|
||||
return nil
|
||||
}
|
||||
s.usageText.WriteString(delta)
|
||||
s.hasSentText = true
|
||||
chunks := s.ensureStart()
|
||||
chunks = append(chunks, s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{
|
||||
Content: &delta,
|
||||
}, nil))
|
||||
return chunks
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) terminalOutputChunks(response *dto.OpenAIResponsesResponse) ([]dto.ChatCompletionsStreamResponse, error) {
|
||||
if s == nil || response == nil || len(response.Output) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var chunks []dto.ChatCompletionsStreamResponse
|
||||
hadSentText := s.hasSentText
|
||||
hadSentReasoning := s.hasSentReasoning
|
||||
annotationOffset := 0
|
||||
for i := range response.Output {
|
||||
out := &response.Output[i]
|
||||
switch {
|
||||
case out.Type == responsesOutputTypeMessage && !hadSentText:
|
||||
var text strings.Builder
|
||||
for _, c := range out.Content {
|
||||
if c.Type == "output_text" && c.Text != "" {
|
||||
text.WriteString(c.Text)
|
||||
}
|
||||
}
|
||||
chunks = append(chunks, s.textDelta(text.String())...)
|
||||
annotationChunks, err := s.remainingAnnotationChunks(out, annotationOffset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chunks = append(chunks, annotationChunks...)
|
||||
annotationOffset += responsesOutputAnnotationCount(out)
|
||||
case out.Type == responsesOutputTypeMessage:
|
||||
annotationChunks, err := s.remainingAnnotationChunks(out, annotationOffset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chunks = append(chunks, annotationChunks...)
|
||||
annotationOffset += responsesOutputAnnotationCount(out)
|
||||
case out.Type == responsesOutputTypeReasoning && !hadSentReasoning:
|
||||
chunks = append(chunks, s.reasoningDelta(reasoningOutputText(out))...)
|
||||
case isResponsesToolOutputType(out.Type):
|
||||
chunks = append(chunks, s.toolItem(&dto.ResponsesStreamResponse{Item: out})...)
|
||||
}
|
||||
}
|
||||
return chunks, nil
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) annotationRawDelta(raw []byte) ([]dto.ChatCompletionsStreamResponse, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var annotation map[string]any
|
||||
if err := kitutil.Unmarshal(raw, &annotation); err != nil {
|
||||
return nil, fmt.Errorf("invalid Responses stream annotation: %w", err)
|
||||
}
|
||||
return s.annotationDelta(annotation)
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) annotationDelta(annotation any) ([]dto.ChatCompletionsStreamResponse, error) {
|
||||
converted, err := responseAnnotationToChat(annotation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := kitutil.Marshal([]any{converted})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal Chat annotation: %w", err)
|
||||
}
|
||||
s.sentAnnotationCount++
|
||||
chunks := s.ensureStart()
|
||||
chunks = append(chunks, s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{
|
||||
Annotations: raw,
|
||||
}, nil))
|
||||
return chunks, nil
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) remainingAnnotationChunks(output *dto.ResponsesOutput, offset int) ([]dto.ChatCompletionsStreamResponse, error) {
|
||||
if output == nil {
|
||||
return nil, nil
|
||||
}
|
||||
annotations := make([]any, 0)
|
||||
for _, content := range output.Content {
|
||||
annotations = append(annotations, content.Annotations...)
|
||||
}
|
||||
start := max(s.sentAnnotationCount-offset, 0)
|
||||
if start >= len(annotations) {
|
||||
return nil, nil
|
||||
}
|
||||
var chunks []dto.ChatCompletionsStreamResponse
|
||||
for _, annotation := range annotations[start:] {
|
||||
converted, err := s.annotationDelta(annotation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
chunks = append(chunks, converted...)
|
||||
}
|
||||
return chunks, nil
|
||||
}
|
||||
|
||||
func responsesOutputAnnotationCount(output *dto.ResponsesOutput) int {
|
||||
count := 0
|
||||
for _, content := range output.Content {
|
||||
count += len(content.Annotations)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) reasoningDelta(delta string) []dto.ChatCompletionsStreamResponse {
|
||||
if delta == "" {
|
||||
return nil
|
||||
}
|
||||
if s.needsReasoningSummaryBreak {
|
||||
if strings.HasPrefix(delta, "\n\n") {
|
||||
s.needsReasoningSummaryBreak = false
|
||||
} else if strings.HasPrefix(delta, "\n") {
|
||||
delta = "\n" + delta
|
||||
s.needsReasoningSummaryBreak = false
|
||||
} else {
|
||||
delta = "\n\n" + delta
|
||||
s.needsReasoningSummaryBreak = false
|
||||
}
|
||||
}
|
||||
s.usageText.WriteString(delta)
|
||||
chunks := s.ensureStart()
|
||||
chunks = append(chunks, s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{
|
||||
ReasoningContent: &delta,
|
||||
}, nil))
|
||||
s.hasSentReasoning = true
|
||||
return chunks
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) toolItem(event *dto.ResponsesStreamResponse) []dto.ChatCompletionsStreamResponse {
|
||||
tool := s.ensureToolForEvent(event)
|
||||
if tool == nil {
|
||||
return nil
|
||||
}
|
||||
args := event.Item.ArgumentsString()
|
||||
if args != "" {
|
||||
tool.Arguments = args
|
||||
}
|
||||
return s.toolDelta(tool, "")
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) toolArgumentsDelta(event *dto.ResponsesStreamResponse) []dto.ChatCompletionsStreamResponse {
|
||||
if event.Delta == "" {
|
||||
return nil
|
||||
}
|
||||
tool := s.findToolForEvent(event)
|
||||
if tool == nil {
|
||||
if event.OutputIndex != nil {
|
||||
s.pendingArgsByOutputIndex[*event.OutputIndex] += event.Delta
|
||||
} else if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
|
||||
s.pendingArgsByItemID[itemID] += event.Delta
|
||||
}
|
||||
return nil
|
||||
}
|
||||
tool.Arguments += event.Delta
|
||||
return s.toolDelta(tool, event.Delta)
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) flushPendingTool(event *dto.ResponsesStreamResponse) []dto.ChatCompletionsStreamResponse {
|
||||
tool := s.findToolForEvent(event)
|
||||
if tool == nil {
|
||||
tool = s.ensureFallbackToolForEvent(event)
|
||||
}
|
||||
if tool == nil {
|
||||
return nil
|
||||
}
|
||||
return s.toolDelta(tool, "")
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) ensureToolForEvent(event *dto.ResponsesStreamResponse) *responsesStreamTool {
|
||||
if event == nil || event.Item == nil {
|
||||
return nil
|
||||
}
|
||||
key := s.keyForEvent(event)
|
||||
if key == "" {
|
||||
key = fallbackToolKey(event.Item.ID, event.Item.CallId, event.OutputIndex)
|
||||
}
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
tool := s.toolByKey[key]
|
||||
if tool == nil {
|
||||
if itemID := responseStreamEventItemID(event); itemID != "" {
|
||||
if existingKey := s.itemIDToKey[itemID]; existingKey != "" {
|
||||
tool = s.toolByKey[existingKey]
|
||||
}
|
||||
}
|
||||
if tool == nil {
|
||||
if callID := strings.TrimSpace(event.Item.CallId); callID != "" {
|
||||
if existingKey := s.callIDToKey[callID]; existingKey != "" {
|
||||
tool = s.toolByKey[existingKey]
|
||||
}
|
||||
}
|
||||
}
|
||||
if tool != nil {
|
||||
s.toolByKey[key] = tool
|
||||
}
|
||||
}
|
||||
if tool == nil {
|
||||
tool = &responsesStreamTool{Key: key, Index: s.nextToolIndex}
|
||||
s.nextToolIndex++
|
||||
s.toolByKey[key] = tool
|
||||
}
|
||||
|
||||
if event.OutputIndex != nil {
|
||||
s.outputIndexToKey[*event.OutputIndex] = key
|
||||
if pending := s.pendingArgsByOutputIndex[*event.OutputIndex]; pending != "" {
|
||||
tool.Arguments += pending
|
||||
delete(s.pendingArgsByOutputIndex, *event.OutputIndex)
|
||||
}
|
||||
}
|
||||
if itemID := responseStreamEventItemID(event); itemID != "" {
|
||||
tool.ItemID = itemID
|
||||
s.itemIDToKey[itemID] = key
|
||||
if pending := s.pendingArgsByItemID[itemID]; pending != "" {
|
||||
tool.Arguments += pending
|
||||
delete(s.pendingArgsByItemID, itemID)
|
||||
}
|
||||
}
|
||||
if callID := strings.TrimSpace(event.Item.CallId); callID != "" {
|
||||
tool.CallID = callID
|
||||
s.callIDToKey[callID] = key
|
||||
} else if tool.CallID == "" {
|
||||
tool.CallID = strings.TrimSpace(event.Item.ID)
|
||||
}
|
||||
if name := strings.TrimSpace(event.Item.Name); name != "" {
|
||||
tool.Name = name
|
||||
}
|
||||
return tool
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) findToolForEvent(event *dto.ResponsesStreamResponse) *responsesStreamTool {
|
||||
if event == nil {
|
||||
return nil
|
||||
}
|
||||
if event.OutputIndex != nil {
|
||||
if key := s.outputIndexToKey[*event.OutputIndex]; key != "" {
|
||||
return s.toolByKey[key]
|
||||
}
|
||||
}
|
||||
if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
|
||||
if key := s.itemIDToKey[itemID]; key != "" {
|
||||
return s.toolByKey[key]
|
||||
}
|
||||
}
|
||||
if event.Item != nil {
|
||||
if key := s.keyForEvent(event); key != "" {
|
||||
return s.toolByKey[key]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) ensureFallbackToolForEvent(event *dto.ResponsesStreamResponse) *responsesStreamTool {
|
||||
if event == nil {
|
||||
return nil
|
||||
}
|
||||
key := ""
|
||||
if event.OutputIndex != nil {
|
||||
key = fmt.Sprintf("output:%d", *event.OutputIndex)
|
||||
}
|
||||
if key == "" && strings.TrimSpace(event.ItemID) != "" {
|
||||
key = "item:" + strings.TrimSpace(event.ItemID)
|
||||
}
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
tool := s.toolByKey[key]
|
||||
if tool == nil {
|
||||
tool = &responsesStreamTool{
|
||||
Key: key,
|
||||
Index: s.nextToolIndex,
|
||||
CallID: fallbackCallID(event),
|
||||
}
|
||||
s.nextToolIndex++
|
||||
s.toolByKey[key] = tool
|
||||
}
|
||||
if event.OutputIndex != nil {
|
||||
s.outputIndexToKey[*event.OutputIndex] = key
|
||||
if pending := s.pendingArgsByOutputIndex[*event.OutputIndex]; pending != "" {
|
||||
tool.Arguments += pending
|
||||
delete(s.pendingArgsByOutputIndex, *event.OutputIndex)
|
||||
}
|
||||
}
|
||||
if itemID := responseStreamEventItemID(event); itemID != "" {
|
||||
tool.ItemID = itemID
|
||||
s.itemIDToKey[itemID] = key
|
||||
if pending := s.pendingArgsByItemID[itemID]; pending != "" {
|
||||
tool.Arguments += pending
|
||||
delete(s.pendingArgsByItemID, itemID)
|
||||
}
|
||||
}
|
||||
return tool
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) toolDelta(tool *responsesStreamTool, explicitDelta string) []dto.ChatCompletionsStreamResponse {
|
||||
if tool == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
argsDelta := explicitDelta
|
||||
if argsDelta == "" && len(tool.Arguments) > tool.ArgsSentAt {
|
||||
argsDelta = tool.Arguments[tool.ArgsSentAt:]
|
||||
}
|
||||
if tool.Sent && argsDelta == "" && (tool.Name == "" || tool.NameSent) {
|
||||
return nil
|
||||
}
|
||||
|
||||
chunks := s.ensureStart()
|
||||
callID := strings.TrimSpace(tool.CallID)
|
||||
if callID == "" {
|
||||
callID = tool.Key
|
||||
}
|
||||
responseTool := dto.ToolCallResponse{
|
||||
ID: callID,
|
||||
Type: "function",
|
||||
Function: dto.FunctionResponse{
|
||||
Arguments: argsDelta,
|
||||
},
|
||||
}
|
||||
responseTool.SetIndex(tool.Index)
|
||||
if !tool.NameSent && tool.Name != "" {
|
||||
responseTool.Function.Name = tool.Name
|
||||
tool.NameSent = true
|
||||
}
|
||||
if !tool.Sent {
|
||||
tool.Sent = true
|
||||
}
|
||||
if argsDelta != "" {
|
||||
tool.ArgsSentAt += len(argsDelta)
|
||||
s.usageText.WriteString(argsDelta)
|
||||
}
|
||||
if responseTool.Function.Name != "" {
|
||||
s.usageText.WriteString(responseTool.Function.Name)
|
||||
}
|
||||
|
||||
chunks = append(chunks, s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{
|
||||
ToolCalls: []dto.ToolCallResponse{responseTool},
|
||||
}, nil))
|
||||
s.sawToolCall = true
|
||||
return chunks
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) finalize(response *dto.OpenAIResponsesResponse) []dto.ChatCompletionsStreamResponse {
|
||||
if s.finalized {
|
||||
return nil
|
||||
}
|
||||
s.finalized = true
|
||||
|
||||
chunks := s.flushAllPendingTools()
|
||||
chunks = append(chunks, s.ensureStart()...)
|
||||
|
||||
finishReason := "stop"
|
||||
if mappedReason, ok := ResponsesFinishReasonFromStatus(response); ok {
|
||||
finishReason = mappedReason
|
||||
} else if s.sawToolCall {
|
||||
finishReason = "tool_calls"
|
||||
}
|
||||
chunks = append(chunks, s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{}, &finishReason))
|
||||
if s.IncludeUsage && s.Usage != nil {
|
||||
chunks = append(chunks, dto.ChatCompletionsStreamResponse{
|
||||
Id: s.ID,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: s.Created,
|
||||
Model: s.Model,
|
||||
Choices: make([]dto.ChatCompletionsStreamResponseChoice, 0),
|
||||
Usage: s.Usage,
|
||||
})
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) flushAllPendingTools() []dto.ChatCompletionsStreamResponse {
|
||||
keys := make([]string, 0, len(s.toolByKey)+len(s.pendingArgsByOutputIndex)+len(s.pendingArgsByItemID))
|
||||
seen := make(map[string]bool)
|
||||
for key := range s.toolByKey {
|
||||
keys = append(keys, key)
|
||||
seen[key] = true
|
||||
}
|
||||
for outputIndex := range s.pendingArgsByOutputIndex {
|
||||
key := fmt.Sprintf("output:%d", outputIndex)
|
||||
if !seen[key] {
|
||||
keys = append(keys, key)
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
for itemID := range s.pendingArgsByItemID {
|
||||
key := "item:" + itemID
|
||||
if !seen[key] {
|
||||
keys = append(keys, key)
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var chunks []dto.ChatCompletionsStreamResponse
|
||||
for _, key := range keys {
|
||||
tool := s.toolByKey[key]
|
||||
if tool == nil {
|
||||
callID := strings.TrimPrefix(key, "item:")
|
||||
if after, ok := strings.CutPrefix(key, "output:"); ok {
|
||||
callID = "call_output_" + after
|
||||
}
|
||||
tool = &responsesStreamTool{
|
||||
Key: key,
|
||||
Index: s.nextToolIndex,
|
||||
CallID: callID,
|
||||
}
|
||||
s.nextToolIndex++
|
||||
s.toolByKey[key] = tool
|
||||
}
|
||||
if strings.HasPrefix(key, "output:") {
|
||||
var outputIndex int
|
||||
if _, err := fmt.Sscanf(key, "output:%d", &outputIndex); err == nil {
|
||||
tool.Arguments += s.pendingArgsByOutputIndex[outputIndex]
|
||||
delete(s.pendingArgsByOutputIndex, outputIndex)
|
||||
}
|
||||
}
|
||||
if after, ok := strings.CutPrefix(key, "item:"); ok {
|
||||
itemID := after
|
||||
tool.Arguments += s.pendingArgsByItemID[itemID]
|
||||
delete(s.pendingArgsByItemID, itemID)
|
||||
}
|
||||
chunks = append(chunks, s.toolDelta(tool, "")...)
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) makeChunk(delta dto.ChatCompletionsStreamResponseChoiceDelta, finishReason *string) dto.ChatCompletionsStreamResponse {
|
||||
return dto.ChatCompletionsStreamResponse{
|
||||
Id: s.ID,
|
||||
Object: "chat.completion.chunk",
|
||||
Created: s.Created,
|
||||
Model: s.Model,
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{
|
||||
{
|
||||
Index: 0,
|
||||
Delta: delta,
|
||||
FinishReason: finishReason,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ResponsesToChatStreamState) keyForEvent(event *dto.ResponsesStreamResponse) string {
|
||||
if event == nil {
|
||||
return ""
|
||||
}
|
||||
if event.OutputIndex != nil {
|
||||
return fmt.Sprintf("output:%d", *event.OutputIndex)
|
||||
}
|
||||
if event.Item != nil {
|
||||
if itemID := strings.TrimSpace(event.Item.ID); itemID != "" {
|
||||
return "item:" + itemID
|
||||
}
|
||||
if callID := strings.TrimSpace(event.Item.CallId); callID != "" {
|
||||
return "call:" + callID
|
||||
}
|
||||
}
|
||||
if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
|
||||
return "item:" + itemID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ResponsesBufferedAccumulator struct {
|
||||
items []*responsesBufferedItem
|
||||
outputIndexToItemIdx map[int]int
|
||||
itemIDToItemIdx map[string]int
|
||||
lastUnindexedItemIdx int
|
||||
tools []*responsesBufferedTool
|
||||
outputIndexToToolIdx map[int]int
|
||||
itemIDToToolIdx map[string]int
|
||||
pendingByOutputIndex map[int]string
|
||||
pendingByItemID map[string]string
|
||||
}
|
||||
|
||||
type responsesBufferedItem struct {
|
||||
Type string
|
||||
ID string
|
||||
Text strings.Builder
|
||||
Annotations []any
|
||||
ToolIndex int
|
||||
NeedsReasoningBreak bool
|
||||
}
|
||||
|
||||
type responsesBufferedTool struct {
|
||||
CallID string
|
||||
ItemID string
|
||||
Name string
|
||||
Arguments strings.Builder
|
||||
}
|
||||
|
||||
func NewResponsesBufferedAccumulator() *ResponsesBufferedAccumulator {
|
||||
return &ResponsesBufferedAccumulator{
|
||||
outputIndexToItemIdx: make(map[int]int),
|
||||
itemIDToItemIdx: make(map[string]int),
|
||||
lastUnindexedItemIdx: -1,
|
||||
outputIndexToToolIdx: make(map[int]int),
|
||||
itemIDToToolIdx: make(map[string]int),
|
||||
pendingByOutputIndex: make(map[int]string),
|
||||
pendingByItemID: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ResponsesBufferedAccumulator) ProcessEvent(event *dto.ResponsesStreamResponse) {
|
||||
if a == nil || event == nil {
|
||||
return
|
||||
}
|
||||
switch event.Type {
|
||||
case responsesEventOutputTextDelta:
|
||||
item := a.ensureItem(event, responsesOutputTypeMessage)
|
||||
item.Text.WriteString(event.Delta)
|
||||
case responsesEventOutputTextAnnotationAdded:
|
||||
item := a.ensureItem(event, responsesOutputTypeMessage)
|
||||
var annotation any
|
||||
if err := kitutil.Unmarshal(event.Annotation, &annotation); err == nil && annotation != nil {
|
||||
item.Annotations = append(item.Annotations, annotation)
|
||||
}
|
||||
case responsesEventReasoningSummaryDelta, responsesEventReasoningTextDelta:
|
||||
item := a.ensureItem(event, responsesOutputTypeReasoning)
|
||||
if item.NeedsReasoningBreak {
|
||||
appendSeparatedText(&item.Text, event.Delta)
|
||||
item.NeedsReasoningBreak = false
|
||||
} else {
|
||||
item.Text.WriteString(event.Delta)
|
||||
}
|
||||
case responsesEventReasoningSummaryDone, responsesEventReasoningTextDone:
|
||||
item := a.ensureItem(event, responsesOutputTypeReasoning)
|
||||
if item.Text.Len() == 0 && event.Text != nil {
|
||||
item.Text.WriteString(*event.Text)
|
||||
}
|
||||
if item.Text.Len() > 0 {
|
||||
item.NeedsReasoningBreak = true
|
||||
}
|
||||
case responsesEventOutputItemAdded, responsesEventOutputItemDone:
|
||||
if event.Item == nil {
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case event.Item.Type == responsesOutputTypeReasoning:
|
||||
item := a.ensureItem(event, event.Item.Type)
|
||||
if item.Text.Len() == 0 {
|
||||
item.Text.WriteString(reasoningOutputText(event.Item))
|
||||
}
|
||||
case event.Item.Type == responsesOutputTypeMessage:
|
||||
item := a.ensureItem(event, event.Item.Type)
|
||||
seedText := item.Text.Len() == 0
|
||||
seedAnnotations := len(item.Annotations) == 0
|
||||
for _, content := range event.Item.Content {
|
||||
if content.Type == "output_text" {
|
||||
if seedText {
|
||||
item.Text.WriteString(content.Text)
|
||||
}
|
||||
if seedAnnotations {
|
||||
item.Annotations = append(item.Annotations, content.Annotations...)
|
||||
}
|
||||
}
|
||||
}
|
||||
case isResponsesToolOutputType(event.Item.Type):
|
||||
tool := a.ensureTool(event)
|
||||
if args := event.Item.ArgumentsString(); args != "" {
|
||||
tool.Arguments.Reset()
|
||||
tool.Arguments.WriteString(args)
|
||||
}
|
||||
}
|
||||
case responsesEventFunctionArgsDelta, responsesEventCustomToolInputDelta:
|
||||
if idx, ok := a.findToolIndex(event); ok {
|
||||
a.tools[idx].Arguments.WriteString(event.Delta)
|
||||
return
|
||||
}
|
||||
if event.OutputIndex != nil {
|
||||
a.pendingByOutputIndex[*event.OutputIndex] += event.Delta
|
||||
} else if itemID := strings.TrimSpace(event.ItemID); itemID != "" {
|
||||
a.pendingByItemID[itemID] += event.Delta
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ResponsesBufferedAccumulator) SupplementResponseOutput(resp *dto.OpenAIResponsesResponse) {
|
||||
if a == nil || resp == nil || len(resp.Output) > 0 {
|
||||
return
|
||||
}
|
||||
resp.Output = a.BuildOutput()
|
||||
}
|
||||
|
||||
func (a *ResponsesBufferedAccumulator) BuildOutput() []dto.ResponsesOutput {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]dto.ResponsesOutput, 0, len(a.items))
|
||||
for _, item := range a.items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
switch item.Type {
|
||||
case responsesOutputTypeReasoning:
|
||||
if item.Text.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, dto.ResponsesOutput{
|
||||
Type: item.Type,
|
||||
ID: item.ID,
|
||||
Summary: []dto.ResponsesReasoningSummaryPart{
|
||||
{Type: "summary_text", Text: item.Text.String()},
|
||||
},
|
||||
})
|
||||
case responsesOutputTypeMessage:
|
||||
if item.Text.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, dto.ResponsesOutput{
|
||||
Type: item.Type,
|
||||
ID: item.ID,
|
||||
Role: "assistant",
|
||||
Content: []dto.ResponsesOutputContent{
|
||||
{Type: "output_text", Text: item.Text.String(), Annotations: item.Annotations},
|
||||
},
|
||||
})
|
||||
case responsesOutputTypeFunctionCall, responsesOutputTypeCustomToolCall:
|
||||
if item.ToolIndex < 0 || item.ToolIndex >= len(a.tools) || a.tools[item.ToolIndex] == nil {
|
||||
continue
|
||||
}
|
||||
tool := a.tools[item.ToolIndex]
|
||||
argsRaw, _ := kitutil.Marshal(tool.Arguments.String())
|
||||
out = append(out, dto.ResponsesOutput{
|
||||
Type: item.Type,
|
||||
ID: tool.ItemID,
|
||||
CallId: tool.CallID,
|
||||
Name: tool.Name,
|
||||
Arguments: argsRaw,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *ResponsesBufferedAccumulator) ensureItem(event *dto.ResponsesStreamResponse, itemType string) *responsesBufferedItem {
|
||||
if idx, ok := a.findItemIndex(event); ok {
|
||||
item := a.items[idx]
|
||||
if item.Type == "" {
|
||||
item.Type = itemType
|
||||
}
|
||||
a.applyItemMetadata(idx, item, event)
|
||||
return item
|
||||
}
|
||||
if event != nil && event.OutputIndex == nil && responseStreamEventItemID(event) == "" && a.lastUnindexedItemIdx >= 0 {
|
||||
item := a.items[a.lastUnindexedItemIdx]
|
||||
if item != nil && item.Type == itemType {
|
||||
return item
|
||||
}
|
||||
}
|
||||
item := &responsesBufferedItem{Type: itemType, ToolIndex: -1}
|
||||
idx := len(a.items)
|
||||
a.items = append(a.items, item)
|
||||
a.lastUnindexedItemIdx = idx
|
||||
a.applyItemMetadata(idx, item, event)
|
||||
return item
|
||||
}
|
||||
|
||||
func (a *ResponsesBufferedAccumulator) applyItemMetadata(idx int, item *responsesBufferedItem, event *dto.ResponsesStreamResponse) {
|
||||
if item == nil || event == nil {
|
||||
return
|
||||
}
|
||||
if event.OutputIndex != nil {
|
||||
a.outputIndexToItemIdx[*event.OutputIndex] = idx
|
||||
}
|
||||
if itemID := responseStreamEventItemID(event); itemID != "" {
|
||||
item.ID = itemID
|
||||
a.itemIDToItemIdx[itemID] = idx
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ResponsesBufferedAccumulator) findItemIndex(event *dto.ResponsesStreamResponse) (int, bool) {
|
||||
if event == nil {
|
||||
return 0, false
|
||||
}
|
||||
if event.OutputIndex != nil {
|
||||
if idx, ok := a.outputIndexToItemIdx[*event.OutputIndex]; ok {
|
||||
return idx, true
|
||||
}
|
||||
}
|
||||
if itemID := responseStreamEventItemID(event); itemID != "" {
|
||||
idx, ok := a.itemIDToItemIdx[itemID]
|
||||
return idx, ok
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func (a *ResponsesBufferedAccumulator) ensureTool(event *dto.ResponsesStreamResponse) *responsesBufferedTool {
|
||||
if idx, ok := a.findToolIndex(event); ok {
|
||||
tool := a.tools[idx]
|
||||
a.applyToolMetadata(tool, event)
|
||||
item := a.ensureItem(event, event.Item.Type)
|
||||
item.ToolIndex = idx
|
||||
return tool
|
||||
}
|
||||
tool := &responsesBufferedTool{}
|
||||
a.applyToolMetadata(tool, event)
|
||||
idx := len(a.tools)
|
||||
a.tools = append(a.tools, tool)
|
||||
item := a.ensureItem(event, event.Item.Type)
|
||||
item.ToolIndex = idx
|
||||
if event.OutputIndex != nil {
|
||||
a.outputIndexToToolIdx[*event.OutputIndex] = idx
|
||||
if pending := a.pendingByOutputIndex[*event.OutputIndex]; pending != "" {
|
||||
tool.Arguments.WriteString(pending)
|
||||
delete(a.pendingByOutputIndex, *event.OutputIndex)
|
||||
}
|
||||
}
|
||||
if tool.ItemID != "" {
|
||||
a.itemIDToToolIdx[tool.ItemID] = idx
|
||||
if pending := a.pendingByItemID[tool.ItemID]; pending != "" {
|
||||
tool.Arguments.WriteString(pending)
|
||||
delete(a.pendingByItemID, tool.ItemID)
|
||||
}
|
||||
}
|
||||
return tool
|
||||
}
|
||||
|
||||
func (a *ResponsesBufferedAccumulator) applyToolMetadata(tool *responsesBufferedTool, event *dto.ResponsesStreamResponse) {
|
||||
if tool == nil || event == nil || event.Item == nil {
|
||||
return
|
||||
}
|
||||
if itemID := strings.TrimSpace(event.Item.ID); itemID != "" {
|
||||
tool.ItemID = itemID
|
||||
}
|
||||
if callID := strings.TrimSpace(event.Item.CallId); callID != "" {
|
||||
tool.CallID = callID
|
||||
} else if tool.CallID == "" {
|
||||
tool.CallID = strings.TrimSpace(event.Item.ID)
|
||||
}
|
||||
if name := strings.TrimSpace(event.Item.Name); name != "" {
|
||||
tool.Name = name
|
||||
}
|
||||
}
|
||||
|
||||
func (a *ResponsesBufferedAccumulator) findToolIndex(event *dto.ResponsesStreamResponse) (int, bool) {
|
||||
if event == nil {
|
||||
return 0, false
|
||||
}
|
||||
if event.OutputIndex != nil {
|
||||
if idx, ok := a.outputIndexToToolIdx[*event.OutputIndex]; ok {
|
||||
return idx, true
|
||||
}
|
||||
}
|
||||
itemID := strings.TrimSpace(event.ItemID)
|
||||
if itemID == "" && event.Item != nil {
|
||||
itemID = strings.TrimSpace(event.Item.ID)
|
||||
}
|
||||
if itemID != "" {
|
||||
idx, ok := a.itemIDToToolIdx[itemID]
|
||||
return idx, ok
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
Reference in new issue
Block a user