package clients

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/easyai/easyai-ai-gateway/apps/api/internal/store"
)

func TestSimulationClientReturnsImageDemoAssets(t *testing.T) {
	response, err := (SimulationClient{}).Run(context.Background(), Request{
		Kind:  "images.generations",
		Model: "gpt-image-1",
		Body: map[string]any{
			"prompt":               "demo image",
			"n":                    2,
			"simulationDurationMs": 5,
		},
		Candidate: store.RuntimeModelCandidate{Provider: "simulation"},
	})
	if err != nil {
		t.Fatalf("run simulation image client: %v", err)
	}
	data, _ := response.Result["data"].([]any)
	if len(data) != 2 || response.ResponseDurationMS <= 0 {
		t.Fatalf("unexpected simulated image response: %+v duration=%d", response.Result, response.ResponseDurationMS)
	}
	item, _ := data[0].(map[string]any)
	if item["url"] != "/static/simulation/image.svg" || item["assetSource"] != "simulation" {
		t.Fatalf("unexpected simulated image item: %+v", item)
	}
}

func TestSimulationClientReturnsVideoDemoAssets(t *testing.T) {
	response, err := (SimulationClient{}).Run(context.Background(), Request{
		Kind:      "videos.generations",
		ModelType: "video_generate",
		Model:     "demo-video-model",
		Body: map[string]any{
			"prompt":               "demo video",
			"count":                2,
			"duration":             6,
			"simulationDurationMs": 5,
		},
		Candidate: store.RuntimeModelCandidate{Provider: "simulation"},
	})
	if err != nil {
		t.Fatalf("run simulation video client: %v", err)
	}
	data, _ := response.Result["data"].([]any)
	if len(data) != 2 || response.ResponseDurationMS <= 0 {
		t.Fatalf("unexpected simulated video response: %+v duration=%d", response.Result, response.ResponseDurationMS)
	}
	item, _ := data[0].(map[string]any)
	if item["video_url"] != "/static/simulation/video.mp4" || item["url"] != "/static/simulation/video.mp4" || item["poster"] != "/static/simulation/video-poster.svg" {
		t.Fatalf("unexpected simulated video item: %+v", item)
	}
	if item["duration"] != 6 || item["assetSource"] != "simulation" {
		t.Fatalf("unexpected simulated video metadata: %+v", item)
	}
}

func TestSimulationClientReturnsAudioDemoAssets(t *testing.T) {
	response, err := (SimulationClient{}).Run(context.Background(), Request{
		Kind:      "speech.generations",
		ModelType: "text_to_speech",
		Model:     "speech-2.6-turbo",
		Body: map[string]any{
			"text":                 "hello from simulation",
			"voice_id":             "female-shaonv",
			"count":                2,
			"simulationDurationMs": 5,
		},
		Candidate: store.RuntimeModelCandidate{Provider: "simulation"},
	})
	if err != nil {
		t.Fatalf("run simulation audio client: %v", err)
	}
	data, _ := response.Result["data"].([]any)
	if len(data) != 2 || response.Result["status"] != "success" {
		t.Fatalf("unexpected simulated audio response: %+v", response.Result)
	}
	item, _ := data[0].(map[string]any)
	if item["type"] != "audio" || item["url"] != "/static/simulation/audio.wav" || item["audio_url"] != "/static/simulation/audio.wav" {
		t.Fatalf("unexpected simulated audio item: %+v", item)
	}
	if item["revised_text"] != "hello from simulation" || item["assetSource"] != "simulation" {
		t.Fatalf("unexpected simulated audio metadata: %+v", item)
	}
}

func TestSimulationDurationDefaultsByMediaType(t *testing.T) {
	imageDuration := simulationDuration(Request{Kind: "images.generations"})
	if imageDuration < 10*time.Second || imageDuration > 30*time.Second {
		t.Fatalf("image simulation duration should default to 10-30s, got %s", imageDuration)
	}
	videoDuration := simulationDuration(Request{Kind: "videos.generations"})
	if videoDuration < 2*time.Minute || videoDuration > 3*time.Minute {
		t.Fatalf("video simulation duration should default to 2-3m, got %s", videoDuration)
	}
	audioDuration := simulationDuration(Request{Kind: "speech.generations"})
	if audioDuration < 2*time.Second || audioDuration > 6*time.Second {
		t.Fatalf("audio simulation duration should default to 2-6s, got %s", audioDuration)
	}
	textDuration := simulationDuration(Request{Kind: "chat.completions"})
	if textDuration < 800*time.Millisecond || textDuration > 2400*time.Millisecond {
		t.Fatalf("text simulation duration should keep short defaults, got %s", textDuration)
	}
}

func TestMinimaxClientSpeechUsesT2AV2AndNormalizesAudio(t *testing.T) {
	var captured map[string]any
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodPost || r.URL.Path != "/t2a_v2" {
			t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
		}
		if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
			t.Fatalf("unexpected auth header: %q", got)
		}
		if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
			t.Fatalf("decode request: %v", err)
		}
		w.Header().Set("x-request-id", "req-minimax-speech")
		_ = json.NewEncoder(w).Encode(map[string]any{
			"data":      map[string]any{"audio": "68656c6c6f"},
			"base_resp": map[string]any{"status_code": 0},
		})
	}))
	defer server.Close()

	response, err := (MinimaxClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:  "speech.generations",
		Model: "MiniMax Speech 2.6 Turbo",
		Body: map[string]any{
			"text":     "hello",
			"voice_id": "female-shaonv",
			"speed":    1.2,
			"vol":      0.8,
			"pitch":    -1,
			"emotion":  "happy",
		},
		Candidate: store.RuntimeModelCandidate{
			Provider:          "minimax",
			BaseURL:           server.URL,
			ProviderModelName: "speech-2.6-turbo",
			Credentials:       map[string]any{"apiKey": "test-key"},
		},
	})
	if err != nil {
		t.Fatalf("run minimax speech client: %v", err)
	}
	if captured["model"] != "speech-2.6-turbo" || captured["text"] != "hello" {
		t.Fatalf("unexpected minimax speech payload: %+v", captured)
	}
	if _, ok := captured["voice_id"]; ok {
		t.Fatalf("voice_id should be moved into voice_setting: %+v", captured)
	}
	voiceSetting, ok := captured["voice_setting"].(map[string]any)
	if !ok {
		t.Fatalf("missing voice_setting: %+v", captured)
	}
	if voiceSetting["voice_id"] != "female-shaonv" || voiceSetting["speed"] != 1.2 || voiceSetting["vol"] != 0.8 || voiceSetting["pitch"] != float64(-1) || voiceSetting["emotion"] != "happy" {
		t.Fatalf("unexpected voice_setting: %+v", voiceSetting)
	}
	data, _ := response.Result["data"].([]any)
	if len(data) != 1 {
		t.Fatalf("unexpected minimax speech response: %+v", response.Result)
	}
	item, _ := data[0].(map[string]any)
	expectedContent := "data:audio/mpeg;base64," + base64.StdEncoding.EncodeToString([]byte("hello"))
	if item["type"] != "audio" || item["content"] != expectedContent || item["mime_type"] != "audio/mpeg" {
		t.Fatalf("unexpected normalized audio item: %+v", item)
	}
	if response.RequestID != "req-minimax-speech" {
		t.Fatalf("unexpected request id: %q", response.RequestID)
	}
}

func TestMinimaxVoiceCloneTextValidationPayload(t *testing.T) {
	var capturedClone map[string]any
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
			t.Fatalf("unexpected auth header: %q", got)
		}
		w.Header().Set("Content-Type", "application/json")
		switch r.URL.Path {
		case "/files/upload":
			_ = json.NewEncoder(w).Encode(map[string]any{
				"file":      map[string]any{"file_id": "123456"},
				"base_resp": map[string]any{"status_code": 0},
			})
		case "/voice_clone":
			if err := json.NewDecoder(r.Body).Decode(&capturedClone); err != nil {
				t.Fatalf("decode voice clone request: %v", err)
			}
			_ = json.NewEncoder(w).Encode(map[string]any{
				"demo_audio": "",
				"base_resp":  map[string]any{"status_code": 0},
			})
		default:
			t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String())
		}
	}))
	defer server.Close()

	_, err := (MinimaxClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:  "voice.clone",
		Model: "MiniMax-Voice-Clone",
		Body: map[string]any{
			"voice_id":                  "voice_test_123",
			"audio_url":                 "data:audio/wav;base64," + base64.StdEncoding.EncodeToString([]byte("wave")),
			"text_validation":           false,
			"need_noise_reduction":      true,
			"need_volume_normalization": true,
			"aigc_watermark":            false,
		},
		Candidate: store.RuntimeModelCandidate{
			Provider:          "minimax",
			BaseURL:           server.URL,
			ProviderModelName: "voice_clone",
			Credentials:       map[string]any{"apiKey": "test-key"},
		},
	})
	if err != nil {
		t.Fatalf("run minimax voice clone client: %v", err)
	}
	if _, ok := capturedClone["text_validation"]; ok {
		t.Fatalf("legacy boolean text_validation should be omitted: %+v", capturedClone)
	}
	if capturedClone["file_id"] != float64(123456) {
		t.Fatalf("file_id should be submitted as number: %+v", capturedClone)
	}

	capturedClone = nil
	_, err = (MinimaxClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:  "voice.clone",
		Model: "MiniMax-Voice-Clone",
		Body: map[string]any{
			"voice_id":        "voice_test_456",
			"audio_url":       "data:audio/wav;base64," + base64.StdEncoding.EncodeToString([]byte("wave")),
			"text_validation": "  这是一段用于校验的源音频文本  ",
		},
		Candidate: store.RuntimeModelCandidate{
			Provider:          "minimax",
			BaseURL:           server.URL,
			ProviderModelName: "voice_clone",
			Credentials:       map[string]any{"apiKey": "test-key"},
		},
	})
	if err != nil {
		t.Fatalf("run minimax voice clone client with transcript: %v", err)
	}
	if capturedClone["text_validation"] != "这是一段用于校验的源音频文本" {
		t.Fatalf("unexpected text_validation payload: %+v", capturedClone)
	}
	if capturedClone["file_id"] != float64(123456) {
		t.Fatalf("file_id should be submitted as number with transcript: %+v", capturedClone)
	}
}

func TestSimulationDurationCanBeControlledByParams(t *testing.T) {
	fixedDuration := simulationDuration(Request{Body: map[string]any{"simulationDurationSeconds": 7}})
	if fixedDuration != 7*time.Second {
		t.Fatalf("simulationDurationSeconds should set fixed duration, got %s", fixedDuration)
	}
	rangeDuration := simulationDuration(Request{
		Kind: "videos.generations",
		Body: map[string]any{
			"simulationMinDurationSeconds": 1,
			"simulationMaxDurationSeconds": 1,
		},
	})
	if rangeDuration != time.Second {
		t.Fatalf("simulation duration range params should override video defaults, got %s", rangeDuration)
	}
}

func TestOpenAIClientChatContract(t *testing.T) {
	var gotPath string
	var gotAuth string
	var gotModel string
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotPath = r.URL.Path
		gotAuth = r.Header.Get("Authorization")
		w.Header().Set("X-Request-Id", "req-chat-test")
		var body map[string]any
		if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
			t.Fatalf("decode request: %v", err)
		}
		gotModel, _ = body["model"].(string)
		time.Sleep(25 * time.Millisecond)
		_ = json.NewEncoder(w).Encode(map[string]any{
			"id":     "chatcmpl-test",
			"object": "chat.completion",
			"model":  gotModel,
			"choices": []any{map[string]any{
				"message": map[string]any{"role": "assistant", "content": "ok"},
			}},
			"usage": map[string]any{"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5, "prompt_cache_hit_tokens": 2},
		})
	}))
	defer server.Close()

	response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:  "chat.completions",
		Model: "openai:gpt-4o-mini",
		Body:  map[string]any{"model": "openai:gpt-4o-mini", "messages": []any{map[string]any{"role": "user", "content": "ping"}}},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           server.URL,
			ModelName:         "gpt-4o-mini",
			ProviderModelName: "openai-compatible-gpt-4o-mini",
			Credentials:       map[string]any{"apiKey": "test-key"},
		},
	})
	if err != nil {
		t.Fatalf("run openai client: %v", err)
	}
	if gotPath != "/chat/completions" || gotAuth != "Bearer test-key" || gotModel != "openai-compatible-gpt-4o-mini" {
		t.Fatalf("unexpected request path=%s auth=%s model=%s", gotPath, gotAuth, gotModel)
	}
	if response.Usage.TotalTokens != 5 || response.Result["id"] != "chatcmpl-test" {
		t.Fatalf("unexpected response: %+v", response)
	}
	if response.Usage.CachedInputTokens != 2 {
		t.Fatalf("expected cached input token usage, got %+v", response.Usage)
	}
	resultUsage, _ := response.Result["usage"].(map[string]any)
	promptDetails, _ := resultUsage["prompt_tokens_details"].(map[string]any)
	if intFromAny(promptDetails["cached_tokens"]) != 2 {
		t.Fatalf("expected normalized cached prompt tokens in result usage, got %+v", resultUsage)
	}
	if response.RequestID != "req-chat-test" || response.ResponseStartedAt.IsZero() || response.ResponseFinishedAt.IsZero() {
		t.Fatalf("response metadata was not captured: %+v", response)
	}
	if response.ResponseDurationMS < 20 {
		t.Fatalf("response duration should include upstream latency, got %dms", response.ResponseDurationMS)
	}
}

func TestOpenAIClientDelayedStream429ReturnsBeforeAnyDelta(t *testing.T) {
	requestStarted := make(chan struct{})
	releaseResponse := make(chan struct{})
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		close(requestStarted)
		<-releaseResponse
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusTooManyRequests)
		_ = json.NewEncoder(w).Encode(map[string]any{
			"error": map[string]any{"message": "controlled delayed 429"},
		})
	}))
	defer server.Close()
	release := func() {
		select {
		case <-releaseResponse:
		default:
			close(releaseResponse)
		}
	}
	defer release()

	type runResult struct {
		response Response
		err      error
	}
	deltaCount := 0
	done := make(chan runResult, 1)
	go func() {
		response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
			Kind:   "chat.completions",
			Model:  "delayed-429-model",
			Stream: true,
			Body: map[string]any{
				"model":    "delayed-429-model",
				"stream":   true,
				"messages": []any{map[string]any{"role": "user", "content": "ping"}},
			},
			Candidate: store.RuntimeModelCandidate{
				BaseURL:           server.URL,
				ProviderModelName: "delayed-429-model",
				Credentials:       map[string]any{"apiKey": "test-key"},
			},
			StreamDelta: func(StreamDeltaEvent) error {
				deltaCount++
				return nil
			},
		})
		done <- runResult{response: response, err: err}
	}()

	select {
	case <-requestStarted:
	case <-time.After(time.Second):
		t.Fatal("upstream request did not start")
	}
	select {
	case result := <-done:
		t.Fatalf("stream returned before upstream status arrived: response=%+v err=%v", result.response, result.err)
	case <-time.After(50 * time.Millisecond):
	}
	release()

	var result runResult
	select {
	case result = <-done:
	case <-time.After(time.Second):
		t.Fatal("stream did not return after delayed 429")
	}
	clientErr, ok := result.err.(*ClientError)
	if !ok || clientErr.Code != "rate_limit" || clientErr.StatusCode != http.StatusTooManyRequests || !clientErr.Retryable {
		t.Fatalf("unexpected delayed 429 error: %T %+v", result.err, result.err)
	}
	if deltaCount != 0 {
		t.Fatalf("delayed 429 emitted %d stream deltas, want 0", deltaCount)
	}
}

func TestOpenAIClientAliyunChatSendsStandardInputAudioAndNormalizesLegacyAudioURL(t *testing.T) {
	var captured map[string]any
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
			t.Fatalf("decode request: %v", err)
		}
		_ = json.NewEncoder(w).Encode(map[string]any{
			"id":     "chatcmpl-audio",
			"object": "chat.completion",
			"model":  captured["model"],
			"choices": []any{map[string]any{
				"message": map[string]any{"role": "assistant", "content": "ok"},
			}},
		})
	}))
	defer server.Close()

	_, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:  "chat.completions",
		Model: "qwen-omni",
		Body: map[string]any{
			"model": "qwen-omni",
			"messages": []any{
				map[string]any{
					"role": "user",
					"content": []any{
						map[string]any{
							"type": "input_audio",
							"input_audio": map[string]any{
								"data":   "https://cdn.example.com/audio/standard.wav",
								"format": "wav",
							},
						},
						map[string]any{
							"type":      "audio_url",
							"audio_url": map[string]any{"url": "https://cdn.example.com/audio/legacy.m4a"},
						},
					},
				},
			},
		},
		Candidate: store.RuntimeModelCandidate{
			Provider:          "aliyun-bailian-openai",
			BaseURL:           server.URL,
			ProviderModelName: "qwen-omni",
			Credentials:       map[string]any{"apiKey": "test-key"},
		},
	})
	if err != nil {
		t.Fatalf("run aliyun openai-compatible client: %v", err)
	}

	messages, _ := captured["messages"].([]any)
	message, _ := messages[0].(map[string]any)
	content, _ := message["content"].([]any)
	standard, _ := content[0].(map[string]any)
	if standard["type"] != "input_audio" {
		t.Fatalf("standard input_audio was not preserved: %+v", standard)
	}
	standardAudio, _ := standard["input_audio"].(map[string]any)
	if standardAudio["data"] != "https://cdn.example.com/audio/standard.wav" || standardAudio["format"] != "wav" {
		t.Fatalf("unexpected standard input_audio: %+v", standardAudio)
	}
	legacy, _ := content[1].(map[string]any)
	if legacy["type"] != "input_audio" {
		t.Fatalf("legacy audio_url was not normalized: %+v", legacy)
	}
	legacyAudio, _ := legacy["input_audio"].(map[string]any)
	if legacyAudio["data"] != "https://cdn.example.com/audio/legacy.m4a" || legacyAudio["format"] != "m4a" {
		t.Fatalf("unexpected normalized legacy audio: %+v", legacyAudio)
	}
}

func TestOpenAIClientImageEditUsesMultipartWithMultipleImages(t *testing.T) {
	var contentType string
	var receivedFields map[string][]string
	var receivedImageCount int
	var receivedImageFields []string
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		contentType = r.Header.Get("Content-Type")
		if err := r.ParseMultipartForm(1 << 20); err != nil {
			t.Fatalf("parse multipart image edit: %v", err)
		}
		receivedFields = r.MultipartForm.Value
		for field, files := range r.MultipartForm.File {
			receivedImageFields = append(receivedImageFields, field)
			receivedImageCount += len(files)
		}
		_ = json.NewEncoder(w).Encode(map[string]any{
			"data": []any{map[string]any{"b64_json": "aW1hZ2U="}},
		})
	}))
	defer server.Close()

	_, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:      "images.edits",
		ModelType: "image_edit",
		Model:     "gpt-image-2",
		Body: map[string]any{
			"model":        "gpt-image-2",
			"prompt":       "combine the references",
			"size":         "1024x1536",
			"quality":      "medium",
			"aspect_ratio": "2:3",
			"resolution":   "2K",
			"width":        1024,
			"height":       1536,
			"images": []any{
				"data:image/png;base64," + base64.StdEncoding.EncodeToString([]byte("image one")),
				"data:image/jpeg;base64," + base64.StdEncoding.EncodeToString([]byte("image two")),
			},
			"_metadata": map[string]any{"private": true},
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           server.URL,
			Provider:          "openai",
			ProviderModelName: "gpt-image-2-vip",
			ModelType:         "image_edit",
			Credentials:       map[string]any{"apiKey": "openai-key"},
		},
	})
	if err != nil {
		t.Fatalf("run OpenAI image edit: %v", err)
	}
	if !strings.HasPrefix(contentType, "multipart/form-data; boundary=") {
		t.Fatalf("image edit should use multipart/form-data, got %q", contentType)
	}
	if receivedImageCount != 2 || len(receivedImageFields) != 1 || receivedImageFields[0] != "image[]" {
		t.Fatalf("unexpected multipart image files: fields=%v count=%d", receivedImageFields, receivedImageCount)
	}
	if receivedFields["model"][0] != "gpt-image-2-vip" ||
		receivedFields["prompt"][0] != "combine the references" ||
		receivedFields["size"][0] != "1024x1536" ||
		receivedFields["quality"][0] != "medium" {
		t.Fatalf("unexpected multipart fields: %+v", receivedFields)
	}
	if _, ok := receivedFields["_metadata"]; ok {
		t.Fatalf("internal metadata must not be forwarded: %+v", receivedFields)
	}
	for _, field := range []string{"aspect_ratio", "resolution", "width", "height"} {
		if _, ok := receivedFields[field]; ok {
			t.Fatalf("generic image geometry field %q must not be forwarded: %+v", field, receivedFields)
		}
	}
}

func TestOpenAIClientImageEditDownloadsURLWhenBuildingMultipart(t *testing.T) {
	imagePayload := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 1, 2, 3, 4}
	fetchCount := 0
	imageServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fetchCount++
		w.Header().Set("Content-Type", "image/png")
		_, _ = w.Write(imagePayload)
	}))
	defer imageServer.Close()

	var uploaded []byte
	upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if err := r.ParseMultipartForm(1 << 20); err != nil {
			t.Fatalf("parse multipart image edit: %v", err)
		}
		files := r.MultipartForm.File["image"]
		if len(files) != 1 {
			t.Fatalf("expected one multipart image, got %+v", r.MultipartForm.File)
		}
		file, err := files[0].Open()
		if err != nil {
			t.Fatalf("open multipart image: %v", err)
		}
		defer file.Close()
		uploaded, err = io.ReadAll(file)
		if err != nil {
			t.Fatalf("read multipart image: %v", err)
		}
		_ = json.NewEncoder(w).Encode(map[string]any{
			"data": []any{map[string]any{"b64_json": "aW1hZ2U="}},
		})
	}))
	defer upstream.Close()

	_, err := (OpenAIClient{HTTPClient: upstream.Client()}).Run(context.Background(), Request{
		Kind:      "images.edits",
		ModelType: "image_edit",
		Body: map[string]any{
			"image":  imageServer.URL + "/source.png",
			"prompt": "edit it",
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           upstream.URL,
			Provider:          "openai",
			ProviderModelName: "gpt-image-compatible",
			ModelType:         "image_edit",
			Credentials:       map[string]any{"apiKey": "openai-key"},
		},
	})
	if err != nil {
		t.Fatalf("run OpenAI URL multipart image edit: %v", err)
	}
	if fetchCount != 1 || string(uploaded) != string(imagePayload) {
		t.Fatalf("image URL should be fetched once during multipart construction: fetches=%d uploaded=%v", fetchCount, uploaded)
	}
}

func TestKelingAndMinimaxMaterializeRemoteMediaInsideClient(t *testing.T) {
	payload := []byte("remote media payload")
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "audio/mpeg")
		_, _ = w.Write(payload)
	}))
	defer server.Close()

	encoded, err := kelingImageToBase64(context.Background(), Request{HTTPClient: server.Client()}, server.URL+"/image.png")
	if err != nil {
		t.Fatalf("materialize Keling image URL: %v", err)
	}
	if encoded != base64.StdEncoding.EncodeToString(payload) {
		t.Fatalf("unexpected Keling image base64: %q", encoded)
	}

	audio, filename, contentType, err := minimaxVoiceCloneFilePayload(context.Background(), server.Client(), server.URL+"/audio.mp3", "voice_clone")
	if err != nil {
		t.Fatalf("materialize Minimax audio URL: %v", err)
	}
	if string(audio) != string(payload) || filename != "voice_clone.mp3" || contentType != "audio/mpeg" {
		t.Fatalf("unexpected Minimax audio materialization: filename=%q contentType=%q payload=%q", filename, contentType, audio)
	}
}

func TestOpenAIClientImageEditUsesJSONURLsOnlyWhenExplicitlyConfigured(t *testing.T) {
	var contentType string
	var received map[string]any
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		contentType = r.Header.Get("Content-Type")
		if err := json.NewDecoder(r.Body).Decode(&received); err != nil {
			t.Fatalf("decode image edit JSON: %v", err)
		}
		_ = json.NewEncoder(w).Encode(map[string]any{
			"data": []any{map[string]any{"url": "https://cdn.example.com/output.png"}},
		})
	}))
	defer server.Close()

	_, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:      "images.edits",
		ModelType: "image_edit",
		Model:     "compatible-image-edit",
		Body: map[string]any{
			"images": []any{
				"https://cdn.example.com/first.png",
				map[string]any{"url": "https://cdn.example.com/second.png"},
			},
			"mask_image": map[string]any{"image_url": "https://cdn.example.com/mask.png"},
			"prompt":     "combine the references",
			"_metadata":  map[string]any{"private": true},
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           server.URL,
			Provider:          "compatible-openai",
			SpecType:          "openai",
			ProviderModelName: "compatible-image-edit-v2",
			ModelType:         "image_edit",
			Credentials:       map[string]any{"apiKey": "openai-key"},
			PlatformConfig:    map[string]any{"imageEditRequestFormat": "json_url"},
		},
	})
	if err != nil {
		t.Fatalf("run OpenAI-compatible JSON image edit: %v", err)
	}
	if !strings.HasPrefix(contentType, "application/json") {
		t.Fatalf("explicit JSON URL mode should use application/json, got %q", contentType)
	}
	images, _ := received["image"].([]any)
	if len(images) != 2 || images[0] != "https://cdn.example.com/first.png" || images[1] != "https://cdn.example.com/second.png" {
		t.Fatalf("unexpected JSON image URLs: %+v", received)
	}
	if received["mask"] != "https://cdn.example.com/mask.png" || received["model"] != "compatible-image-edit-v2" {
		t.Fatalf("unexpected JSON image edit body: %+v", received)
	}
	if _, ok := received["images"]; ok {
		t.Fatalf("images alias must be normalized to image: %+v", received)
	}
	if _, ok := received["_metadata"]; ok {
		t.Fatalf("internal metadata must not be forwarded: %+v", received)
	}
}

func TestOpenAIClientImageEditJSONModeRejectsInlineImageData(t *testing.T) {
	_, _, err := openAIRequestPayload(context.Background(), "images.edits", map[string]any{
		"image": "data:image/png;base64,aW1hZ2U=",
	}, store.RuntimeModelCandidate{PlatformConfig: map[string]any{"imageEditRequestFormat": "json_url"}})
	clientErr, ok := err.(*ClientError)
	if !ok || clientErr.Code != "invalid_parameter" || clientErr.Param != "image" || clientErr.StatusCode != http.StatusBadRequest {
		t.Fatalf("expected non-retryable image URL validation error, got %#v", err)
	}
}

func TestOpenAIImageEditFieldNameUsesArraySyntaxForMultipleImages(t *testing.T) {
	tests := []struct {
		name       string
		candidate  store.RuntimeModelCandidate
		imageCount int
		want       string
	}{
		{name: "compatible single image", candidate: store.RuntimeModelCandidate{BaseURL: "https://compatible.example/v1"}, imageCount: 1, want: "image"},
		{name: "compatible multiple images", candidate: store.RuntimeModelCandidate{BaseURL: "https://compatible.example/v1"}, imageCount: 2, want: "image[]"},
		{name: "official single image", candidate: store.RuntimeModelCandidate{BaseURL: "https://api.openai.com/v1"}, imageCount: 1, want: "image[]"},
		{name: "explicit scalar override", candidate: store.RuntimeModelCandidate{BaseURL: "https://compatible.example/v1", PlatformConfig: map[string]any{"imageEditMultipartFieldName": "image"}}, imageCount: 2, want: "image"},
		{name: "explicit array override", candidate: store.RuntimeModelCandidate{BaseURL: "https://compatible.example/v1", PlatformConfig: map[string]any{"imageEditMultipartFieldName": "image[]"}}, imageCount: 1, want: "image[]"},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			if got := openAIImageEditFieldName(test.candidate, test.imageCount); got != test.want {
				t.Fatalf("field name = %q, want %q", got, test.want)
			}
		})
	}
}

func TestOpenAIClientImageGenerationUsesSizeWithoutGenericGeometryFields(t *testing.T) {
	var received map[string]any
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if err := json.NewDecoder(r.Body).Decode(&received); err != nil {
			t.Fatalf("decode image generation body: %v", err)
		}
		_ = json.NewEncoder(w).Encode(map[string]any{
			"data": []any{map[string]any{"b64_json": "aW1hZ2U="}},
		})
	}))
	defer server.Close()

	_, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:      "images.generations",
		ModelType: "image_generate",
		Model:     "gpt-image-2",
		Body: map[string]any{
			"model":        "gpt-image-2",
			"prompt":       "draw",
			"size":         "2048x1152",
			"quality":      "high",
			"aspect_ratio": "16:9",
			"aspectRatio":  "16:9",
			"resolution":   "2K",
			"width":        2048,
			"height":       1152,
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           server.URL,
			Provider:          "openai",
			ProviderModelName: "gpt-image-2-vip",
			ModelType:         "image_generate",
			Credentials:       map[string]any{"apiKey": "openai-key"},
		},
	})
	if err != nil {
		t.Fatalf("run OpenAI image generation: %v", err)
	}
	if received["model"] != "gpt-image-2-vip" ||
		received["prompt"] != "draw" ||
		received["size"] != "2048x1152" ||
		received["quality"] != "high" {
		t.Fatalf("unexpected image generation fields: %+v", received)
	}
	for _, field := range []string{"aspect_ratio", "aspectRatio", "resolution", "width", "height"} {
		if _, ok := received[field]; ok {
			t.Fatalf("generic image geometry field %q must not be forwarded: %+v", field, received)
		}
	}
}

func TestOpenAIClientChatReasoningParamsByProvider(t *testing.T) {
	cases := []struct {
		name      string
		provider  string
		model     string
		baseURL   string
		body      map[string]any
		assertion func(t *testing.T, body map[string]any)
	}{
		{
			name:     "generic openai keeps openai reasoning effort",
			provider: "openai",
			model:    "gpt-5.5",
			body: map[string]any{
				"reasoning_effort": "xhigh",
			},
			assertion: func(t *testing.T, body map[string]any) {
				if body["reasoning_effort"] != "xhigh" {
					t.Fatalf("generic openai should keep xhigh, got %+v", body)
				}
				if _, ok := body["thinking"]; ok {
					t.Fatalf("generic openai should not receive thinking object: %+v", body)
				}
			},
		},
		{
			name:     "aliyun qwen enables thinking and maps budget",
			provider: "aliyun-bailian-openai",
			model:    "qwen3.7-plus",
			body: map[string]any{
				"reasoning_effort":       "high",
				"thinking_budget_tokens": 4096,
			},
			assertion: func(t *testing.T, body map[string]any) {
				if body["enable_thinking"] != true || body["thinking_budget"] != float64(4096) {
					t.Fatalf("aliyun qwen should use enable_thinking/thinking_budget, got %+v", body)
				}
				if _, ok := body["reasoning_effort"]; ok {
					t.Fatalf("aliyun qwen should not receive reasoning_effort: %+v", body)
				}
				if _, ok := body["thinking_budget_tokens"]; ok {
					t.Fatalf("internal thinking_budget_tokens should be removed: %+v", body)
				}
			},
		},
		{
			name:     "aliyun qwen disables thinking for none",
			provider: "aliyun-bailian-openai",
			model:    "qwen3.7-plus",
			body: map[string]any{
				"reasoning_effort": "none",
			},
			assertion: func(t *testing.T, body map[string]any) {
				if body["enable_thinking"] != false {
					t.Fatalf("aliyun qwen none should disable thinking, got %+v", body)
				}
				if _, ok := body["reasoning_effort"]; ok {
					t.Fatalf("aliyun qwen none should remove reasoning_effort: %+v", body)
				}
			},
		},
		{
			name:     "aliyun qwen 3.8 preview preserves supported effort",
			provider: "aliyun-bailian-openai",
			model:    "qwen3.8-max-preview",
			body: map[string]any{
				"reasoning_effort": "high",
			},
			assertion: func(t *testing.T, body map[string]any) {
				if body["enable_thinking"] != true || body["reasoning_effort"] != "xhigh" {
					t.Fatalf("aliyun qwen 3.8 should use xhigh reasoning effort, got %+v", body)
				}
			},
		},
		{
			name:     "aliyun qwen 3.8 preview uses explicit budget exclusively",
			provider: "aliyun-bailian-openai",
			model:    "qwen3.8-max-preview",
			body: map[string]any{
				"reasoning_effort":       "medium",
				"thinking_budget_tokens": 16384,
			},
			assertion: func(t *testing.T, body map[string]any) {
				if body["enable_thinking"] != true || body["thinking_budget"] != float64(16384) {
					t.Fatalf("aliyun qwen 3.8 should use the explicit thinking budget, got %+v", body)
				}
				if _, ok := body["reasoning_effort"]; ok {
					t.Fatalf("aliyun qwen 3.8 must not combine reasoning effort with thinking budget: %+v", body)
				}
			},
		},
		{
			name:     "same deepseek model on aliyun uses aliyun protocol",
			provider: "aliyun-bailian-openai",
			model:    "deepseek-v4",
			body: map[string]any{
				"reasoning_effort": "xhigh",
			},
			assertion: func(t *testing.T, body map[string]any) {
				if body["enable_thinking"] != true || body["reasoning_effort"] != "max" {
					t.Fatalf("aliyun deepseek should use enable_thinking and max, got %+v", body)
				}
			},
		},
		{
			name:     "deepseek official uses thinking object and high max effort",
			provider: "deepseek-openai",
			model:    "deepseek-v4",
			body: map[string]any{
				"reasoning_effort": "xhigh",
			},
			assertion: func(t *testing.T, body map[string]any) {
				thinking, _ := body["thinking"].(map[string]any)
				if thinking["type"] != "enabled" || body["reasoning_effort"] != "max" {
					t.Fatalf("deepseek official should use thinking enabled and max, got %+v", body)
				}
			},
		},
		{
			name:     "volces seed none disables thinking",
			provider: "volces-openai",
			model:    "doubao-seed-2-0-pro-260215",
			body: map[string]any{
				"reasoning_effort": "none",
			},
			assertion: func(t *testing.T, body map[string]any) {
				thinking, _ := body["thinking"].(map[string]any)
				if thinking["type"] != "disabled" {
					t.Fatalf("volces seed none should disable thinking, got %+v", body)
				}
				if _, ok := body["reasoning_effort"]; ok {
					t.Fatalf("volces seed none should remove reasoning_effort: %+v", body)
				}
			},
		},
		{
			name:     "volces seed enables thinking and maps xhigh",
			provider: "volces-openai",
			model:    "doubao-seed-2-0-pro-260215",
			body: map[string]any{
				"reasoning_effort": "xhigh",
			},
			assertion: func(t *testing.T, body map[string]any) {
				thinking, _ := body["thinking"].(map[string]any)
				if thinking["type"] != "enabled" || body["reasoning_effort"] != "high" {
					t.Fatalf("volces seed xhigh should enable thinking and map to high, got %+v", body)
				}
			},
		},
		{
			name:     "zhipu preserves xhigh for glm 5.2",
			provider: "zhipu-openai",
			model:    "glm-5.2",
			body: map[string]any{
				"reasoning_effort": "xhigh",
			},
			assertion: func(t *testing.T, body map[string]any) {
				thinking, _ := body["thinking"].(map[string]any)
				if thinking["type"] != "enabled" || body["reasoning_effort"] != "xhigh" {
					t.Fatalf("zhipu glm should use thinking enabled and preserve xhigh, got %+v", body)
				}
			},
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			var captured map[string]any
			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
					t.Fatalf("decode request: %v", err)
				}
				_ = json.NewEncoder(w).Encode(map[string]any{
					"id":     "chatcmpl-reasoning",
					"object": "chat.completion",
					"model":  captured["model"],
					"choices": []any{map[string]any{
						"message": map[string]any{"role": "assistant", "content": "ok"},
					}},
				})
			}))
			defer server.Close()

			body := map[string]any{
				"messages": []any{map[string]any{"role": "user", "content": "ping"}},
			}
			for key, value := range tc.body {
				body[key] = value
			}
			baseURL := tc.baseURL
			if baseURL == "" {
				baseURL = server.URL
			}

			_, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
				Kind:  "chat.completions",
				Model: tc.model,
				Body:  body,
				Candidate: store.RuntimeModelCandidate{
					Provider:          tc.provider,
					BaseURL:           baseURL,
					ProviderModelName: tc.model,
					ModelName:         tc.model,
					Credentials:       map[string]any{"apiKey": "test-key"},
				},
			})
			if err != nil {
				t.Fatalf("run openai client: %v", err)
			}
			tc.assertion(t, captured)
		})
	}
}

func TestUsageFromOpenAIUsageTracksKnownCachedInputZero(t *testing.T) {
	knownZero := usageFromOpenAIUsage(map[string]any{
		"prompt_tokens":     100,
		"completion_tokens": 10,
		"total_tokens":      110,
		"prompt_tokens_details": map[string]any{
			"cached_tokens": 0,
		},
	})
	if !knownZero.CachedInputTokensKnown || knownZero.CachedInputTokens != 0 {
		t.Fatalf("expected known zero cached input tokens, got %+v", knownZero)
	}

	absent := usageFromOpenAIUsage(map[string]any{
		"prompt_tokens":     100,
		"completion_tokens": 10,
		"total_tokens":      110,
	})
	if absent.CachedInputTokensKnown {
		t.Fatalf("expected missing cached field to remain unknown, got %+v", absent)
	}
}

func TestOpenAIClientEmbeddingsContract(t *testing.T) {
	var gotPath string
	var gotModel string
	var gotDimensions float64
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotPath = r.URL.Path
		var body map[string]any
		if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
			t.Fatalf("decode request: %v", err)
		}
		gotModel, _ = body["model"].(string)
		gotDimensions, _ = body["dimensions"].(float64)
		_ = json.NewEncoder(w).Encode(map[string]any{
			"id":     "embd-test",
			"object": "list",
			"model":  gotModel,
			"data": []any{map[string]any{
				"object":    "embedding",
				"index":     0,
				"embedding": []any{0.1, 0.2, 0.3},
			}},
			"usage": map[string]any{"prompt_tokens": 3, "total_tokens": 3},
		})
	}))
	defer server.Close()

	response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:  "embeddings",
		Model: "aliyun-bailian-openai:text-embedding-v4",
		Body: map[string]any{
			"model":      "aliyun-bailian-openai:text-embedding-v4",
			"input":      []any{"hello"},
			"dimensions": 3,
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           server.URL,
			ProviderModelName: "text-embedding-v4",
			Credentials:       map[string]any{"apiKey": "test-key"},
		},
	})
	if err != nil {
		t.Fatalf("run embeddings client: %v", err)
	}
	if gotPath != "/embeddings" || gotModel != "text-embedding-v4" || gotDimensions != 3 {
		t.Fatalf("unexpected embeddings request path=%s model=%s dimensions=%v", gotPath, gotModel, gotDimensions)
	}
	if response.Usage.InputTokens != 3 || response.Usage.TotalTokens != 3 || response.Result["id"] != "embd-test" {
		t.Fatalf("unexpected embeddings response: %+v", response)
	}
}

func TestOpenAIClientAliyunRerankUsesCompatibleAPIBase(t *testing.T) {
	var gotPath string
	var gotModel string
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotPath = r.URL.Path
		var body map[string]any
		if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
			t.Fatalf("decode request: %v", err)
		}
		gotModel, _ = body["model"].(string)
		_ = json.NewEncoder(w).Encode(map[string]any{
			"id":     "rerank-test",
			"object": "list",
			"model":  gotModel,
			"results": []any{
				map[string]any{"index": 0, "relevance_score": 0.93},
				map[string]any{"index": 2, "relevance_score": 0.34},
			},
			"usage": map[string]any{"total_tokens": 9},
		})
	}))
	defer server.Close()

	response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:  "reranks",
		Model: "aliyun-bailian-openai:qwen3-rerank",
		Body: map[string]any{
			"model":     "aliyun-bailian-openai:qwen3-rerank",
			"query":     "what is rerank",
			"documents": []any{"rerank sorts documents", "unrelated"},
			"top_n":     2,
		},
		Candidate: store.RuntimeModelCandidate{
			Provider:          "aliyun-bailian-openai",
			BaseURL:           server.URL + "/compatible-mode/v1",
			ProviderModelName: "qwen3-rerank",
			Credentials:       map[string]any{"apiKey": "test-key"},
		},
	})
	if err != nil {
		t.Fatalf("run rerank client: %v", err)
	}
	if gotPath != "/compatible-api/v1/reranks" || gotModel != "qwen3-rerank" {
		t.Fatalf("unexpected rerank request path=%s model=%s", gotPath, gotModel)
	}
	if response.Usage.TotalTokens != 9 || response.Result["id"] != "rerank-test" {
		t.Fatalf("unexpected rerank response: %+v", response)
	}
}

func TestOpenAIClientChatRequestNormalizesToolContext(t *testing.T) {
	var captured map[string]any
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
			t.Fatalf("decode request: %v", err)
		}
		_ = json.NewEncoder(w).Encode(map[string]any{
			"id":     "chatcmpl-normalized-request",
			"object": "chat.completion",
			"model":  captured["model"],
			"choices": []any{map[string]any{
				"message": map[string]any{"role": "assistant", "content": "ok"},
			}},
		})
	}))
	defer server.Close()

	_, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:  "chat.completions",
		Model: "openai:gpt-4o-mini",
		Body: map[string]any{
			"model": "openai:gpt-4o-mini",
			"messages": []any{
				map[string]any{
					"role": "assistant",
					"functionCall": map[string]any{
						"name":      "lookup",
						"arguments": map[string]any{"q": "weather"},
					},
				},
				map[string]any{"role": "tool", "toolCallId": "call_0", "content": "sunny"},
				map[string]any{
					"role": "user",
					"content": []any{
						map[string]any{"type": "text", "text": "keep this"},
						map[string]any{"type": "tool_result", "tool_use_id": "toolu_1", "content": map[string]any{"ok": true}},
					},
				},
			},
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           server.URL,
			ProviderModelName: "openai-compatible-gpt-4o-mini",
			Credentials:       map[string]any{"apiKey": "test-key"},
		},
	})
	if err != nil {
		t.Fatalf("run openai client: %v", err)
	}
	messages, _ := captured["messages"].([]any)
	if len(messages) != 4 {
		t.Fatalf("unexpected normalized messages: %+v", messages)
	}
	assistant, _ := messages[0].(map[string]any)
	if _, ok := assistant["functionCall"]; ok {
		t.Fatalf("functionCall alias should be converted away: %+v", assistant)
	}
	function, _ := assistant["function_call"].(map[string]any)
	if function["name"] != "lookup" || function["arguments"] != `{"q":"weather"}` {
		t.Fatalf("unexpected normalized legacy function call: %+v", assistant)
	}
	toolMessage, _ := messages[1].(map[string]any)
	if toolMessage["tool_call_id"] != "call_0" || toolMessage["toolCallId"] != nil {
		t.Fatalf("tool message was not normalized: %+v", toolMessage)
	}
	keptUser, _ := messages[2].(map[string]any)
	convertedToolResult, _ := messages[3].(map[string]any)
	if keptUser["content"] != "keep this" || convertedToolResult["role"] != "tool" || convertedToolResult["tool_call_id"] != "toolu_1" || convertedToolResult["content"] != `{"ok":true}` {
		t.Fatalf("tool_result block was not restored: user=%+v tool=%+v", keptUser, convertedToolResult)
	}
}

func TestOpenAIClientChatResponseNormalizesReasoning(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_ = json.NewEncoder(w).Encode(map[string]any{
			"id":     "chatcmpl-reasoning",
			"object": "chat.completion",
			"model":  "openrouter-test",
			"choices": []any{map[string]any{
				"message": map[string]any{
					"role": "assistant",
					"reasoning_details": []any{
						map[string]any{"type": "reasoning.text", "text": "detail-"},
						map[string]any{"type": "reasoning.summary", "summary": "summary"},
						map[string]any{"type": "reasoning.encrypted", "data": "secret"},
					},
					"content": "<think>tagged</think>answer",
				},
			}},
		})
	}))
	defer server.Close()

	response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:  "chat.completions",
		Model: "OpenRouter-Test",
		Body:  map[string]any{"model": "OpenRouter-Test", "messages": []any{map[string]any{"role": "user", "content": "ping"}}},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:     server.URL,
			ModelName:   "openrouter-test",
			Credentials: map[string]any{"apiKey": "test-key"},
		},
	})
	if err != nil {
		t.Fatalf("run openai client: %v", err)
	}
	choices, _ := response.Result["choices"].([]any)
	choice, _ := choices[0].(map[string]any)
	message, _ := choice["message"].(map[string]any)
	if message["reasoning_content"] != "detail-summarytagged" || message["content"] != "answer" {
		t.Fatalf("reasoning was not normalized: %+v", response.Result)
	}
	if _, ok := message["reasoning_details"]; ok {
		t.Fatalf("reasoning_details should be converted away: %+v", message)
	}
}

func TestOpenAIClientChatResponseNormalizesToolCallFormats(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_ = json.NewEncoder(w).Encode(map[string]any{
			"id":     "chatcmpl-tools",
			"object": "chat.completion",
			"model":  "tool-format-test",
			"choices": []any{map[string]any{
				"message": map[string]any{
					"role": "assistant",
					"content": []any{
						map[string]any{"type": "text", "text": "calling tools"},
						map[string]any{"type": "tool_use", "id": "toolu_1", "name": "anthropic_lookup", "input": map[string]any{"city": "Boston"}},
					},
					"toolCalls":     []any{map[string]any{"id": "call_camel", "functionCall": map[string]any{"name": "camel_lookup", "args": map[string]any{"city": "SF"}}}},
					"function_call": map[string]any{"name": "legacy_lookup", "arguments": "{\"city\":\"NYC\"}"},
				},
			}},
		})
	}))
	defer server.Close()

	response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:  "chat.completions",
		Model: "Tool-Format-Test",
		Body:  map[string]any{"model": "Tool-Format-Test", "messages": []any{map[string]any{"role": "user", "content": "ping"}}},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:     server.URL,
			ModelName:   "tool-format-test",
			Credentials: map[string]any{"apiKey": "test-key"},
		},
	})
	if err != nil {
		t.Fatalf("run openai client: %v", err)
	}
	choices, _ := response.Result["choices"].([]any)
	choice, _ := choices[0].(map[string]any)
	message, _ := choice["message"].(map[string]any)
	if message["content"] != "calling tools" {
		t.Fatalf("tool_use block should be removed from content: %+v", message)
	}
	for _, key := range []string{"toolCalls"} {
		if _, ok := message[key]; ok {
			t.Fatalf("%s should be converted away: %+v", key, message)
		}
	}
	legacyFunction, _ := message["function_call"].(map[string]any)
	if legacyFunction["name"] != "legacy_lookup" || legacyFunction["arguments"] != "{\"city\":\"NYC\"}" {
		t.Fatalf("canonical legacy function_call should be preserved: %+v", message)
	}
	toolCalls, _ := message["tool_calls"].([]any)
	if len(toolCalls) != 2 {
		t.Fatalf("expected 2 normalized tool calls plus legacy function_call, got %+v", message)
	}
	assertToolCall := func(index int, id string, name string, arguments string) {
		t.Helper()
		toolCall, _ := toolCalls[index].(map[string]any)
		function, _ := toolCall["function"].(map[string]any)
		if toolCall["id"] != id || toolCall["type"] != "function" || function["name"] != name || function["arguments"] != arguments {
			t.Fatalf("unexpected tool call[%d]: %+v", index, toolCall)
		}
	}
	assertToolCall(0, "call_camel", "camel_lookup", "{\"city\":\"SF\"}")
	assertToolCall(1, "toolu_1", "anthropic_lookup", "{\"city\":\"Boston\"}")
}

func TestOpenAIClientChatStreamContract(t *testing.T) {
	var gotStream bool
	var gotIncludeUsage bool
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		var body map[string]any
		if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
			t.Fatalf("decode request: %v", err)
		}
		gotStream, _ = body["stream"].(bool)
		streamOptions, _ := body["stream_options"].(map[string]any)
		gotIncludeUsage, _ = streamOptions["include_usage"].(bool)
		w.Header().Set("Content-Type", "text/event-stream")
		_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"content\":\"hello\"}}],\"usage\":null}\n\n"))
		_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-v4-flash\",\"choices\":[{\"delta\":{\"content\":\" world\"},\"finish_reason\":\"stop\"}],\"usage\":null}\n\n"))
		_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-v4-flash\",\"choices\":[],\"usage\":{\"prompt_tokens\":1,\"completion_tokens\":2,\"total_tokens\":3,\"prompt_tokens_details\":{\"cached_tokens\":1}}}\n\n"))
		_, _ = w.Write([]byte("data: [DONE]\n\n"))
	}))
	defer server.Close()

	response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:  "chat.completions",
		Model: "DeepSeek-V4-Flash",
		Body: map[string]any{
			"model":    "DeepSeek-V4-Flash",
			"messages": []any{map[string]any{"role": "user", "content": "ping"}},
			"stream":   true,
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:     server.URL,
			ModelName:   "deepseek-v4-flash",
			Credentials: map[string]any{"apiKey": "test-key"},
		},
	})
	if err != nil {
		t.Fatalf("run openai stream client: %v", err)
	}
	if !gotStream {
		t.Fatalf("expected upstream stream request")
	}
	if !gotIncludeUsage {
		t.Fatalf("expected upstream stream_options.include_usage=true")
	}
	if response.Usage.TotalTokens != 3 {
		t.Fatalf("unexpected usage: %+v", response.Usage)
	}
	if response.Usage.CachedInputTokens != 1 {
		t.Fatalf("expected cached input tokens from stream usage, got %+v", response.Usage)
	}
	choices, _ := response.Result["choices"].([]any)
	choice, _ := choices[0].(map[string]any)
	message, _ := choice["message"].(map[string]any)
	if message["content"] != "hello world" {
		t.Fatalf("unexpected stream response: %+v", response.Result)
	}
}

func TestOpenAIClientChatStreamExtractsCachedInputTokensFromProviderUsageShapes(t *testing.T) {
	cases := []struct {
		name   string
		usage  map[string]any
		cached int
	}{
		{
			name: "aliyun dashscope openai-compatible",
			usage: map[string]any{
				"prompt_tokens":     10,
				"completion_tokens": 2,
				"total_tokens":      12,
				"prompt_tokens_details": map[string]any{
					"cached_tokens": 7,
				},
			},
			cached: 7,
		},
		{
			name: "volces responses-style input details",
			usage: map[string]any{
				"prompt_tokens":     9,
				"completion_tokens": 3,
				"total_tokens":      12,
				"input_tokens_details": map[string]any{
					"cached_tokens": 6,
				},
			},
			cached: 6,
		},
		{
			name: "deepseek prompt cache hit tokens",
			usage: map[string]any{
				"prompt_tokens":            8,
				"completion_tokens":        4,
				"total_tokens":             12,
				"prompt_cache_hit_tokens":  5,
				"prompt_cache_miss_tokens": 3,
			},
			cached: 5,
		},
		{
			name: "glm openai-compatible",
			usage: map[string]any{
				"prompt_tokens":     11,
				"completion_tokens": 1,
				"total_tokens":      12,
				"prompt_tokens_details": map[string]any{
					"cached_tokens": 4,
				},
			},
			cached: 4,
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			var gotIncludeUsage bool
			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				var body map[string]any
				if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
					t.Fatalf("decode request: %v", err)
				}
				streamOptions, _ := body["stream_options"].(map[string]any)
				gotIncludeUsage, _ = streamOptions["include_usage"].(bool)
				usageBytes, _ := json.Marshal(tc.usage)
				w.Header().Set("Content-Type", "text/event-stream")
				_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-cache\",\"object\":\"chat.completion.chunk\",\"model\":\"cache-test\",\"choices\":[{\"delta\":{\"content\":\"ok\"},\"finish_reason\":\"stop\"}],\"usage\":null}\n\n"))
				_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-cache\",\"object\":\"chat.completion.chunk\",\"model\":\"cache-test\",\"choices\":[],\"usage\":" + string(usageBytes) + "}\n\n"))
				_, _ = w.Write([]byte("data: [DONE]\n\n"))
			}))
			defer server.Close()

			response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
				Kind:  "chat.completions",
				Model: "Cache-Test",
				Body: map[string]any{
					"model":    "Cache-Test",
					"messages": []any{map[string]any{"role": "user", "content": "ping"}},
					"stream":   true,
				},
				Candidate: store.RuntimeModelCandidate{
					BaseURL:     server.URL,
					ModelName:   "cache-test",
					Credentials: map[string]any{"apiKey": "test-key"},
				},
			})
			if err != nil {
				t.Fatalf("run openai stream client: %v", err)
			}
			if !gotIncludeUsage {
				t.Fatal("expected upstream stream_options.include_usage=true")
			}
			if response.Usage.CachedInputTokens != tc.cached {
				t.Fatalf("expected cached input tokens %d, got %+v", tc.cached, response.Usage)
			}
			resultUsage, _ := response.Result["usage"].(map[string]any)
			promptDetails, _ := resultUsage["prompt_tokens_details"].(map[string]any)
			if intFromAny(promptDetails["cached_tokens"]) != tc.cached {
				t.Fatalf("result usage should expose normalized cached_tokens, got %+v", resultUsage)
			}
		})
	}
}

func TestOpenAIClientChatStreamPreservesStructuredDeltas(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/event-stream")
		_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-structured\",\"object\":\"chat.completion.chunk\",\"model\":\"openrouter-reasoner\",\"choices\":[{\"delta\":{\"reasoning_details\":[{\"type\":\"reasoning.text\",\"text\":\"detail-\"},{\"type\":\"reasoning.summary\",\"summary\":\"summary\"},{\"type\":\"reasoning.encrypted\",\"data\":\"secret\"}]}}],\"usage\":null}\n\n"))
		_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-structured\",\"object\":\"chat.completion.chunk\",\"model\":\"openrouter-reasoner\",\"choices\":[{\"delta\":{\"content\":\"<think>tagged</think>answer\"}}],\"usage\":null}\n\n"))
		_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-structured\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-v4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"lookup\",\"arguments\":\"{\\\"q\\\":\"}}]}}],\"usage\":null}\n\n"))
		_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-structured\",\"object\":\"chat.completion.chunk\",\"model\":\"deepseek-v4\",\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\"weather\\\"}\"}}]},\"finish_reason\":\"tool_calls\"}],\"usage\":null}\n\n"))
		_, _ = w.Write([]byte("data: [DONE]\n\n"))
	}))
	defer server.Close()

	captured := make([]StreamDeltaEvent, 0)
	response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:  "chat.completions",
		Model: "DeepSeek-V4",
		Body: map[string]any{
			"model":    "DeepSeek-V4",
			"messages": []any{map[string]any{"role": "user", "content": "ping"}},
			"stream":   true,
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:     server.URL,
			ModelName:   "deepseek-v4",
			Credentials: map[string]any{"apiKey": "test-key"},
		},
		StreamDelta: func(event StreamDeltaEvent) error {
			captured = append(captured, event)
			return nil
		},
	})
	if err != nil {
		t.Fatalf("run openai structured stream client: %v", err)
	}
	if len(captured) != 4 || captured[0].ReasoningContent != "detail-summary" || captured[1].ReasoningContent != "tagged" || captured[1].Text != "answer" || captured[2].Event == nil {
		t.Fatalf("structured stream events were not preserved: %+v", captured)
	}
	firstChoices, _ := captured[0].Event["choices"].([]any)
	firstChoice, _ := firstChoices[0].(map[string]any)
	firstDelta, _ := firstChoice["delta"].(map[string]any)
	if firstDelta["reasoning_content"] != "detail-summary" {
		t.Fatalf("reasoning_details were not converted in stream event: %+v", captured[0].Event)
	}
	if _, ok := firstDelta["reasoning_details"]; ok {
		t.Fatalf("reasoning_details should be removed from stream event: %+v", captured[0].Event)
	}
	choices, _ := response.Result["choices"].([]any)
	choice, _ := choices[0].(map[string]any)
	message, _ := choice["message"].(map[string]any)
	if message["reasoning_content"] != "detail-summarytagged" || message["content"] != "answer" || choice["finish_reason"] != "tool_calls" {
		t.Fatalf("reasoning or finish reason missing from aggregated result: %+v", response.Result)
	}
	toolCalls, _ := message["tool_calls"].([]any)
	toolCall, _ := toolCalls[0].(map[string]any)
	function, _ := toolCall["function"].(map[string]any)
	if function["arguments"] != "{\"q\":\"weather\"}" {
		t.Fatalf("tool call arguments were not aggregated: %+v", response.Result)
	}
}

func TestOpenAIClientChatStreamNormalizesToolCallFormats(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/event-stream")
		_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-tools-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"tool-format-test\",\"choices\":[{\"delta\":{\"function_call\":{\"name\":\"legacy_lookup\",\"arguments\":\"{\\\"city\\\":\"}}}],\"usage\":null}\n\n"))
		_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-tools-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"tool-format-test\",\"choices\":[{\"delta\":{\"functionCall\":{\"arguments\":\"\\\"Boston\\\"}\"}}}],\"usage\":null}\n\n"))
		_, _ = w.Write([]byte("data: {\"id\":\"chatcmpl-tools-stream\",\"object\":\"chat.completion.chunk\",\"model\":\"tool-format-test\",\"choices\":[{\"delta\":{\"toolCall\":{\"index\":1,\"id\":\"call_camel\",\"functionCall\":{\"name\":\"camel_lookup\",\"args\":{\"city\":\"SF\"}}}},\"finish_reason\":\"tool_calls\"}],\"usage\":null}\n\n"))
		_, _ = w.Write([]byte("data: [DONE]\n\n"))
	}))
	defer server.Close()

	captured := make([]StreamDeltaEvent, 0)
	response, err := (OpenAIClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:  "chat.completions",
		Model: "Tool-Format-Test",
		Body: map[string]any{
			"model":    "Tool-Format-Test",
			"messages": []any{map[string]any{"role": "user", "content": "ping"}},
			"stream":   true,
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:     server.URL,
			ModelName:   "tool-format-test",
			Credentials: map[string]any{"apiKey": "test-key"},
		},
		StreamDelta: func(event StreamDeltaEvent) error {
			captured = append(captured, event)
			return nil
		},
	})
	if err != nil {
		t.Fatalf("run openai stream client: %v", err)
	}
	if len(captured) != 3 {
		t.Fatalf("unexpected captured events: %+v", captured)
	}
	for index, event := range captured {
		choices, _ := event.Event["choices"].([]any)
		choice, _ := choices[0].(map[string]any)
		delta, _ := choice["delta"].(map[string]any)
		if _, ok := delta["functionCall"]; ok {
			t.Fatalf("functionCall should be converted away: %+v", event.Event)
		}
		if _, ok := delta["toolCall"]; ok {
			t.Fatalf("toolCall should be converted away: %+v", event.Event)
		}
		if index < 2 {
			if _, ok := delta["function_call"]; !ok {
				t.Fatalf("canonical legacy function_call should be preserved: %+v", event.Event)
			}
		}
	}
	choices, _ := response.Result["choices"].([]any)
	choice, _ := choices[0].(map[string]any)
	message, _ := choice["message"].(map[string]any)
	toolCalls, _ := message["tool_calls"].([]any)
	if len(toolCalls) != 2 || choice["finish_reason"] != "tool_calls" {
		t.Fatalf("unexpected normalized stream result: %+v", response.Result)
	}
	legacyCall, _ := toolCalls[0].(map[string]any)
	legacyFunction, _ := legacyCall["function"].(map[string]any)
	if legacyFunction["name"] != "legacy_lookup" || legacyFunction["arguments"] != "{\"city\":\"Boston\"}" {
		t.Fatalf("legacy function_call was not aggregated: %+v", response.Result)
	}
	camelCall, _ := toolCalls[1].(map[string]any)
	camelFunction, _ := camelCall["function"].(map[string]any)
	if camelCall["id"] != "call_camel" || camelFunction["name"] != "camel_lookup" || camelFunction["arguments"] != "{\"city\":\"SF\"}" {
		t.Fatalf("camel toolCall was not normalized: %+v", response.Result)
	}
}

func TestGeminiClientChatContract(t *testing.T) {
	var gotPath string
	var gotKey string
	var gotText string
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotPath = r.URL.Path
		gotKey = r.URL.Query().Get("key")
		var body map[string]any
		if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
			t.Fatalf("decode request: %v", err)
		}
		contents, _ := body["contents"].([]any)
		first, _ := contents[0].(map[string]any)
		parts, _ := first["parts"].([]any)
		part, _ := parts[0].(map[string]any)
		gotText, _ = part["text"].(string)
		_ = json.NewEncoder(w).Encode(map[string]any{
			"candidates": []any{map[string]any{
				"content": map[string]any{
					"parts": []any{map[string]any{"text": "gemini ok"}},
				},
			}},
			"usageMetadata": map[string]any{
				"promptTokenCount":     4,
				"candidatesTokenCount": 6,
				"totalTokenCount":      10,
			},
		})
	}))
	defer server.Close()

	response, err := (GeminiClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:  "chat.completions",
		Model: "gemini:gemini-2.5-flash",
		Body: map[string]any{
			"model":    "gemini:gemini-2.5-flash",
			"messages": []any{map[string]any{"role": "user", "content": "ping"}},
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           server.URL,
			ModelName:         "gemini-2.5-flash",
			ProviderModelName: "gemini-compatible-2.5-flash",
			ModelType:         "chat",
			Credentials:       map[string]any{"apiKey": "gemini-key"},
		},
	})
	if err != nil {
		t.Fatalf("run gemini client: %v", err)
	}
	if gotPath != "/v1beta/models/gemini-compatible-2.5-flash:generateContent" || gotKey != "gemini-key" || gotText != "ping" {
		t.Fatalf("unexpected request path=%s key=%s text=%s", gotPath, gotKey, gotText)
	}
	if response.Usage.TotalTokens != 10 || extractText(response.Result) != "gemini ok" {
		t.Fatalf("unexpected response: %+v", response)
	}
}

func TestGeminiClientChatConvertsMediaContentParts(t *testing.T) {
	var captured map[string]any
	var gotPath string
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotPath = r.URL.Path
		if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
			t.Fatalf("decode request: %v", err)
		}
		_ = json.NewEncoder(w).Encode(map[string]any{
			"candidates": []any{map[string]any{
				"content": map[string]any{"parts": []any{map[string]any{"text": "video ok"}}},
			}},
		})
	}))
	defer server.Close()

	_, err := (GeminiClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:  "chat.completions",
		Model: "gemini:gemini-2.5-flash",
		Body: map[string]any{
			"model": "gemini:gemini-2.5-flash",
			"messages": []any{map[string]any{
				"role": "user",
				"content": []any{
					map[string]any{"type": "text", "text": "analyze this video"},
					map[string]any{"type": "video_url", "video_url": map[string]any{"url": "https://cdn.example.com/input.mov", "mime_type": "video/quicktime"}},
					map[string]any{"type": "audio_url", "audio_url": map[string]any{"url": "data:audio/wav;base64,UklGRg=="}},
				},
			}},
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           server.URL + "/v1beta/openai",
			ProviderModelName: "gemini-2.5-flash",
			ModelType:         "chat",
			Credentials:       map[string]any{"apiKey": "gemini-key"},
		},
	})
	if err != nil {
		t.Fatalf("run gemini client: %v", err)
	}
	if gotPath != "/v1beta/models/gemini-2.5-flash:generateContent" {
		t.Fatalf("Gemini OpenAI-compatible base URL should normalize to native endpoint, got %s", gotPath)
	}
	contents, _ := captured["contents"].([]any)
	if len(contents) != 1 {
		t.Fatalf("unexpected Gemini contents: %+v", captured)
	}
	turn, _ := contents[0].(map[string]any)
	parts, _ := turn["parts"].([]any)
	if len(parts) != 3 {
		t.Fatalf("expected text, video, and audio parts, got %+v", turn)
	}
	video, _ := parts[1].(map[string]any)
	videoFile, _ := video["fileData"].(map[string]any)
	if videoFile["fileUri"] != "https://cdn.example.com/input.mov" || videoFile["mimeType"] != "video/quicktime" {
		t.Fatalf("video_url should become Gemini fileData, got %+v", video)
	}
	audio, _ := parts[2].(map[string]any)
	audioInline, _ := audio["inlineData"].(map[string]any)
	if audioInline["mimeType"] != "audio/wav" || audioInline["data"] != "UklGRg==" {
		t.Fatalf("audio data URL should become Gemini inlineData, got %+v", audio)
	}
}

func TestGeminiClientImageGenerateBuildsNativeImageBody(t *testing.T) {
	var captured map[string]any
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
			t.Fatalf("decode request: %v", err)
		}
		_ = json.NewEncoder(w).Encode(map[string]any{
			"candidates": []any{map[string]any{
				"content": map[string]any{
					"parts": []any{map[string]any{
						"inlineData": map[string]any{
							"mimeType": "image/png",
							"data":     "aW1hZ2U=",
						},
					}},
				},
			}},
		})
	}))
	defer server.Close()

	response, err := (GeminiClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:      "images.generations",
		ModelType: "image_generate",
		Model:     "gemini-image",
		Body: map[string]any{
			"model":  "gemini-image",
			"prompt": "draw a cat",
			"generationConfig": map[string]any{
				"temperature": 0.5,
				"imageConfig": map[string]any{
					"aspectRatio": "16:9",
				},
			},
			"resolution": "2K",
			"n":          2,
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           server.URL,
			ProviderModelName: "gemini-image",
			ModelType:         "image_generate",
			Credentials:       map[string]any{"apiKey": "gemini-key"},
		},
	})
	if err != nil {
		t.Fatalf("run gemini image generate: %v", err)
	}
	config := captured["generationConfig"].(map[string]any)
	modalities := config["responseModalities"].([]any)
	imageConfig := config["imageConfig"].(map[string]any)
	if modalities[0] != "IMAGE" || config["temperature"] != 0.5 || numericValue(config["candidateCount"], 0) != 2 || imageConfig["aspectRatio"] != "16:9" || imageConfig["imageSize"] != "2K" {
		t.Fatalf("unexpected generationConfig: %+v", config)
	}
	contents := captured["contents"].([]any)
	parts := contents[0].(map[string]any)["parts"].([]any)
	if parts[0].(map[string]any)["text"] != "draw a cat" {
		t.Fatalf("unexpected image contents: %+v", captured)
	}
	data := response.Result["data"].([]any)
	if data[0].(map[string]any)["b64_json"] != "aW1hZ2U=" {
		t.Fatalf("unexpected image response: %+v", response.Result)
	}
	if response.Wire == nil || len(response.Wire.RawJSON) != 0 {
		t.Fatalf("Gemini image response retained duplicate raw JSON: %+v", response.Wire)
	}
	candidates, _ := response.Wire.Body["candidates"].([]any)
	content, _ := candidates[0].(map[string]any)["content"].(map[string]any)
	wireParts, _ := content["parts"].([]any)
	inline, _ := wireParts[0].(map[string]any)["inlineData"].(map[string]any)
	if inline["data"] != "aW1hZ2U=" {
		t.Fatalf("decoded Gemini wire body lost Base64 output: %+v", response.Wire.Body)
	}
}

func TestGeminiClientImageGenerateRejectsPromptBlockedWithoutImage(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("X-Request-Id", "gemini-blocked-request")
		_ = json.NewEncoder(w).Encode(map[string]any{
			"promptFeedback": map[string]any{
				"blockReason":        "SAFETY",
				"blockReasonMessage": "The request was blocked by the image safety policy.",
			},
			"usageMetadata": map[string]any{"promptTokenCount": 12, "totalTokenCount": 12},
		})
	}))
	defer server.Close()

	_, err := (GeminiClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:      "images.generations",
		ModelType: "image_generate",
		Model:     "gemini-image",
		Body:      map[string]any{"model": "gemini-image", "prompt": "blocked prompt"},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           server.URL,
			ProviderModelName: "gemini-image",
			ModelType:         "image_generate",
			Credentials:       map[string]any{"apiKey": "gemini-key"},
		},
	})
	if err == nil {
		t.Fatal("missing Gemini image must fail instead of returning a placeholder")
	}
	if ErrorCode(err) != "gemini_image_output_missing" || IsRetryable(err) {
		t.Fatalf("unexpected missing image error: code=%q retryable=%v err=%v", ErrorCode(err), IsRetryable(err), err)
	}
	meta := ErrorResponseMetadata(err)
	if meta.RequestID != "gemini-blocked-request" || meta.ResponseDurationMS < 0 {
		t.Fatalf("missing image error lost response metadata: %+v", meta)
	}
	details := ErrorDetails(err)
	if details["reason"] != "prompt_blocked" || details["promptBlockReason"] != "SAFETY" || details["candidateCount"] != 0 {
		t.Fatalf("unexpected prompt block details: %+v", details)
	}
	if !strings.Contains(err.Error(), "blockReason=SAFETY") || strings.Contains(err.Error(), "gemini-image-placeholder") {
		t.Fatalf("missing image error should explain the upstream block without a placeholder: %v", err)
	}
}

func TestGeminiMissingImageDetailsExplainEmptyImagePayload(t *testing.T) {
	details := geminiMissingImageDetails(map[string]any{
		"candidates": []any{map[string]any{
			"finishReason": "STOP",
			"content": map[string]any{"parts": []any{
				map[string]any{"inlineData": map[string]any{"mimeType": "image/png", "data": ""}},
				map[string]any{"text": "The model completed without producing an image."},
			}},
		}},
	})
	message := geminiMissingImageMessage(details)

	if details["reason"] != "empty_image_payload" || details["candidateCount"] != 1 || details["partCount"] != 2 {
		t.Fatalf("unexpected empty image payload details: %+v", details)
	}
	if !strings.Contains(message, "missing inline data or file URI") ||
		!strings.Contains(message, "finishReason=STOP") ||
		!strings.Contains(message, "The model completed without producing an image.") {
		t.Fatalf("missing image message should preserve the concrete extraction reason: %s", message)
	}
}

func TestGeminiMissingImageDetailsPreserveUpstreamErrorEnvelope(t *testing.T) {
	details := geminiMissingImageDetails(map[string]any{
		"error": map[string]any{
			"code":    500,
			"status":  "INTERNAL",
			"message": "image backend returned no generated asset",
		},
	})
	message := geminiMissingImageMessage(details)

	if details["reason"] != "upstream_error" ||
		details["upstreamErrorCode"] != "500" ||
		details["upstreamErrorStatus"] != "INTERNAL" {
		t.Fatalf("unexpected upstream error details: %+v", details)
	}
	if !strings.Contains(message, "upstreamErrorCode=500") ||
		!strings.Contains(message, "upstreamErrorStatus=INTERNAL") ||
		!strings.Contains(message, "image backend returned no generated asset") {
		t.Fatalf("upstream error message should remain diagnostic: %s", message)
	}
}

func TestGeminiGenerationConfigInitializesMissingImageConfig(t *testing.T) {
	config := geminiGenerationConfig(map[string]any{
		"aspect_ratio": "16:9",
		"resolution":   "4K",
	}, true)

	imageConfig, ok := config["imageConfig"].(map[string]any)
	if !ok {
		t.Fatalf("imageConfig should be initialized, got %+v", config)
	}
	if imageConfig["aspectRatio"] != "16:9" || imageConfig["imageSize"] != "4K" {
		t.Fatalf("unexpected imageConfig: %+v", imageConfig)
	}
	modalities, ok := config["responseModalities"].([]any)
	if !ok || len(modalities) != 1 || modalities[0] != "IMAGE" {
		t.Fatalf("image response modality should be initialized, got %+v", config)
	}
}

func TestGeminiClientImageEditPreservesNativeContentsAndFileData(t *testing.T) {
	var captured map[string]any
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
			t.Fatalf("decode request: %v", err)
		}
		_ = json.NewEncoder(w).Encode(map[string]any{
			"candidates": []any{map[string]any{
				"content": map[string]any{
					"parts": []any{map[string]any{
						"fileData": map[string]any{
							"mimeType": "image/webp",
							"fileUri":  "https://cdn.example/out.webp",
						},
					}},
				},
			}},
		})
	}))
	defer server.Close()

	_, err := (GeminiClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:      "images.edits",
		ModelType: "image_edit",
		Model:     "gemini-image",
		Body: map[string]any{
			"model": "gemini-image",
			"contents": []any{map[string]any{
				"parts": []any{
					map[string]any{"fileData": map[string]any{
						"mimeType": "image/png",
						"fileUri":  "https://cdn.example/input.png",
					}},
					map[string]any{"text": "edit it"},
				},
			}},
			"generationConfig": map[string]any{"temperature": 0.2},
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           server.URL,
			ProviderModelName: "gemini-image",
			ModelType:         "image_edit",
			Credentials:       map[string]any{"apiKey": "gemini-key"},
		},
	})
	if err != nil {
		t.Fatalf("run gemini image edit: %v", err)
	}
	config := captured["generationConfig"].(map[string]any)
	if config["temperature"] != 0.2 {
		t.Fatalf("generationConfig should preserve caller settings: %+v", config)
	}
	contents := captured["contents"].([]any)
	parts := contents[0].(map[string]any)["parts"].([]any)
	fileData := parts[0].(map[string]any)["fileData"].(map[string]any)
	if fileData["fileUri"] != "https://cdn.example/input.png" {
		t.Fatalf("native fileData should be preserved: %+v", captured)
	}
}

func TestGeminiClientChatRestoresToolContext(t *testing.T) {
	var captured map[string]any
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
			t.Fatalf("decode request: %v", err)
		}
		_ = json.NewEncoder(w).Encode(map[string]any{
			"candidates": []any{map[string]any{
				"content": map[string]any{"parts": []any{map[string]any{"text": "gemini ok"}}},
			}},
		})
	}))
	defer server.Close()

	_, err := (GeminiClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:  "chat.completions",
		Model: "gemini:gemini-2.5-flash",
		Body: map[string]any{
			"model": "gemini:gemini-2.5-flash",
			"messages": []any{
				map[string]any{"role": "user", "content": "weather?"},
				map[string]any{
					"role":    "assistant",
					"content": "checking",
					"tool_calls": []any{map[string]any{
						"id":   "call_weather",
						"type": "function",
						"function": map[string]any{
							"name":      "get_weather",
							"arguments": `{"city":"SF"}`,
						},
					}},
				},
				map[string]any{"role": "tool", "tool_call_id": "call_weather", "content": `{"temperature":"72F"}`},
			},
			"tools": []any{map[string]any{
				"type": "function",
				"function": map[string]any{
					"name":        "get_weather",
					"description": "lookup weather",
					"parameters": map[string]any{
						"type": "object",
						"properties": map[string]any{
							"location": map[string]any{
								"anyOf": []any{
									map[string]any{"type": "string"},
									map[string]any{
										"type": "object",
										"properties": map[string]any{
											"city": map[string]any{"type": "string"},
										},
										"required": []any{"city"},
									},
								},
							},
						},
						"required": []any{"location"},
					},
				},
			}},
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           server.URL,
			ProviderModelName: "gemini-2.5-flash",
			ModelType:         "chat",
			Credentials:       map[string]any{"apiKey": "gemini-key"},
		},
	})
	if err != nil {
		t.Fatalf("run gemini client: %v", err)
	}
	contents, _ := captured["contents"].([]any)
	if len(contents) != 3 {
		t.Fatalf("unexpected Gemini contents: %+v", captured)
	}
	modelTurn, _ := contents[1].(map[string]any)
	if modelTurn["role"] != "model" {
		t.Fatalf("assistant turn should become Gemini model turn: %+v", modelTurn)
	}
	modelParts, _ := modelTurn["parts"].([]any)
	callPart, _ := modelParts[1].(map[string]any)
	functionCall, _ := callPart["functionCall"].(map[string]any)
	args, _ := functionCall["args"].(map[string]any)
	if functionCall["name"] != "get_weather" || args["city"] != "SF" {
		t.Fatalf("tool call was not restored for Gemini: %+v", modelTurn)
	}
	toolTurn, _ := contents[2].(map[string]any)
	toolParts, _ := toolTurn["parts"].([]any)
	responsePart, _ := toolParts[0].(map[string]any)
	functionResponse, _ := responsePart["functionResponse"].(map[string]any)
	response, _ := functionResponse["response"].(map[string]any)
	if toolTurn["role"] != "user" || functionResponse["name"] != "get_weather" || response["temperature"] != "72F" {
		t.Fatalf("tool result was not restored for Gemini: %+v", toolTurn)
	}
	tools, _ := captured["tools"].([]any)
	declarationGroup, _ := tools[0].(map[string]any)
	declarations, _ := declarationGroup["functionDeclarations"].([]any)
	declaration, _ := declarations[0].(map[string]any)
	if declaration["name"] != "get_weather" || declaration["description"] != "lookup weather" {
		t.Fatalf("tool declaration was not converted for Gemini: %+v", captured["tools"])
	}
	if _, exists := declaration["parameters"]; exists {
		t.Fatalf("Gemini tool declaration must not use OpenAPI parameters: %+v", declaration)
	}
	parameters, _ := declaration["parametersJsonSchema"].(map[string]any)
	properties, _ := parameters["properties"].(map[string]any)
	location, _ := properties["location"].(map[string]any)
	anyOf, _ := location["anyOf"].([]any)
	nestedObject, _ := anyOf[1].(map[string]any)
	required, _ := nestedObject["required"].([]any)
	if len(anyOf) != 2 || len(required) != 1 || required[0] != "city" {
		t.Fatalf("Gemini JSON Schema was not preserved: %+v", declaration)
	}
}

func TestGeminiClientChatConvertsFunctionCallResponse(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_ = json.NewEncoder(w).Encode(map[string]any{
			"candidates": []any{map[string]any{
				"finishReason": "STOP",
				"content": map[string]any{"parts": []any{
					map[string]any{"functionCall": map[string]any{
						"name": "get_weather",
						"args": map[string]any{"city": "SF"},
					}},
				}},
			}},
		})
	}))
	defer server.Close()

	response, err := (GeminiClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:  "chat.completions",
		Model: "gemini:gemini-2.5-flash",
		Body: map[string]any{
			"model":    "gemini:gemini-2.5-flash",
			"messages": []any{map[string]any{"role": "user", "content": "weather?"}},
			"tools": []any{map[string]any{
				"type":     "function",
				"function": map[string]any{"name": "get_weather"},
			}},
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           server.URL,
			ProviderModelName: "gemini-2.5-flash",
			ModelType:         "chat",
			Credentials:       map[string]any{"apiKey": "gemini-key"},
		},
	})
	if err != nil {
		t.Fatalf("run gemini client: %v", err)
	}
	choices, _ := response.Result["choices"].([]any)
	choice, _ := choices[0].(map[string]any)
	if choice["finish_reason"] != "tool_calls" {
		t.Fatalf("Gemini function call should use tool_calls finish reason: %+v", response.Result)
	}
	message, _ := choice["message"].(map[string]any)
	if message["content"] != nil {
		t.Fatalf("tool-only Gemini response should keep nullable content: %+v", message)
	}
	toolCalls, _ := message["tool_calls"].([]any)
	if len(toolCalls) != 1 {
		t.Fatalf("Gemini function call was not converted: %+v", message)
	}
	toolCall, _ := toolCalls[0].(map[string]any)
	function, _ := toolCall["function"].(map[string]any)
	if toolCall["type"] != "function" || toolCall["id"] != "call_0" || function["name"] != "get_weather" || function["arguments"] != `{"city":"SF"}` {
		t.Fatalf("unexpected Gemini tool call: %+v", toolCall)
	}
}

func TestGeminiURLAcceptsVersionedBaseURL(t *testing.T) {
	tests := []struct {
		name    string
		baseURL string
		want    string
	}{
		{
			name:    "default version",
			baseURL: "https://generativelanguage.googleapis.com",
			want:    "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=test-key",
		},
		{
			name:    "google beta version",
			baseURL: "https://generativelanguage.googleapis.com/v1beta",
			want:    "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=test-key",
		},
		{
			name:    "compatible v1 endpoint",
			baseURL: "https://cloud.dataeyes.ai/v1",
			want:    "https://cloud.dataeyes.ai/v1/models/gemini-2.5-flash:generateContent?key=test-key",
		},
		{
			name:    "openai suffix after version",
			baseURL: "https://generativelanguage.googleapis.com/v1beta/openai",
			want:    "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=test-key",
		},
	}
	for _, test := range tests {
		t.Run(test.name, func(t *testing.T) {
			got := geminiURL(test.baseURL, "gemini-2.5-flash", "test-key")
			if got != test.want {
				t.Fatalf("unexpected gemini url: %s", got)
			}
		})
	}
}

func TestVolcesClientImageEditUsesGenerationEndpoint(t *testing.T) {
	var gotPath string
	var gotAuth string
	var gotModel string
	var gotImage string
	var gotSequential any
	var gotSequentialPresent bool
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotPath = r.URL.Path
		gotAuth = r.Header.Get("Authorization")
		var body map[string]any
		if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
			t.Fatalf("decode request: %v", err)
		}
		gotModel, _ = body["model"].(string)
		gotImage, _ = body["image"].(string)
		gotSequential, gotSequentialPresent = body["sequential_image_generation"]
		_ = json.NewEncoder(w).Encode(map[string]any{
			"id":      "img-volces-edit",
			"created": 123,
			"data":    []any{map[string]any{"url": "https://example.com/out.png"}},
		})
	}))
	defer server.Close()

	response, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:      "images.edits",
		ModelType: "image_edit",
		Model:     "doubao-4.0图像编辑",
		Body: map[string]any{
			"model":  "doubao-4.0图像编辑",
			"prompt": "make it brighter",
			"image":  "https://example.com/source.png",
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           server.URL,
			ModelName:         "豆包Seedream-4.0",
			ProviderModelName: "doubao-seedream-4-0-250828",
			Credentials:       map[string]any{"apiKey": "volces-key"},
			Capabilities: map[string]any{
				"image_edit": map[string]any{"output_multiple_images": true},
			},
		},
	})
	if err != nil {
		t.Fatalf("run volces image edit: %v", err)
	}
	if gotPath != "/images/generations" || gotAuth != "Bearer volces-key" {
		t.Fatalf("unexpected request path=%s auth=%s", gotPath, gotAuth)
	}
	if gotModel != "doubao-seedream-4-0-250828" || gotImage != "https://example.com/source.png" || gotSequentialPresent {
		t.Fatalf("unexpected body model=%s image=%s sequential=%s", gotModel, gotImage, gotSequential)
	}
	if response.Result["id"] != "img-volces-edit" {
		t.Fatalf("unexpected response: %+v", response.Result)
	}
}

func TestVolcesClientImageEditEnablesSequentialForRequestedMultipleImages(t *testing.T) {
	var gotSequential string
	var gotMaxImages float64
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		var body map[string]any
		if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
			t.Fatalf("decode request: %v", err)
		}
		gotSequential, _ = body["sequential_image_generation"].(string)
		options, _ := body["sequential_image_generation_options"].(map[string]any)
		gotMaxImages = numericValue(options["max_images"], 0)
		_ = json.NewEncoder(w).Encode(map[string]any{
			"id":   "img-volces-edit",
			"data": []any{map[string]any{"url": "https://example.com/out.png"}},
		})
	}))
	defer server.Close()

	_, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:      "images.edits",
		ModelType: "image_edit",
		Body: map[string]any{
			"prompt": "make variants",
			"image":  "https://example.com/source.png",
			"n":      3,
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           server.URL,
			ProviderModelName: "doubao-seedream-4-0-250828",
			Credentials:       map[string]any{"apiKey": "volces-key"},
			Capabilities: map[string]any{
				"image_edit": map[string]any{
					"output_multiple_images":  true,
					"output_max_images_count": 4,
				},
			},
		},
	})
	if err != nil {
		t.Fatalf("run volces image edit: %v", err)
	}
	if gotSequential != "auto" || gotMaxImages != 3 {
		t.Fatalf("unexpected sequential settings mode=%s max_images=%v", gotSequential, gotMaxImages)
	}
}

func TestVolcesClientImageEditPreservesExplicitSequentialDisabledAndClampsMaxImages(t *testing.T) {
	var gotSequential string
	var gotMaxImages float64
	var gotOtherOption string
	options := map[string]any{"max_images": 99, "other": "keep"}
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		var body map[string]any
		if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
			t.Fatalf("decode request: %v", err)
		}
		gotSequential, _ = body["sequential_image_generation"].(string)
		gotOptions, _ := body["sequential_image_generation_options"].(map[string]any)
		gotMaxImages = numericValue(gotOptions["max_images"], 0)
		gotOtherOption, _ = gotOptions["other"].(string)
		_ = json.NewEncoder(w).Encode(map[string]any{
			"id":   "img-volces-edit",
			"data": []any{map[string]any{"url": "https://example.com/out.png"}},
		})
	}))
	defer server.Close()

	_, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:      "images.edits",
		ModelType: "image_edit",
		Body: map[string]any{
			"prompt":                              "make variants",
			"image":                               "https://example.com/source.png",
			"sequential_image_generation":         "disabled",
			"sequential_image_generation_options": options,
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           server.URL,
			ProviderModelName: "doubao-seedream-4-0-250828",
			Credentials:       map[string]any{"apiKey": "volces-key"},
			Capabilities: map[string]any{
				"image_edit": map[string]any{
					"output_multiple_images":  true,
					"output_max_images_count": 4,
				},
			},
		},
	})
	if err != nil {
		t.Fatalf("run volces image edit: %v", err)
	}
	if gotSequential != "disabled" || gotMaxImages != 4 || gotOtherOption != "keep" {
		t.Fatalf("unexpected sequential settings mode=%s max_images=%v other=%s", gotSequential, gotMaxImages, gotOtherOption)
	}
	if numericValue(options["max_images"], 0) != 99 {
		t.Fatalf("request options should not be mutated: %+v", options)
	}
}

func TestVolcesClientSeedreamProGenerationUsesRealModelAndSingleOutput(t *testing.T) {
	var captured map[string]any
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
			t.Fatalf("decode request: %v", err)
		}
		_ = json.NewEncoder(w).Encode(map[string]any{
			"id":   "img-seedream-pro",
			"data": []any{map[string]any{"url": "https://example.com/out.png"}},
		})
	}))
	defer server.Close()

	_, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:      "images.generations",
		ModelType: "image_generate",
		Model:     "Seedream-5.0-Pro",
		Body: map[string]any{
			"model":                               "Seedream-5.0-Pro",
			"prompt":                              "draw a mountain",
			"resolution":                          "2K",
			"n":                                   4,
			"sequential_image_generation":         "auto",
			"sequential_image_generation_options": map[string]any{"max_images": 4},
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           server.URL,
			ModelName:         "Seedream-5.0-Pro",
			ModelAlias:        "Seedream-5.0-Pro",
			ProviderModelName: "doubao-seedream-5-0-pro-260628",
			Credentials:       map[string]any{"apiKey": "volces-key"},
			Capabilities: map[string]any{
				"image_generate": map[string]any{
					"output_multiple_images":  false,
					"output_max_images_count": 1,
				},
			},
		},
	})
	if err != nil {
		t.Fatalf("run Seedream Pro generation: %v", err)
	}
	if captured["model"] != "doubao-seedream-5-0-pro-260628" || captured["size"] != "2K" {
		t.Fatalf("Seedream Pro should use the real model and resolution size, got %+v", captured)
	}
	if captured["n"] != float64(1) {
		t.Fatalf("Seedream Pro should force single-image output, got %+v", captured)
	}
	if _, ok := captured["sequential_image_generation"]; ok {
		t.Fatalf("Seedream Pro request must not include sequential image generation, got %+v", captured)
	}
	if _, ok := captured["sequential_image_generation_options"]; ok {
		t.Fatalf("Seedream Pro request must not include sequential image options, got %+v", captured)
	}
}

func TestVolcesClientSeedreamProEditForwardsTenReferencesAndCustomSize(t *testing.T) {
	var captured map[string]any
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if err := json.NewDecoder(r.Body).Decode(&captured); err != nil {
			t.Fatalf("decode request: %v", err)
		}
		_ = json.NewEncoder(w).Encode(map[string]any{
			"id":   "img-seedream-pro-edit",
			"data": []any{map[string]any{"url": "https://example.com/out.png"}},
		})
	}))
	defer server.Close()

	images := make([]any, 10)
	for index := range images {
		images[index] = fmt.Sprintf("https://example.com/reference-%d.png", index+1)
	}
	_, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:      "images.edits",
		ModelType: "image_edit",
		Model:     "Seedream-5.0-Pro",
		Body: map[string]any{
			"model":  "Seedream-5.0-Pro",
			"prompt": "combine the references",
			"image":  images,
			"width":  1600,
			"height": 900,
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           server.URL,
			ModelName:         "Seedream-5.0-Pro",
			ModelAlias:        "Seedream-5.0-Pro",
			ProviderModelName: "doubao-seedream-5-0-pro-260628",
			Credentials:       map[string]any{"apiKey": "volces-key"},
			Capabilities: map[string]any{
				"image_edit": map[string]any{
					"input_multiple_images":     true,
					"input_max_images_count":    10,
					"output_multiple_images":    false,
					"output_max_images_count":   1,
					"input_max_file_size_bytes": 30 * 1024 * 1024,
				},
			},
		},
	})
	if err != nil {
		t.Fatalf("run Seedream Pro edit: %v", err)
	}
	gotImages, _ := captured["image"].([]any)
	if captured["model"] != "doubao-seedream-5-0-pro-260628" || captured["size"] != "1600x900" || len(gotImages) != 10 {
		t.Fatalf("Seedream Pro edit should preserve ten references and custom size, got %+v", captured)
	}
	if _, ok := captured["sequential_image_generation"]; ok {
		t.Fatalf("Seedream Pro edit must not include sequential image generation, got %+v", captured)
	}
}

func TestVolcesClientVideoSubmitsAndPollsTask(t *testing.T) {
	var submitPath string
	var pollPath string
	var gotAuth string
	var gotModel string
	var gotText string
	var gotFirstFrameRole string
	var gotDuration float64
	var gotRatio string
	var gotResolution string
	var gotSeed float64
	var gotCameraFixed bool
	var gotWatermark bool
	var submittedRemoteTaskID string
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotAuth = r.Header.Get("Authorization")
		switch r.Method + " " + r.URL.Path {
		case "POST /contents/generations/tasks":
			submitPath = r.URL.Path
			var body map[string]any
			if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
				t.Fatalf("decode request: %v", err)
			}
			gotModel, _ = body["model"].(string)
			if body["prompt"] != nil || body["first_frame"] != nil {
				t.Fatalf("video convenience fields leaked upstream: %+v", body)
			}
			for _, key := range []string{"duration_seconds", "aspect_ratio", "audio", "cameraFixed"} {
				if _, ok := body[key]; ok {
					t.Fatalf("volces video task body should not include top-level %s: %+v", key, body)
				}
			}
			gotDuration, _ = body["duration"].(float64)
			gotRatio, _ = body["ratio"].(string)
			gotResolution, _ = body["resolution"].(string)
			gotSeed, _ = body["seed"].(float64)
			gotCameraFixed, _ = body["camera_fixed"].(bool)
			gotWatermark, _ = body["watermark"].(bool)
			content, _ := body["content"].([]any)
			textItem, _ := content[0].(map[string]any)
			gotText, _ = textItem["text"].(string)
			frameItem, _ := content[1].(map[string]any)
			gotFirstFrameRole, _ = frameItem["role"].(string)
			_ = json.NewEncoder(w).Encode(map[string]any{"id": "cgt-test"})
		case "GET /contents/generations/tasks/cgt-test":
			pollPath = r.URL.Path
			_ = json.NewEncoder(w).Encode(map[string]any{
				"id":         "cgt-test",
				"model":      "doubao-seedance-2-0-260128",
				"status":     "succeeded",
				"created_at": 456,
				"content":    map[string]any{"video_url": "https://example.com/out.mp4"},
				"usage":      map[string]any{"completion_tokens": 7, "total_tokens": 9},
			})
		default:
			t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
		}
	}))
	defer server.Close()

	response, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:      "videos.generations",
		ModelType: "video_generate",
		Model:     "豆包Seedance-2.0",
		Body: map[string]any{
			"model":        "豆包Seedance-2.0",
			"prompt":       "A clean product reveal",
			"first_frame":  "https://example.com/first.png",
			"duration":     6,
			"aspect_ratio": "16:9",
			"resolution":   "720p",
			"seed":         11,
			"cameraFixed":  false,
			"watermark":    true,
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           server.URL,
			ModelName:         "豆包Seedance-2.0",
			ProviderModelName: "doubao-seedance-2-0-260128",
			Credentials:       map[string]any{"apiKey": "volces-key"},
			PlatformConfig: map[string]any{
				"volcesPollIntervalMs":     100,
				"volcesPollTimeoutSeconds": 1,
			},
		},
		OnRemoteTaskSubmitted: func(remoteTaskID string, payload map[string]any) error {
			submittedRemoteTaskID = remoteTaskID
			if payload["id"] != "cgt-test" {
				t.Fatalf("unexpected submitted payload: %+v", payload)
			}
			return nil
		},
	})
	if err != nil {
		t.Fatalf("run volces video: %v", err)
	}
	if submitPath != "/contents/generations/tasks" || pollPath != "/contents/generations/tasks/cgt-test" || gotAuth != "Bearer volces-key" {
		t.Fatalf("unexpected paths/auth submit=%s poll=%s auth=%s", submitPath, pollPath, gotAuth)
	}
	if submittedRemoteTaskID != "cgt-test" {
		t.Fatalf("remote task submit callback did not receive task id, got %q", submittedRemoteTaskID)
	}
	if gotModel != "doubao-seedance-2-0-260128" || gotFirstFrameRole != "first_frame" {
		t.Fatalf("unexpected submitted model=%s role=%s", gotModel, gotFirstFrameRole)
	}
	if gotText != "A clean product reveal" {
		t.Fatalf("video params should not be appended to prompt text, got %q", gotText)
	}
	if gotDuration != 6 || gotRatio != "16:9" || gotResolution != "720p" || gotSeed != 11 || gotCameraFixed != false || gotWatermark != true {
		t.Fatalf("unexpected submitted video params duration=%v ratio=%s resolution=%s seed=%v camera_fixed=%v watermark=%v", gotDuration, gotRatio, gotResolution, gotSeed, gotCameraFixed, gotWatermark)
	}
	data, _ := response.Result["data"].([]any)
	item, _ := data[0].(map[string]any)
	if item["url"] != "https://example.com/out.mp4" || response.Result["content"] != nil || response.Result["usage"] != nil || response.Usage.TotalTokens != 9 {
		t.Fatalf("unexpected response: %+v usage=%+v", response.Result, response.Usage)
	}
}

func TestVolcesClientVideoRetriesTransientPollAndKeepsOfficialResult(t *testing.T) {
	polls := 0
	persisted := make([]string, 0)
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.Method + " " + r.URL.Path {
		case "POST /contents/generations/tasks":
			_ = json.NewEncoder(w).Encode(map[string]any{"id": "cgt-retry"})
		case "GET /contents/generations/tasks/cgt-retry":
			polls++
			if polls == 1 {
				w.WriteHeader(http.StatusServiceUnavailable)
				_, _ = w.Write([]byte(`{"error":{"message":"try later"}}`))
				return
			}
			_ = json.NewEncoder(w).Encode(map[string]any{
				"id": "cgt-retry", "model": "doubao-seedance-2-0-mini-260615", "status": "succeeded",
				"created_at": 123, "updated_at": 124, "content": map[string]any{"video_url": "https://example.com/retry.mp4"},
				"usage": map[string]any{"total_tokens": 8}, "seed": 7,
			})
		default:
			t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
		}
	}))
	defer server.Close()

	response, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind: "videos.generations", Model: "seedance", Body: map[string]any{"model": "seedance", "prompt": "retry"},
		Candidate: store.RuntimeModelCandidate{BaseURL: server.URL, ProviderModelName: "doubao-seedance-2-0-mini-260615", Credentials: map[string]any{"apiKey": "key"}, PlatformConfig: map[string]any{"volcesPollIntervalMs": 100, "volcesPollRetryMaxMs": 100, "volcesPollTimeoutSeconds": 2}},
		OnRemoteTaskPolled: func(remoteTaskID string, payload map[string]any) error {
			persisted = append(persisted, remoteTaskID+":"+stringFromAny(payload["status"]))
			return nil
		},
	})
	if err != nil {
		t.Fatalf("run retrying Volces video: %v", err)
	}
	if polls != 2 || len(persisted) != 1 || persisted[0] != "cgt-retry:succeeded" {
		t.Fatalf("unexpected poll state polls=%d persisted=%+v", polls, persisted)
	}
	if response.Result["seed"] != float64(7) || response.Result["raw"] != nil || response.Result["updated_at"] != nil ||
		response.Result["content"] != nil || response.Result["usage"] != nil || response.Result["upstream_task_id"] != nil {
		t.Fatalf("provider response should be reduced to the canonical result: %+v", response.Result)
	}
}

func TestVolcesClientDeletesOfficialVideoTask(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Method != http.MethodDelete || r.URL.Path != "/contents/generations/tasks/cgt-delete" {
			t.Fatalf("unexpected delete request %s %s", r.Method, r.URL.Path)
		}
		if r.Header.Get("Authorization") != "Bearer delete-key" {
			t.Fatalf("unexpected delete authorization: %q", r.Header.Get("Authorization"))
		}
		_ = json.NewEncoder(w).Encode(map[string]any{"id": "cgt-delete", "status": "cancelled"})
	}))
	defer server.Close()

	result, _, err := (VolcesClient{HTTPClient: server.Client()}).DeleteVideoTask(context.Background(), Request{
		Candidate:    store.RuntimeModelCandidate{BaseURL: server.URL, Credentials: map[string]any{"apiKey": "delete-key"}},
		RemoteTaskID: "cgt-delete",
	})
	if err != nil || result["status"] != "cancelled" {
		t.Fatalf("unexpected delete response result=%+v err=%v", result, err)
	}
}

func TestVolcesCancelledTaskUsesDedicatedCancellationCode(t *testing.T) {
	if got := volcesTaskErrorCode(map[string]any{"status": "cancelled"}); got != "volces_task_cancelled" {
		t.Fatalf("cancelled task error code = %q", got)
	}
}

func TestVolcesClientVideoRejectsDuplicateFirstFrameBeforeSubmit(t *testing.T) {
	var submitted bool
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		submitted = true
		t.Fatalf("duplicate first_frame request should not be submitted upstream")
	}))
	defer server.Close()

	_, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:      "videos.generations",
		ModelType: "image_to_video",
		Model:     "豆包Seedance",
		Body: map[string]any{
			"model": "豆包Seedance",
			"content": []any{
				map[string]any{"type": "text", "text": "animate it"},
				map[string]any{"type": "image_url", "role": "first_frame", "image_url": map[string]any{"url": "https://example.com/first.png"}},
				map[string]any{"type": "image_url", "role": "first_frame", "image_url": map[string]any{"url": "https://example.com/second.png"}},
			},
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           server.URL,
			ProviderModelName: "doubao-seedance-1-5-pro-251215",
			Credentials:       map[string]any{"apiKey": "volces-key"},
		},
	})
	if err == nil || ErrorCode(err) != "invalid_parameter" {
		t.Fatalf("expected local invalid_parameter error, got %v", err)
	}
	if submitted {
		t.Fatal("request was submitted upstream")
	}
}

func TestVolcesVideoBodyAllowsOnlyTaskPayloadFields(t *testing.T) {
	body := volcesVideoBody(Request{
		Kind:      "videos.generations",
		ModelType: "omni_video",
		Model:     "豆包Seedance",
		Body: map[string]any{
			"model":                 "豆包Seedance",
			"duration":              8,
			"duration_seconds":      8,
			"aspect_ratio":          "9:16",
			"resolution":            "720p",
			"audio":                 true,
			"callback_url":          "https://example.com/callback",
			"returnLastFrame":       true,
			"executionExpiresAfter": 3600,
			"draft":                 false,
			"cameraFixed":           false,
			"watermark":             true,
			"seed":                  -1,
			"tools":                 []any{map[string]any{"type": "web_search"}},
			"task_id":               "local-task-id",
			"runMode":               "simulation",
			"modelType":             "omni_video",
			"model_type":            "omni_video",
			"fps":                   24,
			"content": []any{
				map[string]any{"type": "text", "text": "Use <<<element_1>>> in a product reveal"},
				map[string]any{
					"type": "element",
					"element": map[string]any{
						"inline_element": map[string]any{
							"name":              "subject",
							"frontal_image_url": "https://example.com/subject.png",
							"refer_images":      []any{map[string]any{"url": "https://example.com/side.png", "slot_key": "side"}},
						},
					},
				},
				map[string]any{
					"type":      "image_url",
					"role":      "unexpected_role",
					"name":      "drop-me",
					"image_url": map[string]any{"url": "https://example.com/ref.png", "extra": "drop-me"},
				},
				map[string]any{
					"type":     "video_url",
					"duration": 3,
					"video_url": map[string]any{
						"url":                 "https://example.com/ref.mp4",
						"refer_type":          "feature",
						"keep_original_sound": "yes",
						"extra":               "drop-me",
					},
				},
				map[string]any{
					"type":      "audio_url",
					"audio_url": map[string]any{"url": "https://example.com/ref.mp3", "extra": "drop-me"},
				},
			},
		},
		Candidate: store.RuntimeModelCandidate{
			ModelName:         "豆包Seedance",
			ProviderModelName: "doubao-seedance-2-0-260128",
			Credentials:       map[string]any{"apiKey": "volces-key"},
		},
	})

	allowedTopLevel := map[string]bool{
		"model": true, "content": true, "callback_url": true, "return_last_frame": true, "execution_expires_after": true,
		"generate_audio": true, "draft": true, "resolution": true, "ratio": true, "duration": true,
		"seed": true, "camera_fixed": true, "watermark": true, "tools": true,
	}
	for key := range body {
		if !allowedTopLevel[key] {
			t.Fatalf("unexpected top-level volces field %q in %+v", key, body)
		}
	}
	if body["model"] != "doubao-seedance-2-0-260128" ||
		body["generate_audio"] != true ||
		body["callback_url"] != "https://example.com/callback" ||
		body["return_last_frame"] != true ||
		body["execution_expires_after"] != 3600 ||
		body["draft"] != false ||
		body["resolution"] != "720p" ||
		body["ratio"] != "9:16" ||
		body["duration"] != 8 ||
		body["seed"] != -1 ||
		body["camera_fixed"] != false ||
		body["watermark"] != true {
		t.Fatalf("unexpected direct video fields: %+v", body)
	}
	tools, ok := body["tools"].([]any)
	if !ok || len(tools) != 1 {
		t.Fatalf("expected web search tools to pass through, got %+v", body["tools"])
	}

	content, ok := body["content"].([]map[string]any)
	if !ok || len(content) != 5 {
		t.Fatalf("unexpected sanitized content: %#v", body["content"])
	}
	text := content[0]
	if text["type"] != "text" || strings.Contains(text["text"].(string), "--dur") || strings.Contains(text["text"].(string), "--ratio") {
		t.Fatalf("video params should not be appended to the text item: %+v", text)
	}
	elementImage := content[1]
	if elementImage["type"] != "image_url" || elementImage["role"] != "reference_image" {
		t.Fatalf("referenced element should be converted to reference image: %+v", elementImage)
	}
	imageURL, _ := elementImage["image_url"].(map[string]any)
	if imageURL["url"] != "https://example.com/subject.png" || len(imageURL) != 1 {
		t.Fatalf("element image payload should only include url: %+v", imageURL)
	}
	referenceImage := content[2]
	if referenceImage["role"] != "reference_image" || referenceImage["name"] != nil {
		t.Fatalf("image references should be role-normalized and scrubbed: %+v", referenceImage)
	}
	videoItem := content[3]
	videoURL, _ := videoItem["video_url"].(map[string]any)
	if videoItem["role"] != "reference_video" || videoURL["url"] != "https://example.com/ref.mp4" || videoURL["refer_type"] != "feature" || videoURL["extra"] != nil {
		t.Fatalf("video references should keep only allowed nested fields: %+v", videoItem)
	}
	audioItem := content[4]
	audioURL, _ := audioItem["audio_url"].(map[string]any)
	if audioItem["role"] != "reference_audio" || audioURL["url"] != "https://example.com/ref.mp3" || len(audioURL) != 1 {
		t.Fatalf("audio references should keep only url: %+v", audioItem)
	}
}

func TestVolcesVideoBodyPrefersFramesOverDuration(t *testing.T) {
	body := volcesVideoBody(Request{
		Kind:      "videos.generations",
		ModelType: "video_generate",
		Body: map[string]any{
			"prompt":   "A quick camera move",
			"duration": 8,
			"frames":   57,
		},
		Candidate: store.RuntimeModelCandidate{
			ProviderModelName: "doubao-seedance-1-0-pro-250528",
		},
	})
	if body["frames"] != 57 {
		t.Fatalf("frames should be passed through as the official duration control: %+v", body)
	}
	if _, ok := body["duration"]; ok {
		t.Fatalf("duration should not be sent when frames is present: %+v", body)
	}
}

func TestVolcesVideoBodyBuildsWebSearchToolsFromFlag(t *testing.T) {
	body := volcesVideoBody(Request{
		Kind:      "videos.generations",
		ModelType: "video_generate",
		Model:     "豆包Seedance-2.0",
		Body: map[string]any{
			"model":             "豆包Seedance-2.0",
			"prompt":            "查找最新城市夜景素材再生成视频",
			"enable_web_search": true,
		},
		Candidate: store.RuntimeModelCandidate{
			ProviderModelName: "doubao-seedance-2-0-260128",
		},
	})

	tools, ok := body["tools"].([]any)
	if !ok || len(tools) != 1 {
		t.Fatalf("expected generated web search tools, got %+v", body["tools"])
	}
	tool, ok := tools[0].(map[string]any)
	if !ok || tool["type"] != "web_search" {
		t.Fatalf("unexpected generated web search tool: %+v", tools[0])
	}
}

func TestVolcesClientVideoResumePollsExistingTaskID(t *testing.T) {
	var submitCalled bool
	var pollPath string
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.Method + " " + r.URL.Path {
		case "POST /contents/generations/tasks":
			submitCalled = true
			t.Fatalf("resume should skip upstream submit when remote task id exists")
		case "GET /contents/generations/tasks/cgt-existing":
			pollPath = r.URL.Path
			_ = json.NewEncoder(w).Encode(map[string]any{
				"id":         "cgt-existing",
				"status":     "succeeded",
				"created_at": 789,
				"content":    map[string]any{"video_url": "https://example.com/resumed.mp4"},
			})
		default:
			t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
		}
	}))
	defer server.Close()

	response, err := (VolcesClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:         "videos.generations",
		ModelType:    "video_generate",
		Model:        "豆包Seedance-2.0",
		Body:         map[string]any{"prompt": "resume polling", "pollIntervalMs": 100, "pollTimeoutSeconds": 1},
		RemoteTaskID: "cgt-existing",
		Candidate: store.RuntimeModelCandidate{
			BaseURL:     server.URL,
			ModelName:   "豆包Seedance-2.0",
			Credentials: map[string]any{"apiKey": "volces-key"},
		},
	})
	if err != nil {
		t.Fatalf("resume volces video: %v", err)
	}
	if submitCalled || pollPath != "/contents/generations/tasks/cgt-existing" {
		t.Fatalf("resume should poll existing task only, submit=%v poll=%s", submitCalled, pollPath)
	}
	data, _ := response.Result["data"].([]any)
	item, _ := data[0].(map[string]any)
	if response.Result["upstream_task_id"] != nil || item["url"] != "https://example.com/resumed.mp4" {
		t.Fatalf("unexpected resumed response: %+v", response.Result)
	}
}

func TestKelingClientVideoSubmitsAndPollsImageTask(t *testing.T) {
	var submitPath string
	var pollPath string
	var gotAuth string
	var submittedTaskID string
	var submittedPayload map[string]any
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotAuth = r.Header.Get("Authorization")
		switch r.Method + " " + r.URL.Path {
		case "POST /videos/image2video":
			submitPath = r.URL.Path
			if err := json.NewDecoder(r.Body).Decode(&submittedPayload); err != nil {
				t.Fatalf("decode keling submit: %v", err)
			}
			if _, ok := submittedPayload["aspect_ratio"]; ok {
				t.Fatalf("image2video payload should not include aspect_ratio: %+v", submittedPayload)
			}
			_ = json.NewEncoder(w).Encode(map[string]any{
				"code":       0,
				"request_id": "req-submit",
				"data":       map[string]any{"task_id": "keling-task-1"},
			})
		case "GET /videos/image2video/keling-task-1":
			pollPath = r.URL.Path
			_ = json.NewEncoder(w).Encode(map[string]any{
				"code":       0,
				"request_id": "req-poll",
				"data": map[string]any{
					"task_id":     "keling-task-1",
					"task_status": "succeed",
					"created_at":  456,
					"task_result": map[string]any{
						"videos": []any{map[string]any{"url": "https://example.com/keling.mp4", "duration": 6}},
					},
				},
			})
		default:
			t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
		}
	}))
	defer server.Close()

	response, err := (KelingClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:      "videos.generations",
		ModelType: "image_to_video",
		Model:     "可灵2.6",
		Body: map[string]any{
			"model":                   "可灵2.6",
			"prompt":                  "A clean product reveal",
			"first_frame":             "data:image/png;base64,Zmlyc3Q=",
			"last_frame":              "data:image/png;base64,bGFzdA==",
			"duration":                6,
			"resolution":              "1080p",
			"aspect_ratio":            "16:9",
			"audio":                   true,
			"camera_control":          "simple:zoom",
			"camera_control_strength": 0.6,
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           server.URL,
			Provider:          "keling",
			AuthType:          "AccessKey-SecretKey",
			ModelName:         "可灵2.6",
			ProviderModelName: "kling-v2-6",
			Credentials:       map[string]any{"accessKey": "ak", "secretKey": "sk"},
			PlatformConfig: map[string]any{
				"kelingPollIntervalMs":     100,
				"kelingPollTimeoutSeconds": 1,
			},
		},
		OnRemoteTaskSubmitted: func(remoteTaskID string, payload map[string]any) error {
			submittedTaskID = remoteTaskID
			if payload["endpoint"] != "/videos/image2video" || payload["taskType"] != "image2video" {
				t.Fatalf("unexpected submitted keling payload: %+v", payload)
			}
			return nil
		},
	})
	if err != nil {
		t.Fatalf("run keling video: %v", err)
	}
	if submitPath != "/videos/image2video" || pollPath != "/videos/image2video/keling-task-1" || !strings.HasPrefix(gotAuth, "Bearer ") {
		t.Fatalf("unexpected keling paths/auth submit=%s poll=%s auth=%s", submitPath, pollPath, gotAuth)
	}
	if submittedTaskID != "keling-task-1" {
		t.Fatalf("remote task submit callback did not receive task id, got %q", submittedTaskID)
	}
	if submittedPayload["model_name"] != "kling-v2-6" ||
		submittedPayload["prompt"] != "A clean product reveal" ||
		submittedPayload["duration"] != "6" ||
		submittedPayload["mode"] != "pro" ||
		submittedPayload["sound"] != "on" ||
		submittedPayload["image"] != "Zmlyc3Q=" ||
		submittedPayload["image_tail"] != "bGFzdA==" {
		t.Fatalf("unexpected keling submit payload: %+v", submittedPayload)
	}
	camera, _ := submittedPayload["camera_control"].(map[string]any)
	config, _ := camera["config"].(map[string]any)
	if camera["type"] != "simple" || numericValue(config["zoom"], 0) != 0.6 || numericValue(config["pan"], -1) != 0 {
		t.Fatalf("unexpected keling camera conversion: %+v", submittedPayload["camera_control"])
	}
	data, _ := response.Result["data"].([]any)
	item, _ := data[0].(map[string]any)
	if response.Result["upstream_task_id"] != "keling-task-1" || item["url"] != "https://example.com/keling.mp4" || item["video_url"] != "https://example.com/keling.mp4" {
		t.Fatalf("unexpected keling response: %+v", response.Result)
	}
}

func TestKelingClient30TurboUsesModelEndpointAndTasksAPI(t *testing.T) {
	var submitPath string
	var pollPath string
	var pollQuery string
	var gotAuth string
	var submittedPayload map[string]any
	var submittedTaskPayload map[string]any
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		gotAuth = r.Header.Get("Authorization")
		switch r.Method + " " + r.URL.Path {
		case "POST /text-to-video/kling-3.0-turbo":
			submitPath = r.URL.Path
			if err := json.NewDecoder(r.Body).Decode(&submittedPayload); err != nil {
				t.Fatalf("decode keling 3.0 turbo submit: %v", err)
			}
			_ = json.NewEncoder(w).Encode(map[string]any{
				"code":       0,
				"request_id": "req-turbo-submit",
				"data": map[string]any{
					"id":     "turbo-task-1",
					"status": "submitted",
				},
			})
		case "GET /tasks":
			pollPath = r.URL.Path
			pollQuery = r.URL.RawQuery
			_ = json.NewEncoder(w).Encode(map[string]any{
				"code":       0,
				"request_id": "req-turbo-poll",
				"data": []any{
					map[string]any{
						"id":          "turbo-task-1",
						"status":      "succeeded",
						"create_time": 789,
						"outputs": []any{
							map[string]any{
								"type":          "video",
								"url":           "https://example.com/turbo.mp4",
								"watermark_url": "https://example.com/turbo-watermark.mp4",
								"duration":      "8",
							},
						},
					},
				},
			})
		default:
			t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
		}
	}))
	defer server.Close()

	response, err := (KelingClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:      "videos.generations",
		ModelType: "video_generate",
		Model:     "可灵3.0 Turbo",
		Body: map[string]any{
			"prompt":           "A cinematic city reveal",
			"duration":         8,
			"resolution":       "1080p",
			"aspect_ratio":     "9:16",
			"callback_url":     "https://example.com/callback",
			"external_task_id": "external-1",
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           server.URL + "/v1",
			Provider:          "keling",
			AuthType:          "APIKey",
			ModelName:         "可灵3.0 Turbo",
			ProviderModelName: "kling-3.0-turbo",
			Credentials:       map[string]any{"apiKey": "kling-api-key"},
			PlatformConfig: map[string]any{
				"kelingPollIntervalMs":     100,
				"kelingPollTimeoutSeconds": 1,
			},
		},
		OnRemoteTaskSubmitted: func(remoteTaskID string, payload map[string]any) error {
			if remoteTaskID != "turbo-task-1" {
				t.Fatalf("unexpected remote task id: %s", remoteTaskID)
			}
			submittedTaskPayload = payload
			return nil
		},
	})
	if err != nil {
		t.Fatalf("run keling 3.0 turbo video: %v", err)
	}
	if submitPath != "/text-to-video/kling-3.0-turbo" ||
		pollPath != "/tasks" ||
		pollQuery != "task_ids=turbo-task-1" ||
		gotAuth != "Bearer kling-api-key" {
		t.Fatalf("unexpected keling 3.0 turbo paths/auth submit=%s poll=%s?%s auth=%s", submitPath, pollPath, pollQuery, gotAuth)
	}
	if submittedTaskPayload["endpoint"] != "/text-to-video/kling-3.0-turbo" ||
		submittedTaskPayload["taskApi"] != "keling_tasks_v2" {
		t.Fatalf("unexpected submitted task payload: %+v", submittedTaskPayload)
	}
	settings, _ := submittedPayload["settings"].(map[string]any)
	options, _ := submittedPayload["options"].(map[string]any)
	if submittedPayload["prompt"] != "A cinematic city reveal" ||
		numericValue(settings["duration"], 0) != 8 ||
		settings["resolution"] != "1080p" ||
		settings["aspect_ratio"] != "9:16" ||
		options["callback_url"] != "https://example.com/callback" ||
		options["external_task_id"] != "external-1" {
		t.Fatalf("unexpected keling 3.0 turbo payload: %+v", submittedPayload)
	}
	data, _ := response.Result["data"].([]any)
	item, _ := data[0].(map[string]any)
	if response.Result["upstream_task_id"] != "turbo-task-1" ||
		item["url"] != "https://example.com/turbo.mp4" ||
		item["watermark_url"] != "https://example.com/turbo-watermark.mp4" {
		t.Fatalf("unexpected keling 3.0 turbo response: %+v", response.Result)
	}
}

func TestKelingClient30TurboRejectsLegacyCredentials(t *testing.T) {
	_, err := (KelingClient{}).Run(context.Background(), Request{
		Kind: "videos.generations",
		Body: map[string]any{"prompt": "A cinematic city reveal"},
		Candidate: store.RuntimeModelCandidate{
			Provider:          "keling",
			AuthType:          "AccessKey-SecretKey",
			ProviderModelName: "kling-3.0-turbo",
			Credentials:       map[string]any{"accessKey": "ak", "secretKey": "sk"},
		},
	})
	if err == nil || !strings.Contains(err.Error(), "new API key") {
		t.Fatalf("expected keling 3.0 turbo API key requirement, got %v", err)
	}
}

func TestKelingClient30TurboResumePollsWithoutSubmitting(t *testing.T) {
	var submitCalled bool
	var pollQuery string
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.Method + " " + r.URL.Path {
		case "POST /text-to-video/kling-3.0-turbo":
			submitCalled = true
			t.Fatalf("resume should not submit a new keling 3.0 turbo task")
		case "GET /tasks":
			pollQuery = r.URL.RawQuery
			_ = json.NewEncoder(w).Encode(map[string]any{
				"code":       0,
				"request_id": "req-turbo-resume",
				"data": []any{
					map[string]any{
						"id":     "turbo-existing",
						"status": "succeeded",
						"outputs": []any{
							map[string]any{"type": "video", "url": "https://example.com/resumed-turbo.mp4"},
						},
					},
				},
			})
		default:
			t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
		}
	}))
	defer server.Close()

	response, err := (KelingClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:         "videos.generations",
		ModelType:    "video_generate",
		RemoteTaskID: "turbo-existing",
		Body:         map[string]any{},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           server.URL + "/v1",
			Provider:          "keling",
			AuthType:          "APIKey",
			ProviderModelName: "kling-3.0-turbo",
			Credentials:       map[string]any{"apiKey": "kling-api-key"},
			PlatformConfig: map[string]any{
				"kelingPollIntervalMs":     100,
				"kelingPollTimeoutSeconds": 1,
			},
		},
	})
	if err != nil {
		t.Fatalf("resume keling 3.0 turbo video: %v", err)
	}
	if submitCalled || pollQuery != "task_ids=turbo-existing" {
		t.Fatalf("resume should only poll existing task, submit=%v query=%s", submitCalled, pollQuery)
	}
	data, _ := response.Result["data"].([]any)
	item, _ := data[0].(map[string]any)
	if item["url"] != "https://example.com/resumed-turbo.mp4" {
		t.Fatalf("unexpected resumed keling 3.0 turbo response: %+v", response.Result)
	}
}

func TestKeling30TurboPayloadBuildsFirstFrameAndMultiShotRequests(t *testing.T) {
	imagePayload, endpoint, err := keling30TurboPayload(Request{
		Body: map[string]any{
			"duration":   5,
			"resolution": "720p",
			"content": []any{
				map[string]any{"type": "text", "text": "The subject looks toward the camera"},
				map[string]any{
					"type":      "image_url",
					"role":      "first_frame",
					"image_url": map[string]any{"url": "https://example.com/first.png"},
				},
			},
		},
	})
	if err != nil {
		t.Fatalf("build keling 3.0 turbo image payload: %v", err)
	}
	if endpoint != "/image-to-video/kling-3.0-turbo" {
		t.Fatalf("unexpected image endpoint: %s", endpoint)
	}
	if _, ok := mapFromAny(imagePayload["settings"])["aspect_ratio"]; ok {
		t.Fatalf("image-to-video settings should not contain aspect_ratio: %+v", imagePayload)
	}
	contents, _ := imagePayload["contents"].([]any)
	frame := mapFromAny(contents[1])
	if frame["type"] != "first_frame" || frame["url"] != "https://example.com/first.png" {
		t.Fatalf("unexpected image contents: %+v", imagePayload["contents"])
	}

	shotPayload, _, err := keling30TurboPayload(Request{
		Body: map[string]any{
			"resolution": "720p",
			"content": []any{
				map[string]any{"type": "text", "role": "shot_prompt", "shot_index": 1, "duration": 4, "text": "A car enters the tunnel"},
				map[string]any{"type": "text", "role": "shot_prompt", "shot_index": 2, "duration": 3, "text": "The headlights fill the frame"},
			},
		},
	})
	if err != nil {
		t.Fatalf("build keling 3.0 turbo shot payload: %v", err)
	}
	if shotPayload["prompt"] != "shot 1, 4s, A car enters the tunnel; shot 2, 3s, The headlights fill the frame;" ||
		numericValue(mapFromAny(shotPayload["settings"])["duration"], 0) != 7 {
		t.Fatalf("unexpected shot payload: %+v", shotPayload)
	}
}

func TestKeling30TurboPayloadRejectsLastFrame(t *testing.T) {
	_, _, err := keling30TurboPayload(Request{
		Body: map[string]any{
			"prompt":     "Move forward",
			"last_frame": "https://example.com/last.png",
		},
	})
	if err == nil || !strings.Contains(err.Error(), "last frame") {
		t.Fatalf("expected unsupported last frame error, got %v", err)
	}
}

func TestKelingOmniPayloadConvertsGatewayContent(t *testing.T) {
	payload, cleanupIDs, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{
		Kind:      "videos.generations",
		ModelType: "omni_video",
		Model:     "可灵V3多模态",
		Body: map[string]any{
			"model":        "可灵V3多模态",
			"duration":     8,
			"aspect_ratio": "9:16",
			"resolution":   "2160p",
			"audio":        true,
			"content": []any{
				map[string]any{"type": "text", "text": "Refine the base video"},
				map[string]any{"type": "image_url", "role": "first_frame", "image_url": map[string]any{"url": "https://example.com/first.png"}},
				map[string]any{"type": "image_url", "role": "last_frame", "image_url": map[string]any{"url": "https://example.com/last.png"}},
				map[string]any{
					"type": "video_url",
					"role": "video_base",
					"video_url": map[string]any{
						"url":                 "https://example.com/base.mp4",
						"keep_original_sound": "yes",
					},
				},
			},
		},
		Candidate: store.RuntimeModelCandidate{
			Provider:          "keling",
			ProviderModelName: "kling-v3-omni",
			Capabilities:      map[string]any{"omni_video": map[string]any{}},
		},
	}, "token")
	if err != nil {
		t.Fatalf("build keling omni payload: %v", err)
	}
	if len(cleanupIDs) != 0 {
		t.Fatalf("unexpected cleanup ids: %+v", cleanupIDs)
	}
	if payload["model_name"] != "kling-v3-omni" || payload["mode"] != "4k" || payload["prompt"] != "Refine the base video" {
		t.Fatalf("unexpected keling omni base fields: %+v", payload)
	}
	if _, ok := payload["sound"]; ok {
		t.Fatalf("omni payload with base video should not include sound: %+v", payload)
	}
	if _, ok := payload["duration"]; ok {
		t.Fatalf("base video edit should not include duration: %+v", payload)
	}
	if _, ok := payload["aspect_ratio"]; ok {
		t.Fatalf("base video edit should not include aspect_ratio: %+v", payload)
	}
	watermark, _ := payload["watermark_info"].(map[string]any)
	if watermark["enabled"] != false {
		t.Fatalf("keling watermark should be disabled by default: %+v", payload)
	}
	images, _ := payload["image_list"].([]any)
	if len(images) != 2 {
		t.Fatalf("unexpected keling image_list: %+v", payload["image_list"])
	}
	firstImage, _ := images[0].(map[string]any)
	lastImage, _ := images[1].(map[string]any)
	if firstImage["type"] != "first_frame" || lastImage["type"] != "end_frame" {
		t.Fatalf("frame roles should convert to keling omni types: %+v", images)
	}
	videos, _ := payload["video_list"].([]map[string]any)
	if len(videos) != 1 || videos[0]["refer_type"] != "base" || videos[0]["keep_original_sound"] != "yes" {
		t.Fatalf("video roles should convert to keling omni refer_type: %+v", payload["video_list"])
	}
}

func TestKelingOmniPayloadPreservesLegacyV1Options(t *testing.T) {
	payload, cleanupIDs, err := (KelingClient{}).kelingOmniPayload(context.Background(), Request{
		Kind:      "videos.generations",
		ModelType: "omni_video",
		Body: map[string]any{
			"multi_shot": true,
			"shot_type":  "customize",
			"multi_prompt": []any{
				map[string]any{"index": 1, "prompt": "镜头一", "duration": 7},
				map[string]any{"index": 2, "prompt": "镜头二", "duration": 8},
			},
			"resolution":       "1080p",
			"callback_url":     "https://example.com/callback",
			"external_task_id": "client-task-1",
			"watermark_info":   map[string]any{"enabled": true},
			"voice_list":       []any{map[string]any{"voice_id": "voice-1"}},
		},
		Candidate: store.RuntimeModelCandidate{
			Provider:          "keling",
			ProviderModelName: "kling-v3-omni",
			Capabilities:      map[string]any{"omni_video": map[string]any{}},
		},
	}, "token")
	if err != nil {
		t.Fatalf("build keling legacy V1 payload: %v", err)
	}
	if len(cleanupIDs) != 0 {
		t.Fatalf("unexpected cleanup ids: %+v", cleanupIDs)
	}
	if payload["multi_shot"] != true || payload["shot_type"] != "customize" || payload["duration"] != "15" {
		t.Fatalf("unexpected multi-shot payload: %+v", payload)
	}
	if payload["callback_url"] != "https://example.com/callback" || payload["external_task_id"] != "client-task-1" {
		t.Fatalf("legacy task options were not preserved: %+v", payload)
	}
	watermark := mapFromAny(payload["watermark_info"])
	if watermark["enabled"] != true || len(mapListFromAny(payload["voice_list"])) != 1 {
		t.Fatalf("watermark or voice options were not preserved: %+v", payload)
	}
}

func TestKelingClientVideoResumePollsWithoutSubmitting(t *testing.T) {
	var submitCalled bool
	var pollPath string
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		switch r.Method + " " + r.URL.Path {
		case "POST /general/custom-elements", "POST /videos/omni-video":
			submitCalled = true
			t.Fatalf("resume should not submit or upload temporary elements")
		case "GET /videos/omni-video/keling-existing":
			pollPath = r.URL.Path
			_ = json.NewEncoder(w).Encode(map[string]any{
				"code":       0,
				"request_id": "req-resume",
				"data": map[string]any{
					"task_id":     "keling-existing",
					"task_status": "succeed",
					"task_result": map[string]any{
						"videos": []any{map[string]any{"url": "https://example.com/resumed-keling.mp4"}},
					},
				},
			})
		default:
			t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
		}
	}))
	defer server.Close()

	response, err := (KelingClient{HTTPClient: server.Client()}).Run(context.Background(), Request{
		Kind:         "videos.generations",
		ModelType:    "omni_video",
		Model:        "可灵V3多模态",
		Body:         map[string]any{"prompt": "resume", "pollIntervalMs": 100, "pollTimeoutSeconds": 1},
		RemoteTaskID: "keling-existing",
		RemoteTaskPayload: map[string]any{
			"endpoint": "/videos/omni-video",
		},
		Candidate: store.RuntimeModelCandidate{
			BaseURL:           server.URL,
			Provider:          "keling",
			AuthType:          "AccessKey-SecretKey",
			ProviderModelName: "kling-v3-omni",
			Credentials:       map[string]any{"accessKey": "ak", "secretKey": "sk"},
		},
	})
	if err != nil {
		t.Fatalf("resume keling video: %v", err)
	}
	if submitCalled || pollPath != "/videos/omni-video/keling-existing" {
		t.Fatalf("resume should poll existing task only, submit=%v poll=%s", submitCalled, pollPath)
	}
	data, _ := response.Result["data"].([]any)
	item, _ := data[0].(map[string]any)
	if response.Result["upstream_task_id"] != "keling-existing" || item["url"] != "https://example.com/resumed-keling.mp4" {
		t.Fatalf("unexpected resumed keling response: %+v", response.Result)
	}
}

func TestKelingElementPayloadMapsTags(t *testing.T) {
	payload := kelingCreateElementPayload(map[string]any{
		"name":              "subject",
		"frontal_image_url": "https://example.com/front.png",
		"tags":              []any{"character", "unknown"},
		"refer_images": []any{
			map[string]any{"url": "https://example.com/side.png"},
		},
	})
	if payload["element_name"] != "subject" || payload["element_frontal_image"] != "https://example.com/front.png" {
		t.Fatalf("unexpected element payload base fields: %+v", payload)
	}
	tags, _ := payload["tag_list"].([]any)
	if len(tags) != 2 {
		t.Fatalf("unexpected tag list: %+v", payload["tag_list"])
	}
	firstTag, _ := tags[0].(map[string]any)
	secondTag, _ := tags[1].(map[string]any)
	if firstTag["tag_id"] != "o_102" || secondTag["tag_id"] != "o_108" {
		t.Fatalf("unexpected keling tag conversion: %+v", payload["tag_list"])
	}
	refs, _ := payload["element_refer_list"].([]any)
	if len(refs) != 1 {
		t.Fatalf("unexpected element references: %+v", payload["element_refer_list"])
	}
}

func extractText(result map[string]any) string {
	choices, _ := result["choices"].([]any)
	choice, _ := choices[0].(map[string]any)
	message, _ := choice["message"].(map[string]any)
	text, _ := message["content"].(string)
	return text
}
