361 lines
12 KiB
Go
361 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"oci-portal/internal/aiwire"
|
|
)
|
|
|
|
// OpenAI Chat Completions ↔ OCI OpenAI 兼容面(/actions/v1/responses)直通转换。
|
|
// 端点定位 Tier 2 兼容层(承接只会说 CC 的存量客户端),机制与 anthresponses.go
|
|
// 同构。语义损失(README 披露):stop / seed / n / penalty 等无对应字段,忽略;
|
|
// 上游 reasoning 输出项与增量事件丢弃;非 function 工具类型拒绝。
|
|
|
|
// ChatToResponsesBody 把 Chat Completions 请求转为直通 body(强制 store:false)。
|
|
func ChatToResponsesBody(req aiwire.ChatRequest) ([]byte, error) {
|
|
input, instructions, err := chatInputItems(req.Messages)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
body := map[string]any{"model": req.Model, "input": input, "store": false}
|
|
if instructions != "" {
|
|
body["instructions"] = instructions
|
|
}
|
|
chatSampling(body, req)
|
|
if err := chatBodyTools(body, req); err != nil {
|
|
return nil, err
|
|
}
|
|
if tf := chatTextFormat(req.ResponseFormat); tf != nil {
|
|
body["text"] = map[string]any{"format": tf}
|
|
}
|
|
if req.ReasoningEffort != "" {
|
|
body["reasoning"] = map[string]string{"effort": strings.ToLower(req.ReasoningEffort)}
|
|
}
|
|
if req.Stream {
|
|
body["stream"] = true
|
|
}
|
|
return json.Marshal(body)
|
|
}
|
|
|
|
// chatSampling 装配采样与输出预算;max_completion_tokens 优先于已弃用的 max_tokens。
|
|
func chatSampling(body map[string]any, req aiwire.ChatRequest) {
|
|
if req.MaxCompletionTokens != nil {
|
|
body["max_output_tokens"] = *req.MaxCompletionTokens
|
|
} else if req.MaxTokens != nil {
|
|
body["max_output_tokens"] = *req.MaxTokens
|
|
}
|
|
if req.Temperature != nil {
|
|
body["temperature"] = *req.Temperature
|
|
}
|
|
if req.TopP != nil {
|
|
body["top_p"] = *req.TopP
|
|
}
|
|
if req.ParallelToolCalls != nil {
|
|
body["parallel_tool_calls"] = *req.ParallelToolCalls
|
|
}
|
|
}
|
|
|
|
// chatBodyTools 装配工具与 tool_choice;非 function 工具类型拒绝
|
|
// (web_search 等新能力仅在 Responses / Messages 端点供给)。
|
|
func chatBodyTools(body map[string]any, req aiwire.ChatRequest) error {
|
|
if len(req.Tools) == 0 {
|
|
return nil
|
|
}
|
|
tools := make([]map[string]any, 0, len(req.Tools))
|
|
for _, t := range req.Tools {
|
|
if t.Type != "function" {
|
|
return fmt.Errorf("不支持的工具类型 %q:该端点仅支持 function 工具", t.Type)
|
|
}
|
|
tools = append(tools, map[string]any{"type": "function", "name": t.Function.Name,
|
|
"description": t.Function.Description, "parameters": t.Function.Parameters})
|
|
}
|
|
body["tools"] = tools
|
|
if tc := chatRespToolChoice(req.ToolChoice); tc != nil {
|
|
body["tool_choice"] = tc
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// chatInputItems 把消息序列展开为 Responses input 项与 instructions:
|
|
// system/developer 文本聚为 instructions;tool 消息为 function_call_output 项;
|
|
// 其余消息经 chatMessageItems 展开,保持相对顺序。
|
|
func chatInputItems(messages []aiwire.ChatMessage) ([]any, string, error) {
|
|
items := make([]any, 0, len(messages))
|
|
var sys []string
|
|
for _, m := range messages {
|
|
switch m.Role {
|
|
case "system", "developer":
|
|
if txt := m.Content.JoinText(); txt != "" {
|
|
sys = append(sys, txt)
|
|
}
|
|
case "tool":
|
|
items = append(items, map[string]any{"type": "function_call_output",
|
|
"call_id": m.ToolCallID, "output": m.Content.JoinText()})
|
|
default:
|
|
msgItems, err := chatMessageItems(m)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
items = append(items, msgItems...)
|
|
}
|
|
}
|
|
return items, strings.Join(sys, "\n\n"), nil
|
|
}
|
|
|
|
// chatMessageItems 把 user/assistant 消息转为 message 项;assistant 的
|
|
// tool_calls 追加为独立 function_call 项(内容在前,与原语序一致)。
|
|
func chatMessageItems(m aiwire.ChatMessage) ([]any, error) {
|
|
parts, err := chatContentParts(m.Role, m.Content)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]any, 0, 1+len(m.ToolCalls))
|
|
if len(parts) > 0 {
|
|
items = append(items, map[string]any{"role": m.Role, "content": parts})
|
|
}
|
|
for _, tc := range m.ToolCalls {
|
|
items = append(items, map[string]any{"type": "function_call", "call_id": tc.ID,
|
|
"name": tc.Function.Name, "arguments": tc.Function.Arguments})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
// chatContentParts 把消息内容转为 Responses 部件(文本按角色定型,图片转 input_image)。
|
|
func chatContentParts(role string, c aiwire.Content) ([]map[string]any, error) {
|
|
if !c.IsArray {
|
|
if c.Text == "" {
|
|
return nil, nil
|
|
}
|
|
return []map[string]any{respTextPart(role, c.Text)}, nil
|
|
}
|
|
parts := make([]map[string]any, 0, len(c.Parts))
|
|
for _, p := range c.Parts {
|
|
switch p.Type {
|
|
case "", "text":
|
|
parts = append(parts, respTextPart(role, p.Text))
|
|
case "image_url":
|
|
if p.ImageURL == nil || p.ImageURL.URL == "" {
|
|
return nil, fmt.Errorf("image_url 块缺少 url")
|
|
}
|
|
// detail 不透传:兼容面对该字段的支持无法证实(实测期间上游视觉请求不稳,
|
|
// 无从归因),它仅是质量提示,忽略合法且消除一个风险轴(README 披露)
|
|
parts = append(parts, map[string]any{"type": "input_image", "image_url": p.ImageURL.URL})
|
|
default:
|
|
return nil, ErrAiUnsupportedBlock
|
|
}
|
|
}
|
|
return parts, nil
|
|
}
|
|
|
|
// chatRespToolChoice 映射 tool_choice:string 形态(auto/none/required)原样;
|
|
// {"type":"function","function":{"name":N}} 拍平为 Responses 具名形态;其余忽略。
|
|
func chatRespToolChoice(raw json.RawMessage) any {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
var s string
|
|
if json.Unmarshal(raw, &s) == nil {
|
|
switch s {
|
|
case "auto", "none", "required":
|
|
return s
|
|
}
|
|
return nil
|
|
}
|
|
var tc struct {
|
|
Type string `json:"type"`
|
|
Function struct {
|
|
Name string `json:"name"`
|
|
} `json:"function"`
|
|
}
|
|
if json.Unmarshal(raw, &tc) != nil || tc.Type != "function" || tc.Function.Name == "" {
|
|
return nil
|
|
}
|
|
return map[string]string{"type": "function", "name": tc.Function.Name}
|
|
}
|
|
|
|
// chatTextFormat 把 response_format 拍平为 text.format(json_schema 提升嵌套字段)。
|
|
func chatTextFormat(rf *aiwire.ResponseFormat) map[string]any {
|
|
if rf == nil || rf.Type == "" || rf.Type == "text" {
|
|
return nil
|
|
}
|
|
format := map[string]any{"type": rf.Type}
|
|
if rf.Type != "json_schema" || len(rf.JSONSchema) == 0 {
|
|
return format
|
|
}
|
|
var js struct {
|
|
Name string `json:"name"`
|
|
Schema json.RawMessage `json:"schema"`
|
|
Strict *bool `json:"strict"`
|
|
}
|
|
if json.Unmarshal(rf.JSONSchema, &js) != nil {
|
|
return format
|
|
}
|
|
if js.Name != "" {
|
|
format["name"] = js.Name
|
|
}
|
|
if len(js.Schema) > 0 {
|
|
format["schema"] = js.Schema
|
|
}
|
|
if js.Strict != nil {
|
|
format["strict"] = *js.Strict
|
|
}
|
|
return format
|
|
}
|
|
|
|
// ResponsesToChat 把直通非流式响应转为 Chat Completions 响应(reasoning 项丢弃)。
|
|
func ResponsesToChat(payload []byte, id string, created int64) (*aiwire.ChatResponse, error) {
|
|
var resp respPayload
|
|
if err := json.Unmarshal(payload, &resp); err != nil {
|
|
return nil, fmt.Errorf("解析上游响应: %w", err)
|
|
}
|
|
msg := aiwire.ChatMessage{Role: "assistant"}
|
|
var text strings.Builder
|
|
for _, item := range resp.Output {
|
|
switch item.Type {
|
|
case "message":
|
|
for _, part := range item.Content {
|
|
if part.Type == "output_text" {
|
|
text.WriteString(part.Text)
|
|
}
|
|
}
|
|
case "function_call":
|
|
msg.ToolCalls = append(msg.ToolCalls, aiwire.ToolCall{ID: item.CallID, Type: "function",
|
|
Function: aiwire.FunctionCall{Name: item.Name, Arguments: item.Arguments}})
|
|
}
|
|
}
|
|
msg.Content = aiwire.NewTextContent(text.String())
|
|
choice := aiwire.Choice{Index: 0, Message: msg,
|
|
FinishReason: chatFinishReason(&resp, len(msg.ToolCalls) > 0)}
|
|
return &aiwire.ChatResponse{ID: id, Object: "chat.completion", Created: created,
|
|
Model: resp.Model, Choices: []aiwire.Choice{choice}, Usage: usageFromResp(resp.Usage)}, nil
|
|
}
|
|
|
|
// chatFinishReason 映射终态:截断优先 → length;含工具调用 → tool_calls;默认 stop。
|
|
func chatFinishReason(resp *respPayload, hasTools bool) string {
|
|
if respTruncated(resp) {
|
|
return "length"
|
|
}
|
|
if hasTools {
|
|
return "tool_calls"
|
|
}
|
|
return "stop"
|
|
}
|
|
|
|
// respTruncated 报告直通响应是否因 max_output_tokens 截断。
|
|
func respTruncated(resp *respPayload) bool {
|
|
return resp != nil && resp.Status == "incomplete" && resp.IncompleteDetails != nil &&
|
|
resp.IncompleteDetails.Reason == "max_output_tokens"
|
|
}
|
|
|
|
// ---- Chat Completions 流式桥:直通 SSE 事件 → chat.completion.chunk 序列 ----
|
|
|
|
// ChatRespBridge 把直通 SSE 事件流桥接为 chunk 序列:首个增量补 role,文本与
|
|
// 工具增量按 OpenAI 形态输出;reasoning 系列事件丢弃;[DONE] 由传输层写出。
|
|
type ChatRespBridge struct {
|
|
id, model string
|
|
created int64
|
|
includeUsage bool
|
|
started bool
|
|
toolIndex int
|
|
inTool bool
|
|
finish string
|
|
usage *aiwire.Usage
|
|
}
|
|
|
|
// NewChatRespBridge 构造桥;includeUsage 即 stream_options.include_usage。
|
|
func NewChatRespBridge(id, model string, created int64, includeUsage bool) *ChatRespBridge {
|
|
return &ChatRespBridge{id: id, model: model, created: created,
|
|
includeUsage: includeUsage, toolIndex: -1, finish: "stop"}
|
|
}
|
|
|
|
// Feed 消费一行 SSE data JSON,返回应立即写出的 chunk。
|
|
func (b *ChatRespBridge) Feed(data []byte) []aiwire.ChatChunk {
|
|
var ev respStreamEvent
|
|
if json.Unmarshal(data, &ev) != nil {
|
|
return nil
|
|
}
|
|
switch ev.Type {
|
|
case "response.output_text.delta":
|
|
b.inTool = false
|
|
return []aiwire.ChatChunk{b.chunk(b.deltaWithRole(aiwire.Delta{Content: ev.Delta}), nil)}
|
|
case "response.output_item.added":
|
|
return b.toolOpen(ev.Item)
|
|
case "response.function_call_arguments.delta":
|
|
return b.argsChunk(ev.Delta)
|
|
case "response.completed", "response.incomplete", "response.failed":
|
|
b.finishFrom(ev.Response)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// chunk 装配一条单 choice 增量事件。
|
|
func (b *ChatRespBridge) chunk(delta aiwire.Delta, finish *string) aiwire.ChatChunk {
|
|
return aiwire.ChatChunk{ID: b.id, Object: "chat.completion.chunk", Created: b.created,
|
|
Model: b.model, Choices: []aiwire.ChunkChoice{{Index: 0, Delta: delta, FinishReason: finish}}}
|
|
}
|
|
|
|
// deltaWithRole 给首个增量补 role(OpenAI 首块携带 role 语义)。
|
|
func (b *ChatRespBridge) deltaWithRole(d aiwire.Delta) aiwire.Delta {
|
|
if !b.started {
|
|
b.started = true
|
|
d.Role = "assistant"
|
|
}
|
|
return d
|
|
}
|
|
|
|
// toolOpen 在新 function_call 输出项开始时发 id/name 增量并推进聚合 index;
|
|
// 非工具输出项只复位增量目标。
|
|
func (b *ChatRespBridge) toolOpen(item *respOutputItem) []aiwire.ChatChunk {
|
|
if item == nil || item.Type != "function_call" {
|
|
b.inTool = false
|
|
return nil
|
|
}
|
|
b.toolIndex++
|
|
b.inTool = true
|
|
b.finish = "tool_calls"
|
|
d := aiwire.Delta{ToolCalls: []aiwire.ToolCallDelta{{Index: b.toolIndex, ID: item.CallID,
|
|
Type: "function", Function: aiwire.FunctionCallDelta{Name: item.Name}}}}
|
|
return []aiwire.ChatChunk{b.chunk(b.deltaWithRole(d), nil)}
|
|
}
|
|
|
|
// argsChunk 输出当前工具的实参增量(仅在工具输出项进行中)。
|
|
func (b *ChatRespBridge) argsChunk(delta string) []aiwire.ChatChunk {
|
|
if !b.inTool {
|
|
return nil
|
|
}
|
|
d := aiwire.Delta{ToolCalls: []aiwire.ToolCallDelta{{Index: b.toolIndex,
|
|
Function: aiwire.FunctionCallDelta{Arguments: delta}}}}
|
|
return []aiwire.ChatChunk{b.chunk(d, nil)}
|
|
}
|
|
|
|
// finishFrom 记录终态 usage 与截断语义(max_output_tokens → length)。
|
|
func (b *ChatRespBridge) finishFrom(resp *respPayload) {
|
|
if resp == nil {
|
|
return
|
|
}
|
|
b.usage = usageFromResp(resp.Usage)
|
|
if respTruncated(resp) {
|
|
b.finish = "length"
|
|
}
|
|
}
|
|
|
|
// Finish 在上游流结束后收尾:终块带 finish_reason;include_usage 时追加 usage 块
|
|
// (choices 为空数组,OpenAI 规范形态;上游未给 usage 时输出零值)。
|
|
func (b *ChatRespBridge) Finish() []aiwire.ChatChunk {
|
|
chunks := []aiwire.ChatChunk{b.chunk(b.deltaWithRole(aiwire.Delta{}), &b.finish)}
|
|
if !b.includeUsage {
|
|
return chunks
|
|
}
|
|
usage := b.usage
|
|
if usage == nil {
|
|
usage = &aiwire.Usage{}
|
|
}
|
|
return append(chunks, aiwire.ChatChunk{ID: b.id, Object: "chat.completion.chunk",
|
|
Created: b.created, Model: b.model, Choices: []aiwire.ChunkChoice{}, Usage: usage})
|
|
}
|
|
|
|
// Usage 返回聚合到的用量(供调用日志),上游未报告时为 nil。
|
|
func (b *ChatRespBridge) Usage() *aiwire.Usage { return b.usage }
|