fix(providers): support per-model request_timeout in model_list
This commit is contained in:
parent
100356e8ec
commit
d4fd0ba96c
10 changed files with 125 additions and 9 deletions
|
|
@ -232,7 +232,8 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "your-api-key"
|
"api_key": "your-api-key",
|
||||||
|
"request_timeout": 300
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
|
@ -262,6 +263,7 @@ picoclaw onboard
|
||||||
```
|
```
|
||||||
|
|
||||||
> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
|
> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
|
||||||
|
> `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s).
|
||||||
|
|
||||||
**3. Get API Keys**
|
**3. Get API Keys**
|
||||||
|
|
||||||
|
|
@ -915,7 +917,8 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
"model_name": "my-custom-model",
|
"model_name": "my-custom-model",
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -234,7 +234,8 @@ picoclaw onboard
|
||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "your-api-key"
|
"api_key": "your-api-key",
|
||||||
|
"request_timeout": 300
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
|
|
@ -263,6 +264,7 @@ picoclaw onboard
|
||||||
```
|
```
|
||||||
|
|
||||||
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
|
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
|
||||||
|
> `request_timeout` 为可选项,单位为秒。若省略或设置为 `<= 0`,PicoClaw 使用默认超时(120 秒)。
|
||||||
|
|
||||||
**3. 获取 API Key**
|
**3. 获取 API Key**
|
||||||
|
|
||||||
|
|
@ -550,7 +552,8 @@ Agent 读取 HEARTBEAT.md
|
||||||
"model_name": "my-custom-model",
|
"model_name": "my-custom-model",
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -117,6 +117,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
|
||||||
| `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` |
|
| `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` |
|
||||||
| `rpm` | No | Requests per minute limit |
|
| `rpm` | No | Requests per minute limit |
|
||||||
| `max_tokens_field` | No | Field name for max tokens |
|
| `max_tokens_field` | No | Field name for max tokens |
|
||||||
|
| `request_timeout` | No | HTTP request timeout in seconds; `<=0` uses default `120s` |
|
||||||
|
|
||||||
*`api_key` is required for HTTP-based protocols unless `api_base` points to a local server.
|
*`api_key` is required for HTTP-based protocols unless `api_base` points to a local server.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -406,6 +406,7 @@ type ModelConfig struct {
|
||||||
// Optional optimizations
|
// Optional optimizations
|
||||||
RPM int `json:"rpm,omitempty"` // Requests per minute limit
|
RPM int `json:"rpm,omitempty"` // Requests per minute limit
|
||||||
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
|
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
|
||||||
|
RequestTimeout int `json:"request_timeout,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate checks if the ModelConfig has all required fields.
|
// Validate checks if the ModelConfig has all required fields.
|
||||||
|
|
|
||||||
|
|
@ -365,3 +365,38 @@ func TestConfig_ValidateModelList(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestModelConfig_RequestTimeoutParsing(t *testing.T) {
|
||||||
|
jsonData := `{
|
||||||
|
"model_name": "slow-local",
|
||||||
|
"model": "openai/local-model",
|
||||||
|
"api_base": "http://localhost:11434/v1",
|
||||||
|
"request_timeout": 300
|
||||||
|
}`
|
||||||
|
|
||||||
|
var cfg ModelConfig
|
||||||
|
if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.RequestTimeout != 300 {
|
||||||
|
t.Fatalf("RequestTimeout = %d, want 300", cfg.RequestTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModelConfig_RequestTimeoutDefaultZeroValue(t *testing.T) {
|
||||||
|
jsonData := `{
|
||||||
|
"model_name": "default-timeout",
|
||||||
|
"model": "openai/gpt-4o",
|
||||||
|
"api_key": "test-key"
|
||||||
|
}`
|
||||||
|
|
||||||
|
var cfg ModelConfig
|
||||||
|
if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
|
||||||
|
t.Fatalf("Unmarshal() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.RequestTimeout != 0 {
|
||||||
|
t.Fatalf("RequestTimeout = %d, want 0", cfg.RequestTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
if apiBase == "" {
|
if apiBase == "" {
|
||||||
apiBase = getDefaultAPIBase(protocol)
|
apiBase = getDefaultAPIBase(protocol)
|
||||||
}
|
}
|
||||||
return NewHTTPProviderWithMaxTokensField(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField), modelID, nil
|
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField, cfg.RequestTimeout), modelID, nil
|
||||||
|
|
||||||
case "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
case "openrouter", "groq", "zhipu", "gemini", "nvidia",
|
||||||
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
|
||||||
|
|
@ -97,7 +97,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
if apiBase == "" {
|
if apiBase == "" {
|
||||||
apiBase = getDefaultAPIBase(protocol)
|
apiBase = getDefaultAPIBase(protocol)
|
||||||
}
|
}
|
||||||
return NewHTTPProviderWithMaxTokensField(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField), modelID, nil
|
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField, cfg.RequestTimeout), modelID, nil
|
||||||
|
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" {
|
if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" {
|
||||||
|
|
@ -116,7 +116,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
if cfg.APIKey == "" {
|
if cfg.APIKey == "" {
|
||||||
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
|
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
|
||||||
}
|
}
|
||||||
return NewHTTPProviderWithMaxTokensField(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField), modelID, nil
|
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField, cfg.RequestTimeout), modelID, nil
|
||||||
|
|
||||||
case "antigravity":
|
case "antigravity":
|
||||||
return NewAntigravityProvider(), modelID, nil
|
return NewAntigravityProvider(), modelID, nil
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,11 @@
|
||||||
package providers
|
package providers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
@ -247,3 +251,42 @@ func TestCreateProviderFromConfig_EmptyModel(t *testing.T) {
|
||||||
t.Fatal("CreateProviderFromConfig() expected error for empty model")
|
t.Fatal("CreateProviderFromConfig() expected error for empty model")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateProviderFromConfig_RequestTimeoutPropagation(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
time.Sleep(1500 * time.Millisecond)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := &config.ModelConfig{
|
||||||
|
ModelName: "test-timeout",
|
||||||
|
Model: "openai/gpt-4o",
|
||||||
|
APIBase: server.URL,
|
||||||
|
RequestTimeout: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if modelID != "gpt-4o" {
|
||||||
|
t.Fatalf("modelID = %q, want %q", modelID, "gpt-4o")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = provider.Chat(
|
||||||
|
t.Context(),
|
||||||
|
[]Message{{Role: "user", Content: "hi"}},
|
||||||
|
nil,
|
||||||
|
modelID,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Chat() expected timeout error, got nil")
|
||||||
|
}
|
||||||
|
errMsg := err.Error()
|
||||||
|
if !strings.Contains(errMsg, "context deadline exceeded") && !strings.Contains(errMsg, "Client.Timeout exceeded") {
|
||||||
|
t.Fatalf("Chat() error = %q, want timeout-related error", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,12 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider {
|
func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider {
|
||||||
|
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField string, requestTimeoutSeconds int) *HTTPProvider {
|
||||||
return &HTTPProvider{
|
return &HTTPProvider{
|
||||||
delegate: openai_compat.NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField),
|
delegate: openai_compat.NewProviderWithMaxTokensFieldAndTimeout(apiKey, apiBase, proxy, maxTokensField, requestTimeoutSeconds),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,13 +34,24 @@ type Provider struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultRequestTimeout = 120 * time.Second
|
||||||
|
|
||||||
func NewProvider(apiKey, apiBase, proxy string) *Provider {
|
func NewProvider(apiKey, apiBase, proxy string) *Provider {
|
||||||
return NewProviderWithMaxTokensField(apiKey, apiBase, proxy, "")
|
return NewProviderWithMaxTokensField(apiKey, apiBase, proxy, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *Provider {
|
func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *Provider {
|
||||||
|
return NewProviderWithMaxTokensFieldAndTimeout(apiKey, apiBase, proxy, maxTokensField, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProviderWithMaxTokensFieldAndTimeout(apiKey, apiBase, proxy, maxTokensField string, requestTimeoutSeconds int) *Provider {
|
||||||
|
timeout := defaultRequestTimeout
|
||||||
|
if requestTimeoutSeconds > 0 {
|
||||||
|
timeout = time.Duration(requestTimeoutSeconds) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 120 * time.Second,
|
Timeout: timeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
if proxy != "" {
|
if proxy != "" {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) {
|
func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) {
|
||||||
|
|
@ -325,3 +326,17 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) {
|
||||||
t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto")
|
t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProvider_RequestTimeoutDefault(t *testing.T) {
|
||||||
|
p := NewProviderWithMaxTokensFieldAndTimeout("key", "https://example.com/v1", "", "", 0)
|
||||||
|
if p.httpClient.Timeout != defaultRequestTimeout {
|
||||||
|
t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProvider_RequestTimeoutOverride(t *testing.T) {
|
||||||
|
p := NewProviderWithMaxTokensFieldAndTimeout("key", "https://example.com/v1", "", "", 300)
|
||||||
|
if p.httpClient.Timeout != 300*time.Second {
|
||||||
|
t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, 300*time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue