Ctrl K
ComunidadGuías…

plugins/doubao_responses_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package plugins_test

import (
	"bytes"
	"fmt"
	"io"
	"maps"
	"net/http"
	"net/http/httptest"
	"slices"
	"strings"
	"testing"

	"github.com/QuantumNous/new-api/common"
	"github.com/QuantumNous/new-api/pkg/billingexpr"
	"github.com/QuantumNous/new-api/pkg/jsplugin"
	builtinplugins "github.com/QuantumNous/new-api/plugins"
	"github.com/QuantumNous/new-api/relay"
	taskplugin "github.com/QuantumNous/new-api/relay/channel/task/jsplugin"
	relaycommon "github.com/QuantumNous/new-api/relay/common"
	"github.com/gin-gonic/gin"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

const (
	doubaoImageRoute = "/doubao/api/v3/images/generations"
	doubaoBaseURL    = "https://ark.cn-beijing.volces.com"
)

func TestDoubaoResponsesProtocol(t *testing.T) {
	testVideoResponsesProtocol(t, videoResponsesTestCase{
		pluginKey: "doubao",
		model:     "doubao-seedance-2-0-260128",
		requestBody: map[string]any{
			"model": "doubao-seedance-2-0-260128",
			"input": []any{map[string]any{"role": "user", "content": []any{
				map[string]any{"type": "input_text", "text": "a running fox"},
				map[string]any{"type": "input_image", "image_url": "https://cdn.example/frame.png"},
			}}},
			"seconds": 6,
			"size":    "1920x1080",
		},
		wantAction: "image_to_video",
		wantRequest: map[string]any{
			"model":   "doubao-seedance-2-0-260128",
			"prompt":  "a running fox",
			"images":  []any{"https://cdn.example/frame.png"},
			"seconds": float64(6),
			"metadata": map[string]any{
				"resolution": "1080p",
			},
		},
		wantUsageKeys:  []string{"resolution", "tokens", "video_input"},
		wantVendorName: "doubao",
	})
}

func newDoubaoPlugin(t *testing.T) (*jsplugin.Registry, *jsplugin.LoadedPlugin) {
	t.Helper()
	source, err := builtinplugins.Source("doubao")
	require.NoError(t, err)
	registry := jsplugin.NewRegistry()
	plugin, err := registry.RegisterFactory(source, jsplugin.Options{Key: "doubao"})
	require.NoError(t, err)
	return registry, plugin
}

func decodeDoubaoImage(t *testing.T, registry *jsplugin.Registry, plugin *jsplugin.LoadedPlugin, body map[string]any) (map[string]any, error) {
	t.Helper()
	binding, found := registry.Generation().LookupDeclaredRoute(http.MethodPost, doubaoImageRoute)
	require.True(t, found)
	require.Equal(t, jsplugin.RouteTypeSubmit, binding.Route.Type)
	value, err := plugin.Engine.CallPath(t.Context(), "native", []string{binding.Route.Decode}, map[string]any{
		"path": doubaoImageRoute, "body": map[string]any{"kind": "json", "value": body},
	})
	if err != nil {
		return nil, err
	}
	return alibabaObject(t, value), nil
}

// Runs the production host validation, body conversion and usage extraction
// for one normalized image request without contacting Volcengine.
func submitDoubaoImage(t *testing.T, plugin *jsplugin.LoadedPlugin, action string, request map[string]any) (map[string]any, map[string]any, string) {
	t.Helper()
	modelName := request["model"].(string)
	// RelayTask copies the decode action onto the relay info before the adaptor runs.
	info := &relaycommon.RelayInfo{
		ChannelMeta:     &relaycommon.ChannelMeta{ChannelBaseUrl: doubaoBaseURL, UpstreamModelName: modelName},
		OriginModelName: modelName,
		TaskRelayInfo:   &relaycommon.TaskRelayInfo{PublicTaskID: "task_public", Action: action},
	}
	adaptor := taskplugin.New(plugin)
	adaptor.Init(info)
	c, _ := gin.CreateTestContext(httptest.NewRecorder())
	c.Request = httptest.NewRequest(http.MethodPost, doubaoImageRoute, nil)
	c.Set("task_request", request)
	require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
	reader, err := adaptor.BuildRequestBody(c, info)
	require.NoError(t, err)
	encoded, err := io.ReadAll(reader)
	require.NoError(t, err)
	var body map[string]any
	require.NoError(t, common.Unmarshal(encoded, &body))
	facts, err := adaptor.ExtractUsageFactsValidated(c, info)
	require.NoError(t, err)
	url, err := adaptor.BuildRequestURL(info)
	require.NoError(t, err)
	return body, alibabaObject(t, facts), url
}

func TestDoubaoImageSubmission(t *testing.T) {
	registry, plugin := newDoubaoPlugin(t)
	reference := "https://cdn.example/reference.png"
	// Every model reports the four facts released with the shared image schema,
	// including those its own profile omits, so expressions saved before the
	// per-model profiles keep their charge.
	noImages := map[string]any{"images_up_to_1_5k": float64(0), "images_above_1_5k": float64(0), "input_images": float64(0), "layer_decomposition": false}
	facts := func(overrides map[string]any) map[string]any {
		merged := map[string]any{}
		maps.Copy(merged, noImages)
		maps.Copy(merged, overrides)
		return merged
	}
	for _, tc := range []struct {
		name       string
		model      string
		body       map[string]any
		wantAction string
		wantBody   map[string]any // upstream fields whose value differs from the request
		wantFacts  map[string]any
	}{
		{"text to image with a preset size", "doubao-seedream-4-0-250828",
			map[string]any{"prompt": "a cat", "size": "2K", "watermark": false, "response_format": "url", "stream": false, "seed": 42, "guidance_scale": 2.5},
			"text_to_image", nil, facts(map[string]any{"images_above_1_5k": float64(1)})},
		{"1K reserves the lower pricing tier", "doubao-seedream-4-0-250828",
			map[string]any{"prompt": "a cat", "size": "1K", "optimize_prompt_options": nil},
			"text_to_image", nil, facts(map[string]any{"images_up_to_1_5k": float64(1)})},
		{"lowercase presets are normalized", "doubao-seedream-4-0-250828",
			map[string]any{"prompt": "a cat", "size": "2k"},
			"text_to_image", map[string]any{"size": "2K"}, facts(map[string]any{"images_above_1_5k": float64(1)})},
		{"pixel sizes are normalized to WxH", "doubao-seedream-4-5-251128",
			map[string]any{"prompt": "a banner", "size": "3750 * 1250"},
			"text_to_image", map[string]any{"size": "3750x1250"}, facts(map[string]any{"images_above_1_5k": float64(1)})},
		{"the 2.61 megapixel boundary belongs to the lower tier", "doubao-seedream-5-0-pro-260628",
			map[string]any{"prompt": "a poster", "size": "1500X1740"},
			"text_to_image", map[string]any{"size": "1500x1740"}, facts(map[string]any{"images_up_to_1_5k": float64(1)})},
		{"5.0 pro 1.5K with fast prompt optimization", "doubao-seedream-5-0-pro-260628",
			map[string]any{"prompt": "a poster", "size": "1.5K", "optimize_prompt_options": map[string]any{"mode": "fast"}, "output_format": "png"},
			"text_to_image", nil, facts(map[string]any{"images_up_to_1_5k": float64(1)})},
		{"group generation estimates max_images and reference images", "doubao-seedream-5-0-lite-260128",
			map[string]any{"prompt": "a brand kit", "image": reference, "size": "2K", "sequential_image_generation": "auto", "sequential_image_generation_options": map[string]any{"max_images": 4}, "output_format": "png", "tools": []any{map[string]any{"type": "web_search"}}},
			"image_to_image", nil, facts(map[string]any{"images_above_1_5k": float64(4), "input_images": float64(1)})},
		{"group generation is capped by reference images", "doubao-seedream-4-5-251128",
			map[string]any{"prompt": "four seasons", "image": []any{reference, "https://cdn.example/second.png"}, "sequential_image_generation": "auto"},
			"image_to_image", nil, facts(map[string]any{"images_above_1_5k": float64(13), "input_images": float64(2)})},
		{"base64 references count as input images", "doubao-seedream-5-0-pro-260628",
			map[string]any{"prompt": "a cat", "image": []any{reference, "data:image/png;base64,iVBORw0KGgo="}, "size": "1K"},
			"image_to_image", nil, facts(map[string]any{"images_up_to_1_5k": float64(1), "input_images": float64(2)})},
		{"transparent background on 5.0 pro", "doubao-seedream-5-0-pro-260628",
			map[string]any{"prompt": "cut out the cat", "image": reference, "background": "transparent", "output_format": "png", "size": "1K"},
			"image_to_image", nil, facts(map[string]any{"images_up_to_1_5k": float64(1), "input_images": float64(1)})},
		{"background is forwarded to other models for upstream to decide", "doubao-seedream-5-0-lite-260128",
			map[string]any{"prompt": "cut out the cat", "background": "transparent", "size": "2K"},
			"text_to_image", nil, facts(map[string]any{"images_above_1_5k": float64(1)})},
		{"layer decomposition with auto size reserves every output at the higher tier", "doubao-seedream-5-0-pro-260628",
			map[string]any{"image": reference, "layer_decomposition": true, "size": "auto"},
			"image_to_image", nil, facts(map[string]any{"images_above_1_5k": float64(17), "input_images": float64(1), "layer_decomposition": true})},
		{"layer decomposition at 1K reserves the lower tier", "doubao-seedream-5-0-pro-260628",
			map[string]any{"image": reference, "layer_decomposition": true, "size": "1k"},
			"image_to_image", map[string]any{"size": "1K"}, facts(map[string]any{"images_up_to_1_5k": float64(17), "input_images": float64(1), "layer_decomposition": true})},
		{"5.0 flash layer decomposition at 1.5K", "doubao-seedream-5-0-flash-260915",
			map[string]any{"image": reference, "layer_decomposition": true, "size": "1.5K", "output_format": "png"},
			"image_to_image", nil, facts(map[string]any{"images_up_to_1_5k": float64(17), "input_images": float64(1), "layer_decomposition": true})},
		{"endpoint ids use the permissive profile", "ep-20260918-seedream",
			map[string]any{"prompt": "a cat", "size": "4K", "sequential_image_generation": "auto"},
			"text_to_image", nil, facts(map[string]any{"images_above_1_5k": float64(15)})},
	} {
		t.Run(tc.name, func(t *testing.T) {
			request := map[string]any{"model": tc.model}
			maps.Copy(request, tc.body)
			resolved, err := decodeDoubaoImage(t, registry, plugin, request)
			require.NoError(t, err)
			assert.Equal(t, tc.model, resolved["model"])
			assert.Equal(t, tc.wantAction, resolved["action"])
			body, facts, url := submitDoubaoImage(t, plugin, tc.wantAction, resolved["requestBody"].(map[string]any))
			assert.Equal(t, doubaoBaseURL+"/api/v3/images/generations", url)
			assert.Equal(t, tc.model, body["model"])
			assert.NotContains(t, body, "stream")
			for key, expected := range alibabaObject(t, tc.body) {
				if key == "stream" {
					continue
				}
				if override, ok := tc.wantBody[key]; ok {
					expected = override
				}
				assert.Contains(t, body, key)
				assert.Equal(t, expected, body[key], key)
			}
			assert.Equal(t, tc.wantFacts, facts)
		})
	}

	// Legacy per-call pricing multiplies the price by every ratio, so the
	// reservation ratio is only the total requested output count: one 1K image
	// with two reference images reserves price × 1, not price × 1 × 2.
	t.Run("legacy per-call pricing reserves only the total output count", func(t *testing.T) {
		for _, tc := range []struct {
			name  string
			model string
			body  map[string]any
			want  float64
		}{
			{"1K image with two reference images", "doubao-seedream-5-0-pro-260628", map[string]any{"prompt": "a cat", "image": []any{reference, "https://cdn.example/second.png"}, "size": "1K"}, 1},
			{"group generation", "doubao-seedream-5-0-lite-260128", map[string]any{"prompt": "a brand kit", "image": reference, "size": "2K", "sequential_image_generation": "auto", "sequential_image_generation_options": map[string]any{"max_images": 4}}, 4},
			{"layer decomposition", "doubao-seedream-5-0-pro-260628", map[string]any{"image": reference, "layer_decomposition": true, "size": "auto"}, 17},
		} {
			request := map[string]any{"model": tc.model}
			maps.Copy(request, tc.body)
			resolved, err := decodeDoubaoImage(t, registry, plugin, request)
			require.NoError(t, err, tc.name)
			ctx := map[string]any{"upstreamModel": tc.model, "model": tc.model, "action": resolved["action"], "requestBody": resolved["requestBody"]}
			ctx["usagePurpose"] = "billing_ratios"
			value, err := plugin.Engine.Call(t.Context(), "extractUsage", ctx)
			require.NoError(t, err, tc.name)
			assert.Equal(t, map[string]any{"image_count": tc.want}, alibabaObject(t, value), tc.name)
			ctx["usagePurpose"] = "facts"
			value, err = plugin.Engine.Call(t.Context(), "extractUsage", ctx)
			require.NoError(t, err, tc.name)
			facts := alibabaObject(t, value)
			assert.NotContains(t, facts, "image_count", tc.name)
			assert.Equal(t, tc.want, facts["images_up_to_1_5k"].(float64)+facts["images_above_1_5k"].(float64), tc.name)
		}
	})

	// Each model's pricing lists only the tiers it can output and layer
	// decomposition only where it is supported.
	t.Run("image models serve Responses and OpenAI Images host protocols", func(t *testing.T) {
		layered := []string{"images_up_to_1_5k", "images_above_1_5k", "input_images", "layer_decomposition"}
		oneTier := []string{"images_above_1_5k", "input_images"}
		for name, usageKeys := range map[string][]string{
			"doubao-seedream-5-0-pro-260628":   layered,
			"doubao-seedream-5-0-flash-260915": layered,
			"doubao-seedream-5-0-lite-260128":  oneTier,
			"doubao-seedream-5-0-260128":       oneTier,
			"doubao-seedream-4-5-251128":       oneTier,
			"doubao-seedream-4-0-250828":       {"images_up_to_1_5k", "images_above_1_5k", "input_images"},
		} {
			_, found := registry.Generation().LookupEndpoint(http.MethodPost, "/v1/responses", name)
			assert.True(t, found, name)
			for _, path := range []string{"/v1/images/generations", "/v1/images/edits"} {
				binding, found := registry.Generation().LookupEndpoint(http.MethodPost, path, name)
				require.True(t, found, name+" "+path)
				assert.Equal(t, "openai_image", binding.Protocol)
			}
			_, found = registry.Generation().LookupEndpoint(http.MethodPost, "/v1/videos", name)
			assert.False(t, found, name)
			schema, examples := plugin.Meta.UsageForModel(name)
			assert.ElementsMatch(t, usageKeys, keysOf(schema), name)
			assert.NotEmpty(t, examples, name)
		}
		_, found := registry.Generation().LookupEndpoint(http.MethodPost, "/v1/videos", "doubao-seedance-2-0-260128")
		assert.True(t, found)
		_, found = registry.Generation().LookupEndpoint(http.MethodPost, "/v1/images/generations", "doubao-seedance-2-0-260128")
		assert.False(t, found)
		schema, _ := plugin.Meta.UsageForModel("doubao-seedance-2-0-260128")
		assert.ElementsMatch(t, []string{"tokens", "resolution", "video_input"}, keysOf(schema))
		schema, examples := plugin.Meta.UsageForModel("doubao-seedance-1-5-pro-251215")
		assert.ElementsMatch(t, []string{"tokens", "resolution", "generate_audio"}, keysOf(schema))
		assert.Equal(t, "boolean", schema["generate_audio"].Type)
		assert.NotEmpty(t, examples)
	})
}

// Capability profiles follow the Ark model list: pricing lists only the
// resolutions each Seedance model offers, reference video input only on
// Seedance 2.x, and audio output only on Seedance 1.5 pro.
func TestDoubaoSeedanceUsageFacts(t *testing.T) {
	_, plugin := newDoubaoPlugin(t)
	const (
		pro10  = "doubao-seedance-1-0-pro-250528"
		pro15  = "doubao-seedance-1-5-pro-251215"
		v20    = "doubao-seedance-2-0-260128"
		fast20 = "doubao-seedance-2-0-fast-260128"
		mini20 = "doubao-seedance-2-0-mini-260615"
		v25    = "doubao-seedance-2-5-260628"
	)
	families := []struct {
		models      []string
		resolutions []string
		fields      []string
	}{
		{[]string{pro10, "doubao-seedance-1-0-lite-t2v", "doubao-seedance-1-0-lite-i2v"}, []string{"480p", "720p", "1080p"}, []string{"resolution", "tokens"}},
		{[]string{pro15}, []string{"480p", "720p", "1080p"}, []string{"generate_audio", "resolution", "tokens"}},
		{[]string{v20}, []string{"480p", "720p", "1080p", "4k"}, []string{"resolution", "tokens", "video_input"}},
		{[]string{fast20, mini20}, []string{"480p", "720p"}, []string{"resolution", "tokens", "video_input"}},
		{[]string{v25}, []string{"480p", "720p", "1080p"}, []string{"resolution", "tokens", "video_input"}},
	}
	profiled := make([]string, 0, len(plugin.Meta.Models))
	for _, family := range families {
		for _, name := range family.models {
			profiled = append(profiled, name)
			t.Run(name, func(t *testing.T) {
				schema, examples := plugin.Meta.UsageForModel(name)
				assert.Equal(t, family.fields, slices.Sorted(maps.Keys(schema)))
				assert.Equal(t, family.resolutions, schema["resolution"].Enum)
				require.NotEmpty(t, examples)
				for _, example := range examples {
					assert.Equal(t, family.fields, slices.Sorted(maps.Keys(example.Facts)), example.Label)
					assert.Contains(t, family.resolutions, example.Facts["resolution"], example.Label)
				}
			})
		}
	}
	videoModels := make([]string, 0, len(profiled))
	for _, name := range plugin.Meta.Models {
		if strings.Contains(name, "seedance") {
			videoModels = append(videoModels, name)
		}
	}
	assert.ElementsMatch(t, videoModels, profiled, "every Seedance model selects a capability profile")

	// A table price saved when every Seedance model declared video_input: its
	// last row is the unconditioned fallback.
	const sharedMatrix = `u("resolution") == "480p" && u("video_input") == "none" ? tier("480p·none", u("tokens") * 2 / 1000000) : ` +
		`u("resolution") == "480p" && u("video_input") == "video" ? tier("480p·video", u("tokens") * 3 / 1000000) : ` +
		`u("resolution") == "720p" && u("video_input") == "none" ? tier("720p·none", u("tokens") * 4 / 1000000) : ` +
		`u("resolution") == "720p" && u("video_input") == "video" ? tier("720p·video", u("tokens") * 5 / 1000000) : ` +
		`u("resolution") == "1080p" && u("video_input") == "none" ? tier("1080p·none", u("tokens") * 6 / 1000000) : ` +
		`u("resolution") == "1080p" && u("video_input") == "video" ? tier("1080p·video", u("tokens") * 7 / 1000000) : ` +
		`u("resolution") == "4k" && u("video_input") == "none" ? tier("4k·none", u("tokens") * 8 / 1000000) : ` +
		`tier("4k·video", u("tokens") * 9 / 1000000)`
	for _, tc := range []struct {
		name    string
		model   string
		request map[string]any
		want    map[string]any
		wantErr string
	}{
		{"1.5 pro defaults to audio", pro15, map[string]any{"metadata": map[string]any{"resolution": "720p"}},
			map[string]any{"tokens": float64(108000), "resolution": "720p", "generate_audio": true, "video_input": "none"}, ""},
		{"1.5 pro silent output", pro15, map[string]any{"metadata": map[string]any{"resolution": "720p", "generate_audio": false}},
			map[string]any{"tokens": float64(108000), "resolution": "720p", "generate_audio": false, "video_input": "none"}, ""},
		{"2.0 has no audio fact", v20, map[string]any{"metadata": map[string]any{"resolution": "720p", "generate_audio": false}},
			map[string]any{"tokens": float64(108000), "resolution": "720p", "video_input": "none"}, ""},
		{"2.0 offers 4k", v20, map[string]any{"metadata": map[string]any{"resolution": "4k"}},
			map[string]any{"tokens": float64(972000), "resolution": "4k", "video_input": "none"}, ""},
		{"1.0 pro reports no reference video although its profile omits it", pro10, map[string]any{"metadata": map[string]any{"resolution": "1080p"}},
			map[string]any{"tokens": float64(243000), "resolution": "1080p", "video_input": "none"}, ""},
		{"mini reserves its highest tier when the resolution is left to Ark", mini20, map[string]any{},
			map[string]any{"tokens": float64(108000), "resolution": "720p", "video_input": "none"}, ""},
		{"mini rejects 1080p", mini20, map[string]any{"metadata": map[string]any{"resolution": "1080p"}}, nil, mini20 + " resolution must be one of 480p, 720p"},
		{"mini rejects a 1080p size", mini20, map[string]any{"size": "1920x1080"}, nil, mini20 + " resolution must be one of 480p, 720p"},
		{"fast rejects 4k", fast20, map[string]any{"metadata": map[string]any{"resolution": "4k"}}, nil, fast20 + " resolution must be one of 480p, 720p"},
		{"2.5 rejects 4k", v25, map[string]any{"metadata": map[string]any{"resolution": "4K"}}, nil, v25 + " resolution must be one of 480p, 720p, 1080p"},
	} {
		t.Run(tc.name, func(t *testing.T) {
			request := map[string]any{"model": tc.model, "prompt": "a cat", "seconds": float64(5)}
			maps.Copy(request, tc.request)
			info := &relaycommon.RelayInfo{
				ChannelMeta:     &relaycommon.ChannelMeta{ChannelBaseUrl: doubaoBaseURL, UpstreamModelName: tc.model},
				OriginModelName: tc.model,
				TaskRelayInfo:   &relaycommon.TaskRelayInfo{PublicTaskID: "task_public", Action: "text_to_video"},
			}
			adaptor := taskplugin.New(plugin)
			adaptor.Init(info)
			c, _ := gin.CreateTestContext(httptest.NewRecorder())
			c.Request = httptest.NewRequest(http.MethodPost, "/v1/videos", nil)
			c.Set("task_request", request)
			taskErr := adaptor.ValidateRequestAndSetAction(c, info)
			if tc.wantErr != "" {
				require.NotNil(t, taskErr)
				assert.Equal(t, http.StatusBadRequest, taskErr.StatusCode)
				assert.Contains(t, taskErr.Message, tc.wantErr)
				return
			}
			require.Nil(t, taskErr)
			reader, err := adaptor.BuildRequestBody(c, info)
			require.NoError(t, err)
			encoded, err := io.ReadAll(reader)
			require.NoError(t, err)
			var body map[string]any
			require.NoError(t, common.Unmarshal(encoded, &body))
			metadata, _ := tc.request["metadata"].(map[string]any)
			assert.Equal(t, metadata["generate_audio"], body["generate_audio"])
			facts, err := adaptor.ExtractUsageFactsValidated(c, info)
			require.NoError(t, err)
			assert.Equal(t, tc.want, alibabaObject(t, facts))
			_, trace, err := billingexpr.RunExprWithRequest(sharedMatrix, billingexpr.TokenParams{}, billingexpr.RequestInput{Usage: facts})
			require.NoError(t, err)
			assert.Equal(t, tc.want["resolution"].(string)+"·none", trace.MatchedTier, "saved table prices keep the no-video tier")
		})
	}

	// Ark reads duration -1 as "the model picks the length" (Seedance 1.5 pro
	// and 2.x; 2.5 also defaults to it). The reservation covers the longest
	// output: 720p is 1280 × 720 × 24 / 1024 = 21,600 tokens per second.
	t.Run("duration -1 reaches Ark and reserves the longest duration", func(t *testing.T) {
		for _, tc := range []struct {
			name         string
			model        string
			request      any
			wantDuration any
			wantTokens   float64
		}{
			{"2.5 metadata on /v1/video/generations", v25, relaycommon.TaskSubmitReq{
				Model: v25, Prompt: "a cat", Metadata: map[string]any{"duration": -1, "resolution": "720p"},
			}, float64(-1), 30 * 21600},
			{"2.5 without a duration keeps the Ark default", v25, map[string]any{
				"model": v25, "prompt": "a cat", "metadata": map[string]any{"resolution": "720p"},
			}, nil, 30 * 21600},
			{"2.0 seconds from Responses or /v1/videos", v20, map[string]any{
				"model": v20, "prompt": "a cat", "seconds": -1, "metadata": map[string]any{"resolution": "720p"},
			}, float64(-1), 15 * 21600},
			{"1.5 pro", pro15, map[string]any{
				"model": pro15, "prompt": "a cat", "metadata": map[string]any{"duration": -1, "resolution": "720p"},
			}, float64(-1), 12 * 21600},
		} {
			t.Run(tc.name, func(t *testing.T) {
				info := &relaycommon.RelayInfo{
					ChannelMeta:     &relaycommon.ChannelMeta{ChannelBaseUrl: doubaoBaseURL, UpstreamModelName: tc.model},
					OriginModelName: tc.model,
					TaskRelayInfo:   &relaycommon.TaskRelayInfo{PublicTaskID: "task_public", Action: "text_to_video"},
				}
				adaptor := taskplugin.New(plugin)
				adaptor.Init(info)
				c, _ := gin.CreateTestContext(httptest.NewRecorder())
				c.Request = httptest.NewRequest(http.MethodPost, "/v1/video/generations", nil)
				c.Set("task_request", tc.request)
				require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
				reader, err := adaptor.BuildRequestBody(c, info)
				require.NoError(t, err)
				encoded, err := io.ReadAll(reader)
				require.NoError(t, err)
				var body map[string]any
				require.NoError(t, common.Unmarshal(encoded, &body))
				assert.Equal(t, tc.wantDuration, body["duration"])
				facts, err := adaptor.ExtractUsageFactsValidated(c, info)
				require.NoError(t, err)
				assert.Equal(t, tc.wantTokens, facts["tokens"])
			})
		}

		decode := func(body map[string]any) (any, error) {
			return plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_video", "decodeRequest"}, map[string]any{
				"model": v20, "body": map[string]any{"kind": "json", "value": body},
			})
		}
		value, err := decode(map[string]any{"model": v20, "prompt": "a cat", "duration": -1})
		require.NoError(t, err)
		assert.Equal(t, float64(-1), alibabaObject(t, value)["requestBody"].(map[string]any)["seconds"])
		_, err = decode(map[string]any{"model": v20, "prompt": "a cat", "seconds": -2})
		require.ErrorContains(t, err, "seconds must be -1 or between 1 and 3600")
	})

	t.Run("completion overlays only resolutions the model offers", func(t *testing.T) {
		for _, tc := range []struct {
			model      string
			resolution string
			want       map[string]any
		}{
			{mini20, "1080p", map[string]any{"tokens": float64(90000)}},
			{v25, "4k", map[string]any{"tokens": float64(90000)}},
			{v20, "4k", map[string]any{"tokens": float64(90000), "resolution": "4k"}},
		} {
			queryContext := map[string]any{"model": tc.model, "upstreamModel": tc.model, "action": "text_to_video"}
			body := map[string]any{
				"status":  "succeeded",
				"usage":   map[string]any{"completion_tokens": 90000},
				"content": map[string]any{"video_url": "https://cdn.example/video.mp4", "resolution": tc.resolution},
			}
			value, err := plugin.Engine.Call(t.Context(), "extractUsageOnComplete", queryContext, map[string]any{"status": "SUCCESS"}, body)
			require.NoError(t, err, tc.model)
			assert.Equal(t, tc.want, alibabaObject(t, value), tc.model)
		}
	})
}

func keysOf(schema map[string]jsplugin.UsageFieldSchema) []string {
	keys := make([]string, 0, len(schema))
	for key := range schema {
		keys = append(keys, key)
	}
	return keys
}

// Rejections mirror the Ark API reference; the model-capability cases were
// confirmed against live 400 responses (2026-09).
func TestDoubaoImageValidation(t *testing.T) {
	registry, plugin := newDoubaoPlugin(t)
	const pro, flash, lite, v45, v40 = "doubao-seedream-5-0-pro-260628", "doubao-seedream-5-0-flash-260915", "doubao-seedream-5-0-lite-260128", "doubao-seedream-4-5-251128", "doubao-seedream-4-0-250828"
	reference := "https://cdn.example/1.png"
	manyReferences := make([]any, 0, 11)
	for index := range 11 {
		manyReferences = append(manyReferences, fmt.Sprintf("https://cdn.example/%d.png", index))
	}
	for _, tc := range []struct {
		name    string
		model   string
		body    map[string]any
		wantErr string
	}{
		{"prompt is required", v40, map[string]any{}, "prompt is required"},
		{"stream is a boolean", v40, map[string]any{"prompt": "a cat", "stream": "yes"}, "stream must be a boolean"},
		{"response formats are Ark's", v40, map[string]any{"prompt": "a cat", "response_format": "png"}, "response_format must be url or b64_json"},
		{"sequential_image_generation is an enum", lite, map[string]any{"prompt": "a cat", "sequential_image_generation": "on"}, "must be auto or disabled"},
		{"sequential options must be an object", lite, map[string]any{"prompt": "a cat", "sequential_image_generation": "auto", "sequential_image_generation_options": 3}, "sequential_image_generation_options must be an object"},
		{"max_images is bounded", lite, map[string]any{"prompt": "a cat", "sequential_image_generation": "auto", "sequential_image_generation_options": map[string]any{"max_images": 16}}, "max_images must be an integer between 1 and 15"},
		{"reference images are bounded", pro, map[string]any{"prompt": "a cat", "image": manyReferences}, "at most 10 reference images"},
		{"fast prompt optimization is unsupported on 5.0 flash", flash, map[string]any{"prompt": "a cat", "optimize_prompt_options": map[string]any{"mode": "fast"}}, "mode fast is not supported"},
		{"fast prompt optimization is unsupported on 5.0 lite", lite, map[string]any{"prompt": "a cat", "optimize_prompt_options": map[string]any{"mode": "fast"}}, "mode fast is not supported"},
		{"fast prompt optimization is unsupported on 4.5", v45, map[string]any{"prompt": "a cat", "optimize_prompt_options": map[string]any{"mode": "fast"}}, "mode fast is not supported"},
		{"prompt optimization mode is an enum", v40, map[string]any{"prompt": "a cat", "optimize_prompt_options": map[string]any{"mode": "turbo"}}, "must be standard or fast"},
		{"prompt optimization options must be an object", v40, map[string]any{"prompt": "a cat", "optimize_prompt_options": 123}, "optimize_prompt_options must be an object"},
		{"output_format is an enum", pro, map[string]any{"prompt": "a cat", "output_format": "webp"}, "output_format must be png or jpeg"},
		{"output_format follows the model", v40, map[string]any{"prompt": "a cat", "output_format": "png"}, "output_format is not supported"},
		{"background is a string enum", pro, map[string]any{"prompt": "a cat", "image": reference, "background": true}, "background must be opaque or transparent"},
		{"transparent background needs exactly one input image", pro, map[string]any{"prompt": "a cat", "background": "transparent"}, "requires exactly one input image"},
		{"transparent background rejects jpeg output", pro, map[string]any{"prompt": "a cat", "image": reference, "background": "transparent", "output_format": "jpeg"}, "cannot be combined with output_format jpeg"},
		{"tools must be objects with a type", lite, map[string]any{"prompt": "a cat", "tools": []any{map[string]any{}}}, "tools must be an array of objects with a string type"},
		{"tools must be an array", lite, map[string]any{"prompt": "a cat", "tools": map[string]any{"type": "web_search"}}, "tools must be an array of objects with a string type"},
		{"tools follow the model", pro, map[string]any{"prompt": "a cat", "tools": []any{map[string]any{"type": "web_search"}}}, "tools are not supported"},
		{"seed must be an integer", v40, map[string]any{"prompt": "a cat", "seed": "abc"}, "seed must be an integer"},
		{"seed rejects fractions", v40, map[string]any{"prompt": "a cat", "seed": 1.5}, "seed must be an integer"},
		{"guidance_scale must be a number", v40, map[string]any{"prompt": "a cat", "guidance_scale": "x"}, "guidance_scale must be a number"},
		{"size must be a string", v40, map[string]any{"prompt": "a cat", "size": 2048}, "size must be a string"},
		{"presets follow the model", v45, map[string]any{"prompt": "a cat", "size": "1K"}, "size must be one of 2K, 4K"},
		{"5.0 flash tops out at 2K", flash, map[string]any{"prompt": "a cat", "size": "4K"}, "size must be one of 1K, 1.5K, 2K"},
		{"pixel sizes follow the model", v45, map[string]any{"prompt": "a cat", "size": "1500x1500"}, "outside the model's pixel"},
		{"aspect ratios are bounded", v40, map[string]any{"prompt": "a cat", "size": "8192x480"}, "outside the model's pixel"},
		{"layer decomposition needs one image", pro, map[string]any{"layer_decomposition": true}, "requires exactly one input image"},
		{"layer decomposition follows the model", v40, map[string]any{"prompt": "a cat", "image": reference, "layer_decomposition": true}, "layer_decomposition is not supported"},
		{"layer decomposition sizes are presets", pro, map[string]any{"image": reference, "layer_decomposition": true, "size": "2048x2048"}, "layer decomposition sizes must be one of"},
		{"auto size needs layer decomposition", v40, map[string]any{"prompt": "a cat", "size": "auto"}, "size auto is only supported"},
		{"image references must be URLs", v40, map[string]any{"prompt": "a cat", "image": "ftp://cdn.example/1.png"}, "image must be an HTTP URL"},
		{"base64 references need the full data URI", v40, map[string]any{"prompt": "a cat", "image": "data:image/png,iVBORw0KGgo="}, "image must be an HTTP URL"},
		{"base64 formats are lowercase", v40, map[string]any{"prompt": "a cat", "image": "data:image/PNG;base64,iVBORw0KGgo="}, "image must be an HTTP URL"},
	} {
		t.Run(tc.name, func(t *testing.T) {
			request := map[string]any{"model": tc.model}
			maps.Copy(request, tc.body)
			_, err := decodeDoubaoImage(t, registry, plugin, request)
			require.ErrorContains(t, err, tc.wantErr)
		})
	}
}

// Ark checks which models stream and which generate groups; the reservation
// counts a group at its largest.
func TestDoubaoImageCapabilitiesAreArksToCheck(t *testing.T) {
	registry, plugin := newDoubaoPlugin(t)
	const pro, flash = "doubao-seedream-5-0-pro-260628", "doubao-seedream-5-0-flash-260915"
	facts := func(lower, higher float64) map[string]any {
		return map[string]any{"images_up_to_1_5k": lower, "images_above_1_5k": higher, "input_images": float64(0), "layer_decomposition": false}
	}
	for _, tc := range []struct {
		name      string
		body      map[string]any
		wantFacts map[string]any
	}{
		{"a stream from 5.0 pro", map[string]any{"model": pro, "prompt": "a cat", "stream": true}, facts(0, 1)},
		{"a group from 5.0 pro", map[string]any{"model": pro, "prompt": "a cat", "sequential_image_generation": "auto"}, facts(0, 15)},
		{"group generation disabled on 5.0 pro", map[string]any{"model": pro, "prompt": "a cat", "sequential_image_generation": "disabled"}, facts(0, 1)},
		{"a group from 5.0 flash", map[string]any{"model": flash, "prompt": "a cat", "size": "1K", "sequential_image_generation": "auto",
			"sequential_image_generation_options": map[string]any{"max_images": 4}}, facts(4, 0)},
	} {
		t.Run(tc.name, func(t *testing.T) {
			request, err := decodeDoubaoImage(t, registry, plugin, tc.body)
			require.NoError(t, err)
			body, facts, _ := submitDoubaoImage(t, plugin, "text_to_image", request["requestBody"].(map[string]any))
			assert.Equal(t, alibabaObject(t, tc.body), body)
			assert.Equal(t, tc.wantFacts, facts)
		})
	}

	request, err := decodeDoubaoOpenAIImage(t, plugin, "generate", pro, map[string]any{"kind": "json", "value": map[string]any{"prompt": "a cat", "sequential_image_generation": "auto"}})
	require.NoError(t, err)
	body, gotFacts, _ := submitDoubaoImage(t, plugin, "text_to_image", request["requestBody"].(map[string]any))
	assert.Equal(t, "auto", body["sequential_image_generation"])
	assert.Equal(t, facts(0, 15), gotFacts)
}

// Fixtures mirror real Ark responses captured on 2026-09-18 (signed URLs replaced).
func TestDoubaoImageResults(t *testing.T) {
	_, plugin := newDoubaoPlugin(t)
	const lite, pro = "doubao-seedream-5-0-lite-260128", "doubao-seedream-5-0-pro-260628"
	first, second := "https://ark-content.example/1.jpeg?sig=secret", "https://ark-content.example/2.jpeg?sig=secret"
	// 5.0 lite group of two plus one failed member; Ark echoes the canonical model name.
	groupBody := map[string]any{
		"model":   "doubao-seedream-5-0-260128",
		"created": 1789733151,
		"data": []any{
			map[string]any{"url": first, "size": "2848x1600"},
			map[string]any{"url": second, "size": "2848x1600"},
			map[string]any{"error": map[string]any{"code": "OutputImageSensitiveContentDetected", "message": "blocked"}},
		},
		"usage": map[string]any{"generated_images": 2, "output_tokens": 35600, "total_tokens": 35600},
	}
	// 5.0 pro layer decomposition with size auto: base and first layer above 2.61 MP, second layer below.
	layerBody := map[string]any{
		"model":   pro,
		"created": 1789733460,
		"data": []any{
			map[string]any{"url": "https://ark-content.example/base.jpeg?sig=secret", "size": "2848x1600", "output_format": "jpeg", "z_index": 0},
			map[string]any{"url": "https://ark-content.example/layer1.png?sig=secret", "size": "2848x1600", "output_format": "png", "z_index": 1, "name": "背景场景", "description": "室内木桌背景",
				"bounding_box": map[string]any{"absolute": []any{0, 0, 2848, 1600}, "normalized": []any{0, 0, 1000, 999}}},
			map[string]any{"url": "https://ark-content.example/layer2.png?sig=secret", "size": "1125x956", "output_format": "png", "z_index": 2, "name": "主体橘猫", "description": "坐姿橘猫",
				"bounding_box": map[string]any{"absolute": []any{955, 622, 2080, 1578}, "normalized": []any{335, 389, 730, 986}}},
		},
		"usage": map[string]any{"input_images": 1, "generated_images": 3, "output_tokens": 42316, "total_tokens": 42316},
	}
	// 5.0 pro single 1K image from two reference images.
	referenceBody := map[string]any{
		"model":   pro,
		"created": 1789733390,
		"data":    []any{map[string]any{"url": first, "size": "1424x800", "output_format": "jpeg"}},
		"usage":   map[string]any{"input_images": 2, "generated_images": 1, "output_tokens": 4450, "total_tokens": 4450},
	}

	parseResponse := func(t *testing.T, model string, request map[string]any, payload map[string]any) *relaycommon.TaskInfo {
		t.Helper()
		info := &relaycommon.RelayInfo{OriginModelName: model, ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: model, ChannelBaseUrl: doubaoBaseURL}, TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_public"}}
		adaptor := taskplugin.New(plugin)
		adaptor.Init(info)
		c, _ := gin.CreateTestContext(httptest.NewRecorder())
		c.Request = httptest.NewRequest(http.MethodPost, doubaoImageRoute, nil)
		c.Set("task_request", request)
		encoded, err := common.Marshal(payload)
		require.NoError(t, err)
		parsed, taskErr := adaptor.ParseResponse(c, &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"application/json"}}, Body: io.NopCloser(bytes.NewReader(encoded))}, info)
		require.Nil(t, taskErr)
		require.NotNil(t, parsed.Immediate)
		assert.Equal(t, "task_public", parsed.UpstreamTaskID)
		assert.JSONEq(t, string(encoded), string(parsed.TaskData))
		return parsed.Immediate
	}
	groupRequest := map[string]any{"model": lite, "prompt": "a cat", "sequential_image_generation": "auto"}
	queryContext := map[string]any{"upstreamModel": lite, "model": lite, "action": "text_to_image"}
	proQuery := map[string]any{"upstreamModel": pro, "model": pro, "action": "text_to_image"}

	t.Run("a single-tier group settles every delivered image", func(t *testing.T) {
		immediate := parseResponse(t, lite, groupRequest, groupBody)
		assert.Equal(t, "SUCCESS", immediate.Status)
		assert.Equal(t, "100%", immediate.Progress)
		assert.Equal(t, first, immediate.Url)
		assert.Equal(t, map[string]any{"images_above_1_5k": float64(2)}, immediate.UsageFacts)

		value, err := plugin.Engine.CallPath(t.Context(), "native", []string{"imageCreated"}, map[string]any{}, map[string]any{"task_id": "task_public", "status": "SUCCESS", "data": groupBody})
		require.NoError(t, err)
		assert.Equal(t, alibabaObject(t, groupBody), alibabaObject(t, value))

		value, err = plugin.Engine.Call(t.Context(), "extractUsageOnSubmit", queryContext, groupBody)
		require.NoError(t, err)
		assert.Equal(t, map[string]any{"image_count": float64(2)}, alibabaObject(t, value), "legacy per-call pricing settles on the delivered count")
	})

	// Legacy per-call pricing multiplies the price by every ratio the plugin
	// returns, so the submit-time settlement carries only the total delivered
	// output count; a mixed-tier result with a reference image must settle 5
	// images, not price × 4 × 1 × 1.
	t.Run("legacy per-call pricing settles the total output count", func(t *testing.T) {
		tieredBody := map[string]any{
			"model": pro,
			"data": []any{
				map[string]any{"url": first, "size": "1024x1024"},
				map[string]any{"url": second, "size": "1024x1024"},
				map[string]any{"url": first, "size": "1024x1024"},
				map[string]any{"url": second, "size": "1024x1024"},
				map[string]any{"url": first, "size": "2848x1600"},
			},
			"usage": map[string]any{"input_images": 1, "generated_images": 5},
		}
		value, err := plugin.Engine.Call(t.Context(), "extractUsageOnComplete", proQuery, map[string]any{"status": "SUCCESS"}, tieredBody)
		require.NoError(t, err)
		assert.Equal(t, map[string]any{"images_up_to_1_5k": float64(4), "images_above_1_5k": float64(1), "input_images": float64(1)}, alibabaObject(t, value), "task expressions keep the tiered facts")
		for _, tc := range []struct {
			name string
			body map[string]any
			want float64
		}{
			{"mixed tiers with a reference image", tieredBody, 5},
			{"layer decomposition", layerBody, 3},
			{"single image from two reference images", referenceBody, 1},
		} {
			value, err := plugin.Engine.Call(t.Context(), "extractUsageOnSubmit", queryContext, tc.body)
			require.NoError(t, err, tc.name)
			assert.Equal(t, map[string]any{"image_count": tc.want}, alibabaObject(t, value), tc.name)
		}
		value, err = plugin.Engine.Call(t.Context(), "extractUsageOnSubmit", queryContext, map[string]any{"data": []any{map[string]any{"error": map[string]any{"code": "x"}}}})
		require.NoError(t, err)
		assert.Empty(t, alibabaObject(t, value), "no delivered image leaves the reservation in place")
	})

	t.Run("mixed-tier layers settle per layer with the input image count", func(t *testing.T) {
		request := map[string]any{"model": pro, "image": "https://cdn.example/photo.png", "layer_decomposition": true, "size": "auto"}
		immediate := parseResponse(t, pro, request, layerBody)
		assert.Equal(t, "SUCCESS", immediate.Status)
		assert.Equal(t, map[string]any{"images_up_to_1_5k": float64(1), "images_above_1_5k": float64(2), "input_images": float64(1)}, immediate.UsageFacts)
	})

	t.Run("reference images settle from usage.input_images", func(t *testing.T) {
		request := map[string]any{"model": pro, "prompt": "a cat", "image": []any{"https://cdn.example/a.png", "https://cdn.example/b.png", "https://cdn.example/c.png"}, "size": "1K"}
		immediate := parseResponse(t, pro, request, referenceBody)
		assert.Equal(t, map[string]any{"images_up_to_1_5k": float64(1), "images_above_1_5k": float64(0), "input_images": float64(2)}, immediate.UsageFacts)
	})

	t.Run("invalid completion counts retain the reservation", func(t *testing.T) {
		for _, count := range []any{-1, 0, 1.5, 99, "2", 3} {
			payload := map[string]any{"data": []any{map[string]any{"url": first, "size": "2048x2048"}}, "usage": map[string]any{"generated_images": count}}
			immediate := parseResponse(t, lite, groupRequest, payload)
			assert.Equal(t, "SUCCESS", immediate.Status)
			assert.Nil(t, immediate.UsageFacts, "count %v must not replace the reservation", count)
		}
	})

	t.Run("invalid input image counts keep the estimate while tiers settle", func(t *testing.T) {
		for _, count := range []any{-1, 1.5, 15, "2"} {
			payload := map[string]any{"data": []any{map[string]any{"url": first, "size": "1424x800"}}, "usage": map[string]any{"generated_images": 1, "input_images": count}}
			value, err := plugin.Engine.Call(t.Context(), "extractUsageOnComplete", proQuery, map[string]any{"status": "SUCCESS"}, payload)
			require.NoError(t, err)
			assert.Equal(t, map[string]any{"images_up_to_1_5k": float64(1), "images_above_1_5k": float64(0)}, alibabaObject(t, value), "input_images %v", count)
		}
	})

	t.Run("missing sizes keep the estimated tiers", func(t *testing.T) {
		payload := map[string]any{"data": []any{map[string]any{"url": first, "size": "2048x2048"}, map[string]any{"url": second}}, "usage": map[string]any{"generated_images": 2, "input_images": 1}}
		value, err := plugin.Engine.Call(t.Context(), "extractUsageOnComplete", proQuery, map[string]any{"status": "SUCCESS"}, payload)
		require.NoError(t, err)
		assert.Equal(t, map[string]any{"input_images": float64(1)}, alibabaObject(t, value))
		// A single-tier model needs no size to know the tier.
		value, err = plugin.Engine.Call(t.Context(), "extractUsageOnComplete", queryContext, map[string]any{"status": "SUCCESS"}, payload)
		require.NoError(t, err)
		assert.Equal(t, map[string]any{"images_above_1_5k": float64(2), "input_images": float64(1)}, alibabaObject(t, value))
	})

	t.Run("missing usage settles from image payload sizes", func(t *testing.T) {
		payload := map[string]any{"data": []any{map[string]any{"url": first, "size": "2848x1600"}, map[string]any{"url": second, "size": "1024x1024"}, map[string]any{"error": map[string]any{"code": "x"}}}}
		value, err := plugin.Engine.Call(t.Context(), "extractUsageOnComplete", proQuery, map[string]any{"status": "SUCCESS"}, payload)
		require.NoError(t, err)
		assert.Equal(t, map[string]any{"images_up_to_1_5k": float64(1), "images_above_1_5k": float64(1)}, alibabaObject(t, value))
	})

	t.Run("failed and empty results do not complete a task", func(t *testing.T) {
		for _, payload := range []map[string]any{
			{"error": map[string]any{"code": "InvalidParameter", "message": "The parameter `seed` specified in the request is not valid. Request id: 0217", "param": "", "type": ""}},
			{"data": []any{map[string]any{"error": map[string]any{"code": "OutputImageSensitiveContentDetected", "message": "blocked"}}}, "usage": map[string]any{"generated_images": 0}},
			{"data": []any{}},
		} {
			_, err := plugin.Engine.Call(t.Context(), "parseSubmitResponse", map[string]any{"upstreamModel": lite, "publicTaskId": "task_public", "requestBody": map[string]any{"model": lite, "prompt": "a cat"}}, map[string]any{"statusCode": 200, "body": payload})
			require.Error(t, err)
		}
	})

	t.Run("artifacts and Responses output use gateway URLs", func(t *testing.T) {
		value, err := plugin.Engine.Call(t.Context(), "listArtifacts", map[string]any{"taskId": "task_public", "status": "SUCCESS", "action": "text_to_image", "data": groupBody})
		require.NoError(t, err)
		encoded, err := common.Marshal(value)
		require.NoError(t, err)
		var artifacts []map[string]any
		require.NoError(t, common.Unmarshal(encoded, &artifacts))
		require.Len(t, artifacts, 2)
		assert.Equal(t, "image-1", artifacts[0]["key"])
		assert.Equal(t, "image", artifacts[0]["type"])
		assert.Equal(t, "image-2", artifacts[1]["key"])

		value, err = plugin.Engine.Call(t.Context(), "buildContentRequest", map[string]any{"artifactKey": "image-2", "action": "text_to_image", "data": groupBody, "clientRequest": map[string]any{"method": "GET"}})
		require.NoError(t, err)
		content := alibabaObject(t, value)
		assert.Equal(t, second, content["url"])
		assert.Equal(t, true, content["credentialless"])

		gateway := map[string]any{
			"model": lite,
			"artifacts": map[string]any{
				"image-1": map[string]any{"key": "image-1", "type": "image", "url": "https://gateway.example/v1/tasks/task_public/artifacts/image-1/content?access=a"},
				"image-2": map[string]any{"key": "image-2", "type": "image", "url": "https://gateway.example/v1/tasks/task_public/artifacts/image-2/content?access=b"},
			},
		}
		task := map[string]any{"task_id": "task_public", "status": "SUCCESS", "progress": "100%", "created_at": 10, "updated_at": 20, "data": groupBody}
		value, err = plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "renderFinal"}, gateway, task)
		require.NoError(t, err)
		machine := relay.NewPluginResponsesMachine("task_public", lite, 10, relay.DefaultPluginProtocolLimits())
		response, err := machine.FinalResponse(value, "SUCCESS")
		require.NoError(t, err)
		text := response["output"].([]any)[0].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string)
		assert.Contains(t, text, "artifacts/image-1/content")
		assert.Contains(t, text, "artifacts/image-2/content")
		assert.NotContains(t, text, "ark-content.example")

		_, err = plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "renderFinal"}, map[string]any{"model": lite}, task)
		require.ErrorContains(t, err, "image artifact is unavailable")
	})
}

func TestDoubaoImageResponsesDecode(t *testing.T) {
	_, plugin := newDoubaoPlugin(t)
	const model = "doubao-seedream-5-0-lite-260128"
	decode := func(t *testing.T, body map[string]any) (map[string]any, error) {
		t.Helper()
		value, err := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_responses", "decodeRequest"}, map[string]any{
			"model": body["model"], "stream": false, "body": map[string]any{"kind": "json", "value": body},
		})
		if err != nil {
			return nil, err
		}
		return alibabaObject(t, value), nil
	}

	resolved, err := decode(t, map[string]any{
		"model": model,
		"input": []any{map[string]any{"role": "user", "content": []any{
			map[string]any{"type": "input_text", "text": "a poster"},
			map[string]any{"type": "input_image", "image_url": "https://cdn.example/ref.png"},
		}}},
		"size":                                "2K",
		"sequential_image_generation":         "auto",
		"sequential_image_generation_options": map[string]any{"max_images": 3},
		"watermark":                           false,
		// Host-owned background execution flag: must never become Ark's transparency option.
		"background": true,
		// Only Ark's web_search tool is forwarded, stripped to its type; function tools are dropped.
		"tools": []any{
			map[string]any{"type": "web_search", "search_context_size": "low"},
			map[string]any{"type": "function", "name": "lookup", "parameters": map[string]any{}},
		},
		"metadata": map[string]any{"ignored": true},
	})
	require.NoError(t, err)
	assert.Equal(t, model, resolved["model"])
	assert.Equal(t, "image_to_image", resolved["action"])
	request := resolved["requestBody"].(map[string]any)
	assert.Equal(t, map[string]any{
		"model": model, "prompt": "a poster", "image": []any{"https://cdn.example/ref.png"},
		"size": "2K", "sequential_image_generation": "auto", "sequential_image_generation_options": map[string]any{"max_images": float64(3)}, "watermark": false,
		"tools": []any{map[string]any{"type": "web_search"}},
	}, request)
	body, facts, url := submitDoubaoImage(t, plugin, "image_to_image", request)
	assert.Equal(t, doubaoBaseURL+"/api/v3/images/generations", url)
	assert.Equal(t, []any{"https://cdn.example/ref.png"}, body["image"])
	assert.NotContains(t, body, "background")
	assert.Equal(t, map[string]any{"images_up_to_1_5k": float64(0), "images_above_1_5k": float64(3), "input_images": float64(1), "layer_decomposition": false}, facts)

	t.Run("function-only tools and background never reach a model without tool support", func(t *testing.T) {
		resolved, err := decode(t, map[string]any{"model": "doubao-seedream-4-0-250828", "input": "a cat", "background": true, "tools": []any{map[string]any{"type": "function", "name": "lookup"}}})
		require.NoError(t, err)
		assert.Equal(t, map[string]any{"model": "doubao-seedream-4-0-250828", "prompt": "a cat"}, resolved["requestBody"])
	})

	_, err = decode(t, map[string]any{"model": model, "input": "a cat", "response_format": "b64_json"})
	require.ErrorContains(t, err, "response_format must be url")
}

func decodeDoubaoOpenAIImage(t *testing.T, plugin *jsplugin.LoadedPlugin, operation, model string, body map[string]any) (map[string]any, error) {
	t.Helper()
	path := "/v1/images/generations"
	if operation == "edit" {
		path = "/v1/images/edits"
	}
	value, err := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_image", "decodeRequest"}, map[string]any{
		"protocol": "openai_image", "operation": operation, "model": model, "path": path, "method": http.MethodPost, "body": body,
	})
	if err != nil {
		return nil, err
	}
	return alibabaObject(t, value), nil
}

// OpenAI Images requests reach Ark with the same body, action and usage facts
// as the equivalent native request. Host-owned and OpenAI-only fields are not
// forwarded, and the facts never gain an image_count.
func TestDoubaoOpenAIImageProtocol(t *testing.T) {
	registry, plugin := newDoubaoPlugin(t)
	const pro, lite, v40 = "doubao-seedream-5-0-pro-260628", "doubao-seedream-5-0-lite-260128", "doubao-seedream-4-0-250828"
	reference := "https://cdn.example/reference.png"
	facts := func(lower, higher, inputs float64, layered bool) map[string]any {
		return map[string]any{"images_up_to_1_5k": lower, "images_above_1_5k": higher, "input_images": inputs, "layer_decomposition": layered}
	}
	for _, tc := range []struct {
		name      string
		model     string
		operation string
		native    map[string]any // the equivalent native route body
		extra     map[string]any // OpenAI fields accepted but not forwarded
		wantFacts map[string]any
	}{
		{"text to image", v40, "generate",
			map[string]any{"prompt": "a cat", "size": "2K", "seed": 42, "watermark": false},
			map[string]any{"n": 1, "response_format": "b64_json", "quality": "high", "user": "u1"}, facts(0, 1, 0, false)},
		{"1K reserves the lower tier", pro, "generate",
			map[string]any{"prompt": "a cat", "size": "1K", "output_format": "png"},
			map[string]any{"response_format": "url", "style": "vivid", "stream": false}, facts(1, 0, 0, false)},
		{"group generation with a reference image", lite, "edit",
			map[string]any{"prompt": "a brand kit", "image": reference, "size": "2K", "sequential_image_generation": "auto", "sequential_image_generation_options": map[string]any{"max_images": 4}, "tools": []any{map[string]any{"type": "web_search"}}},
			map[string]any{"n": nil}, facts(0, 4, 1, false)},
		{"layer decomposition", pro, "edit",
			map[string]any{"image": []any{reference}, "layer_decomposition": true, "size": "auto"},
			nil, facts(0, 17, 1, true)},
	} {
		t.Run(tc.name, func(t *testing.T) {
			nativeBody := map[string]any{"model": tc.model}
			maps.Copy(nativeBody, tc.native)
			native, err := decodeDoubaoImage(t, registry, plugin, nativeBody)
			require.NoError(t, err)
			openaiBody := map[string]any{"model": tc.model}
			maps.Copy(openaiBody, tc.native)
			maps.Copy(openaiBody, tc.extra)
			resolved, err := decodeDoubaoOpenAIImage(t, plugin, tc.operation, tc.model, map[string]any{"kind": "json", "value": openaiBody})
			require.NoError(t, err)
			assert.Equal(t, tc.model, resolved["model"])
			assert.Equal(t, native["action"], resolved["action"])
			assert.Equal(t, native["requestBody"], resolved["requestBody"])
			body, facts, url := submitDoubaoImage(t, plugin, resolved["action"].(string), resolved["requestBody"].(map[string]any))
			assert.Equal(t, doubaoBaseURL+"/api/v3/images/generations", url)
			for key := range tc.extra {
				assert.NotContains(t, body, key)
			}
			assert.Equal(t, tc.wantFacts, facts)
		})
	}

	t.Run("multipart edits send uploads as Base64 data URLs", func(t *testing.T) {
		resolved, err := decodeDoubaoOpenAIImage(t, plugin, "edit", lite, map[string]any{"kind": "multipart",
			"fields": map[string][]string{
				"model": {lite}, "prompt": {"merge the two"}, "image": {reference}, "size": {"2K"}, "n": {"1"}, "response_format": {"b64_json"}, "watermark": {"false"}, "seed": {"7"},
				"sequential_image_generation": {"auto"}, "sequential_image_generation_options": {`{"max_images":2}`},
			},
			"files": []any{
				map[string]any{"ref": "request_file:image[]", "field": "image[]", "filename": "a.png", "mimeType": "image/png", "size": 10},
				map[string]any{"ref": "request_file:image[]#1", "field": "image[]", "filename": "b.jpg", "mimeType": "IMAGE/JPEG", "size": 10},
			},
		})
		require.NoError(t, err)
		assert.Equal(t, "image_to_image", resolved["action"])
		images := []any{
			reference,
			map[string]any{"__fileRef": "request_file:image[]", "encoding": "dataUrl", "mimeType": "image/png", "maxBytes": float64(31457280)},
			map[string]any{"__fileRef": "request_file:image[]#1", "encoding": "dataUrl", "mimeType": "image/jpeg", "maxBytes": float64(31457280)},
		}
		assert.Equal(t, map[string]any{
			"model": lite, "prompt": "merge the two", "image": images, "size": "2K", "watermark": false, "seed": float64(7),
			"sequential_image_generation": "auto", "sequential_image_generation_options": map[string]any{"max_images": float64(2)},
		}, resolved["requestBody"])
		driver := map[string]any{"upstreamModel": lite, "model": lite, "action": "image_to_image", "requestBody": resolved["requestBody"], "baseUrl": doubaoBaseURL, "apiKey": "k", "usagePurpose": "facts"}
		value, err := plugin.Engine.Call(t.Context(), "buildSubmitRequest", driver)
		require.NoError(t, err)
		assert.Equal(t, images, alibabaObject(t, value)["body"].(map[string]any)["image"], "placeholders reach the upstream body for the host to inline")
		value, err = plugin.Engine.Call(t.Context(), "extractUsage", driver)
		require.NoError(t, err)
		assert.Equal(t, facts(0, 2, 3, false), alibabaObject(t, value))
		driver["usagePurpose"] = "billing_ratios"
		value, err = plugin.Engine.Call(t.Context(), "extractUsage", driver)
		require.NoError(t, err)
		assert.Equal(t, map[string]any{"image_count": float64(2)}, alibabaObject(t, value))
	})

	t.Run("requests Ark cannot honor are rejected before billing", func(t *testing.T) {
		for _, tc := range []struct {
			name      string
			model     string
			operation string
			body      map[string]any
			wantErr   string
		}{
			{"counts other than one", v40, "generate", map[string]any{"kind": "json", "value": map[string]any{"prompt": "a cat", "n": 2}}, "n must be 1"},
			{"multipart counts other than one", lite, "edit", map[string]any{"kind": "multipart", "fields": map[string][]string{"prompt": {"a cat"}, "image": {reference}, "n": {"4"}}}, "n must be 1"},
			{"streaming", v40, "generate", map[string]any{"kind": "json", "value": map[string]any{"prompt": "a cat", "stream": true}}, "stream is not supported"},
			{"unknown response formats", v40, "generate", map[string]any{"kind": "json", "value": map[string]any{"prompt": "a cat", "response_format": "base64"}}, "response_format must be url or b64_json"},
			{"edits without an image", v40, "edit", map[string]any{"kind": "json", "value": map[string]any{"prompt": "a cat"}}, "image is required"},
			{"masks", v40, "edit", map[string]any{"kind": "json", "value": map[string]any{"prompt": "a cat", "image": reference, "mask": reference}}, "mask is not supported"},
			{"mask uploads", v40, "edit", map[string]any{"kind": "multipart", "fields": map[string][]string{"prompt": {"a cat"}}, "files": []any{
				map[string]any{"ref": "request_file:image", "field": "image", "filename": "a.png", "mimeType": "image/png", "size": 10},
				map[string]any{"ref": "request_file:mask", "field": "mask", "filename": "m.png", "mimeType": "image/png", "size": 10},
			}}, "mask is not supported"},
			{"non-boolean multipart flags", v40, "edit", map[string]any{"kind": "multipart", "fields": map[string][]string{"prompt": {"a cat"}, "image": {reference}, "watermark": {"yes"}}}, "watermark must be true or false"},
			{"model capabilities", pro, "generate", map[string]any{"kind": "json", "value": map[string]any{"prompt": "a cat", "tools": []any{map[string]any{"type": "web_search"}}}}, "tools are not supported"},
		} {
			_, err := decodeDoubaoOpenAIImage(t, plugin, tc.operation, tc.model, tc.body)
			require.ErrorContains(t, err, tc.wantErr, tc.name)
		}
	})

	t.Run("render returns the delivered image URLs", func(t *testing.T) {
		first, second := "https://ark-content.example/1.jpeg?sig=secret", "https://ark-content.example/2.jpeg?sig=secret"
		body := map[string]any{
			"model": lite, "created": 1789733151,
			"data": []any{
				map[string]any{"url": first, "size": "2848x1600"},
				map[string]any{"error": map[string]any{"code": "OutputImageSensitiveContentDetected", "message": "blocked"}},
				map[string]any{"url": second, "size": "2848x1600"},
			},
			"usage": map[string]any{"generated_images": 2},
		}
		value, err := plugin.Engine.CallPath(t.Context(), "protocols", []string{"openai_image", "render"}, map[string]any{"model": lite}, map[string]any{"task_id": "task_public", "status": "SUCCESS", "created_at": 1789733150, "data": body})
		require.NoError(t, err)
		assert.Equal(t, map[string]any{"created": float64(1789733150), "data": []any{map[string]any{"url": first}, map[string]any{"url": second}}}, alibabaObject(t, value))
	})
}

// Channel model mapping may send a declared Seedream model to an Ark endpoint ID.
// The declared model still drives capability checks and the image usage profile.
func TestDoubaoImageEndpointMappingKeepsDeclaredModel(t *testing.T) {
	_, plugin := newDoubaoPlugin(t)
	const declared, endpoint = "doubao-seedream-5-0-pro-260628", "ep-20260918-seedream"
	for _, tc := range []struct {
		name    string
		body    map[string]any
		wantErr string
	}{
		{"declared capabilities still apply", map[string]any{"prompt": "a cat", "tools": []any{map[string]any{"type": "web_search"}}}, "tools are not supported"},
		{"the endpoint receives the request", map[string]any{"prompt": "a cat", "size": "2K"}, ""},
	} {
		t.Run(tc.name, func(t *testing.T) {
			request := map[string]any{"model": declared}
			maps.Copy(request, tc.body)
			info := &relaycommon.RelayInfo{
				ChannelMeta:     &relaycommon.ChannelMeta{ChannelBaseUrl: doubaoBaseURL, UpstreamModelName: endpoint},
				OriginModelName: declared,
				TaskRelayInfo:   &relaycommon.TaskRelayInfo{PublicTaskID: "task_public", Action: "text_to_image"},
			}
			adaptor := taskplugin.New(plugin)
			adaptor.Init(info)
			c, _ := gin.CreateTestContext(httptest.NewRecorder())
			c.Request = httptest.NewRequest(http.MethodPost, doubaoImageRoute, nil)
			c.Set("task_request", request)
			taskErr := adaptor.ValidateRequestAndSetAction(c, info)
			if tc.wantErr != "" {
				require.NotNil(t, taskErr)
				assert.Equal(t, "plugin_request_invalid", taskErr.Code)
				assert.Contains(t, taskErr.Message, tc.wantErr)
				return
			}
			require.Nil(t, taskErr)
			reader, err := adaptor.BuildRequestBody(c, info)
			require.NoError(t, err)
			encoded, err := io.ReadAll(reader)
			require.NoError(t, err)
			var body map[string]any
			require.NoError(t, common.Unmarshal(encoded, &body))
			assert.Equal(t, endpoint, body["model"])
			facts, err := adaptor.ExtractUsageFactsValidated(c, info)
			require.NoError(t, err)
			assert.Equal(t, map[string]any{"images_up_to_1_5k": float64(0), "images_above_1_5k": float64(1), "input_images": float64(0), "layer_decomposition": false}, alibabaObject(t, facts))
		})
	}
}

// Native routes send Ark's own bodies and answer with Ark's own replies.
func TestDoubaoNativeOfficialBodies(t *testing.T) {
	registry, plugin := newDoubaoPlugin(t)
	const seedance, lite = "doubao-seedance-2-0-260128", "doubao-seedream-5-0-lite-260128"
	call := func(path []string, args ...any) (map[string]any, error) {
		value, err := plugin.Engine.CallPath(t.Context(), path[0], path[1:], args...)
		if err != nil {
			return nil, err
		}
		return alibabaObject(t, value), nil
	}
	jsonBody := func(body map[string]any) map[string]any {
		return map[string]any{"body": map[string]any{"kind": "json", "value": body}}
	}

	t.Run("video content keeps the client's order and texts", func(t *testing.T) {
		content := []any{
			map[string]any{"type": "text", "text": "a cat"},
			map[string]any{"type": "image_url", "image_url": map[string]any{"url": "https://cdn.example/a.png"}, "role": "first_frame"},
			map[string]any{"type": "text", "text": "turns around"},
		}
		body := map[string]any{"model": seedance, "content": content, "duration": float64(6), "ratio": "16:9"}
		intent, err := call([]string{"native", "createTask"}, jsonBody(body))
		require.NoError(t, err)
		assert.Equal(t, "image_to_video", intent["action"])
		driver := map[string]any{"model": seedance, "upstreamModel": seedance, "requestBody": intent["requestBody"], "baseUrl": doubaoBaseURL, "apiKey": "key"}
		descriptor, err := call([]string{"buildSubmitRequest"}, driver)
		require.NoError(t, err)
		assert.Equal(t, alibabaObject(t, body), descriptor["body"])
		facts, err := call([]string{"extractUsage"}, driver)
		require.NoError(t, err)
		assert.Equal(t, "1080p", facts["resolution"], "an omitted resolution reserves the model's highest tier up to 1080p")
		assert.InDelta(t, 6*1920*1080*24/1024.0, facts["tokens"], 1e-6)
	})

	// Ark checks the duration, which goes as sent. The reservation reads the
	// number when it can and takes the model's longest duration otherwise.
	for _, tc := range []struct {
		name     string
		duration any
		seconds  float64
	}{
		{"a fractional duration", 5.9, 5.9},
		{"a duration given as a string", "5", 5},
		{"a duration that is not a number", true, 15},
	} {
		t.Run(tc.name, func(t *testing.T) {
			body := map[string]any{"model": seedance, "content": []any{map[string]any{"type": "text", "text": "a cat"}}, "duration": tc.duration}
			intent, err := call([]string{"native", "createTask"}, jsonBody(body))
			require.NoError(t, err)
			driver := map[string]any{"model": seedance, "upstreamModel": seedance, "requestBody": intent["requestBody"], "baseUrl": doubaoBaseURL, "apiKey": "key"}
			descriptor, err := call([]string{"buildSubmitRequest"}, driver)
			require.NoError(t, err)
			assert.Equal(t, alibabaObject(t, body), descriptor["body"])
			facts, err := call([]string{"extractUsage"}, driver)
			require.NoError(t, err)
			assert.InDelta(t, tc.seconds*1920*1080*24/1024.0, facts["tokens"], 1e-6)
		})
	}

	for _, tc := range []struct {
		name    string
		body    map[string]any
		wantErr string
	}{
		{"the model names the price", map[string]any{"content": []any{map[string]any{"type": "text", "text": "a cat"}}}, "model is required"},
		{"callbacks carry Ark's task ids", map[string]any{"model": seedance, "content": []any{map[string]any{"type": "text", "text": "a cat"}}, "callback_url": "https://client.example/hook"}, "callback_url is not supported"},
		{"content is required", map[string]any{"model": seedance}, "content is required"},
		{"content needs an item", map[string]any{"model": seedance, "content": []any{nil}}, "content is required"},
		{"content is an array", map[string]any{"model": seedance, "content": "a cat"}, "content must be an array"},
	} {
		t.Run(tc.name, func(t *testing.T) {
			_, err := call([]string{"native", "createTask"}, jsonBody(tc.body))
			require.ErrorContains(t, err, tc.wantErr)
		})
	}

	// A /v1/videos client cannot pass a body off as a native one.
	t.Run("a vendorBody field is not a native body", func(t *testing.T) {
		intent, err := call([]string{"protocols", "openai_video", "decodeRequest"}, map[string]any{"model": seedance, "body": map[string]any{"kind": "json", "value": map[string]any{
			"model": seedance, "prompt": "a cat", "vendorBody": map[string]any{"model": seedance, "content": []any{map[string]any{"type": "text", "text": "x"}}, "callback_url": "https://client.example/hook"},
		}}})
		require.NoError(t, err)
		descriptor, err := call([]string{"buildSubmitRequest"}, map[string]any{"model": seedance, "upstreamModel": seedance, "requestBody": intent["requestBody"], "baseUrl": doubaoBaseURL, "apiKey": "key"})
		require.NoError(t, err)
		assert.Equal(t, map[string]any{"model": seedance, "content": []any{map[string]any{"type": "text", "text": "a cat"}}}, descriptor["body"])
	})

	t.Run("query replies", func(t *testing.T) {
		created, err := call([]string{"native", "taskStatus"}, map[string]any{}, map[string]any{"task_id": "task_pub", "status": "SUBMITTED", "data": map[string]any{"id": "task_pub"}})
		require.NoError(t, err)
		assert.Equal(t, map[string]any{"id": "task_pub", "status": "queued"}, created)
		failed, err := call([]string{"native", "taskStatus"}, map[string]any{}, map[string]any{"task_id": "task_pub", "status": "FAILURE", "fail_reason": "task timed out",
			"data": map[string]any{"id": "task_pub", "model": seedance, "status": "running"}})
		require.NoError(t, err)
		assert.Equal(t, map[string]any{"id": "task_pub", "model": seedance, "status": "failed", "error": map[string]any{"code": "InternalServiceError", "message": "task timed out"}}, failed)
		for _, status := range []string{"pending", "processing"} {
			result, err := call([]string{"parseTaskResult"}, map[string]any{}, map[string]any{"status": status})
			require.NoError(t, err)
			assert.Equal(t, "UNKNOWN", result["status"], "%s is not an Ark status", status)
		}
		rendered, err := call([]string{"native", "error"}, map[string]any{}, map[string]any{"httpStatus": 400, "message": "content is required"})
		require.NoError(t, err)
		assert.Equal(t, map[string]any{"error": map[string]any{"code": "InvalidParameter", "message": "content is required"}}, rendered)
	})

	t.Run("Base64 images settle like URLs", func(t *testing.T) {
		request, err := decodeDoubaoImage(t, registry, plugin, map[string]any{"model": lite, "prompt": "a cat", "response_format": "b64_json"})
		require.NoError(t, err)
		body, _, _ := submitDoubaoImage(t, plugin, "text_to_image", request["requestBody"].(map[string]any))
		assert.Equal(t, "b64_json", body["response_format"])
		parsed, err := call([]string{"parseSubmitResponse"}, map[string]any{"upstreamModel": lite, "publicTaskId": "task_public", "requestBody": request["requestBody"]},
			map[string]any{"body": map[string]any{"data": []any{map[string]any{"b64_json": "aGVsbG8=", "size": "2048x2048"}}, "usage": map[string]any{"generated_images": 1}}})
		require.NoError(t, err)
		assert.Equal(t, "SUCCESS", parsed["immediate"].(map[string]any)["status"])
	})

	// Ark streams each image; the gateway reads the same events it relays to a
	// native client and settles on the images Ark delivered.
	t.Run("streamed images settle on what was delivered", func(t *testing.T) {
		request, err := decodeDoubaoImage(t, registry, plugin, map[string]any{"model": lite, "prompt": "seasons", "stream": true, "response_format": "b64_json",
			"sequential_image_generation": "auto", "sequential_image_generation_options": map[string]any{"max_images": 3}})
		require.NoError(t, err)
		info := &relaycommon.RelayInfo{OriginModelName: lite, ChannelMeta: &relaycommon.ChannelMeta{UpstreamModelName: lite, ChannelBaseUrl: doubaoBaseURL},
			TaskRelayInfo: &relaycommon.TaskRelayInfo{PublicTaskID: "task_public", Action: "text_to_image"}}
		adaptor := taskplugin.New(plugin)
		adaptor.Init(info)
		c, _ := gin.CreateTestContext(httptest.NewRecorder())
		c.Request = httptest.NewRequest(http.MethodPost, doubaoImageRoute, nil)
		c.Set("task_request", request["requestBody"])
		require.Nil(t, adaptor.ValidateRequestAndSetAction(c, info))
		reader, err := adaptor.BuildRequestBody(c, info)
		require.NoError(t, err)
		encoded, err := io.ReadAll(reader)
		require.NoError(t, err)
		assert.Contains(t, string(encoded), `"stream":true`)
		stream := strings.Join([]string{
			`data: {"type":"image_generation.partial_succeeded","model":"doubao-seedream-5-0-260128","created":1,"image_index":0,"b64_json":"aGVsbG8=","size":"2048x2048"}`, ``,
			`data: {"type":"image_generation.partial_failed","model":"doubao-seedream-5-0-260128","created":1,"image_index":1,"error":{"code":"OutputImageSensitiveContentDetected","message":"blocked"}}`, ``,
			`data: {"type":"image_generation.partial_succeeded","model":"doubao-seedream-5-0-260128","created":1,"image_index":2,"b64_json":"d29ybGQ=","size":"2048x2048"}`, ``,
			`data: {"type":"image_generation.completed","model":"doubao-seedream-5-0-260128","created":1,"usage":{"generated_images":2,"output_tokens":32768,"total_tokens":32768}}`, ``,
			`data: [DONE]`, ``, ``,
		}, "\n")
		parsed, taskErr := adaptor.ParseResponse(c, &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": {"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(stream))}, info)
		require.Nil(t, taskErr)
		require.NotNil(t, parsed.Immediate)
		assert.Equal(t, "SUCCESS", parsed.Immediate.Status)
		assert.Equal(t, map[string]any{"images_above_1_5k": float64(2)}, parsed.Immediate.UsageFacts, "5.0 lite bills one tier, for the two delivered images")
		assert.NotContains(t, string(parsed.TaskData), "aGVsbG8=", "the streamed Base64 payloads are not kept")
	})

	// A streamed request's events have reached the client, where a thrown
	// error keeps the reservation, so Ark's own verdict that nothing was made
	// fails the task instead. A JSON reply keeps failing the submission.
	t.Run("Ark's failure events fail a streamed request", func(t *testing.T) {
		streamed, err := decodeDoubaoImage(t, registry, plugin, map[string]any{"model": lite, "prompt": "seasons", "stream": true})
		require.NoError(t, err)
		ctx := map[string]any{"upstreamModel": lite, "publicTaskId": "task_public", "requestBody": streamed["requestBody"]}
		for _, tc := range []struct {
			name, wantReason string
			body             map[string]any
		}{
			{"the request failed as a whole", "AuthenticationError: the API key is invalid", map[string]any{"data": []any{}, "error": map[string]any{"code": "AuthenticationError", "message": "the API key is invalid"}}},
			{"completed without an image", "image generation returned no images", map[string]any{"data": []any{map[string]any{"image_index": 0, "error": map[string]any{"code": "OutputImageSensitiveContentDetected"}}}, "usage": map[string]any{"generated_images": 0}}},
		} {
			parsed, err := call([]string{"parseSubmitResponse"}, ctx, map[string]any{"body": tc.body})
			require.NoError(t, err, tc.name)
			assert.Equal(t, map[string]any{"status": "FAILURE", "reason": tc.wantReason}, parsed["immediate"], tc.name)
		}
		_, err = call([]string{"parseSubmitResponse"}, ctx, map[string]any{"body": map[string]any{"data": []any{}}})
		require.ErrorContains(t, err, "image generation returned no images", "without Ark's verdict the outcome is unknown")

		synchronous, err := decodeDoubaoImage(t, registry, plugin, map[string]any{"model": lite, "prompt": "seasons"})
		require.NoError(t, err)
		_, err = call([]string{"parseSubmitResponse"}, map[string]any{"upstreamModel": lite, "publicTaskId": "task_public", "requestBody": synchronous["requestBody"]},
			map[string]any{"body": map[string]any{"error": map[string]any{"code": "AuthenticationError", "message": "the API key is invalid"}}})
		require.ErrorContains(t, err, "AuthenticationError: the API key is invalid")
	})
}