初始提交:OCI 面板后端(含 GenAI 网关一期)

This commit is contained in:
Wang Defa
2026-07-09 15:31:04 +08:00
commit b9a3e97e84
168 changed files with 31794 additions and 0 deletions
+176
View File
@@ -0,0 +1,176 @@
package service
import (
"encoding/json"
"strings"
"testing"
"oci-portal/internal/aiwire"
)
func respReq(t *testing.T, raw string) aiwire.RespRequest {
t.Helper()
var req aiwire.RespRequest
if err := json.Unmarshal([]byte(raw), &req); err != nil {
t.Fatalf("unmarshal req: %v", err)
}
return req
}
func TestResponsesToIR(t *testing.T) {
req := respReq(t, `{
"model": "meta.llama-3.3-70b-instruct",
"instructions": "你是助手",
"max_output_tokens": 128,
"input": [
{"role": "user", "content": "查天气"},
{"type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": "{\"city\":\"上海\"}"},
{"type": "function_call_output", "call_id": "call_1", "output": "{\"temp\":31}"},
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "31 度"}]}
],
"tools": [{"type": "function", "name": "get_weather", "description": "查天气", "parameters": {"type": "object"}, "strict": true}],
"tool_choice": {"type": "function", "name": "get_weather"},
"text": {"format": {"type": "json_schema", "name": "out", "schema": {"type": "object"}}}
}`)
ir, err := ResponsesToIR(req)
if err != nil {
t.Fatalf("ResponsesToIR: %v", err)
}
roles := make([]string, 0, len(ir.Messages))
for _, m := range ir.Messages {
roles = append(roles, m.Role)
}
want := []string{"system", "user", "assistant", "tool", "assistant"}
if strings.Join(roles, ",") != strings.Join(want, ",") {
t.Errorf("roles = %v, want %v", roles, want)
}
if ir.Messages[2].ToolCalls[0].ID != "call_1" || ir.Messages[3].ToolCallID != "call_1" {
t.Errorf("tool call 链接错误: %+v", ir.Messages)
}
if deref(ir.MaxTokens) != 128 || len(ir.Tools) != 1 || ir.Tools[0].Function.Name != "get_weather" {
t.Errorf("参数映射错误: max=%v tools=%+v", ir.MaxTokens, ir.Tools)
}
if !strings.Contains(string(ir.ToolChoice), `"function"`) || !strings.Contains(string(ir.ToolChoice), "get_weather") {
t.Errorf("tool_choice = %s", ir.ToolChoice)
}
if ir.ResponseFormat == nil || ir.ResponseFormat.Type != "json_schema" {
t.Errorf("response_format = %+v", ir.ResponseFormat)
}
}
func deref(p *int) int {
if p == nil {
return 0
}
return *p
}
func TestResponsesToIRRejects(t *testing.T) {
cases := []struct{ name, raw string }{
{"previous_response_id", `{"model":"m","input":"hi","previous_response_id":"resp_x"}`},
{"conversation", `{"model":"m","input":"hi","conversation":{"id":"conv_1"}}`},
{"background", `{"model":"m","input":"hi","background":true}`},
{"builtin tool", `{"model":"m","input":"hi","tools":[{"type":"web_search"}]}`},
{"file_id image", `{"model":"m","input":[{"role":"user","content":[{"type":"input_image","file_id":"file_1"}]}]}`},
{"unknown item", `{"model":"m","input":[{"type":"item_reference","id":"x"}]}`},
}
for _, c := range cases {
if _, err := ResponsesToIR(respReq(t, c.raw)); err == nil {
t.Errorf("%s 应被拒绝", c.name)
}
}
// input 字符串形态 + store/reasoning 忽略项不报错
req := respReq(t, `{"model":"m","input":"你好","store":false,"reasoning":{"effort":"low"},"parallel_tool_calls":true}`)
ir, err := ResponsesToIR(req)
if err != nil || len(ir.Messages) != 1 || ir.Messages[0].Role != "user" || ir.ReasoningEffort != "low" {
t.Errorf("字符串 input = %+v, %v", ir.Messages, err)
}
// input_image(url 形态)放行并保留图文顺序
req2 := respReq(t, `{"model":"m","input":[{"role":"user","content":[{"type":"input_text","text":"看图"},{"type":"input_image","image_url":"https://x/1.png","detail":"low"}]}]}`)
ir2, err := ResponsesToIR(req2)
if err != nil {
t.Fatalf("input_image 应放行: %v", err)
}
parts := ir2.Messages[0].Content.Parts
if len(parts) != 2 || parts[1].Type != "image_url" || parts[1].ImageURL == nil || parts[1].ImageURL.URL != "https://x/1.png" {
t.Errorf("parts = %+v", parts)
}
}
func TestIRRespToResponses(t *testing.T) {
resp := &aiwire.ChatResponse{Model: "m", Choices: []aiwire.Choice{{
Message: aiwire.ChatMessage{Role: "assistant", Content: aiwire.NewTextContent("你好"),
ToolCalls: []aiwire.ToolCall{{ID: "call_9", Type: "function",
Function: aiwire.FunctionCall{Name: "f", Arguments: ""}}}},
FinishReason: "length",
}}, Usage: &aiwire.Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}}
out := IRRespToResponses(resp, "resp_abc", 1751966400)
if out.Status != "incomplete" || out.IncompleteDetails == nil || out.IncompleteDetails.Reason != "max_output_tokens" {
t.Errorf("length 应映射 incomplete: %+v", out)
}
if len(out.Output) != 2 || out.Output[0].Type != "message" || out.Output[1].Type != "function_call" {
t.Fatalf("output = %+v", out.Output)
}
if out.Output[0].Content[0].Text != "你好" || out.Output[1].CallID != "call_9" || out.Output[1].Arguments != "{}" {
t.Errorf("item 装配错误: %+v", out.Output)
}
if out.Usage.InputTokens != 10 || out.Usage.OutputTokens != 5 || out.Store {
t.Errorf("usage/store 错误: %+v", out)
}
}
func chunkText(text string) aiwire.ChatChunk {
return aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{Content: text}}}}
}
func TestRespStreamTextAndTool(t *testing.T) {
st := NewRespStream("resp_x", "m", 1751966400)
var types []string
collect := func(evs []RespEvent) {
for _, ev := range evs {
types = append(types, ev.Event)
}
}
collect(st.Feed(chunkText("你")))
collect(st.Feed(chunkText("好")))
collect(st.Feed(aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{
ToolCalls: []aiwire.ToolCallDelta{{Index: 0, ID: "call_1", Function: aiwire.FunctionCallDelta{Name: "f", Arguments: "{\"a\""}}}}}}}))
collect(st.Feed(aiwire.ChatChunk{Choices: []aiwire.ChunkChoice{{Delta: aiwire.Delta{
ToolCalls: []aiwire.ToolCallDelta{{Index: 0, Function: aiwire.FunctionCallDelta{Arguments: ":1}"}}}}}},
Usage: &aiwire.Usage{PromptTokens: 3, CompletionTokens: 7, TotalTokens: 10}}))
finish := st.Finish()
collect(finish)
want := []string{
"response.created", "response.in_progress",
"response.output_item.added", "response.content_part.added", "response.output_text.delta",
"response.output_text.delta",
"response.output_text.done", "response.content_part.done", "response.output_item.done",
"response.output_item.added", "response.function_call_arguments.delta",
"response.function_call_arguments.delta",
"response.function_call_arguments.done", "response.output_item.done",
"response.completed",
}
if strings.Join(types, "\n") != strings.Join(want, "\n") {
t.Errorf("事件序列 =\n%s\nwant\n%s", strings.Join(types, "\n"), strings.Join(want, "\n"))
}
last := finish[len(finish)-1].Data
resp := last["response"].(aiwire.Response)
if len(resp.Output) != 2 || resp.Output[0].Content[0].Text != "你好" || resp.Output[1].Arguments != "{\"a\":1}" {
t.Errorf("终态快照 = %+v", resp.Output)
}
if resp.Usage == nil || resp.Usage.TotalTokens != 10 {
t.Errorf("终态 usage = %+v", resp.Usage)
}
// sequence_number 严格递增
if seq, ok := last["sequence_number"].(int); !ok || seq != len(want) {
t.Errorf("最终 sequence_number = %v, want %d", last["sequence_number"], len(want))
}
}
func TestRespStreamEmpty(t *testing.T) {
st := NewRespStream("resp_e", "m", 1)
evs := st.Finish()
if len(evs) != 2 || evs[0].Event != "response.created" || evs[1].Event != "response.completed" {
t.Errorf("空流事件 = %+v", evs)
}
}