mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/yunionio/cloudpods.git
synced 2026-09-20 08:03:53 +08:00
Promote visual_provider_id/visual_model_key to dedicated ai_model columns, force non-stream upstream orchestration for visual paths, and synthesize SSE chunks for streaming Responses requests that include images.
292 lines
8.6 KiB
Go
292 lines
8.6 KiB
Go
// Copyright 2019 Yunion
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package openai
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"yunion.io/x/jsonutils"
|
|
)
|
|
|
|
func TestResponsesURL(t *testing.T) {
|
|
if got := ResponsesURL("https://api.openai.com/v1"); got != "https://api.openai.com/v1/responses" {
|
|
t.Fatalf("ResponsesURL = %q", got)
|
|
}
|
|
if got := ResponsesSubResourceURL("https://api.openai.com/v1", "resp_123", "cancel"); got != "https://api.openai.com/v1/responses/resp_123/cancel" {
|
|
t.Fatalf("ResponsesSubResourceURL = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestResponsesToChatCompletionsStringInput(t *testing.T) {
|
|
body, err := jsonutils.Parse([]byte(`{"model":"gpt-test","input":"hello","max_output_tokens":128}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dict := body.(*jsonutils.JSONDict)
|
|
out, state, err := ResponsesToChatCompletions(dict, "upstream-model")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if state == nil {
|
|
t.Fatal("expected state")
|
|
}
|
|
if m, _ := out.GetString("model"); m != "upstream-model" {
|
|
t.Fatalf("model = %q", m)
|
|
}
|
|
if mt, _ := out.Int("max_tokens"); mt != 128 {
|
|
t.Fatalf("max_tokens = %d", mt)
|
|
}
|
|
msgs, _ := out.Get("messages")
|
|
arr, ok := msgs.(*jsonutils.JSONArray)
|
|
if !ok || arr.Size() < 1 {
|
|
t.Fatalf("messages = %v", msgs)
|
|
}
|
|
}
|
|
|
|
func TestResponsesToChatCompletionsFunctionCallInput(t *testing.T) {
|
|
raw := `{
|
|
"model":"gpt-test",
|
|
"max_output_tokens":64,
|
|
"input":[{"type":"function_call","name":"get_weather","call_id":"call_1","arguments":"{\"city\":\"bj\"}"},
|
|
{"type":"function_call_output","call_id":"call_1","output":"sunny"}]
|
|
}`
|
|
body, _ := jsonutils.Parse([]byte(raw))
|
|
out, _, err := ResponsesToChatCompletions(body.(*jsonutils.JSONDict), "m")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := out.String()
|
|
if !strings.Contains(s, "tool_calls") || !strings.Contains(s, "tool_call_id") {
|
|
t.Fatalf("expected tool messages in %s", s)
|
|
}
|
|
}
|
|
|
|
func TestChatCompletionToResponsesBasic(t *testing.T) {
|
|
raw := []byte(`{
|
|
"id":"chatcmpl-1",
|
|
"model":"gpt-test",
|
|
"choices":[{"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],
|
|
"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}
|
|
}`)
|
|
out, err := ChatCompletionToResponses(raw, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var resp map[string]interface{}
|
|
if err := json.Unmarshal(out, &resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp["object"] != "response" {
|
|
t.Fatalf("object = %v", resp["object"])
|
|
}
|
|
if resp["output_text"] != "hi" {
|
|
t.Fatalf("output_text = %v", resp["output_text"])
|
|
}
|
|
}
|
|
|
|
func TestFlattenResponsesNamespaceTool(t *testing.T) {
|
|
arr := jsonutils.NewArray()
|
|
arr.Add(jsonutils.NewString(`{
|
|
"type":"namespace",
|
|
"name":"myns",
|
|
"tools":[{"type":"function","name":"search","parameters":{"type":"object","properties":{"q":{"type":"string"}}}}]
|
|
}`))
|
|
// parse as proper dict
|
|
arr = jsonutils.NewArray()
|
|
ns := jsonutils.NewDict()
|
|
ns.Set("type", jsonutils.NewString("namespace"))
|
|
ns.Set("name", jsonutils.NewString("myns"))
|
|
sub := jsonutils.NewDict()
|
|
sub.Set("type", jsonutils.NewString("function"))
|
|
sub.Set("name", jsonutils.NewString("search"))
|
|
sub.Set("parameters", jsonutils.NewString(`{"type":"object","properties":{"q":{"type":"string"}}}`))
|
|
subs := jsonutils.NewArray(sub)
|
|
ns.Set("tools", subs)
|
|
arr.Add(ns)
|
|
|
|
tools, toolMap, err := FlattenResponsesTools(arr)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tools == nil || tools.Length() != 1 {
|
|
t.Fatalf("tools = %v", tools)
|
|
}
|
|
if _, ok := toolMap["myns"]; !ok {
|
|
t.Fatalf("toolMap = %v", toolMap)
|
|
}
|
|
}
|
|
|
|
func TestResponsesToChatCompletionsWithImageInput(t *testing.T) {
|
|
raw := `{
|
|
"model":"kimi-k2.6",
|
|
"input":[{"role":"user","content":[
|
|
{"type":"input_text","text":"describe this image"},
|
|
{"type":"input_image","image_url":"data:image/png;base64,abc123"}
|
|
]}]
|
|
}`
|
|
body, _ := jsonutils.Parse([]byte(raw))
|
|
out, _, err := ResponsesToChatCompletions(body.(*jsonutils.JSONDict), "kimi-k2.6")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
s := out.String()
|
|
if !strings.Contains(s, "image_url") {
|
|
t.Fatalf("expected image_url in messages, got %s", s)
|
|
}
|
|
if !strings.Contains(s, "data:image/png;base64,abc123") {
|
|
t.Fatalf("expected image data url in messages, got %s", s)
|
|
}
|
|
if !strings.Contains(s, "describe this image") {
|
|
t.Fatalf("expected text in messages, got %s", s)
|
|
}
|
|
}
|
|
|
|
func TestResponsesInputHasImage(t *testing.T) {
|
|
body, _ := jsonutils.Parse([]byte(`{"input":[{"role":"user","content":[{"type":"input_image","image_url":"data:image/png;base64,x"}]}]}`))
|
|
if !ResponsesInputHasImage(body.(*jsonutils.JSONDict)) {
|
|
t.Fatal("expected image input")
|
|
}
|
|
body2, _ := jsonutils.Parse([]byte(`{"input":"hello"}`))
|
|
if ResponsesInputHasImage(body2.(*jsonutils.JSONDict)) {
|
|
t.Fatal("expected no image for string input")
|
|
}
|
|
}
|
|
|
|
func TestResponsesStreamConverterReasoningAndToolOutputIndex(t *testing.T) {
|
|
conv := NewResponsesStreamConverter("kimi-k2.6", nil)
|
|
chunk1 := []byte(`{"id":"chatcmpl-1","model":"kimi-k2.6","choices":[{"delta":{"reasoning_content":"think"}}]}`)
|
|
events1, err := conv.Feed(chunk1, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
chunk2 := []byte(`{"id":"chatcmpl-1","model":"kimi-k2.6","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"view_image","arguments":"{}"}}]}}]}`)
|
|
events2, err := conv.Feed(chunk2, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
events := append(events1, events2...)
|
|
var addedIndex, deltaIndex int
|
|
for _, e := range events {
|
|
if e.Event != "response.output_item.added" && e.Event != "response.function_call_arguments.delta" {
|
|
continue
|
|
}
|
|
var payload map[string]interface{}
|
|
if err := json.Unmarshal(e.Data, &payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
idx, _ := payload["output_index"].(float64)
|
|
if e.Event == "response.output_item.added" {
|
|
item, _ := payload["item"].(map[string]interface{})
|
|
if item["type"] == "function_call" {
|
|
addedIndex = int(idx)
|
|
}
|
|
}
|
|
if e.Event == "response.function_call_arguments.delta" {
|
|
deltaIndex = int(idx)
|
|
}
|
|
}
|
|
if addedIndex == 0 || deltaIndex == 0 {
|
|
t.Fatalf("expected function_call indices, added=%d delta=%d events=%+v", addedIndex, deltaIndex, events)
|
|
}
|
|
if addedIndex != deltaIndex {
|
|
t.Fatalf("output_index mismatch: added=%d delta=%d", addedIndex, deltaIndex)
|
|
}
|
|
}
|
|
|
|
func TestResponsesStreamConverterText(t *testing.T) {
|
|
conv := NewResponsesStreamConverter("gpt-test", nil)
|
|
chunk := []byte(`{"id":"chatcmpl-1","model":"gpt-test","choices":[{"delta":{"content":"Hi"}}]}`)
|
|
events, err := conv.Feed(chunk, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(events) == 0 {
|
|
t.Fatal("expected events")
|
|
}
|
|
if events[0].Event != "response.created" {
|
|
t.Fatalf("first event = %q", events[0].Event)
|
|
}
|
|
end, err := conv.Feed(nil, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
foundCompleted := false
|
|
for _, e := range end {
|
|
if e.Event == "response.completed" {
|
|
foundCompleted = true
|
|
}
|
|
}
|
|
if !foundCompleted {
|
|
t.Fatalf("events = %+v", end)
|
|
}
|
|
}
|
|
|
|
func TestNonStreamChatCompletionToStreamPayloads(t *testing.T) {
|
|
raw := []byte(`{
|
|
"id":"chatcmpl-visual-1",
|
|
"model":"deepseek-v4-flash",
|
|
"choices":[{
|
|
"message":{
|
|
"role":"assistant",
|
|
"reasoning_content":"thinking",
|
|
"content":"Hello from visual"
|
|
},
|
|
"finish_reason":"stop"
|
|
}],
|
|
"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}
|
|
}`)
|
|
payloads, err := NonStreamChatCompletionToStreamPayloads(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(payloads) < 3 {
|
|
t.Fatalf("payloads = %d, want at least 3", len(payloads))
|
|
}
|
|
|
|
conv := NewResponsesStreamConverter("deepseek-v4-flash", nil)
|
|
var events []ResponsesStreamEvent
|
|
for _, payload := range payloads {
|
|
chunkEvents, err := conv.Feed(payload, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
events = append(events, chunkEvents...)
|
|
}
|
|
endEvents, err := conv.Feed(nil, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
events = append(events, endEvents...)
|
|
|
|
foundCreated := false
|
|
foundText := false
|
|
foundCompleted := false
|
|
for _, e := range events {
|
|
switch e.Event {
|
|
case "response.created":
|
|
foundCreated = true
|
|
case "response.output_text.delta":
|
|
foundText = true
|
|
case "response.completed":
|
|
foundCompleted = true
|
|
}
|
|
}
|
|
if !foundCreated || !foundText || !foundCompleted {
|
|
t.Fatalf("events missing lifecycle: created=%v text=%v completed=%v all=%+v", foundCreated, foundText, foundCompleted, events)
|
|
}
|
|
}
|