chore: 并入 iotlcm-app 历史到 app/(原提交 f40bda9)

This commit is contained in:
MiFaZhan
2026-11-02 13:28:00 +08:00
1107 changed files with 147143 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
package ai
import (
"context"
"net/url"
"strings"
"time"
openaiModel "github.com/cloudwego/eino-ext/components/model/openai"
"github.com/cloudwego/eino/components/model"
"github.com/gogf/gf/v2/errors/gerror"
)
type OpenAICompatibleConfig struct {
APIKey string
BaseURL string
Endpoint string
Model string
Timeout time.Duration
MaxTokens int
JSONOutput bool
}
const ProviderOpenAICompatible = "openai-compatible"
type ChatModelConfig struct {
Provider string
APIKey string
BaseURL string
Endpoint string
Model string
Timeout time.Duration
MaxTokens int
JSONOutput bool
}
func NewChatModel(ctx context.Context, config ChatModelConfig) (model.BaseChatModel, error) {
provider := strings.ToLower(strings.TrimSpace(config.Provider))
if provider == "" {
provider = ProviderOpenAICompatible
}
switch provider {
case ProviderOpenAICompatible, "openai":
return NewOpenAICompatibleChatModel(ctx, OpenAICompatibleConfig{
APIKey: config.APIKey,
BaseURL: config.BaseURL,
Endpoint: config.Endpoint,
Model: config.Model,
Timeout: config.Timeout,
MaxTokens: config.MaxTokens,
JSONOutput: config.JSONOutput,
})
default:
return nil, gerror.Newf("暂不支持AI模型提供方:%s", provider)
}
}
func NewOpenAICompatibleChatModel(ctx context.Context, config OpenAICompatibleConfig) (model.BaseChatModel, error) {
apiKey := strings.TrimSpace(config.APIKey)
if apiKey == "" {
return nil, gerror.New("AI模型API Key不能为空")
}
modelName := strings.TrimSpace(config.Model)
if modelName == "" {
return nil, gerror.New("AI模型名称不能为空")
}
baseURL, err := ResolveOpenAIBaseURL(config.BaseURL, config.Endpoint)
if err != nil {
return nil, err
}
modelConfig := &openaiModel.ChatModelConfig{
APIKey: apiKey,
BaseURL: baseURL,
Model: modelName,
Timeout: config.Timeout,
}
if config.MaxTokens > 0 {
modelConfig.MaxTokens = &config.MaxTokens
}
if config.JSONOutput {
modelConfig.ResponseFormat = &openaiModel.ChatCompletionResponseFormat{
Type: openaiModel.ChatCompletionResponseFormatTypeJSONObject,
}
}
chatModel, err := openaiModel.NewChatModel(ctx, modelConfig)
if err != nil {
return nil, gerror.Wrap(err, "初始化Eino AI模型失败")
}
return chatModel, nil
}
func ResolveOpenAIBaseURL(baseURL string, endpoint string) (string, error) {
value := strings.TrimSpace(baseURL)
if value == "" {
value = strings.TrimSpace(endpoint)
}
if value == "" {
return "", gerror.New("AI模型BaseURL不能为空")
}
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", gerror.New("AI模型BaseURL格式不正确")
}
parsed.Path = strings.TrimSuffix(strings.TrimRight(parsed.Path, "/"), "/chat/completions")
parsed.RawPath = ""
parsed.RawQuery = ""
parsed.Fragment = ""
return strings.TrimRight(parsed.String(), "/"), nil
}
+92
View File
@@ -0,0 +1,92 @@
package ai
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/cloudwego/eino/schema"
)
func TestResolveOpenAIBaseURL(t *testing.T) {
tests := []struct {
name string
baseURL string
endpoint string
want string
wantErr bool
}{
{name: "base url", baseURL: "https://xueyangai.com/v1/", want: "https://xueyangai.com/v1"},
{name: "legacy endpoint", endpoint: "https://xueyangai.com/v1/chat/completions", want: "https://xueyangai.com/v1"},
{name: "base url preferred", baseURL: "https://example.com/v1", endpoint: "https://legacy.example.com/v1/chat/completions", want: "https://example.com/v1"},
{name: "invalid", baseURL: "xueyangai.com/v1", wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := ResolveOpenAIBaseURL(test.baseURL, test.endpoint)
if test.wantErr {
if err == nil {
t.Fatal("expected error")
}
return
}
if err != nil {
t.Fatalf("ResolveOpenAIBaseURL() error = %v", err)
}
if got != test.want {
t.Fatalf("ResolveOpenAIBaseURL() = %s, want %s", got, test.want)
}
})
}
}
func TestOpenAICompatibleChatModel(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/v1/chat/completions" {
t.Errorf("path = %s, want /v1/chat/completions", request.URL.Path)
}
if request.Header.Get("Authorization") != "Bearer test-key" {
t.Errorf("authorization = %s", request.Header.Get("Authorization"))
}
var body map[string]any
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Errorf("decode request: %v", err)
}
if body["model"] != "test-model" {
t.Errorf("model = %v, want test-model", body["model"])
}
writer.Header().Set("Content-Type", "application/json")
_, _ = writer.Write([]byte(`{"id":"chatcmpl-test","object":"chat.completion","created":1,"model":"test-model","choices":[{"index":0,"message":{"role":"assistant","content":"{\"ok\":true}"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`))
}))
defer server.Close()
chatModel, err := NewOpenAICompatibleChatModel(context.Background(), OpenAICompatibleConfig{
APIKey: "test-key",
BaseURL: server.URL + "/v1",
Model: "test-model",
Timeout: 3 * time.Second,
MaxTokens: 64,
JSONOutput: true,
})
if err != nil {
t.Fatalf("NewOpenAICompatibleChatModel() error = %v", err)
}
response, err := chatModel.Generate(context.Background(), []*schema.Message{schema.UserMessage("hello")})
if err != nil {
t.Fatalf("Generate() error = %v", err)
}
if response.Content != `{"ok":true}` {
t.Fatalf("response content = %s", response.Content)
}
}
func TestNewChatModelRejectsUnsupportedProvider(t *testing.T) {
_, err := NewChatModel(context.Background(), ChatModelConfig{Provider: "unsupported"})
if err == nil {
t.Fatal("expected unsupported provider error")
}
}