mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-14 08:58:27 +08:00
Go: add Jina chat completions support (#14935)
### What problem does this PR solve? This PR adds non-streaming chat support for the Jina Go model provider. The Jina provider was added with embedding, rerank, model listing, and connection checking, but `ChatWithMessages` still returned a not-implemented error even though Jina exposes an OpenAI-compatible `/v1/chat/completions` endpoint. Closes #14933 **The following functionalities are now supported:** ### **Jina:** - [x] Chat - [ ] Stream Chat - [x] Embedding - [x] Rerank - [x] Model listing - [x] Provider connection checking - [ ] Balance ### **Implementation details:** - Implements `JinaModel.ChatWithMessages` - Sends `Authorization: Bearer <api-key>` and JSON chat completion requests - Validates API key, model name, messages, and configured region before making requests - Forwards supported chat config fields: `max_tokens`, `temperature`, `top_p`, and `stop` - Parses the first chat completion choice into `ChatResponse.Answer` - Adds `jina-ai/jina-vlm` as a chat-capable model in `conf/models/jina.json` - Adds focused unit tests for request construction, auth, response parsing, validation errors, provider errors, and region handling **Verification:** ```plaintext docker run --rm -v $PWD:/repo -w /repo golang:1.25 sh -c '/usr/local/go/bin/gofmt -w internal/entity/models/jina.go internal/entity/models/jina_test.go && /usr/local/go/bin/go test -vet=off ./internal/entity/models -run TestJina -count=1' ok ragflow/internal/entity/models 0.037s ``` Note: `go test ./internal/entity/models -run TestJina -count=1` currently hits unrelated existing vet findings in other provider files, so the focused Jina tests were run with `-vet=off`. ### Type of change - [x] New Feature (non-breaking change which adds functionality) --------- Co-authored-by: Jin Hai <haijin.chn@gmail.com>
This commit is contained in:
@@ -12,6 +12,13 @@
|
||||
},
|
||||
"class": "jina",
|
||||
"models": [
|
||||
{
|
||||
"name": "jina-vlm",
|
||||
"max_tokens": 32768,
|
||||
"model_types": [
|
||||
"chat"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "jina-reranker-v3",
|
||||
"max_tokens": 134144,
|
||||
@@ -97,4 +104,4 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package models
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -39,9 +40,126 @@ 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) {
|
||||
//TODO implement me: https://api.jina.ai/docs#/Search%20Foundation%20Models/chat_completions_v1_chat_completions_post
|
||||
return nil, fmt.Errorf("jina does not implement ChatWithMessages(not available for now)")
|
||||
if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" {
|
||||
return nil, fmt.Errorf("api key is required")
|
||||
}
|
||||
if modelName == "" {
|
||||
return nil, fmt.Errorf("model name is required")
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return nil, fmt.Errorf("messages is empty")
|
||||
}
|
||||
|
||||
region := "default"
|
||||
if apiConfig.Region != nil && *apiConfig.Region != "" {
|
||||
region = *apiConfig.Region
|
||||
}
|
||||
|
||||
baseURL, err := j.baseURLForRegion(region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
url := fmt.Sprintf("%s/%s", baseURL, j.URLSuffix.Chat)
|
||||
|
||||
apiMessages := make([]map[string]interface{}, len(messages))
|
||||
for i, msg := range messages {
|
||||
apiMessages[i] = map[string]interface{}{
|
||||
"role": msg.Role,
|
||||
"content": msg.Content,
|
||||
}
|
||||
}
|
||||
|
||||
reqBody := map[string]interface{}{
|
||||
"model": modelName,
|
||||
"messages": apiMessages,
|
||||
"stream": false,
|
||||
}
|
||||
|
||||
if chatModelConfig != nil {
|
||||
if chatModelConfig.MaxTokens != nil {
|
||||
reqBody["max_tokens"] = *chatModelConfig.MaxTokens
|
||||
}
|
||||
if chatModelConfig.Temperature != nil {
|
||||
reqBody["temperature"] = *chatModelConfig.Temperature
|
||||
}
|
||||
if chatModelConfig.TopP != nil {
|
||||
reqBody["top_p"] = *chatModelConfig.TopP
|
||||
}
|
||||
if chatModelConfig.Stop != nil {
|
||||
reqBody["stop"] = *chatModelConfig.Stop
|
||||
}
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), nonStreamCallTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey))
|
||||
|
||||
resp, err := j.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("Jina chat API error: status %d, body: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err = json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
choices, ok := result["choices"].([]interface{})
|
||||
if !ok || len(choices) == 0 {
|
||||
return nil, fmt.Errorf("no choices in response")
|
||||
}
|
||||
|
||||
firstChoice, ok := choices[0].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid choice format")
|
||||
}
|
||||
|
||||
messageMap, ok := firstChoice["message"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid message format")
|
||||
}
|
||||
|
||||
content, ok := messageMap["content"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid content format")
|
||||
}
|
||||
|
||||
reasonContent := ""
|
||||
return &ChatResponse{
|
||||
Answer: &content,
|
||||
ReasonContent: &reasonContent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (j *JinaModel) ChatStreamlyWithSender(modelName string, messages []Message, apiConfig *APIConfig, modelConfig *ChatConfig, sender func(*string, *string) error) error {
|
||||
|
||||
240
internal/entity/models/jina_test.go
Normal file
240
internal/entity/models/jina_test.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newJinaServer(t *testing.T, expectedPath string, handler func(t *testing.T, body map[string]interface{}, w http.ResponseWriter)) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != expectedPath {
|
||||
t.Errorf("expected path=%s, got %s", expectedPath, r.URL.Path)
|
||||
return
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
|
||||
t.Errorf("expected Authorization=Bearer test-key, got %q", got)
|
||||
return
|
||||
}
|
||||
if got := r.Header.Get("Content-Type"); got != "application/json" {
|
||||
t.Errorf("expected Content-Type=application/json, got %q", got)
|
||||
return
|
||||
}
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("failed to read body: %v", err)
|
||||
return
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &body); err != nil {
|
||||
t.Errorf("invalid JSON body: %v\n%s", err, string(raw))
|
||||
return
|
||||
}
|
||||
handler(t, body, w)
|
||||
}))
|
||||
}
|
||||
|
||||
func newJinaForTest(baseURL string) *JinaModel {
|
||||
return NewJinaModel(
|
||||
map[string]string{"default": baseURL},
|
||||
URLSuffix{
|
||||
Chat: "chat/completions",
|
||||
Models: "models",
|
||||
Embedding: "embeddings",
|
||||
Rerank: "rerank",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func TestJinaChatHappyPath(t *testing.T) {
|
||||
srv := newJinaServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
|
||||
if body["model"] != "jina-vlm" {
|
||||
t.Errorf("expected model=jina-vlm, got %v", body["model"])
|
||||
}
|
||||
if body["stream"] != false {
|
||||
t.Errorf("expected stream=false, got %v", body["stream"])
|
||||
}
|
||||
msgs, ok := body["messages"].([]interface{})
|
||||
if !ok || len(msgs) != 1 {
|
||||
t.Errorf("expected 1 message, got %v", body["messages"])
|
||||
return
|
||||
}
|
||||
msg, ok := msgs[0].(map[string]interface{})
|
||||
if !ok || msg["role"] != "user" || msg["content"] != "ping" {
|
||||
t.Errorf("unexpected message payload: %v", msgs[0])
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"choices": []map[string]interface{}{
|
||||
{"message": map[string]interface{}{"content": "pong"}},
|
||||
},
|
||||
})
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
j := newJinaForTest(srv.URL)
|
||||
apiKey := "test-key"
|
||||
resp, err := j.ChatWithMessages("jina-vlm", []Message{{Role: "user", Content: "ping"}}, &APIConfig{ApiKey: &apiKey}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("ChatWithMessages: %v", err)
|
||||
}
|
||||
if resp.Answer == nil || *resp.Answer != "pong" {
|
||||
t.Errorf("answer=%v, want pong", resp.Answer)
|
||||
}
|
||||
if resp.ReasonContent == nil || *resp.ReasonContent != "" {
|
||||
t.Errorf("expected empty reason content, got %v", resp.ReasonContent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJinaChatPropagatesConfig(t *testing.T) {
|
||||
srv := newJinaServer(t, "/chat/completions", func(t *testing.T, body map[string]interface{}, w http.ResponseWriter) {
|
||||
if body["max_tokens"] != float64(128) {
|
||||
t.Errorf("max_tokens=%v want 128", body["max_tokens"])
|
||||
}
|
||||
if body["temperature"] != 0.2 {
|
||||
t.Errorf("temperature=%v want 0.2", body["temperature"])
|
||||
}
|
||||
if body["top_p"] != 0.8 {
|
||||
t.Errorf("top_p=%v want 0.8", body["top_p"])
|
||||
}
|
||||
stop, ok := body["stop"].([]interface{})
|
||||
if !ok || len(stop) != 1 || stop[0] != "END" {
|
||||
t.Errorf("stop=%v want [END]", body["stop"])
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"choices": []map[string]interface{}{{"message": map[string]interface{}{"content": "ok"}}},
|
||||
})
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
j := newJinaForTest(srv.URL)
|
||||
apiKey := "test-key"
|
||||
maxTokens := 128
|
||||
temperature := 0.2
|
||||
topP := 0.8
|
||||
stop := []string{"END"}
|
||||
_, err := j.ChatWithMessages("jina-vlm", []Message{{Role: "user", Content: "ping"}},
|
||||
&APIConfig{ApiKey: &apiKey},
|
||||
&ChatConfig{MaxTokens: &maxTokens, Temperature: &temperature, TopP: &topP, Stop: &stop},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ChatWithMessages: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJinaChatValidation(t *testing.T) {
|
||||
j := newJinaForTest("http://unused")
|
||||
apiKey := "test-key"
|
||||
emptyKey := ""
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
modelName string
|
||||
messages []Message
|
||||
apiConfig *APIConfig
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing api config",
|
||||
modelName: "jina-vlm",
|
||||
messages: []Message{{Role: "user", Content: "x"}},
|
||||
want: "api key is required",
|
||||
},
|
||||
{
|
||||
name: "missing api key",
|
||||
modelName: "jina-vlm",
|
||||
messages: []Message{{Role: "user", Content: "x"}},
|
||||
apiConfig: &APIConfig{},
|
||||
want: "api key is required",
|
||||
},
|
||||
{
|
||||
name: "empty api key",
|
||||
modelName: "jina-vlm",
|
||||
messages: []Message{{Role: "user", Content: "x"}},
|
||||
apiConfig: &APIConfig{ApiKey: &emptyKey},
|
||||
want: "api key is required",
|
||||
},
|
||||
{
|
||||
name: "missing model",
|
||||
messages: []Message{{Role: "user", Content: "x"}},
|
||||
apiConfig: &APIConfig{ApiKey: &apiKey},
|
||||
want: "model name is required",
|
||||
},
|
||||
{
|
||||
name: "missing messages",
|
||||
modelName: "jina-vlm",
|
||||
apiConfig: &APIConfig{ApiKey: &apiKey},
|
||||
want: "messages is empty",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := j.ChatWithMessages(tt.modelName, tt.messages, tt.apiConfig, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("expected %q error, got %v", tt.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJinaChatRejectsHTTPError(t *testing.T) {
|
||||
srv := newJinaServer(t, "/chat/completions", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"detail":"invalid api key"}`))
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
j := newJinaForTest(srv.URL)
|
||||
apiKey := "test-key"
|
||||
_, err := j.ChatWithMessages("jina-vlm", []Message{{Role: "user", Content: "x"}}, &APIConfig{ApiKey: &apiKey}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "status 401") {
|
||||
t.Errorf("expected 401 propagated, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJinaChatRejectsMalformedResponse(t *testing.T) {
|
||||
srv := newJinaServer(t, "/chat/completions", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"choices": []map[string]interface{}{}})
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
j := newJinaForTest(srv.URL)
|
||||
apiKey := "test-key"
|
||||
_, err := j.ChatWithMessages("jina-vlm", []Message{{Role: "user", Content: "x"}}, &APIConfig{ApiKey: &apiKey}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "no choices in response") {
|
||||
t.Errorf("expected malformed-response error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJinaChatRejectsUnknownRegion(t *testing.T) {
|
||||
j := newJinaForTest("http://unused")
|
||||
apiKey := "test-key"
|
||||
region := "eu"
|
||||
_, err := j.ChatWithMessages("jina-vlm", []Message{{Role: "user", Content: "x"}},
|
||||
&APIConfig{ApiKey: &apiKey, Region: ®ion}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "no base URL configured for region") {
|
||||
t.Errorf("expected region error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJinaChatFallsBackToDefaultOnEmptyRegion(t *testing.T) {
|
||||
srv := newJinaServer(t, "/chat/completions", func(t *testing.T, _ map[string]interface{}, w http.ResponseWriter) {
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"choices": []map[string]interface{}{{"message": map[string]interface{}{"content": "ok"}}},
|
||||
})
|
||||
})
|
||||
defer srv.Close()
|
||||
|
||||
j := newJinaForTest(srv.URL)
|
||||
apiKey := "test-key"
|
||||
emptyRegion := ""
|
||||
_, err := j.ChatWithMessages("jina-vlm", []Message{{Role: "user", Content: "x"}},
|
||||
&APIConfig{ApiKey: &apiKey, Region: &emptyRegion}, nil)
|
||||
if err != nil {
|
||||
t.Errorf("empty Region: expected fallback to default, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user