From fe1433d1ff2e784736498743b5b86286e48f3ab8 Mon Sep 17 00:00:00 2001 From: Jake Armstrong <65635253+jakearmstrong59@users.noreply.github.com> Date: Sun, 17 May 2026 18:03:12 -1000 Subject: [PATCH] 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 ` 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 --- conf/models/jina.json | 9 +- internal/entity/models/jina.go | 122 +++++++++++++- internal/entity/models/jina_test.go | 240 ++++++++++++++++++++++++++++ 3 files changed, 368 insertions(+), 3 deletions(-) create mode 100644 internal/entity/models/jina_test.go diff --git a/conf/models/jina.json b/conf/models/jina.json index 07463b6edf..97069a4b9a 100644 --- a/conf/models/jina.json +++ b/conf/models/jina.json @@ -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 @@ ] } ] -} \ No newline at end of file +} diff --git a/internal/entity/models/jina.go b/internal/entity/models/jina.go index fffca66128..fca88664a1 100644 --- a/internal/entity/models/jina.go +++ b/internal/entity/models/jina.go @@ -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 { diff --git a/internal/entity/models/jina_test.go b/internal/entity/models/jina_test.go new file mode 100644 index 0000000000..2ae8a7be86 --- /dev/null +++ b/internal/entity/models/jina_test.go @@ -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) + } +}