原生 Chat/Responses 改为透明转发,保留标准工具结构并保护调用方显式参数。补齐 Responses 到 Chat 的兼容转换、协议路由边界、完整响应和流式事件,并同步更新 Swagger、回归测试与真实验收脚本。 验证: - cd apps/api && env -u AI_GATEWAY_TEST_DATABASE_URL go test ./... -count=1 - pnpm openapi - pnpm lint - pnpm test - pnpm build - gofmt -l 无输出 - git diff --check 通过 风险: - Chat 回退无法等价表达的 Responses 原生能力现在会返回 unsupported_response_parameter - 真实供应商 E2E 因本地没有已启用的平台模型候选而未完成
1315 lines
45 KiB
Go
1315 lines
45 KiB
Go
package clients
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
ProtocolOpenAIChatCompletions = "openai_chat_completions"
|
|
ProtocolOpenAIResponses = "openai_responses"
|
|
ProtocolAnthropicMessages = "anthropic_messages"
|
|
ProtocolOpenAIEmbeddings = "openai_embeddings"
|
|
ProtocolOpenAIImages = "openai_images"
|
|
ProtocolGeminiGenerateContent = "gemini_generate_content"
|
|
ProtocolGeminiVeo = "gemini_veo_predict_long_running"
|
|
ProtocolVolcesContents = "volces_contents_generations_v3"
|
|
ProtocolKlingV1Omni = "kling_v1_omni_video"
|
|
ProtocolKlingV2Omni = "kling_v2_omni_video"
|
|
)
|
|
|
|
var supportedResponseFallbackParameters = map[string]struct{}{
|
|
"model": {}, "input": {}, "messages": {}, "instructions": {}, "tools": {}, "tool_choice": {},
|
|
"parallel_tool_calls": {}, "max_output_tokens": {}, "temperature": {}, "top_p": {},
|
|
"presence_penalty": {}, "frequency_penalty": {}, "reasoning": {}, "text": {},
|
|
"stream": {}, "stream_options": {}, "store": {}, "previous_response_id": {}, "metadata": {}, "user": {},
|
|
"moderation": {}, "prompt_cache_key": {}, "prompt_cache_options": {}, "prompt_cache_retention": {},
|
|
"safety_identifier": {}, "service_tier": {}, "top_logprobs": {}, "include": {},
|
|
"background": {}, "context_management": {}, "conversation": {}, "max_tool_calls": {},
|
|
"prompt": {}, "truncation": {},
|
|
}
|
|
|
|
// ValidateResponsesChatFallback determines whether a Responses request has a
|
|
// semantics-preserving Chat Completions representation. It is intentionally
|
|
// reusable by routing so native-only requests never reach a Chat candidate.
|
|
func ValidateResponsesChatFallback(body map[string]any) error {
|
|
for key := range body {
|
|
if _, internal := gatewayOpenAIRequestExtensions[key]; internal {
|
|
continue
|
|
}
|
|
if _, ok := supportedResponseFallbackParameters[key]; !ok {
|
|
return unsupportedResponseParameter(key)
|
|
}
|
|
}
|
|
for key, value := range map[string]any{
|
|
"background": body["background"], "context_management": body["context_management"],
|
|
"conversation": body["conversation"], "max_tool_calls": body["max_tool_calls"], "prompt": body["prompt"],
|
|
} {
|
|
if !responseFallbackNoop(value) {
|
|
return unsupportedResponseParameter(key)
|
|
}
|
|
}
|
|
if truncation := strings.TrimSpace(stringFromAny(body["truncation"])); truncation != "" && truncation != "disabled" {
|
|
return unsupportedResponseParameter("truncation")
|
|
}
|
|
if background, ok := body["background"].(bool); ok && background {
|
|
return unsupportedResponseParameter("background")
|
|
}
|
|
if include, ok := body["include"].([]any); ok {
|
|
for index, raw := range include {
|
|
if stringFromAny(raw) != "message.output_text.logprobs" {
|
|
return unsupportedResponseParameter(fmt.Sprintf("include[%d]", index))
|
|
}
|
|
}
|
|
} else if body["include"] != nil {
|
|
return unsupportedResponseParameter("include")
|
|
}
|
|
if reasoning, ok := body["reasoning"].(map[string]any); ok {
|
|
for key, value := range reasoning {
|
|
if key != "effort" && !responseFallbackNoop(value) {
|
|
return unsupportedResponseParameter("reasoning." + key)
|
|
}
|
|
}
|
|
} else if body["reasoning"] != nil {
|
|
return unsupportedResponseParameter("reasoning")
|
|
}
|
|
if tools, ok := body["tools"]; ok {
|
|
if _, err := responseToolsToChat(tools); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if choice, ok := body["tool_choice"]; ok {
|
|
if _, err := responseToolChoiceToChat(choice); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if text, ok := body["text"]; ok {
|
|
if _, _, err := responseTextParams(text); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if streamOptions, ok := body["stream_options"].(map[string]any); ok {
|
|
for key, value := range streamOptions {
|
|
if key == "include_usage" {
|
|
continue
|
|
}
|
|
if !responseFallbackNoop(value) {
|
|
return unsupportedResponseParameter("stream_options." + key)
|
|
}
|
|
}
|
|
} else if body["stream_options"] != nil {
|
|
return unsupportedResponseParameter("stream_options")
|
|
}
|
|
if _, hasChatMessages := body["messages"].([]any); !hasChatMessages {
|
|
if _, err := responseInputMessages(body["input"]); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func responseFallbackNoop(value any) bool {
|
|
switch typed := value.(type) {
|
|
case nil:
|
|
return true
|
|
case bool:
|
|
return !typed
|
|
case string:
|
|
return strings.TrimSpace(typed) == ""
|
|
case map[string]any:
|
|
return len(typed) == 0
|
|
case []any:
|
|
return len(typed) == 0
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func ResponsesRequestToChat(body map[string]any, history []ResponseTurn) (map[string]any, error) {
|
|
if err := ValidateResponsesChatFallback(body); err != nil {
|
|
return nil, err
|
|
}
|
|
messages := make([]any, 0)
|
|
for _, turn := range history {
|
|
priorInput := turn.Request["input"]
|
|
if priorInput == nil {
|
|
priorInput = turn.Request["messages"]
|
|
}
|
|
priorMessages, err := responseInputMessages(priorInput)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
messages = append(messages, priorMessages...)
|
|
if assistant := chatAssistantMessage(turn.Internal, turn.Response); assistant != nil {
|
|
messages = append(messages, assistant)
|
|
}
|
|
}
|
|
if instructions := strings.TrimSpace(stringFromAny(body["instructions"])); instructions != "" {
|
|
messages = append(messages, map[string]any{"role": "system", "content": instructions})
|
|
}
|
|
if rawMessages, ok := body["messages"].([]any); ok {
|
|
messages = append(messages, rawMessages...)
|
|
} else {
|
|
currentMessages, err := responseInputMessages(body["input"])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
messages = append(messages, currentMessages...)
|
|
}
|
|
if len(messages) == 0 {
|
|
return nil, &ClientError{Code: "invalid_parameter", Message: "input is required", StatusCode: http.StatusBadRequest}
|
|
}
|
|
out := map[string]any{"messages": messages}
|
|
for _, key := range []string{
|
|
"temperature", "top_p", "presence_penalty", "frequency_penalty", "parallel_tool_calls",
|
|
"stream", "store", "metadata", "user", "moderation", "prompt_cache_key",
|
|
"prompt_cache_options", "prompt_cache_retention", "safety_identifier", "service_tier",
|
|
} {
|
|
if value, ok := body[key]; ok {
|
|
out[key] = value
|
|
}
|
|
}
|
|
if streamOptions, ok := body["stream_options"].(map[string]any); ok {
|
|
chatOptions := map[string]any{}
|
|
if includeUsage, explicit := streamOptions["include_usage"]; explicit {
|
|
chatOptions["include_usage"] = includeUsage
|
|
}
|
|
if len(chatOptions) > 0 {
|
|
out["stream_options"] = chatOptions
|
|
}
|
|
}
|
|
if value, ok := body["top_logprobs"]; ok {
|
|
out["top_logprobs"] = value
|
|
out["logprobs"] = true
|
|
}
|
|
if include, _ := body["include"].([]any); len(include) > 0 {
|
|
out["logprobs"] = true
|
|
}
|
|
if value, ok := body["max_output_tokens"]; ok {
|
|
out["max_completion_tokens"] = value
|
|
}
|
|
if rawReasoning, ok := body["reasoning"]; ok {
|
|
reasoning, ok := rawReasoning.(map[string]any)
|
|
if !ok {
|
|
return nil, unsupportedResponseParameter("reasoning")
|
|
}
|
|
if effort, ok := reasoning["effort"]; ok {
|
|
out["reasoning_effort"] = effort
|
|
}
|
|
}
|
|
if rawText, ok := body["text"]; ok {
|
|
responseFormat, verbosity, err := responseTextParams(rawText)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if responseFormat != nil {
|
|
out["response_format"] = responseFormat
|
|
}
|
|
if verbosity != nil {
|
|
out["verbosity"] = verbosity
|
|
}
|
|
}
|
|
if rawTools, ok := body["tools"]; ok {
|
|
tools, err := responseToolsToChat(rawTools)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out["tools"] = tools
|
|
}
|
|
if rawChoice, ok := body["tool_choice"]; ok {
|
|
choice, err := responseToolChoiceToChat(rawChoice)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out["tool_choice"] = choice
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func responseInputMessages(value any) ([]any, error) {
|
|
return responseInputMessagesAt(value, "input")
|
|
}
|
|
|
|
func responseInputMessagesAt(value any, path string) ([]any, error) {
|
|
switch typed := value.(type) {
|
|
case nil:
|
|
return nil, nil
|
|
case string:
|
|
return []any{map[string]any{"role": "user", "content": typed}}, nil
|
|
case []any:
|
|
out := make([]any, 0, len(typed))
|
|
for index, raw := range typed {
|
|
itemPath := fmt.Sprintf("%s[%d]", path, index)
|
|
switch item := raw.(type) {
|
|
case string:
|
|
out = append(out, map[string]any{"role": "user", "content": item})
|
|
case map[string]any:
|
|
switch stringFromAny(item["type"]) {
|
|
case "function_call_output", "custom_tool_call_output":
|
|
if err := validateFallbackObjectKeys(item, itemPath, stringSet("type", "id", "call_id", "output", "status")); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateFallbackItemStatus(item, itemPath); err != nil {
|
|
return nil, err
|
|
}
|
|
callID := firstNonEmptyString(item["call_id"], item["id"])
|
|
if callID == "" {
|
|
return nil, &ClientError{Code: "invalid_parameter", Message: itemPath + ".call_id is required", Param: itemPath + ".call_id", StatusCode: http.StatusBadRequest}
|
|
}
|
|
output, err := responseToolOutputToChat(item["output"], itemPath+".output")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, map[string]any{"role": "tool", "tool_call_id": callID, "content": output})
|
|
case "function_call":
|
|
if err := validateFallbackObjectKeys(item, itemPath, stringSet("type", "id", "call_id", "name", "arguments", "status")); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateFallbackItemStatus(item, itemPath); err != nil {
|
|
return nil, err
|
|
}
|
|
if firstNonEmptyString(item["call_id"], item["id"]) == "" {
|
|
return nil, &ClientError{Code: "invalid_parameter", Message: itemPath + ".call_id is required", Param: itemPath + ".call_id", StatusCode: http.StatusBadRequest}
|
|
}
|
|
out = append(out, responseCallMessage(item, "function"))
|
|
case "custom_tool_call":
|
|
if err := validateFallbackObjectKeys(item, itemPath, stringSet("type", "id", "call_id", "name", "input", "status")); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateFallbackItemStatus(item, itemPath); err != nil {
|
|
return nil, err
|
|
}
|
|
if firstNonEmptyString(item["call_id"], item["id"]) == "" {
|
|
return nil, &ClientError{Code: "invalid_parameter", Message: itemPath + ".call_id is required", Param: itemPath + ".call_id", StatusCode: http.StatusBadRequest}
|
|
}
|
|
out = append(out, responseCallMessage(item, "custom"))
|
|
case "message", "":
|
|
if err := validateFallbackObjectKeys(item, itemPath, stringSet("type", "id", "role", "content", "name", "audio", "status")); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := validateFallbackItemStatus(item, itemPath); err != nil {
|
|
return nil, err
|
|
}
|
|
message, err := responseMessageToChat(item, itemPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, message)
|
|
default:
|
|
return nil, unsupportedResponseParameter(itemPath + ".type")
|
|
}
|
|
default:
|
|
return nil, unsupportedResponseParameter(itemPath)
|
|
}
|
|
}
|
|
return out, nil
|
|
case map[string]any:
|
|
return responseInputMessagesAt([]any{typed}, path)
|
|
default:
|
|
return nil, unsupportedResponseParameter("input")
|
|
}
|
|
}
|
|
|
|
func validateFallbackItemStatus(item map[string]any, path string) error {
|
|
status := strings.TrimSpace(stringFromAny(item["status"]))
|
|
if status == "" || status == "completed" {
|
|
return nil
|
|
}
|
|
return unsupportedResponseParameter(path + ".status")
|
|
}
|
|
|
|
func responseMessageToChat(item map[string]any, path string) (map[string]any, error) {
|
|
role := firstNonEmptyString(item["role"], "user")
|
|
content, refusal, err := responseContentToChat(item["content"], path+".content")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
message := map[string]any{"role": role, "content": content}
|
|
if refusal != "" {
|
|
message["refusal"] = refusal
|
|
}
|
|
for _, key := range []string{"name", "audio"} {
|
|
if value, ok := item[key]; ok {
|
|
message[key] = value
|
|
}
|
|
}
|
|
return message, nil
|
|
}
|
|
|
|
func responseCallMessage(item map[string]any, kind string) map[string]any {
|
|
callID := firstNonEmptyString(item["call_id"], item["id"])
|
|
toolCall := map[string]any{"id": callID, "type": kind}
|
|
if kind == "custom" {
|
|
toolCall["custom"] = map[string]any{"name": item["name"], "input": item["input"]}
|
|
} else {
|
|
toolCall["function"] = map[string]any{"name": item["name"], "arguments": item["arguments"]}
|
|
}
|
|
return map[string]any{"role": "assistant", "content": nil, "tool_calls": []any{toolCall}}
|
|
}
|
|
|
|
func responseToolOutputToChat(value any, path string) (any, error) {
|
|
if value == nil {
|
|
return nil, &ClientError{Code: "invalid_parameter", Message: path + " is required", Param: path, StatusCode: http.StatusBadRequest}
|
|
}
|
|
if text, ok := value.(string); ok {
|
|
return text, nil
|
|
}
|
|
items, ok := value.([]any)
|
|
if !ok {
|
|
return toolResultContent(value), nil
|
|
}
|
|
parts := make([]any, 0, len(items))
|
|
for index, raw := range items {
|
|
partPath := fmt.Sprintf("%s[%d]", path, index)
|
|
item, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return nil, unsupportedResponseParameter(partPath)
|
|
}
|
|
typeName := stringFromAny(item["type"])
|
|
if typeName != "input_text" && typeName != "text" {
|
|
return nil, unsupportedResponseParameter(partPath + ".type")
|
|
}
|
|
if err := validateFallbackObjectKeys(item, partPath, stringSet("type", "text", "prompt_cache_breakpoint")); err != nil {
|
|
return nil, err
|
|
}
|
|
part := map[string]any{"type": "text", "text": stringFromAny(item["text"])}
|
|
if breakpoint, ok := item["prompt_cache_breakpoint"]; ok {
|
|
part["prompt_cache_breakpoint"] = breakpoint
|
|
}
|
|
parts = append(parts, part)
|
|
}
|
|
return parts, nil
|
|
}
|
|
|
|
func responseContentToChat(value any, path string) (any, string, error) {
|
|
items, ok := value.([]any)
|
|
if !ok {
|
|
return value, "", nil
|
|
}
|
|
out := make([]any, 0, len(items))
|
|
refusals := make([]string, 0)
|
|
for index, raw := range items {
|
|
partPath := fmt.Sprintf("%s[%d]", path, index)
|
|
item, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return nil, "", unsupportedResponseParameter(partPath)
|
|
}
|
|
cacheBreakpoint := item["prompt_cache_breakpoint"]
|
|
part := map[string]any{}
|
|
switch stringFromAny(item["type"]) {
|
|
case "input_text", "output_text", "text":
|
|
if err := validateFallbackObjectKeys(item, partPath, stringSet("type", "text", "annotations", "logprobs", "prompt_cache_breakpoint")); err != nil {
|
|
return nil, "", err
|
|
}
|
|
part = map[string]any{"type": "text", "text": stringFromAny(item["text"])}
|
|
case "input_image":
|
|
if err := validateFallbackObjectKeys(item, partPath, stringSet("type", "image_url", "url", "file_id", "detail", "prompt_cache_breakpoint")); err != nil {
|
|
return nil, "", err
|
|
}
|
|
if fileID := stringFromAny(item["file_id"]); fileID != "" {
|
|
part = map[string]any{"type": "file", "file": map[string]any{"file_id": fileID}}
|
|
if detail, ok := item["detail"]; ok {
|
|
part["detail"] = detail
|
|
}
|
|
} else {
|
|
image := map[string]any{"url": firstNonEmptyString(item["image_url"], item["url"])}
|
|
if detail, ok := item["detail"]; ok {
|
|
image["detail"] = detail
|
|
}
|
|
part = map[string]any{"type": "image_url", "image_url": image}
|
|
}
|
|
case "input_file", "file":
|
|
if err := validateFallbackObjectKeys(item, partPath, stringSet("type", "file_id", "file_data", "filename", "prompt_cache_breakpoint")); err != nil {
|
|
return nil, "", err
|
|
}
|
|
file := map[string]any{}
|
|
for _, key := range []string{"file_id", "file_data", "filename"} {
|
|
if value, ok := item[key]; ok {
|
|
file[key] = value
|
|
}
|
|
}
|
|
part = map[string]any{"type": "file", "file": file}
|
|
case "input_audio":
|
|
if err := validateFallbackObjectKeys(item, partPath, stringSet("type", "input_audio", "data", "format", "prompt_cache_breakpoint")); err != nil {
|
|
return nil, "", err
|
|
}
|
|
audio := item["input_audio"]
|
|
if audio == nil {
|
|
audio = map[string]any{"data": item["data"], "format": item["format"]}
|
|
}
|
|
part = map[string]any{"type": "input_audio", "input_audio": audio}
|
|
case "refusal":
|
|
if err := validateFallbackObjectKeys(item, partPath, stringSet("type", "refusal")); err != nil {
|
|
return nil, "", err
|
|
}
|
|
refusals = append(refusals, stringFromAny(item["refusal"]))
|
|
continue
|
|
default:
|
|
return nil, "", unsupportedResponseParameter(partPath + ".type")
|
|
}
|
|
if cacheBreakpoint != nil {
|
|
part["prompt_cache_breakpoint"] = cacheBreakpoint
|
|
}
|
|
out = append(out, part)
|
|
}
|
|
return out, strings.Join(refusals, ""), nil
|
|
}
|
|
|
|
func responseToolsToChat(value any) ([]any, error) {
|
|
items, ok := value.([]any)
|
|
if !ok {
|
|
return nil, unsupportedResponseParameter("tools")
|
|
}
|
|
out := make([]any, 0, len(items))
|
|
for index, raw := range items {
|
|
tool, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return nil, unsupportedResponseParameter(fmt.Sprintf("tools[%d]", index))
|
|
}
|
|
switch stringFromAny(tool["type"]) {
|
|
case "function":
|
|
if err := validateFallbackObjectKeys(tool, fmt.Sprintf("tools[%d]", index), stringSet("type", "name", "description", "parameters", "strict", "prompt_cache_breakpoint", "defer_loading")); err != nil {
|
|
return nil, err
|
|
}
|
|
if value := tool["defer_loading"]; !responseFallbackNoop(value) {
|
|
return nil, unsupportedResponseParameter(fmt.Sprintf("tools[%d].defer_loading", index))
|
|
}
|
|
function := map[string]any{}
|
|
for _, key := range []string{"name", "description", "parameters", "strict"} {
|
|
if value, ok := tool[key]; ok {
|
|
function[key] = value
|
|
}
|
|
}
|
|
converted := map[string]any{"type": "function", "function": function}
|
|
if breakpoint, ok := tool["prompt_cache_breakpoint"]; ok {
|
|
converted["prompt_cache_breakpoint"] = breakpoint
|
|
}
|
|
out = append(out, converted)
|
|
case "custom":
|
|
if err := validateFallbackObjectKeys(tool, fmt.Sprintf("tools[%d]", index), stringSet("type", "name", "description", "format", "prompt_cache_breakpoint", "defer_loading")); err != nil {
|
|
return nil, err
|
|
}
|
|
if value := tool["defer_loading"]; !responseFallbackNoop(value) {
|
|
return nil, unsupportedResponseParameter(fmt.Sprintf("tools[%d].defer_loading", index))
|
|
}
|
|
custom := map[string]any{}
|
|
for _, key := range []string{"name", "description", "format"} {
|
|
if value, ok := tool[key]; ok {
|
|
custom[key] = value
|
|
}
|
|
}
|
|
converted := map[string]any{"type": "custom", "custom": custom}
|
|
if breakpoint, ok := tool["prompt_cache_breakpoint"]; ok {
|
|
converted["prompt_cache_breakpoint"] = breakpoint
|
|
}
|
|
out = append(out, converted)
|
|
default:
|
|
return nil, unsupportedResponseParameter(fmt.Sprintf("tools[%d].type", index))
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func validateFallbackObjectKeys(value map[string]any, path string, allowed map[string]struct{}) error {
|
|
for key, field := range value {
|
|
if _, ok := allowed[key]; ok {
|
|
continue
|
|
}
|
|
if !responseFallbackNoop(field) {
|
|
return unsupportedResponseParameter(path + "." + key)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func responseToolChoiceToChat(value any) (any, error) {
|
|
if text, ok := value.(string); ok {
|
|
switch text {
|
|
case "auto", "none", "required":
|
|
return text, nil
|
|
default:
|
|
return nil, unsupportedResponseParameter("tool_choice")
|
|
}
|
|
}
|
|
choice, ok := value.(map[string]any)
|
|
if !ok {
|
|
return nil, unsupportedResponseParameter("tool_choice")
|
|
}
|
|
switch stringFromAny(choice["type"]) {
|
|
case "function":
|
|
if err := validateFallbackObjectKeys(choice, "tool_choice", stringSet("type", "name")); err != nil {
|
|
return nil, err
|
|
}
|
|
if stringFromAny(choice["name"]) == "" {
|
|
return nil, unsupportedResponseParameter("tool_choice.name")
|
|
}
|
|
return map[string]any{"type": "function", "function": map[string]any{"name": choice["name"]}}, nil
|
|
case "custom":
|
|
if err := validateFallbackObjectKeys(choice, "tool_choice", stringSet("type", "name")); err != nil {
|
|
return nil, err
|
|
}
|
|
if stringFromAny(choice["name"]) == "" {
|
|
return nil, unsupportedResponseParameter("tool_choice.name")
|
|
}
|
|
return map[string]any{"type": "custom", "custom": map[string]any{"name": choice["name"]}}, nil
|
|
case "allowed_tools":
|
|
if err := validateFallbackObjectKeys(choice, "tool_choice", stringSet("type", "mode", "tools")); err != nil {
|
|
return nil, err
|
|
}
|
|
allowed := map[string]any{"mode": choice["mode"]}
|
|
tools, err := responseToolReferencesToChat(choice["tools"])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
allowed["tools"] = tools
|
|
return map[string]any{"type": "allowed_tools", "allowed_tools": allowed}, nil
|
|
default:
|
|
return nil, unsupportedResponseParameter("tool_choice.type")
|
|
}
|
|
}
|
|
|
|
func responseToolReferencesToChat(value any) ([]any, error) {
|
|
items, ok := value.([]any)
|
|
if !ok {
|
|
return nil, unsupportedResponseParameter("tool_choice.tools")
|
|
}
|
|
out := make([]any, 0, len(items))
|
|
for index, raw := range items {
|
|
tool, ok := raw.(map[string]any)
|
|
if !ok {
|
|
return nil, unsupportedResponseParameter(fmt.Sprintf("tool_choice.tools[%d]", index))
|
|
}
|
|
switch stringFromAny(tool["type"]) {
|
|
case "function":
|
|
out = append(out, map[string]any{"type": "function", "function": map[string]any{"name": tool["name"]}})
|
|
case "custom":
|
|
out = append(out, map[string]any{"type": "custom", "custom": map[string]any{"name": tool["name"]}})
|
|
default:
|
|
return nil, unsupportedResponseParameter(fmt.Sprintf("tool_choice.tools[%d].type", index))
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func responseTextParams(value any) (map[string]any, any, error) {
|
|
text, ok := value.(map[string]any)
|
|
if !ok {
|
|
return nil, nil, unsupportedResponseParameter("text")
|
|
}
|
|
for key, value := range text {
|
|
if key != "format" && key != "verbosity" && !responseFallbackNoop(value) {
|
|
return nil, nil, unsupportedResponseParameter("text." + key)
|
|
}
|
|
}
|
|
verbosity := text["verbosity"]
|
|
format, ok := text["format"].(map[string]any)
|
|
if !ok || len(format) == 0 {
|
|
return nil, verbosity, nil
|
|
}
|
|
switch stringFromAny(format["type"]) {
|
|
case "text":
|
|
if err := validateFallbackObjectKeys(format, "text.format", stringSet("type")); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return map[string]any{"type": "text"}, verbosity, nil
|
|
case "json_object":
|
|
if err := validateFallbackObjectKeys(format, "text.format", stringSet("type")); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return map[string]any{"type": "json_object"}, verbosity, nil
|
|
case "json_schema":
|
|
if err := validateFallbackObjectKeys(format, "text.format", stringSet("type", "name", "description", "schema", "strict")); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
jsonSchema := map[string]any{}
|
|
for _, key := range []string{"name", "description", "schema", "strict"} {
|
|
if value, ok := format[key]; ok {
|
|
jsonSchema[key] = value
|
|
}
|
|
}
|
|
return map[string]any{"type": "json_schema", "json_schema": jsonSchema}, verbosity, nil
|
|
default:
|
|
return nil, nil, unsupportedResponseParameter("text.format.type")
|
|
}
|
|
}
|
|
|
|
func chatAssistantMessage(internal map[string]any, visible map[string]any) map[string]any {
|
|
if choices, ok := internal["choices"].([]any); ok && len(choices) > 0 {
|
|
choice, _ := choices[0].(map[string]any)
|
|
if message, ok := choice["message"].(map[string]any); ok {
|
|
return cloneMapAny(message)
|
|
}
|
|
}
|
|
output, _ := visible["output"].([]any)
|
|
message := map[string]any{"role": "assistant"}
|
|
textParts := make([]string, 0)
|
|
toolCalls := make([]any, 0)
|
|
refusals := make([]string, 0)
|
|
for _, raw := range output {
|
|
item, _ := raw.(map[string]any)
|
|
switch stringFromAny(item["type"]) {
|
|
case "message":
|
|
content, _ := item["content"].([]any)
|
|
for _, rawContent := range content {
|
|
part, _ := rawContent.(map[string]any)
|
|
if stringFromAny(part["type"]) == "output_text" {
|
|
textParts = append(textParts, stringFromAny(part["text"]))
|
|
} else if stringFromAny(part["type"]) == "refusal" {
|
|
refusals = append(refusals, stringFromAny(part["refusal"]))
|
|
}
|
|
}
|
|
case "function_call":
|
|
toolCalls = append(toolCalls, map[string]any{
|
|
"id": item["call_id"], "type": "function",
|
|
"function": map[string]any{"name": item["name"], "arguments": item["arguments"]},
|
|
})
|
|
case "custom_tool_call":
|
|
toolCalls = append(toolCalls, map[string]any{
|
|
"id": item["call_id"], "type": "custom",
|
|
"custom": map[string]any{"name": item["name"], "input": item["input"]},
|
|
})
|
|
}
|
|
}
|
|
message["content"] = strings.Join(textParts, "")
|
|
if len(refusals) > 0 {
|
|
message["refusal"] = strings.Join(refusals, "")
|
|
}
|
|
if len(toolCalls) > 0 {
|
|
message["tool_calls"] = toolCalls
|
|
}
|
|
if len(textParts) == 0 && len(refusals) == 0 && len(toolCalls) == 0 {
|
|
return nil
|
|
}
|
|
return message
|
|
}
|
|
|
|
func ChatResultToResponse(chat map[string]any, publicID string, model string, requestBody map[string]any) map[string]any {
|
|
createdAt := time.Now().Unix()
|
|
if value := intFromAny(chat["created"]); value > 0 {
|
|
createdAt = int64(value)
|
|
}
|
|
output := make([]any, 0)
|
|
outputText := ""
|
|
choices, _ := chat["choices"].([]any)
|
|
if len(choices) > 0 {
|
|
choice, _ := choices[0].(map[string]any)
|
|
message, _ := choice["message"].(map[string]any)
|
|
content := visibleChatContent(message["content"])
|
|
refusal := stringFromAny(message["refusal"])
|
|
if content != "" || refusal != "" {
|
|
outputText = content
|
|
parts := make([]any, 0, 2)
|
|
if content != "" {
|
|
annotations, _ := message["annotations"].([]any)
|
|
if annotations == nil {
|
|
annotations = []any{}
|
|
}
|
|
parts = append(parts, map[string]any{
|
|
"type": "output_text", "text": content, "annotations": annotations, "logprobs": responseLogprobsFromChoice(choice),
|
|
})
|
|
}
|
|
if refusal != "" {
|
|
parts = append(parts, map[string]any{"type": "refusal", "refusal": refusal})
|
|
}
|
|
output = append(output, map[string]any{
|
|
"id": "msg_" + responseIDSuffix(publicID), "type": "message", "status": "completed", "role": "assistant",
|
|
"content": parts,
|
|
})
|
|
}
|
|
toolCalls, _ := message["tool_calls"].([]any)
|
|
if functionCall, ok := message["function_call"].(map[string]any); ok {
|
|
legacyCall := map[string]any{
|
|
"id": "call_legacy", "type": "function", "function": functionCall,
|
|
}
|
|
toolCalls = append([]any{legacyCall}, toolCalls...)
|
|
}
|
|
for index, rawToolCall := range toolCalls {
|
|
toolCall, _ := rawToolCall.(map[string]any)
|
|
callID := firstNonEmptyString(toolCall["id"], fmt.Sprintf("call_%d", index))
|
|
if stringFromAny(toolCall["type"]) == "custom" {
|
|
custom, _ := toolCall["custom"].(map[string]any)
|
|
output = append(output, map[string]any{
|
|
"id": "ctc_" + responseIDSuffix(publicID) + fmt.Sprintf("_%d", index), "type": "custom_tool_call",
|
|
"status": "completed", "call_id": callID, "name": stringFromAny(custom["name"]), "input": stringFromAny(custom["input"]),
|
|
})
|
|
continue
|
|
}
|
|
function, _ := toolCall["function"].(map[string]any)
|
|
output = append(output, map[string]any{
|
|
"id": "fc_" + responseIDSuffix(publicID) + fmt.Sprintf("_%d", index), "type": "function_call",
|
|
"status": "completed", "call_id": callID, "name": stringFromAny(function["name"]), "arguments": stringFromAny(function["arguments"]),
|
|
})
|
|
}
|
|
}
|
|
usage := responseUsageFromChat(chat["usage"])
|
|
out := map[string]any{
|
|
"id": publicID, "object": "response", "created_at": createdAt, "status": "completed",
|
|
"completed_at": createdAt, "model": model, "output": output, "output_text": outputText,
|
|
"error": nil, "incomplete_details": nil, "usage": usage,
|
|
"background": false, "conversation": nil, "max_output_tokens": nil, "max_tool_calls": nil,
|
|
"moderation": nil, "prompt": nil, "prompt_cache_key": nil, "prompt_cache_options": nil,
|
|
"prompt_cache_retention": nil, "reasoning": nil, "safety_identifier": nil,
|
|
"service_tier": nil, "text": nil, "top_logprobs": nil, "truncation": "disabled", "user": nil,
|
|
"instructions": nil, "metadata": map[string]any{}, "parallel_tool_calls": true,
|
|
"temperature": nil, "tool_choice": "auto", "tools": []any{}, "top_p": nil,
|
|
}
|
|
if previousResponseID := strings.TrimSpace(stringFromAny(requestBody["previous_response_id"])); previousResponseID != "" {
|
|
out["previous_response_id"] = previousResponseID
|
|
} else {
|
|
out["previous_response_id"] = nil
|
|
}
|
|
applyResponseFinishReason(out, choices)
|
|
for _, key := range []string{
|
|
"background", "conversation", "instructions", "max_output_tokens", "max_tool_calls", "metadata", "moderation",
|
|
"parallel_tool_calls", "prompt", "prompt_cache_key", "prompt_cache_options", "prompt_cache_retention", "reasoning",
|
|
"safety_identifier", "service_tier", "temperature", "text", "tool_choice", "tools", "top_logprobs", "top_p", "truncation", "user",
|
|
} {
|
|
if value, ok := requestBody[key]; ok {
|
|
out[key] = value
|
|
}
|
|
}
|
|
for _, key := range []string{"service_tier", "moderation"} {
|
|
if value, ok := chat[key]; ok {
|
|
out[key] = value
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func responseLogprobsFromChoice(choice map[string]any) []any {
|
|
logprobs, _ := choice["logprobs"].(map[string]any)
|
|
content, _ := logprobs["content"].([]any)
|
|
if content == nil {
|
|
return []any{}
|
|
}
|
|
return content
|
|
}
|
|
|
|
func applyResponseFinishReason(response map[string]any, choices []any) {
|
|
if len(choices) == 0 {
|
|
return
|
|
}
|
|
choice, _ := choices[0].(map[string]any)
|
|
switch strings.TrimSpace(stringFromAny(choice["finish_reason"])) {
|
|
case "length":
|
|
response["status"] = "incomplete"
|
|
response["incomplete_details"] = map[string]any{"reason": "max_output_tokens"}
|
|
case "content_filter":
|
|
response["status"] = "incomplete"
|
|
response["incomplete_details"] = map[string]any{"reason": "content_filter"}
|
|
}
|
|
if response["status"] == "incomplete" {
|
|
response["completed_at"] = nil
|
|
}
|
|
}
|
|
|
|
func responseUsageFromChat(value any) map[string]any {
|
|
usage, _ := value.(map[string]any)
|
|
normalized := usageFromOpenAIUsage(usage)
|
|
return map[string]any{
|
|
"input_tokens": normalized.InputTokens,
|
|
"input_tokens_details": map[string]any{"cached_tokens": normalized.CachedInputTokens},
|
|
"output_tokens": normalized.OutputTokens,
|
|
"output_tokens_details": map[string]any{"reasoning_tokens": reasoningTokensFromUsage(usage)},
|
|
"total_tokens": normalized.TotalTokens,
|
|
}
|
|
}
|
|
|
|
func reasoningTokensFromUsage(usage map[string]any) int {
|
|
for _, key := range []string{"completion_tokens_details", "output_tokens_details"} {
|
|
if details, ok := usage[key].(map[string]any); ok {
|
|
if value := intFromAny(details["reasoning_tokens"]); value > 0 {
|
|
return value
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func visibleChatContent(value any) string {
|
|
if text, ok := value.(string); ok {
|
|
return text
|
|
}
|
|
items, _ := value.([]any)
|
|
parts := make([]string, 0, len(items))
|
|
for _, raw := range items {
|
|
item, _ := raw.(map[string]any)
|
|
if text := stringFromAny(firstPresent(item["text"], item["content"])); text != "" && !isReasoningContentBlock(item) {
|
|
parts = append(parts, text)
|
|
}
|
|
}
|
|
return strings.Join(parts, "")
|
|
}
|
|
|
|
func decodeNativeResponsesStream(resp *http.Response, onDelta StreamDelta) (map[string]any, string, error) {
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 16*1024*1024))
|
|
return nil, "", &ClientError{Code: statusCodeName(resp.StatusCode), Message: errorMessage(raw, resp.Status), StatusCode: resp.StatusCode, RequestID: requestIDFromHTTPResponse(resp), Retryable: HTTPRetryable(resp.StatusCode)}
|
|
}
|
|
scanner := bufio.NewScanner(resp.Body)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024)
|
|
var completed map[string]any
|
|
upstreamID := ""
|
|
rawLines := make([]string, 0)
|
|
eventName := ""
|
|
dataLines := make([]string, 0)
|
|
processFrame := func() error {
|
|
payload := strings.TrimSpace(strings.Join(dataLines, "\n"))
|
|
frameEventName := eventName
|
|
eventName = ""
|
|
dataLines = dataLines[:0]
|
|
if payload == "" || payload == "[DONE]" {
|
|
return nil
|
|
}
|
|
var event map[string]any
|
|
if err := json.Unmarshal([]byte(payload), &event); err != nil {
|
|
return nil
|
|
}
|
|
if stringFromAny(event["type"]) == "" && frameEventName != "" {
|
|
event["type"] = frameEventName
|
|
}
|
|
if failure := responseStreamError(event); failure != nil {
|
|
return failure
|
|
}
|
|
if response, ok := event["response"].(map[string]any); ok && upstreamID == "" {
|
|
upstreamID = stringFromAny(response["id"])
|
|
}
|
|
eventType := stringFromAny(event["type"])
|
|
if eventType == "response.completed" || eventType == "response.incomplete" || eventType == "response.cancelled" {
|
|
completed, _ = event["response"].(map[string]any)
|
|
if onDelta != nil {
|
|
return onDelta(StreamDeltaEvent{Event: event})
|
|
}
|
|
return nil
|
|
}
|
|
if eventType == "response.failed" {
|
|
if onDelta != nil {
|
|
if err := onDelta(StreamDeltaEvent{Event: event}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
failed, _ := event["response"].(map[string]any)
|
|
failure, _ := failed["error"].(map[string]any)
|
|
code := firstNonEmptyString(failure["code"], "response_failed")
|
|
message := firstNonEmptyString(failure["message"], "upstream Responses request failed")
|
|
return &ClientError{Code: code, Message: message, StatusCode: http.StatusBadRequest, Retryable: false}
|
|
}
|
|
if onDelta != nil {
|
|
return onDelta(StreamDeltaEvent{Text: nativeResponseDeltaText(event), Event: event})
|
|
}
|
|
return nil
|
|
}
|
|
for scanner.Scan() {
|
|
rawLine := scanner.Text()
|
|
rawLines = append(rawLines, rawLine)
|
|
line := strings.TrimSpace(rawLine)
|
|
if line == "" {
|
|
if err := processFrame(); err != nil {
|
|
return nil, upstreamID, err
|
|
}
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, "event:") {
|
|
eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
|
|
continue
|
|
}
|
|
if strings.HasPrefix(line, "data:") {
|
|
dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
|
|
}
|
|
}
|
|
if err := processFrame(); err != nil {
|
|
return nil, upstreamID, err
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return nil, upstreamID, &ClientError{Code: "stream_read_error", Message: err.Error(), Retryable: true}
|
|
}
|
|
if completed == nil {
|
|
raw := strings.TrimSpace(strings.Join(rawLines, "\n"))
|
|
var response map[string]any
|
|
if raw != "" && json.Unmarshal([]byte(raw), &response) == nil {
|
|
if failure := responseStreamError(response); failure != nil {
|
|
return nil, upstreamID, failure
|
|
}
|
|
if stringFromAny(response["object"]) == "response" {
|
|
upstreamID = stringFromAny(response["id"])
|
|
return response, upstreamID, nil
|
|
}
|
|
}
|
|
return nil, upstreamID, &ClientError{Code: "invalid_response", Message: "Responses stream ended without a terminal response event", Retryable: false}
|
|
}
|
|
return completed, upstreamID, nil
|
|
}
|
|
|
|
func responseStreamError(event map[string]any) error {
|
|
if stringFromAny(event["type"]) != "error" && event["error"] == nil {
|
|
return nil
|
|
}
|
|
failure, _ := event["error"].(map[string]any)
|
|
if failure == nil {
|
|
failure = event
|
|
}
|
|
code := firstNonEmptyString(failure["code"], "response_failed")
|
|
message := firstNonEmptyString(failure["message"], "upstream Responses stream failed")
|
|
return &ClientError{Code: code, Message: message, StatusCode: http.StatusBadRequest, Retryable: false}
|
|
}
|
|
|
|
func nativeResponseDeltaText(event map[string]any) string {
|
|
if stringFromAny(event["type"]) == "response.output_text.delta" {
|
|
return stringFromAny(event["delta"])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
type chatResponsesStreamAdapter struct {
|
|
publicID string
|
|
model string
|
|
requestBody map[string]any
|
|
started bool
|
|
sequence int
|
|
nextOutput int
|
|
messageOutput int
|
|
nextContent int
|
|
textContentIndex int
|
|
refusalContentIndex int
|
|
messageAdded bool
|
|
textAdded bool
|
|
refusalAdded bool
|
|
text strings.Builder
|
|
refusal strings.Builder
|
|
tools map[int]*chatResponseStreamTool
|
|
}
|
|
|
|
type chatResponseStreamTool struct {
|
|
ItemID string
|
|
CallID string
|
|
Kind string
|
|
Name string
|
|
Payload strings.Builder
|
|
Added bool
|
|
OutputIndex int
|
|
}
|
|
|
|
func newChatResponsesStreamAdapter(publicID string, model string, requestBody ...map[string]any) *chatResponsesStreamAdapter {
|
|
var body map[string]any
|
|
if len(requestBody) > 0 {
|
|
body = requestBody[0]
|
|
}
|
|
return &chatResponsesStreamAdapter{
|
|
publicID: publicID, model: model, requestBody: body, messageOutput: -1,
|
|
textContentIndex: -1, refusalContentIndex: -1, tools: map[int]*chatResponseStreamTool{},
|
|
}
|
|
}
|
|
|
|
func (a *chatResponsesStreamAdapter) emit(onDelta StreamDelta, event map[string]any) error {
|
|
if onDelta == nil {
|
|
return nil
|
|
}
|
|
event["sequence_number"] = a.sequence
|
|
a.sequence++
|
|
return onDelta(StreamDeltaEvent{Text: nativeResponseDeltaText(event), Event: event})
|
|
}
|
|
|
|
func (a *chatResponsesStreamAdapter) start(onDelta StreamDelta) error {
|
|
if a.started || onDelta == nil {
|
|
return nil
|
|
}
|
|
a.started = true
|
|
response := ChatResultToResponse(map[string]any{}, a.publicID, a.model, a.requestBody)
|
|
response["status"] = "in_progress"
|
|
response["completed_at"] = nil
|
|
response["output"] = []any{}
|
|
response["output_text"] = ""
|
|
response["usage"] = nil
|
|
for _, eventType := range []string{"response.created", "response.in_progress"} {
|
|
if err := a.emit(onDelta, map[string]any{"type": eventType, "response": response}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *chatResponsesStreamAdapter) delta(event StreamDeltaEvent, onDelta StreamDelta) error {
|
|
if onDelta == nil || event.Event == nil {
|
|
return nil
|
|
}
|
|
if err := a.start(onDelta); err != nil {
|
|
return err
|
|
}
|
|
choices, _ := event.Event["choices"].([]any)
|
|
for _, rawChoice := range choices {
|
|
choice, _ := rawChoice.(map[string]any)
|
|
delta, _ := choice["delta"].(map[string]any)
|
|
if content := stringFromAny(delta["content"]); content != "" {
|
|
if err := a.ensureMessage(onDelta); err != nil {
|
|
return err
|
|
}
|
|
if !a.textAdded {
|
|
a.textAdded = true
|
|
a.textContentIndex = a.nextContent
|
|
a.nextContent++
|
|
itemID := "msg_" + responseIDSuffix(a.publicID)
|
|
if err := a.emit(onDelta, map[string]any{
|
|
"type": "response.content_part.added", "response_id": a.publicID, "item_id": itemID,
|
|
"output_index": a.messageOutput, "content_index": a.textContentIndex,
|
|
"part": map[string]any{"type": "output_text", "text": "", "annotations": []any{}, "logprobs": []any{}},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
a.text.WriteString(content)
|
|
textEvent := map[string]any{
|
|
"type": "response.output_text.delta", "response_id": a.publicID,
|
|
"item_id": "msg_" + responseIDSuffix(a.publicID), "output_index": a.messageOutput, "content_index": a.textContentIndex, "delta": content,
|
|
}
|
|
if logprobs := responseStreamLogprobs(choice); len(logprobs) > 0 {
|
|
textEvent["logprobs"] = logprobs
|
|
}
|
|
if err := a.emit(onDelta, textEvent); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if refusal := stringFromAny(delta["refusal"]); refusal != "" {
|
|
if err := a.ensureMessage(onDelta); err != nil {
|
|
return err
|
|
}
|
|
if !a.refusalAdded {
|
|
a.refusalAdded = true
|
|
a.refusalContentIndex = a.nextContent
|
|
a.nextContent++
|
|
if err := a.emit(onDelta, map[string]any{
|
|
"type": "response.content_part.added", "response_id": a.publicID, "item_id": "msg_" + responseIDSuffix(a.publicID),
|
|
"output_index": a.messageOutput, "content_index": a.refusalContentIndex,
|
|
"part": map[string]any{"type": "refusal", "refusal": ""},
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
a.refusal.WriteString(refusal)
|
|
if err := a.emit(onDelta, map[string]any{
|
|
"type": "response.refusal.delta", "response_id": a.publicID, "item_id": "msg_" + responseIDSuffix(a.publicID),
|
|
"output_index": a.messageOutput, "content_index": a.refusalContentIndex, "delta": refusal,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
toolCalls := streamToolCallsFromDelta(delta)
|
|
for _, rawToolCall := range toolCalls {
|
|
toolCall, _ := rawToolCall.(map[string]any)
|
|
index := intFromAny(toolCall["index"])
|
|
tool := a.tools[index]
|
|
if tool == nil {
|
|
kind := "function"
|
|
prefix := "fc"
|
|
if stringFromAny(toolCall["type"]) == "custom" || toolCall["custom"] != nil {
|
|
kind = "custom"
|
|
prefix = "ctc"
|
|
}
|
|
tool = &chatResponseStreamTool{ItemID: fmt.Sprintf("%s_%s_%d", prefix, responseIDSuffix(a.publicID), index), Kind: kind, OutputIndex: a.nextOutput}
|
|
a.nextOutput++
|
|
a.tools[index] = tool
|
|
}
|
|
if callID := stringFromAny(toolCall["id"]); callID != "" {
|
|
tool.CallID = callID
|
|
}
|
|
payloadContainer, _ := toolCall[tool.Kind].(map[string]any)
|
|
if name := stringFromAny(payloadContainer["name"]); name != "" {
|
|
tool.Name += name
|
|
}
|
|
payloadKey := "arguments"
|
|
if tool.Kind == "custom" {
|
|
payloadKey = "input"
|
|
}
|
|
payload := stringFromAny(payloadContainer[payloadKey])
|
|
if !tool.Added && (tool.CallID != "" || tool.Name != "" || payload != "") {
|
|
tool.Added = true
|
|
item := map[string]any{"id": tool.ItemID, "status": "in_progress", "call_id": tool.CallID, "name": tool.Name}
|
|
if tool.Kind == "custom" {
|
|
item["type"] = "custom_tool_call"
|
|
item["input"] = ""
|
|
} else {
|
|
item["type"] = "function_call"
|
|
item["arguments"] = ""
|
|
}
|
|
if err := a.emit(onDelta, map[string]any{
|
|
"type": "response.output_item.added", "response_id": a.publicID, "output_index": tool.OutputIndex,
|
|
"item": item,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if payload != "" {
|
|
tool.Payload.WriteString(payload)
|
|
eventType := "response.function_call_arguments.delta"
|
|
if tool.Kind == "custom" {
|
|
eventType = "response.custom_tool_call_input.delta"
|
|
}
|
|
if err := a.emit(onDelta, map[string]any{
|
|
"type": eventType, "response_id": a.publicID, "item_id": tool.ItemID, "output_index": tool.OutputIndex, "delta": payload,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *chatResponsesStreamAdapter) ensureMessage(onDelta StreamDelta) error {
|
|
if a.messageAdded {
|
|
return nil
|
|
}
|
|
a.messageAdded = true
|
|
a.messageOutput = a.nextOutput
|
|
a.nextOutput++
|
|
return a.emit(onDelta, map[string]any{
|
|
"type": "response.output_item.added", "response_id": a.publicID, "output_index": a.messageOutput,
|
|
"item": map[string]any{"id": "msg_" + responseIDSuffix(a.publicID), "type": "message", "status": "in_progress", "role": "assistant", "content": []any{}},
|
|
})
|
|
}
|
|
|
|
func responseStreamLogprobs(choice map[string]any) []any {
|
|
logprobs, _ := choice["logprobs"].(map[string]any)
|
|
content, _ := logprobs["content"].([]any)
|
|
return content
|
|
}
|
|
|
|
func (a *chatResponsesStreamAdapter) done(result map[string]any, onDelta StreamDelta) error {
|
|
if onDelta == nil {
|
|
return nil
|
|
}
|
|
if err := a.start(onDelta); err != nil {
|
|
return err
|
|
}
|
|
a.alignOutput(result)
|
|
if a.textAdded {
|
|
text := a.text.String()
|
|
itemID := "msg_" + responseIDSuffix(a.publicID)
|
|
if err := a.emit(onDelta, map[string]any{"type": "response.output_text.done", "response_id": a.publicID, "item_id": itemID, "output_index": a.messageOutput, "content_index": a.textContentIndex, "text": text}); err != nil {
|
|
return err
|
|
}
|
|
if err := a.emit(onDelta, map[string]any{
|
|
"type": "response.content_part.done", "response_id": a.publicID, "item_id": itemID,
|
|
"output_index": a.messageOutput, "content_index": a.textContentIndex,
|
|
"part": responseMessagePart(result, "output_text"),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if a.refusalAdded {
|
|
refusal := a.refusal.String()
|
|
itemID := "msg_" + responseIDSuffix(a.publicID)
|
|
if err := a.emit(onDelta, map[string]any{"type": "response.refusal.done", "response_id": a.publicID, "item_id": itemID, "output_index": a.messageOutput, "content_index": a.refusalContentIndex, "refusal": refusal}); err != nil {
|
|
return err
|
|
}
|
|
if err := a.emit(onDelta, map[string]any{
|
|
"type": "response.content_part.done", "response_id": a.publicID, "item_id": itemID,
|
|
"output_index": a.messageOutput, "content_index": a.refusalContentIndex, "part": responseMessagePart(result, "refusal"),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if a.messageAdded {
|
|
if err := a.emit(onDelta, map[string]any{"type": "response.output_item.done", "response_id": a.publicID, "output_index": a.messageOutput, "item": firstResponseOutputItem(result, "message")}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
tools := make([]*chatResponseStreamTool, 0, len(a.tools))
|
|
for _, tool := range a.tools {
|
|
tools = append(tools, tool)
|
|
}
|
|
sort.Slice(tools, func(i, j int) bool { return tools[i].OutputIndex < tools[j].OutputIndex })
|
|
for _, tool := range tools {
|
|
payload := tool.Payload.String()
|
|
eventType := "response.function_call_arguments.done"
|
|
payloadKey := "arguments"
|
|
if tool.Kind == "custom" {
|
|
eventType = "response.custom_tool_call_input.done"
|
|
payloadKey = "input"
|
|
}
|
|
if err := a.emit(onDelta, map[string]any{"type": eventType, "response_id": a.publicID, "item_id": tool.ItemID, "output_index": tool.OutputIndex, payloadKey: payload}); err != nil {
|
|
return err
|
|
}
|
|
if err := a.emit(onDelta, map[string]any{"type": "response.output_item.done", "response_id": a.publicID, "output_index": tool.OutputIndex, "item": responseOutputItemByID(result, tool.ItemID)}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
terminalType := "response.completed"
|
|
if stringFromAny(result["status"]) == "incomplete" {
|
|
terminalType = "response.incomplete"
|
|
}
|
|
return a.emit(onDelta, map[string]any{"type": terminalType, "response": result})
|
|
}
|
|
|
|
func responseMessagePart(response map[string]any, partType string) any {
|
|
message, _ := firstResponseOutputItem(response, "message").(map[string]any)
|
|
parts, _ := message["content"].([]any)
|
|
for _, raw := range parts {
|
|
part, _ := raw.(map[string]any)
|
|
if stringFromAny(part["type"]) == partType {
|
|
return part
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *chatResponsesStreamAdapter) alignOutput(response map[string]any) {
|
|
items, _ := response["output"].([]any)
|
|
if len(items) == 0 || a.nextOutput == 0 {
|
|
return
|
|
}
|
|
ordered := make([]any, a.nextOutput)
|
|
if a.messageOutput >= 0 {
|
|
ordered[a.messageOutput] = firstResponseOutputItem(response, "message")
|
|
}
|
|
for _, tool := range a.tools {
|
|
ordered[tool.OutputIndex] = responseOutputItemByID(response, tool.ItemID)
|
|
}
|
|
result := make([]any, 0, len(ordered))
|
|
for _, item := range ordered {
|
|
if item != nil {
|
|
result = append(result, item)
|
|
}
|
|
}
|
|
response["output"] = result
|
|
}
|
|
|
|
func firstResponseOutputItem(response map[string]any, itemType string) any {
|
|
items, _ := response["output"].([]any)
|
|
for _, raw := range items {
|
|
item, _ := raw.(map[string]any)
|
|
if stringFromAny(item["type"]) == itemType {
|
|
return item
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func responseOutputItemByID(response map[string]any, id string) any {
|
|
items, _ := response["output"].([]any)
|
|
for _, raw := range items {
|
|
item, _ := raw.(map[string]any)
|
|
if stringFromAny(item["id"]) == id {
|
|
return item
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func responseIDSuffix(publicID string) string {
|
|
return strings.TrimPrefix(publicID, "resp_")
|
|
}
|
|
|
|
func unsupportedResponseParameter(parameter string) error {
|
|
return &ClientError{
|
|
Code: "unsupported_response_parameter", Message: "Chat fallback does not support Responses parameter: " + parameter,
|
|
Param: parameter, StatusCode: http.StatusBadRequest, Retryable: false,
|
|
}
|
|
}
|