Files
ttstd-infinite-canvas/backend/internal/protocol/official_catalog_test.go
T

757 lines
32 KiB
Go

package protocol
import (
"context"
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
const (
manifestContractStart = "<!-- YINGCE_MANIFEST_CONTRACT_START -->"
manifestContractEnd = "<!-- YINGCE_MANIFEST_CONTRACT_END -->"
manifestDocumentationPlaceholder = "<当前插件的完整 documentation,由 README.md 与 docs/interface.md 拼接而成;为避免 JSON 递归,此处不重复展开正文。>"
)
func TestOfficialProtocolPackagesAreSelfContainedDeclarativePlugins(t *testing.T) {
paths, err := filepath.Glob(filepath.Join("..", "..", "..", "plugin-packages", "*.yingce-plugin"))
if err != nil {
t.Fatal(err)
}
if len(paths) < 70 {
t.Fatalf("official protocol packages = %d, want at least 70", len(paths))
}
providerOwners := map[string]string{}
for _, path := range paths {
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
pkg, err := ParsePluginPackage(data)
if err != nil {
t.Fatalf("parse %s: %v", filepath.Base(path), err)
}
if len(pkg.Manifest.Contributes.PaymentProviders) > 0 {
// Payment packages use the yingce.payment/v1 executable ABI rather than
// the declarative AI provider contract covered by this catalog test.
continue
}
if pkg.Manifest.APIVersion != "yingce.plugin/v2" {
t.Fatalf("%s apiVersion = %q", filepath.Base(path), pkg.Manifest.APIVersion)
}
if strings.HasPrefix(strings.TrimSpace(pkg.Manifest.Runtime.Backend), "host:") {
t.Fatalf("%s depends on host runtime", filepath.Base(path))
}
if len(pkg.Files["README.md"]) == 0 || len(pkg.Files["docs/interface.md"]) == 0 {
t.Fatalf("%s must contain README.md and docs/interface.md", filepath.Base(path))
}
interfaceDocs := string(pkg.Files["docs/interface.md"])
expectedDocumentation := strings.TrimSpace(string(pkg.Files["README.md"])) + "\n\n---\n\n" + strings.TrimSpace(interfaceDocs)
if strings.TrimSpace(pkg.Manifest.Metadata.Documentation) != expectedDocumentation {
t.Fatalf("%s manifest.documentation does not contain the packaged README and interface document", filepath.Base(path))
}
assertManifestContractMatchesPackage(t, filepath.Base(path), pkg.ManifestRaw, interfaceDocs)
if !strings.Contains(interfaceDocs, "响应") {
t.Fatalf("%s interface documentation must describe response mapping", filepath.Base(path))
}
for _, field := range pkg.Manifest.Configuration.Fields {
if !strings.Contains(interfaceDocs, field.Name) {
t.Fatalf("%s interface documentation is missing configuration field %q", filepath.Base(path), field.Name)
}
}
adapters, err := LoadInstalledProviders(pkg.ManifestRaw, nil)
if err != nil {
t.Fatalf("load %s: %v", filepath.Base(path), err)
}
if len(adapters) != len(pkg.Manifest.Contributes.Providers) {
t.Fatalf("%s adapters = %d, providers = %d", filepath.Base(path), len(adapters), len(pkg.Manifest.Contributes.Providers))
}
for _, provider := range pkg.Manifest.Contributes.Providers {
if owner, duplicate := providerOwners[provider.ID]; duplicate {
t.Fatalf("provider id %q is shared by %s and %s", provider.ID, owner, filepath.Base(path))
}
providerOwners[provider.ID] = filepath.Base(path)
for _, parameter := range provider.Parameters {
if strings.TrimSpace(parameter.Name) == "" || strings.TrimSpace(parameter.Type) == "" || strings.TrimSpace(parameter.Mapping) == "" || strings.TrimSpace(parameter.Description) == "" {
t.Fatalf("%s provider %s has incomplete parameter documentation: %#v", filepath.Base(path), provider.ID, parameter)
}
if !strings.Contains(interfaceDocs, parameter.Name) {
t.Fatalf("%s interface documentation is missing provider parameter %q", filepath.Base(path), parameter.Name)
}
}
}
}
}
func TestOfficialAtlasCloudChatProfile(t *testing.T) {
data, err := os.ReadFile(filepath.Join("..", "..", "..", "plugin-packages", "atlascloud-chat.yingce-plugin"))
if err != nil {
t.Fatal(err)
}
pkg, err := ParsePluginPackage(data)
if err != nil {
t.Fatal(err)
}
provider := pkg.Manifest.Contributes.Providers[0]
if provider.ID != "atlascloud-chat" || provider.BaseURL != "https://api.atlascloud.ai" || provider.Auth.Type != "bearer" {
t.Fatalf("Atlas Cloud provider metadata = %#v", provider)
}
adapter := officialPackageAdapter(t, "atlascloud-chat.yingce-plugin", "atlascloud-chat")
spec, err := adapter.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{
Model: "openai/gpt-5.6-luna",
Messages: []Message{{Role: "user", Content: "hello"}},
}})
if err != nil {
t.Fatal(err)
}
body := manifestTestBody(t, spec)
if spec.Method != "POST" || spec.Path != "/v1/chat/completions" || body["model"] != "openai/gpt-5.6-luna" {
t.Fatalf("Atlas Cloud request = %#v, body = %#v", spec, body)
}
result, err := adapter.ParseCreate(context.Background(), []byte(`{"choices":[{"message":{"content":"atlas ok"}}],"usage":{"total_tokens":3}}`))
if err != nil {
t.Fatal(err)
}
if result.Status != StatusSucceeded || result.Result == nil || result.Result.Text != "atlas ok" {
t.Fatalf("Atlas Cloud response = %#v", result)
}
}
func assertManifestContractMatchesPackage(t *testing.T, packageName string, manifestRaw []byte, interfaceDocs string) {
t.Helper()
start := strings.Index(interfaceDocs, manifestContractStart)
end := strings.Index(interfaceDocs, manifestContractEnd)
if start < 0 || end < start {
t.Fatalf("%s interface documentation has no complete manifest contract", packageName)
}
contractSection := interfaceDocs[start:end]
jsonStart := strings.Index(contractSection, "```json\n")
if jsonStart < 0 {
t.Fatalf("%s manifest contract has no JSON block", packageName)
}
jsonStart += len("```json\n")
jsonEnd := strings.Index(contractSection[jsonStart:], "\n```")
if jsonEnd < 0 {
t.Fatalf("%s manifest contract JSON block is not terminated", packageName)
}
var actual map[string]any
if err := json.Unmarshal(manifestRaw, &actual); err != nil {
t.Fatalf("decode %s manifest: %v", packageName, err)
}
actual["documentation"] = manifestDocumentationPlaceholder
var documented map[string]any
if err := json.Unmarshal([]byte(contractSection[jsonStart:jsonStart+jsonEnd]), &documented); err != nil {
t.Fatalf("decode %s documented manifest contract: %v", packageName, err)
}
if !reflect.DeepEqual(documented, actual) {
t.Fatalf("%s documented manifest contract does not exactly match manifest.json", packageName)
}
}
func TestOfficialAgentProfilesMapToolRequestsAndResponses(t *testing.T) {
requests := map[string]any{
"chatCompletion": map[string]any{"messages": []any{map[string]any{"role": "user", "content": "inspect"}}, "tools": []any{map[string]any{"type": "function"}}, "tool_choice": "required", "marker": "chat"},
"responses": map[string]any{"input": []any{map[string]any{"role": "user", "content": "inspect"}}, "tools": []any{map[string]any{"type": "function"}}, "tool_choice": "required", "marker": "responses"},
"claude": map[string]any{"messages": []any{map[string]any{"role": "user", "content": "inspect"}}, "tools": []any{map[string]any{"name": "canvas_get_state"}}, "tool_choice": map[string]any{"type": "any"}, "max_tokens": 512, "marker": "claude"},
"gemini": map[string]any{"contents": []any{map[string]any{"role": "user", "parts": []any{map[string]any{"text": "inspect"}}}}, "tools": []any{map[string]any{"functionDeclarations": []any{}}}, "toolConfig": map[string]any{"functionCallingConfig": map[string]any{"mode": "ANY"}}, "marker": "gemini"},
}
tests := []struct {
name, packageName, providerID, marker, wantPath, response, wantID, wantSignature string
}{
{
name: "openai-chat", packageName: "openai-chat-completions.yingce-plugin", providerID: "chat-completion", marker: "chat", wantPath: "/chat/completions", wantID: "call-chat",
response: `{"choices":[{"message":{"content":"chat answer","tool_calls":[{"id":"call-chat","function":{"name":"canvas_get_state","arguments":"{\"scope\":\"all\"}"}}]}}]}`,
},
{
name: "openai-responses", packageName: "openai-responses.yingce-plugin", providerID: "openai-response", marker: "responses", wantPath: "/responses", wantID: "call-responses",
response: `{"output_text":"responses answer","output":[{"type":"message"},{"type":"function_call","call_id":"call-responses","name":"canvas_get_state","arguments":"{\"scope\":\"all\"}"}]}`,
},
{
name: "anthropic", packageName: "anthropic-messages.yingce-plugin", providerID: "claude-api", marker: "claude", wantPath: "/v1/messages", wantID: "call-claude",
response: `{"content":[{"type":"text","text":"claude answer"},{"type":"tool_use","id":"call-claude","name":"canvas_get_state","input":{"scope":"all"}}]}`,
},
{
name: "gemini", packageName: "google-gemini-generate-content.yingce-plugin", providerID: "gemini-generate-content", marker: "gemini", wantPath: "/v1beta/models/gemini-test:generateContent", wantSignature: "signature-1",
response: `{"candidates":[{"content":{"parts":[{"text":"gemini answer"},{"functionCall":{"name":"canvas_get_state","args":{"scope":"all"}},"thoughtSignature":"signature-1"}]}}]}`,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
adapter := officialPackageAdapter(t, test.packageName, test.providerID)
capability, ok := adapter.(AgentCapability)
if !ok || !capability.AgentAvailable() {
t.Fatalf("%s does not expose its declared agent operation", test.providerID)
}
agent, ok := adapter.(AgentAdapter)
if !ok {
t.Fatalf("%s does not implement AgentAdapter", test.providerID)
}
spec, err := agent.BuildAgent(context.Background(), AgentRequestContext{Model: "gemini-test", Request: requests})
if err != nil {
t.Fatal(err)
}
if spec.Path != test.wantPath {
t.Fatalf("request path = %q, want %q", spec.Path, test.wantPath)
}
body := manifestTestBody(t, spec)
if body["marker"] != test.marker {
t.Fatalf("request selected wrong protocol body: %#v", body)
}
if test.providerID != "gemini-generate-content" && body["model"] != "gemini-test" {
t.Fatalf("request model = %#v", body["model"])
}
result, err := agent.ParseAgent(context.Background(), []byte(test.response))
if err != nil {
t.Fatal(err)
}
if len(result.ToolCalls) != 1 || result.ToolCalls[0].Name != "canvas_get_state" || result.ToolCalls[0].Arguments != `{"scope":"all"}` {
t.Fatalf("agent result = %#v", result)
}
if test.wantID != "" && result.ToolCalls[0].ID != test.wantID {
t.Fatalf("tool call id = %q, want %q", result.ToolCalls[0].ID, test.wantID)
}
if test.wantID == "" && !strings.HasPrefix(result.ToolCalls[0].ID, "call_") {
t.Fatalf("provider omitted tool call id and no stable id was synthesized: %#v", result.ToolCalls[0])
}
if result.ToolCalls[0].ThoughtSignature != test.wantSignature {
t.Fatalf("thought signature = %q, want %q", result.ToolCalls[0].ThoughtSignature, test.wantSignature)
}
})
}
}
func TestOfficialVideoProfilesPreserveExplicitMediaRoles(t *testing.T) {
tests := []struct {
packageName string
providerID string
model string
resolution string
assert func(t *testing.T, spec RequestSpec)
}{
{
packageName: "minimax-hailuo-video-v2.yingce-plugin", providerID: "minimax-video", model: "MiniMax-H3", resolution: "1080P",
assert: func(t *testing.T, spec RequestSpec) {
body := manifestTestBody(t, spec)
content, _ := body["content"].([]any)
if len(content) != 3 {
t.Fatalf("MiniMax content = %#v", content)
}
first, _ := content[1].(map[string]any)
last, _ := content[2].(map[string]any)
if first["role"] != "first_frame" || last["role"] != "last_frame" || body["ratio"] != "adaptive" || body["resolution"] != "1080P" {
t.Fatalf("MiniMax role payload = %#v", body)
}
},
},
{
packageName: "newapi-media-task-v1.yingce-plugin", providerID: "newapi-channel-1", model: "minimax-h3", resolution: "720P",
assert: func(t *testing.T, spec RequestSpec) {
body := manifestTestBody(t, spec)
input, _ := body["input"].(map[string]any)
media, _ := input["media"].([]any)
first, _ := media[0].(map[string]any)
last, _ := media[1].(map[string]any)
if first["type"] != "first_frame" || last["type"] != "last_frame" {
t.Fatalf("NewAPI media = %#v", media)
}
},
},
{
packageName: "agnes-video-25.yingce-plugin", providerID: "agnes-video", model: "agnes-video-2.5", resolution: "720P",
assert: func(t *testing.T, spec RequestSpec) {
body := manifestTestBody(t, spec)
if body["mode"] != "keyframe" || body["first_frame"] != "https://cdn.example/first.png" || body["last_frame"] != "https://cdn.example/last.png" {
t.Fatalf("Agnes keyframe payload = %#v", body)
}
},
},
{
packageName: "dashscope-wan-video.yingce-plugin", providerID: "dashscope-wan-video", model: "wan2.2-kf2v-flash", resolution: "720P",
assert: func(t *testing.T, spec RequestSpec) {
body := manifestTestBody(t, spec)
input, _ := body["input"].(map[string]any)
if input["first_frame_url"] != "https://cdn.example/first.png" || input["last_frame_url"] != "https://cdn.example/last.png" {
t.Fatalf("Wan keyframe input = %#v", input)
}
if _, duplicated := input["img_url"]; duplicated {
t.Fatalf("Wan keyframe input must not also send img_url: %#v", input)
}
},
},
}
for _, test := range tests {
t.Run(test.providerID, func(t *testing.T) {
adapter := officialPackageAdapter(t, test.packageName, test.providerID)
spec, err := adapter.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{
Model: test.model, Prompt: "镜头推进", Duration: 6, AspectRatio: "16:9", Resolution: test.resolution,
Images: []MediaReference{
{URL: "https://cdn.example/last.png", Role: "last_frame", Order: 2},
{URL: "https://cdn.example/first.png", Role: "first_frame", Order: 1},
},
}})
if err != nil {
t.Fatal(err)
}
test.assert(t, spec)
})
}
}
func TestOfficialWanProfilesUseMutuallyExclusiveReferenceFields(t *testing.T) {
video := officialPackageAdapter(t, "dashscope-wan-video.yingce-plugin", "dashscope-wan-video")
videoSpec, err := video.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{
Model: "wan2.6-r2v", Prompt: "保持角色一致", Operation: "reference_to_video",
Images: []MediaReference{
{URL: "https://cdn.example/second.png", Role: "subject_reference", Order: 2},
{URL: "https://cdn.example/first.png", Role: "reference_image", Order: 1},
},
}})
if err != nil {
t.Fatal(err)
}
videoBody := manifestTestBody(t, videoSpec)
videoInput, _ := videoBody["input"].(map[string]any)
if _, duplicated := videoInput["img_url"]; duplicated {
t.Fatalf("Wan reference mode must not also send img_url: %#v", videoInput)
}
references, _ := videoInput["reference_images"].([]any)
if len(references) != 2 || references[0] != "https://cdn.example/first.png" || references[1] != "https://cdn.example/second.png" {
t.Fatalf("Wan reference_images = %#v", references)
}
image := officialPackageAdapter(t, "dashscope-wanx-image.yingce-plugin", "dashscope-wanx-image")
for _, test := range []struct {
name string
images []MediaReference
single bool
}{
{name: "single", images: []MediaReference{{URL: "https://cdn.example/one.png", Role: "reference_image", Order: 1}}, single: true},
{name: "multiple", images: []MediaReference{{URL: "https://cdn.example/two.png", Role: "reference_image", Order: 2}, {URL: "https://cdn.example/one.png", Role: "reference_image", Order: 1}}},
} {
t.Run("wanx-"+test.name, func(t *testing.T) {
spec, err := image.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{Model: "wanx2.1-t2i-turbo", Prompt: "角色设定", Images: test.images}})
if err != nil {
t.Fatal(err)
}
body := manifestTestBody(t, spec)
input, _ := body["input"].(map[string]any)
_, hasSingle := input["ref_img"]
_, hasMultiple := input["ref_images"]
if hasSingle != test.single || hasMultiple == test.single {
t.Fatalf("Wanx reference fields are not mutually exclusive: %#v", input)
}
})
}
}
func TestOfficialRollDekWanVideoUsesVideosLifecycle(t *testing.T) {
adapter := officialPackageAdapter(t, "rolldek-wan-video.yingce-plugin", "rolldek-wan-video")
request := GenerationRequest{
Model: "wan3.0-video-prime-1080p", Prompt: "保持角色一致", Duration: 11, AspectRatio: "16:9", Resolution: "1080p",
Images: []MediaReference{{URL: "https://cdn.example/character.png", Role: "reference_image"}},
Videos: []MediaReference{{URL: "https://cdn.example/motion.mp4", Role: "reference_video", Metadata: map[string]any{"durationMs": 5250}}},
Audios: []MediaReference{{URL: "https://cdn.example/voice.mp3", Role: "reference_audio"}},
}
create, err := adapter.BuildCreate(context.Background(), RequestContext{Request: request})
if err != nil {
t.Fatal(err)
}
if create.Method != "POST" || create.Path != "/v1/videos" || create.ContentType != "application/json" {
t.Fatalf("RollDek create = %#v", create)
}
body := manifestTestBody(t, create)
if body["model"] != request.Model || body["seconds"] != "11" || body["size"] != "1080P" || body["aspect_ratio"] != "16:9" {
t.Fatalf("RollDek create body = %#v", body)
}
images, _ := body["reference_images"].([]any)
image, _ := images[0].(map[string]any)
videos, _ := body["reference_videos"].([]any)
video, _ := videos[0].(map[string]any)
audios, _ := body["reference_audios"].([]any)
audio, _ := audios[0].(map[string]any)
if image["url"] != request.Images[0].URL || image["role"] != "reference_image" || video["url"] != request.Videos[0].URL || video["duration"] != 5.25 || audio["url"] != request.Audios[0].URL {
t.Fatalf("RollDek references = images:%#v videos:%#v audios:%#v", images, videos, audios)
}
created, err := adapter.ParseCreate(context.Background(), []byte(`{"id":"task-roll-1","task_id":"task-roll-1","status":"queued"}`))
if err != nil {
t.Fatal(err)
}
if created.TaskID != "task-roll-1" || created.Status != StatusPending {
t.Fatalf("RollDek create result = %#v", created)
}
poll, err := adapter.BuildPoll(context.Background(), PollContext{Request: request, TaskID: created.TaskID})
if err != nil {
t.Fatal(err)
}
if poll.Method != "GET" || poll.Path != "/v1/videos/task-roll-1" {
t.Fatalf("RollDek poll = %#v", poll)
}
state, err := adapter.ParsePoll(context.Background(), PollContext{Request: request, TaskID: created.TaskID}, []byte(`{"id":"task-roll-1","status":"completed","metadata":{"url":"https://cdn.example/result.mp4"}}`))
if err != nil {
t.Fatal(err)
}
if state.Status != StatusSucceeded || state.Result == nil || len(state.Result.Videos) != 1 || state.Result.Videos[0].URL != "https://cdn.example/result.mp4" {
t.Fatalf("RollDek poll result = %#v", state)
}
resultAdapter, ok := adapter.(ResultAdapter)
if !ok {
t.Fatal("RollDek adapter does not expose result download")
}
result, err := resultAdapter.BuildResult(context.Background(), PollContext{Request: request, TaskID: created.TaskID})
if err != nil {
t.Fatal(err)
}
if result.Method != "GET" || result.Path != "/v1/videos/task-roll-1/content" {
t.Fatalf("RollDek result = %#v", result)
}
}
func TestNewAPIVideoGenerationsParsesNestedTaskIDs(t *testing.T) {
adapter := officialPackageAdapter(t, "newapi-video-generations-v1.yingce-plugin", "newapi-channel-2")
tests := []struct {
name string
payload string
wantID string
}{
{name: "snake case", payload: `{"data":{"task_id":"task-snake","status":"queued"}}`, wantID: "task-snake"},
{name: "camel case", payload: `{"data":{"taskId":"task-camel","status":"queued"}}`, wantID: "task-camel"},
{name: "nested upstream id wins over wrapper id", payload: `{"id":"49137","data":{"task_id":"task-upstream","status":"queued"}}`, wantID: "task-upstream"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
created, err := adapter.ParseCreate(context.Background(), []byte(test.payload))
if err != nil {
t.Fatal(err)
}
if created.TaskID != test.wantID || created.Status != StatusPending {
t.Fatalf("created = %#v, want task ID %q and pending status", created, test.wantID)
}
})
}
}
func TestNewAPIVideoGenerationsParsesNestedVideoResults(t *testing.T) {
adapter := officialPackageAdapter(t, "newapi-video-generations-v1.yingce-plugin", "newapi-channel-2")
tests := []struct {
name string
payload string
}{
{name: "channel result URL", payload: `{"code":"success","data":{"task_id":"task-upstream","status":"SUCCESS","result_url":"https://cdn.example/channel-result.mp4"}}`},
{name: "provider nested video URL", payload: `{"code":"success","data":{"task_id":"task-upstream","status":"SUCCESS","data":{"status":"completed","video_url":"https://cdn.example/provider-result.mp4"}}}`},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
state, err := adapter.ParsePoll(context.Background(), PollContext{TaskID: "task-upstream"}, []byte(test.payload))
if err != nil {
t.Fatal(err)
}
if state.Status != StatusSucceeded || state.Result == nil || len(state.Result.Videos) != 1 {
t.Fatalf("state = %#v, want one completed video", state)
}
if state.Result.Videos[0].URL == "" {
t.Fatalf("video = %#v, want a result URL", state.Result.Videos[0])
}
})
}
}
func TestOfficialOpenAIVideosDeclaresAuthenticatedResultDownload(t *testing.T) {
adapter := officialPackageAdapter(t, "openai-videos.yingce-plugin", "newapi")
capability, ok := adapter.(ResultCapability)
if !ok || !capability.ResultAvailable() {
t.Fatal("OpenAI Videos result operation is unavailable")
}
resultAdapter, ok := adapter.(ResultAdapter)
if !ok {
t.Fatal("OpenAI Videos does not implement ResultAdapter")
}
spec, err := resultAdapter.BuildResult(context.Background(), PollContext{Model: "sora-2", TaskID: "video-1"})
if err != nil {
t.Fatal(err)
}
if spec.Method != "GET" || spec.Path != "/v1/videos/video-1/content" || spec.Headers["Accept"] != "video/mp4" || spec.Auth.Type != "bearer" {
t.Fatalf("result request = %#v", spec)
}
}
// 系统指令必须真正到达上游:只映射 messages 的协议要收到 system 消息,
// 有独立 system 字段的协议要用该字段且不能在消息数组里重复发送。
func TestOfficialTextProtocolsDeliverInstructions(t *testing.T) {
tests := []struct {
name, packageName, providerID, messagesField, instructionField string
}{
{name: "openai-chat", packageName: "openai-chat-completions.yingce-plugin", providerID: "chat-completion", messagesField: "messages"},
{name: "deepseek", packageName: "deepseek-chat.yingce-plugin", providerID: "deepseek-chat", messagesField: "messages"},
{name: "atlascloud", packageName: "atlascloud-chat.yingce-plugin", providerID: "atlascloud-chat", messagesField: "messages"},
{name: "openai-responses", packageName: "openai-responses.yingce-plugin", providerID: "openai-response", messagesField: "input", instructionField: "instructions"},
{name: "anthropic", packageName: "anthropic-messages.yingce-plugin", providerID: "claude-api", messagesField: "messages", instructionField: "system"},
{name: "gemini", packageName: "google-gemini-generate-content.yingce-plugin", providerID: "gemini-generate-content", messagesField: "contents", instructionField: "systemInstruction"},
}
const instructions = "只输出一个 JSON 对象"
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
adapter := officialPackageAdapter(t, test.packageName, test.providerID)
spec, err := adapter.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{
Capability: CapabilityText, Model: "model-test", Prompt: "一句话故事", Instructions: instructions,
}})
if err != nil {
t.Fatal(err)
}
body := manifestTestBody(t, spec)
encodedMessages, err := json.Marshal(body[test.messagesField])
if err != nil {
t.Fatal(err)
}
carriesInstructions := strings.Contains(string(encodedMessages), instructions)
if test.instructionField == "" {
if !carriesInstructions {
t.Fatalf("%s dropped the system instruction: %s", test.providerID, encodedMessages)
}
return
}
if carriesInstructions {
t.Fatalf("%s duplicated the system instruction into %s: %s", test.providerID, test.messagesField, encodedMessages)
}
encodedInstruction, err := json.Marshal(body[test.instructionField])
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(encodedInstruction), instructions) {
t.Fatalf("%s did not map the system instruction to %s: %s", test.providerID, test.instructionField, encodedInstruction)
}
})
}
}
func officialPackageAdapter(t *testing.T, packageName, providerID string) Adapter {
t.Helper()
data, err := os.ReadFile(filepath.Join("..", "..", "..", "plugin-packages", packageName))
if err != nil {
t.Fatal(err)
}
pkg, err := ParsePluginPackage(data)
if err != nil {
t.Fatal(err)
}
adapters, err := LoadInstalledProviders(pkg.ManifestRaw, nil)
if err != nil {
t.Fatal(err)
}
for _, adapter := range adapters {
if adapter.Metadata().ID == providerID {
return adapter
}
}
t.Fatalf("provider %s is missing from %s", providerID, packageName)
return nil
}
func manifestTestBody(t *testing.T, spec RequestSpec) map[string]any {
t.Helper()
data, err := json.Marshal(spec.Body)
if err != nil {
t.Fatal(err)
}
var body map[string]any
if err := json.Unmarshal(data, &body); err != nil {
t.Fatal(err)
}
return body
}
func TestOfficialArkSeedreamMapsAspectRatioToPixelSize(t *testing.T) {
adapter := officialPackageAdapter(t, "volcengine-ark-seedream.yingce-plugin", "volcengine-ark-image")
tests := []struct {
name, aspectRatio, wantSize string
}{
{name: "ratio maps to 2K pixels", aspectRatio: "1:1", wantSize: "2048x2048"},
{name: "landscape ratio", aspectRatio: "16:9", wantSize: "2560x1440"},
{name: "auto maps to 2k tier", aspectRatio: "auto", wantSize: "2k"},
{name: "pixel size passes through", aspectRatio: "1920x1080", wantSize: "1920x1080"},
{name: "tier passes through", aspectRatio: "4k", wantSize: "4k"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
create, err := adapter.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{Model: "doubao-seedream-5-0-260128", Prompt: "circle", AspectRatio: tt.aspectRatio}})
if err != nil {
t.Fatal(err)
}
if create.Path != "/api/v3/images/generations" {
t.Fatalf("Seedream create = %#v", create)
}
if body := manifestTestBody(t, create); body["size"] != tt.wantSize {
t.Fatalf("Seedream size = %#v, want %q", body["size"], tt.wantSize)
}
})
}
create, err := adapter.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{Model: "doubao-seedream-5-0-260128", Prompt: "circle"}})
if err != nil {
t.Fatal(err)
}
if _, ok := manifestTestBody(t, create)["size"]; ok {
t.Fatalf("Seedream must omit size when no ratio is requested")
}
}
func TestOfficialGeminiImageMapsQualityToImageSize(t *testing.T) {
adapter := officialPackageAdapter(t, "google-gemini-image.yingce-plugin", "gemini-image")
tests := []struct {
name, quality, wantSize string
wantOmitted bool
}{
{name: "4k becomes 4K", quality: "4k", wantSize: "4K"},
{name: "high becomes 4K", quality: "high", wantSize: "4K"},
{name: "2k becomes 2K", quality: "2k", wantSize: "2K"},
{name: "1k becomes 1K", quality: "1k", wantSize: "1K"},
{name: "video 720 omitted", quality: "720", wantOmitted: true},
{name: "auto omitted", quality: "auto", wantOmitted: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
create, err := adapter.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{
Model: "gemini-3-pro-image-preview", Prompt: "landscape", AspectRatio: "16:9", Quality: tt.quality,
}})
if err != nil {
t.Fatal(err)
}
if create.Path != "/v1beta/models/gemini-3-pro-image-preview:generateContent" {
t.Fatalf("create path = %q", create.Path)
}
body := manifestTestBody(t, create)
generationConfig, _ := body["generationConfig"].(map[string]any)
if _, ok := generationConfig["candidateCount"]; ok {
t.Fatalf("candidateCount must not be mapped from imageCount, got %#v", generationConfig["candidateCount"])
}
imageConfig, _ := generationConfig["imageConfig"].(map[string]any)
if imageConfig["aspectRatio"] != "16:9" {
t.Fatalf("aspectRatio = %#v", imageConfig["aspectRatio"])
}
if tt.wantOmitted {
if imageConfig["imageSize"] != nil {
t.Fatalf("imageSize should be omitted, got %#v", imageConfig["imageSize"])
}
return
}
if imageConfig["imageSize"] != tt.wantSize {
t.Fatalf("imageSize = %#v, want %q", imageConfig["imageSize"], tt.wantSize)
}
})
}
}
func TestOfficialGeminiImagePrefersQualityOverVideoResolution(t *testing.T) {
adapter := officialPackageAdapter(t, "google-gemini-image.yingce-plugin", "gemini-image")
tests := []struct {
name, quality, resolution, wantSize string
wantOmitted bool
}{
{name: "canvas 4k keeps imageSize when vquality is 720", quality: "4k", resolution: "720", wantSize: "4K"},
{name: "empty quality still maps resolution 4k", quality: "", resolution: "4k", wantSize: "4K"},
{name: "video 720 alone is omitted", quality: "", resolution: "720", wantOmitted: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
create, err := adapter.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{
Model: "gemini-3-pro-image-preview", Prompt: "landscape", AspectRatio: "16:9", Quality: tt.quality, Resolution: tt.resolution,
}})
if err != nil {
t.Fatal(err)
}
body := manifestTestBody(t, create)
generationConfig, _ := body["generationConfig"].(map[string]any)
imageConfig, _ := generationConfig["imageConfig"].(map[string]any)
if tt.wantOmitted {
if imageConfig["imageSize"] != nil {
t.Fatalf("imageSize should be omitted, got %#v", imageConfig["imageSize"])
}
return
}
if imageConfig["imageSize"] != tt.wantSize {
t.Fatalf("imageSize = %#v, want %q", imageConfig["imageSize"], tt.wantSize)
}
})
}
}
func TestOfficialGrokImageMapsAspectAndResolution(t *testing.T) {
adapter := officialPackageAdapter(t, "xai-grok-images.yingce-plugin", "grok-image")
create, err := adapter.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{
Model: "grok-imagine-image", Prompt: "a cat", AspectRatio: "1280x720", Quality: "high",
}})
if err != nil {
t.Fatal(err)
}
if create.Path != "/v1/images/generations" {
t.Fatalf("path = %q", create.Path)
}
body := manifestTestBody(t, create)
if body["aspect_ratio"] != "16:9" || body["resolution"] != "2k" {
t.Fatalf("body = %#v", body)
}
if _, ok := body["size"]; ok {
t.Fatalf("size must be omitted: %#v", body)
}
}
func TestOfficialJimengImageSplitsPixelSize(t *testing.T) {
adapter := officialPackageAdapter(t, "volcengine-jimeng-image.yingce-plugin", "volcengine-jimeng-image")
create, err := adapter.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{
Model: "jimeng_t2i_v40", Prompt: "still", AspectRatio: "1024x768",
Images: []MediaReference{{DataURL: "data:image/png;base64,aGVsbG8="}},
}})
if err != nil {
t.Fatal(err)
}
body := manifestTestBody(t, create)
if body["width"] != float64(1024) || body["height"] != float64(768) {
t.Fatalf("dimensions = %#v %#v", body["width"], body["height"])
}
binary, ok := body["binary_data_base64"].([]any)
if !ok || len(binary) != 1 || binary[0] != "aGVsbG8=" {
t.Fatalf("binary_data_base64 = %#v", body["binary_data_base64"])
}
}
func TestOfficialOpenAIAudioUsesBinaryPayload(t *testing.T) {
adapter := officialPackageAdapter(t, "openai-audio.yingce-plugin", "openai-audio")
result, err := adapter.ParseCreate(context.Background(), []byte("ID3fake-mp3"))
if err != nil {
t.Fatal(err)
}
if result.Status != StatusSucceeded || result.Result == nil || len(result.Result.Audios) != 1 {
t.Fatalf("result = %#v", result)
}
}
func TestOfficialOpenAIAudioSpeedDefaultsInvalidAndZeroValues(t *testing.T) {
adapter := officialPackageAdapter(t, "openai-audio.yingce-plugin", "openai-audio")
for _, test := range []struct {
name string
value any
want float64
}{
{name: "invalid", value: "not-a-number", want: 1},
{name: "zero", value: "0", want: 1},
{name: "valid", value: "1.25", want: 1.25},
} {
t.Run(test.name, func(t *testing.T) {
spec, err := adapter.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{
Model: "gpt-4o-mini-tts", Prompt: "hello", Extra: map[string]any{"audioSpeed": test.value},
}})
if err != nil {
t.Fatal(err)
}
body := manifestTestBody(t, spec)
if got := body["speed"]; !reflect.DeepEqual(got, test.want) {
t.Fatalf("speed = %#v, want %v", got, test.want)
}
})
}
}