fd3e2992b1
Introduce per-model `custom_headers` config (similar to OpenAI Python SDK's `extra_headers`) so users can inject gateway auth tokens, trace IDs, etc. into every outbound API request for chat / embedding / rerank / VLLM / ASR models. Reserved headers like Authorization / Content-Type are always preserved to avoid breaking auth or signing flows. Along the way, unify production and "test connection" paths onto a single `ConfigFromModel(*types.Model, appID, appSecret)` constructor in each model package. Both `service.modelService.GetXxxModel` and the four `handler.initialization.Check*Model` / `TestEmbeddingModel` endpoints now go through the same field mapping, so new parameters only need to be added in one place. Backend: - Add `CustomHeaders map[string]string` to `types.ModelParameters` with JSON/YAML `omitempty` tags for forward compatibility. - New `internal/utils/extraheaders.go` providing `ApplyCustomHeaders` (for hand-rolled HTTP paths) and `WrapHTTPClientWithHeaders` / `CustomHeadersRoundTripper` (for SDK paths like go-openai). Reserved headers (Authorization, api-key, Content-Type, Accept, Host, Content-Length, User-Agent) are filtered out. - Inject headers in every remote model path: chat (SDK + raw HTTP), embedding (openai/aliyun/jina/volcengine/nvidia/azure_openai), rerank (remote_api/aliyun/jina/nvidia/zhipu — via shared `customHeaderSetter` interface), VLM and ASR (via http.Client wrapping). - Add `ConfigFromModel` to each of chat/embedding/rerank/vlm/asr, consolidating field mapping (ExtraConfig, CustomHeaders, InterfaceType defaulting, WeKnoraCloud credentials, etc.) and cover it with dedicated unit tests per package. - Refactor `service/model.go`: replace ~100 lines of hand-written Config literals with one-line `ConfigFromModel` calls; drop the now-unused `stringMapToAnyMap` helper. - Refactor `handler/initialization.go` test-connection endpoints: merge four ad-hoc request structs into one `ModelTestRequest`, extract `buildTestModel` and `resolveTenantWeKnoraCloudCreds` helpers, and route all four endpoints through `ConfigFromModel` + `NewXxx` so the test path is now behaviorally identical to production. Frontend: - Add `custom_headers` to the `ModelConfig` API type. - `ModelEditorDialog.vue`: new Key-Value header editor (add/remove rows), auto-converts map <-> array when loading/saving, and passes the current header set to the `Test Connection` button so the preview reflects exactly what production will send. - `ModelSettings.vue`: serialize the header array back into a map for the backend (drops empty rows). - i18n: add custom-header labels / descriptions / placeholders to zh-CN, en-US, ru-RU, ko-KR. Tests: - `internal/utils/extraheaders_test.go` covers reserved-header filtering and round-tripper wrapping (including nil-client fallback). - New `config_from_model_test.go` in chat / embedding / rerank / vlm / asr verifies all fields (CustomHeaders, ExtraConfig, InterfaceType defaulting, AppID/AppSecret) are propagated end-to-end.
143 lines
3.8 KiB
Go
143 lines
3.8 KiB
Go
package vlm
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"fmt"
|
||
"net/http"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/Tencent/WeKnora/internal/logger"
|
||
"github.com/Tencent/WeKnora/internal/models/provider"
|
||
secutils "github.com/Tencent/WeKnora/internal/utils"
|
||
openai "github.com/sashabaranov/go-openai"
|
||
)
|
||
|
||
const (
|
||
defaultTimeout = 90 * time.Second
|
||
defaultMaxToks = 5000
|
||
defaultTemp = float32(0.1)
|
||
)
|
||
|
||
// RemoteAPIVLM implements VLM via an OpenAI-compatible chat completions API.
|
||
type RemoteAPIVLM struct {
|
||
modelName string
|
||
modelID string
|
||
client *openai.Client
|
||
baseURL string
|
||
}
|
||
|
||
// NewRemoteAPIVLM creates a remote-API backed VLM instance.
|
||
func NewRemoteAPIVLM(config *Config) (*RemoteAPIVLM, error) {
|
||
providerName := provider.ProviderName(config.Provider)
|
||
if providerName == "" {
|
||
providerName = provider.DetectProvider(config.BaseURL)
|
||
}
|
||
|
||
var apiCfg openai.ClientConfig
|
||
if providerName == provider.ProviderAzureOpenAI {
|
||
apiCfg = openai.DefaultAzureConfig(config.APIKey, config.BaseURL)
|
||
apiCfg.AzureModelMapperFunc = func(model string) string {
|
||
return model
|
||
}
|
||
if config.Extra != nil {
|
||
if v, ok := config.Extra["api_version"]; ok {
|
||
if vs, ok := v.(string); ok && vs != "" {
|
||
apiCfg.APIVersion = vs
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
apiCfg = openai.DefaultConfig(config.APIKey)
|
||
if config.BaseURL != "" {
|
||
apiCfg.BaseURL = config.BaseURL
|
||
}
|
||
}
|
||
httpClient := &http.Client{Timeout: defaultTimeout}
|
||
|
||
// 注入用户自定义 HTTP header(类似 OpenAI Python SDK 的 extra_headers)
|
||
if len(config.CustomHeaders) > 0 {
|
||
apiCfg.HTTPClient = secutils.WrapHTTPClientWithHeaders(httpClient, config.CustomHeaders)
|
||
} else {
|
||
apiCfg.HTTPClient = httpClient
|
||
}
|
||
|
||
return &RemoteAPIVLM{
|
||
modelName: config.ModelName,
|
||
modelID: config.ModelID,
|
||
client: openai.NewClientWithConfig(apiCfg),
|
||
baseURL: config.BaseURL,
|
||
}, nil
|
||
}
|
||
|
||
// Predict sends an image with a text prompt to the OpenAI-compatible API.
|
||
func (v *RemoteAPIVLM) Predict(ctx context.Context, imgBytesList [][]byte, prompt string) (string, error) {
|
||
var parts []openai.ChatMessagePart
|
||
|
||
// Add text prompt first
|
||
parts = append(parts, openai.ChatMessagePart{
|
||
Type: openai.ChatMessagePartTypeText,
|
||
Text: prompt,
|
||
})
|
||
|
||
// Add images
|
||
for _, imgBytes := range imgBytesList {
|
||
if len(imgBytes) > 0 {
|
||
mimeType := detectImageMIME(imgBytes)
|
||
b64 := base64.StdEncoding.EncodeToString(imgBytes)
|
||
dataURI := fmt.Sprintf("data:%s;base64,%s", mimeType, b64)
|
||
parts = append(parts, openai.ChatMessagePart{
|
||
Type: openai.ChatMessagePartTypeImageURL,
|
||
ImageURL: &openai.ChatMessageImageURL{
|
||
URL: dataURI,
|
||
Detail: openai.ImageURLDetailAuto,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
req := openai.ChatCompletionRequest{
|
||
Model: v.modelName,
|
||
Messages: []openai.ChatCompletionMessage{
|
||
{
|
||
Role: openai.ChatMessageRoleUser,
|
||
MultiContent: parts,
|
||
},
|
||
},
|
||
MaxTokens: defaultMaxToks,
|
||
Temperature: defaultTemp,
|
||
}
|
||
|
||
totalImageSize := 0
|
||
for _, img := range imgBytesList {
|
||
totalImageSize += len(img)
|
||
}
|
||
logger.Infof(ctx, "[VLM] Calling OpenAI-compatible API, model=%s, baseURL=%s, numImages=%d, totalImageSize=%d",
|
||
v.modelName, v.baseURL, len(imgBytesList), totalImageSize)
|
||
|
||
resp, err := v.client.CreateChatCompletion(ctx, req)
|
||
if err != nil {
|
||
return "", fmt.Errorf("OpenAI VLM request: %w", err)
|
||
}
|
||
if len(resp.Choices) == 0 {
|
||
return "", fmt.Errorf("OpenAI VLM returned no choices")
|
||
}
|
||
|
||
content := resp.Choices[0].Message.Content
|
||
logger.Infof(ctx, "[VLM] OpenAI response received, len=%d", len(content))
|
||
return content, nil
|
||
}
|
||
|
||
func (v *RemoteAPIVLM) GetModelName() string { return v.modelName }
|
||
func (v *RemoteAPIVLM) GetModelID() string { return v.modelID }
|
||
|
||
// detectImageMIME returns the MIME type for the given image bytes.
|
||
func detectImageMIME(data []byte) string {
|
||
ct := http.DetectContentType(data)
|
||
if strings.HasPrefix(ct, "image/") {
|
||
return ct
|
||
}
|
||
return "image/png"
|
||
}
|