From 7e1ab9741b1f82383bea7958f2062ab8c3f81c70 Mon Sep 17 00:00:00 2001 From: Robert Keus Date: Tue, 28 Jul 2026 13:19:00 +0200 Subject: [PATCH] feat: add GreenPT model provider (#17447) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary GreenPT is a European AI provider with an OpenAI-compatible API, optimized infrastructure, and datacenters powered by 100% renewable energy. This adds native GreenPT support across RAGFlow’s Go-first provider system and its Python compatibility layer: - discovers the current catalog from `GET /v1/models` - features `glm-5.2` and `kimi-k2.7-code` for chat and coding - supports `green-embedding` through `/v1/embeddings` - supports `green-rerank` through `/v1/rerank` - supports `green-s` and `green-s-pro` speech-to-text through `/v1/listen` - adds provider configuration, UI icon, and supported-provider documentation --- conf/llm_factories.json | 52 +++++++++ conf/models/greenpt.json | 68 +++++++++++ docs/guides/models/supported_models.mdx | 1 + internal/entity/models/factory.go | 2 + internal/entity/models/greenpt.go | 145 ++++++++++++++++++++++++ internal/entity/models/greenpt_test.go | 115 +++++++++++++++++++ rag/llm/chat_model.py | 9 ++ rag/llm/embedding_model.py | 9 ++ rag/llm/model_meta.py | 32 ++++++ rag/llm/rerank_model.py | 12 ++ rag/llm/sequence2txt_model.py | 68 ++++++++++- test/unit_test/rag/llm/test_greenpt.py | 105 +++++++++++++++++ web/src/assets/svg/llm/greenpt.svg | 4 + web/src/components/svg-icon.tsx | 1 + web/src/constants/llm.ts | 3 + 15 files changed, 624 insertions(+), 2 deletions(-) create mode 100644 conf/models/greenpt.json create mode 100644 internal/entity/models/greenpt.go create mode 100644 internal/entity/models/greenpt_test.go create mode 100644 test/unit_test/rag/llm/test_greenpt.py create mode 100644 web/src/assets/svg/llm/greenpt.svg diff --git a/conf/llm_factories.json b/conf/llm_factories.json index 36dea54024..aa1f75acc3 100644 --- a/conf/llm_factories.json +++ b/conf/llm_factories.json @@ -1,5 +1,57 @@ { "factory_llm_infos": [ + { + "name": "GreenPT", + "logo": "", + "tags": "LLM,TEXT EMBEDDING,TEXT RE-RANK,SPEECH2TEXT", + "status": "1", + "rank": "50", + "url": "https://api.greenpt.ai/v1", + "llm": [ + { + "llm_name": "glm-5.2", + "tags": "LLM,CHAT,1M", + "max_tokens": 1000000, + "model_type": "chat", + "is_tools": true + }, + { + "llm_name": "kimi-k2.7-code", + "tags": "LLM,CHAT,256K", + "max_tokens": 262144, + "model_type": "chat", + "is_tools": true + }, + { + "llm_name": "green-embedding", + "tags": "TEXT EMBEDDING,32K", + "max_tokens": 32768, + "model_type": "embedding", + "is_tools": false + }, + { + "llm_name": "green-rerank", + "tags": "RE-RANK,32K", + "max_tokens": 32768, + "model_type": "rerank", + "is_tools": false + }, + { + "llm_name": "green-s", + "tags": "SPEECH2TEXT", + "max_tokens": 0, + "model_type": "speech2text", + "is_tools": false + }, + { + "llm_name": "green-s-pro", + "tags": "SPEECH2TEXT", + "max_tokens": 0, + "model_type": "speech2text", + "is_tools": false + } + ] + }, { "name": "aimlapi.com", "logo": "", diff --git a/conf/models/greenpt.json b/conf/models/greenpt.json new file mode 100644 index 0000000000..36a61bbd93 --- /dev/null +++ b/conf/models/greenpt.json @@ -0,0 +1,68 @@ +{ + "name": "GreenPT", + "url": { + "default": "https://api.greenpt.ai" + }, + "url_suffix": { + "chat": "v1/chat/completions", + "models": "v1/models", + "embedding": "v1/embeddings", + "rerank": "v1/rerank", + "asr": "v1/listen" + }, + "class": "greenpt", + "models": [ + { + "name": "glm-5.2", + "max_tokens": 1000000, + "model_types": [ + "chat" + ], + "tools": { + "support": true + } + }, + { + "name": "kimi-k2.7-code", + "max_tokens": 262144, + "model_types": [ + "chat" + ], + "tools": { + "support": true + } + }, + { + "name": "green-embedding", + "max_tokens": 32768, + "max_dimension": 2560, + "dimensions": [ + 2560 + ], + "model_types": [ + "embedding" + ] + }, + { + "name": "green-rerank", + "max_tokens": 32768, + "model_types": [ + "rerank" + ] + }, + { + "name": "green-s", + "max_tokens": 0, + "model_types": [ + "asr" + ] + }, + { + "name": "green-s-pro", + "max_tokens": 0, + "model_types": [ + "asr" + ] + } + ] +} diff --git a/docs/guides/models/supported_models.mdx b/docs/guides/models/supported_models.mdx index 7dbc8cc2eb..7515a89164 100644 --- a/docs/guides/models/supported_models.mdx +++ b/docs/guides/models/supported_models.mdx @@ -35,6 +35,7 @@ A complete list of model providers supported by RAGFlow, which will continue to | Google Cloud | `https://cloud.google.com` | | GPUStack | `https://gpustack.ai` | | Groq | `https://groq.com` | +| GreenPT | `https://greenpt.ai` | | HuggingFace | `https://huggingface.co` | | Jina | `https://jina.ai` | | LocalAI | `https://localai.io` | diff --git a/internal/entity/models/factory.go b/internal/entity/models/factory.go index 3d3d3acde5..1aa4d1da60 100644 --- a/internal/entity/models/factory.go +++ b/internal/entity/models/factory.go @@ -159,6 +159,8 @@ func (f *ModelFactory) CreateModelDriver(providerName string, baseURL map[string return NewXiaomiModel(baseURL, urlSuffix), nil case "funasr": return NewFunASRModel(baseURL, urlSuffix), nil + case "greenpt": + return NewGreenPTModel(baseURL, urlSuffix), nil default: return NewDummyModel(baseURL, urlSuffix), nil } diff --git a/internal/entity/models/greenpt.go b/internal/entity/models/greenpt.go new file mode 100644 index 0000000000..baa57244b2 --- /dev/null +++ b/internal/entity/models/greenpt.go @@ -0,0 +1,145 @@ +// +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package models + +import ( + "context" + "encoding/json" + "fmt" + "io" + "mime" + "net/http" + "net/url" + "os" + "path/filepath" + "ragflow/internal/common" + "strings" +) + +// GreenPTModel implements GreenPT's OpenAI-compatible chat, embedding, +// reranking, and model-list APIs, plus its Deepgram-compatible STT endpoint. +type GreenPTModel struct { + *OpenAIAPICompatibleModel +} + +// NewGreenPTModel creates a GreenPT model driver. +func NewGreenPTModel(baseURL map[string]string, urlSuffix URLSuffix) *GreenPTModel { + return &GreenPTModel{ + OpenAIAPICompatibleModel: NewOpenAIAPICompatibleModel(baseURL, urlSuffix), + } +} + +func (m *GreenPTModel) Name() string { + return "GreenPT" +} + +func (m *GreenPTModel) NewInstance(baseURL map[string]string) ModelDriver { + return NewGreenPTModel(baseURL, m.baseModel.URLSuffix) +} + +// ListModels uses GreenPT's live /v1/models endpoint and corrects the two +// speech model IDs, whose names do not contain an ASR hint. +func (m *GreenPTModel) ListModels(ctx context.Context, apiConfig *APIConfig) ([]ListModelResponse, error) { + models, err := m.OpenAIAPICompatibleModel.ListModels(ctx, apiConfig) + if err != nil { + return nil, err + } + for i := range models { + switch models[i].Name { + case "green-s", "green-s-pro": + models[i].ModelTypes = []string{"asr"} + } + } + return models, nil +} + +// TranscribeAudio calls GreenPT's Deepgram-compatible /v1/listen endpoint. +func (m *GreenPTModel) TranscribeAudio(ctx context.Context, modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig, modelUsage *common.ModelUsage) (*ASRResponse, error) { + if err := m.baseModel.APIConfigCheck(apiConfig); err != nil { + return nil, err + } + if modelName == nil || strings.TrimSpace(*modelName) == "" { + return nil, fmt.Errorf("model name is required") + } + if file == nil || strings.TrimSpace(*file) == "" { + return nil, fmt.Errorf("file is missing") + } + + ctx, cancel := context.WithTimeout(ctx, nonStreamCallTimeout) + defer cancel() + + audio, err := os.Open(*file) + if err != nil { + return nil, fmt.Errorf("failed to open audio file: %w", err) + } + defer audio.Close() + + baseURL, err := m.baseModel.GetBaseURL(apiConfig) + if err != nil { + return nil, err + } + endpoint := fmt.Sprintf("%s/%s", strings.TrimSuffix(baseURL, "/"), strings.TrimPrefix(m.baseModel.URLSuffix.ASR, "/")) + query := url.Values{"model": {*modelName}} + if asrConfig != nil { + for key, value := range asrConfig.Params { + query.Set(key, fmt.Sprintf("%v", value)) + } + } + endpoint += "?" + query.Encode() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, audio) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + contentType := mime.TypeByExtension(strings.ToLower(filepath.Ext(*file))) + if contentType == "" { + contentType = "application/octet-stream" + } + req.Header.Set("Content-Type", contentType) + req.Header.Set("Authorization", "Token "+*apiConfig.ApiKey) + + resp, err := m.baseModel.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("GreenPT ASR API error: %s, body: %s", resp.Status, string(body)) + } + + var result struct { + Results struct { + Channels []struct { + Alternatives []struct { + Transcript string `json:"transcript"` + } `json:"alternatives"` + } `json:"channels"` + } `json:"results"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse GreenPT ASR response: %w", err) + } + if len(result.Results.Channels) == 0 || len(result.Results.Channels[0].Alternatives) == 0 { + return nil, fmt.Errorf("GreenPT ASR response contains no transcript") + } + return &ASRResponse{Text: result.Results.Channels[0].Alternatives[0].Transcript}, nil +} diff --git a/internal/entity/models/greenpt_test.go b/internal/entity/models/greenpt_test.go new file mode 100644 index 0000000000..63015bec84 --- /dev/null +++ b/internal/entity/models/greenpt_test.go @@ -0,0 +1,115 @@ +// +// Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package models + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func TestGreenPTListModelsClassifiesSpeech(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + t.Errorf("unexpected path: %s", r.URL.Path) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"object":"list","data":[{"id":"glm-5.2"},{"id":"green-embedding"},{"id":"green-rerank"},{"id":"green-s"},{"id":"green-s-pro"}]}`) + })) + defer server.Close() + + driver := NewGreenPTModel(map[string]string{"default": server.URL}, URLSuffix{Models: "v1/models"}) + key := "test" + models, err := driver.ListModels(context.Background(), &APIConfig{ApiKey: &key}) + if err != nil { + t.Fatal(err) + } + + want := map[string]string{ + "glm-5.2": "chat", + "green-embedding": "embedding", + "green-rerank": "rerank", + "green-s": "asr", + "green-s-pro": "asr", + } + if len(models) != len(want) { + t.Fatalf("expected %d models, got %d", len(want), len(models)) + } + for _, model := range models { + expectedType, ok := want[model.Name] + if !ok { + t.Fatalf("unexpected model: %s", model.Name) + } + if len(model.ModelTypes) != 1 || model.ModelTypes[0] != expectedType { + t.Fatalf("%s classified as %v", model.Name, model.ModelTypes) + } + delete(want, model.Name) + } + if len(want) != 0 { + t.Fatalf("missing expected models: %v", want) + } +} + +func TestGreenPTTranscribeAudio(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/listen" || r.URL.Query().Get("model") != "green-s" { + t.Errorf("unexpected request URL: %s", r.URL.String()) + return + } + if r.Header.Get("Authorization") != "Token test-key" { + t.Errorf("unexpected authorization header: %s", r.Header.Get("Authorization")) + return + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("failed to read request body: %v", err) + return + } + if string(body) != "audio" { + t.Errorf("unexpected audio body: %q", body) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"results":{"channels":[{"alternatives":[{"transcript":"renewable inference"}]}]}}`) + })) + defer server.Close() + + audio, err := os.CreateTemp(t.TempDir(), "*.wav") + if err != nil { + t.Fatal(err) + } + if _, err = audio.WriteString("audio"); err != nil { + t.Fatal(err) + } + if err = audio.Close(); err != nil { + t.Fatal(err) + } + + driver := NewGreenPTModel(map[string]string{"default": server.URL}, URLSuffix{ASR: "v1/listen"}) + key, model, path := "test-key", "green-s", audio.Name() + response, err := driver.TranscribeAudio(context.Background(), &model, &path, &APIConfig{ApiKey: &key}, nil, nil) + if err != nil { + t.Fatal(err) + } + if response.Text != "renewable inference" { + t.Fatalf("unexpected transcript: %q", response.Text) + } +} diff --git a/rag/llm/chat_model.py b/rag/llm/chat_model.py index 8a75135725..1783b858ee 100644 --- a/rag/llm/chat_model.py +++ b/rag/llm/chat_model.py @@ -1563,6 +1563,15 @@ class AIMLAPIChat(Base): logging.info("[aimlapi.com] Chat initialized with model %s", model_name) +class GreenPTChat(Base): + """GreenPT OpenAI-compatible chat adapter.""" + + _FACTORY_NAME = "GreenPT" + + def __init__(self, key, model_name, base_url="https://api.greenpt.ai/v1", **kwargs): + super().__init__(key, model_name, base_url or "https://api.greenpt.ai/v1", **kwargs) + + class LiteLLMBase(ABC): _FACTORY_NAME = [ "Tongyi-Qianwen", diff --git a/rag/llm/embedding_model.py b/rag/llm/embedding_model.py index ede09d28b9..005053366f 100644 --- a/rag/llm/embedding_model.py +++ b/rag/llm/embedding_model.py @@ -878,6 +878,15 @@ class OpenAI_APIEmbed(OpenAIEmbed): self.model_name = model_name.split("___")[0] +class GreenPTEmbed(OpenAIEmbed): + """GreenPT OpenAI-compatible embedding adapter.""" + + _FACTORY_NAME = "GreenPT" + + def __init__(self, key, model_name="green-embedding", base_url="https://api.greenpt.ai/v1"): + super().__init__(key, model_name=model_name, base_url=base_url or "https://api.greenpt.ai/v1") + + class CoHereEmbed(Base): _FACTORY_NAME = "Cohere" diff --git a/rag/llm/model_meta.py b/rag/llm/model_meta.py index 301cf8e119..6a91d4b6ea 100644 --- a/rag/llm/model_meta.py +++ b/rag/llm/model_meta.py @@ -18,6 +18,7 @@ import aiohttp from abc import ABC from urllib.parse import urlparse from json.decoder import JSONDecodeError +from typing import ClassVar from common.constants import LLMType @@ -458,6 +459,37 @@ class OpenAIAPICompatible(Base): return model_list +class GreenPT(OpenAIAPICompatible): + """Discover and classify GreenPT models from the live catalog.""" + + _FACTORY_NAME = "GreenPT" + + _MODEL_TYPES: ClassVar[dict[str, list[str]]] = { + "green-embedding": [LLMType.EMBEDDING.value], + "qwen3-embedding-8b": [LLMType.EMBEDDING.value], + "green-rerank": [LLMType.RERANK.value], + "green-s": [LLMType.ASR.value], + "green-s-pro": [LLMType.ASR.value], + } + _MAX_TOKENS: ClassVar[dict[str, int]] = { + "glm-5.2": 1_000_000, + "kimi-k2.7-code": 262_144, + "green-embedding": 32_768, + "qwen3-embedding-8b": 32_768, + "green-rerank": 32_768, + } + + def _format_model_list(self, raw_model_list): + """Apply GreenPT capability metadata to discovered models.""" + models = super()._format_model_list(raw_model_list) + for model in models: + model["model_types"] = self._MODEL_TYPES.get(model["name"], model["model_types"]) + model["max_tokens"] = self._MAX_TOKENS.get(model["name"], model["max_tokens"]) + if model["model_types"] == [LLMType.CHAT.value]: + model["features"] = ["is_tools"] + return models + + class FunASR(Base): _FACTORY_NAME = "FunASR" diff --git a/rag/llm/rerank_model.py b/rag/llm/rerank_model.py index 7778b08b04..2e4d1c43b0 100644 --- a/rag/llm/rerank_model.py +++ b/rag/llm/rerank_model.py @@ -116,6 +116,18 @@ class JinaRerank(Base): return rank, total_token_count_from_response(res) +class GreenPTRerank(JinaRerank): + """GreenPT native reranking adapter.""" + + _FACTORY_NAME = "GreenPT" + + def __init__(self, key, model_name="green-rerank", base_url="https://api.greenpt.ai/v1/rerank"): + endpoint = (base_url or "https://api.greenpt.ai/v1/rerank").rstrip("/") + if not endpoint.endswith("/rerank"): + endpoint += "/rerank" + super().__init__(key, model_name=model_name, base_url=endpoint) + + class XInferenceRerank(Base): _FACTORY_NAME = "Xinference" diff --git a/rag/llm/sequence2txt_model.py b/rag/llm/sequence2txt_model.py index 6957fb961a..beb684d00d 100644 --- a/rag/llm/sequence2txt_model.py +++ b/rag/llm/sequence2txt_model.py @@ -16,12 +16,13 @@ import base64 import io import json +import logging import os import re import struct -from abc import ABC import tempfile -import logging +from abc import ABC +from collections.abc import Mapping, Sequence from urllib.parse import urlparse import requests @@ -31,6 +32,8 @@ from openai.lib.azure import AzureOpenAI from common.token_utils import num_tokens_from_string from rag.utils.url_utils import ensure_v1 +logger = logging.getLogger(__name__) + class Base(ABC): def __init__(self, key, model_name, **kwargs): @@ -124,6 +127,67 @@ class FuturMixSeq2txt(GPTSeq2txt): logging.info("[FuturMix] Speech2Text initialized with model %s", model_name) +class GreenPTSeq2txt(Base): + """Transcribe audio with GreenPT's Deepgram-compatible endpoint.""" + + _FACTORY_NAME = "GreenPT" + + def __init__(self, key, model_name="green-s", base_url="https://api.greenpt.ai/v1", **kwargs): + self.api_key = key + self.model_name = model_name + self.base_url = (base_url or "https://api.greenpt.ai/v1").rstrip("/") + + def transcription(self, audio_path, **kwargs): + """Transcribe audio while logging only non-sensitive request metadata.""" + params = {"model": self.model_name} + params.update(kwargs) + logger.info("[GreenPT] Starting speech transcription with model %s", self.model_name) + try: + with open(audio_path, "rb") as audio_file: + response = requests.post( + f"{self.base_url}/listen", + headers={"Authorization": f"Token {self.api_key}", "Content-Type": "application/octet-stream"}, + params=params, + data=audio_file, + timeout=300, + ) + response.raise_for_status() + except (OSError, requests.RequestException): + logger.exception("[GreenPT] Speech transcription request failed for model %s", self.model_name) + raise + try: + payload = response.json() + if not isinstance(payload, Mapping): + raise TypeError("response root must be an object") + results = payload.get("results") + if not isinstance(results, Mapping): + raise TypeError("results must be an object") + channels = results.get("channels") + if not isinstance(channels, Sequence) or isinstance(channels, (str, bytes)) or not channels: + raise TypeError("channels must be a non-empty array") + channel = channels[0] + if not isinstance(channel, Mapping): + raise TypeError("channel must be an object") + alternatives = channel.get("alternatives") + if not isinstance(alternatives, Sequence) or isinstance(alternatives, (str, bytes)) or not alternatives: + raise TypeError("alternatives must be a non-empty array") + alternative = alternatives[0] + if not isinstance(alternative, Mapping): + raise TypeError("alternative must be an object") + transcript = alternative.get("transcript") + if not isinstance(transcript, str) or not (text := transcript.strip()): + raise TypeError("transcript must be a non-empty string") + except (TypeError, ValueError) as exc: + logger.warning("[GreenPT] Invalid speech response; model=%s status=%s", self.model_name, response.status_code) + raise ValueError("GreenPT speech response contains no valid transcript") from exc + logger.info( + "[GreenPT] Speech transcription completed; status=%s transcript_available=%s", + response.status_code, + bool(text), + ) + return text, num_tokens_from_string(text) + + class QWenSeq2txt(Base): _FACTORY_NAME = "Tongyi-Qianwen" _FUN_ASR_FLASH_PREFIX = "fun-asr-flash" diff --git a/test/unit_test/rag/llm/test_greenpt.py b/test/unit_test/rag/llm/test_greenpt.py new file mode 100644 index 0000000000..a45d3d5abb --- /dev/null +++ b/test/unit_test/rag/llm/test_greenpt.py @@ -0,0 +1,105 @@ +# +# Copyright 2026 The InfiniFlow Authors. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import logging +from io import BytesIO +from unittest.mock import Mock, patch + +import pytest + +from common.constants import LLMType +from rag.llm.model_meta import GreenPT +from rag.llm.rerank_model import GreenPTRerank +from rag.llm.sequence2txt_model import GreenPTSeq2txt + + +def test_greenpt_model_list_classifies_native_endpoints(): + provider = GreenPT("test", "https://api.greenpt.ai/v1") + models = provider._format_model_list( + { + "data": [ + {"id": "glm-5.2"}, + {"id": "green-embedding"}, + {"id": "green-rerank"}, + {"id": "green-s"}, + ] + } + ) + + by_name = {model["name"]: model for model in models} + assert by_name["glm-5.2"]["model_types"] == [LLMType.CHAT.value] + assert by_name["glm-5.2"]["max_tokens"] == 1_000_000 + assert by_name["green-embedding"]["model_types"] == [LLMType.EMBEDDING.value] + assert by_name["green-rerank"]["model_types"] == [LLMType.RERANK.value] + assert by_name["green-s"]["model_types"] == [LLMType.ASR.value] + + +def test_greenpt_rerank_normalizes_endpoint(): + assert GreenPTRerank("test").base_url == "https://api.greenpt.ai/v1/rerank" + assert GreenPTRerank("test", base_url="https://example.com/v1").base_url == "https://example.com/v1/rerank" + + +def test_greenpt_transcription_uses_listen_protocol(caplog): + caplog.set_level(logging.INFO) + response = Mock() + response.status_code = 200 + response.json.return_value = {"results": {"channels": [{"alternatives": [{"transcript": " renewable inference "}]}]}} + response.raise_for_status.return_value = None + + with ( + patch("builtins.open", return_value=BytesIO(b"audio")), + patch("rag.llm.sequence2txt_model.requests.post", return_value=response) as post, + ): + text, _ = GreenPTSeq2txt("secret").transcription("sample.wav", language="en") + + assert text == "renewable inference" + assert post.call_args.args[0] == "https://api.greenpt.ai/v1/listen" + assert post.call_args.kwargs["headers"]["Authorization"] == "Token secret" + assert post.call_args.kwargs["params"] == {"model": "green-s", "language": "en"} + assert "status=200" in caplog.text + assert "secret" not in caplog.text + assert "sample.wav" not in caplog.text + + +@pytest.mark.parametrize( + "payload", + [ + [], + {}, + {"results": []}, + {"results": {"channels": "invalid"}}, + {"results": {"channels": [[]]}}, + {"results": {"channels": [{"alternatives": "invalid"}]}}, + {"results": {"channels": [{"alternatives": [[]]}]}}, + {"results": {"channels": [{"alternatives": [{"transcript": 42}]}]}}, + {"results": {"channels": [{"alternatives": [{"transcript": " "}]}]}}, + ], +) +def test_greenpt_transcription_rejects_malformed_responses(payload, caplog): + caplog.set_level(logging.WARNING) + response = Mock(status_code=200) + response.json.return_value = payload + + with ( + patch("builtins.open", return_value=BytesIO(b"audio")), + patch("rag.llm.sequence2txt_model.requests.post", return_value=response), + pytest.raises(ValueError, match="contains no valid transcript"), + ): + GreenPTSeq2txt("secret").transcription("sample.wav") + + assert "model=green-s status=200" in caplog.text + assert "secret" not in caplog.text + assert "sample.wav" not in caplog.text diff --git a/web/src/assets/svg/llm/greenpt.svg b/web/src/assets/svg/llm/greenpt.svg new file mode 100644 index 0000000000..d446007096 --- /dev/null +++ b/web/src/assets/svg/llm/greenpt.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/src/components/svg-icon.tsx b/web/src/components/svg-icon.tsx index 03b44ad1e6..ccbc5e3c31 100644 --- a/web/src/components/svg-icon.tsx +++ b/web/src/components/svg-icon.tsx @@ -109,6 +109,7 @@ const svgIcons = [ LLMFactory.TokenHub, LLMFactory.FunASR, LLMFactory.AIMLAPI, + LLMFactory.GreenPT, ]; export const LlmIcon = ({ diff --git a/web/src/constants/llm.ts b/web/src/constants/llm.ts index bf7045b5a6..93cd7bc910 100644 --- a/web/src/constants/llm.ts +++ b/web/src/constants/llm.ts @@ -88,6 +88,7 @@ export enum LLMFactory { NewAPI = 'New API', FunASR = 'FunASR', AIMLAPI = 'aimlapi.com', + GreenPT = 'GreenPT', } // Please lowercase the file name @@ -175,6 +176,7 @@ export const IconMap = { [LLMFactory.NewAPI]: 'new-api', [LLMFactory.FunASR]: 'funasr', [LLMFactory.AIMLAPI]: 'aimlapi', + [LLMFactory.GreenPT]: 'greenpt', }; export const ModelTypeToField: Record = { @@ -198,6 +200,7 @@ export const FieldToModelType: Record = { export const APIMapUrl = { [LLMFactory.OpenAI]: 'https://platform.openai.com/api-keys', [LLMFactory.AIMLAPI]: 'https://aimlapi.com/app/keys', + [LLMFactory.GreenPT]: 'https://greenpt.ai', [LLMFactory.Anthropic]: 'https://console.anthropic.com/settings/keys', [LLMFactory.Gemini]: 'https://aistudio.google.com/app/apikey', [LLMFactory.DeepSeek]: 'https://platform.deepseek.com/api_keys',