AI 网关切换 OpenAI 兼容面并移除 chat 端点,新增模型黑白名单
CI / test (push) Successful in 31s
Release / release (push) Successful in 54s

This commit is contained in:
2026-07-12 17:48:28 +08:00
parent 7706f59549
commit 489cb49cb3
34 changed files with 2602 additions and 2901 deletions
+61 -206
View File
@@ -8,38 +8,8 @@ import (
"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 {
// respRejectStateful 拒绝有状态特性(网关无状态)。
func respRejectStateful(req aiwire.RespRequest) error {
if req.PreviousResponseID != "" {
return fmt.Errorf("previous_response_id 不支持:网关不保存历史响应,请在 input 中自带完整上下文(store:false 模式)")
}
@@ -49,195 +19,80 @@ func respRejectUnsupported(req aiwire.RespRequest) error {
if req.Background != nil && *req.Background {
return fmt.Errorf("background 模式不支持")
}
return nil
}
// RespServerTools 报告工具列表是否含 xAI 服务端工具(web_search / x_search)。
func RespServerTools(tools []aiwire.RespTool) bool {
for _, t := range tools {
if t.Type == "web_search" || t.Type == "x_search" {
return true
}
}
return false
}
// RespPassthroughValidate 校验直通请求:模型必填,有状态特性不支持,工具类型
// 只放行 function 与已实测的 web_search / x_search;流式仅在含服务端工具时拒绝
// (工具流式事件形态未实测,不放开)。
func RespPassthroughValidate(req aiwire.RespRequest) error {
if strings.TrimSpace(req.Model) == "" {
return fmt.Errorf("model 不能为空")
}
if req.Stream && RespServerTools(req.Tools) {
return fmt.Errorf("服务端工具暂不支持流式:请去掉 stream 或改用 function 工具")
}
if err := respRejectStateful(req); err != nil {
return err
}
for _, t := range req.Tools {
if t.Type != "function" {
return fmt.Errorf("不支持的工具类型 %q:仅支持 function 工具", t.Type)
switch t.Type {
case "function", "web_search", "x_search":
default:
return fmt.Errorf("不支持的工具类型 %q:服务端工具仅支持 web_search / x_search", 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
// RespPassthroughBody 以原始请求体为基构造上游 body:强制 store:false(禁上游
// 存态),stream 原样保留(流式直通);用 json.Number 保真未知字段与数值。
func RespPassthroughBody(raw []byte) ([]byte, error) {
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.UseNumber()
var body map[string]any
if err := dec.Decode(&body); err != nil {
return nil, fmt.Errorf("解析请求体: %w", err)
}
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
body["store"] = false
return json.Marshal(body)
}
// 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)
// RespPassthroughUsage 从直通响应提取用量;缺失时返回 nil(日志记零)
func RespPassthroughUsage(payload []byte) *aiwire.Usage {
var root struct {
Usage *aiwire.RespUsage `json:"usage"`
}
}
// 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 {
if json.Unmarshal(payload, &root) != nil || root.Usage == nil {
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}})
usage := &aiwire.Usage{PromptTokens: root.Usage.InputTokens,
CompletionTokens: root.Usage.OutputTokens, TotalTokens: root.Usage.TotalTokens}
if cached := root.Usage.InputTokensDetails.CachedTokens; cached > 0 {
usage.PromptTokensDetails = &aiwire.PromptTokensDetails{CachedTokens: cached}
}
return out
return usage
}
// respToolChoice 转换 tool_choice:字符串直通;{type:function,name} 转嵌套;
// allowed_tools 等对象形态降级 auto
func respToolChoice(raw json.RawMessage) json.RawMessage {
if len(raw) == 0 {
// RespStreamCompletedUsage 从一行 SSE data JSON 中提取 response.completed 事件的
// usage;非 completed 事件或解析失败返回 nil。流式直通逐行喂入,最后一次非 nil 生效
func RespStreamCompletedUsage(data []byte) *aiwire.Usage {
var ev struct {
Type string `json:"type"`
Response json.RawMessage `json:"response"`
}
if json.Unmarshal(data, &ev) != nil || ev.Type != "response.completed" || len(ev.Response) == 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()}}
return RespPassthroughUsage(ev.Response)
}