feat: implement multi-model service configuration support

- Support configuring multiple LLM services simultaneously
- Auto-derive model names from service types to simplify configuration
- Maintain backward compatibility with existing configurations
- Refactor configuration logic into dedicated env module
- Add comprehensive unit test coverage
- Update documentation with new configuration approach
This commit is contained in:
lilong.129
2025-06-06 21:58:20 +08:00
parent b642ea004e
commit 484eebdefd
9 changed files with 413 additions and 128 deletions

View File

@@ -1 +1 @@
v5.0.0-beta-2506061529
v5.0.0-beta-2506062217

View File

@@ -103,8 +103,8 @@ type ModelConfig struct {
- 多模型类型支持
**支持的模型类型**:
- `LLMServiceTypeUITARS`: UI-TARS 专业 UI 自动化模型
- `LLMServiceTypeDoubaoVL`: 豆包视觉语言模型
- `DOUBAO_1_5_THINKING_VISION_PRO_250428`: 豆包思维视觉专业版
- `DOUBAO_1_5_UI_TARS_250428`: 豆包UI-TARS专业UI自动化模型
### 2. 智能规划器 (planner.go)
@@ -290,30 +290,120 @@ type ConversationHistory []*schema.Message
### 1. 环境配置
设置必要的环境变量:
HttpRunner AI 模块支持多模型服务配置,您可以同时配置多个大模型服务,然后在测试用例中灵活切换。
#### 多模型配置方式
**服务特定配置**
```bash
# 豆包思维视觉专业版配置
DOUBAO_1_5_THINKING_VISION_PRO_250428_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
DOUBAO_1_5_THINKING_VISION_PRO_250428_API_KEY=your_doubao_api_key
# 豆包UI-TARS配置
DOUBAO_1_5_UI_TARS_250428_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
DOUBAO_1_5_UI_TARS_250428_API_KEY=your_doubao_ui_tars_api_key
**默认配置(向后兼容)**
```bash
# 默认配置,当没有找到服务特定配置时使用
LLM_MODEL_NAME=doubao-1.5-thinking-vision-pro-250428
OPENAI_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
OPENAI_API_KEY=your_default_api_key
```
#### 环境变量命名规则
- 将服务名称转换为大写
- 将连字符 `-` 和点号 `.` 替换为下划线 `_`
- 添加对应的后缀:`_BASE_URL``_API_KEY`
- 模型名称直接从服务类型推导,无需单独配置
例如:
- `doubao-1.5-thinking-vision-pro-250428``DOUBAO_1_5_THINKING_VISION_PRO_250428_*`
- `gpt-4``GPT_4_*`
- `claude-3.5-sonnet``CLAUDE_3_5_SONNET_*`
#### 配置优先级
1. **服务特定配置**(最高优先级):`{SERVICE_NAME}_BASE_URL``{SERVICE_NAME}_API_KEY`
2. **默认配置**(向后兼容):`OPENAI_BASE_URL``OPENAI_API_KEY``LLM_MODEL_NAME`
3. **模型名称**:优先使用服务类型名称,仅在完全使用默认配置时才使用 `LLM_MODEL_NAME`
#### 示例 .env 文件
```bash
export OPENAI_BASE_URL="https://your-api-endpoint"
export OPENAI_API_KEY="your-api-key"
export LLM_MODEL_NAME="your-model-name"
# 默认配置
LLM_MODEL_NAME=doubao-1.5-thinking-vision-pro-250428
OPENAI_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
OPENAI_API_KEY=your_default_api_key
# doubao-1.5-thinking-vision-pro-250428
DOUBAO_1_5_THINKING_VISION_PRO_250428_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
DOUBAO_1_5_THINKING_VISION_PRO_250428_API_KEY=your_doubao_thinking_api_key
# doubao-1.5-ui-tars-250428
DOUBAO_1_5_UI_TARS_250428_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
DOUBAO_1_5_UI_TARS_250428_API_KEY=your_doubao_ui_tars_api_key
```
### 2. 创建 LLM 服务
#### 在测试用例中指定服务
```json
{
"config": {
"name": "AI测试用例",
"llm_service": "doubao-1.5-thinking-vision-pro-250428"
},
"teststeps": [
{
"name": "AI操作步骤",
"android": {
"actions": [
{
"method": "start_to_goal",
"params": "启动应用并完成某个任务"
}
]
}
}
]
}
```
#### 在Go代码中使用
```go
// 创建 UI-TARS 服务
llmService, err := ai.NewLLMService(option.LLMServiceTypeUITARS)
// 创建豆包思维视觉专业版服务
llmService, err := ai.NewLLMService(option.DOUBAO_1_5_THINKING_VISION_PRO_250428)
if err != nil {
log.Fatal().Err(err).Msg("failed to create LLM service")
}
// 创建豆包视觉服务
llmService, err := ai.NewLLMService(option.LLMServiceTypeDoubaoVL)
// 创建豆包UI-TARS服务
llmService, err := ai.NewLLMService(option.DOUBAO_1_5_UI_TARS_250428)
if err != nil {
log.Fatal().Err(err).Msg("failed to create LLM service")
}
```
#### 模型切换
要切换到不同的模型服务,只需要修改测试用例中的 `llm_service` 字段:
```json
{
"config": {
"name": "连连看游戏测试",
"llm_service": "doubao-1.5-ui-tars-250428"
}
}
```
系统会自动根据服务名称获取对应的配置,无需修改环境变量。
### 3. 智能规划使用
```go
@@ -446,8 +536,8 @@ log.Info().Float64("x", center.X).Float64("y", center.Y).
AI 模块支持多种不同的语言模型,每种模型都有其特定的优势:
- **UI-TARS**: 专门针对 UI 自动化优化的模型,支持 Thought/Action 格式
- **豆包视觉**: 通用视觉语言模型,支持结构化 JSON 输出
- **豆包思维视觉专业版**: 支持深度思考的视觉语言模型,适合复杂场景分析
- **豆包UI-TARS**: 专门针对 UI 自动化优化的模型,支持 Thought/Action 格式
### 2. 坐标系统转换
@@ -495,7 +585,8 @@ func normalizeParameterName(paramName string) string {
### 1. 环境变量配置
- 确保所有必需的环境变量都已正确设置
- API 密钥需要有足够的权限和配额
- 模型名称必须与服务类型匹配
- 支持多模型配置,可以同时配置多个服务
- 模型名称自动从服务类型推导,无需手动配置
### 2. 图像格式要求
- 支持 Base64 编码的图像数据
@@ -503,7 +594,7 @@ func normalizeParameterName(paramName string) string {
- 图像尺寸信息必须准确提供
### 3. 坐标系统
- UI-TARS 使用 1000x1000 相对坐标系统
- 豆包UI-TARS 使用 1000x1000 相对坐标系统
- 需要正确的屏幕尺寸信息进行坐标转换
- 注意不同模型的坐标格式差异

View File

@@ -2,17 +2,8 @@ package ai
import (
"context"
"fmt"
"os"
"strings"
"time"
"github.com/cloudwego/eino-ext/components/model/openai"
"github.com/httprunner/httprunner/v5/code"
"github.com/httprunner/httprunner/v5/internal/config"
"github.com/httprunner/httprunner/v5/uixt/option"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
// ILLMService 定义了 LLM 服务接口,包括规划和断言功能
@@ -58,100 +49,3 @@ func (c *combinedLLMService) Call(ctx context.Context, opts *PlanningOptions) (*
func (c *combinedLLMService) Assert(ctx context.Context, opts *AssertOptions) (*AssertionResult, error) {
return c.asserter.Assert(ctx, opts)
}
// LLM model config env variables
const (
EnvOpenAIBaseURL = "OPENAI_BASE_URL"
EnvOpenAIAPIKey = "OPENAI_API_KEY"
EnvModelName = "LLM_MODEL_NAME"
)
const (
defaultTimeout = 30 * time.Second
)
type ModelConfig struct {
*openai.ChatModelConfig
ModelType option.LLMServiceType
}
// GetModelConfig get OpenAI config
func GetModelConfig(modelType option.LLMServiceType) (*ModelConfig, error) {
if err := config.LoadEnv(); err != nil {
return nil, errors.Wrap(code.LoadEnvError, err.Error())
}
openaiBaseURL := os.Getenv(EnvOpenAIBaseURL)
if openaiBaseURL == "" {
return nil, errors.Wrapf(code.LLMEnvMissedError,
"env %s missed", EnvOpenAIBaseURL)
}
openaiAPIKey := os.Getenv(EnvOpenAIAPIKey)
if openaiAPIKey == "" {
return nil, errors.Wrapf(code.LLMEnvMissedError,
"env %s missed", EnvOpenAIAPIKey)
}
modelName := os.Getenv(EnvModelName)
if modelName == "" {
return nil, errors.Wrapf(code.LLMEnvMissedError,
"env %s missed", EnvModelName)
}
// Validate model type and model name compatibility
if err := validateModelType(modelType, modelName); err != nil {
return nil, err
}
// https://www.volcengine.com/docs/82379/1536429
temperature := float32(0)
topP := float32(0.7)
modelConfig := &openai.ChatModelConfig{
BaseURL: openaiBaseURL,
APIKey: openaiAPIKey,
Model: modelName,
Timeout: defaultTimeout,
Temperature: &temperature,
TopP: &topP,
}
// log config info
log.Info().Str("model", modelConfig.Model).
Str("baseURL", modelConfig.BaseURL).
Str("apiKey", maskAPIKey(modelConfig.APIKey)).
Str("timeout", defaultTimeout.String()).
Msg("get model config")
return &ModelConfig{
ChatModelConfig: modelConfig,
ModelType: modelType,
}, nil
}
func validateModelType(modelType option.LLMServiceType, modelName string) error {
switch modelType {
case option.DOUBAO_1_5_UI_TARS_250428:
if !strings.Contains(modelName, string(modelType)) {
return fmt.Errorf("model name %s is not supported for %s", modelName, modelType)
}
return nil
case option.DOUBAO_1_5_THINKING_VISION_PRO_250428:
if !strings.Contains(modelName, string(modelType)) {
return fmt.Errorf("model name %s is not supported", modelName)
}
return nil
}
return fmt.Errorf("model type %s is not supported, supported types: %s, %s",
modelType,
option.DOUBAO_1_5_UI_TARS_250428,
option.DOUBAO_1_5_THINKING_VISION_PRO_250428)
}
// maskAPIKey masks the API key
func maskAPIKey(key string) string {
if len(key) <= 8 {
return "******"
}
return key[:4] + "******" + key[len(key)-4:]
}

View File

@@ -133,7 +133,7 @@ Here is the assertion. Please tell whether it is truthy according to the screens
logRequest(a.history)
startTime := time.Now()
message, err := a.model.Generate(ctx, a.history)
log.Info().Float64("elapsed(s)", time.Since(startTime).Seconds()).
log.Debug().Float64("elapsed(s)", time.Since(startTime).Seconds()).
Str("model", string(a.modelConfig.ModelType)).Msg("call model service for assertion")
if err != nil {
return nil, errors.Wrap(code.LLMRequestServiceError, err.Error())

130
uixt/ai/env.go Normal file
View File

@@ -0,0 +1,130 @@
package ai
import (
"os"
"strings"
"time"
"github.com/cloudwego/eino-ext/components/model/openai"
"github.com/httprunner/httprunner/v5/code"
"github.com/httprunner/httprunner/v5/internal/config"
"github.com/httprunner/httprunner/v5/uixt/option"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
// LLM model config env variables
const (
EnvOpenAIBaseURL = "OPENAI_BASE_URL"
EnvOpenAIAPIKey = "OPENAI_API_KEY"
EnvModelName = "LLM_MODEL_NAME"
)
const (
defaultTimeout = 30 * time.Second
)
// GetModelConfig get OpenAI config
func GetModelConfig(modelType option.LLMServiceType) (*ModelConfig, error) {
if err := config.LoadEnv(); err != nil {
return nil, errors.Wrap(code.LoadEnvError, err.Error())
}
baseURL, apiKey, modelName, err := getModelConfigFromEnv(modelType)
if err != nil {
return nil, errors.Wrap(code.LLMEnvMissedError, err.Error())
}
// https://www.volcengine.com/docs/82379/1536429
temperature := float32(0)
topP := float32(0.7)
modelConfig := &openai.ChatModelConfig{
BaseURL: baseURL,
APIKey: apiKey,
Model: modelName,
Timeout: defaultTimeout,
Temperature: &temperature,
TopP: &topP,
}
// log config info
log.Info().Str("model", modelConfig.Model).
Str("baseURL", modelConfig.BaseURL).
Str("apiKey", maskAPIKey(modelConfig.APIKey)).
Str("timeout", defaultTimeout.String()).
Str("serviceType", string(modelType)).
Msg("get model config")
return &ModelConfig{
ChatModelConfig: modelConfig,
ModelType: modelType,
}, nil
}
type ModelConfig struct {
*openai.ChatModelConfig
ModelType option.LLMServiceType
}
// getServiceEnvPrefix converts LLMServiceType to environment variable prefix
// e.g., "doubao-1.5-thinking-vision-pro-250428" -> "DOUBAO_1_5_THINKING_VISION_PRO_250428"
func getServiceEnvPrefix(modelType option.LLMServiceType) string {
// Convert service name to uppercase and replace hyphens and dots with underscores
prefix := strings.ToUpper(string(modelType))
prefix = strings.ReplaceAll(prefix, "-", "_")
prefix = strings.ReplaceAll(prefix, ".", "_")
return prefix
}
// getModelConfigFromEnv retrieves model configuration from environment variables
// It first tries to get service-specific config, then falls back to default config
// Model name is derived from the service type, no need for separate MODEL_NAME env var
func getModelConfigFromEnv(modelType option.LLMServiceType) (baseURL, apiKey, modelName string, err error) {
servicePrefix := getServiceEnvPrefix(modelType)
// Try to get service-specific configuration first
baseURL = os.Getenv(servicePrefix + "_BASE_URL")
apiKey = os.Getenv(servicePrefix + "_API_KEY")
// Model name is derived from the service type itself
modelName = string(modelType)
envBaseURL := os.Getenv(EnvOpenAIBaseURL)
envAPIKey := os.Getenv(EnvOpenAIAPIKey)
// If service-specific config is not found, fall back to default config
if baseURL == "" {
baseURL = envBaseURL
}
if apiKey == "" {
apiKey = envAPIKey
}
// If we're using default config completely (both base URL and API key from default),
// then use default model name if available
if baseURL == envBaseURL && apiKey == envAPIKey {
defaultModelName := os.Getenv(EnvModelName)
if defaultModelName != "" {
modelName = defaultModelName
}
}
// Check if all required configs are available
if baseURL == "" {
return "", "", "", errors.Errorf("env %s or %s missed", servicePrefix+"_BASE_URL", EnvOpenAIBaseURL)
}
if apiKey == "" {
return "", "", "", errors.Errorf("env %s or %s missed", servicePrefix+"_API_KEY", EnvOpenAIAPIKey)
}
return baseURL, apiKey, modelName, nil
}
// maskAPIKey masks the API key
func maskAPIKey(key string) string {
if len(key) <= 8 {
return "******"
}
return key[:4] + "******" + key[len(key)-4:]
}

171
uixt/ai/env_test.go Normal file
View File

@@ -0,0 +1,171 @@
package ai
import (
"os"
"testing"
"github.com/httprunner/httprunner/v5/uixt/option"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetServiceEnvPrefix(t *testing.T) {
tests := []struct {
name string
modelType option.LLMServiceType
expectedPrefix string
}{
{
name: "doubao thinking vision pro",
modelType: option.DOUBAO_1_5_THINKING_VISION_PRO_250428,
expectedPrefix: "DOUBAO_1_5_THINKING_VISION_PRO_250428",
},
{
name: "doubao ui tars",
modelType: option.DOUBAO_1_5_UI_TARS_250428,
expectedPrefix: "DOUBAO_1_5_UI_TARS_250428",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
prefix := getServiceEnvPrefix(tt.modelType)
assert.Equal(t, tt.expectedPrefix, prefix)
})
}
}
func TestGetModelConfigFromEnv_ServiceSpecific(t *testing.T) {
// Clean up environment variables after test
defer func() {
os.Unsetenv("DOUBAO_1_5_THINKING_VISION_PRO_250428_BASE_URL")
os.Unsetenv("DOUBAO_1_5_THINKING_VISION_PRO_250428_API_KEY")
}()
// Set service-specific environment variables (no need for MODEL_NAME)
os.Setenv("DOUBAO_1_5_THINKING_VISION_PRO_250428_BASE_URL", "https://test-base-url.com")
os.Setenv("DOUBAO_1_5_THINKING_VISION_PRO_250428_API_KEY", "test-api-key")
baseURL, apiKey, modelName, err := getModelConfigFromEnv(option.DOUBAO_1_5_THINKING_VISION_PRO_250428)
require.NoError(t, err)
assert.Equal(t, "https://test-base-url.com", baseURL)
assert.Equal(t, "test-api-key", apiKey)
assert.Equal(t, "doubao-1.5-thinking-vision-pro-250428", modelName) // Model name derived from service type
}
func TestGetModelConfigFromEnv_FallbackToDefault(t *testing.T) {
// Clean up environment variables after test
defer func() {
os.Unsetenv("OPENAI_BASE_URL")
os.Unsetenv("OPENAI_API_KEY")
os.Unsetenv("LLM_MODEL_NAME")
// Ensure service-specific vars are not set
os.Unsetenv("DOUBAO_1_5_THINKING_VISION_PRO_250428_BASE_URL")
os.Unsetenv("DOUBAO_1_5_THINKING_VISION_PRO_250428_API_KEY")
}()
// Set default environment variables
os.Setenv("OPENAI_BASE_URL", "https://default-base-url.com")
os.Setenv("OPENAI_API_KEY", "default-api-key")
os.Setenv("LLM_MODEL_NAME", "default-model-name")
baseURL, apiKey, modelName, err := getModelConfigFromEnv(option.DOUBAO_1_5_THINKING_VISION_PRO_250428)
require.NoError(t, err)
assert.Equal(t, "https://default-base-url.com", baseURL)
assert.Equal(t, "default-api-key", apiKey)
assert.Equal(t, "default-model-name", modelName) // Uses default model name when falling back to default config
}
func TestGetModelConfigFromEnv_MixedConfig(t *testing.T) {
// Clean up environment variables after test
defer func() {
os.Unsetenv("DOUBAO_1_5_THINKING_VISION_PRO_250428_BASE_URL")
os.Unsetenv("OPENAI_API_KEY")
os.Unsetenv("LLM_MODEL_NAME")
}()
// Set mixed configuration: service-specific base URL, default API key
os.Setenv("DOUBAO_1_5_THINKING_VISION_PRO_250428_BASE_URL", "https://service-specific-url.com")
os.Setenv("OPENAI_API_KEY", "default-api-key")
os.Setenv("LLM_MODEL_NAME", "default-model-name")
baseURL, apiKey, modelName, err := getModelConfigFromEnv(option.DOUBAO_1_5_THINKING_VISION_PRO_250428)
require.NoError(t, err)
assert.Equal(t, "https://service-specific-url.com", baseURL) // Service-specific
assert.Equal(t, "default-api-key", apiKey) // Default fallback
assert.Equal(t, "doubao-1.5-thinking-vision-pro-250428", modelName) // Service type derived model name
}
func TestGetModelConfigFromEnv_MissingConfig(t *testing.T) {
// Clean up environment variables after test
defer func() {
os.Unsetenv("DOUBAO_1_5_THINKING_VISION_PRO_250428_BASE_URL")
os.Unsetenv("DOUBAO_1_5_THINKING_VISION_PRO_250428_API_KEY")
os.Unsetenv("OPENAI_BASE_URL")
os.Unsetenv("OPENAI_API_KEY")
os.Unsetenv("LLM_MODEL_NAME")
}()
// Test missing base URL
os.Setenv("OPENAI_API_KEY", "test-api-key")
_, _, _, err := getModelConfigFromEnv(option.DOUBAO_1_5_THINKING_VISION_PRO_250428)
assert.Error(t, err)
assert.Contains(t, err.Error(), "BASE_URL")
// Test missing API key
os.Unsetenv("OPENAI_API_KEY")
os.Setenv("OPENAI_BASE_URL", "https://test-url.com")
_, _, _, err = getModelConfigFromEnv(option.DOUBAO_1_5_THINKING_VISION_PRO_250428)
assert.Error(t, err)
assert.Contains(t, err.Error(), "API_KEY")
// Test with both base URL and API key present - should succeed
os.Setenv("OPENAI_API_KEY", "test-api-key")
baseURL, apiKey, modelName, err := getModelConfigFromEnv(option.DOUBAO_1_5_THINKING_VISION_PRO_250428)
assert.NoError(t, err)
assert.Equal(t, "https://test-url.com", baseURL)
assert.Equal(t, "test-api-key", apiKey)
assert.Equal(t, "doubao-1.5-thinking-vision-pro-250428", modelName) // Model name derived from service type
}
func TestMaskAPIKey(t *testing.T) {
tests := []struct {
name string
apiKey string
expected string
}{
{
name: "normal key",
apiKey: "sk-1234567890abcdef",
expected: "sk-1******cdef",
},
{
name: "short key",
apiKey: "short",
expected: "******",
},
{
name: "empty key",
apiKey: "",
expected: "******",
},
{
name: "exactly 8 chars",
apiKey: "12345678",
expected: "******",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := maskAPIKey(tt.apiKey)
assert.Equal(t, tt.expected, result)
})
}
}

View File

@@ -89,7 +89,6 @@ func (p *Planner) Call(ctx context.Context, opts *PlanningOptions) (*PlanningRes
// reset conversation history if requested
if opts.ResetHistory {
p.history.Clear() // Clear everything including system message for complete isolation
log.Info().Msg("conversation history reset for planner")
}
// prepare prompt
@@ -109,8 +108,8 @@ func (p *Planner) Call(ctx context.Context, opts *PlanningOptions) (*PlanningRes
logRequest(p.history)
startTime := time.Now()
message, err := p.model.Generate(ctx, p.history)
log.Info().Float64("elapsed(s)", time.Since(startTime).Seconds()).
Str("model", string(p.modelConfig.ModelType)).Msg("call model service")
log.Debug().Float64("elapsed(s)", time.Since(startTime).Seconds()).
Str("model", string(p.modelConfig.ModelType)).Msg("call model service for planning")
if err != nil {
return nil, errors.Wrap(code.LLMRequestServiceError, err.Error())
}

View File

@@ -70,7 +70,7 @@ func (h *ConversationHistory) Clear() {
// Clear everything including system message
*h = ConversationHistory{}
log.Info().Msg("conversation history cleared completely")
log.Warn().Msg("conversation history cleared completely")
}
func logRequest(messages ConversationHistory) {

View File

@@ -261,9 +261,9 @@ func findCachedDriver(platform string) *XTDriver {
cached.RefCount++
if platform != "" {
log.Info().Str("platform", platform).Str("serial", serial).Msg("Using cached XTDriver by platform")
log.Debug().Str("platform", platform).Str("serial", serial).Msg("Using cached XTDriver by platform")
} else {
log.Info().Str("serial", serial).Msg("Using any available cached XTDriver")
log.Debug().Str("serial", serial).Msg("Using any available cached XTDriver")
}
return false // stop iteration
}