package runtime import ( "context" "errors" "fmt" "io" "net/http" "net/http/httptest" "regexp" "strings" "sync" "testing" ai "code.tczkiot.com/wlw/ai-agent/internal/ai" "code.tczkiot.com/wlw/ai-agent/internal/models" "github.com/cloudwego/eino/schema" ) func TestPlatformEinoChatModelUsesStableRequestIDsAcrossRunRecovery(t *testing.T) { var mu sync.Mutex requestIDs := make([]string, 0, 2) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { mu.Lock() requestIDs = append(requestIDs, request.Header.Get("X-AI-Request-ID")) mu.Unlock() w.Header().Set("Content-Type", "application/json") _, _ = fmt.Fprint(w, `{"id":"chatcmpl-test","object":"chat.completion","created":1,"model":"platform-default","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}`) })) t.Cleanup(server.Close) requestContext := withPlatformRequestIDBase(context.Background(), "conversation:10:message:20:revision:30") model, err := newEinoChatModel(requestContext, models.AIConfig{ APIKey: "platform-managed", BaseURL: server.URL + "/v1", ModelName: "platform-default", Platform: true, HTTPClient: server.Client(), }) if err != nil { t.Fatalf("newEinoChatModel() error = %v", err) } for range 2 { if _, err = model.Generate(requestContext, []*schema.Message{schema.UserMessage("hello")}); err != nil { t.Fatalf("Generate() error = %v", err) } } recoveredModel, err := newEinoChatModel(requestContext, models.AIConfig{ APIKey: "platform-managed", BaseURL: server.URL + "/v1", ModelName: "platform-default", Platform: true, HTTPClient: server.Client(), }) if err != nil { t.Fatalf("newEinoChatModel(recovered) error = %v", err) } for range 2 { if _, err = recoveredModel.Generate(requestContext, []*schema.Message{schema.UserMessage("hello")}); err != nil { t.Fatalf("recovered Generate() error = %v", err) } } mu.Lock() defer mu.Unlock() if len(requestIDs) != 4 || requestIDs[0] == "" || requestIDs[1] == "" || requestIDs[0] == requestIDs[1] { t.Fatalf("request IDs = %q, want distinct non-empty per-step values", requestIDs) } if requestIDs[0] != requestIDs[2] || requestIDs[1] != requestIDs[3] { t.Fatalf("request IDs = %q, want recovered run to reuse stable per-step IDs", requestIDs) } } func TestIsDeepSeekV4Model(t *testing.T) { tests := []struct { name string config models.AIConfig want bool }{ { name: "flash", config: models.AIConfig{ BaseURL: "https://api.deepseek.com", ModelName: "deepseek-v4-flash", }, want: true, }, { name: "pro with whitespace", config: models.AIConfig{ BaseURL: " https://api.deepseek.com/v1 ", ModelName: " DeepSeek-V4-Pro ", }, want: true, }, { name: "other openai compatible provider", config: models.AIConfig{ BaseURL: "https://example.com/v1", ModelName: "deepseek-v4-flash", }, want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := isDeepSeekV4Model(tt.config); got != tt.want { t.Fatalf("isDeepSeekV4Model() = %v, want %v", got, tt.want) } }) } } func TestEinoFunctionToolNormalizesModelNameAndExecutesOriginalBusinessCode(t *testing.T) { var executed ai.ToolCall tool, err := newEinoFunctionTool(ai.ToolDefinition{ Name: "business/card_diagnosis", Description: "Diagnose the current card.", Parameters: map[string]any{"type": "object"}, }, func(_ context.Context, call ai.ToolCall) (string, error) { executed = call return "ok", nil }) if err != nil { t.Fatalf("newEinoFunctionTool() error = %v", err) } info, err := tool.Info(context.Background()) if err != nil { t.Fatalf("Info() error = %v", err) } if !regexp.MustCompile(`^[a-zA-Z0-9_-]+$`).MatchString(info.Name) { t.Fatalf("normalized tool name %q is not OpenAI compatible", info.Name) } if info.Name == "business/card_diagnosis" || len(info.Name) > 64 { t.Fatalf("unexpected normalized tool name %q", info.Name) } result, err := tool.InvokableRun(context.Background(), `{"card":"current"}`) if err != nil { t.Fatalf("InvokableRun() error = %v", err) } if result != "ok" { t.Fatalf("InvokableRun() = %q, want ok", result) } if executed.Name != "business/card_diagnosis" || executed.Arguments != `{"card":"current"}` { t.Fatalf("executed call = %#v", executed) } } func TestNormalizeEinoToolNameKeepsCompatibleName(t *testing.T) { if got := normalizeEinoToolName("conversation_decision"); got != "conversation_decision" { t.Fatalf("normalizeEinoToolName() = %q", got) } } func TestBuildEinoUserMessageUsesTrustedInlineImages(t *testing.T) { message := buildEinoUserMessage("请看设备指示灯", []ai.ImageInput{{ AssetID: "asset-1", MIMEType: "image/png", Base64Data: "aGVsbG8=", }}) if message.Role != schema.User || message.Content != "" || len(message.UserInputMultiContent) != 4 { t.Fatalf("unexpected multimodal message: %#v", message) } if message.UserInputMultiContent[2].Type != schema.ChatMessagePartTypeText || !strings.Contains(message.UserInputMultiContent[2].Text, "图1") { t.Fatalf("image ordinal label missing: %#v", message.UserInputMultiContent[2]) } imagePart := message.UserInputMultiContent[3] if imagePart.Type != schema.ChatMessagePartTypeImageURL || imagePart.Image == nil || imagePart.Image.URL != nil || imagePart.Image.Base64Data == nil || *imagePart.Image.Base64Data != "aGVsbG8=" || imagePart.Image.MIMEType != "image/png" { t.Fatalf("unexpected trusted image part: %#v", imagePart) } if imagePart.Image.Detail != schema.ImageURLDetailHigh { t.Fatalf("device image must use high detail, got %q", imagePart.Image.Detail) } } func TestSupportsVisionInputIsConservativeAndFallbackErrorsAreScoped(t *testing.T) { for _, modelName := range []string{"qwen2.5-vl-max", "gpt-4o-mini", "gemini-2.5-flash"} { if !supportsVisionInput(models.AIConfig{ModelName: modelName}) { t.Fatalf("expected %q to support vision", modelName) } } for _, modelName := range []string{"deepseek-v4-flash", "qwen-plus", "platform-default"} { if supportsVisionInput(models.AIConfig{ModelName: modelName}) { t.Fatalf("text-only/unknown model %q must degrade without image parts", modelName) } } if supportsVisionInput(models.AIConfig{Platform: true, ModelName: "deepseek-v4-flash"}) { t.Fatal("managed platform without an enabled vision route must reject image parts") } if !supportsVisionInput(models.AIConfig{Platform: true, VisionEnabled: true, VisionModel: "qwen3-vl-plus", ModelName: "deepseek-v4-flash"}) { t.Fatal("managed platform with a configured vision route must preserve image parts") } if !isVisionUnsupportedError(errors.New("model does not support image content")) { t.Fatal("expected image capability error to trigger text-only retry") } if isVisionUnsupportedError(errors.New("upstream timeout")) { t.Fatal("unrelated upstream failures must not trigger a duplicate model call") } } func TestVisionFallbackExplicitlyForbidsPretendingToSeeImage(t *testing.T) { messages := []*schema.Message{schema.SystemMessage("base"), buildEinoUserMessage("看图", []ai.ImageInput{{MIMEType: "image/png", Base64Data: "aGVsbG8="}})} fallback := buildVisionFallbackMessages(messages, "看图") if len(fallback) != 3 || fallback[1].Role != schema.System || !strings.Contains(fallback[1].Content, "Never claim that you saw") || len(fallback[2].UserInputMultiContent) != 0 || fallback[2].Content != "看图" { t.Fatalf("unsafe text-only vision fallback: %#v", fallback) } } func TestEinoOpenAIAdapterSerializesInlineImageURLWithoutExternalURL(t *testing.T) { var requestBody string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { data, err := io.ReadAll(request.Body) if err != nil { t.Errorf("read request body: %v", err) } requestBody = string(data) w.Header().Set("Content-Type", "application/json") _, _ = fmt.Fprint(w, `{"id":"chatcmpl-vision","object":"chat.completion","created":1,"model":"gpt-4o-mini","choices":[{"index":0,"message":{"role":"assistant","content":"看到了"},"finish_reason":"stop"}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}`) })) t.Cleanup(server.Close) model, err := newEinoChatModel(context.Background(), models.AIConfig{ APIKey: "test", BaseURL: server.URL + "/v1", ModelName: "gpt-4o-mini", HTTPClient: server.Client(), }) if err != nil { t.Fatalf("newEinoChatModel() error = %v", err) } message := buildEinoUserMessage("分析照片", []ai.ImageInput{{MIMEType: "image/png", Base64Data: "aGVsbG8="}}) if _, err := model.Generate(context.Background(), []*schema.Message{message}); err != nil { t.Fatalf("Generate() error = %v", err) } if !strings.Contains(requestBody, "data:image/png;base64,aGVsbG8=") { t.Fatalf("request does not contain the expected inline image URL: %s", requestBody) } if strings.Contains(requestBody, "http://attacker") || strings.Contains(requestBody, "https://attacker") { t.Fatalf("external URL leaked into vision request: %s", requestBody) } }