easyai-ai-gateway/apps/api/internal/clients/clients_test.go

1387 lines
54 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 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
}