244 lines
8.0 KiB
Go
244 lines
8.0 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"oci-portal/internal/aiwire"
|
|
)
|
|
|
|
// ResponsesToIR 把 Responses API 请求转换为网关 IR(OpenAI Chat 形态)。
|
|
// 有状态特性与内置工具不支持,直接报错(API 层 400)。
|
|
func ResponsesToIR(req aiwire.RespRequest) (aiwire.ChatRequest, error) {
|
|
if err := respRejectUnsupported(req); err != nil {
|
|
return aiwire.ChatRequest{}, err
|
|
}
|
|
ir := aiwire.ChatRequest{
|
|
Model: req.Model,
|
|
MaxTokens: req.MaxOutputTokens,
|
|
Temperature: req.Temperature,
|
|
TopP: req.TopP,
|
|
Stream: req.Stream,
|
|
ToolChoice: respToolChoice(req.ToolChoice),
|
|
}
|
|
if req.Reasoning != nil {
|
|
ir.ReasoningEffort = req.Reasoning.Effort
|
|
}
|
|
if req.Instructions != "" {
|
|
ir.Messages = append(ir.Messages, aiwire.ChatMessage{Role: "system", Content: aiwire.NewTextContent(req.Instructions)})
|
|
}
|
|
msgs, err := respInputToMessages(req.Input)
|
|
if err != nil {
|
|
return aiwire.ChatRequest{}, err
|
|
}
|
|
ir.Messages = append(ir.Messages, msgs...)
|
|
ir.Tools = respTools(req.Tools)
|
|
ir.ResponseFormat = respFormat(req.Text)
|
|
return ir, nil
|
|
}
|
|
|
|
// respRejectUnsupported 拒绝无状态网关无法承接的请求特性。
|
|
func respRejectUnsupported(req aiwire.RespRequest) error {
|
|
if req.PreviousResponseID != "" {
|
|
return fmt.Errorf("previous_response_id 不支持:网关不保存历史响应,请在 input 中自带完整上下文(store:false 模式)")
|
|
}
|
|
if len(req.Conversation) > 0 && string(req.Conversation) != "null" {
|
|
return fmt.Errorf("conversation 不支持:网关不保存对话状态")
|
|
}
|
|
if req.Background != nil && *req.Background {
|
|
return fmt.Errorf("background 模式不支持")
|
|
}
|
|
for _, t := range req.Tools {
|
|
if t.Type != "function" {
|
|
return fmt.Errorf("不支持的工具类型 %q:仅支持 function 工具", t.Type)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// respInputToMessages 把 input(string 或 item 数组)展开为 IR 消息序列。
|
|
func respInputToMessages(in aiwire.RespInput) ([]aiwire.ChatMessage, error) {
|
|
if !in.IsArray {
|
|
if strings.TrimSpace(in.Text) == "" {
|
|
return nil, fmt.Errorf("input 不能为空")
|
|
}
|
|
return []aiwire.ChatMessage{{Role: "user", Content: aiwire.NewTextContent(in.Text)}}, nil
|
|
}
|
|
var msgs []aiwire.ChatMessage
|
|
for _, it := range in.Items {
|
|
out, err := respItemToMessages(it, msgs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
msgs = out
|
|
}
|
|
return msgs, nil
|
|
}
|
|
|
|
// respItemToMessages 追加一个 input item;连续 function_call 合并进同一 assistant 消息。
|
|
func respItemToMessages(it aiwire.RespItem, msgs []aiwire.ChatMessage) ([]aiwire.ChatMessage, error) {
|
|
switch it.Type {
|
|
case "", "message":
|
|
if it.Content.HasUnsupported() {
|
|
return nil, ErrAiUnsupportedBlock
|
|
}
|
|
role := respRole(it.Role)
|
|
return append(msgs, aiwire.ChatMessage{Role: role, Content: respContentToIR(it.Content)}), nil
|
|
case "function_call":
|
|
call := aiwire.ToolCall{ID: it.CallID, Type: "function",
|
|
Function: aiwire.FunctionCall{Name: it.Name, Arguments: it.Arguments}}
|
|
if n := len(msgs); n > 0 && msgs[n-1].Role == "assistant" && len(msgs[n-1].ToolCalls) > 0 {
|
|
msgs[n-1].ToolCalls = append(msgs[n-1].ToolCalls, call)
|
|
return msgs, nil
|
|
}
|
|
return append(msgs, aiwire.ChatMessage{Role: "assistant", ToolCalls: []aiwire.ToolCall{call}}), nil
|
|
case "function_call_output":
|
|
return append(msgs, aiwire.ChatMessage{Role: "tool", ToolCallID: it.CallID,
|
|
Content: aiwire.NewTextContent(it.OutputText())}), nil
|
|
case "reasoning":
|
|
return msgs, nil // 推理块不回灌上游
|
|
default:
|
|
return nil, fmt.Errorf("不支持的 input item 类型 %q", it.Type)
|
|
}
|
|
}
|
|
|
|
// respRole 归一 item 角色;developer 视为 system,缺省 user。
|
|
func respRole(role string) string {
|
|
switch role {
|
|
case "system", "developer":
|
|
return "system"
|
|
case "assistant":
|
|
return "assistant"
|
|
default:
|
|
return "user"
|
|
}
|
|
}
|
|
|
|
// respContentToIR 把 Responses 内容转 IR;纯文本拍平,含图片时保留部件顺序。
|
|
func respContentToIR(c aiwire.RespContent) aiwire.Content {
|
|
var parts []aiwire.ContentPart
|
|
hasImage := false
|
|
for _, p := range c.Parts {
|
|
switch p.Type {
|
|
case "input_image":
|
|
parts = append(parts, aiwire.ContentPart{Type: "image_url",
|
|
ImageURL: &aiwire.ImageURL{URL: p.ImageURL, Detail: p.Detail}})
|
|
hasImage = true
|
|
default:
|
|
if p.Text != "" {
|
|
parts = append(parts, aiwire.ContentPart{Type: "text", Text: p.Text})
|
|
}
|
|
}
|
|
}
|
|
if !c.IsArray || !hasImage {
|
|
return aiwire.NewTextContent(c.JoinText())
|
|
}
|
|
return aiwire.NewPartsContent(parts)
|
|
}
|
|
|
|
// respTools 把扁平工具定义还原为嵌套 Chat 形态;strict 无 OCI 对应,忽略。
|
|
func respTools(tools []aiwire.RespTool) []aiwire.Tool {
|
|
if len(tools) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]aiwire.Tool, 0, len(tools))
|
|
for _, t := range tools {
|
|
out = append(out, aiwire.Tool{Type: "function",
|
|
Function: aiwire.FunctionDef{Name: t.Name, Description: t.Description, Parameters: t.Parameters}})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// respToolChoice 转换 tool_choice:字符串直通;{type:function,name} 转嵌套;
|
|
// allowed_tools 等对象形态降级 auto。
|
|
func respToolChoice(raw json.RawMessage) json.RawMessage {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
t := strings.TrimSpace(string(raw))
|
|
if strings.HasPrefix(t, "\"") {
|
|
return raw
|
|
}
|
|
var obj struct {
|
|
Type string `json:"type"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.Unmarshal(raw, &obj); err != nil || obj.Type != "function" || obj.Name == "" {
|
|
return json.RawMessage(`"auto"`)
|
|
}
|
|
out, _ := json.Marshal(map[string]any{"type": "function", "function": map[string]string{"name": obj.Name}})
|
|
return out
|
|
}
|
|
|
|
// respFormat 把 text.format 转成 response_format;text 形态无需显式指定。
|
|
func respFormat(text *aiwire.RespText) *aiwire.ResponseFormat {
|
|
if text == nil || text.Format == nil {
|
|
return nil
|
|
}
|
|
switch text.Format.Type {
|
|
case "json_object":
|
|
return &aiwire.ResponseFormat{Type: "json_object"}
|
|
case "json_schema":
|
|
spec, _ := json.Marshal(map[string]any{
|
|
"name": text.Format.Name, "schema": json.RawMessage(text.Format.Schema), "strict": text.Format.Strict,
|
|
})
|
|
return &aiwire.ResponseFormat{Type: "json_schema", JSONSchema: spec}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// IRRespToResponses 把 IR 非流式响应装配为 Response 对象。
|
|
func IRRespToResponses(resp *aiwire.ChatResponse, id string, created int64) aiwire.Response {
|
|
out := aiwire.Response{ID: id, Object: "response", CreatedAt: created,
|
|
Status: "completed", Model: resp.Model, Output: []aiwire.RespOutItem{}, Store: false}
|
|
if len(resp.Choices) == 0 {
|
|
return out
|
|
}
|
|
choice := resp.Choices[0]
|
|
if text := choice.Message.Content.JoinText(); text != "" {
|
|
out.Output = append(out.Output, respMessageItem(id, 0, text))
|
|
}
|
|
for i, tc := range choice.Message.ToolCalls {
|
|
out.Output = append(out.Output, aiwire.RespOutItem{Type: "function_call",
|
|
ID: respItemID(id, "fc", len(out.Output)+i), Status: "completed",
|
|
CallID: tc.ID, Name: tc.Function.Name, Arguments: respArgs(tc.Function.Arguments)})
|
|
}
|
|
if choice.FinishReason == "length" {
|
|
out.Status = "incomplete"
|
|
out.IncompleteDetails = &aiwire.RespIncomplete{Reason: "max_output_tokens"}
|
|
}
|
|
out.Usage = respUsage(resp.Usage)
|
|
return out
|
|
}
|
|
|
|
func respMessageItem(respID string, idx int, text string) aiwire.RespOutItem {
|
|
return aiwire.RespOutItem{Type: "message", ID: respItemID(respID, "msg", idx),
|
|
Status: "completed", Role: "assistant",
|
|
Content: []aiwire.RespOutPart{{Type: "output_text", Text: text, Annotations: []any{}}}}
|
|
}
|
|
|
|
// respItemID 从响应 ID 派生确定性 item ID。
|
|
func respItemID(respID, kind string, idx int) string {
|
|
return fmt.Sprintf("%s_%s_%d", kind, strings.TrimPrefix(respID, "resp_"), idx)
|
|
}
|
|
|
|
// respArgs 保证 arguments 是合法 JSON 字符串(空实参回退 {})。
|
|
func respArgs(args string) string {
|
|
if strings.TrimSpace(args) == "" {
|
|
return "{}"
|
|
}
|
|
return args
|
|
}
|
|
|
|
// respUsage 把 IR 用量改写为 Responses 命名口径。
|
|
func respUsage(u *aiwire.Usage) *aiwire.RespUsage {
|
|
if u == nil {
|
|
return nil
|
|
}
|
|
return &aiwire.RespUsage{InputTokens: u.PromptTokens, OutputTokens: u.CompletionTokens,
|
|
TotalTokens: u.TotalTokens,
|
|
InputTokensDetails: aiwire.RespInDetails{CachedTokens: u.CachedTokens()}}
|
|
}
|