177 lines
7.2 KiB
Go
177 lines
7.2 KiB
Go
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)
|
|
}
|
|
}
|