easyai-ai-gateway/apps/api/internal/clients/clients_test.go
wangbo 8ad5b06c18 feat(api): 添加多媒体内容支持并优化钱包计费系统
- 在 API 接口定义中为 video_url 和 audio_url 类型添加 mime_type 字段
- 实现 Google Gemini 客户端对视频和音频内容的支持,包括媒体类型检测和数据传输
- 添加 Gemini 客户端测试用例验证多媒体内容转换功能
- 重构 Playground 页面的媒体上传逻辑以支持 MIME 类型传递
- 实现钱包计费预留机制,确保任务执行前余额充足
- 添加钱包冻结余额管理,防止并发操作导致的超扣问题
- 实现计费预留释放逻辑,处理任务失败或取消情况下的资金返还
- 优化数据库事务处理,确保计费操作的原子性和一致性
- 添加数据库集成测试验证迁移脚本执行流程
- 统一 Google Gemini 相关模型提供商标识符映射
2026-05-22 23:46:08 +08:00

1451 lines
56 KiB
Go

package clients
import (
"context"
"encoding/json"
"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 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)
}
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 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},
})
}))
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.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 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 should be converted away: %+v", assistant)
}
toolCalls, _ := assistant["tool_calls"].([]any)
toolCall, _ := toolCalls[0].(map[string]any)
function, _ := toolCall["function"].(map[string]any)
if function["name"] != "lookup" || function["arguments"] != `{"q":"weather"}` {
t.Fatalf("unexpected normalized tool 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", "function_call"} {
if _, ok := message[key]; ok {
t.Fatalf("%s should be converted away: %+v", key, message)
}
}
toolCalls, _ := message["tool_calls"].([]any)
if len(toolCalls) != 3 {
t.Fatalf("expected 3 normalized tool calls, 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, "call_1", "legacy_lookup", "{\"city\":\"NYC\"}")
assertToolCall(2, "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}}\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)
}
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 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 _, event := range captured {
choices, _ := event.Event["choices"].([]any)
choice, _ := choices[0].(map[string]any)
delta, _ := choice["delta"].(map[string]any)
if _, ok := delta["function_call"]; ok {
t.Fatalf("function_call should be converted away: %+v", event.Event)
}
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)
}
}
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 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"},
},
}},
},
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"])
}
}
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) {
got := geminiURL("https://generativelanguage.googleapis.com/v1beta", "gemini-2.5-flash", "test-key")
want := "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=test-key"
if got != 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 string
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, _ = body["sequential_image_generation"].(string)
_ = 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" || gotSequential != "auto" {
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 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.Usage.TotalTokens != 9 {
t.Fatalf("unexpected response: %+v usage=%+v", response.Result, response.Usage)
}
}
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,
"task_id": "local-task-id",
"runMode": "simulation",
"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,
}
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)
}
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 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"] != "cgt-existing" || 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 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 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
}