diff --git a/conf/models/hunyuan.json b/conf/models/hunyuan.json new file mode 100644 index 0000000000..042563912d --- /dev/null +++ b/conf/models/hunyuan.json @@ -0,0 +1,33 @@ +{ + "name": "HunYuan", + "url": { + "default": "https://api.hunyuan.cloud.tencent.com/v1" + }, + "url_suffix": { + "chat": "chat/completions", + "models": "models" + }, + "class": "hunyuan", + "models": [ + { + "name": "hunyuan-pro", + "max_tokens": 32768, + "model_types": ["chat"] + }, + { + "name": "hunyuan-standard", + "max_tokens": 32768, + "model_types": ["chat"] + }, + { + "name": "hunyuan-standard-256K", + "max_tokens": 262144, + "model_types": ["chat"] + }, + { + "name": "hunyuan-lite", + "max_tokens": 262144, + "model_types": ["chat"] + } + ] +} diff --git a/internal/entity/models/factory.go b/internal/entity/models/factory.go index 14f45c18ed..66c024706d 100644 --- a/internal/entity/models/factory.go +++ b/internal/entity/models/factory.go @@ -99,6 +99,8 @@ func (f *ModelFactory) CreateModelDriver(providerName string, baseURL map[string return NewAstraflowModel(baseURL, urlSuffix), nil case "longcat": return NewLongCatModel(baseURL, urlSuffix), nil + case "hunyuan": + return NewHunyuanModel(baseURL, urlSuffix), nil case "tokenpony": return NewTokenPonyModel(baseURL, urlSuffix), nil case "novita": diff --git a/internal/entity/models/hunyuan.go b/internal/entity/models/hunyuan.go new file mode 100644 index 0000000000..f0ee83ba2d --- /dev/null +++ b/internal/entity/models/hunyuan.go @@ -0,0 +1,495 @@ +// +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package models + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// HunyuanModel implements ModelDriver for Tencent Hunyuan +// (https://cloud.tencent.com/document/product/1729/111007). Hunyuan +// exposes an OpenAI-API-compatible REST surface at +// https://api.hunyuan.cloud.tencent.com/v1, serving the hunyuan-pro, +// hunyuan-standard, hunyuan-standard-256K, hunyuan-lite, and +// hunyuan-vision families. +// +// Wire shape matches the OpenAI convention exactly: +// - POST /v1/chat/completions with {model, messages, stream, ...} +// - GET /v1/models for the catalog +// - Authorization: Bearer on every call +// - SSE response with `data:` lines and a [DONE] terminator +// +// Reasoning models surface chain-of-thought in `reasoning_content` +// (OpenAI o-series shape), so the same handling as LongCat / +// DeepSeek-R1 applies and there's no need for an inline ... +// extractor like Novita's. +type HunyuanModel struct { + BaseURL map[string]string + URLSuffix URLSuffix + httpClient *http.Client +} + +// NewHunyuanModel creates a new Hunyuan model instance. +// +// Same transport convention as the other Go drivers in this package: +// clone http.DefaultTransport to keep ProxyFromEnvironment, DialContext, +// HTTP/2, and TLS defaults, and only override the connection-pool +// fields. No client-level Timeout so SSE streams aren't capped mid-flight. +func NewHunyuanModel(baseURL map[string]string, urlSuffix URLSuffix) *HunyuanModel { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.MaxIdleConns = 100 + transport.MaxIdleConnsPerHost = 10 + transport.IdleConnTimeout = 90 * time.Second + transport.DisableCompression = false + transport.ResponseHeaderTimeout = 60 * time.Second + + return &HunyuanModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, + } +} + +func (a *HunyuanModel) NewInstance(baseURL map[string]string) ModelDriver { + return NewHunyuanModel(baseURL, a.URLSuffix) +} + +func (a *HunyuanModel) Name() string { + return "hunyuan" +} + +func (a *HunyuanModel) baseURLForRegion(region string) (string, error) { + base, ok := a.BaseURL[region] + if !ok || base == "" { + return "", fmt.Errorf("hunyuan: no base URL configured for region %q", region) + } + return strings.TrimSuffix(base, "/"), nil +} + +// ChatWithMessages sends a non-streaming chat request and returns the +// full response. Forwards documented OpenAI-shaped parameters when the +// caller supplies them; reasoning_content is surfaced separately so the +// visible Answer is never polluted by chain-of-thought. +func (a *HunyuanModel) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { + if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { + return nil, fmt.Errorf("api key is required") + } + if len(messages) == 0 { + return nil, fmt.Errorf("messages is empty") + } + + region := "default" + if apiConfig.Region != nil && *apiConfig.Region != "" { + region = *apiConfig.Region + } + + baseURL, err := a.baseURLForRegion(region) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", baseURL, a.URLSuffix.Chat) + + apiMessages := make([]map[string]interface{}, len(messages)) + for i, msg := range messages { + apiMessages[i] = map[string]interface{}{ + "role": msg.Role, + "content": msg.Content, + } + } + + reqBody := map[string]interface{}{ + "model": modelName, + "messages": apiMessages, + "stream": false, + } + + if chatModelConfig != nil { + if chatModelConfig.MaxTokens != nil { + reqBody["max_tokens"] = *chatModelConfig.MaxTokens + } + if chatModelConfig.Temperature != nil { + reqBody["temperature"] = *chatModelConfig.Temperature + } + if chatModelConfig.TopP != nil { + reqBody["top_p"] = *chatModelConfig.TopP + } + if chatModelConfig.Stop != nil { + reqBody["stop"] = *chatModelConfig.Stop + } + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) + + resp, err := a.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var result map[string]interface{} + if err = json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + choices, ok := result["choices"].([]interface{}) + if !ok || len(choices) == 0 { + return nil, fmt.Errorf("no choices in response") + } + + firstChoice, ok := choices[0].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid choice format") + } + + messageMap, ok := firstChoice["message"].(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("invalid message format") + } + + content, ok := messageMap["content"].(string) + if !ok { + return nil, fmt.Errorf("invalid content format") + } + + // Reasoning models (deepseek-r1 / kimi / glm-thinking) put + // chain-of-thought in a separate `reasoning_content` field with + // `content` already cleaned. Absent or non-string means no reasoning + // was emitted; leave it empty rather than synthesizing one. + reasonContent := "" + if r, ok := messageMap["reasoning_content"].(string); ok { + reasonContent = r + } + + return &ChatResponse{ + Answer: &content, + ReasonContent: &reasonContent, + }, nil +} + +// ChatStreamlyWithSender opens the SSE chat-completions endpoint and +// forwards each delta through the supplied sender. Reasoning chunks go +// to the sender's second argument, content chunks to the first; the +// stream is terminated by either `[DONE]` or a delta with finish_reason. +func (a *HunyuanModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if sender == nil { + return fmt.Errorf("sender is required") + } + if len(messages) == 0 { + return fmt.Errorf("messages is empty") + } + if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { + return fmt.Errorf("api key is required") + } + + region := "default" + if apiConfig.Region != nil && *apiConfig.Region != "" { + region = *apiConfig.Region + } + + baseURL, err := a.baseURLForRegion(region) + if err != nil { + return err + } + url := fmt.Sprintf("%s/%s", baseURL, a.URLSuffix.Chat) + + apiMessages := make([]map[string]interface{}, len(messages)) + for i, msg := range messages { + apiMessages[i] = map[string]interface{}{ + "role": msg.Role, + "content": msg.Content, + } + } + + reqBody := map[string]interface{}{ + "model": modelName, + "messages": apiMessages, + "stream": true, + } + + if chatModelConfig != nil { + // Guard against the caller asking for stream=false on a code path + // that only knows how to read SSE. Without this, a non-SSE JSON + // body would parse as zero chunks and look like a silent timeout. + if chatModelConfig.Stream != nil && !*chatModelConfig.Stream { + return fmt.Errorf("stream must be true in ChatStreamlyWithSender") + } + if chatModelConfig.MaxTokens != nil { + reqBody["max_tokens"] = *chatModelConfig.MaxTokens + } + if chatModelConfig.Temperature != nil { + reqBody["temperature"] = *chatModelConfig.Temperature + } + if chatModelConfig.TopP != nil { + reqBody["top_p"] = *chatModelConfig.TopP + } + if chatModelConfig.Stop != nil { + reqBody["stop"] = *chatModelConfig.Stop + } + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + // SSE is long-lived; rely on the transport's ResponseHeaderTimeout + // to cap connection-establishment instead of a hard deadline. + req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) + + resp, err := a.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + sawTerminal := false + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data:") { + continue + } + data := strings.TrimSpace(line[5:]) + if data == "[DONE]" { + sawTerminal = true + break + } + + var event map[string]interface{} + if err = json.Unmarshal([]byte(data), &event); err != nil { + // A malformed frame usually means a truncated event or an + // upstream incident. Surface it instead of silently producing + // partial output. + return fmt.Errorf("hunyuan: invalid SSE event: %w", err) + } + + // Hunyuan can emit a terminal `{"error": ...}` frame when the + // upstream model rejects mid-stream (rate limit, content policy). + // Surface it verbatim instead of falling through to "no choices". + if apiErr, ok := event["error"]; ok { + return fmt.Errorf("hunyuan: upstream stream error: %v", apiErr) + } + + choices, ok := event["choices"].([]interface{}) + if !ok || len(choices) == 0 { + continue + } + firstChoice, ok := choices[0].(map[string]interface{}) + if !ok { + continue + } + // Reasoning first, content second — matches the wire ordering + // for reasoning models and lets UIs render the chain-of-thought + // before the visible token. A terminal frame may carry + // finish_reason without a delta, so don't skip when delta is absent. + if delta, ok := firstChoice["delta"].(map[string]interface{}); ok { + if r, ok := delta["reasoning_content"].(string); ok && r != "" { + rr := r + if err := sender(nil, &rr); err != nil { + return err + } + } + if c, ok := delta["content"].(string); ok && c != "" { + cc := c + if err := sender(&cc, nil); err != nil { + return err + } + } + } + if finish, ok := firstChoice["finish_reason"].(string); ok && finish != "" { + sawTerminal = true + break + } + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("failed to scan response body: %w", err) + } + if !sawTerminal { + return fmt.Errorf("hunyuan: stream ended before [DONE] or finish_reason") + } + + endOfStream := "[DONE]" + if err := sender(&endOfStream, nil); err != nil { + return err + } + return nil +} + +// ListModels returns the model ids visible to the API key by calling +// /v1/models. Used by Add-Provider's connection check and by the UI's +// model picker. +func (a *HunyuanModel) ListModels(apiConfig *APIConfig) ([]string, error) { + if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { + return nil, fmt.Errorf("api key is required") + } + + region := "default" + if apiConfig.Region != nil && *apiConfig.Region != "" { + region = *apiConfig.Region + } + + baseURL, err := a.baseURLForRegion(region) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", baseURL, a.URLSuffix.Models) + + ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) + + resp, err := a.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + var result map[string]interface{} + if err = json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + data, ok := result["data"].([]interface{}) + if !ok { + return nil, fmt.Errorf("invalid models list format") + } + + models := make([]string, 0, len(data)) + for _, m := range data { + modelMap, ok := m.(map[string]interface{}) + if !ok { + continue + } + id, ok := modelMap["id"].(string) + if !ok { + continue + } + models = append(models, id) + } + return models, nil +} + +// CheckConnection verifies the API key by calling ListModels. The /v1/models +// endpoint is the documented lightweight way to validate credentials on +// OpenAI-compatible gateways without burning chat-completion quota. +func (a *HunyuanModel) CheckConnection(apiConfig *APIConfig) error { + _, err := a.ListModels(apiConfig) + return err +} + +// Embed is not implemented. The Hunyuan factory tag is +// "LLM,IMAGE2TEXT" — no embedding surface is registered, and Image2Text +// is reserved for a follow-up issue. +func (a *HunyuanModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { + return nil, fmt.Errorf("%s, no such method", a.Name()) +} + +func (a *HunyuanModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { + return nil, fmt.Errorf("%s, no such method", a.Name()) +} + +func (a *HunyuanModel) Balance(apiConfig *APIConfig) (map[string]interface{}, error) { + return nil, fmt.Errorf("%s, no such method", a.Name()) +} + +func (a *HunyuanModel) TranscribeAudio(modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig) (*ASRResponse, error) { + return nil, fmt.Errorf("%s, no such method", a.Name()) +} + +func (a *HunyuanModel) TranscribeAudioWithSender(modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig, sender func(*string, *string) error) error { + return fmt.Errorf("%s, no such method", a.Name()) +} + +func (a *HunyuanModel) AudioSpeech(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig) (*TTSResponse, error) { + return nil, fmt.Errorf("%s, no such method", a.Name()) +} + +func (a *HunyuanModel) AudioSpeechWithSender(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig, sender func(*string, *string) error) error { + return fmt.Errorf("%s, no such method", a.Name()) +} + +func (a *HunyuanModel) OCRFile(modelName *string, content []byte, url *string, apiConfig *APIConfig, ocrConfig *OCRConfig) (*OCRFileResponse, error) { + return nil, fmt.Errorf("%s, no such method", a.Name()) +} + +func (a *HunyuanModel) ParseFile(modelName *string, content []byte, url *string, apiConfig *APIConfig, parseFileConfig *ParseFileConfig) (*ParseFileResponse, error) { + return nil, fmt.Errorf("%s, no such method", a.Name()) +} + +func (a *HunyuanModel) ListTasks(apiConfig *APIConfig) ([]ListTaskStatus, error) { + return nil, fmt.Errorf("%s, no such method", a.Name()) +} + +func (a *HunyuanModel) ShowTask(taskID string, apiConfig *APIConfig) (*TaskResponse, error) { + return nil, fmt.Errorf("%s, no such method", a.Name()) +} diff --git a/internal/entity/models/hunyuan_test.go b/internal/entity/models/hunyuan_test.go new file mode 100644 index 0000000000..99b738134b --- /dev/null +++ b/internal/entity/models/hunyuan_test.go @@ -0,0 +1,451 @@ +package models + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func newHunyuanServer(t *testing.T, expectedMethod, expectedPath string, handler func(t *testing.T, body map[string]interface{}, w http.ResponseWriter)) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != expectedMethod { + t.Errorf("expected method=%s, got %s", expectedMethod, r.Method) + return + } + if r.URL.Path != expectedPath { + t.Errorf("expected path=%s, got %s", expectedPath, r.URL.Path) + return + } + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Errorf("expected Authorization=Bearer test-key, got %q", got) + return + } + if r.Method == http.MethodPost { + if got := r.Header.Get("Content-Type"); !strings.HasPrefix(got, "application/json") { + t.Errorf("expected Content-Type to start with application/json, got %q", got) + return + } + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read body: %v", err) + http.Error(w, "read error", http.StatusBadRequest) + return + } + var body map[string]interface{} + if err := json.Unmarshal(raw, &body); err != nil { + t.Errorf("unmarshal: %v\nraw=%s", err, string(raw)) + http.Error(w, "unmarshal error", http.StatusBadRequest) + return + } + handler(t, body, w) + return + } + handler(t, nil, w) + })) +} + +func newHunyuanSSEServer(t *testing.T, expectedPath, ssePayload string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + return + } + if r.URL.Path != expectedPath { + t.Errorf("expected path=%s, got %s", expectedPath, r.URL.Path) + return + } + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Errorf("expected Authorization=Bearer test-key, got %q", got) + return + } + if got := r.Header.Get("Content-Type"); !strings.HasPrefix(got, "application/json") { + t.Errorf("expected Content-Type to start with application/json, got %q", got) + return + } + w.Header().Set("Content-Type", "text/event-stream") + _, _ = io.WriteString(w, ssePayload) + })) +} + +func newHunyuanForTest(baseURL string) *HunyuanModel { + return NewHunyuanModel( + map[string]string{"default": baseURL}, + URLSuffix{Chat: "chat/completions", Models: "models"}, + ) +} + +func TestHunyuanName(t *testing.T) { + if got := newHunyuanForTest("http://unused").Name(); got != "hunyuan" { + t.Errorf("Name()=%q, want %q", got, "hunyuan") + } +} + +func TestHunyuanFactory(t *testing.T) { + driver, err := NewModelFactory().CreateModelDriver("Hunyuan", map[string]string{"default": "http://unused"}, URLSuffix{}) + if err != nil { + t.Fatalf("CreateModelDriver: %v", err) + } + if _, ok := driver.(*HunyuanModel); !ok { + t.Fatalf("driver type=%T, want *HunyuanModel", driver) + } +} + +func TestHunyuanChatHappyPath(t *testing.T) { + srv := newHunyuanServer(t, http.MethodPost, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { + if body["model"] != "hunyuan-pro" { + t.Errorf("model=%v", body["model"]) + } + if body["stream"] != false { + t.Errorf("stream=%v, want false", body["stream"]) + } + if body["max_tokens"] != float64(64) { + t.Errorf("max_tokens=%v, want 64", body["max_tokens"]) + } + if body["temperature"] != 0.3 { + t.Errorf("temperature=%v, want 0.3", body["temperature"]) + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "choices": []map[string]interface{}{{ + "message": map[string]interface{}{ + "content": "pong", + "reasoning_content": "thought about it", + }, + }}, + }) + }) + defer srv.Close() + + apiKey := "test-key" + mt := 64 + temp := 0.3 + resp, err := newHunyuanForTest(srv.URL).ChatWithMessages( + "hunyuan-pro", + []Message{{Role: "user", Content: "ping"}}, + &APIConfig{ApiKey: &apiKey}, + &ChatConfig{MaxTokens: &mt, Temperature: &temp}, + ) + if err != nil { + t.Fatalf("ChatWithMessages: %v", err) + } + if resp.Answer == nil || *resp.Answer != "pong" { + t.Errorf("Answer=%v, want pong", resp.Answer) + } + if resp.ReasonContent == nil || *resp.ReasonContent != "thought about it" { + t.Errorf("ReasonContent=%v, want 'thought about it'", resp.ReasonContent) + } +} + +func TestHunyuanChatNoReasoning(t *testing.T) { + srv := newHunyuanServer(t, http.MethodPost, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "choices": []map[string]interface{}{{ + "message": map[string]interface{}{"content": "hi"}, + }}, + }) + }) + defer srv.Close() + + apiKey := "test-key" + resp, err := newHunyuanForTest(srv.URL).ChatWithMessages( + "hunyuan-lite", + []Message{{Role: "user", Content: "hi"}}, + &APIConfig{ApiKey: &apiKey}, nil) + if err != nil { + t.Fatalf("Chat: %v", err) + } + if resp.Answer == nil || *resp.Answer != "hi" { + t.Errorf("Answer=%v, want 'hi'", resp.Answer) + } + if resp.ReasonContent == nil || *resp.ReasonContent != "" { + t.Errorf("ReasonContent=%v, want empty", resp.ReasonContent) + } +} + +func TestHunyuanChatRequiresAPIKey(t *testing.T) { + _, err := newHunyuanForTest("http://unused").ChatWithMessages( + "hunyuan-pro", + []Message{{Role: "user", Content: "x"}}, + &APIConfig{}, nil) + if err == nil || !strings.Contains(err.Error(), "api key is required") { + t.Errorf("expected api-key error, got %v", err) + } +} + +func TestHunyuanChatRequiresMessages(t *testing.T) { + apiKey := "test-key" + _, err := newHunyuanForTest("http://unused").ChatWithMessages( + "hunyuan-pro", nil, &APIConfig{ApiKey: &apiKey}, nil) + if err == nil || !strings.Contains(err.Error(), "messages is empty") { + t.Errorf("expected messages-empty error, got %v", err) + } +} + +func TestHunyuanChatPropagatesHTTPError(t *testing.T) { + srv := newHunyuanServer(t, http.MethodPost, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"bad key"}`)) + }) + defer srv.Close() + + apiKey := "test-key" + _, err := newHunyuanForTest(srv.URL).ChatWithMessages( + "hunyuan-pro", + []Message{{Role: "user", Content: "x"}}, + &APIConfig{ApiKey: &apiKey}, nil) + if err == nil || !strings.Contains(err.Error(), "401") { + t.Errorf("expected 401 propagated, got %v", err) + } +} + +func TestHunyuanStreamHappyPath(t *testing.T) { + srv := newHunyuanSSEServer(t, "/chat/completions", + `data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+ + `data: {"choices":[{"index":0,"delta":{"content":"Hello"}}]}`+"\n"+ + `data: {"choices":[{"index":0,"delta":{"content":" world"},"finish_reason":"stop"}]}`+"\n"+ + `data: [DONE]`+"\n", + ) + defer srv.Close() + + apiKey := "test-key" + var chunks []string + var sawDone bool + err := newHunyuanForTest(srv.URL).ChatStreamlyWithSender( + "hunyuan-pro", + []Message{{Role: "user", Content: "hi"}}, + &APIConfig{ApiKey: &apiKey}, nil, + func(c *string, _ *string) error { + if c == nil { + return nil + } + if *c == "[DONE]" { + sawDone = true + return nil + } + chunks = append(chunks, *c) + return nil + }) + if err != nil { + t.Fatalf("stream: %v", err) + } + if strings.Join(chunks, "") != "Hello world" { + t.Errorf("content=%v", chunks) + } + if !sawDone { + t.Error("expected [DONE] sentinel") + } +} + +func TestHunyuanStreamSplitsReasoning(t *testing.T) { + srv := newHunyuanSSEServer(t, "/chat/completions", + `data: {"choices":[{"index":0,"delta":{"role":"assistant"}}]}`+"\n"+ + `data: {"choices":[{"index":0,"delta":{"reasoning_content":"step 1. "}}]}`+"\n"+ + `data: {"choices":[{"index":0,"delta":{"reasoning_content":"step 2."}}]}`+"\n"+ + `data: {"choices":[{"index":0,"delta":{"content":"final"},"finish_reason":"stop"}]}`+"\n"+ + `data: [DONE]`+"\n", + ) + defer srv.Close() + + apiKey := "test-key" + var content, reasoning []string + err := newHunyuanForTest(srv.URL).ChatStreamlyWithSender( + "hunyuan-standard-256K", + []Message{{Role: "user", Content: "x"}}, + &APIConfig{ApiKey: &apiKey}, nil, + func(c *string, r *string) error { + if c != nil && r != nil { + t.Errorf("sender called with both args non-nil") + } + if r != nil && *r != "" { + reasoning = append(reasoning, *r) + } + if c != nil && *c != "" && *c != "[DONE]" { + content = append(content, *c) + } + return nil + }) + if err != nil { + t.Fatalf("stream: %v", err) + } + if got := strings.Join(reasoning, ""); got != "step 1. step 2." { + t.Errorf("reasoning=%q", got) + } + if got := strings.Join(content, ""); got != "final" { + t.Errorf("content=%q", got) + } +} + +func TestHunyuanStreamRejectsExplicitFalse(t *testing.T) { + apiKey := "test-key" + stream := false + err := newHunyuanForTest("http://unused").ChatStreamlyWithSender( + "hunyuan-pro", + []Message{{Role: "user", Content: "x"}}, + &APIConfig{ApiKey: &apiKey}, + &ChatConfig{Stream: &stream}, + func(*string, *string) error { return nil }) + if err == nil || !strings.Contains(err.Error(), "stream must be true") { + t.Errorf("expected stream-true guard, got %v", err) + } +} + +func TestHunyuanStreamRequiresSender(t *testing.T) { + apiKey := "test-key" + err := newHunyuanForTest("http://unused").ChatStreamlyWithSender( + "hunyuan-pro", + []Message{{Role: "user", Content: "x"}}, + &APIConfig{ApiKey: &apiKey}, nil, nil) + if err == nil || !strings.Contains(err.Error(), "sender is required") { + t.Errorf("expected sender-required error, got %v", err) + } +} + +func TestHunyuanStreamFailsWithoutTerminal(t *testing.T) { + srv := newHunyuanSSEServer(t, "/chat/completions", + `data: {"choices":[{"delta":{"content":"half"}}]}`+"\n", + ) + defer srv.Close() + + apiKey := "test-key" + err := newHunyuanForTest(srv.URL).ChatStreamlyWithSender( + "hunyuan-pro", + []Message{{Role: "user", Content: "x"}}, + &APIConfig{ApiKey: &apiKey}, nil, + func(*string, *string) error { return nil }) + if err == nil || !strings.Contains(err.Error(), "stream ended before") { + t.Errorf("expected truncation error, got %v", err) + } +} + +func TestHunyuanStreamRejectsMalformedFrame(t *testing.T) { + srv := newHunyuanSSEServer(t, "/chat/completions", + `data: {"choices":[{"delta":{"content":"ok"}}]}`+"\n"+ + `data: {oops not json}`+"\n", + ) + defer srv.Close() + + apiKey := "test-key" + err := newHunyuanForTest(srv.URL).ChatStreamlyWithSender( + "hunyuan-pro", + []Message{{Role: "user", Content: "x"}}, + &APIConfig{ApiKey: &apiKey}, nil, + func(*string, *string) error { return nil }) + if err == nil || !strings.Contains(err.Error(), "invalid SSE event") { + t.Errorf("expected invalid-SSE error, got %v", err) + } +} + +func TestHunyuanStreamSurfacesUpstreamError(t *testing.T) { + srv := newHunyuanSSEServer(t, "/chat/completions", + `data: {"choices":[{"delta":{"content":"partial "}}]}`+"\n"+ + `data: {"error":{"message":"rate limit","type":"rate_limit_error"}}`+"\n", + ) + defer srv.Close() + + apiKey := "test-key" + err := newHunyuanForTest(srv.URL).ChatStreamlyWithSender( + "hunyuan-pro", + []Message{{Role: "user", Content: "x"}}, + &APIConfig{ApiKey: &apiKey}, nil, + func(*string, *string) error { return nil }) + if err == nil || !strings.Contains(err.Error(), "upstream stream error") { + t.Errorf("expected upstream-error surfacing, got %v", err) + } + if err != nil && !strings.Contains(err.Error(), "rate limit") { + t.Errorf("expected upstream message included, got %v", err) + } +} + +func TestHunyuanListModelsHappyPath(t *testing.T) { + srv := newHunyuanServer(t, http.MethodGet, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "data": []map[string]interface{}{ + {"id": "hunyuan-pro"}, + {"id": "hunyuan-standard"}, + {"id": "hunyuan-standard-256K"}, + }, + }) + }) + defer srv.Close() + + apiKey := "test-key" + models, err := newHunyuanForTest(srv.URL).ListModels(&APIConfig{ApiKey: &apiKey}) + if err != nil { + t.Fatalf("ListModels: %v", err) + } + want := []string{"hunyuan-pro", "hunyuan-standard", "hunyuan-standard-256K"} + if strings.Join(models, ",") != strings.Join(want, ",") { + t.Errorf("models=%v, want %v", models, want) + } +} + +func TestHunyuanListModelsRequiresAPIKey(t *testing.T) { + _, err := newHunyuanForTest("http://unused").ListModels(&APIConfig{}) + if err == nil || !strings.Contains(err.Error(), "api key is required") { + t.Errorf("expected api-key error, got %v", err) + } +} + +func TestHunyuanCheckConnectionDelegatesToListModels(t *testing.T) { + srv := newHunyuanServer(t, http.MethodGet, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "data": []map[string]interface{}{{"id": "hunyuan-pro"}}, + }) + }) + defer srv.Close() + + apiKey := "test-key" + if err := newHunyuanForTest(srv.URL).CheckConnection(&APIConfig{ApiKey: &apiKey}); err != nil { + t.Errorf("CheckConnection: %v", err) + } +} + +func TestHunyuanCheckConnectionPropagatesError(t *testing.T) { + srv := newHunyuanServer(t, http.MethodGet, "/models", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"bad key"}`)) + }) + defer srv.Close() + + apiKey := "test-key" + err := newHunyuanForTest(srv.URL).CheckConnection(&APIConfig{ApiKey: &apiKey}) + if err == nil || !strings.Contains(err.Error(), "401") { + t.Errorf("expected 401 propagated, got %v", err) + } +} + +func TestHunyuanBaseURLForRegionUnknown(t *testing.T) { + m := newHunyuanForTest("http://unused") + apiKey := "test-key" + region := "missing" + _, err := m.ListModels(&APIConfig{ApiKey: &apiKey, Region: ®ion}) + if err == nil || !strings.Contains(err.Error(), "no base URL configured") { + t.Errorf("expected base-URL error, got %v", err) + } +} + +func TestHunyuanEmbedReturnsNoSuchMethod(t *testing.T) { + model := "x" + _, err := newHunyuanForTest("http://unused").Embed(&model, []string{"a"}, &APIConfig{}, nil) + if err == nil || !strings.Contains(err.Error(), "no such method") { + t.Errorf("Embed: want 'no such method', got %v", err) + } +} + +func TestHunyuanAudioOCRReturnNoSuchMethod(t *testing.T) { + m := newHunyuanForTest("http://unused") + model := "x" + if _, err := m.TranscribeAudio(&model, &model, &APIConfig{}, nil); err == nil || !strings.Contains(err.Error(), "no such method") { + t.Errorf("TranscribeAudio: %v", err) + } + if _, err := m.AudioSpeech(&model, &model, &APIConfig{}, nil); err == nil || !strings.Contains(err.Error(), "no such method") { + t.Errorf("AudioSpeech: %v", err) + } + if _, err := m.OCRFile(&model, nil, &model, &APIConfig{}, nil); err == nil || !strings.Contains(err.Error(), "no such method") { + t.Errorf("OCRFile: %v", err) + } +}