Files
oci-portal/internal/service/chatresponses.go
T
wangdefa c7cc5616ed
CI / test (push) Successful in 31s
Release / release (push) Successful in 48s
发布 0.3.0:恢复 Chat 接口并增强云端事件通知
2026-07-13 10:13:44 +08:00

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 }