mirror of
https://github.com/yunionio/cloudpods.git
synced 2026-08-30 17:13:08 +08:00
068cc8bdbd
* feat(aiproxy): support streaming responses for visual messages * refactor(aiproxy): share API log helpers and support input/output token usage Extract common chatlog record lifecycle across proxy handlers, and fall back to Responses/Anthropic usage field aliases when filling token counts.
209 lines
6.5 KiB
Go
209 lines
6.5 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 visual
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"yunion.io/x/jsonutils"
|
|
|
|
"yunion.io/x/onecloud/pkg/aiproxy/models"
|
|
api "yunion.io/x/onecloud/pkg/apis/aiproxy"
|
|
)
|
|
|
|
func TestStripImagesFromChat(t *testing.T) {
|
|
body := jsonutils.NewDict()
|
|
msgs := jsonutils.NewArray()
|
|
user := jsonutils.NewDict()
|
|
user.Set("role", jsonutils.NewString("user"))
|
|
parts := jsonutils.NewArray()
|
|
text := jsonutils.NewDict()
|
|
text.Set("type", jsonutils.NewString("text"))
|
|
text.Set("text", jsonutils.NewString("describe"))
|
|
parts.Add(text)
|
|
img := jsonutils.NewDict()
|
|
img.Set("type", jsonutils.NewString("image_url"))
|
|
imgURL := jsonutils.NewDict()
|
|
imgURL.Set("url", jsonutils.NewString("data:image/png;base64,abc"))
|
|
img.Set("image_url", imgURL)
|
|
parts.Add(img)
|
|
user.Set("content", parts)
|
|
msgs.Add(user)
|
|
body.Set("messages", msgs)
|
|
|
|
images, err := StripImagesFromChat(body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(images) != 1 {
|
|
t.Fatalf("images = %d", len(images))
|
|
}
|
|
s := body.String()
|
|
if strings.Contains(s, "image_url") {
|
|
t.Fatalf("expected stripped image_url, got %s", s)
|
|
}
|
|
if !strings.Contains(s, "Image #1") {
|
|
t.Fatalf("expected placeholder, got %s", s)
|
|
}
|
|
}
|
|
|
|
func TestEnabled(t *testing.T) {
|
|
cfg := &api.SAiModelConfig{
|
|
Extensions: &api.SAiModelExtensions{
|
|
Visual: &api.SAiModelVisualConfig{Enabled: true},
|
|
},
|
|
}
|
|
up := &models.ChatUpstream{
|
|
ModelConfig: cfg,
|
|
VisualProviderId: "prov-1",
|
|
VisualModelKey: "vision-model",
|
|
}
|
|
if !Enabled(up) {
|
|
t.Fatal("expected enabled")
|
|
}
|
|
up.VisualProviderId = ""
|
|
if Enabled(up) {
|
|
t.Fatal("expected disabled without visual_provider_id")
|
|
}
|
|
}
|
|
|
|
func TestInjectChatTools(t *testing.T) {
|
|
body := jsonutils.NewDict()
|
|
InjectChatTools(body)
|
|
tools, err := body.Get("tools")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
arr, ok := tools.(*jsonutils.JSONArray)
|
|
if !ok || arr.Size() < 2 {
|
|
t.Fatalf("tools = %v", tools)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeImagesUsesAttached(t *testing.T) {
|
|
available := []ImageInput{{URL: "data:image/png;base64,abc"}}
|
|
images := normalizeImages("", nil, nil, []string{"Image #1"}, available)
|
|
if len(images) != 1 || images[0].URL == "" {
|
|
t.Fatalf("images = %#v", images)
|
|
}
|
|
}
|
|
|
|
func TestChatBaseURLStripsAnthropicSuffix(t *testing.T) {
|
|
cases := []struct {
|
|
in, want string
|
|
}{
|
|
{"https://api.deepseek.com/anthropic", "https://api.deepseek.com"},
|
|
{"https://api.deepseek.com/anthropic/", "https://api.deepseek.com"},
|
|
{"https://open.bigmodel.cn/api/anthropic", "https://open.bigmodel.cn"},
|
|
{"https://api.moonshot.cn", "https://api.moonshot.cn"},
|
|
}
|
|
for _, c := range cases {
|
|
if got := ChatBaseURL(c.in); got != c.want {
|
|
t.Fatalf("ChatBaseURL(%q)=%q want %q", c.in, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnthropicMessagesHasImage(t *testing.T) {
|
|
body, _ := jsonutils.Parse([]byte(`{"messages":[{"role":"user","content":[{"type":"image","source":{"type":"url","url":"https://x/a.png"}}]}]}`))
|
|
if !AnthropicMessagesHasImage(body.(*jsonutils.JSONDict)) {
|
|
t.Fatal("expected image")
|
|
}
|
|
body2, _ := jsonutils.Parse([]byte(`{"messages":[{"role":"user","content":"hello"}]}`))
|
|
if AnthropicMessagesHasImage(body2.(*jsonutils.JSONDict)) {
|
|
t.Fatal("expected no image")
|
|
}
|
|
}
|
|
|
|
func TestShouldHandleMessagesIgnoresStream(t *testing.T) {
|
|
cfg := &api.SAiModelConfig{
|
|
Extensions: &api.SAiModelExtensions{
|
|
Visual: &api.SAiModelVisualConfig{Enabled: true},
|
|
},
|
|
}
|
|
up := &models.ChatUpstream{
|
|
ModelConfig: cfg,
|
|
VisualProviderId: "prov-1",
|
|
VisualModelKey: "vision-model",
|
|
}
|
|
body, _ := jsonutils.Parse([]byte(`{"messages":[{"role":"user","content":[{"type":"image","source":{"type":"url","url":"https://x/a.png"}}]}]}`))
|
|
if !ShouldHandleMessages(body.(*jsonutils.JSONDict), up, false) {
|
|
t.Fatal("expected visual handle non-stream with image")
|
|
}
|
|
if !ShouldHandleMessages(body.(*jsonutils.JSONDict), up, true) {
|
|
t.Fatal("stream with image should use visual orchestrator")
|
|
}
|
|
bodyText, _ := jsonutils.Parse([]byte(`{"messages":[{"role":"user","content":"hello"}]}`))
|
|
if ShouldHandleMessages(bodyText.(*jsonutils.JSONDict), up, true) {
|
|
t.Fatal("stream without image should not use visual orchestrator")
|
|
}
|
|
}
|
|
|
|
func TestShouldRejectMessagesStreamingAlwaysFalse(t *testing.T) {
|
|
cfg := &api.SAiModelConfig{
|
|
Extensions: &api.SAiModelExtensions{
|
|
Visual: &api.SAiModelVisualConfig{Enabled: true},
|
|
},
|
|
}
|
|
up := &models.ChatUpstream{
|
|
ModelConfig: cfg,
|
|
VisualProviderId: "prov-1",
|
|
VisualModelKey: "vision-model",
|
|
}
|
|
body, _ := jsonutils.Parse([]byte(`{"messages":[{"role":"user","content":[{"type":"image","source":{"type":"url","url":"https://x/a.png"}}]}]}`))
|
|
if ShouldRejectMessagesStreaming(body.(*jsonutils.JSONDict), up, true) {
|
|
t.Fatal("stream+visual+image must not reject after synthetic SSE support")
|
|
}
|
|
}
|
|
|
|
func TestForceNonStreamChatBody(t *testing.T) {
|
|
body := jsonutils.NewDict()
|
|
body.Set("stream", jsonutils.JSONTrue)
|
|
body.Set("stream_options", jsonutils.NewDict())
|
|
forceNonStreamChatBody(body)
|
|
if stream, _ := body.Bool("stream"); stream {
|
|
t.Fatal("expected stream removed")
|
|
}
|
|
if _, err := body.Get("stream_options"); err == nil {
|
|
t.Fatal("expected stream_options removed")
|
|
}
|
|
}
|
|
|
|
func TestShouldHandleResponsesIgnoresAPIMode(t *testing.T) {
|
|
cfg := &api.SAiModelConfig{
|
|
Extensions: &api.SAiModelExtensions{
|
|
Visual: &api.SAiModelVisualConfig{Enabled: true},
|
|
},
|
|
}
|
|
up := &models.ChatUpstream{
|
|
ModelConfig: cfg,
|
|
VisualProviderId: "prov-1",
|
|
VisualModelKey: "vision-model",
|
|
APIMode: "anthropic",
|
|
}
|
|
body, _ := jsonutils.Parse([]byte(`{"input":[{"role":"user","content":[{"type":"input_image","image_url":"data:image/png;base64,x"}]}]}`))
|
|
if !ShouldHandle(body.(*jsonutils.JSONDict), up, false) {
|
|
t.Fatal("expected visual handle even with anthropic api_mode")
|
|
}
|
|
if !ShouldHandle(body.(*jsonutils.JSONDict), up, true) {
|
|
t.Fatal("stream with image should use visual orchestrator")
|
|
}
|
|
bodyText, _ := jsonutils.Parse([]byte(`{"input":[{"role":"user","content":[{"type":"input_text","text":"hello"}]}]}`))
|
|
if ShouldHandle(bodyText.(*jsonutils.JSONDict), up, true) {
|
|
t.Fatal("stream without image should not use visual orchestrator")
|
|
}
|
|
}
|