初始提交:OCI 面板后端(含 GenAI 网关一期)
This commit is contained in:
@@ -0,0 +1,332 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"oci-portal/internal/aiwire"
|
||||
)
|
||||
|
||||
// ErrAiUnsupportedBlock 表示请求含网关无法承接的内容块(仅支持文本与图片)。
|
||||
var ErrAiUnsupportedBlock = fmt.Errorf("暂不支持文本与图片以外的内容块")
|
||||
|
||||
// AnthropicToIR 把 Anthropic Messages 请求转为 IR(OpenAI 线格式)。
|
||||
// tool_result 块拆为独立 tool 角色消息(一拆多),顶层 system 变首条 system 消息。
|
||||
func AnthropicToIR(req aiwire.MessagesRequest) (aiwire.ChatRequest, error) {
|
||||
ir := aiwire.ChatRequest{
|
||||
Model: req.Model,
|
||||
Temperature: req.Temperature,
|
||||
TopP: req.TopP,
|
||||
TopK: req.TopK,
|
||||
Stop: req.StopSequences,
|
||||
Stream: req.Stream,
|
||||
Tools: anthTools(req.Tools),
|
||||
ToolChoice: anthToolChoice(req.ToolChoice),
|
||||
}
|
||||
if req.MaxTokens > 0 {
|
||||
mt := req.MaxTokens
|
||||
ir.MaxTokens = &mt
|
||||
}
|
||||
if sys := req.SystemText(); sys != "" {
|
||||
ir.Messages = append(ir.Messages, aiwire.ChatMessage{Role: "system", Content: aiwire.NewTextContent(sys)})
|
||||
}
|
||||
for _, m := range req.Messages {
|
||||
msgs, err := anthMessageToIR(m)
|
||||
if err != nil {
|
||||
return ir, err
|
||||
}
|
||||
ir.Messages = append(ir.Messages, msgs...)
|
||||
}
|
||||
return ir, nil
|
||||
}
|
||||
|
||||
// anthMessageToIR 拆解单条 Anthropic 消息;user 消息里的 tool_result 前置为独立 tool 消息,
|
||||
// image 块转为 IR image_url 部件。
|
||||
func anthMessageToIR(m aiwire.AnthMessage) ([]aiwire.ChatMessage, error) {
|
||||
var out []aiwire.ChatMessage
|
||||
var parts []aiwire.ContentPart
|
||||
var toolCalls []aiwire.ToolCall
|
||||
hasImage := false
|
||||
for _, b := range m.Content.AllBlocks() {
|
||||
switch b.Type {
|
||||
case "text":
|
||||
parts = append(parts, aiwire.ContentPart{Type: "text", Text: b.Text})
|
||||
case "image":
|
||||
p, err := anthImagePart(b.Source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
parts, hasImage = append(parts, p), true
|
||||
case "tool_use":
|
||||
toolCalls = append(toolCalls, aiwire.ToolCall{ID: b.ID, Type: "function",
|
||||
Function: aiwire.FunctionCall{Name: b.Name, Arguments: string(b.Input)}})
|
||||
case "tool_result":
|
||||
out = append(out, aiwire.ChatMessage{Role: "tool", ToolCallID: b.ToolUseID,
|
||||
Content: aiwire.NewTextContent(b.ResultText())})
|
||||
case "thinking", "redacted_thinking":
|
||||
// 一期忽略 thinking 块
|
||||
default:
|
||||
return nil, ErrAiUnsupportedBlock
|
||||
}
|
||||
}
|
||||
content := partsContent(parts, hasImage)
|
||||
if hasImage || content.JoinText() != "" || len(toolCalls) > 0 {
|
||||
out = append(out, aiwire.ChatMessage{Role: m.Role, Content: content, ToolCalls: toolCalls})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// anthImagePart 把 Anthropic image 块转为 IR image_url 部件(base64 → data URI)。
|
||||
func anthImagePart(source json.RawMessage) (aiwire.ContentPart, error) {
|
||||
var src struct {
|
||||
Type string `json:"type"`
|
||||
MediaType string `json:"media_type"`
|
||||
Data string `json:"data"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(source, &src); err != nil {
|
||||
return aiwire.ContentPart{}, fmt.Errorf("image source 解析失败: %w", err)
|
||||
}
|
||||
switch src.Type {
|
||||
case "base64":
|
||||
if src.MediaType == "" || src.Data == "" {
|
||||
return aiwire.ContentPart{}, fmt.Errorf("image source 缺少 media_type 或 data")
|
||||
}
|
||||
url := "data:" + src.MediaType + ";base64," + src.Data
|
||||
return aiwire.ContentPart{Type: "image_url", ImageURL: &aiwire.ImageURL{URL: url}}, nil
|
||||
case "url":
|
||||
if src.URL == "" {
|
||||
return aiwire.ContentPart{}, fmt.Errorf("image source 缺少 url")
|
||||
}
|
||||
return aiwire.ContentPart{Type: "image_url", ImageURL: &aiwire.ImageURL{URL: src.URL}}, nil
|
||||
}
|
||||
return aiwire.ContentPart{}, fmt.Errorf("不支持的 image source 类型 %q", src.Type)
|
||||
}
|
||||
|
||||
// partsContent 无图片时退回字符串形态(与上游线格式习惯一致),含图片时保留块数组。
|
||||
func partsContent(parts []aiwire.ContentPart, hasImage bool) aiwire.Content {
|
||||
if !hasImage {
|
||||
var sb strings.Builder
|
||||
for _, p := range parts {
|
||||
sb.WriteString(p.Text)
|
||||
}
|
||||
return aiwire.NewTextContent(sb.String())
|
||||
}
|
||||
return aiwire.NewPartsContent(parts)
|
||||
}
|
||||
|
||||
func anthTools(tools []aiwire.AnthTool) []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.InputSchema}})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// anthToolChoice 映射 {type:auto|any|tool,name} → OpenAI 形态。
|
||||
func anthToolChoice(raw json.RawMessage) json.RawMessage {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var tc struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if json.Unmarshal(raw, &tc) != nil {
|
||||
return nil
|
||||
}
|
||||
switch tc.Type {
|
||||
case "auto":
|
||||
return json.RawMessage(`"auto"`)
|
||||
case "any":
|
||||
return json.RawMessage(`"required"`)
|
||||
case "tool":
|
||||
b, _ := json.Marshal(map[string]any{"type": "function", "function": map[string]string{"name": tc.Name}})
|
||||
return b
|
||||
case "none":
|
||||
return json.RawMessage(`"none"`)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IRRespToAnthropic 把 IR 非流式响应转为 Anthropic Messages 响应。
|
||||
func IRRespToAnthropic(resp *aiwire.ChatResponse, id string) aiwire.MessagesResponse {
|
||||
out := aiwire.MessagesResponse{ID: id, Type: "message", Role: "assistant", Model: resp.Model, Content: []aiwire.AnthBlock{}}
|
||||
if len(resp.Choices) > 0 {
|
||||
choice := resp.Choices[0]
|
||||
if text := choice.Message.Content.JoinText(); text != "" {
|
||||
out.Content = append(out.Content, aiwire.AnthBlock{Type: "text", Text: text})
|
||||
}
|
||||
for _, tc := range choice.Message.ToolCalls {
|
||||
out.Content = append(out.Content, aiwire.AnthBlock{Type: "tool_use", ID: tc.ID,
|
||||
Name: tc.Function.Name, Input: argsToJSON(tc.Function.Arguments)})
|
||||
}
|
||||
out.StopReason = anthStopReason(choice.FinishReason)
|
||||
}
|
||||
if resp.Usage != nil {
|
||||
out.Usage = aiwire.AnthUsage{InputTokens: resp.Usage.PromptTokens, OutputTokens: resp.Usage.CompletionTokens,
|
||||
CacheReadInputTokens: resp.Usage.CachedTokens()}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// argsToJSON 保证 tool_use.input 是合法 JSON 对象(模型可能产出非法片段)。
|
||||
func argsToJSON(args string) json.RawMessage {
|
||||
trimmed := strings.TrimSpace(args)
|
||||
if trimmed == "" {
|
||||
return json.RawMessage(`{}`)
|
||||
}
|
||||
if json.Valid([]byte(trimmed)) {
|
||||
return json.RawMessage(trimmed)
|
||||
}
|
||||
b, _ := json.Marshal(map[string]string{"_raw": args})
|
||||
return b
|
||||
}
|
||||
|
||||
func anthStopReason(finish string) string {
|
||||
switch finish {
|
||||
case "length":
|
||||
return "max_tokens"
|
||||
case "tool_calls":
|
||||
return "tool_use"
|
||||
default:
|
||||
return "end_turn"
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Anthropic 流式状态机 ----
|
||||
|
||||
// AnthEvent 是一条待写出的 Anthropic SSE 事件。
|
||||
type AnthEvent struct {
|
||||
Event string
|
||||
Data any
|
||||
}
|
||||
|
||||
// AnthStream 把 IR chunk 流聚合为 Anthropic 事件序列:
|
||||
// message_start → content_block_start/delta/stop(text 与 tool_use 分块)→ message_delta → message_stop。
|
||||
type AnthStream struct {
|
||||
id, model string
|
||||
started bool
|
||||
blockOpen bool
|
||||
blockIsTool bool
|
||||
toolID string
|
||||
blockIndex int
|
||||
stopReason string
|
||||
usage aiwire.AnthUsage
|
||||
}
|
||||
|
||||
// NewAnthStream 构造状态机;id 为响应消息 ID。
|
||||
func NewAnthStream(id, model string) *AnthStream {
|
||||
return &AnthStream{id: id, model: model, blockIndex: -1, stopReason: "end_turn"}
|
||||
}
|
||||
|
||||
// Feed 消费一个 IR chunk,返回应立即写出的事件。
|
||||
func (st *AnthStream) Feed(chunk aiwire.ChatChunk) []AnthEvent {
|
||||
var events []AnthEvent
|
||||
if !st.started {
|
||||
st.started = true
|
||||
events = append(events, st.startEvent())
|
||||
}
|
||||
if chunk.Usage != nil {
|
||||
st.usage = aiwire.AnthUsage{InputTokens: chunk.Usage.PromptTokens, OutputTokens: chunk.Usage.CompletionTokens,
|
||||
CacheReadInputTokens: chunk.Usage.CachedTokens()}
|
||||
}
|
||||
for _, choice := range chunk.Choices {
|
||||
events = append(events, st.feedDelta(choice.Delta)...)
|
||||
if choice.FinishReason != nil && *choice.FinishReason != "" {
|
||||
st.stopReason = anthStopReason(*choice.FinishReason)
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func (st *AnthStream) startEvent() AnthEvent {
|
||||
return AnthEvent{Event: "message_start", Data: map[string]any{
|
||||
"type": "message_start",
|
||||
"message": map[string]any{
|
||||
"id": st.id, "type": "message", "role": "assistant", "model": st.model,
|
||||
"content": []any{}, "stop_reason": nil,
|
||||
"usage": map[string]int{"input_tokens": 0, "output_tokens": 0},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// feedDelta 处理文本与工具调用增量,必要时切块。
|
||||
func (st *AnthStream) feedDelta(d aiwire.Delta) []AnthEvent {
|
||||
var events []AnthEvent
|
||||
if d.Content != "" {
|
||||
if !st.blockOpen || st.blockIsTool {
|
||||
events = append(events, st.openBlock(false, "", "")...)
|
||||
}
|
||||
events = append(events, AnthEvent{Event: "content_block_delta", Data: map[string]any{
|
||||
"type": "content_block_delta", "index": st.blockIndex,
|
||||
"delta": map[string]string{"type": "text_delta", "text": d.Content},
|
||||
}})
|
||||
}
|
||||
for _, tc := range d.ToolCalls {
|
||||
if tc.ID != "" && (!st.blockOpen || !st.blockIsTool || st.toolID != tc.ID) {
|
||||
events = append(events, st.openBlock(true, tc.ID, tc.Function.Name)...)
|
||||
}
|
||||
if tc.Function.Arguments != "" && st.blockOpen && st.blockIsTool {
|
||||
events = append(events, AnthEvent{Event: "content_block_delta", Data: map[string]any{
|
||||
"type": "content_block_delta", "index": st.blockIndex,
|
||||
"delta": map[string]string{"type": "input_json_delta", "partial_json": tc.Function.Arguments},
|
||||
}})
|
||||
}
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
// openBlock 关闭当前块并打开新块(text 或 tool_use)。
|
||||
func (st *AnthStream) openBlock(isTool bool, toolID, toolName string) []AnthEvent {
|
||||
var events []AnthEvent
|
||||
if st.blockOpen {
|
||||
events = append(events, st.closeBlockEvent())
|
||||
}
|
||||
st.blockOpen, st.blockIsTool, st.toolID = true, isTool, toolID
|
||||
st.blockIndex++
|
||||
block := map[string]any{"type": "text", "text": ""}
|
||||
if isTool {
|
||||
block = map[string]any{"type": "tool_use", "id": toolID, "name": toolName, "input": map[string]any{}}
|
||||
}
|
||||
events = append(events, AnthEvent{Event: "content_block_start", Data: map[string]any{
|
||||
"type": "content_block_start", "index": st.blockIndex, "content_block": block,
|
||||
}})
|
||||
return events
|
||||
}
|
||||
|
||||
func (st *AnthStream) closeBlockEvent() AnthEvent {
|
||||
return AnthEvent{Event: "content_block_stop", Data: map[string]any{
|
||||
"type": "content_block_stop", "index": st.blockIndex,
|
||||
}}
|
||||
}
|
||||
|
||||
// Finish 在上游流结束后收尾:关块 → message_delta(stop_reason+usage)→ message_stop。
|
||||
func (st *AnthStream) Finish() []AnthEvent {
|
||||
var events []AnthEvent
|
||||
if !st.started {
|
||||
events = append(events, st.startEvent())
|
||||
st.started = true
|
||||
}
|
||||
if st.blockOpen {
|
||||
events = append(events, st.closeBlockEvent())
|
||||
st.blockOpen = false
|
||||
}
|
||||
events = append(events,
|
||||
AnthEvent{Event: "message_delta", Data: map[string]any{
|
||||
"type": "message_delta",
|
||||
"delta": map[string]any{"stop_reason": st.stopReason, "stop_sequence": nil},
|
||||
"usage": map[string]int{"output_tokens": st.usage.OutputTokens},
|
||||
}},
|
||||
AnthEvent{Event: "message_stop", Data: map[string]any{"type": "message_stop"}},
|
||||
)
|
||||
return events
|
||||
}
|
||||
|
||||
// Usage 返回聚合到的用量(供调用日志)。
|
||||
func (st *AnthStream) Usage() aiwire.AnthUsage { return st.usage }
|
||||
Reference in New Issue
Block a user