diff --git a/internal/entity/models/302ai.go b/internal/entity/models/302ai.go index da1973a2fc..3b7489ea27 100644 --- a/internal/entity/models/302ai.go +++ b/internal/entity/models/302ai.go @@ -35,54 +35,35 @@ import ( ) type AI302Model struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } func NewAI302Model(baseURL map[string]string, urlSuffix URLSuffix) *AI302Model { return &AI302Model{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, - MaxIdleConns: 10, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: time.Second * 90, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + MaxIdleConns: 10, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: time.Second * 90, + DisableCompression: false, + }, }, }, } } func (a *AI302Model) NewInstance(baseURL map[string]string) ModelDriver { - return &AI302Model{ - BaseURL: baseURL, - URLSuffix: a.URLSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, - MaxIdleConns: 10, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: time.Second * 90, - DisableCompression: false, - }, - }, - } + return NewAI302Model(baseURL, a.baseModel.URLSuffix) } func (a *AI302Model) Name() string { return "302ai" } -func validateAI302APIKey(apiConfig *APIConfig) (string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || strings.TrimSpace(*apiConfig.ApiKey) == "" { - return "", fmt.Errorf("api key is required") - } - return strings.TrimSpace(*apiConfig.ApiKey), nil -} - func validateAI302ModelName(modelName *string) (string, error) { if modelName == nil || strings.TrimSpace(*modelName) == "" { return "", fmt.Errorf("model name is required") @@ -100,10 +81,10 @@ func validateAI302DocumentURL(rawURL string) (string, error) { } func (a *AI302Model) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { - apiKey, err := validateAI302APIKey(apiConfig) - if err != nil { + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { return nil, err } + apiKey := strings.TrimSpace(*apiConfig.ApiKey) if strings.TrimSpace(modelName) == "" { return nil, fmt.Errorf("model name is required") } @@ -111,12 +92,11 @@ func (a *AI302Model) ChatWithMessages(modelName string, messages []Message, apiC return nil, fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := a.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", a.BaseURL[region], a.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, a.baseModel.URLSuffix.Chat) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -186,9 +166,8 @@ func (a *AI302Model) ChatWithMessages(modelName string, messages []Message, apiC req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) - req.Header.Set("Accept", "application/json") - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -250,10 +229,10 @@ func (a *AI302Model) ChatWithMessages(modelName string, messages []Message, apiC } func (a *AI302Model) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, modelConfig *ChatConfig, sender func(*string, *string) error) error { - apiKey, err := validateAI302APIKey(apiConfig) - if err != nil { + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { return err } + apiKey := strings.TrimSpace(*apiConfig.ApiKey) if strings.TrimSpace(modelName) == "" { return fmt.Errorf("model name is required") } @@ -264,12 +243,11 @@ func (a *AI302Model) ChatStreamlyWithSender(modelName string, messages []Message return fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := a.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(a.BaseURL[region], "/"), a.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(resolvedBaseURL, "/"), a.baseModel.URLSuffix.Chat) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -345,7 +323,7 @@ func (a *AI302Model) ChatStreamlyWithSender(modelName string, messages []Message req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) req.Header.Set("Accept", "text/event-stream") - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -426,6 +404,9 @@ func (a *AI302Model) ChatStreamlyWithSender(modelName string, messages []Message } func (a *AI302Model) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } if len(texts) == 0 { return []EmbeddingData{}, nil } @@ -433,17 +414,13 @@ func (a *AI302Model) Embed(modelName *string, texts []string, apiConfig *APIConf if err != nil { return nil, err } - apiKey, err := validateAI302APIKey(apiConfig) + apiKey := strings.TrimSpace(*apiConfig.ApiKey) + + resolvedBaseURL, err := a.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - url := fmt.Sprintf("%s/%s", a.BaseURL[region], a.URLSuffix.Embedding) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, a.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": model, @@ -466,7 +443,7 @@ func (a *AI302Model) Embed(modelName *string, texts []string, apiConfig *APIConf req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -508,6 +485,10 @@ func (a *AI302Model) Embed(modelName *string, texts []string, apiConfig *APIConf } func (a *AI302Model) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(documents) == 0 { return &RerankResponse{}, nil } @@ -518,17 +499,13 @@ func (a *AI302Model) Rerank(modelName *string, query string, documents []string, if err != nil { return nil, err } - apiKey, err := validateAI302APIKey(apiConfig) + apiKey := strings.TrimSpace(*apiConfig.ApiKey) + + resolvedBaseURL, err := a.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - url := fmt.Sprintf("%s/%s", a.BaseURL[region], a.URLSuffix.Rerank) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, a.baseModel.URLSuffix.Rerank) var topN int if rerankConfig != nil && rerankConfig.TopN != 0 { @@ -558,7 +535,7 @@ func (a *AI302Model) Rerank(modelName *string, query string, documents []string, req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -597,6 +574,10 @@ func (a *AI302Model) Rerank(modelName *string, query string, documents []string, } func (a *AI302Model) TranscribeAudio(modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig) (*ASRResponse, error) { + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if file == nil || strings.TrimSpace(*file) == "" { return nil, fmt.Errorf("file is missing") } @@ -604,17 +585,13 @@ func (a *AI302Model) TranscribeAudio(modelName *string, file *string, apiConfig if err != nil { return nil, err } - apiKey, err := validateAI302APIKey(apiConfig) + apiKey := strings.TrimSpace(*apiConfig.ApiKey) + + resolvedBaseURL, err := a.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - - region := "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - url := fmt.Sprintf("%s/%s", a.BaseURL[region], a.URLSuffix.ASR) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, a.baseModel.URLSuffix.ASR) // multipart body var body bytes.Buffer @@ -693,7 +670,7 @@ func (a *AI302Model) TranscribeAudio(modelName *string, file *string, apiConfig req.Header.Set("Accept", "application/json") // send request - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -734,6 +711,10 @@ func (a *AI302Model) AudioSpeechWithSender(modelName *string, audioContent *stri } func (a *AI302Model) OCRFile(modelName *string, content []byte, urls *string, apiConfig *APIConfig, ocrConfig *OCRConfig) (*OCRFileResponse, error) { + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if (urls == nil || strings.TrimSpace(*urls) == "") && (content == nil || len(content) == 0) { return nil, fmt.Errorf("file url or content is required") } @@ -741,17 +722,13 @@ func (a *AI302Model) OCRFile(modelName *string, content []byte, urls *string, ap if err != nil { return nil, err } - apiKey, err := validateAI302APIKey(apiConfig) + apiKey := strings.TrimSpace(*apiConfig.ApiKey) + + resolvedBaseURL, err := a.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - url := fmt.Sprintf("%s/%s", a.BaseURL[region], a.URLSuffix.OCR) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, a.baseModel.URLSuffix.OCR) var docURL string if urls != nil && strings.TrimSpace(*urls) != "" { @@ -789,7 +766,7 @@ func (a *AI302Model) OCRFile(modelName *string, content []byte, urls *string, ap req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -829,6 +806,10 @@ func (a *AI302Model) OCRFile(modelName *string, content []byte, urls *string, ap } func (a *AI302Model) ParseFile(modelName *string, content []byte, documentURL *string, apiConfig *APIConfig, parseFileConfig *ParseFileConfig) (*ParseFileResponse, error) { + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if documentURL == nil || strings.TrimSpace(*documentURL) == "" { return nil, fmt.Errorf("302.ai API requires a valid public document URL; direct file upload is not supported") } @@ -837,16 +818,11 @@ func (a *AI302Model) ParseFile(modelName *string, content []byte, documentURL *s return nil, err } - if apiConfig == nil || apiConfig.ApiKey == nil || strings.TrimSpace(*apiConfig.ApiKey) == "" { - return nil, fmt.Errorf("api key is required") + resolvedBaseURL, err := a.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - apiURL := fmt.Sprintf("%s/%s", a.BaseURL[region], a.URLSuffix.DocumentParse) + apiURL := fmt.Sprintf("%s/%s", resolvedBaseURL, a.baseModel.URLSuffix.DocumentParse) reqBody := map[string]interface{}{ "url": docURL, @@ -872,7 +848,7 @@ func (a *AI302Model) ParseFile(modelName *string, content []byte, documentURL *s req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(*apiConfig.ApiKey))) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -902,16 +878,16 @@ func (a *AI302Model) ParseFile(modelName *string, content []byte, documentURL *s } func (a *AI302Model) ListModels(apiConfig *APIConfig) ([]string, error) { - apiKey, err := validateAI302APIKey(apiConfig) + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + apiKey := strings.TrimSpace(*apiConfig.ApiKey) + + resolvedBaseURL, err := a.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - url := fmt.Sprintf("%s/%s", a.BaseURL[region], a.URLSuffix.Models) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, a.baseModel.URLSuffix.Models) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -924,7 +900,7 @@ func (a *AI302Model) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) req.Header.Set("Accept", "application/json") - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -976,20 +952,20 @@ func (a *AI302Model) ListTasks(apiConfig *APIConfig) ([]ListTaskStatus, error) { } func (a *AI302Model) ShowTask(taskID string, apiConfig *APIConfig) (*TaskResponse, error) { + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if strings.TrimSpace(taskID) == "" { return nil, fmt.Errorf("task id is required") } - if apiConfig == nil || apiConfig.ApiKey == nil || strings.TrimSpace(*apiConfig.ApiKey) == "" { - return nil, fmt.Errorf("api key is required") - } - - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } // URL: https://mineru.net/api/v4/extract/task/{task_id} - apiURL := fmt.Sprintf("%s/%s/%s", a.BaseURL[region], a.URLSuffix.DocumentParse, url.PathEscape(strings.TrimSpace(taskID))) + resolvedBaseURL, err := a.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + apiURL := fmt.Sprintf("%s/%s/%s", resolvedBaseURL, a.baseModel.URLSuffix.DocumentParse, url.PathEscape(strings.TrimSpace(taskID))) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -1001,7 +977,7 @@ func (a *AI302Model) ShowTask(taskID string, apiConfig *APIConfig) (*TaskRespons req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(*apiConfig.ApiKey))) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/aliyun.go b/internal/entity/models/aliyun.go index 38b86c3d0c..d05fb947ce 100644 --- a/internal/entity/models/aliyun.go +++ b/internal/entity/models/aliyun.go @@ -31,29 +31,29 @@ import ( // AliyunModel implements ModelDriver for Aliyun type AliyunModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client // Reusable HTTP client with connection pool + baseModel BaseModel } // NewAliyunModel creates a new Aliyun model instance func NewAliyunModel(baseURL map[string]string, urlSuffix URLSuffix) *AliyunModel { return &AliyunModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + DisableCompression: false, + }, }, }, } } func (a *AliyunModel) NewInstance(baseURL map[string]string) ModelDriver { - return nil + return NewAliyunModel(baseURL, a.baseModel.URLSuffix) } func (a *AliyunModel) Name() string { @@ -61,21 +61,21 @@ func (a *AliyunModel) Name() string { } func (a *AliyunModel) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := a.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } + baseURL := resolvedBaseURL - baseURL, ok := a.BaseURL[region] - if !ok || baseURL == "" { - return nil, fmt.Errorf("aliyun: no base URL configured for region %q", region) - } - - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), a.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), a.baseModel.URLSuffix.Chat) // Convert messages to the format expected by API apiMessages := make([]map[string]interface{}, len(messages)) @@ -138,11 +138,9 @@ func (a *AliyunModel) ChatWithMessages(modelName string, messages []Message, api } req.Header.Set("Content-Type", "application/json") - if apiConfig != nil && apiConfig.ApiKey != nil { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -205,21 +203,21 @@ func (a *AliyunModel) ChatWithMessages(modelName string, messages []Message, api // ChatStreamlyWithSender sends messages and streams response via sender function (best performance, no channel) func (a *AliyunModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if len(messages) == 0 { return fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := a.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } + baseURL := resolvedBaseURL - baseURL, ok := a.BaseURL[region] - if !ok || baseURL == "" { - return fmt.Errorf("aliyun: no base URL configured for region %q", region) - } - - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), a.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), a.baseModel.URLSuffix.Chat) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -286,7 +284,7 @@ func (a *AliyunModel) ChatStreamlyWithSender(modelName string, messages []Messag req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -388,34 +386,25 @@ type aliyunUsage struct { // Embed embeds a list of texts into embeddings func (a *AliyunModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { - if len(texts) == 0 { - return []EmbeddingData{}, nil + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if len(texts) == 0 { + return []EmbeddingData{}, nil } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := a.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } + baseURL := resolvedBaseURL - baseURL := a.BaseURL["default"] - if region != "default" { - if regional, ok := a.BaseURL[region]; ok && regional != "" { - baseURL = regional - } - } - if baseURL == "" { - return nil, fmt.Errorf("aliyun: no base URL configured for default region") - } - - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), a.URLSuffix.Embedding) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), a.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": *modelName, @@ -438,7 +427,7 @@ func (a *AliyunModel) Embed(modelName *string, texts []string, apiConfig *APICon req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -485,32 +474,24 @@ type aliyunRerankResponse struct { } func (a *AliyunModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(documents) == 0 { return &RerankResponse{}, nil } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") - } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := a.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } + baseURL := resolvedBaseURL - baseURL := a.BaseURL["default"] - if region != "default" { - if regional, ok := a.BaseURL[region]; ok && regional != "" { - baseURL = regional - } - } - if baseURL == "" { - return nil, fmt.Errorf("aliyun: no base URL configured for default region") - } - - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), a.URLSuffix.Rerank) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), a.baseModel.URLSuffix.Rerank) var topN = rerankConfig.TopN if rerankConfig.TopN == 0 { @@ -541,7 +522,7 @@ func (a *AliyunModel) Rerank(modelName *string, query string, documents []string req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -610,17 +591,17 @@ type AliyunModelList struct { } func (a *AliyunModel) ListModels(apiConfig *APIConfig) ([]string, error) { - var region = "default" - if apiConfig.Region != nil { - region = *apiConfig.Region + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - baseURL, ok := a.BaseURL[region] - if !ok || baseURL == "" { - return nil, fmt.Errorf("aliyun: no base URL configured for region %q", region) + resolvedBaseURL, err := a.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } + baseURL := resolvedBaseURL - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), a.URLSuffix.Models) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), a.baseModel.URLSuffix.Models) // Build request body reqBody := map[string]interface{}{} @@ -641,7 +622,7 @@ func (a *AliyunModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/anthropic.go b/internal/entity/models/anthropic.go index 8777712f7d..3418f1ccbe 100644 --- a/internal/entity/models/anthropic.go +++ b/internal/entity/models/anthropic.go @@ -33,9 +33,7 @@ const anthropicVersion = "2023-06-01" // AnthropicModel implements ModelDriver for Claude models through the // Anthropic Messages API. type AnthropicModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } func NewAnthropicModel(baseURL map[string]string, urlSuffix URLSuffix) *AnthropicModel { @@ -46,30 +44,24 @@ func NewAnthropicModel(baseURL map[string]string, urlSuffix URLSuffix) *Anthropi transport.ResponseHeaderTimeout = 60 * time.Second return &AnthropicModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (a *AnthropicModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewAnthropicModel(baseURL, a.URLSuffix) + return NewAnthropicModel(baseURL, a.baseModel.URLSuffix) } func (a *AnthropicModel) Name() string { return "anthropic" } -func (a *AnthropicModel) baseURLForRegion(region string) (string, error) { - base, ok := a.BaseURL[region] - if !ok || strings.TrimSpace(base) == "" { - return "", fmt.Errorf("anthropic: no base URL configured for region %q", region) - } - return strings.TrimRight(base, "/"), nil -} - func (a *AnthropicModel) region(apiConfig *APIConfig) string { if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { return *apiConfig.Region @@ -78,10 +70,10 @@ func (a *AnthropicModel) region(apiConfig *APIConfig) string { } func (a *AnthropicModel) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { - apiKey, err := anthropicAPIKey(apiConfig) - if err != nil { + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { return nil, err } + apiKey := strings.TrimSpace(*apiConfig.ApiKey) if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } @@ -91,11 +83,17 @@ func (a *AnthropicModel) ChatWithMessages(modelName string, messages []Message, return nil, err } - baseURL, err := a.baseURLForRegion(a.region(apiConfig)) + baseURLRegion := a.region(apiConfig) + baseURLConfig := &APIConfig{Region: &baseURLRegion} + if apiConfig != nil { + baseURLConfig.BaseURL = apiConfig.BaseURL + } + baseURL, err := a.baseModel.GetBaseURL(baseURLConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, strings.TrimLeft(a.URLSuffix.Chat, "/")) + baseURL = strings.TrimSpace(strings.TrimSuffix(baseURL, "/")) + url := fmt.Sprintf("%s/%s", baseURL, strings.TrimLeft(a.baseModel.URLSuffix.Chat, "/")) reqBody := map[string]interface{}{ "model": modelName, @@ -121,7 +119,7 @@ func (a *AnthropicModel) ChatWithMessages(modelName string, messages []Message, } setAnthropicHeaders(req, apiKey) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -145,13 +143,6 @@ func (a *AnthropicModel) ChatWithMessages(modelName string, messages []Message, }, nil } -func anthropicAPIKey(apiConfig *APIConfig) (string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || strings.TrimSpace(*apiConfig.ApiKey) == "" { - return "", fmt.Errorf("api key is required") - } - return strings.TrimSpace(*apiConfig.ApiKey), nil -} - func applyAnthropicChatConfig(reqBody map[string]interface{}, chatModelConfig *ChatConfig) { if chatModelConfig == nil { return @@ -386,16 +377,22 @@ func parseAnthropicChatResponse(body []byte) (string, string, error) { } func (a *AnthropicModel) ListModels(apiConfig *APIConfig) ([]string, error) { - apiKey, err := anthropicAPIKey(apiConfig) - if err != nil { + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { return nil, err } + apiKey := strings.TrimSpace(*apiConfig.ApiKey) - baseURL, err := a.baseURLForRegion(a.region(apiConfig)) + baseURLRegion := a.region(apiConfig) + baseURLConfig := &APIConfig{Region: &baseURLRegion} + if apiConfig != nil { + baseURLConfig.BaseURL = apiConfig.BaseURL + } + baseURL, err := a.baseModel.GetBaseURL(baseURLConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, strings.TrimLeft(a.URLSuffix.Models, "/")) + baseURL = strings.TrimSpace(strings.TrimSuffix(baseURL, "/")) + url := fmt.Sprintf("%s/%s", baseURL, strings.TrimLeft(a.baseModel.URLSuffix.Models, "/")) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -406,7 +403,7 @@ func (a *AnthropicModel) ListModels(apiConfig *APIConfig) ([]string, error) { } setAnthropicHeaders(req, apiKey) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/astraflow.go b/internal/entity/models/astraflow.go index 3c2b5873bc..3fd6d95edb 100644 --- a/internal/entity/models/astraflow.go +++ b/internal/entity/models/astraflow.go @@ -46,17 +46,10 @@ import ( // DeepSeek-R1 applies and there's no need for an inline ... // extractor like Novita's. type AstraflowModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } // NewAstraflowModel creates a new Astraflow 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 NewAstraflowModel(baseURL map[string]string, urlSuffix URLSuffix) *AstraflowModel { transport := http.DefaultTransport.(*http.Transport).Clone() transport.MaxIdleConns = 100 @@ -66,52 +59,39 @@ func NewAstraflowModel(baseURL map[string]string, urlSuffix URLSuffix) *Astraflo transport.ResponseHeaderTimeout = 60 * time.Second return &AstraflowModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (a *AstraflowModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewAstraflowModel(baseURL, a.URLSuffix) + return NewAstraflowModel(baseURL, a.baseModel.URLSuffix) } func (a *AstraflowModel) Name() string { return "astraflow" } -func (a *AstraflowModel) baseURLForRegion(region string) (string, error) { - base, ok := a.BaseURL[region] - if !ok || base == "" { - return "", fmt.Errorf("astraflow: 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. +// ChatWithMessages sends a non-streaming chat request func (a *AstraflowModel) 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 err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } 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) + baseURL, err := a.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, a.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, a.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -157,7 +137,7 @@ func (a *AstraflowModel) ChatWithMessages(modelName string, messages []Message, req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -196,10 +176,6 @@ func (a *AstraflowModel) ChatWithMessages(modelName string, messages []Message, 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 @@ -211,31 +187,25 @@ func (a *AstraflowModel) ChatWithMessages(modelName string, messages []Message, }, 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. +// ChatStreamlyWithSender opens the SSE chat-completions func (a *AstraflowModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + 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) + baseURL, err := a.baseModel.GetBaseURL(apiConfig) if err != nil { return err } - url := fmt.Sprintf("%s/%s", baseURL, a.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, a.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -252,9 +222,6 @@ func (a *AstraflowModel) ChatStreamlyWithSender(modelName string, messages []Mes } 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") } @@ -286,7 +253,7 @@ func (a *AstraflowModel) ChatStreamlyWithSender(modelName string, messages []Mes req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -313,15 +280,8 @@ func (a *AstraflowModel) ChatStreamlyWithSender(modelName string, messages []Mes 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("astraflow: invalid SSE event: %w", err) } - - // Astraflow 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("astraflow: upstream stream error: %v", apiErr) } @@ -334,10 +294,6 @@ func (a *AstraflowModel) ChatStreamlyWithSender(modelName string, messages []Mes 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 @@ -372,24 +328,17 @@ func (a *AstraflowModel) ChatStreamlyWithSender(modelName string, messages []Mes 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 *AstraflowModel) ListModels(apiConfig *APIConfig) ([]string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := a.baseURLForRegion(region) + baseURL, err := a.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, a.URLSuffix.Models) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, a.baseModel.URLSuffix.Models) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -400,7 +349,7 @@ func (a *AstraflowModel) ListModels(apiConfig *APIConfig) ([]string, error) { } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -439,29 +388,25 @@ func (a *AstraflowModel) ListModels(apiConfig *APIConfig) ([]string, error) { 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 *AstraflowModel) CheckConnection(apiConfig *APIConfig) error { _, err := a.ListModels(apiConfig) return err } -// Embed is reserved for a follow-up issue; the Astraflow factory tag -// includes "TEXT EMBEDDING" but this initial driver only implements -// chat, mirroring how Novita / TogetherAI / DeepInfra landed -// method-by-method. func (a *AstraflowModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(texts) == 0 { return []EmbeddingData{}, nil } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := a.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", a.BaseURL[region], a.URLSuffix.Embedding) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, a.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": *modelName, @@ -481,7 +426,7 @@ func (a *AstraflowModel) Embed(modelName *string, texts []string, apiConfig *API req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -523,16 +468,19 @@ func (a *AstraflowModel) Embed(modelName *string, texts []string, apiConfig *API } func (a *AstraflowModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(documents) == 0 { return &RerankResponse{}, nil } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := a.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", a.BaseURL[region], a.URLSuffix.Rerank) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, a.baseModel.URLSuffix.Rerank) var topN = rerankConfig.TopN if rerankConfig.TopN != 0 { @@ -559,7 +507,7 @@ func (a *AstraflowModel) Rerank(modelName *string, query string, documents []str req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -610,20 +558,19 @@ func (a *AstraflowModel) TranscribeAudioWithSender(modelName *string, file *stri } func (a *AstraflowModel) AudioSpeech(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig) (*TTSResponse, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("Astraflow API key is missing") + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if audioContent == nil || *audioContent == "" { return nil, fmt.Errorf("text content is missing") } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := a.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", a.BaseURL[region], a.URLSuffix.TTS) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, a.baseModel.URLSuffix.TTS) reqBody := map[string]interface{}{ "model": *modelName, @@ -652,7 +599,7 @@ func (a *AstraflowModel) AudioSpeech(modelName *string, audioContent *string, ap req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/avian.go b/internal/entity/models/avian.go index 53edcdbafd..d7d8085de5 100644 --- a/internal/entity/models/avian.go +++ b/internal/entity/models/avian.go @@ -29,30 +29,11 @@ import ( ) // AvianModel implements ModelDriver for Avian (https://api.avian.io/docs/). -// -// Avian is a SaaS inference platform exposing an OpenAI-compatible REST API -// at https://api.avian.io/v1 (chat completions at /chat/completions, list -// models at /models). It serves a catalog of third-party chat models -// (DeepSeek, Kimi, GLM, MiniMax, etc.) behind a single OpenAI-shaped surface. -// -// The shipped base URL is https://api.avian.io; the tenant may override -// per-instance. Authentication is always required: every call sets -// Authorization: Bearer . Reasoning models surface their thinking -// in either `reasoning_content` (preferred) or `reasoning`; the driver -// extracts whichever is non-empty and routes it to ChatResponse.ReasonContent -// (non-stream) or the sender's second argument (stream). type AvianModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } // NewAvianModel creates a new Avian model instance. -// -// Same transport convention as other Go SaaS drivers in this package: -// clone http.DefaultTransport, override the connection-pool fields, -// no client-level Timeout so SSE streams are not capped at the client -// layer (per-request contexts handle that). func NewAvianModel(baseURL map[string]string, urlSuffix URLSuffix) *AvianModel { transport := http.DefaultTransport.(*http.Transport).Clone() transport.MaxIdleConns = 100 @@ -62,35 +43,24 @@ func NewAvianModel(baseURL map[string]string, urlSuffix URLSuffix) *AvianModel { transport.ResponseHeaderTimeout = 60 * time.Second return &AvianModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (a *AvianModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewAvianModel(baseURL, a.URLSuffix) + return NewAvianModel(baseURL, a.baseModel.URLSuffix) } func (a *AvianModel) Name() string { return "avian" } -func (a *AvianModel) baseURLForRegion(region string) (string, error) { - base, ok := a.BaseURL[region] - if !ok || base == "" { - return "", fmt.Errorf("avian: no base URL configured for region %q", region) - } - // Tenants may paste in a base URL that already includes the API - // version (".../v1" or ".../v1/"); callers append "v1/..." so we - // strip those plus any trailing "/" to avoid "/v1/v1/..." paths. - base = strings.TrimSuffix(base, "/") - base = strings.TrimSuffix(base, "/v1") - return strings.TrimSuffix(base, "/"), nil -} - func (a *AvianModel) chatPayload(modelName string, messages []Message, stream bool, chatModelConfig *ChatConfig) map[string]interface{} { apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -125,16 +95,13 @@ func (a *AvianModel) chatPayload(modelName string, messages []Message, stream bo } func (a *AvianModel) chatURL(apiConfig *APIConfig) (string, error) { - region := "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - baseURL, err := a.baseURLForRegion(region) + baseURL, err := a.baseModel.GetBaseURL(apiConfig) if err != nil { return "", err } - return fmt.Sprintf("%s/%s", baseURL, a.URLSuffix.Chat), nil + baseURL = strings.TrimSuffix(baseURL, "/") + return fmt.Sprintf("%s/%s", baseURL, a.baseModel.URLSuffix.Chat), nil } type avianChatMessage struct { @@ -156,8 +123,8 @@ type avianChatResponse struct { } func (a *AvianModel) 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 err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if strings.TrimSpace(modelName) == "" { return nil, fmt.Errorf("model name is required") @@ -186,7 +153,7 @@ func (a *AvianModel) ChatWithMessages(modelName string, messages []Message, apiC req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -223,12 +190,13 @@ func (a *AvianModel) ChatWithMessages(modelName string, messages []Message, apiC } func (a *AvianModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if sender == nil { return fmt.Errorf("sender is required") } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return fmt.Errorf("api key is required") - } if strings.TrimSpace(modelName) == "" { return fmt.Errorf("model name is required") } @@ -249,9 +217,6 @@ func (a *AvianModel) ChatStreamlyWithSender(modelName string, messages []Message return fmt.Errorf("failed to marshal request: %w", err) } - // ResponseHeaderTimeout caps the initial header wait. This context - // also caps the body-read phase so a stalled SSE stream cannot hold - // the caller's goroutine and connection indefinitely. ctx, cancel := context.WithTimeout(context.Background(), streamCallTimeout) defer cancel() @@ -262,7 +227,7 @@ func (a *AvianModel) ChatStreamlyWithSender(modelName string, messages []Message req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -335,20 +300,16 @@ type avianModelInfo struct { } func (a *AvianModel) ListModels(apiConfig *APIConfig) ([]string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := a.baseURLForRegion(region) + baseURL, err := a.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, a.URLSuffix.Models) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, a.baseModel.URLSuffix.Models) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -360,7 +321,7 @@ func (a *AvianModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/azure_openai.go b/internal/entity/models/azure_openai.go index 14bea3898f..4e13439d1f 100644 --- a/internal/entity/models/azure_openai.go +++ b/internal/entity/models/azure_openai.go @@ -29,37 +29,14 @@ import ( ) // azureAPIVersion is the Azure OpenAI REST API version sent as the -// api-version query parameter on every request. Azure requires this on -// all data-plane calls; 2024-10-21 is the latest GA (non-preview) version. const azureAPIVersion = "2024-10-21" // AzureOpenAIModel implements ModelDriver for Azure OpenAI. -// -// Azure OpenAI is not a base-URL swap of the OpenAI driver. It differs in -// three ways that this driver handles: -// - Endpoints are deployment-scoped: -// {baseURL}/deployments/{deployment}/{op}?api-version={azureAPIVersion} -// The model name passed in is the Azure deployment name, which goes in -// the URL path rather than the request body. -// - Authentication uses the "api-key" header, not "Authorization: Bearer". -// - Listing models means listing deployments via {baseURL}/deployments. -// -// The base URL is user-supplied (e.g. https://.openai.azure.com/openai) -// because each Azure resource has its own endpoint; there is no shared default. type AzureOpenAIModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client // Reusable HTTP client with connection pool + baseModel BaseModel } // NewAzureOpenAIModel creates a new Azure OpenAI model instance. -// -// The transport mirrors the OpenAI driver: clone http.DefaultTransport to -// keep Go's defaults (proxy, dial keep-alive, HTTP/2, TLS handshake) and -// only tune the connection pool. The Client has no overall Timeout so SSE -// streams in ChatStreamlyWithSender are not cut off; non-streaming callers -// wrap each request in context.WithTimeout, and ResponseHeaderTimeout caps -// how long we wait for the first response header. func NewAzureOpenAIModel(baseURL map[string]string, urlSuffix URLSuffix) *AzureOpenAIModel { transport := http.DefaultTransport.(*http.Transport).Clone() transport.MaxIdleConns = 100 @@ -69,35 +46,25 @@ func NewAzureOpenAIModel(baseURL map[string]string, urlSuffix URLSuffix) *AzureO transport.ResponseHeaderTimeout = 60 * time.Second return &AzureOpenAIModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (a *AzureOpenAIModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewAzureOpenAIModel(baseURL, a.URLSuffix) + return NewAzureOpenAIModel(baseURL, a.baseModel.URLSuffix) } func (a *AzureOpenAIModel) Name() string { return "azure-openai" } -// baseURLForRegion returns the base URL for the given region, or an error if -// no entry exists. A misconfigured region fails fast with a clear message -// instead of silently producing a relative URL the transport then rejects. -func (a *AzureOpenAIModel) baseURLForRegion(region string) (string, error) { - base, ok := a.BaseURL[region] - if !ok || base == "" { - return "", fmt.Errorf("azure-openai: no base URL configured for region %q", region) - } - return base, nil -} - // deploymentURL builds a deployment-scoped data-plane URL of the form -// {baseURL}/deployments/{deployment}/{op}?api-version={azureAPIVersion}. func (a *AzureOpenAIModel) deploymentURL(baseURL, deployment, op string) string { return fmt.Sprintf("%s/deployments/%s/%s?api-version=%s", strings.TrimRight(baseURL, "/"), deployment, op, azureAPIVersion) @@ -105,8 +72,8 @@ func (a *AzureOpenAIModel) deploymentURL(baseURL, deployment, op string) string // ChatWithMessages sends multiple messages with roles and returns the response. func (a *AzureOpenAIModel) 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 err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if len(messages) == 0 { @@ -117,16 +84,12 @@ func (a *AzureOpenAIModel) ChatWithMessages(modelName string, messages []Message return nil, fmt.Errorf("deployment name is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := a.baseURLForRegion(region) + baseURL, err := a.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := a.deploymentURL(baseURL, modelName, a.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := a.deploymentURL(baseURL, modelName, a.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -136,8 +99,6 @@ func (a *AzureOpenAIModel) ChatWithMessages(modelName string, messages []Message } } - // The deployment (and therefore the model) is identified by the URL path, - // so Azure does not take a "model" field in the body. reqBody := map[string]interface{}{ "messages": apiMessages, "stream": false, @@ -175,7 +136,7 @@ func (a *AzureOpenAIModel) ChatWithMessages(modelName string, messages []Message req.Header.Set("Content-Type", "application/json") req.Header.Set("api-key", *apiConfig.ApiKey) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -232,12 +193,12 @@ func (a *AzureOpenAIModel) ChatWithMessages(modelName string, messages []Message // ChatStreamlyWithSender sends messages and streams the response via the // sender function. Used for streaming chat responses with no extra channel. func (a *AzureOpenAIModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { - if len(messages) == 0 { - return fmt.Errorf("messages is empty") + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return err } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return fmt.Errorf("api key is required") + if len(messages) == 0 { + return fmt.Errorf("messages is empty") } if modelName == "" { @@ -248,16 +209,12 @@ func (a *AzureOpenAIModel) ChatStreamlyWithSender(modelName string, messages []M return fmt.Errorf("sender is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := a.baseURLForRegion(region) + baseURL, err := a.baseModel.GetBaseURL(apiConfig) if err != nil { return err } - url := a.deploymentURL(baseURL, modelName, a.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := a.deploymentURL(baseURL, modelName, a.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -273,9 +230,6 @@ func (a *AzureOpenAIModel) ChatStreamlyWithSender(modelName string, messages []M } if chatModelConfig != nil { - // This code path only knows how to read SSE, so refuse an explicit - // stream=false rather than mis-parsing a single JSON response as a - // stream and emitting no chunks. if chatModelConfig.Stream != nil && !*chatModelConfig.Stream { return fmt.Errorf("stream must be true in ChatStreamlyWithSender") } @@ -297,9 +251,6 @@ func (a *AzureOpenAIModel) ChatStreamlyWithSender(modelName string, messages []M if err != nil { return fmt.Errorf("failed to marshal request: %w", err) } - - // Background context: SSE streams are long-lived so we attach no hard - // deadline. The transport's ResponseHeaderTimeout caps connection setup. req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(jsonData)) if err != nil { return fmt.Errorf("failed to create request: %w", err) @@ -308,7 +259,7 @@ func (a *AzureOpenAIModel) ChatStreamlyWithSender(modelName string, messages []M req.Header.Set("Content-Type", "application/json") req.Header.Set("api-key", *apiConfig.ApiKey) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -323,10 +274,6 @@ func (a *AzureOpenAIModel) ChatStreamlyWithSender(modelName string, messages []M // never silently truncated by the default 64KB cap. scanner := bufio.NewScanner(resp.Body) scanner.Buffer(make([]byte, 64*1024), 1024*1024) - // sawTerminal flips true when upstream signals the stream is done (a - // "[DONE]" marker or a non-empty finish_reason). If the body closes - // before either, we must not emit a synthetic "[DONE]" that would hide - // a truncated response from the caller. sawTerminal := false for scanner.Scan() { line := scanner.Text() @@ -398,32 +345,26 @@ type azureEmbeddingResponse struct { } `json:"data"` } -// Embed turns a list of texts into embedding vectors using the Azure OpenAI -// embeddings deployment. The output has one vector per input, in the same -// order the inputs were given. +// Embed turns a list of texts into embedding vectors func (a *AzureOpenAIModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { - if len(texts) == 0 { - return []EmbeddingData{}, nil + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if len(texts) == 0 { + return []EmbeddingData{}, nil } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("deployment name is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := a.baseURLForRegion(region) + baseURL, err := a.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := a.deploymentURL(baseURL, *modelName, a.URLSuffix.Embedding) + baseURL = strings.TrimSuffix(baseURL, "/") + url := a.deploymentURL(baseURL, *modelName, a.baseModel.URLSuffix.Embedding) // As with chat, the deployment is in the URL path, so no "model" field. reqBody := map[string]interface{}{ @@ -449,7 +390,7 @@ func (a *AzureOpenAIModel) Embed(modelName *string, texts []string, apiConfig *A req.Header.Set("Content-Type", "application/json") req.Header.Set("api-key", *apiConfig.ApiKey) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -468,11 +409,6 @@ func (a *AzureOpenAIModel) Embed(modelName *string, texts []string, apiConfig *A if err = json.Unmarshal(body, &parsed); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } - - // Azure returns one item per input, but does not guarantee response - // order. Each item's Index refers to its position in the input list, so - // place vectors by Index to honor the documented input-order guarantee. - // Reject an out-of-range index instead of panicking. embeddings := make([]EmbeddingData, len(texts)) for _, d := range parsed.Data { if d.Index < 0 || d.Index >= len(embeddings) { @@ -488,24 +424,18 @@ func (a *AzureOpenAIModel) Embed(modelName *string, texts []string, apiConfig *A } // ListModels returns the deployment names visible to the configured API key. -// Azure exposes deployments (not a shared model catalog) at -// {baseURL}/deployments?api-version={azureAPIVersion}. func (a *AzureOpenAIModel) ListModels(apiConfig *APIConfig) ([]string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := a.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := a.baseURLForRegion(region) + baseURL, err := a.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } + baseURL = strings.TrimSuffix(baseURL, "/") url := fmt.Sprintf("%s/%s?api-version=%s", - strings.TrimRight(baseURL, "/"), a.URLSuffix.Models, azureAPIVersion) + strings.TrimRight(baseURL, "/"), a.baseModel.URLSuffix.Models, azureAPIVersion) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -517,7 +447,7 @@ func (a *AzureOpenAIModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("api-key", *apiConfig.ApiKey) - resp, err := a.httpClient.Do(req) + resp, err := a.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -560,7 +490,6 @@ func (a *AzureOpenAIModel) ListModels(apiConfig *APIConfig) ([]string, error) { } // CheckConnection runs a lightweight ListModels call to verify the endpoint -// and API key. func (a *AzureOpenAIModel) CheckConnection(apiConfig *APIConfig) error { _, err := a.ListModels(apiConfig) return err diff --git a/internal/entity/models/baichuan.go b/internal/entity/models/baichuan.go index 115fc2f66e..d84fa81049 100644 --- a/internal/entity/models/baichuan.go +++ b/internal/entity/models/baichuan.go @@ -29,41 +29,29 @@ import ( "time" ) -// sk-6e16f0a6bfaa7fc58e30a50962665d1d type BaichuanModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } func NewBaichuanModel(baseURL map[string]string, urlSuffix URLSuffix) *BaichuanModel { return &BaichuanModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + DisableCompression: false, + }, }, }, } } func (b *BaichuanModel) NewInstance(baseURL map[string]string) ModelDriver { - return &BaichuanModel{ - BaseURL: baseURL, - URLSuffix: b.URLSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, - }, - }, - } + return NewBaichuanModel(baseURL, b.baseModel.URLSuffix) } func (b *BaichuanModel) Name() string { @@ -71,19 +59,18 @@ func (b *BaichuanModel) Name() string { } func (b *BaichuanModel) 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 nil or empty") + if err := b.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := b.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", b.BaseURL[region], b.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, b.baseModel.URLSuffix.Chat) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -136,7 +123,7 @@ func (b *BaichuanModel) ChatWithMessages(modelName string, messages []Message, a req.Header.Add("Content-Type", "application/json") req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := b.httpClient.Do(req) + resp, err := b.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -188,16 +175,19 @@ func (b *BaichuanModel) ChatWithMessages(modelName string, messages []Message, a } func (b *BaichuanModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, modelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := b.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if len(messages) == 0 { return fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := b.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - url := fmt.Sprintf("%s/%s", b.BaseURL[region], b.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, b.baseModel.URLSuffix.Chat) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -253,7 +243,7 @@ func (b *BaichuanModel) ChatStreamlyWithSender(modelName string, messages []Mess req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := b.httpClient.Do(req) + resp, err := b.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -328,16 +318,19 @@ func (b *BaichuanModel) ChatStreamlyWithSender(modelName string, messages []Mess } func (b *BaichuanModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { + if err := b.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(texts) == 0 { return []EmbeddingData{}, nil } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := b.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", b.BaseURL[region], b.URLSuffix.Embedding) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, b.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": *modelName, @@ -360,7 +353,7 @@ func (b *BaichuanModel) Embed(modelName *string, texts []string, apiConfig *APIC req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(*apiConfig.ApiKey))) - resp, err := b.httpClient.Do(req) + resp, err := b.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/baidu.go b/internal/entity/models/baidu.go index ca1eb9b6f8..702f10d469 100644 --- a/internal/entity/models/baidu.go +++ b/internal/entity/models/baidu.go @@ -31,36 +31,25 @@ import ( ) type BaiduModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } func (b *BaiduModel) NewInstance(baseURL map[string]string) ModelDriver { - return &BaiduModel{ - BaseURL: baseURL, - URLSuffix: b.URLSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 10, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, - }, - }, - } + return NewBaiduModel(baseURL, b.baseModel.URLSuffix) } func NewBaiduModel(baseURL map[string]string, urlSuffix URLSuffix) *BaiduModel { return &BaiduModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxConnsPerHost: 10, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + MaxConnsPerHost: 10, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: 90 * time.Second, + DisableCompression: false, + }, }, }, } @@ -71,19 +60,18 @@ func (b *BaiduModel) Name() string { } func (b *BaiduModel) 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 nil or empty") + if err := b.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := b.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", b.BaseURL[region], b.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, b.baseModel.URLSuffix.Chat) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -181,7 +169,7 @@ func (b *BaiduModel) ChatWithMessages(modelName string, messages []Message, apiC req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := b.httpClient.Do(req) + resp, err := b.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -243,16 +231,19 @@ func (b *BaiduModel) ChatWithMessages(modelName string, messages []Message, apiC } func (b *BaiduModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, modelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := b.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if len(messages) == 0 { return fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := b.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(b.BaseURL[region], "/"), b.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(resolvedBaseURL, "/"), b.baseModel.URLSuffix.Chat) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -354,7 +345,7 @@ func (b *BaiduModel) ChatStreamlyWithSender(modelName string, messages []Message req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := b.httpClient.Do(req) + resp, err := b.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -456,8 +447,8 @@ type baiduUsage struct { } func (b *BaiduModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := b.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") @@ -466,12 +457,11 @@ func (b *BaiduModel) Embed(modelName *string, texts []string, apiConfig *APIConf return []EmbeddingData{}, nil } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := b.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", b.BaseURL[region], b.URLSuffix.Embedding) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, b.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": *modelName, @@ -497,7 +487,7 @@ func (b *BaiduModel) Embed(modelName *string, texts []string, apiConfig *APIConf req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := b.httpClient.Do(req) + resp, err := b.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -554,16 +544,19 @@ func (b *BaiduModel) Embed(modelName *string, texts []string, apiConfig *APIConf } func (b *BaiduModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { + if err := b.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(documents) == 0 { return &RerankResponse{}, nil } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := b.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(b.BaseURL[region], "/"), b.URLSuffix.Rerank) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(resolvedBaseURL, "/"), b.baseModel.URLSuffix.Rerank) var topN = rerankConfig.TopN if rerankConfig.TopN == 0 { @@ -593,7 +586,7 @@ func (b *BaiduModel) Rerank(modelName *string, query string, documents []string, req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := b.httpClient.Do(req) + resp, err := b.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -662,22 +655,22 @@ type qianfanOCRResponse struct { } func (b *BaiduModel) OCRFile(modelName *string, content []byte, fileURL *string, apiConfig *APIConfig, ocrConfig *OCRConfig) (*OCRFileResponse, error) { + if err := b.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if (fileURL == nil || *fileURL == "") && (content == nil || len(content) == 0) { return nil, fmt.Errorf("image url or content is required") } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") - } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := b.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", b.BaseURL[region], b.URLSuffix.OCR) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, b.baseModel.URLSuffix.OCR) reqData := map[string]interface{}{ "model": *modelName, @@ -717,7 +710,7 @@ func (b *BaiduModel) OCRFile(modelName *string, content []byte, fileURL *string, req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := b.httpClient.Do(req) + resp, err := b.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -752,12 +745,15 @@ func (b *BaiduModel) OCRFile(modelName *string, content []byte, fileURL *string, } func (b *BaiduModel) ListModels(apiConfig *APIConfig) ([]string, error) { - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + if err := b.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - url := fmt.Sprintf("%s/%s", b.BaseURL[region], b.URLSuffix.Models) + resolvedBaseURL, err := b.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", resolvedBaseURL, b.baseModel.URLSuffix.Models) reqBody := map[string]string{} @@ -777,7 +773,7 @@ func (b *BaiduModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := b.httpClient.Do(req) + resp, err := b.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/base_model.go b/internal/entity/models/base_model.go index 59e8b198d3..84b05bb58f 100644 --- a/internal/entity/models/base_model.go +++ b/internal/entity/models/base_model.go @@ -29,32 +29,32 @@ type BaseModel struct { } func (b *BaseModel) APIConfigCheck(apiConfig *APIConfig) error { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return fmt.Errorf("api key is nil or empty") - } - - if apiConfig.BaseURL == nil || *apiConfig.BaseURL == "" { - if apiConfig.Region == nil || *apiConfig.Region == "" { - return fmt.Errorf("no base url and region") - } + if apiConfig == nil || apiConfig.ApiKey == nil || strings.TrimSpace(*apiConfig.ApiKey) == "" { + return fmt.Errorf("api key is required") } return nil } func (b *BaseModel) GetBaseURL(apiConfig *APIConfig) (string, error) { - - if apiConfig.BaseURL != nil && *apiConfig.BaseURL != "" { + if apiConfig != nil && apiConfig.BaseURL != nil && *apiConfig.BaseURL != "" { return strings.TrimSuffix(*apiConfig.BaseURL, "/"), nil } region := "default" - if apiConfig.Region != nil { + hasRegion := false + if apiConfig != nil && apiConfig.Region != nil { + hasRegion = true region = *apiConfig.Region } baseURL, ok := b.BaseURL[region] if !ok || baseURL == "" { + if (!hasRegion || region == "") && b.BaseURL != nil { + if defaultBaseURL, ok := b.BaseURL["default"]; ok && defaultBaseURL != "" { + return defaultBaseURL, nil + } + } return "", fmt.Errorf("no base URL configured for region %q", region) } diff --git a/internal/entity/models/bedrock.go b/internal/entity/models/bedrock.go index 62fb5583dd..fffffb90ba 100644 --- a/internal/entity/models/bedrock.go +++ b/internal/entity/models/bedrock.go @@ -42,10 +42,10 @@ import ( // URLSuffix, while honouring an operator override (e.g. fronting // Bedrock through a corporate VPC endpoint at a non-AWS path). const ( - defaultBedrockChatSuffix = "converse" - defaultBedrockStreamSuffix = "converse-stream" - defaultBedrockListModelsSuffix = "foundation-models" - bedrockStreamSuffixSuffix = "-stream" + defaultBedrockChatSuffix = "converse" + defaultBedrockStreamSuffix = "converse-stream" + defaultBedrockListModelsSuffix = "foundation-models" + bedrockStreamSuffixSuffix = "-stream" ) // Bedrock signing services and endpoint hostnames. @@ -93,9 +93,7 @@ const bedrockAssumeRoleSession = "BedrockSession" // has its own endpoint and the URL is fully determined by the region // in the API key. type BedrockModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } // NewBedrockModel creates a new Bedrock model instance. @@ -119,10 +117,12 @@ func NewBedrockModel(baseURL map[string]string, urlSuffix URLSuffix) *BedrockMod transport.ResponseHeaderTimeout = 60 * time.Second return &BedrockModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } @@ -132,7 +132,7 @@ func NewBedrockModel(baseURL map[string]string, urlSuffix URLSuffix) *BedrockMod // instance with a custom endpoint override (e.g. a VPC endpoint // fronting Bedrock for compliance reasons). func (b *BedrockModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewBedrockModel(baseURL, b.URLSuffix) + return NewBedrockModel(baseURL, b.baseModel.URLSuffix) } // Name returns the canonical lower-case provider name used by the @@ -280,8 +280,8 @@ func assumeBedrockRole(ctx context.Context, key *bedrockKey, region string) (aws // the AWS-defined "converse" path when conf/models/bedrock.json does // not override it. func (b *BedrockModel) chatSuffix() string { - if b.URLSuffix.Chat != "" { - return b.URLSuffix.Chat + if b.baseModel.URLSuffix.Chat != "" { + return b.baseModel.URLSuffix.Chat } return defaultBedrockChatSuffix } @@ -291,11 +291,11 @@ func (b *BedrockModel) chatSuffix() string { // stream path from the chat suffix rather than carrying a separate // configuration field that would have to stay in sync. func (b *BedrockModel) streamSuffix() string { - if b.URLSuffix.AsyncChat != "" { - return b.URLSuffix.AsyncChat + if b.baseModel.URLSuffix.AsyncChat != "" { + return b.baseModel.URLSuffix.AsyncChat } - if b.URLSuffix.Chat != "" { - return b.URLSuffix.Chat + bedrockStreamSuffixSuffix + if b.baseModel.URLSuffix.Chat != "" { + return b.baseModel.URLSuffix.Chat + bedrockStreamSuffixSuffix } return defaultBedrockStreamSuffix } @@ -303,8 +303,8 @@ func (b *BedrockModel) streamSuffix() string { // modelsSuffix returns the list-models URL suffix on the control // plane, falling back to the AWS-defined "foundation-models" path. func (b *BedrockModel) modelsSuffix() string { - if b.URLSuffix.Models != "" { - return b.URLSuffix.Models + if b.baseModel.URLSuffix.Models != "" { + return b.baseModel.URLSuffix.Models } return defaultBedrockListModelsSuffix } @@ -315,7 +315,7 @@ func (b *BedrockModel) modelsSuffix() string { // wins so on-premises proxies (e.g. CloudFront-fronted VPC endpoints) // keep working. func (b *BedrockModel) bedrockRuntimeURL(region, modelID, op string) string { - if override, ok := b.BaseURL[region]; ok && override != "" { + if override, ok := b.baseModel.BaseURL[region]; ok && override != "" { return joinBedrockPath(override, "model", modelID, op) } host := fmt.Sprintf(bedrockRuntimeHostTmpl, region) @@ -326,7 +326,7 @@ func (b *BedrockModel) bedrockRuntimeURL(region, modelID, op string) string { // for a given operation (typically "foundation-models" for the model // catalog). func (b *BedrockModel) bedrockControlURL(region, op string) string { - if override, ok := b.BaseURL["control:"+region]; ok && override != "" { + if override, ok := b.baseModel.BaseURL["control:"+region]; ok && override != "" { return joinBedrockPath(override, op) } host := fmt.Sprintf(bedrockControlHostTmpl, region) @@ -515,9 +515,10 @@ func signBedrockRequest(ctx context.Context, req *http.Request, body []byte, cre // driver contract; Bedrock surfaces no reasoning channel today, so it // is left empty rather than nil. func (b *BedrockModel) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { - if apiConfig == nil || apiConfig.ApiKey == nil { - return nil, fmt.Errorf("api key is required") + if err := b.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } + if modelName == "" { return nil, fmt.Errorf("bedrock: model id is required") } @@ -558,7 +559,7 @@ func (b *BedrockModel) ChatWithMessages(modelName string, messages []Message, ap return nil, err } - resp, err := b.httpClient.Do(req) + resp, err := b.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("bedrock: send request: %w", err) } @@ -594,9 +595,10 @@ func (b *BedrockModel) ChatWithMessages(modelName string, messages []Message, ap // messageStop, and (for error propagation) exception frames; other // events are ignored. func (b *BedrockModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { - if apiConfig == nil || apiConfig.ApiKey == nil { - return fmt.Errorf("api key is required") + if err := b.baseModel.APIConfigCheck(apiConfig); err != nil { + return err } + if modelName == "" { return fmt.Errorf("bedrock: model id is required") } @@ -645,7 +647,7 @@ func (b *BedrockModel) ChatStreamlyWithSender(modelName string, messages []Messa return err } - resp, err := b.httpClient.Do(req) + resp, err := b.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("bedrock: send request: %w", err) } @@ -767,9 +769,10 @@ type bedrockListModelsResponse struct { // bedrock.{region}.amazonaws.com (not bedrock-runtime), signs against // the "bedrock" service, and is GET-only. func (b *BedrockModel) ListModels(apiConfig *APIConfig) ([]string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil { - return nil, fmt.Errorf("api key is required") + if err := b.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } + key, err := parseBedrockKey(*apiConfig.ApiKey) if err != nil { return nil, err @@ -797,7 +800,7 @@ func (b *BedrockModel) ListModels(apiConfig *APIConfig) ([]string, error) { return nil, err } - resp, err := b.httpClient.Do(req) + resp, err := b.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("bedrock: send request: %w", err) } diff --git a/internal/entity/models/cohere.go b/internal/entity/models/cohere.go index b3285709ea..722c0461a6 100644 --- a/internal/entity/models/cohere.go +++ b/internal/entity/models/cohere.go @@ -32,24 +32,20 @@ import ( ) type CoHereModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } func (c *CoHereModel) NewInstance(baseURL map[string]string) ModelDriver { - return &CoHereModel{ - BaseURL: baseURL, - URLSuffix: c.URLSuffix, - httpClient: &http.Client{}, - } + return NewCoHereModel(baseURL, c.baseModel.URLSuffix) } func NewCoHereModel(baseURL map[string]string, urlSuffix URLSuffix) *CoHereModel { return &CoHereModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{}, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{}, + }, } } @@ -58,19 +54,18 @@ func (c *CoHereModel) Name() string { } func (c *CoHereModel) 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 nil or empty") + if err := c.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := c.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", c.BaseURL[region], c.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, c.baseModel.URLSuffix.Chat) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -136,7 +131,7 @@ func (c *CoHereModel) ChatWithMessages(modelName string, messages []Message, api req.Header.Set("accept", "application/json") req.Header.Set("Authorization", fmt.Sprintf("bearer %s", strings.TrimSpace(*apiConfig.ApiKey))) - resp, err := c.httpClient.Do(req) + resp, err := c.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -192,16 +187,19 @@ func (c *CoHereModel) ChatWithMessages(modelName string, messages []Message, api } func (c *CoHereModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, modelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := c.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if len(messages) == 0 { return fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := c.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - url := fmt.Sprintf("%s/%s", c.BaseURL[region], c.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, c.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -277,7 +275,7 @@ func (c *CoHereModel) ChatStreamlyWithSender(modelName string, messages []Messag req.Header.Set("accept", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(*apiConfig.ApiKey))) - resp, err := c.httpClient.Do(req) + resp, err := c.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -352,17 +350,20 @@ func (c *CoHereModel) ChatStreamlyWithSender(modelName string, messages []Messag } func (c *CoHereModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { + if err := c.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(texts) == 0 { return []EmbeddingData{}, nil } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := c.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - baseURL := strings.TrimSuffix(c.BaseURL[region], "/") - suffix := strings.TrimPrefix(c.URLSuffix.Embedding, "/") + baseURL := strings.TrimSuffix(resolvedBaseURL, "/") + suffix := strings.TrimPrefix(c.baseModel.URLSuffix.Embedding, "/") url := fmt.Sprintf("%s/%s", baseURL, suffix) reqBody := map[string]interface{}{ @@ -389,7 +390,7 @@ func (c *CoHereModel) Embed(modelName *string, texts []string, apiConfig *APICon req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(*apiConfig.ApiKey))) - resp, err := c.httpClient.Do(req) + resp, err := c.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -429,17 +430,20 @@ func (c *CoHereModel) Embed(modelName *string, texts []string, apiConfig *APICon } func (c *CoHereModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { + if err := c.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(documents) == 0 { return &RerankResponse{}, nil } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := c.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - baseURL := strings.TrimSuffix(c.BaseURL[region], "/") - suffix := strings.TrimPrefix(c.URLSuffix.Rerank, "/") + baseURL := strings.TrimSuffix(resolvedBaseURL, "/") + suffix := strings.TrimPrefix(c.baseModel.URLSuffix.Rerank, "/") url := fmt.Sprintf("%s/%s", baseURL, suffix) var topN = rerankConfig.TopN @@ -471,7 +475,7 @@ func (c *CoHereModel) Rerank(modelName *string, query string, documents []string req.Header.Set("Accept", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(*apiConfig.ApiKey))) - resp, err := c.httpClient.Do(req) + resp, err := c.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -511,16 +515,19 @@ func (c *CoHereModel) Rerank(modelName *string, query string, documents []string // TranscribeAudio transcribe audio func (c *CoHereModel) TranscribeAudio(modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig) (*ASRResponse, error) { + if err := c.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if file == nil || *file == "" { return nil, fmt.Errorf("file is missing") } - region := "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := c.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", c.BaseURL[region], c.URLSuffix.ASR) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, c.baseModel.URLSuffix.ASR) // multipart body @@ -594,7 +601,7 @@ func (c *CoHereModel) TranscribeAudio(modelName *string, file *string, apiConfig req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) req.Header.Set("Content-Type", writer.FormDataContentType()) - resp, err := c.httpClient.Do(req) + resp, err := c.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -644,12 +651,15 @@ func (c *CoHereModel) ParseFile(modelName *string, content []byte, url *string, } func (c *CoHereModel) ListModels(apiConfig *APIConfig) ([]string, error) { - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + if err := c.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - url := fmt.Sprintf("%s/%s", c.BaseURL[region], c.URLSuffix.Models) + resolvedBaseURL, err := c.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", resolvedBaseURL, c.baseModel.URLSuffix.Models) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -660,11 +670,9 @@ func (c *CoHereModel) ListModels(apiConfig *APIConfig) ([]string, error) { } req.Header.Set("accept", "application/json") - if apiConfig != nil && apiConfig.ApiKey != nil { - req.Header.Set("Authorization", fmt.Sprintf("bearer %s", strings.TrimSpace(*apiConfig.ApiKey))) - } + req.Header.Set("Authorization", fmt.Sprintf("bearer %s", strings.TrimSpace(*apiConfig.ApiKey))) - resp, err := c.httpClient.Do(req) + resp, err := c.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/cometapi.go b/internal/entity/models/cometapi.go index 36fa682fc7..1d27127455 100644 --- a/internal/entity/models/cometapi.go +++ b/internal/entity/models/cometapi.go @@ -34,28 +34,11 @@ import ( ) // CometAPIModel implements ModelDriver for CometAPI AI. -// -// CometAPI exposes OpenAI-compatible chat and embeddings under -// https://api.cometapi.com/v1, a public model catalog under -// https://api.cometapi.com/api/models, and account quota data through the -// separate query service at https://query.cometapi.com/user/quota. type CometAPIModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } // NewCometAPIModel creates a new CometAPI model instance. -// -// We clone http.DefaultTransport so we keep Go's defaults for -// ProxyFromEnvironment, DialContext (with KeepAlive), HTTP/2, -// TLSHandshakeTimeout, and ExpectContinueTimeout, and only override -// the connection-pool fields we care about. -// -// The Client itself has no Timeout. http.Client.Timeout would also -// cap the time spent reading the response body, which would cut off -// long-lived SSE streams in ChatStreamlyWithSender. Non-streaming -// callers wrap each request with context.WithTimeout instead. func NewCometAPIModel(baseURL map[string]string, urlSuffix URLSuffix) *CometAPIModel { transport := http.DefaultTransport.(*http.Transport).Clone() transport.MaxIdleConns = 100 @@ -65,29 +48,24 @@ func NewCometAPIModel(baseURL map[string]string, urlSuffix URLSuffix) *CometAPIM transport.ResponseHeaderTimeout = 60 * time.Second return &CometAPIModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (c *CometAPIModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewCometAPIModel(baseURL, c.URLSuffix) + return NewCometAPIModel(baseURL, c.baseModel.URLSuffix) } func (c *CometAPIModel) Name() string { return "cometapi" } -func validateCometAPIAPIKey(apiConfig *APIConfig) (string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return "", fmt.Errorf("api key is required") - } - return *apiConfig.ApiKey, nil -} - func validateCometAPIModelName(modelName string) error { if strings.TrimSpace(modelName) == "" { return fmt.Errorf("model name is required") @@ -102,28 +80,17 @@ func cometapiRegion(apiConfig *APIConfig) string { return "default" } -// baseURLForRegion returns the base URL for the given region, or an -// error if no entry exists. This makes a misconfigured region fail -// fast with a clear message, instead of silently producing a relative -// URL that the HTTP transport then rejects. -func (c *CometAPIModel) baseURLForRegion(region string) (string, error) { - base, ok := c.BaseURL[region] - if !ok || base == "" { - return "", fmt.Errorf("cometapi: no base URL configured for region %q", region) - } - return strings.TrimRight(base, "/"), nil -} - func (c *CometAPIModel) endpointURL(region, suffix string) (string, error) { - baseURL, err := c.baseURLForRegion(region) + baseURL, err := c.baseModel.GetBaseURL(&APIConfig{Region: ®ion}) if err != nil { return "", err } + baseURL = strings.TrimSuffix(baseURL, "/") return fmt.Sprintf("%s/%s", baseURL, strings.TrimLeft(suffix, "/")), nil } func (c *CometAPIModel) balanceURL(apiKey string) string { - rawURL := strings.TrimSpace(c.URLSuffix.Balance) + rawURL := strings.TrimSpace(c.baseModel.URLSuffix.Balance) if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") { rawURL = fmt.Sprintf("https://query.cometapi.com/%s", strings.TrimLeft(rawURL, "/")) } @@ -199,7 +166,7 @@ type cometapiHTTPResponse struct { } func (c *CometAPIModel) doCometAPIRequest(req *http.Request) (*cometapiHTTPResponse, error) { - resp, err := c.httpClient.Do(req) + resp, err := c.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -294,10 +261,10 @@ func parseCometAPIModelCatalog(body []byte) ([]string, error) { // ChatWithMessages sends multiple messages with roles and returns the response. func (c *CometAPIModel) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { - apiKey, err := validateCometAPIAPIKey(apiConfig) - if err != nil { + if err := c.baseModel.APIConfigCheck(apiConfig); err != nil { return nil, err } + apiKey := *apiConfig.ApiKey if err := validateCometAPIModelName(modelName); err != nil { return nil, err } @@ -306,14 +273,11 @@ func (c *CometAPIModel) ChatWithMessages(modelName string, messages []Message, a return nil, fmt.Errorf("messages is empty") } - url, err := c.endpointURL(cometapiRegion(apiConfig), c.URLSuffix.Chat) + url, err := c.endpointURL(cometapiRegion(apiConfig), c.baseModel.URLSuffix.Chat) if err != nil { return nil, err } - // Note: do NOT propagate chatModelConfig.Stream into the request body - // here. ChatWithMessages parses a single JSON response, so stream must - // always be off for this code path. reqBody := buildCometAPIChatRequest(modelName, messages, false, chatModelConfig) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) @@ -334,10 +298,12 @@ func (c *CometAPIModel) ChatWithMessages(modelName string, messages []Message, a return parseCometAPIChatResponse(resp.Body) } -// ChatStreamlyWithSender sends messages and streams the response via the -// sender function. The CometAPI SSE stream uses the same shape as OpenAI: -// "data:" lines carrying JSON events, with a final "[DONE]" line. +// ChatStreamlyWithSender sends messages and streams the response func (c *CometAPIModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := c.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if sender == nil { return fmt.Errorf("sender is required") } @@ -350,12 +316,9 @@ func (c *CometAPIModel) ChatStreamlyWithSender(modelName string, messages []Mess return fmt.Errorf("messages is empty") } - apiKey, err := validateCometAPIAPIKey(apiConfig) - if err != nil { - return err - } + apiKey := *apiConfig.ApiKey - url, err := c.endpointURL(cometapiRegion(apiConfig), c.URLSuffix.Chat) + url, err := c.endpointURL(cometapiRegion(apiConfig), c.baseModel.URLSuffix.Chat) if err != nil { return err } @@ -371,14 +334,11 @@ func (c *CometAPIModel) ChatStreamlyWithSender(modelName string, messages []Mess } reqBody := buildCometAPIChatRequest(modelName, messages, true, chatModelConfig) - // Use an explicit background context. SSE streams are long-lived - // so we do not attach a hard deadline here; the transport's - // ResponseHeaderTimeout caps the connection-establishment phase. req, err := newCometAPIJSONRequest(context.Background(), "POST", url, reqBody, apiKey) if err != nil { return err } - resp, err := c.httpClient.Do(req) + resp, err := c.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -389,8 +349,6 @@ func (c *CometAPIModel) ChatStreamlyWithSender(modelName string, messages []Mess return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) } - // SSE parsing: bump the scanner buffer from the 64KB default to 1MB - // so we never silently truncate a long data: line. scanner := bufio.NewScanner(resp.Body) scanner.Buffer(make([]byte, 64*1024), 1024*1024) sawTerminal := false @@ -464,24 +422,23 @@ type cometapiEmbeddingRequest struct { Dimensions int `json:"dimensions,omitempty"` } -// Embed turns a list of texts into embedding vectors using the -// CometAPI /v1/embeddings endpoint. The output has one vector per input, -// in the same order the inputs were given. +// Embed turns a list of texts into embedding vectors func (c *CometAPIModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { + if err := c.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(texts) == 0 { return []EmbeddingData{}, nil } - apiKey, err := validateCometAPIAPIKey(apiConfig) - if err != nil { - return nil, err - } + apiKey := *apiConfig.ApiKey if modelName == nil || strings.TrimSpace(*modelName) == "" { return nil, fmt.Errorf("model name is required") } - url, err := c.endpointURL(cometapiRegion(apiConfig), c.URLSuffix.Embedding) + url, err := c.endpointURL(cometapiRegion(apiConfig), c.baseModel.URLSuffix.Embedding) if err != nil { return nil, err } @@ -516,10 +473,6 @@ func (c *CometAPIModel) Embed(modelName *string, texts []string, apiConfig *APIC return nil, fmt.Errorf("failed to parse response: %w", err) } - // Reorder the returned vectors by their reported index so the output - // always lines up with the input texts, even if the upstream API ever - // returns items out of order. A nil slot at the end indicates the - // upstream did not return an embedding for that input. embeddings := make([]EmbeddingData, len(texts)) filled := make([]bool, len(texts)) for _, item := range parsed.Data { @@ -527,9 +480,6 @@ func (c *CometAPIModel) Embed(modelName *string, texts []string, apiConfig *APIC return nil, fmt.Errorf("cometapi: response index %d out of range for %d inputs", item.Index, len(texts)) } if filled[item.Index] { - // A malformed response that repeats the same index would - // silently overwrite the earlier vector. Fail loudly so - // the caller never uses ambiguous output. return nil, fmt.Errorf("cometapi: duplicate embedding index %d in response", item.Index) } embeddings[item.Index] = EmbeddingData{ @@ -549,7 +499,7 @@ func (c *CometAPIModel) Embed(modelName *string, texts []string, apiConfig *APIC // ListModels returns the public CometAPI model catalog. func (c *CometAPIModel) ListModels(apiConfig *APIConfig) ([]string, error) { - url, err := c.endpointURL(cometapiRegion(apiConfig), c.URLSuffix.Models) + url, err := c.endpointURL(cometapiRegion(apiConfig), c.baseModel.URLSuffix.Models) if err != nil { return nil, err } @@ -573,13 +523,12 @@ func (c *CometAPIModel) ListModels(apiConfig *APIConfig) ([]string, error) { return parseCometAPIModelCatalog(resp.Body) } -// Balance queries CometAPI's quota service. Unlike model requests, this -// endpoint authenticates with the key query parameter on query.cometapi.coc. +// Balance queries CometAPI's quota service. func (c *CometAPIModel) Balance(apiConfig *APIConfig) (map[string]interface{}, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := c.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - if strings.TrimSpace(c.URLSuffix.Balance) == "" { + if strings.TrimSpace(c.baseModel.URLSuffix.Balance) == "" { return nil, fmt.Errorf("balance URL is required") } @@ -617,24 +566,26 @@ func (c *CometAPIModel) CheckConnection(apiConfig *APIConfig) error { return nil } -// Rerank calculates similarity scores between query and documents. CometAPI -// does not expose a public rerank API, so this returns "no such method". +// Rerank calculates similarity scores between query and documents. func (c *CometAPIModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { return nil, fmt.Errorf("no such method") } // TranscribeAudio transcribe audio func (c *CometAPIModel) TranscribeAudio(modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig) (*ASRResponse, error) { + if err := c.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if file == nil || *file == "" { return nil, fmt.Errorf("file is missing") } - region := "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := c.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", c.BaseURL[region], c.URLSuffix.ASR) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, c.baseModel.URLSuffix.ASR) // multipart body var body bytes.Buffer @@ -707,7 +658,7 @@ func (c *CometAPIModel) TranscribeAudio(modelName *string, file *string, apiConf req.Header.Set("Accept", "application/json") // send request - resp, err := c.httpClient.Do(req) + resp, err := c.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -740,16 +691,19 @@ func (c *CometAPIModel) TranscribeAudioWithSender(modelName *string, file *strin // AudioSpeech synthesizes speech audio from text. func (c *CometAPIModel) AudioSpeech(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig) (*TTSResponse, error) { + if err := c.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if audioContent == nil || *audioContent == "" { return nil, fmt.Errorf("audio content is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := c.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", c.BaseURL[region], c.URLSuffix.TTS) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, c.baseModel.URLSuffix.TTS) reqBody := map[string]interface{}{ "model": *modelName, @@ -778,7 +732,7 @@ func (c *CometAPIModel) AudioSpeech(modelName *string, audioContent *string, api req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := c.httpClient.Do(req) + resp, err := c.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/cometapi_test.go b/internal/entity/models/cometapi_test.go index 34cfe9c6ce..64d1b311ff 100644 --- a/internal/entity/models/cometapi_test.go +++ b/internal/entity/models/cometapi_test.go @@ -335,9 +335,9 @@ func TestCometAPIBaseURLNormalizesSlashes(t *testing.T) { defer srv.Close() m := newCometAPIForTest(srv.URL + "/") - m.URLSuffix.Chat = "/v1/chat/completions" - m.URLSuffix.Models = "/api/models" - m.URLSuffix.Embedding = "/v1/embeddings" + m.baseModel.URLSuffix.Chat = "/v1/chat/completions" + m.baseModel.URLSuffix.Models = "/api/models" + m.baseModel.URLSuffix.Embedding = "/v1/embeddings" apiKey := "test-key" if err := tt.run(m, &APIConfig{ApiKey: &apiKey}); err != nil { t.Fatalf("%s: %v", tt.name, err) @@ -487,12 +487,12 @@ func TestCometAPICheckConnectionDelegatesToBalance(t *testing.T) { apiKey := "test-key" mOK := newCometAPIForTest(okSrv.URL) - mOK.URLSuffix.Balance = okSrv.URL + "/user/quota" + mOK.baseModel.URLSuffix.Balance = okSrv.URL + "/user/quota" if err := mOK.CheckConnection(&APIConfig{ApiKey: &apiKey}); err != nil { t.Errorf("CheckConnection(ok): %v", err) } mFail := newCometAPIForTest(failSrv.URL) - mFail.URLSuffix.Balance = failSrv.URL + "/user/quota" + mFail.baseModel.URLSuffix.Balance = failSrv.URL + "/user/quota" if err := mFail.CheckConnection(&APIConfig{ApiKey: &apiKey}); err == nil { t.Error("CheckConnection(fail): expected error, got nil") } @@ -519,7 +519,7 @@ func TestCometAPIBalanceHappyPath(t *testing.T) { defer srv.Close() m := newCometAPIForTest("http://unused") - m.URLSuffix.Balance = srv.URL + "/user/quota" + m.baseModel.URLSuffix.Balance = srv.URL + "/user/quota" apiKey := "test-key" balance, err := m.Balance(&APIConfig{ApiKey: &apiKey}) if err != nil { @@ -540,7 +540,7 @@ func TestCometAPIBalanceRequiresAPIKey(t *testing.T) { func TestCometAPIBalanceRequiresConfiguredURL(t *testing.T) { m := newCometAPIForTest("http://unused") - m.URLSuffix.Balance = "" + m.baseModel.URLSuffix.Balance = "" apiKey := "test-key" _, err := m.Balance(&APIConfig{ApiKey: &apiKey}) if err == nil || !strings.Contains(err.Error(), "balance URL is required") { diff --git a/internal/entity/models/deepinfra.go b/internal/entity/models/deepinfra.go index 9ff3f51b61..23475c8cf2 100644 --- a/internal/entity/models/deepinfra.go +++ b/internal/entity/models/deepinfra.go @@ -36,39 +36,28 @@ import ( ) type DeepInfraModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } func NewDeepInfraModel(baseURL map[string]string, urlSuffix URLSuffix) *DeepInfraModel { return &DeepInfraModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 10, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: time.Second * 90, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 10, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: time.Second * 90, + DisableCompression: false, + }, }, }, } } func (d *DeepInfraModel) NewInstance(baseURL map[string]string) ModelDriver { - return &DeepInfraModel{ - BaseURL: baseURL, - URLSuffix: d.URLSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 10, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: time.Second * 90, - DisableCompression: false, - }, - }, - } + return NewDeepInfraModel(baseURL, d.baseModel.URLSuffix) } func (d *DeepInfraModel) Name() string { @@ -76,16 +65,19 @@ func (d *DeepInfraModel) Name() string { } func (d *DeepInfraModel) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { + if err := d.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := d.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", d.BaseURL[region], d.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, d.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -153,7 +145,7 @@ func (d *DeepInfraModel) ChatWithMessages(modelName string, messages []Message, req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := d.httpClient.Do(req) + resp, err := d.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -206,16 +198,19 @@ func (d *DeepInfraModel) ChatWithMessages(modelName string, messages []Message, } func (d *DeepInfraModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, modelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := d.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if len(messages) == 0 { return fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := d.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - url := fmt.Sprintf("%s/%s", d.BaseURL[region], d.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, d.baseModel.URLSuffix.Chat) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -290,7 +285,7 @@ func (d *DeepInfraModel) ChatStreamlyWithSender(modelName string, messages []Mes req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := d.httpClient.Do(req) + resp, err := d.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -372,16 +367,19 @@ func (d *DeepInfraModel) ChatStreamlyWithSender(modelName string, messages []Mes } func (d *DeepInfraModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { + if err := d.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(texts) == 0 { return []EmbeddingData{}, fmt.Errorf("texts is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := d.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", d.BaseURL[region], d.URLSuffix.Embedding) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, d.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": *modelName, @@ -408,7 +406,7 @@ func (d *DeepInfraModel) Embed(modelName *string, texts []string, apiConfig *API req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := d.httpClient.Do(req) + resp, err := d.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -434,7 +432,6 @@ func (d *DeepInfraModel) Embed(modelName *string, texts []string, apiConfig *API return nil, fmt.Errorf("failed to unmarshal response: %w", err) } - // 组装 RAGFlow 需要的返回格式 var embeddings []EmbeddingData for _, data := range parsed.Data { embeddings = append(embeddings, EmbeddingData{ @@ -452,37 +449,26 @@ type deepinfraRerankResponse struct { } // Rerank scores documents against a query using DeepInfra's inference endpoint. -// The model id is part of the URL path (e.g. Qwen/Qwen3-Reranker-4B). The API -// returns one score per input document; RerankConfig.TopN is enforced client-side -// by keeping the highest-scoring entries when TopN is less than len(documents). -func (d *DeepInfraModel) Rerank( - modelName *string, - query string, - documents []string, - apiConfig *APIConfig, - rerankConfig *RerankConfig, -) (*RerankResponse, error) { +func (d *DeepInfraModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { + if err := d.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(documents) == 0 { return &RerankResponse{}, nil } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") - } if modelName == nil || strings.TrimSpace(*modelName) == "" { return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - baseURL := d.BaseURL[region] - if baseURL == "" { - return nil, fmt.Errorf("deepinfra: no base URL configured for region %q", region) + resolvedBaseURL, err := d.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } + baseURL := resolvedBaseURL // Reranker model ids may contain slashes (e.g. Qwen/Qwen3-Reranker-4B). - url := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(baseURL, "/"), d.URLSuffix.Rerank, *modelName) + url := fmt.Sprintf("%s/%s/%s", strings.TrimSuffix(baseURL, "/"), d.baseModel.URLSuffix.Rerank, *modelName) reqBody := map[string]interface{}{ "query": query, @@ -504,7 +490,7 @@ func (d *DeepInfraModel) Rerank( req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := d.httpClient.Do(req) + resp, err := d.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -553,8 +539,8 @@ func (d *DeepInfraModel) Rerank( } func (d *DeepInfraModel) TranscribeAudio(modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig) (*ASRResponse, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("DeepInfra API key is missing") + if err := d.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if file == nil || *file == "" { @@ -565,12 +551,11 @@ func (d *DeepInfraModel) TranscribeAudio(modelName *string, file *string, apiCon return nil, fmt.Errorf("model name is missing") } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := d.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", d.BaseURL[region], d.URLSuffix.ASR) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, d.baseModel.URLSuffix.ASR) var body bytes.Buffer writer := multipart.NewWriter(&body) @@ -639,7 +624,7 @@ func (d *DeepInfraModel) TranscribeAudio(modelName *string, file *string, apiCon req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Accept", "application/json") - resp, err := d.httpClient.Do(req) + resp, err := d.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -673,19 +658,14 @@ func (d *DeepInfraModel) TranscribeAudioWithSender(modelName *string, file *stri } func (d *DeepInfraModel) AudioSpeech(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig) (*TTSResponse, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("DeepInfra API key is missing") + if err := d.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if audioContent == nil || *audioContent == "" { return nil, fmt.Errorf("text content is missing") } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - reqBody := map[string]interface{}{ "text": *audioContent, } @@ -710,7 +690,11 @@ func (d *DeepInfraModel) AudioSpeech(modelName *string, audioContent *string, ap } // URL: https://api.deepinfra.com/v1/text-to-speech/{voice_id} - url := fmt.Sprintf("%s/%s/%s", d.BaseURL[region], d.URLSuffix.TTS, voiceID) + resolvedBaseURL, err := d.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s/%s", resolvedBaseURL, d.baseModel.URLSuffix.TTS, voiceID) if ttsConfig != nil && ttsConfig.Format != "" { reqBody["output_format"] = ttsConfig.Format @@ -732,7 +716,7 @@ func (d *DeepInfraModel) AudioSpeech(modelName *string, audioContent *string, ap req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := d.httpClient.Do(req) + resp, err := d.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -751,19 +735,14 @@ func (d *DeepInfraModel) AudioSpeech(modelName *string, audioContent *string, ap } func (d *DeepInfraModel) AudioSpeechWithSender(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig, sender func(*string, *string) error) error { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return fmt.Errorf("DeepInfra API key is missing") + if err := d.baseModel.APIConfigCheck(apiConfig); err != nil { + return err } if audioContent == nil || *audioContent == "" { return fmt.Errorf("text content is missing") } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - voiceID := "" reqBody := map[string]interface{}{ @@ -789,7 +768,11 @@ func (d *DeepInfraModel) AudioSpeechWithSender(modelName *string, audioContent * } // URL: https://api.deepinfra.com/v1/text-to-speech/{voice_id}/stream - url := fmt.Sprintf("%s/%s/%s/stream", d.BaseURL[region], d.URLSuffix.TTS, voiceID) + resolvedBaseURL, err := d.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err + } + url := fmt.Sprintf("%s/%s/%s/stream", resolvedBaseURL, d.baseModel.URLSuffix.TTS, voiceID) if ttsConfig != nil && ttsConfig.Format != "" { reqBody["output_format"] = ttsConfig.Format @@ -811,7 +794,7 @@ func (d *DeepInfraModel) AudioSpeechWithSender(modelName *string, audioContent * req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := d.httpClient.Do(req) + resp, err := d.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -858,12 +841,12 @@ func (d *DeepInfraModel) ParseFile(modelName *string, content []byte, url *strin } func (d *DeepInfraModel) ListModels(apiConfig *APIConfig) ([]string, error) { - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - url := fmt.Sprintf("%s/%s", d.BaseURL[region], d.URLSuffix.Models) + resolvedBaseURL, err := d.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", resolvedBaseURL, d.baseModel.URLSuffix.Models) reqBody := map[string]interface{}{} @@ -882,7 +865,7 @@ func (d *DeepInfraModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Content-Type", "application/json") - resp, err := d.httpClient.Do(req) + resp, err := d.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -916,16 +899,15 @@ func (d *DeepInfraModel) ListModels(apiConfig *APIConfig) ([]string, error) { } func (d *DeepInfraModel) Balance(apiConfig *APIConfig) (map[string]interface{}, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := d.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + baseURL, err := d.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", d.BaseURL[region], d.URLSuffix.Balance) + url := fmt.Sprintf("%s/%s", baseURL, d.baseModel.URLSuffix.Balance) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -938,7 +920,7 @@ func (d *DeepInfraModel) Balance(apiConfig *APIConfig) (map[string]interface{}, req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := d.httpClient.Do(req) + resp, err := d.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/deepseek.go b/internal/entity/models/deepseek.go index 6844e91650..6faa05d274 100644 --- a/internal/entity/models/deepseek.go +++ b/internal/entity/models/deepseek.go @@ -32,29 +32,29 @@ import ( // DeepSeekModel implements ModelDriver for DeepSeek type DeepSeekModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client // Reusable HTTP client with connection pool + baseModel BaseModel } // NewDeepSeekModel creates a new DeepSeek model instance func NewDeepSeekModel(baseURL map[string]string, urlSuffix URLSuffix) *DeepSeekModel { return &DeepSeekModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + DisableCompression: false, + }, }, }, } } func (d *DeepSeekModel) NewInstance(baseURL map[string]string) ModelDriver { - return nil + return NewDeepSeekModel(baseURL, d.baseModel.URLSuffix) } func (d *DeepSeekModel) Name() string { @@ -62,16 +62,19 @@ func (d *DeepSeekModel) Name() string { } func (d *DeepSeekModel) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { + if err := d.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := d.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", d.BaseURL[region], d.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, d.baseModel.URLSuffix.Chat) // Convert messages to the format expected by API apiMessages := make([]map[string]interface{}, len(messages)) @@ -166,11 +169,9 @@ func (d *DeepSeekModel) ChatWithMessages(modelName string, messages []Message, a } req.Header.Set("Content-Type", "application/json") - if apiConfig != nil && apiConfig.ApiKey != nil { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := d.httpClient.Do(req) + resp, err := d.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -233,16 +234,19 @@ func (d *DeepSeekModel) ChatWithMessages(modelName string, messages []Message, a // ChatStreamlyWithSender sends messages and streams response via sender function (best performance, no channel) func (d *DeepSeekModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := d.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if len(messages) == 0 { return fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := d.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - url := fmt.Sprintf("%s/chat/completions", d.BaseURL[region]) + url := fmt.Sprintf("%s/chat/completions", resolvedBaseURL) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -341,7 +345,7 @@ func (d *DeepSeekModel) ChatStreamlyWithSender(modelName string, messages []Mess req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := d.httpClient.Do(req) + resp, err := d.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -439,12 +443,15 @@ type DSModelList struct { } func (d *DeepSeekModel) ListModels(apiConfig *APIConfig) ([]string, error) { - var region = "default" - if apiConfig.Region != nil { - region = *apiConfig.Region + if err := d.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - url := fmt.Sprintf("%s/%s", d.BaseURL[region], d.URLSuffix.Models) + resolvedBaseURL, err := d.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", resolvedBaseURL, d.baseModel.URLSuffix.Models) // Build request body reqBody := map[string]interface{}{} @@ -465,7 +472,7 @@ func (d *DeepSeekModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := d.httpClient.Do(req) + resp, err := d.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -512,30 +519,16 @@ type deepseekBalanceResponse struct { // The result map matches the shape used by the Moonshot driver, // so the UI can render it without provider-specific code. func (d *DeepSeekModel) Balance(apiConfig *APIConfig) (map[string]interface{}, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := d.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + baseURL, err := d.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - // Look up the base URL for the requested region. If the region was - // supplied but is not configured (or is empty), fall back to the - // "default" region instead of erroring out, so a stray region value - // does not break an otherwise valid request. - baseURL := d.BaseURL["default"] - if region != "default" { - if regional, ok := d.BaseURL[region]; ok && regional != "" { - baseURL = regional - } - } - if baseURL == "" { - return nil, fmt.Errorf("deepseek: no base URL configured for default region") - } - - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), d.URLSuffix.Balance) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), d.baseModel.URLSuffix.Balance) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -547,7 +540,7 @@ func (d *DeepSeekModel) Balance(apiConfig *APIConfig) (map[string]interface{}, e req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := d.httpClient.Do(req) + resp, err := d.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/dummy.go b/internal/entity/models/dummy.go index 0469ab38c6..a80c816461 100644 --- a/internal/entity/models/dummy.go +++ b/internal/entity/models/dummy.go @@ -22,20 +22,21 @@ import ( // DummyModel implements ModelDriver for Dummy AI type DummyModel struct { - BaseURL map[string]string - URLSuffix URLSuffix + baseModel BaseModel } // NewDummyModel creates a new Dummy AI model instance func NewDummyModel(baseURL map[string]string, urlSuffix URLSuffix) *DummyModel { return &DummyModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + }, } } func (d *DummyModel) NewInstance(baseURL map[string]string) ModelDriver { - return nil + return NewDummyModel(baseURL, d.baseModel.URLSuffix) } func (d *DummyModel) Name() string { diff --git a/internal/entity/models/fishaudio.go b/internal/entity/models/fishaudio.go index cee2fd2e10..4677a5cabc 100644 --- a/internal/entity/models/fishaudio.go +++ b/internal/entity/models/fishaudio.go @@ -32,28 +32,22 @@ import ( "strings" ) -// 208cc2d0e4594ca896a600c43c9497aa - type FishAudioModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } func NewFishAudioModel(baseURL map[string]string, urlSuffix URLSuffix) *FishAudioModel { return &FishAudioModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{}, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{}, + }, } } func (f *FishAudioModel) NewInstance(baseURL map[string]string) ModelDriver { - return &FishAudioModel{ - BaseURL: baseURL, - URLSuffix: f.URLSuffix, - httpClient: &http.Client{}, - } + return NewFishAudioModel(baseURL, f.baseModel.URLSuffix) } func (f *FishAudioModel) Name() string { @@ -78,20 +72,19 @@ func (f *FishAudioModel) Rerank(modelName *string, query string, documents []str // TranscribeAudio transcribe audio func (f *FishAudioModel) TranscribeAudio(modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig) (*ASRResponse, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("FishAudio API key is missing") + if err := f.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if file == nil || *file == "" { return nil, fmt.Errorf("file is missing") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := f.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", f.BaseURL[region], f.URLSuffix.ASR) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, f.baseModel.URLSuffix.ASR) var body bytes.Buffer writer := multipart.NewWriter(&body) @@ -153,7 +146,7 @@ func (f *FishAudioModel) TranscribeAudio(modelName *string, file *string, apiCon req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) req.Header.Set("Content-Type", writer.FormDataContentType()) - resp, err := f.httpClient.Do(req) + resp, err := f.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -188,20 +181,19 @@ func (f *FishAudioModel) TranscribeAudioWithSender(modelName *string, file *stri // AudioSpeech convert text to audio func (f *FishAudioModel) AudioSpeech(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig) (*TTSResponse, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("FishAudio API key is missing") + if err := f.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if audioContent == nil || *audioContent == "" { return nil, fmt.Errorf("text content is missing") } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := f.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", f.BaseURL[region], f.URLSuffix.TTS) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, f.baseModel.URLSuffix.TTS) reqBody := map[string]interface{}{ "text": *audioContent, @@ -233,7 +225,7 @@ func (f *FishAudioModel) AudioSpeech(modelName *string, audioContent *string, ap req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) req.Header.Set("model", *modelName) - resp, err := f.httpClient.Do(req) + resp, err := f.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -252,20 +244,19 @@ func (f *FishAudioModel) AudioSpeech(modelName *string, audioContent *string, ap } func (f *FishAudioModel) AudioSpeechWithSender(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig, sender func(*string, *string) error) error { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return fmt.Errorf("FishAudio API key is missing") + if err := f.baseModel.APIConfigCheck(apiConfig); err != nil { + return err } if audioContent == nil || *audioContent == "" { return fmt.Errorf("text content is missing") } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := f.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - url := fmt.Sprintf("%s/%s/%s", f.BaseURL[region], f.URLSuffix.TTS, "stream/with-timestamp") + url := fmt.Sprintf("%s/%s/%s", resolvedBaseURL, f.baseModel.URLSuffix.TTS, "stream/with-timestamp") reqBody := map[string]interface{}{ "text": *audioContent, @@ -298,7 +289,7 @@ func (f *FishAudioModel) AudioSpeechWithSender(modelName *string, audioContent * req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) req.Header.Set("model", *modelName) - resp, err := f.httpClient.Do(req) + resp, err := f.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -362,12 +353,15 @@ func (f *FishAudioModel) ParseFile(modelName *string, content []byte, url *strin } func (f *FishAudioModel) ListModels(apiConfig *APIConfig) ([]string, error) { - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + if err := f.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - url := fmt.Sprintf("%s/%s", f.BaseURL[region], f.URLSuffix.Models) + resolvedBaseURL, err := f.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", resolvedBaseURL, f.baseModel.URLSuffix.Models) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -377,13 +371,9 @@ func (f *FishAudioModel) ListModels(apiConfig *APIConfig) ([]string, error) { return nil, fmt.Errorf("failed to create request: %w", err) } - if apiConfig != nil && apiConfig.ApiKey != nil && *apiConfig.ApiKey != "" { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - } else { - return nil, fmt.Errorf("Fish Audio API key is missing") - } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := f.httpClient.Do(req) + resp, err := f.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -418,14 +408,13 @@ func (f *FishAudioModel) ListModels(apiConfig *APIConfig) ([]string, error) { } func (f *FishAudioModel) Balance(apiConfig *APIConfig) (map[string]interface{}, error) { - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + if err := f.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - baseURL := f.BaseURL[region] - if baseURL == "" { - baseURL = f.BaseURL["default"] + baseURL, err := f.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } url := fmt.Sprintf("%s/wallet/self/api-credit", strings.TrimSuffix(baseURL, "/")) @@ -440,7 +429,7 @@ func (f *FishAudioModel) Balance(apiConfig *APIConfig) (map[string]interface{}, req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := f.httpClient.Do(req) + resp, err := f.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/futurmix.go b/internal/entity/models/futurmix.go index 3416f81673..4ffaaa52b7 100644 --- a/internal/entity/models/futurmix.go +++ b/internal/entity/models/futurmix.go @@ -29,43 +29,11 @@ import ( ) // FuturMixModel implements ModelDriver for FuturMix -// (https://futurmix.ai/docs). -// -// FuturMix advertises itself as an "OpenAI-compatible API" aggregator -// (Claude, GPT, Gemini, DeepSeek, and others, ~22 models per their -// /models page) reachable at https://futurmix.ai. The public docs -// confirm three /v1 endpoints exist: /v1/chat/completions -// (OpenAI-compatible), /v1/messages (Anthropic-format), and -// /v1/responses (OpenAI Responses API). This driver implements only -// the OpenAI-compatible chat surface — the same path the FuturMix -// admin UI uses as its canonical example endpoint URL. The -// Anthropic-format and Responses-format surfaces require different -// request/response shapes than the ModelDriver interface currently -// models and are deferred to a follow-up. -// -// Per the maintainer's guidance on -// https://github.com/infiniflow/ragflow/pull/14809#pullrequestreview-4277917390 -// ("there is no need to implement the interface that is not -// officially given"), endpoints FuturMix does not explicitly document -// (embeddings, rerank, audio, OCR, models list, balance) all return -// the standard `", no such method"` sentinel rather than guess. type FuturMixModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } // NewFuturMixModel creates a new FuturMix model instance. -// -// We clone http.DefaultTransport so we keep Go's defaults for -// ProxyFromEnvironment, DialContext (with KeepAlive), HTTP/2, -// TLSHandshakeTimeout, and ExpectContinueTimeout, and only override -// the connection-pool fields we care about. -// -// The Client itself has no Timeout. http.Client.Timeout would also -// cap the time spent reading the response body, which would cut off -// long-lived SSE streams in ChatStreamlyWithSender. Non-streaming -// callers wrap each request with context.WithTimeout instead. func NewFuturMixModel(baseURL map[string]string, urlSuffix URLSuffix) *FuturMixModel { transport := http.DefaultTransport.(*http.Transport).Clone() transport.MaxIdleConns = 100 @@ -75,38 +43,30 @@ func NewFuturMixModel(baseURL map[string]string, urlSuffix URLSuffix) *FuturMixM transport.ResponseHeaderTimeout = 60 * time.Second return &FuturMixModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (f *FuturMixModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewFuturMixModel(baseURL, f.URLSuffix) + return NewFuturMixModel(baseURL, f.baseModel.URLSuffix) } func (f *FuturMixModel) Name() string { return "futurmix" } -// baseURLForRegion returns the base URL for the given region, trimmed -// of any trailing slash so callers can append a suffix without -// producing "//" in the path. -func (f *FuturMixModel) baseURLForRegion(region string) (string, error) { - base, ok := f.BaseURL[region] - if !ok || base == "" { - return "", fmt.Errorf("futurmix: no base URL configured for region %q", region) - } - return strings.TrimRight(base, "/"), nil -} - func (f *FuturMixModel) endpointURL(region, suffix string) (string, error) { - baseURL, err := f.baseURLForRegion(region) + baseURL, err := f.baseModel.GetBaseURL(&APIConfig{Region: ®ion}) if err != nil { return "", err } + baseURL = strings.TrimSuffix(baseURL, "/") return fmt.Sprintf("%s/%s", baseURL, strings.TrimLeft(suffix, "/")), nil } @@ -117,13 +77,6 @@ func futurmixRegion(apiConfig *APIConfig) string { return "default" } -func futurmixValidateAPIKey(apiConfig *APIConfig) (string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return "", fmt.Errorf("api key is required") - } - return *apiConfig.ApiKey, nil -} - func newFuturMixJSONRequest(ctx context.Context, method, endpoint string, payload interface{}, apiKey string) (*http.Request, error) { var body io.Reader if payload != nil { @@ -202,15 +155,12 @@ type futurmixChatResponse struct { Choices []futurmixChatChoice `json:"choices"` } -// ChatWithMessages sends a non-streaming chat completion against -// FuturMix's /v1/chat/completions endpoint. Wire shape follows the -// OpenAI Chat Completions contract since FuturMix is documented as -// "OpenAI-compatible". +// ChatWithMessages sends a non-streaming chat completion func (f *FuturMixModel) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { - apiKey, err := futurmixValidateAPIKey(apiConfig) - if err != nil { + if err := f.baseModel.APIConfigCheck(apiConfig); err != nil { return nil, err } + apiKey := *apiConfig.ApiKey if strings.TrimSpace(modelName) == "" { return nil, fmt.Errorf("model name is required") } @@ -218,14 +168,11 @@ func (f *FuturMixModel) ChatWithMessages(modelName string, messages []Message, a return nil, fmt.Errorf("messages is empty") } - endpoint, err := f.endpointURL(futurmixRegion(apiConfig), f.URLSuffix.Chat) + endpoint, err := f.endpointURL(futurmixRegion(apiConfig), f.baseModel.URLSuffix.Chat) if err != nil { return nil, err } - // Force stream=false here; ChatWithMessages reads a single JSON - // response body, so a streaming SSE response would be parsed as - // truncated JSON and produce a confusing error. reqBody := buildFuturMixChatRequest(modelName, messages, false, chatModelConfig) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) @@ -236,7 +183,7 @@ func (f *FuturMixModel) ChatWithMessages(modelName string, messages []Message, a return nil, err } - resp, err := f.httpClient.Do(req) + resp, err := f.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -270,17 +217,12 @@ func (f *FuturMixModel) ChatWithMessages(modelName string, messages []Message, a }, nil } -// ChatStreamlyWithSender sends a streaming chat completion. The -// FuturMix SSE stream is assumed to use the standard OpenAI shape: -// "data:" lines carrying JSON events with delta.content (and -// delta.reasoning_content for reasoning-capable models routed -// through FuturMix's aggregator), terminated by a "[DONE]" line. -// Without live testing access this is taken on faith from the -// "OpenAI-compatible API" marketing language; if a future test -// reveals divergence (e.g. routed Claude responses surfacing in -// /v1/messages-style chunks) the SSE event parser is where to -// intervene. +// ChatStreamlyWithSender sends a streaming chat completion func (f *FuturMixModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := f.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if sender == nil { return fmt.Errorf("sender is required") } @@ -290,35 +232,25 @@ func (f *FuturMixModel) ChatStreamlyWithSender(modelName string, messages []Mess if len(messages) == 0 { return fmt.Errorf("messages is empty") } - apiKey, err := futurmixValidateAPIKey(apiConfig) - if err != nil { - return err - } + apiKey := *apiConfig.ApiKey - endpoint, err := f.endpointURL(futurmixRegion(apiConfig), f.URLSuffix.Chat) + endpoint, err := f.endpointURL(futurmixRegion(apiConfig), f.baseModel.URLSuffix.Chat) if err != nil { return err } if chatModelConfig != nil && chatModelConfig.Stream != nil && !*chatModelConfig.Stream { - // Caller explicitly asked for stream=false. The body of this - // method only knows how to read SSE, so a non-SSE JSON - // response would be parsed as if it were a stream and produce - // no chunks. Fail clearly. return fmt.Errorf("stream must be true in ChatStreamlyWithSender") } reqBody := buildFuturMixChatRequest(modelName, messages, true, chatModelConfig) - // SSE streams are long-lived; rely on the transport's - // ResponseHeaderTimeout to cap the connection-establishment phase - // instead of attaching a hard deadline here. req, err := newFuturMixJSONRequest(context.Background(), "POST", endpoint, reqBody, apiKey) if err != nil { return err } - resp, err := f.httpClient.Do(req) + resp, err := f.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -329,19 +261,10 @@ func (f *FuturMixModel) ChatStreamlyWithSender(modelName string, messages []Mess return fmt.Errorf("futurmix chat stream API error: %s, body: %s", resp.Status, string(body)) } - // Bump the scanner buffer from the 64KB default to 1MB so we - // never silently truncate a long data: line. scanner := bufio.NewScanner(resp.Body) scanner.Buffer(make([]byte, 64*1024), 1024*1024) sawTerminal := false - // SSE allows a single event to span multiple `data:` lines that - // the consumer must join with newlines (separator), then parse - // the result as one payload — see the HTML Living Standard - // "Server-sent events" section. A blank line terminates the - // event. The previous implementation parsed each `data:` line as - // a standalone JSON document, which broke streaming whenever the - // upstream emitted a wrapped event (multi-line JSON or a deltas - // payload too wide for the upstream's single-line buffer). + var dataLines []string dispatchEvent := func() (bool, error) { if len(dataLines) == 0 { @@ -356,9 +279,6 @@ func (f *FuturMixModel) ChatStreamlyWithSender(modelName string, messages []Mess var event futurmixChatResponse if err := json.Unmarshal([]byte(payload), &event); err != nil { - // A malformed frame can mean a truncated SSE event or an - // upstream incident; the caller is better served by a - // hard failure than by silent partial output. return false, fmt.Errorf("futurmix: invalid SSE event: %w", err) } if len(event.Choices) == 0 { @@ -399,22 +319,14 @@ func (f *FuturMixModel) ChatStreamlyWithSender(modelName string, messages []Mess continue } if strings.HasPrefix(line, "data:") { - // Trim only the single optional space after the colon — any - // further leading whitespace is part of the payload (the SSE - // spec strips at most one space after the field name). + value := line[5:] if strings.HasPrefix(value, " ") { value = value[1:] } dataLines = append(dataLines, value) } - // All other field lines (event:, id:, retry:, comments) are - // intentionally ignored — only `data:` carries the payload - // the OpenAI-compatible /v1/chat/completions stream uses. } - // Streams that end without a trailing blank line still leave a - // pending event in the buffer; flush it so we don't drop the - // final delta on partially-conforming upstreams. if !sawTerminal { if _, err := dispatchEvent(); err != nil { return err @@ -446,17 +358,11 @@ func (f *FuturMixModel) Rerank(modelName *string, query string, documents []stri } // ListModels is not documented as a public endpoint by FuturMix. -// The shipped catalog in conf/models/futurmix.json is the source of -// truth for which models RAGFlow knows about; this method does not -// invent a fake live listing. func (f *FuturMixModel) ListModels(apiConfig *APIConfig) ([]string, error) { return nil, fmt.Errorf("%s, no such method", f.Name()) } -// CheckConnection is not exposed by the FuturMix API. With no -// documented /models or /health endpoint, the only way to verify -// credentials would be to burn a real chat completion against -// tenant quota — return the documented sentinel rather than pretend. +// CheckConnection is not exposed by the FuturMix API. func (f *FuturMixModel) CheckConnection(apiConfig *APIConfig) error { return fmt.Errorf("%s, no such method", f.Name()) } diff --git a/internal/entity/models/gitee.go b/internal/entity/models/gitee.go index 8b6a97cfd7..2c2cfc6962 100644 --- a/internal/entity/models/gitee.go +++ b/internal/entity/models/gitee.go @@ -32,29 +32,29 @@ import ( // GiteeModel implements ModelDriver for Gitee type GiteeModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } // NewGiteeModel creates a new Gitee model instance func NewGiteeModel(baseURL map[string]string, urlSuffix URLSuffix) *GiteeModel { return &GiteeModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + DisableCompression: false, + }, }, }, } } func (g *GiteeModel) NewInstance(baseURL map[string]string) ModelDriver { - return nil + return NewGiteeModel(baseURL, g.baseModel.URLSuffix) } func (g *GiteeModel) Name() string { @@ -63,19 +63,19 @@ func (g *GiteeModel) Name() string { // ChatWithMessages sends multiple messages with roles and returns response func (g *GiteeModel) 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 nil or empty") + if err := g.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := g.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - url := fmt.Sprintf("%s/%s", g.BaseURL[region], g.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, g.baseModel.URLSuffix.Chat) // Convert messages to the format expected by API apiMessages := make([]map[string]interface{}, len(messages)) @@ -147,7 +147,7 @@ func (g *GiteeModel) ChatWithMessages(modelName string, messages []Message, apiC req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := g.httpClient.Do(req) + resp, err := g.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -217,16 +217,19 @@ func (g *GiteeModel) ChatWithMessages(modelName string, messages []Message, apiC // ChatStreamlyWithSender sends messages and streams response via sender function (best performance, no channel) func (g *GiteeModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := g.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if len(messages) == 0 { return fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := g.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - url := fmt.Sprintf("%s/chat/completions", g.BaseURL[region]) + url := fmt.Sprintf("%s/chat/completions", resolvedBaseURL) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -297,7 +300,7 @@ func (g *GiteeModel) ChatStreamlyWithSender(modelName string, messages []Message req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := g.httpClient.Do(req) + resp, err := g.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -425,34 +428,25 @@ type giteeUsage struct { // Embed embeds a list of texts into embeddings func (g *GiteeModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { - if len(texts) == 0 { - return []EmbeddingData{}, nil + if err := g.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if len(texts) == 0 { + return []EmbeddingData{}, nil } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := g.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } + baseURL := resolvedBaseURL - baseURL := g.BaseURL["default"] - if region != "default" { - if regional, ok := g.BaseURL[region]; ok && regional != "" { - baseURL = regional - } - } - if baseURL == "" { - return nil, fmt.Errorf("gitee: no base URL configured for default region") - } - - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), g.URLSuffix.Embedding) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), g.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": *modelName, @@ -478,7 +472,7 @@ func (g *GiteeModel) Embed(modelName *string, texts []string, apiConfig *APIConf req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := g.httpClient.Do(req) + resp, err := g.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -519,34 +513,25 @@ type giteeRerankRequest struct { // Rerank calculates similarity scores between query and documents func (g *GiteeModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { - if len(documents) == 0 { - return &RerankResponse{}, nil + if err := g.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if len(documents) == 0 { + return &RerankResponse{}, nil } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := g.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } + baseURL := resolvedBaseURL - baseURL := g.BaseURL["default"] - if region != "default" { - if regional, ok := g.BaseURL[region]; ok && regional != "" { - baseURL = regional - } - } - if baseURL == "" { - return nil, fmt.Errorf("gitee: no base URL configured for default region") - } - - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), g.URLSuffix.Rerank) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), g.baseModel.URLSuffix.Rerank) var topN = rerankConfig.TopN if rerankConfig.TopN == 0 { @@ -577,7 +562,7 @@ func (g *GiteeModel) Rerank(modelName *string, query string, documents []string, req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := g.httpClient.Do(req) + resp, err := g.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -625,34 +610,25 @@ type giteeOCRResponse struct { // OCRFile OCR file func (g *GiteeModel) OCRFile(modelName *string, content []byte, imageURL *string, apiConfig *APIConfig, ocrConfig *OCRConfig) (*OCRFileResponse, error) { - if imageURL == nil && content == nil { - return nil, fmt.Errorf("url or content is required") + if err := g.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if imageURL == nil && content == nil { + return nil, fmt.Errorf("url or content is required") } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := g.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } + baseURL := resolvedBaseURL - baseURL := g.BaseURL["default"] - if region != "default" { - if regional, ok := g.BaseURL[region]; ok && regional != "" { - baseURL = regional - } - } - if baseURL == "" { - return nil, fmt.Errorf("gitee: no base URL configured for default region") - } - - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), g.URLSuffix.OCR) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), g.baseModel.URLSuffix.OCR) payload := &bytes.Buffer{} writer := multipart.NewWriter(payload) @@ -690,7 +666,7 @@ func (g *GiteeModel) OCRFile(modelName *string, content []byte, imageURL *string req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := g.httpClient.Do(req) + resp, err := g.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -731,34 +707,25 @@ type giteeURLs struct { // ParseFile parse file func (g *GiteeModel) ParseFile(modelName *string, content []byte, documentURL *string, apiConfig *APIConfig, parseFileConfig *ParseFileConfig) (*ParseFileResponse, error) { - if documentURL == nil && content == nil { - return nil, fmt.Errorf("url or content is required") + if err := g.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if documentURL == nil && content == nil { + return nil, fmt.Errorf("url or content is required") } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := g.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } + baseURL := resolvedBaseURL - baseURL := g.BaseURL["default"] - if region != "default" { - if regional, ok := g.BaseURL[region]; ok && regional != "" { - baseURL = regional - } - } - if baseURL == "" { - return nil, fmt.Errorf("gitee: no base URL configured for default region") - } - - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), g.URLSuffix.DocumentParse) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), g.baseModel.URLSuffix.DocumentParse) payload := &bytes.Buffer{} writer := multipart.NewWriter(payload) @@ -796,7 +763,7 @@ func (g *GiteeModel) ParseFile(modelName *string, content []byte, documentURL *s req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := g.httpClient.Do(req) + resp, err := g.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -851,7 +818,7 @@ func (g *GiteeModel) getParseFile(baseURL *string, apiKey, taskID *string, timeO req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiKey)) - resp, err := g.httpClient.Do(req) + resp, err := g.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -886,12 +853,12 @@ func (g *GiteeModel) getParseFile(baseURL *string, apiKey, taskID *string, timeO } func (g *GiteeModel) ListModels(apiConfig *APIConfig) ([]string, error) { - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - url := fmt.Sprintf("%s/%s", g.BaseURL[region], g.URLSuffix.Models) + resolvedBaseURL, err := g.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", resolvedBaseURL, g.baseModel.URLSuffix.Models) // Build request body reqBody := map[string]interface{}{} @@ -911,7 +878,7 @@ func (g *GiteeModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Content-Type", "application/json") - resp, err := g.httpClient.Do(req) + resp, err := g.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -945,21 +912,15 @@ func (g *GiteeModel) ListModels(apiConfig *APIConfig) ([]string, error) { } func (g *GiteeModel) Balance(apiConfig *APIConfig) (map[string]interface{}, error) { - - var baseURL = "" - if apiConfig.BaseURL != nil && *apiConfig.BaseURL != "" { - baseURL = *apiConfig.BaseURL + if err := g.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + baseURL, err := g.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - if baseURL == "" { - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - baseURL = g.BaseURL[region] - } - - url := fmt.Sprintf("%s/%s", baseURL, g.URLSuffix.Balance) + url := fmt.Sprintf("%s/%s", baseURL, g.baseModel.URLSuffix.Balance) // Build request body reqBody := map[string]interface{}{} @@ -980,7 +941,7 @@ func (g *GiteeModel) Balance(apiConfig *APIConfig) (map[string]interface{}, erro req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := g.httpClient.Do(req) + resp, err := g.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -1012,12 +973,15 @@ func (g *GiteeModel) Balance(apiConfig *APIConfig) (map[string]interface{}, erro } func (g *GiteeModel) CheckConnection(apiConfig *APIConfig) error { - var region = "default" - if apiConfig.Region != nil { - region = *apiConfig.Region + if err := g.baseModel.APIConfigCheck(apiConfig); err != nil { + return err } - url := fmt.Sprintf("%s/%s", g.BaseURL[region], g.URLSuffix.Status) + resolvedBaseURL, err := g.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err + } + url := fmt.Sprintf("%s/%s", resolvedBaseURL, g.baseModel.URLSuffix.Status) // Build request body reqBody := map[string]interface{}{} @@ -1038,7 +1002,7 @@ func (g *GiteeModel) CheckConnection(apiConfig *APIConfig) error { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := g.httpClient.Do(req) + resp, err := g.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -1087,12 +1051,15 @@ type giteeTaskURLs struct { } func (g *GiteeModel) ListTasks(apiConfig *APIConfig) ([]ListTaskStatus, error) { - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + if err := g.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - url := fmt.Sprintf("%s/%s", g.BaseURL[region], g.URLSuffix.Tasks) + resolvedBaseURL, err := g.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", resolvedBaseURL, g.baseModel.URLSuffix.Tasks) // Build request body reqBody := map[string]interface{}{} @@ -1113,7 +1080,7 @@ func (g *GiteeModel) ListTasks(apiConfig *APIConfig) ([]ListTaskStatus, error) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := g.httpClient.Do(req) + resp, err := g.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -1145,12 +1112,15 @@ func (g *GiteeModel) ListTasks(apiConfig *APIConfig) ([]ListTaskStatus, error) { } func (g *GiteeModel) ShowTask(taskID string, apiConfig *APIConfig) (*TaskResponse, error) { - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + if err := g.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - url := fmt.Sprintf("%s/%s/%s/get", g.BaseURL[region], g.URLSuffix.Task, taskID) + resolvedBaseURL, err := g.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s/%s/get", resolvedBaseURL, g.baseModel.URLSuffix.Task, taskID) // Build request body reqBody := map[string]interface{}{} @@ -1171,7 +1141,7 @@ func (g *GiteeModel) ShowTask(taskID string, apiConfig *APIConfig) (*TaskRespons req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := g.httpClient.Do(req) + resp, err := g.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/google.go b/internal/entity/models/google.go index e8187c6441..df4e2cc8de 100644 --- a/internal/entity/models/google.go +++ b/internal/entity/models/google.go @@ -70,20 +70,21 @@ var googleListModels = func(ctx context.Context, config *genai.ClientConfig) ([] // GoogleModel implements ModelDriver for Google AI type GoogleModel struct { - BaseURL map[string]string - URLSuffix URLSuffix + baseModel BaseModel } // NewGoogleModel creates a new Google AI model instance func NewGoogleModel(baseURL map[string]string, urlSuffix URLSuffix) *GoogleModel { return &GoogleModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + }, } } func (g *GoogleModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewGoogleModel(baseURL, g.URLSuffix) + return NewGoogleModel(baseURL, g.baseModel.URLSuffix) } func (g *GoogleModel) Name() string { @@ -95,17 +96,23 @@ func (g *GoogleModel) clientConfig(apiKey string, apiConfig *APIConfig) *genai.C } func (g *GoogleModel) baseURL(apiConfig *APIConfig) string { - if apiConfig != nil && apiConfig.Region != nil { - if baseURL := strings.TrimSpace(g.BaseURL[*apiConfig.Region]); baseURL != "" { - return baseURL + baseURL, err := g.baseModel.GetBaseURL(apiConfig) + if err != nil { + defaultConfig := &APIConfig{} + if apiConfig != nil { + defaultConfig.BaseURL = apiConfig.BaseURL + } + baseURL, err = g.baseModel.GetBaseURL(defaultConfig) + if err != nil { + return "" } } - return strings.TrimSpace(g.BaseURL["default"]) + return strings.TrimSpace(baseURL) } func (g *GoogleModel) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || strings.TrimSpace(*apiConfig.ApiKey) == "" { - return nil, fmt.Errorf("api key is nil or empty") + if err := g.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if strings.TrimSpace(modelName) == "" { return nil, fmt.Errorf("model name is empty") @@ -178,12 +185,13 @@ func (g *GoogleModel) ChatWithMessages(modelName string, messages []Message, api // ChatStreamlyWithSender sends messages and streams response via sender function (best performance, no channel) func (g *GoogleModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := g.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if len(messages) == 0 { return fmt.Errorf("messages is empty") } - if apiConfig == nil || apiConfig.ApiKey == nil || strings.TrimSpace(*apiConfig.ApiKey) == "" { - return fmt.Errorf("api key is nil or empty") - } if strings.TrimSpace(modelName) == "" { return fmt.Errorf("model name is empty") } @@ -278,8 +286,8 @@ func (g *GoogleModel) ChatStreamlyWithSender(modelName string, messages []Messag // Embed generates embeddings for a batch of texts using the Gemini embeddings API. // The SDK routes to batchEmbedContents internally, so all texts are sent in one request. func (g *GoogleModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || strings.TrimSpace(*apiConfig.ApiKey) == "" { - return nil, fmt.Errorf("api key is required") + if err := g.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") @@ -332,8 +340,8 @@ func (g *GoogleModel) Embed(modelName *string, texts []string, apiConfig *APICon } func (g *GoogleModel) ListModels(apiConfig *APIConfig) ([]string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || strings.TrimSpace(*apiConfig.ApiKey) == "" { - return nil, fmt.Errorf("api key is required") + if err := g.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } return googleListModels(context.Background(), g.clientConfig(strings.TrimSpace(*apiConfig.ApiKey), apiConfig)) diff --git a/internal/entity/models/google_test.go b/internal/entity/models/google_test.go index c9fe8bac9b..576484757c 100644 --- a/internal/entity/models/google_test.go +++ b/internal/entity/models/google_test.go @@ -266,11 +266,11 @@ func TestGoogleModelNewInstancePreservesCustomBaseURL(t *testing.T) { if !ok { t.Fatalf("expected *GoogleModel, got %T", instance) } - if google.BaseURL["default"] != customBaseURL["default"] { - t.Fatalf("expected base URL %q, got %q", customBaseURL["default"], google.BaseURL["default"]) + if google.baseModel.BaseURL["default"] != customBaseURL["default"] { + t.Fatalf("expected base URL %q, got %q", customBaseURL["default"], google.baseModel.BaseURL["default"]) } - if google.URLSuffix != model.URLSuffix { - t.Fatalf("expected URL suffix %v, got %v", model.URLSuffix, google.URLSuffix) + if google.baseModel.URLSuffix != model.baseModel.URLSuffix { + t.Fatalf("expected URL suffix %v, got %v", model.baseModel.URLSuffix, google.baseModel.URLSuffix) } } diff --git a/internal/entity/models/gpustack.go b/internal/entity/models/gpustack.go index 31f8468db9..a62ad4d182 100644 --- a/internal/entity/models/gpustack.go +++ b/internal/entity/models/gpustack.go @@ -29,19 +29,8 @@ import ( ) // GPUStackModel implements ModelDriver for GPUStack -// (https://docs.gpustack.ai/), a self-hosted OpenAI-compatible model -// serving layer. The base URL is supplied by the tenant at instance -// creation time, matching the LM Studio / vLLM / Ollama pattern. -// -// Chat is served at /v1/chat/completions with the standard -// OpenAI wire shape and Bearer auth (the GPUStack server always -// requires an API key; see rag/llm/chat_model.py GPUStack route). -// Chat uses /v1 (see #13236). Embeddings use the v1-openai route per GPUStack -// API docs and maintainer review (v1-openai/embeddings, not v1/embeddings). type GPUStackModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } func NewGPUStackModel(baseURL map[string]string, urlSuffix URLSuffix) *GPUStackModel { @@ -53,38 +42,28 @@ func NewGPUStackModel(baseURL map[string]string, urlSuffix URLSuffix) *GPUStackM transport.ResponseHeaderTimeout = 60 * time.Second return &GPUStackModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (g *GPUStackModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewGPUStackModel(baseURL, g.URLSuffix) + return NewGPUStackModel(baseURL, g.baseModel.URLSuffix) } func (g *GPUStackModel) Name() string { return "gpustack" } -// baseURLForRegion returns the base URL for the given region. GPUStack -// is self-hosted: the base URL is supplied by the tenant per instance, -// so a missing entry is a configuration error rather than something -// we can paper over with a default host. -func (g *GPUStackModel) baseURLForRegion(region string) (string, error) { - base, ok := g.BaseURL[region] - if !ok || base == "" { - return "", fmt.Errorf("gpustack: no base URL configured for region %q (the tenant must supply the GPUStack server URL when adding the instance)", region) - } - return strings.TrimSuffix(base, "/"), nil -} - // ChatWithMessages sends multiple messages and returns the response. func (g *GPUStackModel) 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 err := g.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if strings.TrimSpace(modelName) == "" { return nil, fmt.Errorf("model name is required") @@ -93,16 +72,12 @@ func (g *GPUStackModel) ChatWithMessages(modelName string, messages []Message, a return nil, fmt.Errorf("messages is empty") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := g.baseURLForRegion(region) + baseURL, err := g.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, g.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, g.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -148,7 +123,7 @@ func (g *GPUStackModel) ChatWithMessages(modelName string, messages []Message, a req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := g.httpClient.Do(req) + resp, err := g.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -195,17 +170,15 @@ func (g *GPUStackModel) ChatWithMessages(modelName string, messages []Message, a }, nil } -// ChatStreamlyWithSender streams the response via the sender. GPUStack -// proxies through OpenAI-compatible upstream engines (vLLM, SGLang) -// that emit standard "data:" SSE frames with delta.content (and -// delta.reasoning_content for thinking models), terminated by [DONE]. +// ChatStreamlyWithSender streams the response via the sender. func (g *GPUStackModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := g.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if sender == nil { return fmt.Errorf("sender is required") } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return fmt.Errorf("api key is required") - } if strings.TrimSpace(modelName) == "" { return fmt.Errorf("model name is required") } @@ -213,16 +186,12 @@ func (g *GPUStackModel) ChatStreamlyWithSender(modelName string, messages []Mess return fmt.Errorf("messages is empty") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := g.baseURLForRegion(region) + baseURL, err := g.baseModel.GetBaseURL(apiConfig) if err != nil { return err } - url := fmt.Sprintf("%s/%s", baseURL, g.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, g.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -269,7 +238,7 @@ func (g *GPUStackModel) ChatStreamlyWithSender(modelName string, messages []Mess req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) req.Header.Set("Accept", "text/event-stream") - resp, err := g.httpClient.Do(req) + resp, err := g.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -361,24 +330,17 @@ type gpustackModelsResponse struct { Data []gpustackModelInfo `json:"data"` } -// ListModels returns the catalog served by the GPUStack instance at -// /v1/models. Note: GPUStack's /v1-openai alias does NOT cover -// /models — only /v1/models works. func (g *GPUStackModel) ListModels(apiConfig *APIConfig) ([]string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := g.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := g.baseURLForRegion(region) + baseURL, err := g.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, g.URLSuffix.Models) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, g.baseModel.URLSuffix.Models) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -390,7 +352,7 @@ func (g *GPUStackModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := g.httpClient.Do(req) + resp, err := g.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -441,28 +403,26 @@ func (g *GPUStackModel) Embed( apiConfig *APIConfig, embeddingConfig *EmbeddingConfig, ) ([]EmbeddingData, error) { + if err := g.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(texts) == 0 { return []EmbeddingData{}, nil } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") - } if modelName == nil || strings.TrimSpace(*modelName) == "" { return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - baseURL, err := g.baseURLForRegion(region) + baseURL, err := g.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - if g.URLSuffix.Embedding == "" { + baseURL = strings.TrimSuffix(baseURL, "/") + if g.baseModel.URLSuffix.Embedding == "" { return nil, fmt.Errorf("gpustack: embedding URL suffix is not configured") } - url := fmt.Sprintf("%s/%s", baseURL, strings.TrimPrefix(g.URLSuffix.Embedding, "/")) + url := fmt.Sprintf("%s/%s", baseURL, strings.TrimPrefix(g.baseModel.URLSuffix.Embedding, "/")) reqBody := map[string]interface{}{ "model": *modelName, @@ -487,7 +447,7 @@ func (g *GPUStackModel) Embed( req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := g.httpClient.Do(req) + resp, err := g.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/groq.go b/internal/entity/models/groq.go index 50c106ad1c..00851c4fee 100644 --- a/internal/entity/models/groq.go +++ b/internal/entity/models/groq.go @@ -33,14 +33,8 @@ import ( ) // GroqModel implements ModelDriver for Groq. -// -// Groq exposes an OpenAI-compatible chat-completions endpoint at -// https://api.groq.com/openai/v1/chat/completions and lists models at -// https://api.groq.com/openai/v1/models. type GroqModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } func NewGroqModel(baseURL map[string]string, urlSuffix URLSuffix) *GroqModel { @@ -60,45 +54,30 @@ func NewGroqModel(baseURL map[string]string, urlSuffix URLSuffix) *GroqModel { transport.ResponseHeaderTimeout = 60 * time.Second return &GroqModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (g *GroqModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewGroqModel(baseURL, g.URLSuffix) + return NewGroqModel(baseURL, g.baseModel.URLSuffix) } func (g *GroqModel) Name() string { return "groq" } -func (g *GroqModel) baseURLForRegion(region string) (string, error) { - base, ok := g.BaseURL[region] - if ok && base != "" { - return strings.TrimSuffix(base, "/"), nil - } - if region == "" { - if base, ok := g.BaseURL["default"]; ok && base != "" { - return strings.TrimSuffix(base, "/"), nil - } - } - return "", fmt.Errorf("groq: no base URL configured for region %q", region) -} - func (g *GroqModel) endpoint(apiConfig *APIConfig, suffix string) (string, error) { - region := "default" - if apiConfig != nil && apiConfig.Region != nil { - region = *apiConfig.Region - } - - baseURL, err := g.baseURLForRegion(region) + baseURL, err := g.baseModel.GetBaseURL(apiConfig) if err != nil { return "", err } + baseURL = strings.TrimSuffix(baseURL, "/") return fmt.Sprintf("%s/%s", baseURL, strings.TrimPrefix(suffix, "/")), nil } @@ -165,8 +144,8 @@ type groqChatResponse struct { } func (g *GroqModel) 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 err := g.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if strings.TrimSpace(modelName) == "" { return nil, fmt.Errorf("model name is required") @@ -175,7 +154,7 @@ func (g *GroqModel) ChatWithMessages(modelName string, messages []Message, apiCo return nil, fmt.Errorf("messages is empty") } - url, err := g.endpoint(apiConfig, g.URLSuffix.Chat) + url, err := g.endpoint(apiConfig, g.baseModel.URLSuffix.Chat) if err != nil { return nil, err } @@ -195,7 +174,7 @@ func (g *GroqModel) ChatWithMessages(modelName string, messages []Message, apiCo req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := g.httpClient.Do(req) + resp, err := g.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -233,12 +212,13 @@ func (g *GroqModel) ChatWithMessages(modelName string, messages []Message, apiCo } func (g *GroqModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := g.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if sender == nil { return fmt.Errorf("sender is required") } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return fmt.Errorf("api key is required") - } if strings.TrimSpace(modelName) == "" { return fmt.Errorf("model name is required") } @@ -249,7 +229,7 @@ func (g *GroqModel) ChatStreamlyWithSender(modelName string, messages []Message, return fmt.Errorf("stream must be true in ChatStreamlyWithSender") } - url, err := g.endpoint(apiConfig, g.URLSuffix.Chat) + url, err := g.endpoint(apiConfig, g.baseModel.URLSuffix.Chat) if err != nil { return err } @@ -270,7 +250,7 @@ func (g *GroqModel) ChatStreamlyWithSender(modelName string, messages []Message, req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) req.Header.Set("Accept", "text/event-stream") - resp, err := g.httpClient.Do(req) + resp, err := g.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -348,11 +328,11 @@ type groqListModelsResponse struct { } func (g *GroqModel) ListModels(apiConfig *APIConfig) ([]string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := g.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - url, err := g.endpoint(apiConfig, g.URLSuffix.Models) + url, err := g.endpoint(apiConfig, g.baseModel.URLSuffix.Models) if err != nil { return nil, err } @@ -367,7 +347,7 @@ func (g *GroqModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := g.httpClient.Do(req) + resp, err := g.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -416,16 +396,19 @@ func (g *GroqModel) Balance(apiConfig *APIConfig) (map[string]interface{}, error } func (g *GroqModel) TranscribeAudio(modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig) (*ASRResponse, error) { + if err := g.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if file == nil || *file == "" { return nil, fmt.Errorf("file is missing") } - region := "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := g.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", g.BaseURL[region], g.URLSuffix.ASR) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, g.baseModel.URLSuffix.ASR) // multipart body var body bytes.Buffer @@ -500,7 +483,7 @@ func (g *GroqModel) TranscribeAudio(modelName *string, file *string, apiConfig * req.Header.Set("Content-Type", writer.FormDataContentType()) // send request - resp, err := g.httpClient.Do(req) + resp, err := g.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -532,16 +515,19 @@ func (g *GroqModel) TranscribeAudioWithSender(modelName *string, file *string, a } func (g *GroqModel) AudioSpeech(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig) (*TTSResponse, error) { + if err := g.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if audioContent == nil || *audioContent == "" { return nil, fmt.Errorf("audio content is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := g.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", g.BaseURL[region], g.URLSuffix.TTS) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, g.baseModel.URLSuffix.TTS) reqBody := map[string]interface{}{ "model": *modelName, @@ -570,7 +556,7 @@ func (g *GroqModel) AudioSpeech(modelName *string, audioContent *string, apiConf req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := g.httpClient.Do(req) + resp, err := g.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/groq_test.go b/internal/entity/models/groq_test.go index e5658bc6b6..984bd95b08 100644 --- a/internal/entity/models/groq_test.go +++ b/internal/entity/models/groq_test.go @@ -73,7 +73,7 @@ func TestNewGroqModelHandlesCustomDefaultTransport(t *testing.T) { }) model := NewGroqModel(map[string]string{"default": "http://unused"}, URLSuffix{}) - if model == nil || model.httpClient == nil || model.httpClient.Transport == nil { + if model == nil || model.baseModel.httpClient == nil || model.baseModel.httpClient.Transport == nil { t.Fatalf("NewGroqModel returned incomplete model: %#v", model) } } diff --git a/internal/entity/models/huaweicloud.go b/internal/entity/models/huaweicloud.go index 63052428a6..d57af46c5a 100644 --- a/internal/entity/models/huaweicloud.go +++ b/internal/entity/models/huaweicloud.go @@ -30,42 +30,34 @@ import ( ) type HuaweiCloudModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } func NewHuaweiCloudModel(baseURL map[string]string, urlSuffix URLSuffix) *HuaweiCloudModel { return &HuaweiCloudModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 10, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 10, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: 90 * time.Second, + DisableCompression: false, + }, }, }, } } func (h *HuaweiCloudModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewHuaweiCloudModel(baseURL, h.URLSuffix) + return NewHuaweiCloudModel(baseURL, h.baseModel.URLSuffix) } func (h *HuaweiCloudModel) Name() string { return "huaweicloud" } -func (h *HuaweiCloudModel) baseURLForRegion(region string) (string, error) { - base, ok := h.BaseURL[region] - if !ok || base == "" { - return "", fmt.Errorf("huaweicloud: no base URL configured for region %q", region) - } - return strings.TrimSuffix(base, "/"), nil -} - func huaweiCloudRegion(api *APIConfig) string { region := "default" if api != nil && api.Region != nil && *api.Region != "" { @@ -135,10 +127,10 @@ func huaweiCloudAuthorization(apiKey string) string { } func (h *HuaweiCloudModel) chatURL(baseURL, modelName string) string { - suffix := h.URLSuffix.Chat + suffix := h.baseModel.URLSuffix.Chat if huaweiCloudUsesV1Chat(modelName) { - if h.URLSuffix.AsyncChat != "" { - suffix = h.URLSuffix.AsyncChat + if h.baseModel.URLSuffix.AsyncChat != "" { + suffix = h.baseModel.URLSuffix.AsyncChat } else { suffix = "v1/chat/completions" } @@ -191,8 +183,8 @@ func huaweiCloudApplyChatConfig(req map[string]any, modelName string, chatModelC } func (h *HuaweiCloudModel) 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 err := h.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if modelName == "" { return nil, fmt.Errorf("model name is required") @@ -201,10 +193,16 @@ func (h *HuaweiCloudModel) ChatWithMessages(modelName string, messages []Message return nil, fmt.Errorf("messages is empty") } - baseURL, err := h.baseURLForRegion(huaweiCloudRegionForModel(apiConfig, modelName)) + baseURLRegion := huaweiCloudRegionForModel(apiConfig, modelName) + baseURLConfig := &APIConfig{Region: &baseURLRegion} + if apiConfig != nil { + baseURLConfig.BaseURL = apiConfig.BaseURL + } + baseURL, err := h.baseModel.GetBaseURL(baseURLConfig) if err != nil { return nil, err } + baseURL = strings.TrimSuffix(baseURL, "/") url := h.chatURL(baseURL, modelName) @@ -230,7 +228,7 @@ func (h *HuaweiCloudModel) ChatWithMessages(modelName string, messages []Message req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", huaweiCloudAuthorization(*apiConfig.ApiKey)) - rep, err := h.httpClient.Do(req) + rep, err := h.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -283,12 +281,13 @@ func (h *HuaweiCloudModel) ChatWithMessages(modelName string, messages []Message } func (h *HuaweiCloudModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := h.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if sender == nil { return fmt.Errorf("sender is required") } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return fmt.Errorf("api key is required") - } if modelName == "" { return fmt.Errorf("model name is required") } @@ -296,10 +295,16 @@ func (h *HuaweiCloudModel) ChatStreamlyWithSender(modelName string, messages []M return fmt.Errorf("messages is empty") } - baseURL, err := h.baseURLForRegion(huaweiCloudRegionForModel(apiConfig, modelName)) + baseURLRegion := huaweiCloudRegionForModel(apiConfig, modelName) + baseURLConfig := &APIConfig{Region: &baseURLRegion} + if apiConfig != nil { + baseURLConfig.BaseURL = apiConfig.BaseURL + } + baseURL, err := h.baseModel.GetBaseURL(baseURLConfig) if err != nil { return err } + baseURL = strings.TrimSuffix(baseURL, "/") url := h.chatURL(baseURL, modelName) reqBody := map[string]interface{}{ @@ -327,7 +332,7 @@ func (h *HuaweiCloudModel) ChatStreamlyWithSender(modelName string, messages []M req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", huaweiCloudAuthorization(*apiConfig.ApiKey)) - resp, err := h.httpClient.Do(req) + resp, err := h.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -412,8 +417,8 @@ type huaweiCloudEmbeddingResponse struct { } func (h *HuaweiCloudModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := h.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") @@ -422,11 +427,17 @@ func (h *HuaweiCloudModel) Embed(modelName *string, texts []string, apiConfig *A return []EmbeddingData{}, nil } - baseURL, err := h.baseURLForRegion(huaweiCloudRegion(apiConfig)) + baseURLRegion := huaweiCloudRegion(apiConfig) + baseURLConfig := &APIConfig{Region: &baseURLRegion} + if apiConfig != nil { + baseURLConfig.BaseURL = apiConfig.BaseURL + } + baseURL, err := h.baseModel.GetBaseURL(baseURLConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, strings.TrimPrefix(h.URLSuffix.Embedding, "/")) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, strings.TrimPrefix(h.baseModel.URLSuffix.Embedding, "/")) reqBody := map[string]interface{}{ "model": *modelName, @@ -449,7 +460,7 @@ func (h *HuaweiCloudModel) Embed(modelName *string, texts []string, apiConfig *A req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", huaweiCloudAuthorization(*apiConfig.ApiKey)) - resp, err := h.httpClient.Do(req) + resp, err := h.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -504,8 +515,8 @@ func (h *HuaweiCloudModel) Embed(modelName *string, texts []string, apiConfig *A } func (h *HuaweiCloudModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := h.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") @@ -517,11 +528,17 @@ func (h *HuaweiCloudModel) Rerank(modelName *string, query string, documents []s return &RerankResponse{}, nil } - baseURL, err := h.baseURLForRegion(huaweiCloudRegion(apiConfig)) + baseURLRegion := huaweiCloudRegion(apiConfig) + baseURLConfig := &APIConfig{Region: &baseURLRegion} + if apiConfig != nil { + baseURLConfig.BaseURL = apiConfig.BaseURL + } + baseURL, err := h.baseModel.GetBaseURL(baseURLConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, strings.TrimPrefix(h.URLSuffix.Rerank, "/")) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, strings.TrimPrefix(h.baseModel.URLSuffix.Rerank, "/")) reqBody := map[string]interface{}{ "model": *modelName, @@ -544,7 +561,7 @@ func (h *HuaweiCloudModel) Rerank(modelName *string, query string, documents []s req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", huaweiCloudAuthorization(*apiConfig.ApiKey)) - resp, err := h.httpClient.Do(req) + resp, err := h.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -615,15 +632,21 @@ func (h *HuaweiCloudModel) ParseFile(modelName *string, content []byte, url *str } func (h *HuaweiCloudModel) ListModels(apiConfig *APIConfig) ([]string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := h.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - baseURL, err := h.baseURLForRegion(huaweiCloudRegion(apiConfig)) + baseURLRegion := huaweiCloudRegion(apiConfig) + baseURLConfig := &APIConfig{Region: &baseURLRegion} + if apiConfig != nil { + baseURLConfig.BaseURL = apiConfig.BaseURL + } + baseURL, err := h.baseModel.GetBaseURL(baseURLConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, strings.TrimPrefix(h.URLSuffix.Models, "/")) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, strings.TrimPrefix(h.baseModel.URLSuffix.Models, "/")) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -634,7 +657,7 @@ func (h *HuaweiCloudModel) ListModels(apiConfig *APIConfig) ([]string, error) { } req.Header.Set("Authorization", huaweiCloudAuthorization(*apiConfig.ApiKey)) - resp, err := h.httpClient.Do(req) + resp, err := h.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/huggingface.go b/internal/entity/models/huggingface.go index 87d80e76bd..beb7dae0de 100644 --- a/internal/entity/models/huggingface.go +++ b/internal/entity/models/huggingface.go @@ -30,25 +30,21 @@ import ( // HuggingFaceModel implements ModelDriver for HuggingFace type HuggingFaceModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } // NewHuggingFaceModel creates a new huggingFace model instance func NewHuggingFaceModel(baseURL map[string]string, urlSuffix URLSuffix) *HuggingFaceModel { return &HuggingFaceModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{}, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{}, + }, } } func (h *HuggingFaceModel) NewInstance(baseURL map[string]string) ModelDriver { - return &HuggingFaceModel{ - BaseURL: baseURL, - URLSuffix: h.URLSuffix, - httpClient: &http.Client{}, - } + return NewHuggingFaceModel(baseURL, h.baseModel.URLSuffix) } func (h *HuggingFaceModel) Name() string { @@ -56,16 +52,19 @@ func (h *HuggingFaceModel) Name() string { } func (h *HuggingFaceModel) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { + if err := h.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := h.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", h.BaseURL[region], h.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, h.baseModel.URLSuffix.Chat) // Convert messages to the format expected by API apiMessages := make([]map[string]interface{}, len(messages)) @@ -132,11 +131,9 @@ func (h *HuggingFaceModel) ChatWithMessages(modelName string, messages []Message } req.Header.Set("Content-Type", "application/json") - if apiConfig != nil && apiConfig.ApiKey != nil { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := h.httpClient.Do(req) + resp, err := h.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -198,16 +195,19 @@ func (h *HuggingFaceModel) ChatWithMessages(modelName string, messages []Message } func (h *HuggingFaceModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, modelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := h.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if len(messages) == 0 { return fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := h.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - url := fmt.Sprintf("%s/%s", h.BaseURL[region], h.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, h.baseModel.URLSuffix.Chat) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -277,7 +277,7 @@ func (h *HuggingFaceModel) ChatStreamlyWithSender(modelName string, messages []M req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := h.httpClient.Do(req) + resp, err := h.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -359,23 +359,18 @@ func (h *HuggingFaceModel) ChatStreamlyWithSender(modelName string, messages []M } func (h *HuggingFaceModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { + if err := h.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(texts) == 0 { return []EmbeddingData{}, nil } - region := "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") - } - reqBody := map[string]interface{}{ "inputs": texts, } @@ -385,7 +380,11 @@ func (h *HuggingFaceModel) Embed(modelName *string, texts []string, apiConfig *A return nil, err } - url := fmt.Sprintf("%s/%s/%s", h.BaseURL[region], h.URLSuffix.Embedding, *modelName) + resolvedBaseURL, err := h.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s/%s", resolvedBaseURL, h.baseModel.URLSuffix.Embedding, *modelName) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -398,7 +397,7 @@ func (h *HuggingFaceModel) Embed(modelName *string, texts []string, apiConfig *A req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := h.httpClient.Do(req) + resp, err := h.baseModel.httpClient.Do(req) if err != nil { return nil, err } @@ -462,12 +461,15 @@ func (h *HuggingFaceModel) ParseFile(modelName *string, content []byte, url *str } func (h *HuggingFaceModel) ListModels(apiConfig *APIConfig) ([]string, error) { - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + if err := h.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - url := fmt.Sprintf("%s/%s", h.BaseURL[region], h.URLSuffix.Models) + resolvedBaseURL, err := h.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", resolvedBaseURL, h.baseModel.URLSuffix.Models) // Build request body reqBody := map[string]interface{}{} @@ -488,7 +490,7 @@ func (h *HuggingFaceModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := h.httpClient.Do(req) + resp, err := h.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/hunyuan.go b/internal/entity/models/hunyuan.go index 87e2ed1a24..cc2abe4702 100644 --- a/internal/entity/models/hunyuan.go +++ b/internal/entity/models/hunyuan.go @@ -29,23 +29,11 @@ import ( ) // HunyuanModel implements ModelDriver for Tencent Hunyuan -// To further enhance the user experience of large-scale model services, relevant features of Tencent Hunyuan Large Model -// will be gradually migrated to TokenHub. Following the migration, the original platform will no longer introduce new model -// capabilities and will cease to support the purchase of new model services. Model services already purchased by users may -// continue to be used and will not be affected for the time being. If you wish to activate new model services or utilise additional -// model capabilities, please visit TokenHub. type HunyuanModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } // 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 @@ -55,52 +43,38 @@ func NewHunyuanModel(baseURL map[string]string, urlSuffix URLSuffix) *HunyuanMod transport.ResponseHeaderTimeout = 60 * time.Second return &HunyuanModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (h *HunyuanModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewHunyuanModel(baseURL, h.URLSuffix) + return NewHunyuanModel(baseURL, h.baseModel.URLSuffix) } func (h *HunyuanModel) Name() string { return "hunyuan" } -func (h *HunyuanModel) baseURLForRegion(region string) (string, error) { - base, ok := h.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 (h *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 err := h.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := h.baseURLForRegion(region) + baseURL, err := h.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, h.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, h.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -146,7 +120,7 @@ func (h *HunyuanModel) ChatWithMessages(modelName string, messages []Message, ap req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := h.httpClient.Do(req) + resp, err := h.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -185,10 +159,6 @@ func (h *HunyuanModel) ChatWithMessages(modelName string, messages []Message, ap 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 @@ -200,31 +170,25 @@ func (h *HunyuanModel) ChatWithMessages(modelName string, messages []Message, ap }, 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. +// ChatStreamlyWithSender opens the SSE chat-completions func (h *HunyuanModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := h.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + 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 := h.baseURLForRegion(region) + baseURL, err := h.baseModel.GetBaseURL(apiConfig) if err != nil { return err } - url := fmt.Sprintf("%s/%s", baseURL, h.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, h.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -241,9 +205,6 @@ func (h *HunyuanModel) ChatStreamlyWithSender(modelName string, messages []Messa } 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") } @@ -266,8 +227,6 @@ func (h *HunyuanModel) ChatStreamlyWithSender(modelName string, messages []Messa 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) @@ -275,7 +234,7 @@ func (h *HunyuanModel) ChatStreamlyWithSender(modelName string, messages []Messa req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := h.httpClient.Do(req) + resp, err := h.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -302,15 +261,9 @@ func (h *HunyuanModel) ChatStreamlyWithSender(modelName string, messages []Messa 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) } @@ -323,10 +276,6 @@ func (h *HunyuanModel) ChatStreamlyWithSender(modelName string, messages []Messa 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 @@ -361,24 +310,17 @@ func (h *HunyuanModel) ChatStreamlyWithSender(modelName string, messages []Messa 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 (h *HunyuanModel) ListModels(apiConfig *APIConfig) ([]string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := h.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := h.baseURLForRegion(region) + baseURL, err := h.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, h.URLSuffix.Models) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, h.baseModel.URLSuffix.Models) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -389,7 +331,7 @@ func (h *HunyuanModel) ListModels(apiConfig *APIConfig) ([]string, error) { } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := h.httpClient.Do(req) + resp, err := h.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -428,35 +370,29 @@ func (h *HunyuanModel) ListModels(apiConfig *APIConfig) ([]string, error) { 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 (h *HunyuanModel) CheckConnection(apiConfig *APIConfig) error { _, err := h.ListModels(apiConfig) return err } func (h *HunyuanModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { + if err := h.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(texts) == 0 { return []EmbeddingData{}, nil } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") - } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := h.baseURLForRegion(region) + baseURL, err := h.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, h.URLSuffix.Embedding) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, h.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": *modelName, @@ -479,7 +415,7 @@ func (h *HunyuanModel) Embed(modelName *string, texts []string, apiConfig *APICo req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := h.httpClient.Do(req) + resp, err := h.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/jiekouai.go b/internal/entity/models/jiekouai.go index 3aedbe2abf..638c46f47c 100644 --- a/internal/entity/models/jiekouai.go +++ b/internal/entity/models/jiekouai.go @@ -28,54 +28,35 @@ import ( ) type JieKouAIModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } func NewJieKouAIModel(baseURL map[string]string, urlSuffix URLSuffix) *JieKouAIModel { return &JieKouAIModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Timeout: time.Second * 120, - Transport: &http.Transport{ - MaxIdleConns: 10, - MaxConnsPerHost: 100, - IdleConnTimeout: time.Second * 90, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Timeout: time.Second * 120, + Transport: &http.Transport{ + MaxIdleConns: 10, + MaxConnsPerHost: 100, + IdleConnTimeout: time.Second * 90, + DisableCompression: false, + }, }, }, } } func (j *JieKouAIModel) NewInstance(baseURL map[string]string) ModelDriver { - return &JieKouAIModel{ - BaseURL: baseURL, - URLSuffix: j.URLSuffix, - httpClient: &http.Client{ - Timeout: time.Second * 120, - Transport: &http.Transport{ - MaxIdleConns: 10, - MaxConnsPerHost: 100, - IdleConnTimeout: time.Second * 90, - DisableCompression: false, - }, - }, - } + return NewJieKouAIModel(baseURL, j.baseModel.URLSuffix) } func (j *JieKouAIModel) Name() string { return "jiekouai" } -func validateJieKouAIAPIKey(apiConfig *APIConfig) (string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || strings.TrimSpace(*apiConfig.ApiKey) == "" { - return "", fmt.Errorf("api key is required") - } - return strings.TrimSpace(*apiConfig.ApiKey), nil -} - func validateJieKouAIModelName(modelName *string) (string, error) { if modelName == nil || strings.TrimSpace(*modelName) == "" { return "", fmt.Errorf("model name is required") @@ -84,10 +65,10 @@ func validateJieKouAIModelName(modelName *string) (string, error) { } func (j *JieKouAIModel) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { - apiKey, err := validateJieKouAIAPIKey(apiConfig) - if err != nil { + if err := j.baseModel.APIConfigCheck(apiConfig); err != nil { return nil, err } + apiKey := strings.TrimSpace(*apiConfig.ApiKey) if modelName = strings.TrimSpace(modelName); modelName == "" { return nil, fmt.Errorf("model name is required") } @@ -95,12 +76,11 @@ func (j *JieKouAIModel) ChatWithMessages(modelName string, messages []Message, a return nil, fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := j.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", j.BaseURL[region], j.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, j.baseModel.URLSuffix.Chat) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -158,7 +138,7 @@ func (j *JieKouAIModel) ChatWithMessages(modelName string, messages []Message, a req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) req.Header.Set("Accept", "application/json") - resp, err := j.httpClient.Do(req) + resp, err := j.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -220,10 +200,10 @@ func (j *JieKouAIModel) ChatWithMessages(modelName string, messages []Message, a } func (j *JieKouAIModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, modelConfig *ChatConfig, sender func(*string, *string) error) error { - apiKey, err := validateJieKouAIAPIKey(apiConfig) - if err != nil { + if err := j.baseModel.APIConfigCheck(apiConfig); err != nil { return err } + apiKey := strings.TrimSpace(*apiConfig.ApiKey) if modelName = strings.TrimSpace(modelName); modelName == "" { return fmt.Errorf("model name is required") } @@ -234,12 +214,11 @@ func (j *JieKouAIModel) ChatStreamlyWithSender(modelName string, messages []Mess return fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := j.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(j.BaseURL[region], "/"), j.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(resolvedBaseURL, "/"), j.baseModel.URLSuffix.Chat) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -300,7 +279,7 @@ func (j *JieKouAIModel) ChatStreamlyWithSender(modelName string, messages []Mess req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) req.Header.Set("Accept", "text/event-stream") - resp, err := j.httpClient.Do(req) + resp, err := j.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -380,6 +359,10 @@ func (j *JieKouAIModel) ChatStreamlyWithSender(modelName string, messages []Mess } func (j *JieKouAIModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { + if err := j.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(texts) == 0 { return []EmbeddingData{}, fmt.Errorf("texts is empty") } @@ -387,17 +370,13 @@ func (j *JieKouAIModel) Embed(modelName *string, texts []string, apiConfig *APIC if err != nil { return nil, err } - apiKey, err := validateJieKouAIAPIKey(apiConfig) + apiKey := strings.TrimSpace(*apiConfig.ApiKey) + + resolvedBaseURL, err := j.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(j.BaseURL[region], "/"), j.URLSuffix.Embedding) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(resolvedBaseURL, "/"), j.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": model, @@ -417,7 +396,7 @@ func (j *JieKouAIModel) Embed(modelName *string, texts []string, apiConfig *APIC req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) - resp, err := j.httpClient.Do(req) + resp, err := j.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -459,6 +438,10 @@ func (j *JieKouAIModel) Embed(modelName *string, texts []string, apiConfig *APIC } func (j *JieKouAIModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { + if err := j.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(documents) == 0 { return &RerankResponse{}, nil } @@ -469,17 +452,13 @@ func (j *JieKouAIModel) Rerank(modelName *string, query string, documents []stri if err != nil { return nil, err } - apiKey, err := validateJieKouAIAPIKey(apiConfig) + apiKey := strings.TrimSpace(*apiConfig.ApiKey) + + resolvedBaseURL, err := j.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(j.BaseURL[region], "/"), j.URLSuffix.Rerank) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(resolvedBaseURL, "/"), j.baseModel.URLSuffix.Rerank) reqBody := map[string]interface{}{ "model": model, @@ -503,7 +482,7 @@ func (j *JieKouAIModel) Rerank(modelName *string, query string, documents []stri req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) - resp, err := j.httpClient.Do(req) + resp, err := j.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -566,16 +545,16 @@ func (j *JieKouAIModel) ParseFile(modelName *string, content []byte, url *string } func (j *JieKouAIModel) ListModels(apiConfig *APIConfig) ([]string, error) { - apiKey, err := validateJieKouAIAPIKey(apiConfig) + if err := j.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + apiKey := strings.TrimSpace(*apiConfig.ApiKey) + + resolvedBaseURL, err := j.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - url := fmt.Sprintf("%s/%s", j.BaseURL[region], j.URLSuffix.Models) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, j.baseModel.URLSuffix.Models) req, err := http.NewRequest("GET", url, nil) if err != nil { @@ -585,7 +564,7 @@ func (j *JieKouAIModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) req.Header.Set("Accept", "application/json") - resp, err := j.httpClient.Do(req) + resp, err := j.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/jina.go b/internal/entity/models/jina.go index 1b117c83d2..759ba85c1c 100644 --- a/internal/entity/models/jina.go +++ b/internal/entity/models/jina.go @@ -23,50 +23,37 @@ import ( "fmt" "io" "net/http" + "strings" "time" ) type JinaModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } func NewJinaModel(baseURL map[string]string, urlSuffix URLSuffix) *JinaModel { return &JinaModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Timeout: time.Second * 90, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Timeout: time.Second * 90, + }, }, } } func (j *JinaModel) NewInstance(baseURL map[string]string) ModelDriver { - return &JinaModel{ - BaseURL: baseURL, - URLSuffix: j.URLSuffix, - httpClient: &http.Client{ - Timeout: time.Second * 90, - }, - } + return NewJinaModel(baseURL, j.baseModel.URLSuffix) } func (j *JinaModel) Name() string { return "jina" } -func (j *JinaModel) baseURLForRegion(region string) (string, error) { - base, ok := j.BaseURL[region] - if !ok || base == "" { - return "", fmt.Errorf("jina: no base URL configured for region %q", region) - } - return base, nil -} - func (j *JinaModel) 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 err := j.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if modelName == "" { return nil, fmt.Errorf("model name is required") @@ -75,16 +62,12 @@ func (j *JinaModel) ChatWithMessages(modelName string, messages []Message, apiCo return nil, fmt.Errorf("messages is empty") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := j.baseURLForRegion(region) + baseURL, err := j.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, j.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, j.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -131,7 +114,7 @@ func (j *JinaModel) ChatWithMessages(modelName string, messages []Message, apiCo req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := j.httpClient.Do(req) + resp, err := j.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -184,16 +167,19 @@ func (j *JinaModel) ChatStreamlyWithSender(modelName string, messages []Message, } func (j *JinaModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { + if err := j.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(texts) == 0 { return []EmbeddingData{}, nil } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := j.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", j.BaseURL[region], j.URLSuffix.Embedding) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, j.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": *modelName, @@ -213,7 +199,7 @@ func (j *JinaModel) Embed(modelName *string, texts []string, apiConfig *APIConfi req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := j.httpClient.Do(req) + resp, err := j.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -255,16 +241,19 @@ func (j *JinaModel) Embed(modelName *string, texts []string, apiConfig *APIConfi } func (j *JinaModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { + if err := j.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(documents) == 0 { return &RerankResponse{}, nil } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := j.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", j.BaseURL[region], j.URLSuffix.Rerank) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, j.baseModel.URLSuffix.Rerank) var topN = rerankConfig.TopN if rerankConfig.TopN != 0 { @@ -291,7 +280,7 @@ func (j *JinaModel) Rerank(modelName *string, query string, documents []string, req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := j.httpClient.Do(req) + resp, err := j.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -330,12 +319,12 @@ func (j *JinaModel) Rerank(modelName *string, query string, documents []string, } func (j *JinaModel) ListModels(apiConfig *APIConfig) ([]string, error) { - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - url := fmt.Sprintf("%s/%s", j.BaseURL[region], j.URLSuffix.Models) + resolvedBaseURL, err := j.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", resolvedBaseURL, j.baseModel.URLSuffix.Models) req, err := http.NewRequest("GET", url, nil) if err != nil { @@ -344,7 +333,7 @@ func (j *JinaModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Content-Type", "application/json") - resp, err := j.httpClient.Do(req) + resp, err := j.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/lmstudio.go b/internal/entity/models/lmstudio.go index 892b02b191..6df4b70cbe 100644 --- a/internal/entity/models/lmstudio.go +++ b/internal/entity/models/lmstudio.go @@ -31,40 +31,29 @@ import ( // LmStudioModel implements ModelDriver for lm-studio type LmStudioModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } // NewLmStudioModel func NewLmStudioModel(baseURL map[string]string, urlSuffix URLSuffix) *LmStudioModel { return &LmStudioModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + DisableCompression: false, + }, }, }, } } func (l *LmStudioModel) NewInstance(baseURL map[string]string) ModelDriver { - return &LmStudioModel{ - BaseURL: baseURL, - URLSuffix: l.URLSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, - }, - }, - } + return NewLmStudioModel(baseURL, l.baseModel.URLSuffix) } func (l *LmStudioModel) Name() string { @@ -73,21 +62,24 @@ func (l *LmStudioModel) Name() string { // ChatWithMessages sends multiple messages with roles and returns response func (l *LmStudioModel) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { + if err := l.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig.Region != nil { - region = *apiConfig.Region + resolvedBaseURL, err := l.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", l.BaseURL[region], l.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, l.baseModel.URLSuffix.Chat) // For qwen/glm models, use async chat endpoint modelType := strings.Split(modelName, "-")[0] if modelType == "qwen" || modelType == "glm" { - url = fmt.Sprintf("%s/%s", l.BaseURL[region], l.URLSuffix.AsyncChat) + url = fmt.Sprintf("%s/%s", resolvedBaseURL, l.baseModel.URLSuffix.AsyncChat) } // Convert messages to API format @@ -157,7 +149,7 @@ func (l *LmStudioModel) ChatWithMessages(modelName string, messages []Message, a req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := l.httpClient.Do(req) + resp, err := l.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -219,19 +211,22 @@ func (l *LmStudioModel) ChatWithMessages(modelName string, messages []Message, a // ChatStreamlyWithSender sends messages and streams response via sender function (best performance, no channel) func (l *LmStudioModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, modelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := l.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if len(messages) == 0 { return fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := l.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - url := fmt.Sprintf("%s/%s", l.BaseURL[region], l.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, l.baseModel.URLSuffix.Chat) modelType := strings.Split(modelName, "-")[0] if modelType == "qwen" || modelType == "glm" { - url = fmt.Sprintf("%s/%s", l.BaseURL[region], l.URLSuffix.AsyncChat) + url = fmt.Sprintf("%s/%s", resolvedBaseURL, l.baseModel.URLSuffix.AsyncChat) } // Convert messages to API format (supporting multimodal content) @@ -302,7 +297,7 @@ func (l *LmStudioModel) ChatStreamlyWithSender(modelName string, messages []Mess req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := l.httpClient.Do(req) + resp, err := l.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -384,6 +379,10 @@ func (l *LmStudioModel) ChatStreamlyWithSender(modelName string, messages []Mess } func (l *LmStudioModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { + if err := l.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(texts) == 0 { return []EmbeddingData{}, nil } @@ -392,20 +391,19 @@ func (l *LmStudioModel) Embed(modelName *string, texts []string, apiConfig *APIC return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := l.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - baseURL := l.BaseURL[region] + baseURL := resolvedBaseURL if baseURL == "" { - baseURL = l.BaseURL["default"] + baseURL = resolvedBaseURL } if baseURL == "" { return nil, fmt.Errorf("missing base URL: please configure the local access address for LM Studio (e.g., http://127.0.0.1:1234/v1)") } - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), l.URLSuffix.Embedding) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), l.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": *modelName, @@ -429,11 +427,9 @@ func (l *LmStudioModel) Embed(modelName *string, texts []string, apiConfig *APIC } req.Header.Set("Content-Type", "application/json") - if apiConfig != nil && apiConfig.ApiKey != nil && *apiConfig.ApiKey != "" { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := l.httpClient.Do(req) + resp, err := l.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -498,20 +494,23 @@ func (l *LmStudioModel) ParseFile(modelName *string, content []byte, url *string // ListModels list supported models func (l *LmStudioModel) ListModels(apiConfig *APIConfig) ([]string, error) { - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + if err := l.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - baseURL := l.BaseURL[region] + resolvedBaseURL, err := l.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + baseURL := resolvedBaseURL if baseURL == "" { - baseURL = l.BaseURL["default"] + baseURL = resolvedBaseURL } if baseURL == "" { return nil, fmt.Errorf("missing base URL: please configure the local access address for LM Studio (e.g., http://127.0.0.1:1234/v1)") } - url := fmt.Sprintf("%s/%s", baseURL, l.URLSuffix.Models) + url := fmt.Sprintf("%s/%s", baseURL, l.baseModel.URLSuffix.Models) reqBody := map[string]interface{}{} @@ -529,14 +528,9 @@ func (l *LmStudioModel) ListModels(apiConfig *APIConfig) ([]string, error) { } req.Header.Set("Content-Type", "application/json") - // LM Studio is a local provider and the API key is optional. Only - // set the Authorization header when a non-empty key was supplied. - // This also avoids a nil-pointer dereference on apiConfig or ApiKey. - if apiConfig != nil && apiConfig.ApiKey != nil && *apiConfig.ApiKey != "" { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := l.httpClient.Do(req) + resp, err := l.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/localai.go b/internal/entity/models/localai.go index 3305b03cc9..cb6d2a0a1d 100644 --- a/internal/entity/models/localai.go +++ b/internal/entity/models/localai.go @@ -29,41 +29,15 @@ import ( "time" ) -// localAIStreamIdleTimeout bounds how long ChatStreamlyWithSender will -// wait between SSE chunks before assuming the upstream has stalled and -// aborting the request. A local LLM normally emits at least one token -// every few seconds; 60s is generous enough to never break a working -// stream but tight enough to bound a worst-case mid-body hang. -// -// var (not const) so tests can lower it without waiting a real minute. +// localAIStreamIdleTimeout bounds how long ChatStreamlyWithSender var localAIStreamIdleTimeout = 60 * time.Second -// LocalAIModel implements ModelDriver for LocalAI, a self-hosted -// OpenAI-compatible inference server (https://localai.io). -// -// Unlike cloud providers, LocalAI runs on a tenant-supplied base URL -// (for example http://127.0.0.1:8080/v1). The driver therefore reads -// the base URL from the per-instance map at call time and does not -// assume a "default" entry. The API key is optional: LocalAI accepts -// an empty key by default, and the driver only sets the Authorization -// header when a non-empty key was supplied. +// LocalAIModel implements ModelDriver for LocalAI type LocalAIModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } -// NewLocalAIModel creates a new LocalAI model instance. -// -// We clone http.DefaultTransport so we keep Go's defaults for -// ProxyFromEnvironment, DialContext (with KeepAlive), HTTP/2, -// TLSHandshakeTimeout, and ExpectContinueTimeout, and only override -// the connection-pool fields we care about. -// -// The Client itself has no Timeout. http.Client.Timeout would also -// cap the time spent reading the response body, which would cut off -// long-lived SSE streams in ChatStreamlyWithSender. Non-streaming -// callers wrap each request with context.WithTimeout instead. +// NewLocalAIModel creates a new LocalAI model instance func NewLocalAIModel(baseURL map[string]string, urlSuffix URLSuffix) *LocalAIModel { transport := http.DefaultTransport.(*http.Transport).Clone() transport.MaxIdleConns = 100 @@ -73,66 +47,26 @@ func NewLocalAIModel(baseURL map[string]string, urlSuffix URLSuffix) *LocalAIMod transport.ResponseHeaderTimeout = 60 * time.Second return &LocalAIModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (l *LocalAIModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewLocalAIModel(baseURL, l.URLSuffix) + return NewLocalAIModel(baseURL, l.baseModel.URLSuffix) } func (l *LocalAIModel) Name() string { return "localai" } -// resolveBaseURL returns the tenant-supplied base URL for the given -// region, falling back to the "default" entry, and fails with a clear -// message when nothing is configured. LocalAI is self-hosted so the -// driver cannot fall back to a public endpoint. -func (l *LocalAIModel) resolveBaseURL(region string) (string, error) { - if base, ok := l.BaseURL[region]; ok && base != "" { - return strings.TrimSuffix(base, "/"), nil - } - if base, ok := l.BaseURL["default"]; ok && base != "" { - return strings.TrimSuffix(base, "/"), nil - } - return "", fmt.Errorf("localai: missing base URL, configure the local access address (e.g., http://127.0.0.1:8080/v1)") -} - -// setAuth sets the Authorization header only when a non-empty API key -// is supplied. LocalAI accepts an empty key by default, so sending -// "Bearer " (with an empty value) would be wrong in both directions: -// some local proxies reject it, and it leaks the fact that the -// driver was misconfigured. -func setLocalAIAuth(req *http.Request, apiConfig *APIConfig) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return - } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) -} - -// localAIReasoningFields lists the JSON field names that different -// upstream models put their chain-of-thought into. LocalAI is a proxy -// that can route to any of these, so the driver tries each in turn: -// -// - reasoning_content: OpenAI o-series, kimi-k2.6, DeepSeek-R1, -// magistral when proxied through an OpenAI-shim -// - reasoning: Upstage solar-pro3 (and its proxies) -// - thinking: Qwen3 (Ollama-style) and some local llama-r1 -// variants exposed through LocalAI's OpenAI shim -// -// The first non-empty match wins. Order matters: reasoning_content is -// the OpenAI-conformant name and the most widely used, so it's tried -// first. var localAIReasoningFields = []string{"reasoning_content", "reasoning", "thinking"} -// extractLocalAIReasoning pulls the chain-of-thought out of a message -// or delta object regardless of which field name the upstream model -// chose. Returns "" when no reasoning field is present or non-string. func extractLocalAIReasoning(m map[string]interface{}) string { for _, k := range localAIReasoningFields { if v, ok := m[k].(string); ok && v != "" { @@ -142,17 +76,6 @@ func extractLocalAIReasoning(m map[string]interface{}) string { return "" } -// addLocalAIReasoningRequestParams propagates the caller's request-side -// reasoning intent into the body. Different upstream models behind -// LocalAI accept different parameters: -// -// - reasoning_effort: OpenAI-compatible reasoning APIs (kimi, magistral, -// solar-pro2/pro3, gpt-o-series, R1 proxies) -// - enable_thinking: Qwen3 explicit thinking toggle -// -// Both are emitted when the caller opts in, so the request works -// against whichever family the LocalAI instance routes to. A non- -// supporting upstream simply ignores the extra field. func addLocalAIReasoningRequestParams(reqBody map[string]interface{}, cfg *ChatConfig) { if cfg == nil { return @@ -167,20 +90,18 @@ func addLocalAIReasoningRequestParams(reqBody map[string]interface{}, cfg *ChatC // ChatWithMessages sends multiple messages with roles and returns the response. func (l *LocalAIModel) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { + if err := l.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - region := "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := l.resolveBaseURL(region) + baseURL, err := l.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, l.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), l.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -196,9 +117,6 @@ func (l *LocalAIModel) ChatWithMessages(modelName string, messages []Message, ap "stream": false, } - // Note: do NOT propagate chatModelConfig.Stream into the request body - // here. ChatWithMessages parses a single JSON response, so stream must - // always be off for this code path. if chatModelConfig != nil { if chatModelConfig.MaxTokens != nil { reqBody["max_tokens"] = *chatModelConfig.MaxTokens @@ -212,10 +130,6 @@ func (l *LocalAIModel) ChatWithMessages(modelName string, messages []Message, ap if chatModelConfig.Stop != nil { reqBody["stop"] = *chatModelConfig.Stop } - // LocalAI is a proxy; emit both reasoning_effort and - // enable_thinking so the request works regardless of which - // model family the LocalAI instance routes to. See - // addLocalAIReasoningRequestParams. addLocalAIReasoningRequestParams(reqBody, chatModelConfig) } jsonData, err := json.Marshal(reqBody) @@ -232,9 +146,9 @@ func (l *LocalAIModel) ChatWithMessages(modelName string, messages []Message, ap } req.Header.Set("Content-Type", "application/json") - setLocalAIAuth(req, apiConfig) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := l.httpClient.Do(req) + resp, err := l.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -288,6 +202,10 @@ func (l *LocalAIModel) ChatWithMessages(modelName string, messages []Message, ap // sender function. The LocalAI SSE stream uses the same shape as OpenAI: // "data:" lines carrying JSON events, with a final "[DONE]" line. func (l *LocalAIModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := l.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if sender == nil { return fmt.Errorf("sender is required") } @@ -296,16 +214,11 @@ func (l *LocalAIModel) ChatStreamlyWithSender(modelName string, messages []Messa return fmt.Errorf("messages is empty") } - region := "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := l.resolveBaseURL(region) + baseURL, err := l.baseModel.GetBaseURL(apiConfig) if err != nil { return err } - url := fmt.Sprintf("%s/%s", baseURL, l.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), l.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -322,10 +235,6 @@ func (l *LocalAIModel) ChatStreamlyWithSender(modelName string, messages []Messa } if chatModelConfig != nil { - // Refuse to run if the caller explicitly asked for stream=false. - // The body of this method only knows how to read SSE, so a - // non-SSE JSON response would be parsed as if it were a stream - // and produce no chunks. Better to fail clearly. if chatModelConfig.Stream != nil && !*chatModelConfig.Stream { return fmt.Errorf("stream must be true in ChatStreamlyWithSender") } @@ -342,9 +251,6 @@ func (l *LocalAIModel) ChatStreamlyWithSender(modelName string, messages []Messa if chatModelConfig.Stop != nil { reqBody["stop"] = *chatModelConfig.Stop } - // LocalAI is a proxy; emit both reasoning_effort and - // enable_thinking so the streaming request works regardless of - // which model family the LocalAI instance routes to. addLocalAIReasoningRequestParams(reqBody, chatModelConfig) } @@ -353,15 +259,6 @@ func (l *LocalAIModel) ChatStreamlyWithSender(modelName string, messages []Messa return fmt.Errorf("failed to marshal request: %w", err) } - // SSE streams are long-lived, so we cannot attach a hard deadline: - // a legitimate response may take many minutes to finish on a busy - // local model. Instead, wrap the request with WithCancel and run - // an idle watchdog below that calls cancel() if no new data has - // arrived for streamIdleTimeout. That bounds the worst-case stall - // to a known finite window without breaking working long streams. - // - // Threading a real caller-supplied ctx through the ModelDriver - // interface remains a wider follow-up; this is the contained fix. ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -371,9 +268,9 @@ func (l *LocalAIModel) ChatStreamlyWithSender(modelName string, messages []Messa } req.Header.Set("Content-Type", "application/json") - setLocalAIAuth(req, apiConfig) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := l.httpClient.Do(req) + resp, err := l.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -384,12 +281,6 @@ func (l *LocalAIModel) ChatStreamlyWithSender(modelName string, messages []Messa return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) } - // Idle watchdog: every successful Scan resets lastActive. If - // streamIdleTimeout passes without a reset, the watchdog calls - // cancel(), which closes the underlying connection. The blocking - // scanner.Scan() then returns false with the context error in - // scanner.Err(), and we surface it to the caller instead of - // hanging the goroutine forever. lastActive := time.Now() var lastActiveMu sync.Mutex done := make(chan struct{}) @@ -413,8 +304,6 @@ func (l *LocalAIModel) ChatStreamlyWithSender(modelName string, messages []Messa } }() - // SSE parsing: bump the scanner buffer from the 64KB default to 1MB - // so we never silently truncate a long data: line. scanner := bufio.NewScanner(resp.Body) scanner.Buffer(make([]byte, 64*1024), 1024*1024) sawTerminal := false @@ -456,13 +345,6 @@ func (l *LocalAIModel) ChatStreamlyWithSender(modelName string, messages []Messa continue } - // Reasoning chunk first, content second. When an SSE event - // carries both, callers that pipe them to a UI render the - // chain-of-thought before the answer for that token, matching - // the wire ordering Upstage solar-pro3 and kimi-k2.6 emit. - // extractLocalAIReasoning tries reasoning_content, reasoning, - // and thinking in that order so this works against whichever - // model family LocalAI routes to. if reasoning := extractLocalAIReasoning(delta); reasoning != "" { if err := sender(nil, &reasoning); err != nil { return err @@ -484,9 +366,6 @@ func (l *LocalAIModel) ChatStreamlyWithSender(modelName string, messages []Messa } if err := scanner.Err(); err != nil { - // If the watchdog fired, the context is done; surface that as - // a clearer "idle" error instead of leaking the raw - // "context canceled" string. if ctx.Err() != nil { return fmt.Errorf("localai: stream idle for more than %s, aborted", localAIStreamIdleTimeout) } @@ -516,10 +395,10 @@ type localAIEmbeddingResponse struct { Object string `json:"object"` } -// Embed turns a list of texts into embedding vectors using the LocalAI -// /v1/embeddings endpoint. The output has one vector per input, in the -// same order the inputs were given. func (l *LocalAIModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { + if err := l.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } if len(texts) == 0 { return []EmbeddingData{}, nil } @@ -528,16 +407,11 @@ func (l *LocalAIModel) Embed(modelName *string, texts []string, apiConfig *APICo return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := l.resolveBaseURL(region) + baseURL, err := l.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, l.URLSuffix.Embedding) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), l.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": *modelName, @@ -561,9 +435,9 @@ func (l *LocalAIModel) Embed(modelName *string, texts []string, apiConfig *APICo } req.Header.Set("Content-Type", "application/json") - setLocalAIAuth(req, apiConfig) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := l.httpClient.Do(req) + resp, err := l.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -583,10 +457,6 @@ func (l *LocalAIModel) Embed(modelName *string, texts []string, apiConfig *APICo return nil, fmt.Errorf("failed to parse response: %w", err) } - // Reorder by the reported index so the output always lines up with - // the input texts, even if the upstream API ever returns items out - // of order. A nil slot at the end indicates the upstream did not - // return an embedding for that input. embeddings := make([]EmbeddingData, len(texts)) filled := make([]bool, len(texts)) for _, item := range parsed.Data { @@ -594,9 +464,6 @@ func (l *LocalAIModel) Embed(modelName *string, texts []string, apiConfig *APICo return nil, fmt.Errorf("localai: response index %d out of range for %d inputs", item.Index, len(texts)) } if filled[item.Index] { - // A malformed response that repeats the same index would - // silently overwrite the earlier vector. Fail loudly so - // the caller never uses ambiguous output. return nil, fmt.Errorf("localai: duplicate embedding index %d in response", item.Index) } embeddings[item.Index] = EmbeddingData{ @@ -628,12 +495,10 @@ type localAIRerankResponse struct { } `json:"results"` } -// Rerank calculates similarity scores between a query and a list of documents -// using LocalAI's /v1/rerank endpoint. The response shape is Cohere-style: -// {results: [{index, relevance_score}]}. The output is copied into the shared -// RerankResponse{Data: []RerankResult{Index, RelevanceScore}} shape that the -// rest of the codebase already consumes. func (l *LocalAIModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { + if err := l.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } if len(documents) == 0 { return &RerankResponse{}, nil } @@ -641,16 +506,11 @@ func (l *LocalAIModel) Rerank(modelName *string, query string, documents []strin return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := l.resolveBaseURL(region) + baseURL, err := l.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, l.URLSuffix.Rerank) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), l.baseModel.URLSuffix.Rerank) topN := len(documents) if rerankConfig != nil && rerankConfig.TopN > 0 { @@ -678,9 +538,9 @@ func (l *LocalAIModel) Rerank(modelName *string, query string, documents []strin } req.Header.Set("Content-Type", "application/json") - setLocalAIAuth(req, apiConfig) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := l.httpClient.Do(req) + resp, err := l.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -714,20 +574,16 @@ func (l *LocalAIModel) Rerank(modelName *string, query string, documents []strin return rerankResponse, nil } -// ListModels returns the list of model ids the running LocalAI instance has -// loaded. There is no fixed model list at the SaaS level because LocalAI is -// self-hosted; the answer depends on what the tenant has configured. func (l *LocalAIModel) ListModels(apiConfig *APIConfig) ([]string, error) { - region := "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + if err := l.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - baseURL, err := l.resolveBaseURL(region) + baseURL, err := l.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, l.URLSuffix.Models) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), l.baseModel.URLSuffix.Models) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -737,9 +593,10 @@ func (l *LocalAIModel) ListModels(apiConfig *APIConfig) ([]string, error) { return nil, fmt.Errorf("failed to create request: %w", err) } - setLocalAIAuth(req, apiConfig) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := l.httpClient.Do(req) + resp, err := l.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -780,14 +637,10 @@ func (l *LocalAIModel) ListModels(apiConfig *APIConfig) ([]string, error) { return models, nil } -// Balance is not exposed by LocalAI (it is self-hosted and free), so this -// returns "no such method". func (l *LocalAIModel) Balance(apiConfig *APIConfig) (map[string]interface{}, error) { return nil, fmt.Errorf("no such method") } -// CheckConnection runs a lightweight ListModels call to verify the LocalAI -// base URL is reachable. func (l *LocalAIModel) CheckConnection(apiConfig *APIConfig) error { _, err := l.ListModels(apiConfig) if err != nil { @@ -796,10 +649,6 @@ func (l *LocalAIModel) CheckConnection(apiConfig *APIConfig) error { return nil } -// TranscribeAudio (ASR): LocalAI can route audio to a Whisper backend -// when one is loaded, but the wire shape and driver-side plumbing for -// streaming audio uploads is separate from this PR's scope. Stub here -// to satisfy the ModelDriver interface; follow-up PR welcome. func (l *LocalAIModel) TranscribeAudio(modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig) (*ASRResponse, error) { return nil, fmt.Errorf("%s, no such method", l.Name()) } @@ -817,8 +666,6 @@ func (l *LocalAIModel) AudioSpeechWithSender(modelName *string, audioContent *st return fmt.Errorf("%s, no such method", l.Name()) } -// OCRFile: LocalAI has no OCR pipeline in its OpenAI-compatible surface; -// document parsing belongs to a different interface entirely. func (l *LocalAIModel) OCRFile(modelName *string, content []byte, url *string, apiConfig *APIConfig, ocrConfig *OCRConfig) (*OCRFileResponse, error) { return nil, fmt.Errorf("%s, no such method", l.Name()) } diff --git a/internal/entity/models/longcat.go b/internal/entity/models/longcat.go index 1ace473bd1..cca044a34b 100644 --- a/internal/entity/models/longcat.go +++ b/internal/entity/models/longcat.go @@ -29,36 +29,11 @@ import ( ) // LongCatModel implements ModelDriver for LongCat (Meituan). -// -// LongCat exposes an OpenAI-compatible chat completions endpoint at -// https://api.longcat.chat/openai/v1/chat/completions and lists models at -// https://api.longcat.chat/openai/v1/models. The official docs -// (https://longcat.chat/platform/docs/APIDocs.html) do not advertise separate -// /embeddings, /rerank, /audio, or /ocr endpoints. The wire shape matches the -// OpenAI convention: response/delta carry reasoning_content alongside content -// for thinking models. -// -// Documented request fields are limited to: model, messages, stream, -// max_tokens, temperature, top_p. Sending other OpenAI-style fields -// (stop, reasoning_effort, etc.) is not documented and is therefore -// omitted to avoid relying on undocumented upstream behavior. type LongCatModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } // NewLongCatModel creates a new LongCat model instance. -// -// We clone http.DefaultTransport so we keep Go's defaults for -// ProxyFromEnvironment, DialContext (with KeepAlive), HTTP/2, -// TLSHandshakeTimeout, and ExpectContinueTimeout, and only override -// the connection-pool fields we care about. -// -// The Client itself has no Timeout. http.Client.Timeout would also -// cap the time spent reading the response body, which would cut off -// long-lived SSE streams in ChatStreamlyWithSender. Non-streaming -// callers wrap each request with context.WithTimeout instead. func NewLongCatModel(baseURL map[string]string, urlSuffix URLSuffix) *LongCatModel { defaultTransport, ok := http.DefaultTransport.(*http.Transport) var transport *http.Transport @@ -76,54 +51,40 @@ func NewLongCatModel(baseURL map[string]string, urlSuffix URLSuffix) *LongCatMod transport.ResponseHeaderTimeout = 60 * time.Second return &LongCatModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (l *LongCatModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewLongCatModel(baseURL, l.URLSuffix) + return NewLongCatModel(baseURL, l.baseModel.URLSuffix) } func (l *LongCatModel) Name() string { return "longcat" } -// baseURLForRegion returns the base URL for the given region, or an -// error if no entry exists. This makes a misconfigured region fail -// fast with a clear message, instead of silently producing a relative -// URL that the HTTP transport then rejects. -func (l *LongCatModel) baseURLForRegion(region string) (string, error) { - base, ok := l.BaseURL[region] - if !ok || base == "" { - return "", fmt.Errorf("longcat: no base URL configured for region %q", region) - } - return base, nil -} - // ChatWithMessages sends multiple messages with roles and returns the response. func (l *LongCatModel) 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 err := l.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := l.baseURLForRegion(region) + baseURL, err := l.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, l.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, l.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -139,14 +100,6 @@ func (l *LongCatModel) ChatWithMessages(modelName string, messages []Message, ap "stream": false, } - // Note: do NOT propagate chatModelConfig.Stream into the request body - // here. ChatWithMessages parses a single JSON response, so stream must - // always be off for this code path. - // - // Only the fields documented at - // https://longcat.chat/platform/docs/APIDocs.html are forwarded. - // Other ChatConfig fields (Stop, Effort, ...) are dropped on the - // floor because the upstream behavior is undefined. if chatModelConfig != nil { if chatModelConfig.MaxTokens != nil { reqBody["max_tokens"] = *chatModelConfig.MaxTokens @@ -175,7 +128,7 @@ func (l *LongCatModel) ChatWithMessages(modelName string, messages []Message, ap req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := l.httpClient.Do(req) + resp, err := l.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -232,12 +185,11 @@ func (l *LongCatModel) ChatWithMessages(modelName string, messages []Message, ap } // ChatStreamlyWithSender sends messages and streams the response via the -// sender function. The LongCat SSE stream uses the same shape as the -// OpenAI o-series: "data:" lines carrying JSON events with -// delta.content for the visible answer and delta.reasoning_content for -// the chain-of-thought (LongCat-Flash-Thinking only), terminated by -// a [DONE] line. func (l *LongCatModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := l.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if sender == nil { return fmt.Errorf("sender is required") } @@ -246,20 +198,12 @@ func (l *LongCatModel) ChatStreamlyWithSender(modelName string, messages []Messa 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 := l.baseURLForRegion(region) + baseURL, err := l.baseModel.GetBaseURL(apiConfig) if err != nil { return err } - url := fmt.Sprintf("%s/%s", baseURL, l.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, l.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -276,10 +220,6 @@ func (l *LongCatModel) ChatStreamlyWithSender(modelName string, messages []Messa } if chatModelConfig != nil { - // Refuse to run if the caller explicitly asked for stream=false. - // The body of this method only knows how to read SSE, so a - // non-SSE JSON response would be parsed as if it were a stream - // and produce no chunks. Better to fail clearly. if chatModelConfig.Stream != nil && !*chatModelConfig.Stream { return fmt.Errorf("stream must be true in ChatStreamlyWithSender") } @@ -301,9 +241,7 @@ func (l *LongCatModel) ChatStreamlyWithSender(modelName string, messages []Messa return fmt.Errorf("failed to marshal request: %w", err) } - // SSE streams are long-lived. Rely on the transport's - // ResponseHeaderTimeout to cap the connection-establishment phase - // instead of attaching a hard deadline here. + // SSE streams are long-lived. req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(jsonData)) if err != nil { return fmt.Errorf("failed to create request: %w", err) @@ -312,7 +250,7 @@ func (l *LongCatModel) ChatStreamlyWithSender(modelName string, messages []Messa req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := l.httpClient.Do(req) + resp, err := l.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -323,8 +261,6 @@ func (l *LongCatModel) ChatStreamlyWithSender(modelName string, messages []Messa return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) } - // SSE parsing: bump the scanner buffer from the 64KB default to 1MB - // so we never silently truncate a long data: line. scanner := bufio.NewScanner(resp.Body) scanner.Buffer(make([]byte, 64*1024), 1024*1024) sawTerminal := false @@ -344,16 +280,9 @@ func (l *LongCatModel) ChatStreamlyWithSender(modelName string, messages []Messa var event map[string]interface{} if err = json.Unmarshal([]byte(data), &event); err != nil { - // A malformed frame can mean a truncated SSE event or an - // upstream incident; either way, the caller is better - // served by a hard failure than by silent partial output. return fmt.Errorf("longcat: invalid SSE event: %w", err) } - // LongCat (like other OpenAI-compatible upstreams) can emit a - // terminal `{"error": ...}` frame instead of a normal choices - // chunk when something goes wrong mid-stream. Surface it - // instead of falling through to the choices-missing branch. if apiErr, ok := event["error"]; ok { return fmt.Errorf("longcat: upstream stream error: %v", apiErr) } @@ -373,10 +302,6 @@ func (l *LongCatModel) ChatStreamlyWithSender(modelName string, messages []Messa continue } - // Reasoning chunks first, content second. When an SSE event - // carries both, callers that pipe them to a UI render the - // chain-of-thought before the answer for that token, matching - // the wire ordering LongCat-Flash-Thinking emits. if r, ok := delta["reasoning_content"].(string); ok && r != "" { if err := sender(nil, &r); err != nil { return err @@ -424,20 +349,16 @@ type longCatListModelsResponse struct { const longCatMaxListModelsResponseBytes = 1 << 20 func (l *LongCatModel) ListModels(apiConfig *APIConfig) ([]string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := l.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := l.baseURLForRegion(region) + baseURL, err := l.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, l.URLSuffix.Models) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, l.baseModel.URLSuffix.Models) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -449,7 +370,7 @@ func (l *LongCatModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := l.httpClient.Do(req) + resp, err := l.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -491,9 +412,6 @@ func (l *LongCatModel) CheckConnection(apiConfig *APIConfig) error { return err } -// Embed is not exposed by the LongCat API. The /v1/embeddings endpoint -// does not exist on api.longcat.chat; this returns the documented -// sentinel. func (l *LongCatModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { return nil, fmt.Errorf("%s, no such method", l.Name()) } @@ -526,7 +444,6 @@ func (l *LongCatModel) AudioSpeechWithSender(modelName *string, audioContent *st return fmt.Errorf("%s, no such method", l.Name()) } -// OCRFile is not exposed by the LongCat API. func (l *LongCatModel) OCRFile(modelName *string, content []byte, url *string, apiConfig *APIConfig, ocrConfig *OCRConfig) (*OCRFileResponse, error) { return nil, fmt.Errorf("%s, no such method", l.Name()) } diff --git a/internal/entity/models/mineru.go b/internal/entity/models/mineru.go index b657c961d3..7f1a667219 100644 --- a/internal/entity/models/mineru.go +++ b/internal/entity/models/mineru.go @@ -27,39 +27,28 @@ import ( ) type MinerUModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } func NewMinerUModel(baseURL map[string]string, urlSuffix URLSuffix) *MinerUModel { return &MinerUModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 10, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: time.Second * 90, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 10, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: time.Second * 90, + DisableCompression: false, + }, }, }, } } func (m *MinerUModel) NewInstance(baseURL map[string]string) ModelDriver { - return &MinerUModel{ - BaseURL: baseURL, - URLSuffix: m.URLSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 10, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: time.Second * 90, - DisableCompression: false, - }, - }, - } + return NewMinerUModel(baseURL, m.baseModel.URLSuffix) } func (m *MinerUModel) Name() string { @@ -124,20 +113,23 @@ type mineruTaskSubmitResponse struct { } func (m *MinerUModel) ParseFile(modelName *string, content []byte, documentURL *string, apiConfig *APIConfig, parseFileConfig *ParseFileConfig) (*ParseFileResponse, error) { + if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if documentURL == nil || *documentURL == "" { return nil, fmt.Errorf("MinerU API requires a valid public document URL; direct file upload is not supported") } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := m.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - apiURL := fmt.Sprintf("%s/api/%s", m.BaseURL[region], m.URLSuffix.DocumentParse) + apiURL := fmt.Sprintf("%s/api/%s", resolvedBaseURL, m.baseModel.URLSuffix.DocumentParse) reqBody := map[string]interface{}{ "url": *documentURL, @@ -163,7 +155,7 @@ func (m *MinerUModel) ParseFile(modelName *string, content []byte, documentURL * req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := m.httpClient.Do(req) + resp, err := m.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -212,17 +204,16 @@ type mineruTaskQueryResponse struct { } func (m *MinerUModel) ShowTask(taskID string, apiConfig *APIConfig) (*TaskResponse, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") - } - - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } // URL: https://mineru.net/api/v4/extract/task/{task_id} - apiURL := fmt.Sprintf("%s/api/%s/%s", m.BaseURL[region], m.URLSuffix.DocumentParse, taskID) + resolvedBaseURL, err := m.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + apiURL := fmt.Sprintf("%s/api/%s/%s", resolvedBaseURL, m.baseModel.URLSuffix.DocumentParse, taskID) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -234,7 +225,7 @@ func (m *MinerUModel) ShowTask(taskID string, apiConfig *APIConfig) (*TaskRespon req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := m.httpClient.Do(req) + resp, err := m.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/mineru_local.go b/internal/entity/models/mineru_local.go index 02a16ea93e..2a376a6deb 100644 --- a/internal/entity/models/mineru_local.go +++ b/internal/entity/models/mineru_local.go @@ -28,39 +28,28 @@ import ( ) type MinerULocalModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } func NewMinerLocalUModel(baseURL map[string]string, urlSuffix URLSuffix) *MinerULocalModel { return &MinerULocalModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 10, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: time.Second * 90, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 10, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: time.Second * 90, + DisableCompression: false, + }, }, }, } } func (m *MinerULocalModel) NewInstance(baseURL map[string]string) ModelDriver { - return &MinerULocalModel{ - BaseURL: baseURL, - URLSuffix: m.URLSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 10, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: time.Second * 90, - DisableCompression: false, - }, - }, - } + return NewMinerLocalUModel(baseURL, m.baseModel.URLSuffix) } func (m *MinerULocalModel) Name() string { @@ -116,16 +105,19 @@ func (m *MinerULocalModel) CheckConnection(apiConfig *APIConfig) error { } func (m *MinerULocalModel) ParseFile(modelName *string, content []byte, documentURL *string, apiConfig *APIConfig, parseFileConfig *ParseFileConfig) (*ParseFileResponse, error) { + if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(content) == 0 { return nil, fmt.Errorf("local MinerU API requires file content byte array, but content is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := m.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - apiURL := fmt.Sprintf("%s/%s", m.BaseURL[region], m.URLSuffix.DocumentParse) + apiURL := fmt.Sprintf("%s/%s", resolvedBaseURL, m.baseModel.URLSuffix.DocumentParse) var body bytes.Buffer writer := multipart.NewWriter(&body) @@ -159,11 +151,9 @@ func (m *MinerULocalModel) ParseFile(modelName *string, content []byte, document req.Header.Set("Content-Type", writer.FormDataContentType()) - if apiConfig != nil && apiConfig.ApiKey != nil && *apiConfig.ApiKey != "" { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := m.httpClient.Do(req) + resp, err := m.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -206,16 +196,19 @@ func (m *MinerULocalModel) ListTasks(apiConfig *APIConfig) ([]ListTaskStatus, er } func (m *MinerULocalModel) ShowTask(taskID string, apiConfig *APIConfig) (*TaskResponse, error) { + if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if taskID == "" { return nil, fmt.Errorf("taskID is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := m.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s/%s/result", m.BaseURL[region], m.URLSuffix.Task, taskID) + url := fmt.Sprintf("%s/%s/%s/result", resolvedBaseURL, m.baseModel.URLSuffix.Task, taskID) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -224,11 +217,9 @@ func (m *MinerULocalModel) ShowTask(taskID string, apiConfig *APIConfig) (*TaskR return nil, fmt.Errorf("failed to create status request: %w", err) } - if apiConfig != nil && apiConfig.ApiKey != nil && *apiConfig.ApiKey != "" { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := m.httpClient.Do(req) + resp, err := m.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send status request: %w", err) } diff --git a/internal/entity/models/minimax.go b/internal/entity/models/minimax.go index 00ab3aef8f..c54f6b8871 100644 --- a/internal/entity/models/minimax.go +++ b/internal/entity/models/minimax.go @@ -32,29 +32,29 @@ import ( // MinimaxModel implements ModelDriver for Minimax type MinimaxModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client // Reusable HTTP client with connection pool + baseModel BaseModel } // NewMinimaxModel creates a new Minimax model instance func NewMinimaxModel(baseURL map[string]string, urlSuffix URLSuffix) *MinimaxModel { return &MinimaxModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + DisableCompression: false, + }, }, }, } } func (m *MinimaxModel) NewInstance(baseURL map[string]string) ModelDriver { - return nil + return NewMinimaxModel(baseURL, m.baseModel.URLSuffix) } func (m *MinimaxModel) Name() string { @@ -63,19 +63,18 @@ func (m *MinimaxModel) Name() string { // ChatWithMessages sends multiple messages with roles and returns response func (m *MinimaxModel) 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 nil or empty") + if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := m.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", m.BaseURL[region], m.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, m.baseModel.URLSuffix.Chat) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -145,7 +144,7 @@ func (m *MinimaxModel) ChatWithMessages(modelName string, messages []Message, ap req.Header.Add("Content-Type", "application/json") req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := m.httpClient.Do(req) + resp, err := m.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -207,17 +206,19 @@ func (m *MinimaxModel) ChatWithMessages(modelName string, messages []Message, ap // ChatStreamlyWithSender sends messages and streams response via sender function (best performance, no channel) func (m *MinimaxModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, modelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if len(messages) == 0 { return fmt.Errorf("messages is empty") } - var region = "default" - - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := m.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - url := fmt.Sprintf("%s/%s", m.BaseURL[region], m.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, m.baseModel.URLSuffix.Chat) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -289,7 +290,7 @@ func (m *MinimaxModel) ChatStreamlyWithSender(modelName string, messages []Messa req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := m.httpClient.Do(req) + resp, err := m.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -376,12 +377,15 @@ func (m *MinimaxModel) Embed(modelName *string, texts []string, apiConfig *APICo } func (m *MinimaxModel) ListModels(apiConfig *APIConfig) ([]string, error) { - var region = "default" - if apiConfig.Region != nil { - region = *apiConfig.Region + if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - url := fmt.Sprintf("%s/%s", m.BaseURL[region], m.URLSuffix.Models) + resolvedBaseURL, err := m.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", resolvedBaseURL, m.baseModel.URLSuffix.Models) // Build request body reqBody := map[string]interface{}{} @@ -402,7 +406,7 @@ func (m *MinimaxModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := m.httpClient.Do(req) + resp, err := m.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -439,12 +443,15 @@ func (m *MinimaxModel) Balance(apiConfig *APIConfig) (map[string]interface{}, er } func (m *MinimaxModel) CheckConnection(apiConfig *APIConfig) error { - var region = "default" - if apiConfig.Region != nil { - region = *apiConfig.Region + if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return err } - url := fmt.Sprintf("%s/%s", m.BaseURL[region], m.URLSuffix.Files) + resolvedBaseURL, err := m.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err + } + url := fmt.Sprintf("%s/%s", resolvedBaseURL, m.baseModel.URLSuffix.Files) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -457,7 +464,7 @@ func (m *MinimaxModel) CheckConnection(apiConfig *APIConfig) error { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := m.httpClient.Do(req) + resp, err := m.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -491,19 +498,18 @@ func (m *MinimaxModel) TranscribeAudioWithSender(modelName *string, file *string // AudioSpeech convert text to audio func (m *MinimaxModel) AudioSpeech(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig) (*TTSResponse, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("MiniMax API key is missing") + if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if audioContent == nil || *audioContent == "" { return nil, fmt.Errorf("text content is empty") } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := m.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", m.BaseURL[region], m.URLSuffix.TTS) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, m.baseModel.URLSuffix.TTS) reqBody := map[string]interface{}{ "model": modelName, @@ -537,7 +543,7 @@ func (m *MinimaxModel) AudioSpeech(modelName *string, audioContent *string, apiC req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(*apiConfig.ApiKey))) - resp, err := m.httpClient.Do(req) + resp, err := m.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -582,23 +588,22 @@ func (m *MinimaxModel) AudioSpeech(modelName *string, audioContent *string, apiC } func (m *MinimaxModel) AudioSpeechWithSender(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig, sender func(*string, *string) error) error { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return fmt.Errorf("MiniMax API key is missing") + if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return err } if audioContent == nil || *audioContent == "" { return fmt.Errorf("text content is empty") } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := m.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - baseURL := strings.TrimSuffix(m.BaseURL[region], "/") + baseURL := strings.TrimSuffix(resolvedBaseURL, "/") if baseURL == "" { - baseURL = strings.TrimSuffix(m.BaseURL["default"], "/") + baseURL = strings.TrimSuffix(resolvedBaseURL, "/") } - suffix := strings.TrimPrefix(m.URLSuffix.TTS, "/") + suffix := strings.TrimPrefix(m.baseModel.URLSuffix.TTS, "/") if suffix == "" { suffix = "v1/t2a_v2" } @@ -639,7 +644,7 @@ func (m *MinimaxModel) AudioSpeechWithSender(modelName *string, audioContent *st req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", strings.TrimSpace(*apiConfig.ApiKey))) - resp, err := m.httpClient.Do(req) + resp, err := m.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/mistral.go b/internal/entity/models/mistral.go index fb0a74b893..7c55b9ea71 100644 --- a/internal/entity/models/mistral.go +++ b/internal/entity/models/mistral.go @@ -30,30 +30,12 @@ import ( ) // MistralModel implements ModelDriver for Mistral AI. -// -// Mistral exposes an OpenAI-compatible REST API at https://api.mistral.ai/v1 -// (chat completions at /chat/completions, list models at /models). The wire -// shape matches OpenAI closely enough that the chat path here is a direct -// port of the OpenAI driver, with the differences kept small on purpose: -// no reasoning_content pass-through (Mistral does not expose one), and a -// distinct Name() so the factory can route to this driver. + type MistralModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } // NewMistralModel creates a new Mistral model instance. -// -// We clone http.DefaultTransport so we keep Go's defaults for -// ProxyFromEnvironment, DialContext (with KeepAlive), HTTP/2, -// TLSHandshakeTimeout, and ExpectContinueTimeout, and only override -// the connection-pool fields we care about. -// -// The Client itself has no Timeout. http.Client.Timeout would also -// cap the time spent reading the response body, which would cut off -// long-lived SSE streams in ChatStreamlyWithSender. Non-streaming -// callers wrap each request with context.WithTimeout instead. func NewMistralModel(baseURL map[string]string, urlSuffix URLSuffix) *MistralModel { transport := http.DefaultTransport.(*http.Transport).Clone() transport.MaxIdleConns = 100 @@ -63,54 +45,40 @@ func NewMistralModel(baseURL map[string]string, urlSuffix URLSuffix) *MistralMod transport.ResponseHeaderTimeout = 60 * time.Second return &MistralModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (m *MistralModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewMistralModel(baseURL, m.URLSuffix) + return NewMistralModel(baseURL, m.baseModel.URLSuffix) } func (m *MistralModel) Name() string { return "mistral" } -// baseURLForRegion returns the base URL for the given region, or an -// error if no entry exists. This makes a misconfigured region fail -// fast with a clear message, instead of silently producing a relative -// URL that the HTTP transport then rejects. -func (m *MistralModel) baseURLForRegion(region string) (string, error) { - base, ok := m.BaseURL[region] - if !ok || base == "" { - return "", fmt.Errorf("mistral: no base URL configured for region %q", region) - } - return base, nil -} - // ChatWithMessages sends multiple messages with roles and returns the response. func (m *MistralModel) 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 err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := m.baseURLForRegion(region) + baseURL, err := m.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, m.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, m.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -160,7 +128,7 @@ func (m *MistralModel) ChatWithMessages(modelName string, messages []Message, ap req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := m.httpClient.Do(req) + resp, err := m.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -206,27 +174,6 @@ func (m *MistralModel) ChatWithMessages(modelName string, messages []Message, ap }, nil } -// extractMistralContent normalizes the two shapes Mistral can return in -// choices[0].message.content. -// -// 1. Plain string. The historical shape, used by every non-reasoning -// Mistral model (mistral-large, mistral-medium, ministral-*, etc.): -// -// "content": "Pong." -// -// 2. Structured array of typed parts. Used by the magistral reasoning -// family (magistral-small-*, magistral-medium-*) when the model -// actually produces a chain-of-thought: -// -// "content": [ -// {"type": "thinking", "thinking": [{"type": "text", "text": "..."}]}, -// {"type": "text", "text": "The final answer is ..."} -// ] -// -// The function concatenates the visible text parts into the assistant -// answer and the inner thinking text into the reasoning trace. Unknown -// part types are skipped rather than failing, so a new part shape from -// Mistral does not break the driver for tenants that don't use it. func extractMistralContent(raw interface{}) (string, string, error) { switch v := raw.(type) { case string: @@ -269,10 +216,12 @@ func extractMistralContent(raw interface{}) (string, string, error) { } } -// ChatStreamlyWithSender sends messages and streams the response via the -// sender function. The Mistral SSE stream uses the same shape as OpenAI: -// "data:" lines carrying JSON events, with a final "[DONE]" line. +// ChatStreamlyWithSender sends messages and streams the response func (m *MistralModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if sender == nil { return fmt.Errorf("sender is required") } @@ -281,20 +230,12 @@ func (m *MistralModel) ChatStreamlyWithSender(modelName string, messages []Messa return fmt.Errorf("messages is empty") } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return fmt.Errorf("api key is required") - } - - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := m.baseURLForRegion(region) + baseURL, err := m.baseModel.GetBaseURL(apiConfig) if err != nil { return err } - url := fmt.Sprintf("%s/%s", baseURL, m.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, m.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -311,10 +252,6 @@ func (m *MistralModel) ChatStreamlyWithSender(modelName string, messages []Messa } if chatModelConfig != nil { - // Refuse to run if the caller explicitly asked for stream=false. - // The body of this method only knows how to read SSE, so a - // non-SSE JSON response would be parsed as if it were a stream - // and produce no chunks. Better to fail clearly. if chatModelConfig.Stream != nil && !*chatModelConfig.Stream { return fmt.Errorf("stream must be true in ChatStreamlyWithSender") } @@ -338,9 +275,6 @@ func (m *MistralModel) ChatStreamlyWithSender(modelName string, messages []Messa return fmt.Errorf("failed to marshal request: %w", err) } - // Use an explicit background context. SSE streams are long-lived - // so we do not attach a hard deadline here; the transport's - // ResponseHeaderTimeout caps the connection-establishment phase. req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(jsonData)) if err != nil { return fmt.Errorf("failed to create request: %w", err) @@ -349,7 +283,7 @@ func (m *MistralModel) ChatStreamlyWithSender(modelName string, messages []Messa req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := m.httpClient.Do(req) + resp, err := m.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -440,32 +374,26 @@ type mistralEmbeddingResponse struct { Object string `json:"object"` } -// Embed turns a list of texts into embedding vectors using the -// Mistral /v1/embeddings endpoint (mistral-embed). The output has -// one vector per input, in the same order the inputs were given. +// Embed turns a list of texts into embedding vectors func (m *MistralModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { - if len(texts) == 0 { - return []EmbeddingData{}, nil + if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if len(texts) == 0 { + return []EmbeddingData{}, nil } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := m.baseURLForRegion(region) + baseURL, err := m.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, m.URLSuffix.Embedding) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, m.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": *modelName, @@ -488,7 +416,7 @@ func (m *MistralModel) Embed(modelName *string, texts []string, apiConfig *APICo req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := m.httpClient.Do(req) + resp, err := m.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -508,10 +436,6 @@ func (m *MistralModel) Embed(modelName *string, texts []string, apiConfig *APICo return nil, fmt.Errorf("failed to parse response: %w", err) } - // Reorder the returned vectors by their reported index so the output - // always lines up with the input texts, even if the upstream API ever - // returns items out of order. A nil slot at the end indicates the - // upstream did not return an embedding for that input. embeddings := make([]EmbeddingData, len(texts)) filled := make([]bool, len(texts)) for _, item := range parsed.Data { @@ -519,9 +443,6 @@ func (m *MistralModel) Embed(modelName *string, texts []string, apiConfig *APICo return nil, fmt.Errorf("mistral: response index %d out of range for %d inputs", item.Index, len(texts)) } if filled[item.Index] { - // A malformed response that repeats the same index would - // silently overwrite the earlier vector. Fail loudly so - // the caller never uses ambiguous output. return nil, fmt.Errorf("mistral: duplicate embedding index %d in response", item.Index) } embeddings[item.Index] = EmbeddingData{ @@ -541,20 +462,16 @@ func (m *MistralModel) Embed(modelName *string, texts []string, apiConfig *APICo // ListModels returns the list of model ids visible to the API key. func (m *MistralModel) ListModels(apiConfig *APIConfig) ([]string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := m.baseURLForRegion(region) + baseURL, err := m.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, m.URLSuffix.Models) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, m.baseModel.URLSuffix.Models) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -566,7 +483,7 @@ func (m *MistralModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := m.httpClient.Do(req) + resp, err := m.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -621,8 +538,7 @@ func (m *MistralModel) CheckConnection(apiConfig *APIConfig) error { return nil } -// Rerank calculates similarity scores between query and documents. Mistral -// does not expose a public rerank API, so this returns "no such method". +// Rerank calculates similarity scores between query and documents func (m *MistralModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { return nil, fmt.Errorf("%s, no such method", m.Name()) } @@ -647,16 +563,19 @@ func (m *MistralModel) AudioSpeechWithSender(modelName *string, audioContent *st // OCRFile OCR file func (m *MistralModel) OCRFile(modelName *string, content []byte, urls *string, apiConfig *APIConfig, ocrConfig *OCRConfig) (*OCRFileResponse, error) { + if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if (urls == nil || *urls == "") && (content == nil || len(content) == 0) { return nil, fmt.Errorf("file url or content is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := m.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", m.BaseURL[region], m.URLSuffix.OCR) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, m.baseModel.URLSuffix.OCR) var docURL string if urls != nil && *urls != "" { @@ -691,7 +610,7 @@ func (m *MistralModel) OCRFile(modelName *string, content []byte, urls *string, req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := m.httpClient.Do(req) + resp, err := m.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/modelscope.go b/internal/entity/models/modelscope.go index 9c937c027f..61edd10e3d 100644 --- a/internal/entity/models/modelscope.go +++ b/internal/entity/models/modelscope.go @@ -30,25 +30,11 @@ import ( ) // modelscopeStreamIdleTimeout bounds how long a stream can go without -// receiving any SSE line. Self-hosted ModelScope deployments can be slow, -// but a stream that stays silent for a full minute is more useful as a -// surfaced error than as a stuck goroutine. var modelscopeStreamIdleTimeout = 60 * time.Second // ModelScopeModel implements ModelDriver for ModelScope chat models. -// -// ModelScope exposes an OpenAI-compatible API under /v1. -// The tenant supplies the deployment endpoint (no default — matches the -// Python ModelScopeChat at rag/llm/chat_model.py which raises on a -// missing base URL). Both the root endpoint and the OpenAI-compatible -// endpoint (.../v1) are accepted; the driver normalizes both to the root -// before appending URLSuffix values like v1/chat/completions. -// Authentication is optional: deployments without auth ignore API keys, -// while auth-enabled deployments require Authorization: Bearer . type ModelScopeModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } type modelscopeChatChoice struct { @@ -88,32 +74,24 @@ func NewModelScopeModel(baseURL map[string]string, urlSuffix URLSuffix) *ModelSc transport.ResponseHeaderTimeout = 60 * time.Second return &ModelScopeModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (m *ModelScopeModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewModelScopeModel(baseURL, m.URLSuffix) + return NewModelScopeModel(baseURL, m.baseModel.URLSuffix) } func (m *ModelScopeModel) Name() string { return "modelscope" } -func (m *ModelScopeModel) baseURLForRegion(region string) (string, error) { - if base, ok := m.BaseURL[region]; ok && strings.TrimSpace(base) != "" { - return normalizeModelScopeBaseURL(base), nil - } - if base, ok := m.BaseURL["default"]; ok && strings.TrimSpace(base) != "" { - return normalizeModelScopeBaseURL(base), nil - } - return "", fmt.Errorf("modelscope: missing base URL, configure the ModelScope endpoint (e.g., http://127.0.0.1:8000 or http://127.0.0.1:8000/v1)") -} - func normalizeModelScopeBaseURL(base string) string { trimmed := strings.TrimRight(strings.TrimSpace(base), "/") if trimmed == "" { @@ -125,20 +103,6 @@ func normalizeModelScopeBaseURL(base string) string { return trimmed } -func setModelScopeAuth(req *http.Request, apiConfig *APIConfig) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return - } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) -} - -func modelscopeRegion(apiConfig *APIConfig) string { - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - return *apiConfig.Region - } - return "default" -} - func modelscopeReasoningFromStrings(reasoningContent string, reasoning string, thinking string) string { switch { case reasoningContent != "": @@ -196,15 +160,20 @@ func buildModelScopeChatBody(modelName string, messages []Message, stream bool, // ChatWithMessages sends multiple messages with roles and returns the response. func (m *ModelScopeModel) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { + if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - baseURL, err := m.baseURLForRegion(modelscopeRegion(apiConfig)) + baseURL, err := m.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, m.URLSuffix.Chat) + baseURL = normalizeModelScopeBaseURL(baseURL) + url := fmt.Sprintf("%s/%s", baseURL, m.baseModel.URLSuffix.Chat) reqBody := buildModelScopeChatBody(modelName, messages, false, chatModelConfig) jsonData, err := json.Marshal(reqBody) @@ -220,9 +189,9 @@ func (m *ModelScopeModel) ChatWithMessages(modelName string, messages []Message, return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") - setModelScopeAuth(req, apiConfig) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := m.httpClient.Do(req) + resp, err := m.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -259,6 +228,10 @@ func (m *ModelScopeModel) ChatWithMessages(modelName string, messages []Message, // ChatStreamlyWithSender sends messages and streams response via sender. func (m *ModelScopeModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if sender == nil { return fmt.Errorf("sender is required") } @@ -269,11 +242,12 @@ func (m *ModelScopeModel) ChatStreamlyWithSender(modelName string, messages []Me return fmt.Errorf("stream must be true in ChatStreamlyWithSender") } - baseURL, err := m.baseURLForRegion(modelscopeRegion(apiConfig)) + baseURL, err := m.baseModel.GetBaseURL(apiConfig) if err != nil { return err } - url := fmt.Sprintf("%s/%s", baseURL, m.URLSuffix.Chat) + baseURL = normalizeModelScopeBaseURL(baseURL) + url := fmt.Sprintf("%s/%s", baseURL, m.baseModel.URLSuffix.Chat) reqBody := buildModelScopeChatBody(modelName, messages, true, chatModelConfig) jsonData, err := json.Marshal(reqBody) @@ -289,9 +263,9 @@ func (m *ModelScopeModel) ChatStreamlyWithSender(modelName string, messages []Me return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") - setModelScopeAuth(req, apiConfig) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := m.httpClient.Do(req) + resp, err := m.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -424,11 +398,16 @@ func (m *ModelScopeModel) ParseFile(modelName *string, content []byte, url *stri // ListModels returns the model IDs exposed by ModelScope's OpenAI-compatible // /v1/models endpoint. func (m *ModelScopeModel) ListModels(apiConfig *APIConfig) ([]string, error) { - baseURL, err := m.baseURLForRegion(modelscopeRegion(apiConfig)) + if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + + baseURL, err := m.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, m.URLSuffix.Models) + baseURL = normalizeModelScopeBaseURL(baseURL) + url := fmt.Sprintf("%s/%s", baseURL, m.baseModel.URLSuffix.Models) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -437,9 +416,11 @@ func (m *ModelScopeModel) ListModels(apiConfig *APIConfig) ([]string, error) { if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } - setModelScopeAuth(req, apiConfig) - resp, err := m.httpClient.Do(req) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) + + resp, err := m.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/moonshot.go b/internal/entity/models/moonshot.go index 67052ada6f..ecf1a32f9e 100644 --- a/internal/entity/models/moonshot.go +++ b/internal/entity/models/moonshot.go @@ -31,29 +31,29 @@ import ( // MoonshotModel implements ModelDriver for Moonshot type MoonshotModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client // Reusable HTTP client with connection pool + baseModel BaseModel } // NewMoonshotModel creates a new Moonshot model instance func NewMoonshotModel(baseURL map[string]string, urlSuffix URLSuffix) *MoonshotModel { return &MoonshotModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + DisableCompression: false, + }, }, }, } } func (m *MoonshotModel) NewInstance(baseURL map[string]string) ModelDriver { - return nil + return NewMoonshotModel(baseURL, m.baseModel.URLSuffix) } func (m *MoonshotModel) Name() string { @@ -61,16 +61,19 @@ func (m *MoonshotModel) Name() string { } func (m *MoonshotModel) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { + if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := m.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", m.BaseURL[region], m.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, m.baseModel.URLSuffix.Chat) // Convert messages to the format expected by API apiMessages := make([]map[string]interface{}, len(messages)) @@ -137,11 +140,9 @@ func (m *MoonshotModel) ChatWithMessages(modelName string, messages []Message, a } req.Header.Set("Content-Type", "application/json") - if apiConfig != nil && apiConfig.ApiKey != nil { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := m.httpClient.Do(req) + resp, err := m.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -201,16 +202,19 @@ func (m *MoonshotModel) ChatWithMessages(modelName string, messages []Message, a // ChatStreamlyWithSender sends messages and streams response via sender function (best performance, no channel) func (m *MoonshotModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if len(messages) == 0 { return fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := m.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - url := fmt.Sprintf("%s/%s", m.BaseURL[region], m.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, m.baseModel.URLSuffix.Chat) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -282,7 +286,7 @@ func (m *MoonshotModel) ChatStreamlyWithSender(modelName string, messages []Mess req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := m.httpClient.Do(req) + resp, err := m.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -369,12 +373,15 @@ func (m *MoonshotModel) Embed(modelName *string, texts []string, apiConfig *APIC } func (m *MoonshotModel) ListModels(apiConfig *APIConfig) ([]string, error) { - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - url := fmt.Sprintf("%s/%s", m.BaseURL[region], m.URLSuffix.Models) + resolvedBaseURL, err := m.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", resolvedBaseURL, m.baseModel.URLSuffix.Models) // Build request body reqBody := map[string]interface{}{} @@ -395,7 +402,7 @@ func (m *MoonshotModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := m.httpClient.Do(req) + resp, err := m.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -428,12 +435,15 @@ func (m *MoonshotModel) ListModels(apiConfig *APIConfig) ([]string, error) { } func (m *MoonshotModel) Balance(apiConfig *APIConfig) (map[string]interface{}, error) { - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - url := fmt.Sprintf("%s/%s", m.BaseURL[region], m.URLSuffix.Balance) + baseURL, err := m.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", baseURL, m.baseModel.URLSuffix.Balance) // Build request body reqBody := map[string]interface{}{} @@ -454,7 +464,7 @@ func (m *MoonshotModel) Balance(apiConfig *APIConfig) (map[string]interface{}, e req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := m.httpClient.Do(req) + resp, err := m.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/n1n.go b/internal/entity/models/n1n.go index 1985773bcf..6689cef7b9 100644 --- a/internal/entity/models/n1n.go +++ b/internal/entity/models/n1n.go @@ -29,30 +29,11 @@ import ( ) // N1NModel implements ModelDriver for n1n.ai -// (https://docs.n1n.ai). -// -// n1n.ai is an aggregator that exposes an OpenAI-compatible REST API -// at https://api.n1n.ai/v1. The chat, embeddings, rerank, and models -// endpoints are documented; audio/image/video endpoints are listed in -// the docs but out of scope for this driver, which sticks to the -// ModelDriver methods backed by documented JSON surfaces. type N1NModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } // NewN1NModel creates a new n1n.ai model instance. -// -// We clone http.DefaultTransport so we keep Go's defaults for -// ProxyFromEnvironment, DialContext (with KeepAlive), HTTP/2, -// TLSHandshakeTimeout, and ExpectContinueTimeout, and only override -// the connection-pool fields we care about. -// -// The Client itself has no Timeout. http.Client.Timeout would also -// cap the time spent reading the response body, which would cut off -// long-lived SSE streams in ChatStreamlyWithSender. Non-streaming -// callers wrap each request with context.WithTimeout instead. func NewN1NModel(baseURL map[string]string, urlSuffix URLSuffix) *N1NModel { defaultTransport, ok := http.DefaultTransport.(*http.Transport) var transport *http.Transport @@ -70,38 +51,30 @@ func NewN1NModel(baseURL map[string]string, urlSuffix URLSuffix) *N1NModel { transport.ResponseHeaderTimeout = 60 * time.Second return &N1NModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (n *N1NModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewN1NModel(baseURL, n.URLSuffix) + return NewN1NModel(baseURL, n.baseModel.URLSuffix) } func (n *N1NModel) Name() string { return "n1n" } -// baseURLForRegion returns the base URL for the given region, trimmed -// of any trailing slash so callers can append a suffix without -// producing "//" in the path. -func (n *N1NModel) baseURLForRegion(region string) (string, error) { - base, ok := n.BaseURL[region] - if !ok || base == "" { - return "", fmt.Errorf("n1n: no base URL configured for region %q", region) - } - return strings.TrimRight(base, "/"), nil -} - func (n *N1NModel) endpointURL(region, suffix string) (string, error) { - baseURL, err := n.baseURLForRegion(region) + baseURL, err := n.baseModel.GetBaseURL(&APIConfig{Region: ®ion}) if err != nil { return "", err } + baseURL = strings.TrimSuffix(baseURL, "/") return fmt.Sprintf("%s/%s", baseURL, strings.TrimLeft(suffix, "/")), nil } @@ -112,13 +85,6 @@ func n1nRegion(apiConfig *APIConfig) string { return "default" } -func n1nValidateAPIKey(apiConfig *APIConfig) (string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return "", fmt.Errorf("api key is required") - } - return *apiConfig.ApiKey, nil -} - func newN1NJSONRequest(ctx context.Context, method, endpoint string, payload interface{}, apiKey string) (*http.Request, error) { var body io.Reader if payload != nil { @@ -178,15 +144,6 @@ func buildN1NChatRequest(modelName string, messages []Message, stream bool, chat reqBody.Temperature = chatModelConfig.Temperature reqBody.TopP = chatModelConfig.TopP reqBody.Stop = chatModelConfig.Stop - // Map ChatConfig.Thinking *bool -> n1n.ai's documented - // `thinking: {type: "enabled"|"disabled"}` body field - // (per maintainer review on PR #15010, with example curl - // against deepseek-v3-1-250821). Models that don't support - // the field ignore it silently; the reasoning-capable - // variants (e.g. deepseek-v3-1-think-250821) surface the - // chain-of-thought via message.reasoning_content on the - // non-stream path and delta.reasoning_content on the - // streaming path, both of which this driver already reads. if chatModelConfig.Thinking != nil { if *chatModelConfig.Thinking { reqBody.Thinking = &n1nThinking{Type: "enabled"} @@ -221,10 +178,10 @@ type n1nChatResponse struct { // ChatWithMessages sends a single, non-streaming chat completion // against n1n.ai's /v1/chat/completions endpoint. func (n *N1NModel) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { - apiKey, err := n1nValidateAPIKey(apiConfig) - if err != nil { + if err := n.baseModel.APIConfigCheck(apiConfig); err != nil { return nil, err } + apiKey := *apiConfig.ApiKey if strings.TrimSpace(modelName) == "" { return nil, fmt.Errorf("model name is required") } @@ -232,14 +189,11 @@ func (n *N1NModel) ChatWithMessages(modelName string, messages []Message, apiCon return nil, fmt.Errorf("messages is empty") } - endpoint, err := n.endpointURL(n1nRegion(apiConfig), n.URLSuffix.Chat) + endpoint, err := n.endpointURL(n1nRegion(apiConfig), n.baseModel.URLSuffix.Chat) if err != nil { return nil, err } - // Force stream=false here; ChatWithMessages reads a single JSON - // response body, so a streaming SSE response would be parsed as - // truncated JSON and produce a confusing error. reqBody := buildN1NChatRequest(modelName, messages, false, chatModelConfig) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) @@ -250,7 +204,7 @@ func (n *N1NModel) ChatWithMessages(modelName string, messages []Message, apiCon return nil, err } - resp, err := n.httpClient.Do(req) + resp, err := n.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -280,10 +234,6 @@ func (n *N1NModel) ChatWithMessages(modelName string, messages []Message, apiCon chatResp := &ChatResponse{ Answer: &content, } - // Preserve a nil pointer when the upstream omitted reasoning, so - // downstream callers can distinguish "no reasoning emitted" from - // "reasoning present but empty". Matches the streaming path, - // which already suppresses empty reasoning chunks. if parsed.Choices[0].Message.ReasoningContent != "" { reasonContent := parsed.Choices[0].Message.ReasoningContent chatResp.ReasonContent = &reasonContent @@ -291,11 +241,12 @@ func (n *N1NModel) ChatWithMessages(modelName string, messages []Message, apiCon return chatResp, nil } -// ChatStreamlyWithSender sends a streaming chat completion. The n1n.ai -// SSE stream uses the standard OpenAI shape: "data:" lines carrying -// JSON events with delta.content (and delta.reasoning_content for -// reasoning-capable models), terminated by a "[DONE]" line. +// ChatStreamlyWithSender sends a streaming chat completion. func (n *N1NModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := n.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if sender == nil { return fmt.Errorf("sender is required") } @@ -305,35 +256,25 @@ func (n *N1NModel) ChatStreamlyWithSender(modelName string, messages []Message, if len(messages) == 0 { return fmt.Errorf("messages is empty") } - apiKey, err := n1nValidateAPIKey(apiConfig) - if err != nil { - return err - } + apiKey := *apiConfig.ApiKey - endpoint, err := n.endpointURL(n1nRegion(apiConfig), n.URLSuffix.Chat) + endpoint, err := n.endpointURL(n1nRegion(apiConfig), n.baseModel.URLSuffix.Chat) if err != nil { return err } if chatModelConfig != nil && chatModelConfig.Stream != nil && !*chatModelConfig.Stream { - // Caller explicitly asked for stream=false. The body of this - // method only knows how to read SSE, so a non-SSE JSON - // response would be parsed as if it were a stream and produce - // no chunks. Fail clearly. return fmt.Errorf("stream must be true in ChatStreamlyWithSender") } reqBody := buildN1NChatRequest(modelName, messages, true, chatModelConfig) - // SSE streams are long-lived; rely on the transport's - // ResponseHeaderTimeout to cap the connection-establishment phase - // instead of attaching a hard deadline here. req, err := newN1NJSONRequest(context.Background(), "POST", endpoint, reqBody, apiKey) if err != nil { return err } - resp, err := n.httpClient.Do(req) + resp, err := n.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -344,10 +285,6 @@ func (n *N1NModel) ChatStreamlyWithSender(modelName string, messages []Message, return fmt.Errorf("n1n chat stream API error: %s, body: %s", resp.Status, string(body)) } - // Bump the scanner buffer from the 64KB default to 1MB so we - // never silently truncate a long data: line. n1n.ai tags some - // chunks with a long obfuscation suffix that can push individual - // SSE lines well past 64KB on large contexts. scanner := bufio.NewScanner(resp.Body) scanner.Buffer(make([]byte, 64*1024), 1024*1024) sawTerminal := false @@ -364,9 +301,6 @@ func (n *N1NModel) ChatStreamlyWithSender(modelName string, messages []Message, var event n1nChatResponse if err := json.Unmarshal([]byte(data), &event); err != nil { - // A malformed frame can mean a truncated SSE event or an - // upstream incident; the caller is better served by a - // hard failure than by silent partial output. return fmt.Errorf("n1n: invalid SSE event: %w", err) } if len(event.Choices) == 0 { @@ -422,22 +356,21 @@ type n1nEmbeddingRequest struct { Dimensions int `json:"dimensions,omitempty"` } -// Embed turns a list of texts into embedding vectors using the -// n1n.ai /v1/embeddings endpoint. Output is one vector per input, in -// the same order the inputs were given. +// Embed turns a list of texts into embedding vectors func (n *N1NModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { + if err := n.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(texts) == 0 { return []EmbeddingData{}, nil } - apiKey, err := n1nValidateAPIKey(apiConfig) - if err != nil { - return nil, err - } + apiKey := *apiConfig.ApiKey if modelName == nil || strings.TrimSpace(*modelName) == "" { return nil, fmt.Errorf("model name is required") } - endpoint, err := n.endpointURL(n1nRegion(apiConfig), n.URLSuffix.Embedding) + endpoint, err := n.endpointURL(n1nRegion(apiConfig), n.baseModel.URLSuffix.Embedding) if err != nil { return nil, err } @@ -458,7 +391,7 @@ func (n *N1NModel) Embed(modelName *string, texts []string, apiConfig *APIConfig return nil, err } - resp, err := n.httpClient.Do(req) + resp, err := n.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -477,11 +410,6 @@ func (n *N1NModel) Embed(modelName *string, texts []string, apiConfig *APIConfig return nil, fmt.Errorf("failed to parse response: %w", err) } - // Reorder the returned vectors by their reported index so the - // output always lines up with the input texts, even if the - // upstream returns items out of order. Reject duplicates and - // out-of-range indices so a malformed response fails loudly - // rather than silently overwriting earlier slots. embeddings := make([]EmbeddingData, len(texts)) filled := make([]bool, len(texts)) for _, item := range parsed.Data { @@ -524,18 +452,19 @@ type n1nRerankRequest struct { // Rerank scores a query against a list of documents using // n1n.ai's /v1/rerank endpoint (Cohere-shaped response). func (n *N1NModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { + if err := n.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(documents) == 0 { return &RerankResponse{}, nil } - apiKey, err := n1nValidateAPIKey(apiConfig) - if err != nil { - return nil, err - } + apiKey := *apiConfig.ApiKey if modelName == nil || strings.TrimSpace(*modelName) == "" { return nil, fmt.Errorf("model name is required") } - endpoint, err := n.endpointURL(n1nRegion(apiConfig), n.URLSuffix.Rerank) + endpoint, err := n.endpointURL(n1nRegion(apiConfig), n.baseModel.URLSuffix.Rerank) if err != nil { return nil, err } @@ -557,7 +486,7 @@ func (n *N1NModel) Rerank(modelName *string, query string, documents []string, a return nil, err } - resp, err := n.httpClient.Do(req) + resp, err := n.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -602,17 +531,14 @@ type n1nModelCatalogResponse struct { Data []n1nModelCatalogItem `json:"data"` } -// ListModels returns the live n1n.ai model catalog from -// GET /v1/models. The shipped catalog in conf/models/n1n.json is a -// representative subset; this method surfaces the full upstream list -// (hundreds of models routed through the aggregator). +// ListModels returns the live n1n.ai model catalog func (n *N1NModel) ListModels(apiConfig *APIConfig) ([]string, error) { - apiKey, err := n1nValidateAPIKey(apiConfig) - if err != nil { + if err := n.baseModel.APIConfigCheck(apiConfig); err != nil { return nil, err } + apiKey := *apiConfig.ApiKey - endpoint, err := n.endpointURL(n1nRegion(apiConfig), n.URLSuffix.Models) + endpoint, err := n.endpointURL(n1nRegion(apiConfig), n.baseModel.URLSuffix.Models) if err != nil { return nil, err } @@ -625,7 +551,7 @@ func (n *N1NModel) ListModels(apiConfig *APIConfig) ([]string, error) { return nil, err } - resp, err := n.httpClient.Do(req) + resp, err := n.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -653,24 +579,17 @@ func (n *N1NModel) ListModels(apiConfig *APIConfig) ([]string, error) { return models, nil } -// CheckConnection verifies the API key by querying the documented -// /v1/models endpoint — the cheapest auth check on the documented -// surface, with no per-call charge against tenant quota. +// CheckConnection verifies the API key func (n *N1NModel) CheckConnection(apiConfig *APIConfig) error { _, err := n.ListModels(apiConfig) return err } -// Balance is not exposed by the n1n.ai API. Account balance and -// quota are available only via the web console at -// https://api.n1n.ai/console; the public API surface does not -// publish them. func (n *N1NModel) Balance(apiConfig *APIConfig) (map[string]interface{}, error) { return nil, fmt.Errorf("%s, no such method", n.Name()) } -// TranscribeAudio: n1n.ai exposes /v1/audio/transcriptions but the -// driver does not currently implement the multipart upload flow. +// TranscribeAudio: n1n.ai exposes /v1/audio/transcriptions func (n *N1NModel) TranscribeAudio(modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig) (*ASRResponse, error) { return nil, fmt.Errorf("%s, no such method", n.Name()) } @@ -679,8 +598,7 @@ func (n *N1NModel) TranscribeAudioWithSender(modelName *string, file *string, ap return fmt.Errorf("%s, no such method", n.Name()) } -// AudioSpeech: n1n.ai exposes /v1/audio/speech but the driver does -// not currently implement the binary audio response handling. +// AudioSpeech: n1n.ai exposes /v1/audio/speech func (n *N1NModel) AudioSpeech(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig) (*TTSResponse, error) { return nil, fmt.Errorf("%s, no such method", n.Name()) } @@ -699,8 +617,7 @@ func (n *N1NModel) ParseFile(modelName *string, content []byte, url *string, api return nil, fmt.Errorf("%s, no such method", n.Name()) } -// ListTasks: n1n.ai has /v1/contents/generations/tasks for async -// image/video jobs, but that surface is not modeled by this driver. +// ListTasks: n1n.ai has /v1/contents/generations/tasks func (n *N1NModel) ListTasks(apiConfig *APIConfig) ([]ListTaskStatus, error) { return nil, fmt.Errorf("%s, no such method", n.Name()) } diff --git a/internal/entity/models/novita.go b/internal/entity/models/novita.go index 518f0b2d36..f32363492f 100644 --- a/internal/entity/models/novita.go +++ b/internal/entity/models/novita.go @@ -30,32 +30,11 @@ import ( ) // NovitaModel implements ModelDriver for Novita.ai -// (https://novita.ai/docs/api-reference/). -// -// Novita exposes an OpenAI-compatible REST API at -// https://api.novita.ai/v3/openai (chat completions at -// /chat/completions, list models at /models). It serves a large -// catalog of third-party models (DeepSeek, Llama, Qwen3, Kimi, -// Gemma, Mistral, etc.) behind a single OpenAI-shaped surface. -// -// The wire shape matches OpenAI standard with ONE notable -// difference: reasoning models like qwen3-* embed their -// chain-of-thought INLINE inside content as ... -// tags, rather than in a separate reasoning_content field. The -// driver detects those tags and routes the inner text to -// ChatResponse.ReasonContent (non-stream) or the sender's second -// arg (stream), keeping the answer clean of tag clutter. type NovitaModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } // NewNovitaModel creates a new Novita model instance. -// -// Same transport convention as other Go drivers in this package: -// clone http.DefaultTransport, override the connection-pool fields, -// no client-level Timeout so SSE streams are not capped. func NewNovitaModel(baseURL map[string]string, urlSuffix URLSuffix) *NovitaModel { defaultTransport, ok := http.DefaultTransport.(*http.Transport) var transport *http.Transport @@ -73,34 +52,24 @@ func NewNovitaModel(baseURL map[string]string, urlSuffix URLSuffix) *NovitaModel transport.ResponseHeaderTimeout = 60 * time.Second return &NovitaModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (n *NovitaModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewNovitaModel(baseURL, n.URLSuffix) + return NewNovitaModel(baseURL, n.baseModel.URLSuffix) } func (n *NovitaModel) Name() string { return "novita" } -func (n *NovitaModel) baseURLForRegion(region string) (string, error) { - base, ok := n.BaseURL[region] - if !ok || base == "" { - return "", fmt.Errorf("novita: no base URL configured for region %q", region) - } - // Strip a trailing "/" so callers can safely do - // fmt.Sprintf("%s/%s", base, suffix) without producing "//" in - // the path. The shipped config has no trailing slash, but a - // tenant can override the URL per-instance and may add one. - return strings.TrimSuffix(base, "/"), nil -} - const ( novitaThinkOpen = "" novitaThinkClose = "" @@ -238,23 +207,19 @@ func (e *novitaThinkExtractor) Flush() *novitaThinkSegment { // ChatWithMessages sends multiple messages with roles and returns the response. func (n *NovitaModel) 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 err := n.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := n.baseURLForRegion(region) + baseURL, err := n.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, n.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, n.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -313,7 +278,7 @@ func (n *NovitaModel) ChatWithMessages(modelName string, messages []Message, api req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := n.httpClient.Do(req) + resp, err := n.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -386,26 +351,23 @@ func (n *NovitaModel) ChatWithMessages(modelName string, messages []Message, api // across SSE chunk boundaries, then routes content/reasoning to // the first/second sender arg respectively. func (n *NovitaModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := n.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + 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 := n.baseURLForRegion(region) + baseURL, err := n.baseModel.GetBaseURL(apiConfig) if err != nil { return err } - url := fmt.Sprintf("%s/%s", baseURL, n.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, n.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -455,7 +417,7 @@ func (n *NovitaModel) ChatStreamlyWithSender(modelName string, messages []Messag req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := n.httpClient.Do(req) + resp, err := n.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -565,20 +527,16 @@ func (n *NovitaModel) ChatStreamlyWithSender(modelName string, messages []Messag // ListModels returns the list of model ids visible to the API key. func (n *NovitaModel) ListModels(apiConfig *APIConfig) ([]string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := n.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := n.baseURLForRegion(region) + baseURL, err := n.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, n.URLSuffix.Models) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, n.baseModel.URLSuffix.Models) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -590,7 +548,7 @@ func (n *NovitaModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := n.httpClient.Do(req) + resp, err := n.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -651,28 +609,24 @@ type novitaEmbeddingResponse struct { // /v3/embeddings endpoint. The output has one vector per input, in the // same order the inputs were given. func (n *NovitaModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { - if len(texts) == 0 { - return []EmbeddingData{}, nil + if err := n.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if len(texts) == 0 { + return []EmbeddingData{}, nil } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := n.baseURLForRegion(region) + baseURL, err := n.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, n.URLSuffix.Embedding) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, n.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": *modelName, @@ -698,7 +652,7 @@ func (n *NovitaModel) Embed(modelName *string, texts []string, apiConfig *APICon req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := n.httpClient.Do(req) + resp, err := n.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -759,29 +713,26 @@ type novitaRerankResponse struct { // document in the API's ranking order. Caller may sort by Index to // recover original input order. func (n *NovitaModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { + if err := n.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(documents) == 0 { return &RerankResponse{}, nil } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") - } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := n.baseURLForRegion(region) + baseURL, err := n.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - if n.URLSuffix.Rerank == "" { + baseURL = strings.TrimSuffix(baseURL, "/") + if n.baseModel.URLSuffix.Rerank == "" { return nil, fmt.Errorf("novita: no rerank URL suffix configured") } - url := fmt.Sprintf("%s/%s", baseURL, n.URLSuffix.Rerank) + url := fmt.Sprintf("%s/%s", baseURL, n.baseModel.URLSuffix.Rerank) topN := len(documents) if rerankConfig != nil && rerankConfig.TopN > 0 && rerankConfig.TopN < topN { @@ -811,7 +762,7 @@ func (n *NovitaModel) Rerank(modelName *string, query string, documents []string req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := n.httpClient.Do(req) + resp, err := n.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -852,12 +803,15 @@ func (n *NovitaModel) Rerank(modelName *string, query string, documents []string // Balance Get remaining credit func (n *NovitaModel) Balance(apiConfig *APIConfig) (map[string]interface{}, error) { - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + if err := n.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - url := fmt.Sprintf("%s/%s", n.BaseURL[region], n.URLSuffix.Balance) + baseURL, err := n.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", baseURL, n.baseModel.URLSuffix.Balance) // Build request body reqBody := map[string]interface{}{} @@ -875,7 +829,7 @@ func (n *NovitaModel) Balance(apiConfig *APIConfig) (map[string]interface{}, err req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := n.httpClient.Do(req) + resp, err := n.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/nvidia.go b/internal/entity/models/nvidia.go index 864b9f89d3..9acbc3340f 100644 --- a/internal/entity/models/nvidia.go +++ b/internal/entity/models/nvidia.go @@ -30,40 +30,29 @@ import ( // NvidiaModel implements ModelDriver for Nvidia type NvidiaModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } // NewNvidiaModel creates a new Nvidia model instance func NewNvidiaModel(baseURL map[string]string, urlSuffix URLSuffix) *NvidiaModel { return &NvidiaModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + DisableCompression: false, + }, }, }, } } func (n NvidiaModel) NewInstance(baseURL map[string]string) ModelDriver { - return &NvidiaModel{ - BaseURL: baseURL, - URLSuffix: n.URLSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, - }, - }, - } + return NewNvidiaModel(baseURL, n.baseModel.URLSuffix) } func (n NvidiaModel) Name() string { @@ -71,20 +60,23 @@ func (n NvidiaModel) Name() string { } func (n *NvidiaModel) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { + if err := n.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil { - region = *apiConfig.Region + resolvedBaseURL, err := n.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - baseURL := n.BaseURL[region] + baseURL := resolvedBaseURL if baseURL == "" { - baseURL = n.BaseURL["default"] + baseURL = resolvedBaseURL } - url := fmt.Sprintf("%s/%s", baseURL, n.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", baseURL, n.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -139,11 +131,9 @@ func (n *NvidiaModel) ChatWithMessages(modelName string, messages []Message, api } req.Header.Set("Content-Type", "application/json") - if apiConfig != nil && apiConfig.ApiKey != nil { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := n.httpClient.Do(req) + resp, err := n.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -203,20 +193,25 @@ func (n *NvidiaModel) ChatWithMessages(modelName string, messages []Message, api } func (n *NvidiaModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, modelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := n.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if sender == nil { + return fmt.Errorf("sender is required") + } if len(messages) == 0 { return fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := n.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - baseURL := n.BaseURL[region] + baseURL := resolvedBaseURL if baseURL == "" { - baseURL = n.BaseURL["default"] + baseURL = resolvedBaseURL } - url := fmt.Sprintf("%s/%s", baseURL, n.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", baseURL, n.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -274,11 +269,9 @@ func (n *NvidiaModel) ChatStreamlyWithSender(modelName string, messages []Messag } req.Header.Set("Content-Type", "application/json") - if apiConfig != nil && apiConfig.ApiKey != nil { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := n.httpClient.Do(req) + resp, err := n.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -359,32 +352,28 @@ type nvidiaEmbeddingResponse struct { } func (n NvidiaModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { - if len(texts) == 0 { - return []EmbeddingData{}, nil + if err := n.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if len(texts) == 0 { + return []EmbeddingData{}, nil } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := n.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + baseURL := resolvedBaseURL + if baseURL == "" { + baseURL = resolvedBaseURL } - baseURL := n.BaseURL[region] - if baseURL == "" { - baseURL = n.BaseURL["default"] - } - if baseURL == "" { - return nil, fmt.Errorf("nvidia: no base URL configured for region %q", region) - } - - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), n.URLSuffix.Embedding) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), n.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": *modelName, @@ -413,7 +402,7 @@ func (n NvidiaModel) Embed(modelName *string, texts []string, apiConfig *APIConf req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := n.httpClient.Do(req) + resp, err := n.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -480,30 +469,27 @@ type nvidiaRerankResponse struct { // need original-input order should sort by Index. Same return-shape // contract as the Aliyun and ZhipuAI Rerank drivers. func (n NvidiaModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { + if err := n.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(documents) == 0 { return &RerankResponse{}, nil } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") - } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := n.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + baseURL := resolvedBaseURL + if baseURL == "" { + baseURL = resolvedBaseURL } - baseURL := n.BaseURL[region] - if baseURL == "" { - baseURL = n.BaseURL["default"] - } - if baseURL == "" { - return nil, fmt.Errorf("nvidia: no base URL configured for region %q", region) - } - - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), n.URLSuffix.Rerank) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), n.baseModel.URLSuffix.Rerank) topN := len(documents) if rerankConfig != nil && rerankConfig.TopN > 0 && rerankConfig.TopN < topN { @@ -539,7 +525,7 @@ func (n NvidiaModel) Rerank(modelName *string, query string, documents []string, req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := n.httpClient.Do(req) + resp, err := n.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -606,20 +592,20 @@ func (n *NvidiaModel) ParseFile(modelName *string, content []byte, url *string, // OpenAI-compatible, so the parsing follows the same shape used by // the moonshot, xai, and openai drivers. func (n NvidiaModel) ListModels(apiConfig *APIConfig) ([]string, error) { - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + if err := n.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - baseURL := n.BaseURL[region] - if baseURL == "" { - baseURL = n.BaseURL["default"] + resolvedBaseURL, err := n.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } + baseURL := resolvedBaseURL if baseURL == "" { - return nil, fmt.Errorf("nvidia: no base URL configured for region %q", region) + baseURL = resolvedBaseURL } - url := fmt.Sprintf("%s/%s", baseURL, n.URLSuffix.Models) + url := fmt.Sprintf("%s/%s", baseURL, n.baseModel.URLSuffix.Models) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -629,11 +615,9 @@ func (n NvidiaModel) ListModels(apiConfig *APIConfig) ([]string, error) { return nil, fmt.Errorf("failed to create request: %w", err) } - if apiConfig != nil && apiConfig.ApiKey != nil && *apiConfig.ApiKey != "" { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := n.httpClient.Do(req) + resp, err := n.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/ollama.go b/internal/entity/models/ollama.go index d1dd752e56..c85251cfe8 100644 --- a/internal/entity/models/ollama.go +++ b/internal/entity/models/ollama.go @@ -30,40 +30,29 @@ import ( // OllamaModel implements ModelDriver for Ollama AI type OllamaModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } // NewOllamaModel creates a new Ollama AI model instance func NewOllamaModel(baseURL map[string]string, urlSuffix URLSuffix) *OllamaModel { return &OllamaModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + DisableCompression: false, + }, }, }, } } func (o *OllamaModel) NewInstance(baseURL map[string]string) ModelDriver { - return &OllamaModel{ - BaseURL: baseURL, - URLSuffix: o.URLSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, - }, - }, - } + return NewOllamaModel(baseURL, o.baseModel.URLSuffix) } func (o *OllamaModel) Name() string { @@ -75,17 +64,16 @@ func (o *OllamaModel) ChatWithMessages(modelName string, messages []Message, api return nil, fmt.Errorf("message is nil") } - var region = "default" - if apiConfig.Region != nil { - region = *apiConfig.Region + resolvedBaseURL, err := o.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", o.BaseURL[region], o.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, o.baseModel.URLSuffix.Chat) // For qwen/glm models, use async chat endpoint modelType := strings.Split(modelName, "_")[0] if modelType == "qwen" || modelType == "glm" { - url = fmt.Sprintf("%s/%s", o.BaseURL[region], o.URLSuffix.AsyncChat) + url = fmt.Sprintf("%s/%s", resolvedBaseURL, o.baseModel.URLSuffix.AsyncChat) } // Convert messages to API format @@ -158,7 +146,7 @@ func (o *OllamaModel) ChatWithMessages(modelName string, messages []Message, api req.Header.Set("Content-Type", "application/json") - resp, err := o.httpClient.Do(req) + resp, err := o.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -207,15 +195,14 @@ func (o *OllamaModel) ChatStreamlyWithSender(modelName string, messages []Messag return fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := o.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - url := fmt.Sprintf("%s/%s", o.BaseURL[region], o.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, o.baseModel.URLSuffix.Chat) modelType := strings.Split(modelName, "-")[0] if modelType == "qwen" || modelType == "glm" { - url = fmt.Sprintf("%s/%s", o.BaseURL[region], o.URLSuffix.AsyncChat) + url = fmt.Sprintf("%s/%s", resolvedBaseURL, o.baseModel.URLSuffix.AsyncChat) } // Convert messages to API format (supporting multimodal content) @@ -289,7 +276,7 @@ func (o *OllamaModel) ChatStreamlyWithSender(modelName string, messages []Messag req.Header.Set("Content-Type", "application/json") - resp, err := o.httpClient.Do(req) + resp, err := o.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -345,6 +332,10 @@ func (o *OllamaModel) ChatStreamlyWithSender(modelName string, messages []Messag } func (o *OllamaModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { + if err := o.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(texts) == 0 { return []EmbeddingData{}, nil } @@ -353,20 +344,19 @@ func (o *OllamaModel) Embed(modelName *string, texts []string, apiConfig *APICon return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := o.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - baseURL := o.BaseURL[region] + baseURL := resolvedBaseURL if baseURL == "" { - baseURL = o.BaseURL["default"] + baseURL = resolvedBaseURL } if baseURL == "" { return nil, fmt.Errorf("missing base URL: please configure the local access address for Ollama (e.g., http://127.0.0.1:11434/v1)") } - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), o.URLSuffix.Embedding) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), o.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": *modelName, @@ -390,11 +380,9 @@ func (o *OllamaModel) Embed(modelName *string, texts []string, apiConfig *APICon } req.Header.Set("Content-Type", "application/json") - if apiConfig != nil && apiConfig.ApiKey != nil && *apiConfig.ApiKey != "" { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := o.httpClient.Do(req) + resp, err := o.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -471,21 +459,20 @@ func (o *OllamaModel) ParseFile(modelName *string, content []byte, url *string, } func (o *OllamaModel) ListModels(apiConfig *APIConfig) ([]string, error) { - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := o.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - baseURL := o.BaseURL[region] + baseURL := resolvedBaseURL if baseURL == "" { - baseURL = o.BaseURL["default"] + baseURL = resolvedBaseURL } if baseURL == "" { return nil, fmt.Errorf("missing base URL: please configure the local access address for Ollama (e.g., http://127.0.0.1:11434/v1)") } - url := fmt.Sprintf("%s/%s", baseURL, o.URLSuffix.Models) + url := fmt.Sprintf("%s/%s", baseURL, o.baseModel.URLSuffix.Models) reqBody := map[string]interface{}{} jsonData, err := json.Marshal(reqBody) @@ -503,7 +490,7 @@ func (o *OllamaModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Content-Type", "application/json") - resp, err := o.httpClient.Do(req) + resp, err := o.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/openai.go b/internal/entity/models/openai.go index dabb3b7bbf..93711e9c5e 100644 --- a/internal/entity/models/openai.go +++ b/internal/entity/models/openai.go @@ -34,86 +34,54 @@ import ( ) // OpenAIModel implements ModelDriver for OpenAI (GPT models). -// The non-streaming call timeout is the shared nonStreamCallTimeout -// constant defined alongside the xAI driver in this package. type OpenAIModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client // Reusable HTTP client with connection pool + baseModel BaseModel } // NewOpenAIModel creates a new OpenAI model instance. -// -// We clone http.DefaultTransport so we keep Go's defaults for -// ProxyFromEnvironment, DialContext (with KeepAlive), HTTP/2, -// TLSHandshakeTimeout, and ExpectContinueTimeout, and only override -// the few connection-pool fields we care about. -// -// The Client itself has no Timeout. http.Client.Timeout would also -// cap the time spent reading the response body, which would cut off -// long-lived SSE streams in ChatStreamlyWithSender. Non-streaming -// callers wrap each request with context.WithTimeout instead. func NewOpenAIModel(baseURL map[string]string, urlSuffix URLSuffix) *OpenAIModel { transport := http.DefaultTransport.(*http.Transport).Clone() transport.MaxIdleConns = 100 transport.MaxIdleConnsPerHost = 10 transport.IdleConnTimeout = 90 * time.Second transport.DisableCompression = false - // Cap how long the client waits for the first response header. - // This protects ChatStreamlyWithSender, which has no client-wide - // timeout, against a server that opens the TCP connection and - // then never sends a response. transport.ResponseHeaderTimeout = 60 * time.Second return &OpenAIModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (o *OpenAIModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewOpenAIModel(baseURL, o.URLSuffix) + return NewOpenAIModel(baseURL, o.baseModel.URLSuffix) } func (o *OpenAIModel) Name() string { return "openai" } -// baseURLForRegion returns the base URL for the given region, or an -// error if no entry exists. This makes a misconfigured region fail -// fast with a clear message, instead of silently producing a relative -// URL that the HTTP transport then rejects. -func (o *OpenAIModel) baseURLForRegion(region string) (string, error) { - base, ok := o.BaseURL[region] - if !ok || base == "" { - return "", fmt.Errorf("openai: no base URL configured for region %q", region) - } - return base, nil -} - // ChatWithMessages sends multiple messages with roles and returns the response func (o *OpenAIModel) 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 err := o.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig.Region != nil { - region = *apiConfig.Region - } - - baseURL, err := o.baseURLForRegion(region) + baseURL, err := o.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, o.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, o.baseModel.URLSuffix.Chat) // Convert messages to the format expected by the API apiMessages := make([]map[string]interface{}, len(messages)) @@ -132,9 +100,6 @@ func (o *OpenAIModel) ChatWithMessages(modelName string, messages []Message, api "temperature": 1, } - // Note: do NOT propagate chatModelConfig.Stream into the request body - // here. ChatWithMessages parses a single JSON response, so SSE/stream - // must always be off for this code path. if chatModelConfig != nil { if chatModelConfig.MaxTokens != nil { reqBody["max_tokens"] = *chatModelConfig.MaxTokens @@ -169,7 +134,7 @@ func (o *OpenAIModel) ChatWithMessages(modelName string, messages []Message, api req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := o.httpClient.Do(req) + resp, err := o.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -228,27 +193,22 @@ func (o *OpenAIModel) ChatWithMessages(modelName string, messages []Message, api return chatResponse, nil } -// ChatStreamlyWithSender sends messages and streams the response via the -// sender function. Used for streaming chat responses with no extra channel. +// ChatStreamlyWithSender sends messages and streams the response func (o *OpenAIModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := o.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + 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") - } - - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := o.baseURLForRegion(region) + baseURL, err := o.baseModel.GetBaseURL(apiConfig) if err != nil { return err } - url := fmt.Sprintf("%s/%s", baseURL, o.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, o.baseModel.URLSuffix.Chat) // Convert messages to API format (supports multimodal content) apiMessages := make([]map[string]interface{}, len(messages)) @@ -267,11 +227,6 @@ func (o *OpenAIModel) ChatStreamlyWithSender(modelName string, messages []Messag } if chatModelConfig != nil { - // Refuse to run if the caller explicitly asked for stream=false. - // The body of this method only knows how to read SSE, so a non-SSE - // JSON response would be parsed as if it were a stream and produce - // no chunks. Better to fail clearly. Leave reqBody["stream"] as - // the default (true) when Stream is nil or true. if chatModelConfig.Stream != nil && !*chatModelConfig.Stream { return fmt.Errorf("stream must be true in ChatStreamlyWithSender") } @@ -298,11 +253,6 @@ func (o *OpenAIModel) ChatStreamlyWithSender(modelName string, messages []Messag return fmt.Errorf("failed to marshal request: %w", err) } - // Use an explicit background context here so the request is at least - // cancellable in principle. We do not attach a hard deadline because - // SSE streams are long-lived. The transport's ResponseHeaderTimeout - // caps the connection-establishment phase. Threading a real ctx - // through the ModelDriver interface is a wider change for a follow-up. req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(jsonData)) if err != nil { return fmt.Errorf("failed to create request: %w", err) @@ -311,7 +261,7 @@ func (o *OpenAIModel) ChatStreamlyWithSender(modelName string, messages []Messag req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := o.httpClient.Do(req) + resp, err := o.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -322,16 +272,8 @@ func (o *OpenAIModel) ChatStreamlyWithSender(modelName string, messages []Messag return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) } - // SSE parsing: read line by line. The default bufio.Scanner buffer - // is 64KB, which can be too small for long SSE chunks. Bump it to - // 1MB so we never silently truncate a long data: line. scanner := bufio.NewScanner(resp.Body) scanner.Buffer(make([]byte, 64*1024), 1024*1024) - // sawTerminal flips to true when the upstream actually told us the - // stream is over (either a "[DONE]" marker or a non-empty - // finish_reason). If the body closes before either of those, we - // must not emit a synthetic "[DONE]" because that would hide a - // truncated response from the caller. sawTerminal := false for scanner.Scan() { line := scanner.Text() @@ -426,33 +368,25 @@ type openaiUsage struct { TotalTokens int `json:"total_tokens"` } -// Embed turns a list of texts into embedding vectors using the -// OpenAI /v1/embeddings endpoint (e.g. text-embedding-3-small, -// text-embedding-3-large, text-embedding-ada-002). The output has -// one vector per input, in the same order the inputs were given. func (o *OpenAIModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { - if len(texts) == 0 { - return []EmbeddingData{}, nil + if err := o.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if len(texts) == 0 { + return []EmbeddingData{}, nil } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := o.baseURLForRegion(region) + baseURL, err := o.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, o.URLSuffix.Embedding) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, o.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": *modelName, @@ -478,7 +412,7 @@ func (o *OpenAIModel) Embed(modelName *string, texts []string, apiConfig *APICon req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := o.httpClient.Do(req) + resp, err := o.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -511,20 +445,16 @@ func (o *OpenAIModel) Embed(modelName *string, texts []string, apiConfig *APICon // ListModels returns the list of model ids visible to the API key. func (o *OpenAIModel) ListModels(apiConfig *APIConfig) ([]string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := o.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - var region = "default" - if apiConfig.Region != nil { - region = *apiConfig.Region - } - - baseURL, err := o.baseURLForRegion(region) + baseURL, err := o.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, o.URLSuffix.Models) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, o.baseModel.URLSuffix.Models) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -537,7 +467,7 @@ func (o *OpenAIModel) ListModels(apiConfig *APIConfig) ([]string, error) { // GET has no body, so Content-Type is not needed. req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := o.httpClient.Do(req) + resp, err := o.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -611,7 +541,7 @@ func (o *OpenAIModel) TranscribeAudio(modelName *string, file *string, apiConfig req.Header.Set("Accept", "application/json") - resp, err := o.httpClient.Do(req) + resp, err := o.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -640,7 +570,7 @@ func (o *OpenAIModel) TranscribeAudioWithSender(modelName *string, file *string, } req.Header.Set("Accept", "text/event-stream") - resp, err := o.httpClient.Do(req) + resp, err := o.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -746,7 +676,7 @@ func (o *OpenAIModel) AudioSpeech(modelName *string, audioContent *string, apiCo return nil, err } - resp, err := o.httpClient.Do(req) + resp, err := o.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -777,7 +707,7 @@ func (o *OpenAIModel) AudioSpeechWithSender(modelName *string, audioContent *str req.Header.Set("Accept", "text/event-stream") } - resp, err := o.httpClient.Do(req) + resp, err := o.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -795,8 +725,8 @@ func (o *OpenAIModel) AudioSpeechWithSender(modelName *string, audioContent *str } func (o *OpenAIModel) newOpenAIASRRequest(ctx context.Context, modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig, stream bool) (*http.Request, string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, "", fmt.Errorf("api key is required") + if err := o.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, "", err } if modelName == nil || *modelName == "" { return nil, "", fmt.Errorf("model name is required") @@ -804,20 +734,16 @@ func (o *OpenAIModel) newOpenAIASRRequest(ctx context.Context, modelName *string if file == nil || *file == "" { return nil, "", fmt.Errorf("file is missing") } - if strings.TrimSpace(o.URLSuffix.ASR) == "" { + if strings.TrimSpace(o.baseModel.URLSuffix.ASR) == "" { return nil, "", fmt.Errorf("openai ASR URL suffix is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := o.baseURLForRegion(region) + baseURL, err := o.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, "", err } - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), strings.TrimPrefix(o.URLSuffix.ASR, "/")) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), strings.TrimPrefix(o.baseModel.URLSuffix.ASR, "/")) var body bytes.Buffer writer := multipart.NewWriter(&body) @@ -874,8 +800,8 @@ func (o *OpenAIModel) newOpenAIASRRequest(ctx context.Context, modelName *string } func (o *OpenAIModel) newOpenAITTSRequest(ctx context.Context, modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig, stream bool) (*http.Request, string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, "", fmt.Errorf("api key is required") + if err := o.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, "", err } if modelName == nil || *modelName == "" { return nil, "", fmt.Errorf("model name is required") @@ -883,20 +809,16 @@ func (o *OpenAIModel) newOpenAITTSRequest(ctx context.Context, modelName *string if audioContent == nil || *audioContent == "" { return nil, "", fmt.Errorf("audio content is empty") } - if strings.TrimSpace(o.URLSuffix.TTS) == "" { + if strings.TrimSpace(o.baseModel.URLSuffix.TTS) == "" { return nil, "", fmt.Errorf("openai TTS URL suffix is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := o.baseURLForRegion(region) + baseURL, err := o.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, "", err } - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), strings.TrimPrefix(o.URLSuffix.TTS, "/")) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), strings.TrimPrefix(o.baseModel.URLSuffix.TTS, "/")) reqBody := map[string]interface{}{ "model": *modelName, diff --git a/internal/entity/models/openrouter.go b/internal/entity/models/openrouter.go index 4361f48a65..33d0bfcff9 100644 --- a/internal/entity/models/openrouter.go +++ b/internal/entity/models/openrouter.go @@ -34,40 +34,29 @@ import ( // OpenRouterModel implements ModelDriver for OpenRouter AI type OpenRouterModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } // NewOpenRouterModel creates a new OpenRouter AI model instance func NewOpenRouterModel(baseURL map[string]string, urlSuffix URLSuffix) *OpenRouterModel { return &OpenRouterModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 10, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 10, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: 90 * time.Second, + DisableCompression: false, + }, }, }, } } func (o *OpenRouterModel) NewInstance(baseURL map[string]string) ModelDriver { - return &OpenRouterModel{ - BaseURL: baseURL, - URLSuffix: o.URLSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 10, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, - }, - }, - } + return NewOpenRouterModel(baseURL, o.baseModel.URLSuffix) } func (o *OpenRouterModel) Name() string { @@ -75,19 +64,18 @@ func (o *OpenRouterModel) Name() string { } func (o *OpenRouterModel) 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 nil or empty") + if err := o.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := o.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", o.BaseURL[region], o.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, o.baseModel.URLSuffix.Chat) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -150,7 +138,7 @@ func (o *OpenRouterModel) ChatWithMessages(modelName string, messages []Message, req.Header.Add("Content-Type", "application/json") req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := o.httpClient.Do(req) + resp, err := o.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -211,20 +199,23 @@ func (o *OpenRouterModel) ChatWithMessages(modelName string, messages []Message, } func (o *OpenRouterModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, modelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := o.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if len(messages) == 0 { return fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := o.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - url := fmt.Sprintf("%s/%s", o.BaseURL[region], o.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, o.baseModel.URLSuffix.Chat) modelType := strings.Split(modelName, "_")[0] if modelType == "qwen" || modelType == "glm" { - url = fmt.Sprintf("%s/%s", o.BaseURL[region], o.URLSuffix.AsyncChat) + url = fmt.Sprintf("%s/%s", resolvedBaseURL, o.baseModel.URLSuffix.AsyncChat) } // Convert messages to API format @@ -297,7 +288,7 @@ func (o *OpenRouterModel) ChatStreamlyWithSender(modelName string, messages []Me req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := o.httpClient.Do(req) + resp, err := o.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -397,6 +388,10 @@ type openrouterUsage struct { } func (o *OpenRouterModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { + if err := o.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(texts) == 0 { return []EmbeddingData{}, nil } @@ -404,12 +399,11 @@ func (o *OpenRouterModel) Embed(modelName *string, texts []string, apiConfig *AP return nil, fmt.Errorf("model name is required") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := o.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", o.BaseURL[region], o.URLSuffix.Embedding) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, o.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": *modelName, @@ -434,11 +428,9 @@ func (o *OpenRouterModel) Embed(modelName *string, texts []string, apiConfig *AP } req.Header.Set("Content-Type", "application/json") - if apiConfig != nil && apiConfig.ApiKey != nil && *apiConfig.ApiKey != "" { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := o.httpClient.Do(req) + resp, err := o.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -491,13 +483,12 @@ type OpenRouterRerankResponse struct { } func (o *OpenRouterModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { - if len(documents) == 0 { - return &RerankResponse{}, nil + if err := o.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + if len(documents) == 0 { + return &RerankResponse{}, nil } var topN = rerankConfig.TopN @@ -517,7 +508,11 @@ func (o *OpenRouterModel) Rerank(modelName *string, query string, documents []st return nil, fmt.Errorf("failed to marshal request: %w", err) } - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(o.BaseURL[region], "/"), o.URLSuffix.Rerank) + resolvedBaseURL, err := o.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(resolvedBaseURL, "/"), o.baseModel.URLSuffix.Rerank) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -530,7 +525,7 @@ func (o *OpenRouterModel) Rerank(modelName *string, query string, documents []st req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := o.httpClient.Do(req) + resp, err := o.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -583,8 +578,8 @@ func openRouterAudioFormat(file string, asrConfig *ASRConfig) string { // TranscribeAudio transcribe audio func (o *OpenRouterModel) TranscribeAudio(modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig) (*ASRResponse, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("OpenRouter API key is missing") + if err := o.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") @@ -592,7 +587,7 @@ func (o *OpenRouterModel) TranscribeAudio(modelName *string, file *string, apiCo if file == nil || *file == "" { return nil, fmt.Errorf("file is missing") } - if o.URLSuffix.ASR == "" { + if o.baseModel.URLSuffix.ASR == "" { return nil, fmt.Errorf("OpenRouter ASR url suffix is missing") } @@ -601,11 +596,6 @@ func (o *OpenRouterModel) TranscribeAudio(modelName *string, file *string, apiCo return nil, fmt.Errorf("failed to read audio file: %w", err) } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - reqBody := map[string]interface{}{ "model": *modelName, "input_audio": map[string]interface{}{ @@ -629,7 +619,11 @@ func (o *OpenRouterModel) TranscribeAudio(modelName *string, file *string, apiCo return nil, fmt.Errorf("failed to marshal request: %w", err) } - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(o.BaseURL[region], "/"), o.URLSuffix.ASR) + resolvedBaseURL, err := o.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(resolvedBaseURL, "/"), o.baseModel.URLSuffix.ASR) ctx, cancel := context.WithTimeout(context.Background(), longOpCallTimeout) defer cancel() @@ -641,7 +635,7 @@ func (o *OpenRouterModel) TranscribeAudio(modelName *string, file *string, apiCo req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := o.httpClient.Do(req) + resp, err := o.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -670,19 +664,18 @@ func (o *OpenRouterModel) TranscribeAudioWithSender(modelName *string, file *str // AudioSpeech convert text to audio func (o *OpenRouterModel) AudioSpeech(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig) (*TTSResponse, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("OpenRouter API key is missing") + if err := o.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if audioContent == nil || *audioContent == "" { return nil, fmt.Errorf("text content is empty") } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := o.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", o.BaseURL[region], o.URLSuffix.TTS) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, o.baseModel.URLSuffix.TTS) // OpenRouter:response Audio bytes stream reqBody := map[string]interface{}{ @@ -715,7 +708,7 @@ func (o *OpenRouterModel) AudioSpeech(modelName *string, audioContent *string, a req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := o.httpClient.Do(req) + resp, err := o.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -748,12 +741,15 @@ func (o *OpenRouterModel) ParseFile(modelName *string, content []byte, url *stri } func (o *OpenRouterModel) ListModels(apiConfig *APIConfig) ([]string, error) { - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + if err := o.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - url := fmt.Sprintf("%s/%s", o.BaseURL[region], o.URLSuffix.Models) + resolvedBaseURL, err := o.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", resolvedBaseURL, o.baseModel.URLSuffix.Models) // Build request body reqBody := map[string]interface{}{} @@ -774,7 +770,7 @@ func (o *OpenRouterModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := o.httpClient.Do(req) + resp, err := o.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -807,12 +803,15 @@ func (o *OpenRouterModel) ListModels(apiConfig *APIConfig) ([]string, error) { } func (o *OpenRouterModel) Balance(apiConfig *APIConfig) (map[string]interface{}, error) { - region := "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + if err := o.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - url := fmt.Sprintf("%s/%s", o.BaseURL[region], o.URLSuffix.Balance) + baseURL, err := o.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", baseURL, o.baseModel.URLSuffix.Balance) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -825,7 +824,7 @@ func (o *OpenRouterModel) Balance(apiConfig *APIConfig) (map[string]interface{}, req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := o.httpClient.Do(req) + resp, err := o.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/orcarouter.go b/internal/entity/models/orcarouter.go index 62056f0742..57892cdcbf 100644 --- a/internal/entity/models/orcarouter.go +++ b/internal/entity/models/orcarouter.go @@ -30,28 +30,28 @@ import ( ) type OrcaRouterModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } func NewOrcaRouterModel(baseURL map[string]string, urlSuffix URLSuffix) *OrcaRouterModel { return &OrcaRouterModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + DisableCompression: false, + }, }, }, } } func (o *OrcaRouterModel) NewInstance(baseURL map[string]string) ModelDriver { - return nil + return NewOrcaRouterModel(baseURL, o.baseModel.URLSuffix) } func (o *OrcaRouterModel) Name() string { @@ -59,19 +59,18 @@ func (o *OrcaRouterModel) Name() string { } func (o *OrcaRouterModel) 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 nil or empty") + if err := o.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := o.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", o.BaseURL[region], o.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, o.baseModel.URLSuffix.Chat) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -128,7 +127,7 @@ func (o *OrcaRouterModel) ChatWithMessages(modelName string, messages []Message, req.Header.Add("Content-Type", "application/json") req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := o.httpClient.Do(req) + resp, err := o.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -180,16 +179,19 @@ func (o *OrcaRouterModel) ChatWithMessages(modelName string, messages []Message, } func (o *OrcaRouterModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, modelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := o.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if len(messages) == 0 { return fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := o.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - url := fmt.Sprintf("%s/%s", o.BaseURL[region], o.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, o.baseModel.URLSuffix.Chat) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -245,7 +247,7 @@ func (o *OrcaRouterModel) ChatStreamlyWithSender(modelName string, messages []Me req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := o.httpClient.Do(req) + resp, err := o.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -336,16 +338,19 @@ func (o *OrcaRouterModel) TranscribeAudioWithSender(modelName *string, file *str } func (o *OrcaRouterModel) AudioSpeech(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig) (*TTSResponse, error) { + if err := o.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if audioContent == nil || *audioContent == "" { return nil, fmt.Errorf("audio content is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := o.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", o.BaseURL[region], o.URLSuffix.TTS) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, o.baseModel.URLSuffix.TTS) reqBody := map[string]interface{}{ "model": *modelName, @@ -378,7 +383,7 @@ func (o *OrcaRouterModel) AudioSpeech(modelName *string, audioContent *string, a req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := o.httpClient.Do(req) + resp, err := o.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -409,12 +414,15 @@ func (o *OrcaRouterModel) ParseFile(modelName *string, content []byte, url *stri } func (o *OrcaRouterModel) ListModels(apiConfig *APIConfig) ([]string, error) { - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + if err := o.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - url := fmt.Sprintf("%s/%s", o.BaseURL[region], o.URLSuffix.Models) + resolvedBaseURL, err := o.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", resolvedBaseURL, o.baseModel.URLSuffix.Models) reqBody := map[string]string{} @@ -434,7 +442,7 @@ func (o *OrcaRouterModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := o.httpClient.Do(req) + resp, err := o.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/paddleocr.go b/internal/entity/models/paddleocr.go index edd6d12c2b..2969a055fc 100644 --- a/internal/entity/models/paddleocr.go +++ b/internal/entity/models/paddleocr.go @@ -30,39 +30,28 @@ import ( ) type PaddleOCRModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } func NewPaddleOCRModel(baseURL map[string]string, urlSuffix URLSuffix) *PaddleOCRModel { return &PaddleOCRModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + DisableCompression: false, + }, }, }, } } func (p PaddleOCRModel) NewInstance(baseURL map[string]string) ModelDriver { - return &PaddleOCRModel{ - BaseURL: baseURL, - URLSuffix: p.URLSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, - }, - }, - } + return NewPaddleOCRModel(baseURL, p.baseModel.URLSuffix) } func (p *PaddleOCRModel) Name() string { @@ -128,20 +117,19 @@ type paddleJsonlLine struct { } func (p *PaddleOCRModel) OCRFile(modelName *string, content []byte, fileURL *string, apiConfig *APIConfig, ocrConfig *OCRConfig) (*OCRFileResponse, error) { + if err := p.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if (content == nil || len(content) == 0) && (fileURL == nil || *fileURL == "") { return nil, fmt.Errorf("content and fileURL cannot be both empty") } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + resolvedBaseURL, err := p.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - url := fmt.Sprintf("%s/%s", p.BaseURL[region], p.URLSuffix.OCR) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, p.baseModel.URLSuffix.OCR) optionalPayload := map[string]bool{ "useDocOrientationClassify": false, @@ -156,7 +144,6 @@ func (p *PaddleOCRModel) OCRFile(modelName *string, content []byte, fileURL *str defer cancel() var req *http.Request - var err error if fileURL != nil && strings.HasPrefix(*fileURL, "http") { reqData := map[string]interface{}{ @@ -190,7 +177,7 @@ func (p *PaddleOCRModel) OCRFile(modelName *string, content []byte, fileURL *str req.Header.Set("Authorization", fmt.Sprintf("bearer %s", *apiConfig.ApiKey)) - resp, err := p.httpClient.Do(req) + resp, err := p.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to submit job: %w", err) } @@ -215,8 +202,6 @@ func (p *PaddleOCRModel) OCRFile(modelName *string, content []byte, fileURL *str var jsonlUrl string for { - // Wait between polls but bail out immediately if the overall - // deadline fires instead of sleeping through it. select { case <-time.After(3 * time.Second): case <-ctx.Done(): @@ -226,7 +211,7 @@ func (p *PaddleOCRModel) OCRFile(modelName *string, content []byte, fileURL *str pollReq, _ := http.NewRequestWithContext(ctx, "GET", pollUrl, nil) pollReq.Header.Set("Authorization", fmt.Sprintf("bearer %s", *apiConfig.ApiKey)) - pollResp, err := p.httpClient.Do(pollReq) + pollResp, err := p.baseModel.httpClient.Do(pollReq) if err != nil { return nil, fmt.Errorf("failed to poll job status: %w", err) } @@ -262,7 +247,7 @@ func (p *PaddleOCRModel) OCRFile(modelName *string, content []byte, fileURL *str return nil, fmt.Errorf("failed to create request for jsonl: %w", err) } - resResp, err := p.httpClient.Do(resReq) + resResp, err := p.baseModel.httpClient.Do(resReq) if err != nil { return nil, fmt.Errorf("failed to download jsonl result: %w", err) } diff --git a/internal/entity/models/paddleocr_local.go b/internal/entity/models/paddleocr_local.go index 308849caf4..28c307dea0 100644 --- a/internal/entity/models/paddleocr_local.go +++ b/internal/entity/models/paddleocr_local.go @@ -29,39 +29,28 @@ import ( ) type PaddleOCRLocalModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } func NewPaddleOCRLocalModel(baseURL map[string]string, urlSuffix URLSuffix) *PaddleOCRLocalModel { return &PaddleOCRLocalModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 10, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: time.Second * 90, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 10, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: time.Second * 90, + DisableCompression: false, + }, }, }, } } func (p *PaddleOCRLocalModel) NewInstance(baseURL map[string]string) ModelDriver { - return &PaddleOCRLocalModel{ - BaseURL: baseURL, - URLSuffix: p.URLSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 10, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: time.Second * 90, - DisableCompression: false, - }, - }, - } + return NewPaddleOCRLocalModel(baseURL, p.baseModel.URLSuffix) } func (p *PaddleOCRLocalModel) Name() string { @@ -121,12 +110,11 @@ func (p *PaddleOCRLocalModel) OCRFile(modelName *string, content []byte, fileURL return nil, fmt.Errorf("local PaddleOCR requires file content, but content is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := p.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", p.BaseURL[region], p.URLSuffix.OCR) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, p.baseModel.URLSuffix.OCR) base64Str := base64.StdEncoding.EncodeToString(content) @@ -159,7 +147,7 @@ func (p *PaddleOCRLocalModel) OCRFile(modelName *string, content []byte, fileURL req.Header.Set("Content-Type", "application/json") - resp, err := p.httpClient.Do(req) + resp, err := p.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request to local PaddleOCR: %w", err) } diff --git a/internal/entity/models/perplexity.go b/internal/entity/models/perplexity.go index a8c4205504..2be7a2c971 100644 --- a/internal/entity/models/perplexity.go +++ b/internal/entity/models/perplexity.go @@ -29,9 +29,7 @@ import ( ) type PerplexityModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } func NewPerplexityModel(baseURL map[string]string, urlSuffix URLSuffix) *PerplexityModel { @@ -43,30 +41,24 @@ func NewPerplexityModel(baseURL map[string]string, urlSuffix URLSuffix) *Perplex transport.ResponseHeaderTimeout = 60 * time.Second return &PerplexityModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (p *PerplexityModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewPerplexityModel(baseURL, p.URLSuffix) + return NewPerplexityModel(baseURL, p.baseModel.URLSuffix) } func (p *PerplexityModel) Name() string { return "perplexity" } -func (p *PerplexityModel) baseURLForRegion(region string) (string, error) { - base, ok := p.BaseURL[region] - if !ok || base == "" { - return "", fmt.Errorf("perplexity: no base URL configured for region %q", region) - } - return strings.TrimSuffix(base, "/"), nil -} - func (p *PerplexityModel) chatPayload(modelName string, messages []Message, stream bool, chatModelConfig *ChatConfig) map[string]interface{} { apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -105,16 +97,13 @@ func (p *PerplexityModel) chatPayload(modelName string, messages []Message, stre } func (p *PerplexityModel) chatURL(apiConfig *APIConfig) (string, error) { - region := "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - baseURL, err := p.baseURLForRegion(region) + baseURL, err := p.baseModel.GetBaseURL(apiConfig) if err != nil { return "", err } - return fmt.Sprintf("%s/%s", baseURL, p.URLSuffix.Chat), nil + baseURL = strings.TrimSuffix(baseURL, "/") + return fmt.Sprintf("%s/%s", baseURL, p.baseModel.URLSuffix.Chat), nil } type perplexityChatMessage struct { @@ -136,8 +125,8 @@ type perplexityChatResponse struct { } func (p *PerplexityModel) 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 err := p.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if strings.TrimSpace(modelName) == "" { return nil, fmt.Errorf("model name is required") @@ -166,7 +155,7 @@ func (p *PerplexityModel) ChatWithMessages(modelName string, messages []Message, req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := p.httpClient.Do(req) + resp, err := p.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -203,12 +192,13 @@ func (p *PerplexityModel) ChatWithMessages(modelName string, messages []Message, } func (p *PerplexityModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := p.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if sender == nil { return fmt.Errorf("sender is required") } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return fmt.Errorf("api key is required") - } if strings.TrimSpace(modelName) == "" { return fmt.Errorf("model name is required") } @@ -239,11 +229,12 @@ func (p *PerplexityModel) ChatStreamlyWithSender(modelName string, messages []Me 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)) - req.Header.Set("Accept", "text/event-stream") - resp, err := p.httpClient.Do(req) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) + + resp, err := p.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -321,20 +312,16 @@ type perplexityModelListResponse struct { } func (p *PerplexityModel) ListModels(apiConfig *APIConfig) ([]string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := p.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := p.baseURLForRegion(region) + baseURL, err := p.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, p.URLSuffix.Models) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, p.baseModel.URLSuffix.Models) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -345,7 +332,7 @@ func (p *PerplexityModel) ListModels(apiConfig *APIConfig) ([]string, error) { } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := p.httpClient.Do(req) + resp, err := p.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -403,26 +390,23 @@ type perplexityEmbeddingResponse struct { } func (p *PerplexityModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { + if err := p.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(texts) == 0 { return []EmbeddingData{}, nil } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") - } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := p.baseURLForRegion(region) + baseURL, err := p.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, p.URLSuffix.Embedding) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, p.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": *modelName, @@ -447,7 +431,7 @@ func (p *PerplexityModel) Embed(modelName *string, texts []string, apiConfig *AP req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := p.httpClient.Do(req) + resp, err := p.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/ppio.go b/internal/entity/models/ppio.go index e9bcc33008..34c4414216 100644 --- a/internal/entity/models/ppio.go +++ b/internal/entity/models/ppio.go @@ -32,9 +32,7 @@ import ( // // PPIO exposes OpenAI-compatible chat completions and model listing endpoints. type PPIOModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } func NewPPIOModel(baseURL map[string]string, urlSuffix URLSuffix) *PPIOModel { @@ -54,45 +52,30 @@ func NewPPIOModel(baseURL map[string]string, urlSuffix URLSuffix) *PPIOModel { transport.ResponseHeaderTimeout = 60 * time.Second return &PPIOModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (p *PPIOModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewPPIOModel(baseURL, p.URLSuffix) + return NewPPIOModel(baseURL, p.baseModel.URLSuffix) } func (p *PPIOModel) Name() string { return "ppio" } -func (p *PPIOModel) baseURLForRegion(region string) (string, error) { - base, ok := p.BaseURL[region] - if ok && base != "" { - return strings.TrimSuffix(base, "/"), nil - } - if region == "" { - if base, ok := p.BaseURL["default"]; ok && base != "" { - return strings.TrimSuffix(base, "/"), nil - } - } - return "", fmt.Errorf("ppio: no base URL configured for region %q", region) -} - func (p *PPIOModel) endpoint(apiConfig *APIConfig, suffix string) (string, error) { - region := "default" - if apiConfig != nil && apiConfig.Region != nil { - region = *apiConfig.Region - } - - baseURL, err := p.baseURLForRegion(region) + baseURL, err := p.baseModel.GetBaseURL(apiConfig) if err != nil { return "", err } + baseURL = strings.TrimSuffix(baseURL, "/") return fmt.Sprintf("%s/%s", baseURL, strings.TrimPrefix(suffix, "/")), nil } @@ -148,8 +131,8 @@ type ppioChatResponse struct { } func (p *PPIOModel) 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 err := p.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if strings.TrimSpace(modelName) == "" { return nil, fmt.Errorf("model name is required") @@ -158,7 +141,7 @@ func (p *PPIOModel) ChatWithMessages(modelName string, messages []Message, apiCo return nil, fmt.Errorf("messages is empty") } - url, err := p.endpoint(apiConfig, p.URLSuffix.Chat) + url, err := p.endpoint(apiConfig, p.baseModel.URLSuffix.Chat) if err != nil { return nil, err } @@ -178,7 +161,7 @@ func (p *PPIOModel) ChatWithMessages(modelName string, messages []Message, apiCo req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := p.httpClient.Do(req) + resp, err := p.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -216,12 +199,13 @@ func (p *PPIOModel) ChatWithMessages(modelName string, messages []Message, apiCo } func (p *PPIOModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := p.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if sender == nil { return fmt.Errorf("sender is required") } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return fmt.Errorf("api key is required") - } if strings.TrimSpace(modelName) == "" { return fmt.Errorf("model name is required") } @@ -232,7 +216,7 @@ func (p *PPIOModel) ChatStreamlyWithSender(modelName string, messages []Message, return fmt.Errorf("stream must be true in ChatStreamlyWithSender") } - url, err := p.endpoint(apiConfig, p.URLSuffix.Chat) + url, err := p.endpoint(apiConfig, p.baseModel.URLSuffix.Chat) if err != nil { return err } @@ -253,7 +237,7 @@ func (p *PPIOModel) ChatStreamlyWithSender(modelName string, messages []Message, req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) req.Header.Set("Accept", "text/event-stream") - resp, err := p.httpClient.Do(req) + resp, err := p.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -331,11 +315,11 @@ type ppioListModelsResponse struct { } func (p *PPIOModel) ListModels(apiConfig *APIConfig) ([]string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := p.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - url, err := p.endpoint(apiConfig, p.URLSuffix.Models) + url, err := p.endpoint(apiConfig, p.baseModel.URLSuffix.Models) if err != nil { return nil, err } @@ -350,7 +334,7 @@ func (p *PPIOModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := p.httpClient.Do(req) + resp, err := p.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/ppio_test.go b/internal/entity/models/ppio_test.go index 54800fdd26..34bfbc7b45 100644 --- a/internal/entity/models/ppio_test.go +++ b/internal/entity/models/ppio_test.go @@ -409,7 +409,7 @@ func TestPPIOListModelsRejectsProviderError(t *testing.T) { func TestPPIOEndpointTrimsTrailingSlash(t *testing.T) { model := NewPPIOModel(map[string]string{"default": "https://example.test/base/"}, URLSuffix{Chat: "/chat/completions"}) apiKey := "test-key" - endpoint, err := model.endpoint(&APIConfig{ApiKey: &apiKey}, model.URLSuffix.Chat) + endpoint, err := model.endpoint(&APIConfig{ApiKey: &apiKey}, model.baseModel.URLSuffix.Chat) if err != nil { t.Fatalf("endpoint: %v", err) } @@ -421,7 +421,7 @@ func TestPPIOEndpointTrimsTrailingSlash(t *testing.T) { func TestPPIODefaultEndpointUsesPPIOAPI(t *testing.T) { model := NewPPIOModel(map[string]string{"default": "https://api.ppio.com/openai/v1"}, URLSuffix{Chat: "chat/completions"}) apiKey := "test-key" - endpoint, err := model.endpoint(&APIConfig{ApiKey: &apiKey}, model.URLSuffix.Chat) + endpoint, err := model.endpoint(&APIConfig{ApiKey: &apiKey}, model.baseModel.URLSuffix.Chat) if err != nil { t.Fatalf("endpoint: %v", err) } @@ -434,7 +434,7 @@ func TestPPIOEmptyRegionCustomBaseURL(t *testing.T) { model := NewPPIOModel(map[string]string{"": "https://custom.example/openai/v1"}, URLSuffix{Models: "models"}) apiKey := "test-key" region := "" - endpoint, err := model.endpoint(&APIConfig{ApiKey: &apiKey, Region: ®ion}, model.URLSuffix.Models) + endpoint, err := model.endpoint(&APIConfig{ApiKey: &apiKey, Region: ®ion}, model.baseModel.URLSuffix.Models) if err != nil { t.Fatalf("endpoint: %v", err) } @@ -450,7 +450,7 @@ func TestPPIONamedRegionBaseURL(t *testing.T) { }, URLSuffix{Chat: "chat/completions"}) apiKey := "test-key" region := "us" - endpoint, err := model.endpoint(&APIConfig{ApiKey: &apiKey, Region: ®ion}, model.URLSuffix.Chat) + endpoint, err := model.endpoint(&APIConfig{ApiKey: &apiKey, Region: ®ion}, model.baseModel.URLSuffix.Chat) if err != nil { t.Fatalf("endpoint: %v", err) } @@ -463,7 +463,7 @@ func TestPPIOMissingRegionBaseURL(t *testing.T) { model := NewPPIOModel(map[string]string{"default": "https://api.ppinfra.com/v3/openai"}, URLSuffix{Models: "models"}) apiKey := "test-key" region := "missing" - _, err := model.endpoint(&APIConfig{ApiKey: &apiKey, Region: ®ion}, model.URLSuffix.Models) + _, err := model.endpoint(&APIConfig{ApiKey: &apiKey, Region: ®ion}, model.baseModel.URLSuffix.Models) if err == nil || !strings.Contains(err.Error(), "no base URL configured") { t.Errorf("expected base URL error, got %v", err) } diff --git a/internal/entity/models/qiniu.go b/internal/entity/models/qiniu.go index 0ddf4d471f..cc048f3393 100644 --- a/internal/entity/models/qiniu.go +++ b/internal/entity/models/qiniu.go @@ -31,28 +31,28 @@ import ( ) type QiniuModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } func NewQiniuModel(baseURL map[string]string, urlSuffix URLSuffix) *QiniuModel { return &QiniuModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxConnsPerHost: 10, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + MaxConnsPerHost: 10, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: 90 * time.Second, + DisableCompression: false, + }, }, }, } } func (q *QiniuModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewQiniuModel(baseURL, q.URLSuffix) + return NewQiniuModel(baseURL, q.baseModel.URLSuffix) } func (q *QiniuModel) Name() string { @@ -150,18 +150,18 @@ func applyQiniuThinkingConfig(reqBody map[string]interface{}, modelName string, } func (q *QiniuModel) 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 nil or empty") + if err := q.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if len(messages) == 0 { return nil, fmt.Errorf("no messages") } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := q.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - url := fmt.Sprintf("%s/%s", q.BaseURL[region], q.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, q.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -213,7 +213,7 @@ func (q *QiniuModel) ChatWithMessages(modelName string, messages []Message, apiC req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := q.httpClient.Do(req) + resp, err := q.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -274,22 +274,19 @@ func (q *QiniuModel) ChatWithMessages(modelName string, messages []Message, apiC } func (q *QiniuModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return fmt.Errorf("api key is nil or empty") + if err := q.baseModel.APIConfigCheck(apiConfig); err != nil { + return err } if len(messages) == 0 { return fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := q.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - baseURL := strings.TrimSuffix(q.BaseURL[region], "/") - if baseURL == "" { - return fmt.Errorf("qiniu: no base URL configured for region %q", region) - } - url := fmt.Sprintf("%s/%s", baseURL, q.URLSuffix.Chat) + baseURL := strings.TrimSuffix(resolvedBaseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, q.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -340,7 +337,7 @@ func (q *QiniuModel) ChatStreamlyWithSender(modelName string, messages []Message req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := q.httpClient.Do(req) + resp, err := q.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -449,19 +446,16 @@ func (q *QiniuModel) ParseFile(modelName *string, content []byte, url *string, a } func (q *QiniuModel) ListModels(apiConfig *APIConfig) ([]string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is nil or empty") + if err := q.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := q.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - baseURL := strings.TrimSuffix(q.BaseURL[region], "/") - if baseURL == "" { - return nil, fmt.Errorf("qiniu: no base URL configured for region %q", region) - } - url := fmt.Sprintf("%s/%s", baseURL, q.URLSuffix.Models) + baseURL := strings.TrimSuffix(resolvedBaseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, q.baseModel.URLSuffix.Models) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -472,7 +466,7 @@ func (q *QiniuModel) ListModels(apiConfig *APIConfig) ([]string, error) { } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := q.httpClient.Do(req) + resp, err := q.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/replicate.go b/internal/entity/models/replicate.go index 135fcebf4f..65644de70b 100644 --- a/internal/entity/models/replicate.go +++ b/internal/entity/models/replicate.go @@ -33,9 +33,7 @@ import ( const replicatePollInterval = time.Second type ReplicateModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } func NewReplicateModel(baseURL map[string]string, urlSuffix URLSuffix) *ReplicateModel { @@ -47,16 +45,18 @@ func NewReplicateModel(baseURL map[string]string, urlSuffix URLSuffix) *Replicat transport.ResponseHeaderTimeout = 60 * time.Second return &ReplicateModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (r *ReplicateModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewReplicateModel(baseURL, r.URLSuffix) + return NewReplicateModel(baseURL, r.baseModel.URLSuffix) } func (r *ReplicateModel) Name() string { @@ -88,24 +88,13 @@ type replicateSSEEvent struct { data string } -func (r *ReplicateModel) baseURLForRegion(region string) (string, error) { - base, ok := r.BaseURL[region] - if !ok || base == "" { - return "", fmt.Errorf("replicate: no base URL configured for region %q", region) - } - return strings.TrimSuffix(base, "/"), nil -} - func (r *ReplicateModel) endpoint(apiConfig *APIConfig, suffix string) (string, error) { - region := "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - baseURL, err := r.baseURLForRegion(region) + baseURL, err := r.baseModel.GetBaseURL(apiConfig) if err != nil { return "", err } + baseURL = strings.TrimSuffix(baseURL, "/") return fmt.Sprintf("%s/%s", baseURL, suffix), nil } @@ -116,7 +105,7 @@ func replicateUsesVersionEndpoint(modelName string) bool { func (r *ReplicateModel) predictionEndpoint(apiConfig *APIConfig, modelName string) (string, string, error) { if replicateUsesVersionEndpoint(modelName) { - endpoint, err := r.endpoint(apiConfig, r.URLSuffix.Chat) + endpoint, err := r.endpoint(apiConfig, r.baseModel.URLSuffix.Chat) return endpoint, modelName, err } @@ -125,7 +114,7 @@ func (r *ReplicateModel) predictionEndpoint(apiConfig *APIConfig, modelName stri return "", "", fmt.Errorf("replicate: official model name must be owner/name") } - modelsPrefix := strings.TrimSuffix(r.URLSuffix.Models, "models") + modelsPrefix := strings.TrimSuffix(r.baseModel.URLSuffix.Models, "models") if modelsPrefix == "" { modelsPrefix = "v1/" } @@ -248,7 +237,7 @@ func (r *ReplicateModel) createPrediction(ctx context.Context, url string, versi req.Header.Set("Prefer", "wait=60") } - resp, err := r.httpClient.Do(req) + resp, err := r.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -288,7 +277,7 @@ func (r *ReplicateModel) getPrediction(ctx context.Context, url string, apiKey s req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) - resp, err := r.httpClient.Do(req) + resp, err := r.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -342,8 +331,8 @@ func (r *ReplicateModel) waitForPrediction(ctx context.Context, prediction *repl } func (r *ReplicateModel) 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 err := r.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if strings.TrimSpace(modelName) == "" { return nil, fmt.Errorf("model name is required") @@ -381,12 +370,13 @@ func (r *ReplicateModel) ChatWithMessages(modelName string, messages []Message, } func (r *ReplicateModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := r.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if sender == nil { return fmt.Errorf("sender is required") } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return fmt.Errorf("api key is required") - } if strings.TrimSpace(modelName) == "" { return fmt.Errorf("model name is required") } @@ -437,7 +427,7 @@ func (r *ReplicateModel) readPredictionStream(url string, apiKey string, sender req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", apiKey)) req.Header.Set("Accept", "text/event-stream") - resp, err := r.httpClient.Do(req) + resp, err := r.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -515,11 +505,11 @@ func dispatchReplicateSSEEvent(event replicateSSEEvent, sender func(*string, *st } func (r *ReplicateModel) ListModels(apiConfig *APIConfig) ([]string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := r.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - url, err := r.endpoint(apiConfig, r.URLSuffix.Models) + url, err := r.endpoint(apiConfig, r.baseModel.URLSuffix.Models) if err != nil { return nil, err } @@ -534,7 +524,7 @@ func (r *ReplicateModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := r.httpClient.Do(req) + resp, err := r.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -679,12 +669,13 @@ func replicateKeys(m map[string]interface{}) []string { // {embedding: [floats]} objects); see replicateEmbedInput and // replicateEmbedOutputToVectors for details. func (r *ReplicateModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { + if err := r.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(texts) == 0 { return []EmbeddingData{}, nil } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") - } if modelName == nil || strings.TrimSpace(*modelName) == "" { return nil, fmt.Errorf("model name is required") } @@ -802,12 +793,13 @@ func replicateScoresFromInterface(arr []interface{}, n int) ([]float64, error) { // can compare against per-model thresholds, but the RelevanceScore // field should not be assumed to be a probability. func (r *ReplicateModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { + if err := r.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(documents) == 0 { return &RerankResponse{}, nil } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") - } if modelName == nil || strings.TrimSpace(*modelName) == "" { return nil, fmt.Errorf("model name is required") } @@ -869,7 +861,6 @@ func (r *ReplicateModel) Rerank(modelName *string, query string, documents []str return &RerankResponse{Data: results}, nil } - func (r *ReplicateModel) Balance(apiConfig *APIConfig) (map[string]interface{}, error) { return nil, fmt.Errorf("%s, no such method", r.Name()) } diff --git a/internal/entity/models/siliconflow.go b/internal/entity/models/siliconflow.go index fc1519c8f7..b72233c621 100644 --- a/internal/entity/models/siliconflow.go +++ b/internal/entity/models/siliconflow.go @@ -35,29 +35,29 @@ import ( // SiliconflowModel implements ModelDriver for Siliconflow type SiliconflowModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client // Reusable HTTP client with connection pool + baseModel BaseModel } // NewSiliconflowModel creates a new Siliconflow model instance func NewSiliconflowModel(baseURL map[string]string, urlSuffix URLSuffix) *SiliconflowModel { return &SiliconflowModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + DisableCompression: false, + }, }, }, } } func (s *SiliconflowModel) NewInstance(baseURL map[string]string) ModelDriver { - return nil + return NewSiliconflowModel(baseURL, s.baseModel.URLSuffix) } func (s *SiliconflowModel) Name() string { @@ -77,19 +77,19 @@ type SiliconflowRerankRequest struct { // ChatWithMessages sends multiple messages with roles and returns response func (s *SiliconflowModel) 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 nil or empty") + if err := s.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := s.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - url := fmt.Sprintf("%s/%s", s.BaseURL[region], s.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, s.baseModel.URLSuffix.Chat) // Convert messages to the format expected by API apiMessages := make([]map[string]interface{}, len(messages)) @@ -146,7 +146,7 @@ func (s *SiliconflowModel) ChatWithMessages(modelName string, messages []Message req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := s.httpClient.Do(req) + resp, err := s.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -215,16 +215,19 @@ func (s *SiliconflowModel) ChatWithMessages(modelName string, messages []Message // ChatStreamlyWithSender sends messages and streams response via sender function (best performance, no channel) func (s *SiliconflowModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := s.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if len(messages) == 0 { return fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := s.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - url := fmt.Sprintf("%s/%s", s.BaseURL[region], s.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, s.baseModel.URLSuffix.Chat) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -297,7 +300,7 @@ func (s *SiliconflowModel) ChatStreamlyWithSender(modelName string, messages []M req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := s.httpClient.Do(req) + resp, err := s.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -398,11 +401,14 @@ type siliconflowUsage struct { } // siliconflowMaxBatchSize is the per-request input limit documented at -// https://docs.siliconflow.cn/en/api-reference/embeddings/create-embeddings. const siliconflowMaxBatchSize = 32 // Embed embeds a list of texts into embeddings func (s *SiliconflowModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { + if err := s.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(texts) == 0 { return []EmbeddingData{}, nil } @@ -414,17 +420,13 @@ func (s *SiliconflowModel) Embed(modelName *string, texts []string, apiConfig *A return nil, fmt.Errorf("model name is required") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := s.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(resolvedBaseURL, "/"), s.baseModel.URLSuffix.Embedding) - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(s.BaseURL[region], "/"), s.URLSuffix.Embedding) - - apiKey := "" - if apiConfig != nil && apiConfig.ApiKey != nil { - apiKey = *apiConfig.ApiKey - } + apiKey := *apiConfig.ApiKey reqBody := map[string]interface{}{ "model": modelName, @@ -449,7 +451,7 @@ func (s *SiliconflowModel) Embed(modelName *string, texts []string, apiConfig *A req.Header.Set("Authorization", "Bearer "+apiKey) } - resp, err := s.httpClient.Do(req) + resp, err := s.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -482,12 +484,15 @@ func (s *SiliconflowModel) Embed(modelName *string, texts []string, apiConfig *A } func (s *SiliconflowModel) ListModels(apiConfig *APIConfig) ([]string, error) { - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + if err := s.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - url := fmt.Sprintf("%s/%s", s.BaseURL[region], s.URLSuffix.Models) + resolvedBaseURL, err := s.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", resolvedBaseURL, s.baseModel.URLSuffix.Models) // Build request body reqBody := map[string]interface{}{} @@ -508,7 +513,7 @@ func (s *SiliconflowModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := s.httpClient.Do(req) + resp, err := s.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -552,26 +557,16 @@ type siliconflowBalanceResponse struct { } func (s *SiliconflowModel) Balance(apiConfig *APIConfig) (map[string]interface{}, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := s.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + baseURL, err := s.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - baseURL := s.BaseURL["default"] - if region != "default" { - if regional, ok := s.BaseURL[region]; ok && regional != "" { - baseURL = regional - } - } - if baseURL == "" { - return nil, fmt.Errorf("siliconflow: no base URL configured for default region") - } - - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), s.URLSuffix.Balance) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), s.baseModel.URLSuffix.Balance) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -583,7 +578,7 @@ func (s *SiliconflowModel) Balance(apiConfig *APIConfig) (map[string]interface{} req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := s.httpClient.Do(req) + resp, err := s.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -666,19 +661,15 @@ type SiliconflowRerankResponse struct { // Rerank calculates similarity scores between query and documents func (s *SiliconflowModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { + if err := s.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(documents) == 0 { return &RerankResponse{}, nil } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - apiKey := "" - if apiConfig != nil && apiConfig.ApiKey != nil { - apiKey = *apiConfig.ApiKey - } + apiKey := *apiConfig.ApiKey var topN = rerankConfig.TopN if rerankConfig.TopN == 0 { @@ -700,7 +691,11 @@ func (s *SiliconflowModel) Rerank(modelName *string, query string, documents []s return nil, fmt.Errorf("failed to marshal request: %w", err) } - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(s.BaseURL[region], "/"), s.URLSuffix.Rerank) + resolvedBaseURL, err := s.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(resolvedBaseURL, "/"), s.baseModel.URLSuffix.Rerank) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -715,7 +710,7 @@ func (s *SiliconflowModel) Rerank(modelName *string, query string, documents []s req.Header.Set("Authorization", "Bearer "+apiKey) } - resp, err := s.httpClient.Do(req) + resp, err := s.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -745,16 +740,19 @@ func (s *SiliconflowModel) Rerank(modelName *string, query string, documents []s // TranscribeAudio transcribe audio func (s *SiliconflowModel) TranscribeAudio(modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig) (*ASRResponse, error) { + if err := s.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if file == nil || *file == "" { return nil, fmt.Errorf("file is missing") } - region := "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := s.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", s.BaseURL[region], s.URLSuffix.ASR) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, s.baseModel.URLSuffix.ASR) // multipart body var body bytes.Buffer @@ -833,7 +831,7 @@ func (s *SiliconflowModel) TranscribeAudio(modelName *string, file *string, apiC req.Header.Set("Accept", "application/json") // send request - resp, err := s.httpClient.Do(req) + resp, err := s.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -866,16 +864,19 @@ func (s *SiliconflowModel) TranscribeAudioWithSender(modelName *string, file *st // AudioSpeech convert text to audio func (s *SiliconflowModel) AudioSpeech(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig) (*TTSResponse, error) { + if err := s.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if audioContent == nil || *audioContent == "" { return nil, fmt.Errorf("audio content is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := s.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", s.BaseURL[region], s.URLSuffix.TTS) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, s.baseModel.URLSuffix.TTS) reqBody := map[string]interface{}{ "model": *modelName, @@ -908,7 +909,7 @@ func (s *SiliconflowModel) AudioSpeech(modelName *string, audioContent *string, req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := s.httpClient.Do(req) + resp, err := s.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -927,20 +928,19 @@ func (s *SiliconflowModel) AudioSpeech(modelName *string, audioContent *string, } func (s *SiliconflowModel) AudioSpeechWithSender(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig, sender func(*string, *string) error) error { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return fmt.Errorf("SiliconFlow API key is missing") + if err := s.baseModel.APIConfigCheck(apiConfig); err != nil { + return err } if audioContent == nil || *audioContent == "" { return fmt.Errorf("audio content is empty") } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := s.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - url := fmt.Sprintf("%s/%s", s.BaseURL[region], s.URLSuffix.TTS) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, s.baseModel.URLSuffix.TTS) reqBody := map[string]interface{}{ "model": *modelName, @@ -973,7 +973,7 @@ func (s *SiliconflowModel) AudioSpeechWithSender(modelName *string, audioContent req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := s.httpClient.Do(req) + resp, err := s.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/stepfun.go b/internal/entity/models/stepfun.go index 2e6b0cfc43..8008d87d31 100644 --- a/internal/entity/models/stepfun.go +++ b/internal/entity/models/stepfun.go @@ -30,28 +30,11 @@ import ( ) // StepFunModel implements ModelDriver for StepFun (阶跃星辰). -// -// StepFun exposes an OpenAI-compatible REST API at https://api.stepfun.com/v1 -// (chat completions at /chat/completions, list models at /models). The wire -// shape matches OpenAI closely enough that the chat path here is a direct -// port of the OpenAI driver. type StepFunModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } // NewStepFunModel creates a new StepFun model instance. -// -// We clone http.DefaultTransport so we keep Go's defaults for -// ProxyFromEnvironment, DialContext (with KeepAlive), HTTP/2, -// TLSHandshakeTimeout, and ExpectContinueTimeout, and only override -// the connection-pool fields we care about. -// -// The Client itself has no Timeout. http.Client.Timeout would also -// cap the time spent reading the response body, which would cut off -// long-lived SSE streams in ChatStreamlyWithSender. Non-streaming -// callers wrap each request with context.WithTimeout instead. func NewStepFunModel(baseURL map[string]string, urlSuffix URLSuffix) *StepFunModel { transport := http.DefaultTransport.(*http.Transport).Clone() transport.MaxIdleConns = 100 @@ -61,63 +44,40 @@ func NewStepFunModel(baseURL map[string]string, urlSuffix URLSuffix) *StepFunMod transport.ResponseHeaderTimeout = 60 * time.Second return &StepFunModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } -/* - -RAGFlow(user)> tts with 'fnlp/MOSS-TTSD-v0.5@test@siliconflow' text 'He who desires but acts not, breeds pestilence.' play format 'wav' param '{"voice": "fnlp/MOSS-TTSD-v0.5:alex"}' -SUCCESS -RAGFlow(user)> stream tts with 'fnlp/MOSS-TTSD-v0.5@test@siliconflow' text 'He who desires but acts not, breeds pestilence.' play format 'wav' param '{"voice": "fnlp/MOSS-TTSD-v0.5:claire"}' -SUCCESS - -*/ - func (s *StepFunModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewStepFunModel(baseURL, s.URLSuffix) + return NewStepFunModel(baseURL, s.baseModel.URLSuffix) } func (s *StepFunModel) Name() string { return "stepfun" } -// baseURLForRegion returns the base URL for the given region, or an -// error if no entry exists. This makes a misconfigured region fail -// fast with a clear message, instead of silently producing a relative -// URL that the HTTP transport then rejects. -func (s *StepFunModel) baseURLForRegion(region string) (string, error) { - base, ok := s.BaseURL[region] - if !ok || base == "" { - return "", fmt.Errorf("stepfun: no base URL configured for region %q", region) - } - return base, nil -} - // ChatWithMessages sends multiple messages with roles and returns the response. func (s *StepFunModel) 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 err := s.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := s.baseURLForRegion(region) + baseURL, err := s.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, s.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, s.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -133,9 +93,6 @@ func (s *StepFunModel) ChatWithMessages(modelName string, messages []Message, ap "stream": false, } - // Note: do NOT propagate chatModelConfig.Stream into the request body - // here. ChatWithMessages parses a single JSON response, so stream must - // always be off for this code path. if chatModelConfig != nil { if chatModelConfig.MaxTokens != nil { reqBody["max_tokens"] = *chatModelConfig.MaxTokens @@ -167,7 +124,7 @@ func (s *StepFunModel) ChatWithMessages(modelName string, messages []Message, ap req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := s.httpClient.Do(req) + resp, err := s.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -214,10 +171,12 @@ func (s *StepFunModel) ChatWithMessages(modelName string, messages []Message, ap }, nil } -// ChatStreamlyWithSender sends messages and streams the response via the -// sender function. The StepFun SSE stream uses the same shape as OpenAI: -// "data:" lines carrying JSON events, with a final "[DONE]" line. +// ChatStreamlyWithSender sends messages and streams the response func (s *StepFunModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := s.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if sender == nil { return fmt.Errorf("sender is required") } @@ -226,20 +185,12 @@ func (s *StepFunModel) ChatStreamlyWithSender(modelName string, messages []Messa return fmt.Errorf("messages is empty") } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return fmt.Errorf("api key is required") - } - - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := s.baseURLForRegion(region) + baseURL, err := s.baseModel.GetBaseURL(apiConfig) if err != nil { return err } - url := fmt.Sprintf("%s/%s", baseURL, s.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, s.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -256,10 +207,6 @@ func (s *StepFunModel) ChatStreamlyWithSender(modelName string, messages []Messa } if chatModelConfig != nil { - // Refuse to run if the caller explicitly asked for stream=false. - // The body of this method only knows how to read SSE, so a - // non-SSE JSON response would be parsed as if it were a stream - // and produce no chunks. Better to fail clearly. if chatModelConfig.Stream != nil && !*chatModelConfig.Stream { return fmt.Errorf("stream must be true in ChatStreamlyWithSender") } @@ -283,9 +230,6 @@ func (s *StepFunModel) ChatStreamlyWithSender(modelName string, messages []Messa return fmt.Errorf("failed to marshal request: %w", err) } - // SSE streams are long-lived. We rely on the transport's - // ResponseHeaderTimeout to cap the connection-establishment phase - // instead of attaching a hard deadline here. req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(jsonData)) if err != nil { return fmt.Errorf("failed to create request: %w", err) @@ -294,7 +238,7 @@ func (s *StepFunModel) ChatStreamlyWithSender(modelName string, messages []Messa req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := s.httpClient.Do(req) + resp, err := s.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -305,8 +249,6 @@ func (s *StepFunModel) ChatStreamlyWithSender(modelName string, messages []Messa return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) } - // SSE parsing: bump the scanner buffer from the 64KB default to 1MB - // so we never silently truncate a long data: line. scanner := bufio.NewScanner(resp.Body) scanner.Buffer(make([]byte, 64*1024), 1024*1024) sawTerminal := false @@ -373,29 +315,23 @@ func (s *StepFunModel) ChatStreamlyWithSender(modelName string, messages []Messa return nil } -// Embed is left as a stub. StepFun has not advertised a public embeddings -// endpoint in the API reference linked from the umbrella issue, so any real -// implementation belongs in a follow-up only after the endpoint is verified. +// Embed is left as a stub. func (s *StepFunModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { return nil, fmt.Errorf("not implemented") } // ListModels returns the list of model ids visible to the API key. func (s *StepFunModel) ListModels(apiConfig *APIConfig) ([]string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := s.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := s.baseURLForRegion(region) + baseURL, err := s.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, s.URLSuffix.Models) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, s.baseModel.URLSuffix.Models) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -407,7 +343,7 @@ func (s *StepFunModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := s.httpClient.Do(req) + resp, err := s.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -479,17 +415,20 @@ func (s *StepFunModel) TranscribeAudioWithSender(modelName *string, file *string // AudioSpeech convert text to audio func (s *StepFunModel) AudioSpeech(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig) (*TTSResponse, error) { + if err := s.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + // TODO Test it if audioContent == nil || *audioContent == "" { return nil, fmt.Errorf("audio content is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := s.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", s.BaseURL[region], s.URLSuffix.TTS) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, s.baseModel.URLSuffix.TTS) reqBody := map[string]interface{}{ "model": *modelName, @@ -518,7 +457,7 @@ func (s *StepFunModel) AudioSpeech(modelName *string, audioContent *string, apiC req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := s.httpClient.Do(req) + resp, err := s.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -538,21 +477,19 @@ func (s *StepFunModel) AudioSpeech(modelName *string, audioContent *string, apiC // AudioSpeechWithSender for Streaming TTS func (s *StepFunModel) AudioSpeechWithSender(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig, sender func(*string, *string) error) error { - // TODO Test it - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return fmt.Errorf("StepFun API key is missing") + if err := s.baseModel.APIConfigCheck(apiConfig); err != nil { + return err } if audioContent == nil || *audioContent == "" { return fmt.Errorf("audio content is empty") } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := s.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - url := fmt.Sprintf("%s/%s", s.BaseURL[region], s.URLSuffix.TTS) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, s.baseModel.URLSuffix.TTS) reqBody := map[string]interface{}{ "model": *modelName, @@ -582,7 +519,7 @@ func (s *StepFunModel) AudioSpeechWithSender(modelName *string, audioContent *st req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := s.httpClient.Do(req) + resp, err := s.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/togetherai.go b/internal/entity/models/togetherai.go index a112a970fa..d487209be1 100644 --- a/internal/entity/models/togetherai.go +++ b/internal/entity/models/togetherai.go @@ -34,9 +34,7 @@ import ( ) type TogetherAIModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } func NewTogetherAIModel(baseURL map[string]string, urlSuffix URLSuffix) *TogetherAIModel { @@ -48,30 +46,24 @@ func NewTogetherAIModel(baseURL map[string]string, urlSuffix URLSuffix) *Togethe transport.ResponseHeaderTimeout = 60 * time.Second return &TogetherAIModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (t *TogetherAIModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewTogetherAIModel(baseURL, t.URLSuffix) + return NewTogetherAIModel(baseURL, t.baseModel.URLSuffix) } func (t *TogetherAIModel) Name() string { return "togetherai" } -func (t *TogetherAIModel) baseURLForRegion(region string) (string, error) { - base, ok := t.BaseURL[region] - if !ok || base == "" { - return "", fmt.Errorf("togetherai: no base URL configured for region %q", region) - } - return strings.TrimSuffix(base, "/"), nil -} - type togetherAIReasoningOptions struct { Enabled bool `json:"enabled"` } @@ -118,16 +110,13 @@ func (t *TogetherAIModel) chatPayload(modelName string, messages []Message, stre } func (t *TogetherAIModel) chatURL(apiConfig *APIConfig) (string, error) { - region := "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - baseURL, err := t.baseURLForRegion(region) + baseURL, err := t.baseModel.GetBaseURL(apiConfig) if err != nil { return "", err } - return fmt.Sprintf("%s/%s", baseURL, t.URLSuffix.Chat), nil + baseURL = strings.TrimSuffix(baseURL, "/") + return fmt.Sprintf("%s/%s", baseURL, t.baseModel.URLSuffix.Chat), nil } type togetherAIChatMessage struct { @@ -149,8 +138,8 @@ type togetherAIChatResponse struct { } func (t *TogetherAIModel) 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 err := t.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if strings.TrimSpace(modelName) == "" { return nil, fmt.Errorf("model name is required") @@ -179,7 +168,7 @@ func (t *TogetherAIModel) ChatWithMessages(modelName string, messages []Message, req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := t.httpClient.Do(req) + resp, err := t.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -216,12 +205,13 @@ func (t *TogetherAIModel) ChatWithMessages(modelName string, messages []Message, } func (t *TogetherAIModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := t.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if sender == nil { return fmt.Errorf("sender is required") } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return fmt.Errorf("api key is required") - } if strings.TrimSpace(modelName) == "" { return fmt.Errorf("model name is required") } @@ -241,10 +231,7 @@ func (t *TogetherAIModel) ChatStreamlyWithSender(modelName string, messages []Me if err != nil { return fmt.Errorf("failed to marshal request: %w", err) } - - // ResponseHeaderTimeout caps the initial header wait. This context - // also caps the body-read phase so a stalled SSE stream cannot hold - // the caller's goroutine and connection indefinitely. + ctx, cancel := context.WithTimeout(context.Background(), streamCallTimeout) defer cancel() @@ -256,7 +243,7 @@ func (t *TogetherAIModel) ChatStreamlyWithSender(modelName string, messages []Me req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) req.Header.Set("Accept", "text/event-stream") - resp, err := t.httpClient.Do(req) + resp, err := t.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -330,20 +317,16 @@ type togetherAIModelInfo struct { } func (t *TogetherAIModel) ListModels(apiConfig *APIConfig) ([]string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := t.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := t.baseURLForRegion(region) + baseURL, err := t.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, t.URLSuffix.Models) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, t.baseModel.URLSuffix.Models) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -355,7 +338,7 @@ func (t *TogetherAIModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := t.httpClient.Do(req) + resp, err := t.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -401,28 +384,24 @@ type togetherAIEmbeddingResponse struct { } func (t *TogetherAIModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { - if len(texts) == 0 { - return []EmbeddingData{}, nil + if err := t.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if len(texts) == 0 { + return []EmbeddingData{}, nil } if modelName == nil || strings.TrimSpace(*modelName) == "" { return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := t.baseURLForRegion(region) + baseURL, err := t.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, t.URLSuffix.Embedding) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, t.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": *modelName, @@ -448,7 +427,7 @@ func (t *TogetherAIModel) Embed(modelName *string, texts []string, apiConfig *AP req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := t.httpClient.Do(req) + resp, err := t.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -493,16 +472,19 @@ func (t *TogetherAIModel) Embed(modelName *string, texts []string, apiConfig *AP } func (t *TogetherAIModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { + if err := t.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(documents) == 0 { return &RerankResponse{}, nil } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := t.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", t.BaseURL[region], t.URLSuffix.Rerank) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, t.baseModel.URLSuffix.Rerank) var topN = rerankConfig.TopN if rerankConfig.TopN != 0 { @@ -529,7 +511,7 @@ func (t *TogetherAIModel) Rerank(modelName *string, query string, documents []st req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := t.httpClient.Do(req) + resp, err := t.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -572,20 +554,19 @@ func (t *TogetherAIModel) Balance(apiConfig *APIConfig) (map[string]interface{}, } func (t *TogetherAIModel) TranscribeAudio(modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig) (*ASRResponse, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("TogetherAI API key is missing") + if err := t.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if file == nil || *file == "" { return nil, fmt.Errorf("file is missing") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := t.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", t.BaseURL[region], t.URLSuffix.ASR) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, t.baseModel.URLSuffix.ASR) var body bytes.Buffer writer := multipart.NewWriter(&body) @@ -644,7 +625,7 @@ func (t *TogetherAIModel) TranscribeAudio(modelName *string, file *string, apiCo req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) req.Header.Set("Content-Type", writer.FormDataContentType()) - resp, err := t.httpClient.Do(req) + resp, err := t.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -678,20 +659,19 @@ func (t *TogetherAIModel) TranscribeAudioWithSender(modelName *string, file *str } func (t *TogetherAIModel) AudioSpeech(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig) (*TTSResponse, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("TogetherAI API key is missing") + if err := t.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if audioContent == nil || *audioContent == "" { return nil, fmt.Errorf("text content is missing") } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := t.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", t.BaseURL[region], t.URLSuffix.TTS) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, t.baseModel.URLSuffix.TTS) reqBody := map[string]interface{}{ "model": *modelName, @@ -720,7 +700,7 @@ func (t *TogetherAIModel) AudioSpeech(modelName *string, audioContent *string, a req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := t.httpClient.Do(req) + resp, err := t.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -739,21 +719,20 @@ func (t *TogetherAIModel) AudioSpeech(modelName *string, audioContent *string, a } func (t *TogetherAIModel) AudioSpeechWithSender(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig, sender func(*string, *string) error) error { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return fmt.Errorf("TogetherAI API key is missing") + if err := t.baseModel.APIConfigCheck(apiConfig); err != nil { + return err } if audioContent == nil || *audioContent == "" { return fmt.Errorf("text content is missing") } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := t.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - cleanBaseURL := strings.TrimRight(t.BaseURL[region], "/") - cleanSuffix := strings.TrimLeft(t.URLSuffix.TTS, "/") + cleanBaseURL := strings.TrimRight(resolvedBaseURL, "/") + cleanSuffix := strings.TrimLeft(t.baseModel.URLSuffix.TTS, "/") url := fmt.Sprintf("%s/%s", cleanBaseURL, cleanSuffix) // Build Request body @@ -788,7 +767,7 @@ func (t *TogetherAIModel) AudioSpeechWithSender(modelName *string, audioContent req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := t.httpClient.Do(req) + resp, err := t.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/tokenhub.go b/internal/entity/models/tokenhub.go index 05d2b11f63..a65619e39f 100644 --- a/internal/entity/models/tokenhub.go +++ b/internal/entity/models/tokenhub.go @@ -29,38 +29,35 @@ import ( ) type TokenHubModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } func NewTokenHubModel(baseURL map[string]string, urlSuffix URLSuffix) *TokenHubModel { return &TokenHubModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 10, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: time.Second * 60, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 10, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: time.Second * 60, + DisableCompression: false, + }, }, }, } } func (t *TokenHubModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewTokenHubModel(baseURL, t.URLSuffix) + return NewTokenHubModel(baseURL, t.baseModel.URLSuffix) } func (t *TokenHubModel) Name() string { return "tokenhub" } -func validateTokenHubChatRequest(modelName string, messages []Message, apiConfig *APIConfig) error { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return fmt.Errorf("api key is required") - } +func validateTokenHubChatRequest(modelName string, messages []Message) error { if strings.TrimSpace(modelName) == "" { return fmt.Errorf("model name is required") } @@ -71,16 +68,18 @@ func validateTokenHubChatRequest(modelName string, messages []Message, apiConfig } func (t *TokenHubModel) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { - if err := validateTokenHubChatRequest(modelName, messages, apiConfig); err != nil { + if err := t.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if err := validateTokenHubChatRequest(modelName, messages); err != nil { return nil, err } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := t.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", t.BaseURL[region], t.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, t.baseModel.URLSuffix.Chat) // Convert messages to the format expected by API apiMessages := make([]map[string]interface{}, len(messages)) @@ -131,11 +130,9 @@ func (t *TokenHubModel) ChatWithMessages(modelName string, messages []Message, a } req.Header.Set("Content-Type", "application/json") - if apiConfig != nil && apiConfig.ApiKey != nil { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := t.httpClient.Do(req) + resp, err := t.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -196,19 +193,22 @@ func (t *TokenHubModel) ChatWithMessages(modelName string, messages []Message, a } func (t *TokenHubModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, modelConfig *ChatConfig, sender func(*string, *string) error) error { - if sender == nil { - return fmt.Errorf("sender is required") - } - if err := validateTokenHubChatRequest(modelName, messages, apiConfig); err != nil { + if err := t.baseModel.APIConfigCheck(apiConfig); err != nil { return err } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + if sender == nil { + return fmt.Errorf("sender is required") + } + if err := validateTokenHubChatRequest(modelName, messages); err != nil { + return err } - url := fmt.Sprintf("%s/%s", t.BaseURL[region], t.URLSuffix.Chat) + resolvedBaseURL, err := t.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err + } + url := fmt.Sprintf("%s/%s", resolvedBaseURL, t.baseModel.URLSuffix.Chat) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -268,7 +268,7 @@ func (t *TokenHubModel) ChatStreamlyWithSender(modelName string, messages []Mess req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := t.httpClient.Do(req) + resp, err := t.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -353,22 +353,22 @@ func (t *TokenHubModel) ChatStreamlyWithSender(modelName string, messages []Mess } func (t *TokenHubModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { + if err := t.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(texts) == 0 { return []EmbeddingData{}, nil } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") - } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := t.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", t.BaseURL[region], t.URLSuffix.Embedding) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, t.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": *modelName, @@ -391,7 +391,7 @@ func (t *TokenHubModel) Embed(modelName *string, texts []string, apiConfig *APIC req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := t.httpClient.Do(req) + resp, err := t.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -461,16 +461,15 @@ func (t *TokenHubModel) ParseFile(modelName *string, content []byte, url *string } func (t *TokenHubModel) ListModels(apiConfig *APIConfig) ([]string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := t.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := t.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", t.BaseURL[region], t.URLSuffix.Models) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, t.baseModel.URLSuffix.Models) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -482,7 +481,7 @@ func (t *TokenHubModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := t.httpClient.Do(req) + resp, err := t.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/tokenpony.go b/internal/entity/models/tokenpony.go index 8d56c4daca..4907c70fa0 100644 --- a/internal/entity/models/tokenpony.go +++ b/internal/entity/models/tokenpony.go @@ -29,32 +29,11 @@ import ( ) // TokenPonyModel implements ModelDriver for TokenPony. TokenPony is a -// SaaS, OpenAI-compatible inference gateway hosting Qwen, DeepSeek, -// Kimi, GLM, MiniMax, and Hunyuan families behind a single Bearer-token -// API at https://ragflow.vip-api.tokenpony.cn/v1. -// -// 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 TokenPonyModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } // NewTokenPonyModel creates a new TokenPony 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 NewTokenPonyModel(baseURL map[string]string, urlSuffix URLSuffix) *TokenPonyModel { transport := http.DefaultTransport.(*http.Transport).Clone() transport.MaxIdleConns = 100 @@ -64,52 +43,39 @@ func NewTokenPonyModel(baseURL map[string]string, urlSuffix URLSuffix) *TokenPon transport.ResponseHeaderTimeout = 60 * time.Second return &TokenPonyModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (t *TokenPonyModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewTokenPonyModel(baseURL, t.URLSuffix) + return NewTokenPonyModel(baseURL, t.baseModel.URLSuffix) } func (t *TokenPonyModel) Name() string { return "tokenpony" } -func (t *TokenPonyModel) baseURLForRegion(region string) (string, error) { - base, ok := t.BaseURL[region] - if !ok || base == "" { - return "", fmt.Errorf("tokenpony: 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. +// ChatWithMessages sends a non-streaming chat request func (t *TokenPonyModel) 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 err := t.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := t.baseURLForRegion(region) + baseURL, err := t.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, t.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, t.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -155,7 +121,7 @@ func (t *TokenPonyModel) ChatWithMessages(modelName string, messages []Message, req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := t.httpClient.Do(req) + resp, err := t.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -194,10 +160,6 @@ func (t *TokenPonyModel) ChatWithMessages(modelName string, messages []Message, 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 @@ -209,31 +171,25 @@ func (t *TokenPonyModel) ChatWithMessages(modelName string, messages []Message, }, 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. +// ChatStreamlyWithSender opens the SSE chat-completions func (t *TokenPonyModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := t.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + 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 := t.baseURLForRegion(region) + baseURL, err := t.baseModel.GetBaseURL(apiConfig) if err != nil { return err } - url := fmt.Sprintf("%s/%s", baseURL, t.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, t.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -250,9 +206,6 @@ func (t *TokenPonyModel) ChatStreamlyWithSender(modelName string, messages []Mes } 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") } @@ -275,8 +228,6 @@ func (t *TokenPonyModel) ChatStreamlyWithSender(modelName string, messages []Mes 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) @@ -284,7 +235,7 @@ func (t *TokenPonyModel) ChatStreamlyWithSender(modelName string, messages []Mes req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := t.httpClient.Do(req) + resp, err := t.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -311,15 +262,9 @@ func (t *TokenPonyModel) ChatStreamlyWithSender(modelName string, messages []Mes 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("tokenpony: invalid SSE event: %w", err) } - // TokenPony 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("tokenpony: upstream stream error: %v", apiErr) } @@ -332,10 +277,7 @@ func (t *TokenPonyModel) ChatStreamlyWithSender(modelName string, messages []Mes 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 @@ -370,24 +312,17 @@ func (t *TokenPonyModel) ChatStreamlyWithSender(modelName string, messages []Mes 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 (t *TokenPonyModel) ListModels(apiConfig *APIConfig) ([]string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := t.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := t.baseURLForRegion(region) + baseURL, err := t.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, t.URLSuffix.Models) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, t.baseModel.URLSuffix.Models) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -398,7 +333,7 @@ func (t *TokenPonyModel) ListModels(apiConfig *APIConfig) ([]string, error) { } req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := t.httpClient.Do(req) + resp, err := t.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -437,17 +372,12 @@ func (t *TokenPonyModel) ListModels(apiConfig *APIConfig) ([]string, error) { 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. +// CheckConnection verifies the API key by calling ListModels. func (t *TokenPonyModel) CheckConnection(apiConfig *APIConfig) error { _, err := t.ListModels(apiConfig) return err } -// Embed is not implemented for TokenPony in this initial driver; the -// factory entry only registers chat models. Mirrors how LongCat / -// Astraflow landed chat-only. func (t *TokenPonyModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { return nil, fmt.Errorf("%s, no such method", t.Name()) } diff --git a/internal/entity/models/upstage.go b/internal/entity/models/upstage.go index 9ab7c16422..dfb87af92b 100644 --- a/internal/entity/models/upstage.go +++ b/internal/entity/models/upstage.go @@ -29,30 +29,11 @@ import ( ) // UpstageModel implements ModelDriver for Upstage (Solar models). -// -// Upstage exposes an OpenAI-compatible REST API at -// https://api.upstage.ai/v1 (chat completions at /chat/completions, list -// models at /models, embeddings at /embeddings). The wire shape matches -// OpenAI closely enough that the chat path here is a direct port of the -// OpenAI driver. The legacy /v1/solar/* paths still work but the canonical -// base is /v1. type UpstageModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } // NewUpstageModel creates a new Upstage model instance. -// -// We clone http.DefaultTransport so we keep Go's defaults for -// ProxyFromEnvironment, DialContext (with KeepAlive), HTTP/2, -// TLSHandshakeTimeout, and ExpectContinueTimeout, and only override -// the connection-pool fields we care about. -// -// The Client itself has no Timeout. http.Client.Timeout would also -// cap the time spent reading the response body, which would cut off -// long-lived SSE streams in ChatStreamlyWithSender. Non-streaming -// callers wrap each request with context.WithTimeout instead. func NewUpstageModel(baseURL map[string]string, urlSuffix URLSuffix) *UpstageModel { transport := http.DefaultTransport.(*http.Transport).Clone() transport.MaxIdleConns = 100 @@ -62,54 +43,40 @@ func NewUpstageModel(baseURL map[string]string, urlSuffix URLSuffix) *UpstageMod transport.ResponseHeaderTimeout = 60 * time.Second return &UpstageModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (u *UpstageModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewUpstageModel(baseURL, u.URLSuffix) + return NewUpstageModel(baseURL, u.baseModel.URLSuffix) } func (u *UpstageModel) Name() string { return "upstage" } -// baseURLForRegion returns the base URL for the given region, or an -// error if no entry exists. This makes a misconfigured region fail -// fast with a clear message, instead of silently producing a relative -// URL that the HTTP transport then rejects. -func (u *UpstageModel) baseURLForRegion(region string) (string, error) { - base, ok := u.BaseURL[region] - if !ok || base == "" { - return "", fmt.Errorf("upstage: no base URL configured for region %q", region) - } - return base, nil -} - // ChatWithMessages sends multiple messages with roles and returns the response. func (u *UpstageModel) 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 err := u.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := u.baseURLForRegion(region) + baseURL, err := u.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, u.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, u.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -125,9 +92,6 @@ func (u *UpstageModel) ChatWithMessages(modelName string, messages []Message, ap "stream": false, } - // Note: do NOT propagate chatModelConfig.Stream into the request body - // here. ChatWithMessages parses a single JSON response, so stream must - // always be off for this code path. if chatModelConfig != nil { if chatModelConfig.MaxTokens != nil { reqBody["max_tokens"] = *chatModelConfig.MaxTokens @@ -141,10 +105,6 @@ func (u *UpstageModel) ChatWithMessages(modelName string, messages []Message, ap if chatModelConfig.Stop != nil { reqBody["stop"] = *chatModelConfig.Stop } - // Upstage Solar reasoning models (solar-pro2 and the upcoming - // solar-pro3) accept reasoning_effort=low|medium|high to trade - // latency for chain-of-thought depth, matching the OpenAI - // o-series shape. ChatConfig.Effort is the canonical carrier. if chatModelConfig.Effort != nil && *chatModelConfig.Effort != "" { reqBody["reasoning_effort"] = *chatModelConfig.Effort } @@ -166,7 +126,7 @@ func (u *UpstageModel) ChatWithMessages(modelName string, messages []Message, ap req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := u.httpClient.Do(req) + resp, err := u.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -206,11 +166,6 @@ func (u *UpstageModel) ChatWithMessages(modelName string, messages []Message, ap return nil, fmt.Errorf("invalid content format") } - // Upstage Solar reasoning models (solar-pro3, solar-pro2 with - // reasoning_effort >= medium) return the chain-of-thought in a - // `reasoning` field on the message. Pass it through when present - // so callers that opted into reasoning can show it. Absent or - // non-string means no reasoning was emitted — leave it empty. reasonContent := "" if r, ok := messageMap["reasoning"].(string); ok { reasonContent = r @@ -222,10 +177,12 @@ func (u *UpstageModel) ChatWithMessages(modelName string, messages []Message, ap }, nil } -// ChatStreamlyWithSender sends messages and streams the response via the -// sender function. The Upstage SSE stream uses the same shape as OpenAI: -// "data:" lines carrying JSON events, with a final "[DONE]" line. +// ChatStreamlyWithSender sends messages and streams the response func (u *UpstageModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := u.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if sender == nil { return fmt.Errorf("sender is required") } @@ -234,20 +191,12 @@ func (u *UpstageModel) ChatStreamlyWithSender(modelName string, messages []Messa return fmt.Errorf("messages is empty") } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return fmt.Errorf("api key is required") - } - - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := u.baseURLForRegion(region) + baseURL, err := u.baseModel.GetBaseURL(apiConfig) if err != nil { return err } - url := fmt.Sprintf("%s/%s", baseURL, u.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, u.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -264,10 +213,6 @@ func (u *UpstageModel) ChatStreamlyWithSender(modelName string, messages []Messa } if chatModelConfig != nil { - // Refuse to run if the caller explicitly asked for stream=false. - // The body of this method only knows how to read SSE, so a - // non-SSE JSON response would be parsed as if it were a stream - // and produce no chunks. Better to fail clearly. if chatModelConfig.Stream != nil && !*chatModelConfig.Stream { return fmt.Errorf("stream must be true in ChatStreamlyWithSender") } @@ -295,9 +240,6 @@ func (u *UpstageModel) ChatStreamlyWithSender(modelName string, messages []Messa return fmt.Errorf("failed to marshal request: %w", err) } - // SSE streams are long-lived. We rely on the transport's - // ResponseHeaderTimeout to cap the connection-establishment phase - // instead of attaching a hard deadline here. req, err := http.NewRequestWithContext(context.Background(), "POST", url, bytes.NewBuffer(jsonData)) if err != nil { return fmt.Errorf("failed to create request: %w", err) @@ -306,7 +248,7 @@ func (u *UpstageModel) ChatStreamlyWithSender(modelName string, messages []Messa req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := u.httpClient.Do(req) + resp, err := u.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -316,9 +258,6 @@ func (u *UpstageModel) ChatStreamlyWithSender(modelName string, messages []Messa body, _ := io.ReadAll(resp.Body) return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) } - - // SSE parsing: bump the scanner buffer from the 64KB default to 1MB - // so we never silently truncate a long data: line. scanner := bufio.NewScanner(resp.Body) scanner.Buffer(make([]byte, 64*1024), 1024*1024) sawTerminal := false @@ -356,13 +295,6 @@ func (u *UpstageModel) ChatStreamlyWithSender(modelName string, messages []Messa continue } - // Reasoning chunks first, content second. Upstage's solar-pro3 - // stream interleaves both fields within the same SSE event when - // reasoning_effort is medium or high; emit reasoning before the - // visible answer so callers that pipe both into a UI see the - // chain-of-thought start before the answer, matching the wire - // ordering. solar-pro2 inlines reasoning into delta.content and - // never sets delta.reasoning, so this block is a no-op for it. if r, ok := delta["reasoning"].(string); ok && r != "" { if err := sender(nil, &r); err != nil { return err @@ -410,33 +342,26 @@ type upstageEmbeddingResponse struct { Object string `json:"object"` } -// Embed turns a list of texts into embedding vectors using the Upstage -// /v1/solar/embeddings endpoint (solar-embedding-1-large-query for queries, -// solar-embedding-1-large-passage for passages). The output has one vector -// per input, in the same order the inputs were given. +// Embed turns a list of texts into embedding vectors func (u *UpstageModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { - if len(texts) == 0 { - return []EmbeddingData{}, nil + if err := u.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if len(texts) == 0 { + return []EmbeddingData{}, nil } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := u.baseURLForRegion(region) + baseURL, err := u.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, u.URLSuffix.Embedding) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, u.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": *modelName, @@ -459,7 +384,7 @@ func (u *UpstageModel) Embed(modelName *string, texts []string, apiConfig *APICo req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := u.httpClient.Do(req) + resp, err := u.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -479,10 +404,6 @@ func (u *UpstageModel) Embed(modelName *string, texts []string, apiConfig *APICo return nil, fmt.Errorf("failed to parse response: %w", err) } - // Reorder by the reported index so the output always lines up with - // the input texts, even if the upstream API ever returns items out - // of order. A nil slot at the end indicates the upstream did not - // return an embedding for that input. embeddings := make([]EmbeddingData, len(texts)) filled := make([]bool, len(texts)) for _, item := range parsed.Data { @@ -490,9 +411,6 @@ func (u *UpstageModel) Embed(modelName *string, texts []string, apiConfig *APICo return nil, fmt.Errorf("upstage: response index %d out of range for %d inputs", item.Index, len(texts)) } if filled[item.Index] { - // A malformed response that repeats the same index would - // silently overwrite the earlier vector. Fail loudly so - // the caller never uses ambiguous output. return nil, fmt.Errorf("upstage: duplicate embedding index %d in response", item.Index) } embeddings[item.Index] = EmbeddingData{ @@ -512,20 +430,16 @@ func (u *UpstageModel) Embed(modelName *string, texts []string, apiConfig *APICo // ListModels returns the list of model ids visible to the API key. func (u *UpstageModel) ListModels(apiConfig *APIConfig) ([]string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := u.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := u.baseURLForRegion(region) + baseURL, err := u.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, u.URLSuffix.Models) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, u.baseModel.URLSuffix.Models) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -537,7 +451,7 @@ func (u *UpstageModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := u.httpClient.Do(req) + resp, err := u.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/vllm.go b/internal/entity/models/vllm.go index 4ff5f2cac2..3294509e93 100644 --- a/internal/entity/models/vllm.go +++ b/internal/entity/models/vllm.go @@ -31,40 +31,29 @@ import ( // VllmModel implements ModelDriver for Vllm AI type VllmModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client // Reusable HTTP client with connection pool + baseModel BaseModel } // NewVllmModel creates a new Vllm AI model instance func NewVllmModel(baseURL map[string]string, urlSuffix URLSuffix) *VllmModel { return &VllmModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + DisableCompression: false, + }, }, }, } } func (v *VllmModel) NewInstance(baseURL map[string]string) ModelDriver { - return &VllmModel{ - BaseURL: baseURL, - URLSuffix: v.URLSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, - }, - }, - } + return NewVllmModel(baseURL, v.baseModel.URLSuffix) } func (v *VllmModel) Name() string { @@ -73,21 +62,24 @@ func (v *VllmModel) Name() string { // ChatWithMessages sends multiple messages with roles and returns response func (v *VllmModel) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { + if err := v.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := v.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", v.BaseURL[region], v.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, v.baseModel.URLSuffix.Chat) // For qwen/glm models, use async chat endpoint modelType := strings.Split(modelName, "-")[0] if modelType == "qwen" || modelType == "glm" { - url = fmt.Sprintf("%s/%s", v.BaseURL[region], v.URLSuffix.AsyncChat) + url = fmt.Sprintf("%s/%s", resolvedBaseURL, v.baseModel.URLSuffix.AsyncChat) } // Convert messages to API format @@ -157,7 +149,7 @@ func (v *VllmModel) ChatWithMessages(modelName string, messages []Message, apiCo req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := v.httpClient.Do(req) + resp, err := v.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -219,19 +211,22 @@ func (v *VllmModel) ChatWithMessages(modelName string, messages []Message, apiCo // ChatStreamlyWithSender sends messages and streams response via sender function (best performance, no channel) func (v *VllmModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, modelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := v.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if len(messages) == 0 { return fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := v.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - url := fmt.Sprintf("%s/%s", v.BaseURL[region], v.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, v.baseModel.URLSuffix.Chat) modelType := strings.Split(modelName, "-")[0] if modelType == "qwen" || modelType == "glm" { - url = fmt.Sprintf("%s/%s", v.BaseURL[region], v.URLSuffix.AsyncChat) + url = fmt.Sprintf("%s/%s", resolvedBaseURL, v.baseModel.URLSuffix.AsyncChat) } // Convert messages to API format (supporting multimodal content) @@ -302,7 +297,7 @@ func (v *VllmModel) ChatStreamlyWithSender(modelName string, messages []Message, req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := v.httpClient.Do(req) + resp, err := v.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -393,6 +388,10 @@ type vllmEmbeddingResponse struct { // Embed embeds a list of texts into embeddings func (v *VllmModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { + if err := v.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(texts) == 0 { return []EmbeddingData{}, nil } @@ -401,20 +400,19 @@ func (v *VllmModel) Embed(modelName *string, texts []string, apiConfig *APIConfi return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := v.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - baseURL := v.BaseURL[region] + baseURL := resolvedBaseURL if baseURL == "" { - baseURL = v.BaseURL["default"] + baseURL = resolvedBaseURL } if baseURL == "" { return nil, fmt.Errorf("missing base URL: please configure the local access address for vLLM (e.g., http://127.0.0.1:8000/v1)") } - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), v.URLSuffix.Embedding) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), v.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": *modelName, @@ -438,11 +436,9 @@ func (v *VllmModel) Embed(modelName *string, texts []string, apiConfig *APIConfi } req.Header.Set("Content-Type", "application/json") - if apiConfig != nil && apiConfig.ApiKey != nil && *apiConfig.ApiKey != "" { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := v.httpClient.Do(req) + resp, err := v.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -474,21 +470,23 @@ func (v *VllmModel) Embed(modelName *string, texts []string, apiConfig *APIConfi } func (v *VllmModel) ListModels(apiConfig *APIConfig) ([]string, error) { - var region = "default" - - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + if err := v.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - baseURL := v.BaseURL[region] + resolvedBaseURL, err := v.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + baseURL := resolvedBaseURL if baseURL == "" { - baseURL = v.BaseURL["default"] + baseURL = resolvedBaseURL } if baseURL == "" { return nil, fmt.Errorf("missing base URL: please configure the local access address for vLLM (e.g., http://127.0.0.1:8000/v1)") } - url := fmt.Sprintf("%s/%s", baseURL, v.URLSuffix.Models) + url := fmt.Sprintf("%s/%s", baseURL, v.baseModel.URLSuffix.Models) reqBody := map[string]interface{}{} @@ -506,14 +504,9 @@ func (v *VllmModel) ListModels(apiConfig *APIConfig) ([]string, error) { } req.Header.Set("Content-Type", "application/json") - // vLLM is a local provider and the API key is optional. Only set - // the Authorization header when a non-empty key was supplied. This - // also avoids a nil-pointer dereference on apiConfig or ApiKey. - if apiConfig != nil && apiConfig.ApiKey != nil && *apiConfig.ApiKey != "" { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := v.httpClient.Do(req) + resp, err := v.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -585,6 +578,10 @@ type vllmRerankResponse struct { // matching the existing Embed/ListModels behaviour for this local // driver. func (v *VllmModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { + if err := v.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(documents) == 0 { return &RerankResponse{}, nil } @@ -592,20 +589,19 @@ func (v *VllmModel) Rerank(modelName *string, query string, documents []string, return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := v.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - baseURL := v.BaseURL[region] + baseURL := resolvedBaseURL if baseURL == "" { - baseURL = v.BaseURL["default"] + baseURL = resolvedBaseURL } if baseURL == "" { return nil, fmt.Errorf("missing base URL: please configure the local access address for vLLM (e.g., http://127.0.0.1:8000/v1)") } - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), v.URLSuffix.Rerank) + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), v.baseModel.URLSuffix.Rerank) topN := len(documents) if rerankConfig != nil && rerankConfig.TopN > 0 && rerankConfig.TopN < topN { @@ -633,11 +629,9 @@ func (v *VllmModel) Rerank(modelName *string, query string, documents []string, } req.Header.Set("Content-Type", "application/json") - if apiConfig != nil && apiConfig.ApiKey != nil && *apiConfig.ApiKey != "" { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := v.httpClient.Do(req) + resp, err := v.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/volcengine.go b/internal/entity/models/volcengine.go index 00d21e0c3e..5a7a196486 100644 --- a/internal/entity/models/volcengine.go +++ b/internal/entity/models/volcengine.go @@ -31,29 +31,29 @@ import ( // VolcEngine implements ModelDriver for VolcEngine type VolcEngine struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client // Reusable HTTP client with connection pool + baseModel BaseModel } // NewVolcEngine creates a new VolcEngine model instance func NewVolcEngine(baseURL map[string]string, urlSuffix URLSuffix) *VolcEngine { return &VolcEngine{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 100, - MaxIdleConnsPerHost: 10, - IdleConnTimeout: 90 * time.Second, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + DisableCompression: false, + }, }, }, } } func (v *VolcEngine) NewInstance(baseURL map[string]string) ModelDriver { - return nil + return NewVolcEngine(baseURL, v.baseModel.URLSuffix) } func (v *VolcEngine) Name() string { @@ -62,16 +62,19 @@ func (v *VolcEngine) Name() string { // ChatWithMessages sends multiple messages with roles and returns response func (v *VolcEngine) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { + if err := v.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := v.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", v.BaseURL[region], v.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, v.baseModel.URLSuffix.Chat) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -159,11 +162,9 @@ func (v *VolcEngine) ChatWithMessages(modelName string, messages []Message, apiC } req.Header.Set("Content-Type", "application/json") - if apiConfig != nil && apiConfig.ApiKey != nil { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := v.httpClient.Do(req) + resp, err := v.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -225,17 +226,19 @@ func (v *VolcEngine) ChatWithMessages(modelName string, messages []Message, apiC // ChatStreamlyWithSender sends messages and streams response via sender function (best performance, no channel) func (v *VolcEngine) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, modelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := v.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if len(messages) == 0 { return fmt.Errorf("messages is empty") } - var region = "default" - - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := v.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - url := fmt.Sprintf("%s/chat/completions", v.BaseURL[region]) + url := fmt.Sprintf("%s/chat/completions", resolvedBaseURL) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -332,7 +335,7 @@ func (v *VolcEngine) ChatStreamlyWithSender(modelName string, messages []Message req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := v.httpClient.Do(req) + resp, err := v.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -440,16 +443,19 @@ type volcenginePromptTokensDetails struct { // Embed embeds a list of texts into embeddings func (v *VolcEngine) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { + if err := v.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(texts) == 0 { return []EmbeddingData{}, nil } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := v.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", v.BaseURL[region], v.URLSuffix.Embedding) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, v.baseModel.URLSuffix.Embedding) var embeddings []EmbeddingData @@ -491,7 +497,7 @@ func (v *VolcEngine) Embed(modelName *string, texts []string, apiConfig *APIConf req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := v.httpClient.Do(req) + resp, err := v.baseModel.httpClient.Do(req) if err != nil { return parsed, fmt.Errorf("failed to send request: %w", err) } @@ -558,19 +564,19 @@ func (v *VolcEngine) ParseFile(modelName *string, content []byte, url *string, a } func (v *VolcEngine) ListModels(apiConfig *APIConfig) ([]string, error) { - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + if err := v.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - baseURL := v.BaseURL[region] - if baseURL == "" { - baseURL = v.BaseURL["default"] + resolvedBaseURL, err := v.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } + baseURL := resolvedBaseURL if baseURL == "" { - return nil, fmt.Errorf("volcengine: no base URL configured for region %q", region) + baseURL = resolvedBaseURL } - modelsSuffix := strings.Trim(strings.TrimSpace(v.URLSuffix.Models), "/") + modelsSuffix := strings.Trim(strings.TrimSpace(v.baseModel.URLSuffix.Models), "/") if modelsSuffix == "" { return nil, fmt.Errorf("volcengine: models URL suffix is not configured") } @@ -585,11 +591,9 @@ func (v *VolcEngine) ListModels(apiConfig *APIConfig) ([]string, error) { return nil, fmt.Errorf("failed to create request: %w", err) } - if apiConfig != nil && apiConfig.ApiKey != nil && *apiConfig.ApiKey != "" { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := v.httpClient.Do(req) + resp, err := v.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -626,12 +630,15 @@ func (v *VolcEngine) Balance(apiConfig *APIConfig) (map[string]interface{}, erro } func (v *VolcEngine) CheckConnection(apiConfig *APIConfig) error { - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + if err := v.baseModel.APIConfigCheck(apiConfig); err != nil { + return err } - url := fmt.Sprintf("%s/%s", v.BaseURL[region], v.URLSuffix.Files) + resolvedBaseURL, err := v.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err + } + url := fmt.Sprintf("%s/%s", resolvedBaseURL, v.baseModel.URLSuffix.Files) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -644,7 +651,7 @@ func (v *VolcEngine) CheckConnection(apiConfig *APIConfig) error { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := v.httpClient.Do(req) + resp, err := v.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/voyage.go b/internal/entity/models/voyage.go index de7bcb4941..f2c72e3253 100644 --- a/internal/entity/models/voyage.go +++ b/internal/entity/models/voyage.go @@ -28,33 +28,11 @@ import ( ) // VoyageModel implements ModelDriver for Voyage AI. -// -// Voyage AI exposes a focused REST API at https://api.voyageai.com/v1 -// with embedding (/embeddings) and reranking (/rerank) only — no chat, -// no streaming, no /v1/models, no balance. This driver covers Embed -// and Rerank with real implementations and returns "no such method" -// for every other ModelDriver method. -// -// Wire shape, captured live: -// -// Embed response: {object, data:[{object,embedding,index,text}], model, usage} -// Rerank response: {object, data:[{relevance_score,index}], model, usage} -// -// Rerank uses top_k as the request param name (not top_n like -// Aliyun/SiliconFlow); the driver translates RerankConfig.TopN to -// top_k on the wire. type VoyageModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } // NewVoyageModel creates a new Voyage AI model instance. -// -// We clone http.DefaultTransport so we keep Go's defaults for -// ProxyFromEnvironment, DialContext (with KeepAlive), HTTP/2, -// TLSHandshakeTimeout, and ExpectContinueTimeout, and only override -// the connection-pool fields we care about. func NewVoyageModel(baseURL map[string]string, urlSuffix URLSuffix) *VoyageModel { defaultTransport, ok := http.DefaultTransport.(*http.Transport) var transport *http.Transport @@ -72,33 +50,24 @@ func NewVoyageModel(baseURL map[string]string, urlSuffix URLSuffix) *VoyageModel transport.ResponseHeaderTimeout = 60 * time.Second return &VoyageModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (v *VoyageModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewVoyageModel(baseURL, v.URLSuffix) + return NewVoyageModel(baseURL, v.baseModel.URLSuffix) } func (v *VoyageModel) Name() string { return "voyage" } -// baseURLForRegion returns the base URL for the given region, or an -// error if no entry exists. Single-region for Voyage but kept here -// for consistency with other drivers. -func (v *VoyageModel) baseURLForRegion(region string) (string, error) { - base, ok := v.BaseURL[region] - if !ok || base == "" { - return "", fmt.Errorf("voyage: no base URL configured for region %q", region) - } - return base, nil -} - type voyageEmbeddingData struct { Embedding []float64 `json:"embedding"` Object string `json:"object"` @@ -111,44 +80,30 @@ type voyageEmbeddingResponse struct { Model string `json:"model"` } -// Embed turns a list of texts into embedding vectors using the -// Voyage AI /v1/embeddings endpoint. Output is one vector per input, -// in the same order the inputs were given. func (v *VoyageModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { - if len(texts) == 0 { - return []EmbeddingData{}, nil + if err := v.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if len(texts) == 0 { + return []EmbeddingData{}, nil } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := v.baseURLForRegion(region) + baseURL, err := v.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), v.URLSuffix.Embedding) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), v.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": *modelName, "input": texts, } - - // Voyage's Matryoshka models (voyage-3.5, voyage-3.5-lite, - // voyage-3-large, voyage-code-3) accept output_dimension to - // truncate the vector. The wire param is output_dimension - // (singular) per https://docs.voyageai.com/reference/embeddings-api; - // passing "dimensions" or "output_dimensions" gets rejected with - // HTTP 400, so it's worth matching the docs spelling exactly. if embeddingConfig != nil && embeddingConfig.Dimension > 0 { reqBody["output_dimension"] = embeddingConfig.Dimension } @@ -169,7 +124,7 @@ func (v *VoyageModel) Embed(modelName *string, texts []string, apiConfig *APICon req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := v.httpClient.Do(req) + resp, err := v.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -189,10 +144,6 @@ func (v *VoyageModel) Embed(modelName *string, texts []string, apiConfig *APICon return nil, fmt.Errorf("failed to parse response: %w", err) } - // Reorder by the reported index so the output always lines up with - // the input texts. Reject duplicates (silent overwrite would hide - // a malformed response) and out-of-range indices (silent panic on - // slice growth would mask the bug). embeddings := make([]EmbeddingData, len(texts)) filled := make([]bool, len(texts)) for _, item := range parsed.Data { @@ -233,31 +184,24 @@ type voyageRerankResponse struct { Model string `json:"model"` } -// Rerank calculates similarity scores between a query and a list of -// documents using Voyage AI's /v1/rerank endpoint. Unlike many other -// rerank APIs that use `top_n`, Voyage uses `top_k` as the request -// parameter; the driver translates RerankConfig.TopN -> top_k. func (v *VoyageModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { + if err := v.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(documents) == 0 { return &RerankResponse{}, nil } - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") - } if modelName == nil || *modelName == "" { return nil, fmt.Errorf("model name is required") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := v.baseURLForRegion(region) + baseURL, err := v.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), v.URLSuffix.Rerank) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), v.baseModel.URLSuffix.Rerank) topK := len(documents) if rerankConfig != nil && rerankConfig.TopN > 0 { @@ -287,7 +231,7 @@ func (v *VoyageModel) Rerank(modelName *string, query string, documents []string req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := v.httpClient.Do(req) + resp, err := v.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -307,9 +251,6 @@ func (v *VoyageModel) Rerank(modelName *string, query string, documents []string return nil, fmt.Errorf("failed to parse response: %w", err) } - // Match Embed's defensive posture: rerank only returns top_k of - // len(documents) results, but a duplicate index would still be - // a malformed response and should fail loudly. rerankResponse := &RerankResponse{} seen := make(map[int]bool, len(parsed.Data)) for _, r := range parsed.Data { @@ -329,20 +270,10 @@ func (v *VoyageModel) Rerank(modelName *string, query string, documents []string return rerankResponse, nil } -// ListModels is not exposed by the Voyage AI API. The docs at -// https://docs.voyageai.com publish embeddings and rerank endpoints -// only; /v1/models is not documented (live-confirmed: 404). The -// shipped catalog lives in conf/models/voyage.json; this driver -// method does not invent a fake one. func (v *VoyageModel) ListModels(apiConfig *APIConfig) ([]string, error) { return nil, fmt.Errorf("%s, no such method", v.Name()) } -// CheckConnection is not exposed by the Voyage AI API. With no -// documented /models or /health endpoint, the only way to verify -// credentials is to burn an embedding or rerank call against the -// tenant's quota — which is what this method exists to avoid. -// Return the documented sentinel rather than pretend. func (v *VoyageModel) CheckConnection(apiConfig *APIConfig) error { return fmt.Errorf("%s, no such method", v.Name()) } diff --git a/internal/entity/models/xai.go b/internal/entity/models/xai.go index e1d6f92bdc..0654658029 100644 --- a/internal/entity/models/xai.go +++ b/internal/entity/models/xai.go @@ -32,24 +32,6 @@ import ( "time" ) -// Per-call context deadlines shared by every provider in this package. -// -// The shared httpClient sets no Client.Timeout: that field also bounds the time -// spent reading the response body, which would sever long-lived SSE streams in -// ChatStreamlyWithSender once a generation outlasts the limit. Each call wraps -// its request in a context.WithTimeout sized to the operation instead: -// -// - nonStreamCallTimeout for interactive non-streaming calls -// (chat, embed, rerank, list models, check connection, balance). -// - streamCallTimeout for streaming chat, bounded generously so slow or -// reasoning-heavy generations are not truncated mid-stream. -// - longOpCallTimeout for heavy synchronous file work (OCR, document -// parsing, audio transcription/synthesis) that legitimately runs for -// minutes. Kept distinct from streamCallTimeout so the two can be tuned -// independently even though they currently share a value. -// -// They are vars rather than consts so tests can shrink them to milliseconds -// and exercise the deadline behaviour without real-time waits. var ( nonStreamCallTimeout = 120 * time.Second streamCallTimeout = 10 * time.Minute @@ -58,22 +40,10 @@ var ( // XAIModel implements ModelDriver for xAI (Grok models) type XAIModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client // Reusable HTTP client with connection pool + baseModel BaseModel } // NewXAIModel creates a new xAI model instance. -// -// We clone http.DefaultTransport so we keep Go's defaults for -// ProxyFromEnvironment, DialContext (with KeepAlive), HTTP/2, -// TLSHandshakeTimeout, and ExpectContinueTimeout, and only override -// the few connection-pool fields we care about. -// -// The Client itself has no Timeout. http.Client.Timeout would also -// cap the time spent reading the response body, which would cut off -// long-lived SSE streams in ChatStreamlyWithSender. Non-streaming -// callers wrap each request with context.WithTimeout instead. func NewXAIModel(baseURL map[string]string, urlSuffix URLSuffix) *XAIModel { transport := http.DefaultTransport.(*http.Transport).Clone() transport.MaxIdleConns = 100 @@ -82,54 +52,40 @@ func NewXAIModel(baseURL map[string]string, urlSuffix URLSuffix) *XAIModel { transport.DisableCompression = false return &XAIModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (x *XAIModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewXAIModel(baseURL, x.URLSuffix) + return NewXAIModel(baseURL, x.baseModel.URLSuffix) } func (x *XAIModel) Name() string { return "xai" } -// baseURLForRegion returns the base URL for the given region, or an -// error if no entry exists. This makes a misconfigured region fail -// fast with a clear message, instead of silently producing a relative -// URL that the HTTP transport then rejects. -func (x *XAIModel) baseURLForRegion(region string) (string, error) { - base, ok := x.BaseURL[region] - if !ok || base == "" { - return "", fmt.Errorf("xai: no base URL configured for region %q", region) - } - return base, nil -} - // ChatWithMessages sends multiple messages with roles and returns the response func (x *XAIModel) 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 err := x.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig.Region != nil { - region = *apiConfig.Region - } - - baseURL, err := x.baseURLForRegion(region) + baseURL, err := x.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, x.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, x.baseModel.URLSuffix.Chat) // Convert messages to the format expected by the API apiMessages := make([]map[string]interface{}, len(messages)) @@ -148,9 +104,6 @@ func (x *XAIModel) ChatWithMessages(modelName string, messages []Message, apiCon "temperature": 1, } - // Note: do NOT propagate chatModelConfig.Stream into the request body - // here. ChatWithMessages parses a single JSON response, so SSE/stream - // must always be off for this code path. if chatModelConfig != nil { if chatModelConfig.MaxTokens != nil { reqBody["max_tokens"] = *chatModelConfig.MaxTokens @@ -185,7 +138,7 @@ func (x *XAIModel) ChatWithMessages(modelName string, messages []Message, apiCon req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := x.httpClient.Do(req) + resp, err := x.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -244,27 +197,22 @@ func (x *XAIModel) ChatWithMessages(modelName string, messages []Message, apiCon return chatResponse, nil } -// ChatStreamlyWithSender sends messages and streams the response via the -// sender function. Used for streaming chat responses with no extra channel. +// ChatStreamlyWithSender sends messages and streams the response func (x *XAIModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := x.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + 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") - } - - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := x.baseURLForRegion(region) + baseURL, err := x.baseModel.GetBaseURL(apiConfig) if err != nil { return err } - url := fmt.Sprintf("%s/%s", baseURL, x.URLSuffix.Chat) + baseURL = strings.TrimSuffix(baseURL, "/") + url := fmt.Sprintf("%s/%s", baseURL, x.baseModel.URLSuffix.Chat) // Convert messages to API format (supports multimodal content) apiMessages := make([]map[string]interface{}, len(messages)) @@ -283,11 +231,6 @@ func (x *XAIModel) ChatStreamlyWithSender(modelName string, messages []Message, } if chatModelConfig != nil { - // Refuse to run if the caller explicitly asked for stream=false. - // The body of this method only knows how to read SSE, so a non-SSE - // JSON response would be parsed as if it were a stream and produce - // no chunks. Better to fail clearly. Leave reqBody["stream"] as - // the default (true) when Stream is nil or true. if chatModelConfig.Stream != nil && !*chatModelConfig.Stream { return fmt.Errorf("stream must be true in ChatStreamlyWithSender") } @@ -325,7 +268,7 @@ func (x *XAIModel) ChatStreamlyWithSender(modelName string, messages []Message, req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := x.httpClient.Do(req) + resp, err := x.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -336,16 +279,8 @@ func (x *XAIModel) ChatStreamlyWithSender(modelName string, messages []Message, return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) } - // SSE parsing: read line by line. The default bufio.Scanner buffer - // is 64KB, which can be too small for long SSE chunks. Bump it to - // 1MB so we never silently truncate a long data: line. scanner := bufio.NewScanner(resp.Body) scanner.Buffer(make([]byte, 64*1024), 1024*1024) - // sawTerminal flips to true when the upstream actually told us the - // stream is over (either a "[DONE]" marker or a non-empty - // finish_reason). If the body closes before either of those, we - // must not emit a synthetic "[DONE]" because that would hide a - // truncated response from the caller. sawTerminal := false for scanner.Scan() { line := scanner.Text() @@ -430,20 +365,16 @@ func (x *XAIModel) Embed(modelName *string, texts []string, apiConfig *APIConfig // ListModels returns the list of model ids visible to the API key. func (x *XAIModel) ListModels(apiConfig *APIConfig) ([]string, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("api key is required") + if err := x.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region - } - - baseURL, err := x.baseURLForRegion(region) + baseURL, err := x.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - modelsSuffix := strings.Trim(strings.TrimSpace(x.URLSuffix.Models), "/") + baseURL = strings.TrimSuffix(baseURL, "/") + modelsSuffix := strings.Trim(strings.TrimSpace(x.baseModel.URLSuffix.Models), "/") if modelsSuffix == "" { return nil, fmt.Errorf("xai: models URL suffix is not configured") } @@ -460,7 +391,7 @@ func (x *XAIModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := x.httpClient.Do(req) + resp, err := x.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -516,24 +447,26 @@ func (x *XAIModel) CheckConnection(apiConfig *APIConfig) error { return nil } -// Rerank calculates similarity scores between query and documents. xAI does not -// expose a rerank API, so this is left unimplemented. +// Rerank calculates similarity scores between query and documents func (x *XAIModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { return nil, fmt.Errorf("%s, Rerank not implemented", x.Name()) } // TranscribeAudio transcribe audio func (x *XAIModel) TranscribeAudio(modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig) (*ASRResponse, error) { + if err := x.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if file == nil || *file == "" { return nil, fmt.Errorf("file is missing") } - region := "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := x.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", x.BaseURL[region], x.URLSuffix.ASR) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, x.baseModel.URLSuffix.ASR) // multipart body var body bytes.Buffer @@ -620,7 +553,7 @@ func (x *XAIModel) TranscribeAudio(modelName *string, file *string, apiConfig *A req.Header.Set("Content-Type", writer.FormDataContentType()) // send request - resp, err := x.httpClient.Do(req) + resp, err := x.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -653,20 +586,19 @@ func (x *XAIModel) TranscribeAudioWithSender(modelName *string, file *string, ap // AudioSpeech convert text to audio func (x *XAIModel) AudioSpeech(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig) (*TTSResponse, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return nil, fmt.Errorf("xai API key is missing") + if err := x.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } if audioContent == nil || *audioContent == "" { return nil, fmt.Errorf("text content is missing") } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := x.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", x.BaseURL[region], x.URLSuffix.TTS) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, x.baseModel.URLSuffix.TTS) reqBody := map[string]interface{}{ "text": *audioContent, @@ -697,7 +629,7 @@ func (x *XAIModel) AudioSpeech(modelName *string, audioContent *string, apiConfi req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := x.httpClient.Do(req) + resp, err := x.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/xinference.go b/internal/entity/models/xinference.go index 52307da44d..3a3f5ef3d8 100644 --- a/internal/entity/models/xinference.go +++ b/internal/entity/models/xinference.go @@ -32,25 +32,11 @@ import ( "time" ) -// xinferenceStreamIdleTimeout bounds how long a stream can go without -// receiving any SSE line. Self-hosted models can be slow, but a stream -// that stays silent for a full minute is more useful as a surfaced error -// than as a stuck goroutine. var xinferenceStreamIdleTimeout = 60 * time.Second // XinferenceModel implements ModelDriver for Xinference chat models. -// -// Xinference exposes an OpenAI-compatible API under /v1. The -// tenant may configure either the root endpoint (http://127.0.0.1:9997) -// or the OpenAI-compatible endpoint (http://127.0.0.1:9997/v1); the -// driver normalizes both to the root endpoint before adding URLSuffix -// values that match Xinference docs, such as v1/chat/completions. -// Authentication is optional: no-auth deployments ignore API keys, while -// auth-enabled deployments require Authorization: Bearer . type XinferenceModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } type xinferenceChatChoice struct { @@ -82,32 +68,24 @@ func NewXinferenceModel(baseURL map[string]string, urlSuffix URLSuffix) *Xinfere transport.ResponseHeaderTimeout = 60 * time.Second return &XinferenceModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: transport, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: transport, + }, }, } } func (x *XinferenceModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewXinferenceModel(baseURL, x.URLSuffix) + return NewXinferenceModel(baseURL, x.baseModel.URLSuffix) } func (x *XinferenceModel) Name() string { return "xinference" } -func (x *XinferenceModel) baseURLForRegion(region string) (string, error) { - if base, ok := x.BaseURL[region]; ok && strings.TrimSpace(base) != "" { - return normalizeXinferenceBaseURL(base), nil - } - if base, ok := x.BaseURL["default"]; ok && strings.TrimSpace(base) != "" { - return normalizeXinferenceBaseURL(base), nil - } - return "", fmt.Errorf("xinference: missing base URL, configure the Xinference endpoint (e.g., http://127.0.0.1:9997 or http://127.0.0.1:9997/v1)") -} - func normalizeXinferenceBaseURL(base string) string { trimmed := strings.TrimRight(strings.TrimSpace(base), "/") if trimmed == "" { @@ -119,20 +97,6 @@ func normalizeXinferenceBaseURL(base string) string { return trimmed } -func setXinferenceAuth(req *http.Request, apiConfig *APIConfig) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { - return - } - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) -} - -func xinferenceRegion(apiConfig *APIConfig) string { - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - return *apiConfig.Region - } - return "default" -} - func xinferenceReasoningFromStrings(reasoningContent string, reasoning string, thinking string) string { switch { case reasoningContent != "": @@ -190,15 +154,20 @@ func buildXinferenceChatBody(modelName string, messages []Message, stream bool, // ChatWithMessages sends multiple messages with roles and returns the response. func (x *XinferenceModel) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { + if err := x.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - baseURL, err := x.baseURLForRegion(xinferenceRegion(apiConfig)) + baseURL, err := x.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, x.URLSuffix.Chat) + baseURL = normalizeXinferenceBaseURL(baseURL) + url := fmt.Sprintf("%s/%s", baseURL, x.baseModel.URLSuffix.Chat) reqBody := buildXinferenceChatBody(modelName, messages, false, chatModelConfig) jsonData, err := json.Marshal(reqBody) @@ -214,9 +183,9 @@ func (x *XinferenceModel) ChatWithMessages(modelName string, messages []Message, return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") - setXinferenceAuth(req, apiConfig) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := x.httpClient.Do(req) + resp, err := x.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -253,6 +222,10 @@ func (x *XinferenceModel) ChatWithMessages(modelName string, messages []Message, // ChatStreamlyWithSender sends messages and streams response via sender. func (x *XinferenceModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := x.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if sender == nil { return fmt.Errorf("sender is required") } @@ -263,11 +236,12 @@ func (x *XinferenceModel) ChatStreamlyWithSender(modelName string, messages []Me return fmt.Errorf("stream must be true in ChatStreamlyWithSender") } - baseURL, err := x.baseURLForRegion(xinferenceRegion(apiConfig)) + baseURL, err := x.baseModel.GetBaseURL(apiConfig) if err != nil { return err } - url := fmt.Sprintf("%s/%s", baseURL, x.URLSuffix.Chat) + baseURL = normalizeXinferenceBaseURL(baseURL) + url := fmt.Sprintf("%s/%s", baseURL, x.baseModel.URLSuffix.Chat) reqBody := buildXinferenceChatBody(modelName, messages, true, chatModelConfig) jsonData, err := json.Marshal(reqBody) @@ -283,9 +257,9 @@ func (x *XinferenceModel) ChatStreamlyWithSender(modelName string, messages []Me return fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") - setXinferenceAuth(req, apiConfig) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := x.httpClient.Do(req) + resp, err := x.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -391,22 +365,12 @@ type xinferenceEmbeddingResponse struct { } `json:"data"` } -// Embed POSTs the input texts to the tenant's Xinference /v1/embeddings -// endpoint and returns one EmbeddingData per input in the original input -// order. -// -// Mirrors the Python XinferenceEmbed class in rag/llm/embedding_model.py -// for payload shape (OpenAI-compatible: model + input → data[*].index + -// data[*].embedding) and tolerates the same no-auth default Xinference -// deployments use. Authorization: Bearer is only set when the -// tenant configured a non-empty API key, via setXinferenceAuth. -// -// The response is validated by index: duplicate, missing, or -// out-of-range data[*].index values fail with a clear error rather than -// silently producing misaligned vectors. EmbeddingConfig.Dimension, when -// > 0, is forwarded as the OpenAI-style "dimensions" field for models -// that support truncated output vectors. +// Embed POSTs the input texts to the tenant's Xinference func (x *XinferenceModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { + if err := x.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(texts) == 0 { return []EmbeddingData{}, nil } @@ -414,14 +378,15 @@ func (x *XinferenceModel) Embed(modelName *string, texts []string, apiConfig *AP return nil, fmt.Errorf("model name is required") } - baseURL, err := x.baseURLForRegion(xinferenceRegion(apiConfig)) + baseURL, err := x.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - if x.URLSuffix.Embedding == "" { + baseURL = normalizeXinferenceBaseURL(baseURL) + if x.baseModel.URLSuffix.Embedding == "" { return nil, fmt.Errorf("xinference: no embedding URL suffix configured") } - url := fmt.Sprintf("%s/%s", baseURL, x.URLSuffix.Embedding) + url := fmt.Sprintf("%s/%s", baseURL, x.baseModel.URLSuffix.Embedding) reqBody := map[string]interface{}{ "model": *modelName, @@ -444,9 +409,9 @@ func (x *XinferenceModel) Embed(modelName *string, texts []string, apiConfig *AP return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") - setXinferenceAuth(req, apiConfig) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := x.httpClient.Do(req) + resp, err := x.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -508,6 +473,10 @@ type xinferenceRerankResponse struct { // original input order. Xinference rerank models are launched with // --model-type rerank and exposed under the OpenAI-compatible base URL. func (x *XinferenceModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { + if err := x.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(documents) == 0 { return &RerankResponse{}, nil } @@ -515,14 +484,15 @@ func (x *XinferenceModel) Rerank(modelName *string, query string, documents []st return nil, fmt.Errorf("model name is required") } - baseURL, err := x.baseURLForRegion(xinferenceRegion(apiConfig)) + baseURL, err := x.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - if x.URLSuffix.Rerank == "" { + baseURL = normalizeXinferenceBaseURL(baseURL) + if x.baseModel.URLSuffix.Rerank == "" { return nil, fmt.Errorf("xinference: no rerank URL suffix configured") } - url := fmt.Sprintf("%s/%s", baseURL, x.URLSuffix.Rerank) + url := fmt.Sprintf("%s/%s", baseURL, x.baseModel.URLSuffix.Rerank) topN := len(documents) if rerankConfig != nil && rerankConfig.TopN > 0 && rerankConfig.TopN < topN { @@ -550,9 +520,9 @@ func (x *XinferenceModel) Rerank(modelName *string, query string, documents []st } req.Header.Set("Content-Type", "application/json") - setXinferenceAuth(req, apiConfig) + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := x.httpClient.Do(req) + resp, err := x.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -592,16 +562,19 @@ func (x *XinferenceModel) Rerank(modelName *string, query string, documents []st } func (x *XinferenceModel) TranscribeAudio(modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig) (*ASRResponse, error) { + if err := x.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if file == nil || *file == "" { return nil, fmt.Errorf("file is missing") } - region := "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := x.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", x.BaseURL[region], x.URLSuffix.ASR) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, x.baseModel.URLSuffix.ASR) var body bytes.Buffer writer := multipart.NewWriter(&body) @@ -664,7 +637,7 @@ func (x *XinferenceModel) TranscribeAudio(modelName *string, file *string, apiCo req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) req.Header.Set("Content-Type", writer.FormDataContentType()) - resp, err := x.httpClient.Do(req) + resp, err := x.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -698,16 +671,19 @@ func (x *XinferenceModel) TranscribeAudioWithSender(modelName *string, file *str } func (x *XinferenceModel) AudioSpeech(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig) (*TTSResponse, error) { + if err := x.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if audioContent == nil || *audioContent == "" { return nil, fmt.Errorf("text content is missing") } - var region = "default" - if apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := x.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", x.BaseURL[region], x.URLSuffix.TTS) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, x.baseModel.URLSuffix.TTS) reqBody := map[string]interface{}{ "model": *modelName, @@ -736,7 +712,7 @@ func (x *XinferenceModel) AudioSpeech(modelName *string, audioContent *string, a req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := x.httpClient.Do(req) + resp, err := x.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -769,11 +745,16 @@ func (x *XinferenceModel) ParseFile(modelName *string, content []byte, url *stri // ListModels returns the model IDs exposed by Xinference's OpenAI-compatible // /v1/models endpoint. func (x *XinferenceModel) ListModels(apiConfig *APIConfig) ([]string, error) { - baseURL, err := x.baseURLForRegion(xinferenceRegion(apiConfig)) + if err := x.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + + baseURL, err := x.baseModel.GetBaseURL(apiConfig) if err != nil { return nil, err } - url := fmt.Sprintf("%s/%s", baseURL, x.URLSuffix.Models) + baseURL = normalizeXinferenceBaseURL(baseURL) + url := fmt.Sprintf("%s/%s", baseURL, x.baseModel.URLSuffix.Models) ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout) defer cancel() @@ -782,9 +763,11 @@ func (x *XinferenceModel) ListModels(apiConfig *APIConfig) ([]string, error) { if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } - setXinferenceAuth(req, apiConfig) - resp, err := x.httpClient.Do(req) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) + + resp, err := x.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/xunfei.go b/internal/entity/models/xunfei.go index f85a9e480c..f1f13a3ee7 100644 --- a/internal/entity/models/xunfei.go +++ b/internal/entity/models/xunfei.go @@ -30,28 +30,28 @@ import ( ) type XunFeiModel struct { - BaseURL map[string]string - URLSuffix URLSuffix - httpClient *http.Client + baseModel BaseModel } func NewXunFeiModel(baseURL map[string]string, urlSuffix URLSuffix) *XunFeiModel { return &XunFeiModel{ - BaseURL: baseURL, - URLSuffix: urlSuffix, - httpClient: &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 10, - MaxIdleConnsPerHost: 100, - IdleConnTimeout: time.Second * 90, - DisableCompression: false, + baseModel: BaseModel{ + BaseURL: baseURL, + URLSuffix: urlSuffix, + httpClient: &http.Client{ + Transport: &http.Transport{ + MaxIdleConns: 10, + MaxIdleConnsPerHost: 100, + IdleConnTimeout: time.Second * 90, + DisableCompression: false, + }, }, }, } } func (x *XunFeiModel) NewInstance(baseURL map[string]string) ModelDriver { - return NewXunFeiModel(baseURL, x.URLSuffix) + return NewXunFeiModel(baseURL, x.baseModel.URLSuffix) } func (x *XunFeiModel) Name() string { @@ -59,16 +59,19 @@ func (x *XunFeiModel) Name() string { } func (x *XunFeiModel) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { + if err := x.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if len(messages) == 0 { return nil, fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := x.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err } - - url := fmt.Sprintf("%s/%s", x.BaseURL[region], x.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, x.baseModel.URLSuffix.Chat) apiMessages := make([]map[string]interface{}, len(messages)) for i, msg := range messages { @@ -131,7 +134,7 @@ func (x *XunFeiModel) ChatWithMessages(modelName string, messages []Message, api req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := x.httpClient.Do(req) + resp, err := x.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } @@ -188,16 +191,19 @@ func (x *XunFeiModel) ChatWithMessages(modelName string, messages []Message, api } func (x *XunFeiModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, modelConfig *ChatConfig, sender func(*string, *string) error) error { + if err := x.baseModel.APIConfigCheck(apiConfig); err != nil { + return err + } + if len(messages) == 0 { return fmt.Errorf("messages is empty") } - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + resolvedBaseURL, err := x.baseModel.GetBaseURL(apiConfig) + if err != nil { + return err } - - url := fmt.Sprintf("%s/%s", x.BaseURL[region], x.URLSuffix.Chat) + url := fmt.Sprintf("%s/%s", resolvedBaseURL, x.baseModel.URLSuffix.Chat) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -265,7 +271,7 @@ func (x *XunFeiModel) ChatStreamlyWithSender(modelName string, messages []Messag req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := x.httpClient.Do(req) + resp, err := x.baseModel.httpClient.Do(req) if err != nil { return fmt.Errorf("failed to send request: %w", err) } @@ -379,12 +385,15 @@ func (x *XunFeiModel) ParseFile(modelName *string, content []byte, url *string, } func (x *XunFeiModel) ListModels(apiConfig *APIConfig) ([]string, error) { - var region = "default" - if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { - region = *apiConfig.Region + if err := x.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - url := fmt.Sprintf("%s/%s", x.BaseURL[region], x.URLSuffix.Models) + resolvedBaseURL, err := x.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + url := fmt.Sprintf("%s/%s", resolvedBaseURL, x.baseModel.URLSuffix.Models) // Build request body reqBody := map[string]interface{}{} @@ -405,7 +414,7 @@ func (x *XunFeiModel) ListModels(apiConfig *APIConfig) ([]string, error) { req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - resp, err := x.httpClient.Do(req) + resp, err := x.baseModel.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } diff --git a/internal/entity/models/zhipu-ai.go b/internal/entity/models/zhipu-ai.go index b900212017..d1ae513e50 100644 --- a/internal/entity/models/zhipu-ai.go +++ b/internal/entity/models/zhipu-ai.go @@ -57,7 +57,7 @@ func NewZhipuAIModel(baseURL map[string]string, urlSuffix URLSuffix) *ZhipuAIMod } func (z *ZhipuAIModel) NewInstance(baseURL map[string]string) ModelDriver { - return nil + return NewZhipuAIModel(baseURL, z.baseModel.URLSuffix) } func (z *ZhipuAIModel) Name() string { @@ -66,8 +66,7 @@ func (z *ZhipuAIModel) Name() string { // ChatWithMessages sends multiple messages with roles and returns response func (z *ZhipuAIModel) ChatWithMessages(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig) (*ChatResponse, error) { - err := z.baseModel.APIConfigCheck(apiConfig) - if err != nil { + if err := z.baseModel.APIConfigCheck(apiConfig); err != nil { return nil, err } @@ -212,8 +211,7 @@ func (z *ZhipuAIModel) ChatWithMessages(modelName string, messages []Message, ap // ChatStreamlyWithSender sends messages and streams response via sender function (best performance, no channel) func (z *ZhipuAIModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, chatModelConfig *ChatConfig, sender func(*string, *string) error) error { - err := z.baseModel.APIConfigCheck(apiConfig) - if err != nil { + if err := z.baseModel.APIConfigCheck(apiConfig); err != nil { return err } @@ -401,13 +399,12 @@ type zhipuUsage struct { // Encode encodes a list of texts into embeddings func (z *ZhipuAIModel) Embed(modelName *string, texts []string, apiConfig *APIConfig, embeddingConfig *EmbeddingConfig) ([]EmbeddingData, error) { - if len(texts) == 0 { - return []EmbeddingData{}, nil + if err := z.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - err := z.baseModel.APIConfigCheck(apiConfig) - if err != nil { - return nil, err + if len(texts) == 0 { + return []EmbeddingData{}, nil } if modelName == nil || *modelName == "" { @@ -478,8 +475,7 @@ func (z *ZhipuAIModel) Embed(modelName *string, texts []string, apiConfig *APICo } func (z *ZhipuAIModel) ListModels(apiConfig *APIConfig) ([]string, error) { - err := z.baseModel.APIConfigCheck(apiConfig) - if err != nil { + if err := z.baseModel.APIConfigCheck(apiConfig); err != nil { return nil, err } @@ -499,9 +495,7 @@ func (z *ZhipuAIModel) ListModels(apiConfig *APIConfig) ([]string, error) { } req.Header.Set("Content-Type", "application/json") - if apiConfig != nil && apiConfig.ApiKey != nil && *apiConfig.ApiKey != "" { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) - } + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) resp, err := z.baseModel.httpClient.Do(req) if err != nil { @@ -537,8 +531,7 @@ func (z *ZhipuAIModel) Balance(apiConfig *APIConfig) (map[string]interface{}, er } func (z *ZhipuAIModel) CheckConnection(apiConfig *APIConfig) error { - err := z.baseModel.APIConfigCheck(apiConfig) - if err != nil { + if err := z.baseModel.APIConfigCheck(apiConfig); err != nil { return err } @@ -614,13 +607,12 @@ type zhipuOCRResponse struct { // the ZhipuAI /rerank endpoint (e.g. glm-rerank). The result is one // score per input text, in the same order the documents were given. func (z *ZhipuAIModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { - if len(documents) == 0 { - return &RerankResponse{}, nil + if err := z.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err } - err := z.baseModel.APIConfigCheck(apiConfig) - if err != nil { - return nil, err + if len(documents) == 0 { + return &RerankResponse{}, nil } if modelName == nil || *modelName == "" { @@ -697,8 +689,7 @@ func (z *ZhipuAIModel) Rerank(modelName *string, query string, documents []strin // TranscribeAudio transcribe audio func (z *ZhipuAIModel) TranscribeAudio(modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig) (*ASRResponse, error) { - err := z.baseModel.APIConfigCheck(apiConfig) - if err != nil { + if err := z.baseModel.APIConfigCheck(apiConfig); err != nil { return nil, err } @@ -829,8 +820,7 @@ func (z *ZhipuAIModel) TranscribeAudioWithSender(modelName *string, file *string // AudioSpeech convert text to audio func (z *ZhipuAIModel) AudioSpeech(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig) (*TTSResponse, error) { - err := z.baseModel.APIConfigCheck(apiConfig) - if err != nil { + if err := z.baseModel.APIConfigCheck(apiConfig); err != nil { return nil, err } @@ -872,8 +862,7 @@ func (z *ZhipuAIModel) AudioSpeech(modelName *string, audioContent *string, apiC } func (z *ZhipuAIModel) AudioSpeechWithSender(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig, sender func(*string, *string) error) error { - err := z.baseModel.APIConfigCheck(apiConfig) - if err != nil { + if err := z.baseModel.APIConfigCheck(apiConfig); err != nil { return err } @@ -971,8 +960,7 @@ func (z *ZhipuAIModel) buildTTSRequest(modelName *string, audioContent *string, // OCRFile OCR file func (z *ZhipuAIModel) OCRFile(modelName *string, content []byte, fileURL *string, apiConfig *APIConfig, ocrConfig *OCRConfig) (*OCRFileResponse, error) { - err := z.baseModel.APIConfigCheck(apiConfig) - if err != nil { + if err := z.baseModel.APIConfigCheck(apiConfig); err != nil { return nil, err }