feat: add userAgent config for ModelConfig (#2242)
* feat: add userAgent config for ModelConfig * update docs for ModelConfig.userAgent * make defaut userAgent to PicoClaw and add test case
This commit is contained in:
parent
415abc8cd4
commit
2c446e1e07
15 changed files with 286 additions and 29 deletions
|
|
@ -99,6 +99,24 @@ Cette conception permet également le **support multi-agents** avec une sélecti
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Champs d'entrée `model_list`
|
||||||
|
|
||||||
|
| Champ | Type | Requis | Description |
|
||||||
|
|-------|------|--------|-------------|
|
||||||
|
| `model_name` | string | Oui | Nom unique pour référencer ce modèle dans la config agent |
|
||||||
|
| `model` | string | Oui | Identifiant fournisseur/modèle (ex : `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) |
|
||||||
|
| `api_keys` | string[] | Oui* | Clé(s) API pour l'authentification. Plusieurs clés permettent la rotation par requête. Non requis pour les fournisseurs locaux (Ollama, LM Studio, VLLM) |
|
||||||
|
| `api_base` | string | Non | Remplace l'URL de base API par défaut |
|
||||||
|
| `proxy` | string | Non | URL du proxy HTTP pour cette entrée de modèle |
|
||||||
|
| `user_agent` | string | Non | En-tête `User-Agent` personnalisé pour les requêtes API (supporté par les providers OpenAI-compatible, Anthropic et Azure) |
|
||||||
|
| `request_timeout` | int | Non | Délai d'expiration de la requête en secondes (la valeur par défaut varie selon le provider) |
|
||||||
|
| `max_tokens_field` | string | Non | Remplace le nom du champ max tokens dans le corps de la requête (ex : `max_completion_tokens` pour les modèles o1) |
|
||||||
|
| `thinking_level` | string | Non | Niveau de pensée étendue : `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` |
|
||||||
|
| `extra_body` | object | Non | Champs supplémentaires à injecter dans chaque corps de requête |
|
||||||
|
| `rpm` | int | Non | Limite de requêtes par minute |
|
||||||
|
| `fallbacks` | string[] | Non | Noms des modèles de secours pour le basculement automatique |
|
||||||
|
| `enabled` | bool | Non | Activer ou désactiver cette entrée de modèle (par défaut : `true`) |
|
||||||
|
|
||||||
#### Exemples par Vendor
|
#### Exemples par Vendor
|
||||||
|
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
|
|
@ -190,6 +208,7 @@ Pour l'accès direct à l'API Anthropic ou les endpoints personnalisés qui ne p
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_keys": ["sk-..."],
|
"api_keys": ["sk-..."],
|
||||||
|
"user_agent": "MyApp/1.0",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,24 @@
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### `model_list` エントリフィールド
|
||||||
|
|
||||||
|
| フィールド | 型 | 必須 | 説明 |
|
||||||
|
|-----------|------|------|------|
|
||||||
|
| `model_name` | string | はい | agent 設定でこのモデルを参照するための一意の名前 |
|
||||||
|
| `model` | string | はい | ベンダー/モデル識別子(例:`openai/gpt-5.4`、`azure/gpt-5.4`、`anthropic/claude-sonnet-4.6`) |
|
||||||
|
| `api_keys` | string[] | はい* | 認証キー。複数キーでリクエストごとのローテーションが可能。ローカル provider(Ollama、LM Studio、VLLM)には不要 |
|
||||||
|
| `api_base` | string | いいえ | デフォルトの API エンドポイント URL を上書き |
|
||||||
|
| `proxy` | string | いいえ | このモデルエントリの HTTP プロキシ URL |
|
||||||
|
| `user_agent` | string | いいえ | カスタム `User-Agent` リクエストヘッダー(OpenAI 互換、Anthropic、Azure provider で対応) |
|
||||||
|
| `request_timeout` | int | いいえ | リクエストタイムアウト(秒)。デフォルト値は provider により異なる |
|
||||||
|
| `max_tokens_field` | string | いいえ | リクエストボディの max tokens フィールド名を上書き(例:o1 モデルでは `max_completion_tokens`) |
|
||||||
|
| `thinking_level` | string | いいえ | 拡張思考レベル:`off`、`low`、`medium`、`high`、`xhigh`、`adaptive` |
|
||||||
|
| `extra_body` | object | いいえ | 各リクエストボディに注入する追加フィールド |
|
||||||
|
| `rpm` | int | いいえ | 1 分あたりのリクエストレート制限 |
|
||||||
|
| `fallbacks` | string[] | いいえ | 自動フェイルオーバーのフォールバックモデル名 |
|
||||||
|
| `enabled` | bool | いいえ | このモデルエントリを有効にするかどうか(デフォルト:`true`) |
|
||||||
|
|
||||||
#### ベンダー別設定例
|
#### ベンダー別設定例
|
||||||
|
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
|
|
@ -201,6 +219,7 @@ Anthropic API への直接アクセスや、Anthropic のネイティブメッ
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_keys": ["sk-..."],
|
"api_keys": ["sk-..."],
|
||||||
|
"user_agent": "MyApp/1.0",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,24 @@ This design also enables **multi-agent support** with flexible provider selectio
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### `model_list` Entry Fields
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `model_name` | string | Yes | Unique name used to reference this model in agent config |
|
||||||
|
| `model` | string | Yes | Vendor/model identifier (e.g., `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) |
|
||||||
|
| `api_keys` | string[] | Yes* | API key(s) for authentication. Multiple keys enable per-request rotation. Not required for local providers (Ollama, LM Studio, VLLM) |
|
||||||
|
| `api_base` | string | No | Override the default API endpoint URL |
|
||||||
|
| `proxy` | string | No | HTTP proxy URL for this model entry |
|
||||||
|
| `user_agent` | string | No | Custom `User-Agent` header sent with API requests (supported by OpenAI-compatible, Anthropic, and Azure providers) |
|
||||||
|
| `request_timeout` | int | No | Request timeout in seconds (default varies by provider) |
|
||||||
|
| `max_tokens_field` | string | No | Override the max tokens field name in request body (e.g., `max_completion_tokens` for o1 models) |
|
||||||
|
| `thinking_level` | string | No | Extended thinking level: `off`, `low`, `medium`, `high`, `xhigh`, or `adaptive` |
|
||||||
|
| `extra_body` | object | No | Additional fields to inject into every request body |
|
||||||
|
| `rpm` | int | No | Per-minute request rate limit |
|
||||||
|
| `fallbacks` | string[] | No | Fallback model names for automatic failover |
|
||||||
|
| `enabled` | bool | No | Whether this model entry is active (default: `true`) |
|
||||||
|
|
||||||
#### Voice Transcription
|
#### Voice Transcription
|
||||||
|
|
||||||
You can configure a dedicated model for audio transcription with `voice.model_name`. This lets you reuse existing multimodal providers that support audio input instead of relying only on Groq.
|
You can configure a dedicated model for audio transcription with `voice.model_name`. This lets you reuse existing multimodal providers that support audio input instead of relying only on Groq.
|
||||||
|
|
@ -249,6 +267,7 @@ PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_keys": ["sk-..."],
|
"api_keys": ["sk-..."],
|
||||||
|
"user_agent": "MyApp/1.0",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,24 @@ Este design também permite **suporte multi-agente** com seleção flexível de
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Campos de entrada `model_list`
|
||||||
|
|
||||||
|
| Campo | Tipo | Obrigatório | Descrição |
|
||||||
|
|-------|------|-------------|-----------|
|
||||||
|
| `model_name` | string | Sim | Nome único para referenciar este modelo na config do agent |
|
||||||
|
| `model` | string | Sim | Identificador fornecedor/modelo (ex: `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) |
|
||||||
|
| `api_keys` | string[] | Sim* | Chave(s) API para autenticação. Múltiplas chaves permitem rotação por requisição. Não necessário para providers locais (Ollama, LM Studio, VLLM) |
|
||||||
|
| `api_base` | string | Não | Substitui a URL base da API padrão |
|
||||||
|
| `proxy` | string | Não | URL do proxy HTTP para esta entrada de modelo |
|
||||||
|
| `user_agent` | string | Não | Cabeçalho `User-Agent` personalizado enviado com requisições API (suportado por providers OpenAI-compatible, Anthropic e Azure) |
|
||||||
|
| `request_timeout` | int | Não | Timeout de requisição em segundos (o padrão varia por provider) |
|
||||||
|
| `max_tokens_field` | string | Não | Substitui o nome do campo max tokens no corpo da requisição (ex: `max_completion_tokens` para modelos o1) |
|
||||||
|
| `thinking_level` | string | Não | Nível de pensamento estendido: `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` |
|
||||||
|
| `extra_body` | object | Não | Campos adicionais para injetar em cada corpo de requisição |
|
||||||
|
| `rpm` | int | Não | Limite de requisições por minuto |
|
||||||
|
| `fallbacks` | string[] | Não | Nomes dos modelos de fallback para failover automático |
|
||||||
|
| `enabled` | bool | Não | Ativar ou desativar esta entrada de modelo (padrão: `true`) |
|
||||||
|
|
||||||
#### Exemplos por Vendor
|
#### Exemplos por Vendor
|
||||||
|
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
|
|
@ -190,6 +208,7 @@ Para acesso direto à API Anthropic ou endpoints personalizados que suportam ape
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_keys": ["sk-..."],
|
"api_keys": ["sk-..."],
|
||||||
|
"user_agent": "MyApp/1.0",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,24 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Các trường entry `model_list`
|
||||||
|
|
||||||
|
| Trường | Kiểu | Bắt buộc | Mô tả |
|
||||||
|
|--------|------|----------|------|
|
||||||
|
| `model_name` | string | Có | Tên duy nhất để tham chiếu model này trong cấu hình agent |
|
||||||
|
| `model` | string | Có | Định danh nhà cung cấp/model (ví dụ: `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) |
|
||||||
|
| `api_keys` | string[] | Có* | Khóa API xác thực. Nhiều khóa cho phép xoay vòng theo yêu cầu. Không cần thiết cho provider nội bộ (Ollama, LM Studio, VLLM) |
|
||||||
|
| `api_base` | string | Không | Ghi đè URL endpoint API mặc định |
|
||||||
|
| `proxy` | string | Không | URL proxy HTTP cho entry model này |
|
||||||
|
| `user_agent` | string | Không | Header `User-Agent` tùy chỉnh gửi với yêu cầu API (được hỗ trợ bởi provider OpenAI-compatible, Anthropic và Azure) |
|
||||||
|
| `request_timeout` | int | Không | Timeout yêu cầu tính bằng giây (mặc định khác nhau tùy provider) |
|
||||||
|
| `max_tokens_field` | string | Không | Ghi đè tên trường max tokens trong request body (ví dụ: `max_completion_tokens` cho model o1) |
|
||||||
|
| `thinking_level` | string | Không | Mức độ tư duy mở rộng: `off`, `low`, `medium`, `high`, `xhigh` hoặc `adaptive` |
|
||||||
|
| `extra_body` | object | Không | Các trường bổ sung để chèn vào mỗi request body |
|
||||||
|
| `rpm` | int | Không | Giới hạn tốc độ yêu cầu mỗi phút |
|
||||||
|
| `fallbacks` | string[] | Không | Tên model dự phòng cho failover tự động |
|
||||||
|
| `enabled` | bool | Không | Kích hoạt hay vô hiệu hóa entry model này (mặc định: `true`) |
|
||||||
|
|
||||||
#### Ví Dụ Theo Vendor
|
#### Ví Dụ Theo Vendor
|
||||||
|
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
|
|
@ -190,6 +208,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_keys": ["sk-..."],
|
"api_keys": ["sk-..."],
|
||||||
|
"user_agent": "MyApp/1.0",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,24 @@
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### `model_list` 条目字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `model_name` | string | 是 | 在 agent 配置中引用此模型的唯一名称 |
|
||||||
|
| `model` | string | 是 | 厂商/模型标识符(如 `openai/gpt-5.4`、`azure/gpt-5.4`、`anthropic/claude-sonnet-4.6`) |
|
||||||
|
| `api_keys` | string[] | 是* | 认证密钥。多个密钥可按请求轮换。本地 provider(Ollama、LM Studio、VLLM)不需要 |
|
||||||
|
| `api_base` | string | 否 | 覆盖默认的 API 端点 URL |
|
||||||
|
| `proxy` | string | 否 | 此模型条目的 HTTP 代理 URL |
|
||||||
|
| `user_agent` | string | 否 | 自定义 `User-Agent` 请求头(支持 OpenAI 兼容、Anthropic 和 Azure provider) |
|
||||||
|
| `request_timeout` | int | 否 | 请求超时时间(秒),默认值因 provider 而异 |
|
||||||
|
| `max_tokens_field` | string | 否 | 覆盖请求体中 max tokens 的字段名(如 o1 模型使用 `max_completion_tokens`) |
|
||||||
|
| `thinking_level` | string | 否 | 扩展思考级别:`off`、`low`、`medium`、`high`、`xhigh` 或 `adaptive` |
|
||||||
|
| `extra_body` | object | 否 | 注入到每个请求体中的额外字段 |
|
||||||
|
| `rpm` | int | 否 | 每分钟请求速率限制 |
|
||||||
|
| `fallbacks` | string[] | 否 | 自动故障转移的备用模型名称 |
|
||||||
|
| `enabled` | bool | 否 | 是否启用此模型条目(默认:`true`) |
|
||||||
|
|
||||||
#### 语音转录
|
#### 语音转录
|
||||||
|
|
||||||
你可以通过 `voice.model_name` 为语音转录指定一个专用模型。这样可以直接复用已经配置好的、支持音频输入的多模态 provider,而不必只依赖 Groq。
|
你可以通过 `voice.model_name` 为语音转录指定一个专用模型。这样可以直接复用已经配置好的、支持音频输入的多模态 provider,而不必只依赖 Groq。
|
||||||
|
|
@ -234,6 +252,7 @@ PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_keys": ["sk-..."],
|
"api_keys": ["sk-..."],
|
||||||
|
"user_agent": "MyApp/1.0",
|
||||||
"request_timeout": 300
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -600,6 +600,8 @@ type ModelConfig struct {
|
||||||
// existing configs, the field is inferred during load: models with API keys
|
// existing configs, the field is inferred during load: models with API keys
|
||||||
// or the reserved "local-model" name are auto-enabled.
|
// or the reserved "local-model" name are auto-enabled.
|
||||||
Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"`
|
Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"`
|
||||||
|
// UserAgent is the user agent string to use for HTTP requests.
|
||||||
|
UserAgent string `json:"user_agent,omitempty" yaml:"-"`
|
||||||
|
|
||||||
// isVirtual marks this model as a virtual model generated from multi-key expansion.
|
// isVirtual marks this model as a virtual model generated from multi-key expansion.
|
||||||
// Virtual models should not be persisted to config files.
|
// Virtual models should not be persisted to config files.
|
||||||
|
|
|
||||||
|
|
@ -41,15 +41,16 @@ type Provider struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
apiBase string
|
apiBase string
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
userAgent string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProvider creates a new Anthropic Messages API provider.
|
// NewProvider creates a new Anthropic Messages API provider.
|
||||||
func NewProvider(apiKey, apiBase string) *Provider {
|
func NewProvider(apiKey, apiBase, userAgent string) *Provider {
|
||||||
return NewProviderWithTimeout(apiKey, apiBase, 0)
|
return NewProviderWithTimeout(apiKey, apiBase, userAgent, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProviderWithTimeout creates a provider with custom request timeout.
|
// NewProviderWithTimeout creates a provider with custom request timeout.
|
||||||
func NewProviderWithTimeout(apiKey, apiBase string, timeoutSeconds int) *Provider {
|
func NewProviderWithTimeout(apiKey, apiBase, userAgent string, timeoutSeconds int) *Provider {
|
||||||
baseURL := normalizeBaseURL(apiBase)
|
baseURL := normalizeBaseURL(apiBase)
|
||||||
timeout := defaultRequestTimeout
|
timeout := defaultRequestTimeout
|
||||||
if timeoutSeconds > 0 {
|
if timeoutSeconds > 0 {
|
||||||
|
|
@ -57,8 +58,9 @@ func NewProviderWithTimeout(apiKey, apiBase string, timeoutSeconds int) *Provide
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Provider{
|
return &Provider{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
apiBase: baseURL,
|
apiBase: baseURL,
|
||||||
|
userAgent: userAgent,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
},
|
},
|
||||||
|
|
@ -105,6 +107,9 @@ func (p *Provider) Chat(
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("X-API-Key", p.apiKey) //nolint:canonicalheader // Anthropic API requires exact header name
|
req.Header.Set("X-API-Key", p.apiKey) //nolint:canonicalheader // Anthropic API requires exact header name
|
||||||
req.Header.Set("Anthropic-Version", defaultAPIVersion)
|
req.Header.Set("Anthropic-Version", defaultAPIVersion)
|
||||||
|
if p.userAgent != "" {
|
||||||
|
req.Header.Set("User-Agent", p.userAgent)
|
||||||
|
}
|
||||||
|
|
||||||
// Execute request
|
// Execute request
|
||||||
resp, err := p.httpClient.Do(req)
|
resp, err := p.httpClient.Do(req)
|
||||||
|
|
|
||||||
|
|
@ -411,7 +411,7 @@ func TestNormalizeBaseURL(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewProvider(t *testing.T) {
|
func TestNewProvider(t *testing.T) {
|
||||||
provider := NewProvider("test-key", "https://api.example.com")
|
provider := NewProvider("test-key", "https://api.example.com", "")
|
||||||
if provider == nil {
|
if provider == nil {
|
||||||
t.Fatal("NewProvider() returned nil")
|
t.Fatal("NewProvider() returned nil")
|
||||||
}
|
}
|
||||||
|
|
@ -424,7 +424,7 @@ func TestNewProvider(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetDefaultModel(t *testing.T) {
|
func TestGetDefaultModel(t *testing.T) {
|
||||||
provider := NewProvider("test-key", "")
|
provider := NewProvider("test-key", "", "")
|
||||||
got := provider.GetDefaultModel()
|
got := provider.GetDefaultModel()
|
||||||
expected := "claude-sonnet-4.6"
|
expected := "claude-sonnet-4.6"
|
||||||
if got != expected {
|
if got != expected {
|
||||||
|
|
@ -743,7 +743,7 @@ func TestProviderChatErrors(t *testing.T) {
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Create provider using constructor to ensure proper initialization
|
// Create provider using constructor to ensure proper initialization
|
||||||
provider := NewProvider(tt.apiKey, "https://api.example.com")
|
provider := NewProvider(tt.apiKey, "https://api.example.com", "")
|
||||||
|
|
||||||
_, err := provider.Chat(context.Background(), tt.messages, nil, "test-model", nil)
|
_, err := provider.Chat(context.Background(), tt.messages, nil, "test-model", nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ type Provider struct {
|
||||||
apiKey string
|
apiKey string
|
||||||
apiBase string
|
apiBase string
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
userAgent string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Option configures the Azure Provider.
|
// Option configures the Azure Provider.
|
||||||
|
|
@ -50,11 +51,19 @@ func WithRequestTimeout(timeout time.Duration) Option {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithUserAgent sets the User-Agent header for requests.
|
||||||
|
func WithUserAgent(userAgent string) Option {
|
||||||
|
return func(p *Provider) {
|
||||||
|
p.userAgent = userAgent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NewProvider creates a new Azure OpenAI provider.
|
// NewProvider creates a new Azure OpenAI provider.
|
||||||
func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
|
func NewProvider(apiKey, apiBase, proxy, userAgent string, opts ...Option) *Provider {
|
||||||
p := &Provider{
|
p := &Provider{
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
apiBase: strings.TrimRight(apiBase, "/"),
|
apiBase: strings.TrimRight(apiBase, "/"),
|
||||||
|
userAgent: userAgent,
|
||||||
httpClient: common.NewHTTPClient(proxy),
|
httpClient: common.NewHTTPClient(proxy),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,9 +77,9 @@ func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProviderWithTimeout creates a new Azure OpenAI provider with a custom request timeout in seconds.
|
// NewProviderWithTimeout creates a new Azure OpenAI provider with a custom request timeout in seconds.
|
||||||
func NewProviderWithTimeout(apiKey, apiBase, proxy string, requestTimeoutSeconds int) *Provider {
|
func NewProviderWithTimeout(apiKey, apiBase, proxy, userAgent string, requestTimeoutSeconds int) *Provider {
|
||||||
return NewProvider(
|
return NewProvider(
|
||||||
apiKey, apiBase, proxy,
|
apiKey, apiBase, proxy, userAgent,
|
||||||
WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
|
WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -141,6 +150,9 @@ func (p *Provider) Chat(
|
||||||
if p.apiKey != "" {
|
if p.apiKey != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||||
}
|
}
|
||||||
|
if p.userAgent != "" {
|
||||||
|
req.Header.Set("User-Agent", p.userAgent)
|
||||||
|
}
|
||||||
|
|
||||||
resp, err := p.httpClient.Do(req)
|
resp, err := p.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ func TestProviderChat_AzureURLConstruction(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
p := NewProvider("test-key", server.URL, "")
|
p := NewProvider("test-key", server.URL, "", "")
|
||||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "my-gpt5-deployment", nil)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "my-gpt5-deployment", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error = %v", err)
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
|
@ -69,7 +69,7 @@ func TestProviderChat_AzureAuthHeader(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
p := NewProvider("test-azure-key", server.URL, "")
|
p := NewProvider("test-azure-key", server.URL, "", "")
|
||||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error = %v", err)
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
|
@ -92,7 +92,7 @@ func TestProviderChat_AzureRequestBodyContainsModel(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
p := NewProvider("test-key", server.URL, "")
|
p := NewProvider("test-key", server.URL, "", "")
|
||||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "my-deployment", nil)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "my-deployment", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error = %v", err)
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
|
@ -112,7 +112,7 @@ func TestProviderChat_AzureUsesMaxOutputTokens(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
p := NewProvider("test-key", server.URL, "")
|
p := NewProvider("test-key", server.URL, "", "")
|
||||||
_, err := p.Chat(
|
_, err := p.Chat(
|
||||||
t.Context(),
|
t.Context(),
|
||||||
[]Message{{Role: "user", Content: "hi"}},
|
[]Message{{Role: "user", Content: "hi"}},
|
||||||
|
|
@ -144,7 +144,7 @@ func TestProviderChat_AzureStoreIsFalse(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
p := NewProvider("test-key", server.URL, "")
|
p := NewProvider("test-key", server.URL, "", "")
|
||||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error = %v", err)
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
|
@ -161,7 +161,7 @@ func TestProviderChat_AzureHTTPError(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
p := NewProvider("bad-key", server.URL, "")
|
p := NewProvider("bad-key", server.URL, "", "")
|
||||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error, got nil")
|
t.Fatal("expected error, got nil")
|
||||||
|
|
@ -176,7 +176,7 @@ func TestProviderChat_AzureRateLimitError(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
p := NewProvider("test-key", server.URL, "")
|
p := NewProvider("test-key", server.URL, "", "")
|
||||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for 429, got nil")
|
t.Fatal("expected error for 429, got nil")
|
||||||
|
|
@ -194,7 +194,7 @@ func TestProviderChat_AzureServerError(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
p := NewProvider("test-key", server.URL, "")
|
p := NewProvider("test-key", server.URL, "", "")
|
||||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for 500, got nil")
|
t.Fatal("expected error for 500, got nil")
|
||||||
|
|
@ -229,7 +229,7 @@ func TestProviderChat_AzureParseTextOutput(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
p := NewProvider("test-key", server.URL, "")
|
p := NewProvider("test-key", server.URL, "", "")
|
||||||
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error = %v", err)
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
|
@ -270,7 +270,7 @@ func TestProviderChat_AzureParseToolCalls(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
p := NewProvider("test-key", server.URL, "")
|
p := NewProvider("test-key", server.URL, "", "")
|
||||||
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "weather?"}}, nil, "deployment", nil)
|
out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "weather?"}}, nil, "deployment", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Chat() error = %v", err)
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
|
@ -287,7 +287,7 @@ func TestProviderChat_AzureParseToolCalls(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProvider_AzureEmptyAPIBase(t *testing.T) {
|
func TestProvider_AzureEmptyAPIBase(t *testing.T) {
|
||||||
p := NewProvider("test-key", "", "")
|
p := NewProvider("test-key", "", "", "")
|
||||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for empty API base")
|
t.Fatal("expected error for empty API base")
|
||||||
|
|
@ -295,21 +295,21 @@ func TestProvider_AzureEmptyAPIBase(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProvider_AzureRequestTimeoutDefault(t *testing.T) {
|
func TestProvider_AzureRequestTimeoutDefault(t *testing.T) {
|
||||||
p := NewProvider("test-key", "https://example.com", "")
|
p := NewProvider("test-key", "https://example.com", "", "")
|
||||||
if p.httpClient.Timeout != defaultRequestTimeout {
|
if p.httpClient.Timeout != defaultRequestTimeout {
|
||||||
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout)
|
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProvider_AzureRequestTimeoutOverride(t *testing.T) {
|
func TestProvider_AzureRequestTimeoutOverride(t *testing.T) {
|
||||||
p := NewProvider("test-key", "https://example.com", "", WithRequestTimeout(300*time.Second))
|
p := NewProvider("test-key", "https://example.com", "", "", WithRequestTimeout(300*time.Second))
|
||||||
if p.httpClient.Timeout != 300*time.Second {
|
if p.httpClient.Timeout != 300*time.Second {
|
||||||
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, 300*time.Second)
|
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, 300*time.Second)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProvider_AzureNewProviderWithTimeout(t *testing.T) {
|
func TestProvider_AzureNewProviderWithTimeout(t *testing.T) {
|
||||||
p := NewProviderWithTimeout("test-key", "https://example.com", "", 180)
|
p := NewProviderWithTimeout("test-key", "https://example.com", "", "", 180)
|
||||||
if p.httpClient.Timeout != 180*time.Second {
|
if p.httpClient.Timeout != 180*time.Second {
|
||||||
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, 180*time.Second)
|
t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, 180*time.Second)
|
||||||
}
|
}
|
||||||
|
|
@ -343,7 +343,7 @@ func TestProviderChat_AzureNativeWebSearchInjection(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
p := NewProvider("test-key", server.URL, "")
|
p := NewProvider("test-key", server.URL, "", "")
|
||||||
|
|
||||||
// With native_search=true: user-defined web_search should be replaced by built-in
|
// With native_search=true: user-defined web_search should be replaced by built-in
|
||||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, tools, "deployment",
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, tools, "deployment",
|
||||||
|
|
@ -393,7 +393,7 @@ func TestProviderChat_AzureNoNativeWebSearch(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
p := NewProvider("test-key", server.URL, "")
|
p := NewProvider("test-key", server.URL, "", "")
|
||||||
|
|
||||||
// Without native_search: user-defined web_search should be kept as-is
|
// Without native_search: user-defined web_search should be kept as-is
|
||||||
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, tools, "deployment", nil)
|
_, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, tools, "deployment", nil)
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,11 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
|
|
||||||
protocol, modelID := ExtractProtocol(cfg.Model)
|
protocol, modelID := ExtractProtocol(cfg.Model)
|
||||||
|
|
||||||
|
userAgent := cfg.UserAgent
|
||||||
|
if userAgent == "" {
|
||||||
|
userAgent = fmt.Sprintf("PicoClaw/%s", config.Version)
|
||||||
|
}
|
||||||
|
|
||||||
switch protocol {
|
switch protocol {
|
||||||
case "openai":
|
case "openai":
|
||||||
// OpenAI with OAuth/token auth (Codex-style)
|
// OpenAI with OAuth/token auth (Codex-style)
|
||||||
|
|
@ -152,6 +157,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
apiBase,
|
apiBase,
|
||||||
cfg.Proxy,
|
cfg.Proxy,
|
||||||
cfg.MaxTokensField,
|
cfg.MaxTokensField,
|
||||||
|
userAgent,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
cfg.ExtraBody,
|
cfg.ExtraBody,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
@ -171,6 +177,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
cfg.APIKey(),
|
cfg.APIKey(),
|
||||||
cfg.APIBase,
|
cfg.APIBase,
|
||||||
cfg.Proxy,
|
cfg.Proxy,
|
||||||
|
userAgent,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
||||||
|
|
@ -228,6 +235,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
apiBase,
|
apiBase,
|
||||||
cfg.Proxy,
|
cfg.Proxy,
|
||||||
cfg.MaxTokensField,
|
cfg.MaxTokensField,
|
||||||
|
userAgent,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
cfg.ExtraBody,
|
cfg.ExtraBody,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
@ -253,6 +261,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
apiBase,
|
apiBase,
|
||||||
cfg.Proxy,
|
cfg.Proxy,
|
||||||
cfg.MaxTokensField,
|
cfg.MaxTokensField,
|
||||||
|
userAgent,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
extraBody,
|
extraBody,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
@ -279,6 +288,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
apiBase,
|
apiBase,
|
||||||
cfg.Proxy,
|
cfg.Proxy,
|
||||||
cfg.MaxTokensField,
|
cfg.MaxTokensField,
|
||||||
|
userAgent,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
cfg.ExtraBody,
|
cfg.ExtraBody,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
@ -295,6 +305,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
return anthropicmessages.NewProviderWithTimeout(
|
return anthropicmessages.NewProviderWithTimeout(
|
||||||
cfg.APIKey(),
|
cfg.APIKey(),
|
||||||
apiBase,
|
apiBase,
|
||||||
|
userAgent,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
||||||
|
|
@ -310,6 +321,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
|
||||||
return anthropicmessages.NewProviderWithTimeout(
|
return anthropicmessages.NewProviderWithTimeout(
|
||||||
cfg.APIKey(),
|
cfg.APIKey(),
|
||||||
apiBase,
|
apiBase,
|
||||||
|
userAgent,
|
||||||
cfg.RequestTimeout,
|
cfg.RequestTimeout,
|
||||||
), modelID, nil
|
), modelID, nil
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -846,6 +846,107 @@ func TestCreateProviderFromConfig_MinimaxPreservesUserExtraBody(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// openaiCompatResponse is the JSON response used by OpenAI-compatible providers.
|
||||||
|
const openaiCompatResponse = `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`
|
||||||
|
|
||||||
|
// anthropicResponse is the JSON response used by Anthropic providers.
|
||||||
|
const anthropicResponse = `{"content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","model":"claude-sonnet-4-20250514","usage":{"input_tokens":10,"output_tokens":5}}`
|
||||||
|
|
||||||
|
func TestCreateProviderFromConfig_UserAgent(t *testing.T) {
|
||||||
|
defaultUA := "PicoClaw/" + config.Version
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
model string
|
||||||
|
userAgent string
|
||||||
|
apiKey string
|
||||||
|
response string
|
||||||
|
wantUA string
|
||||||
|
chatOpts map[string]any
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "openai default user agent",
|
||||||
|
model: "openai/gpt-4o",
|
||||||
|
apiKey: "test-key",
|
||||||
|
response: openaiCompatResponse,
|
||||||
|
wantUA: defaultUA,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "openai custom user agent",
|
||||||
|
model: "openai/gpt-4o",
|
||||||
|
apiKey: "test-key",
|
||||||
|
userAgent: "MyAgent/1.2.3",
|
||||||
|
response: openaiCompatResponse,
|
||||||
|
wantUA: "MyAgent/1.2.3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "anthropic default user agent",
|
||||||
|
model: "anthropic/claude-sonnet-4-20250514",
|
||||||
|
apiKey: "test-key",
|
||||||
|
response: anthropicResponse,
|
||||||
|
wantUA: defaultUA,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "anthropic-messages default user agent",
|
||||||
|
model: "anthropic-messages/claude-sonnet-4-20250514",
|
||||||
|
apiKey: "test-key",
|
||||||
|
response: anthropicResponse,
|
||||||
|
wantUA: defaultUA,
|
||||||
|
chatOpts: map[string]any{"max_tokens": 1024},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "azure default user agent",
|
||||||
|
model: "azure/my-deployment",
|
||||||
|
apiKey: "test-azure-key",
|
||||||
|
response: openaiCompatResponse,
|
||||||
|
wantUA: defaultUA,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var receivedUA string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
receivedUA = r.Header.Get("User-Agent")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(tt.response))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := &config.ModelConfig{
|
||||||
|
ModelName: "test-ua-" + tt.name,
|
||||||
|
Model: tt.model,
|
||||||
|
APIBase: server.URL,
|
||||||
|
UserAgent: tt.userAgent,
|
||||||
|
}
|
||||||
|
cfg.SetAPIKey(tt.apiKey)
|
||||||
|
|
||||||
|
provider, modelID, err := CreateProviderFromConfig(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateProviderFromConfig() error = %v", err)
|
||||||
|
}
|
||||||
|
if provider == nil {
|
||||||
|
t.Fatal("CreateProviderFromConfig() returned nil provider")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = provider.Chat(
|
||||||
|
t.Context(),
|
||||||
|
[]Message{{Role: "user", Content: "hi"}},
|
||||||
|
nil,
|
||||||
|
modelID,
|
||||||
|
tt.chatOpts,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Chat() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if receivedUA != tt.wantUA {
|
||||||
|
t.Errorf("User-Agent = %q, want %q", receivedUA, tt.wantUA)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateProviderFromConfig_Bedrock(t *testing.T) {
|
func TestCreateProviderFromConfig_Bedrock(t *testing.T) {
|
||||||
// Set dummy AWS env vars to make test deterministic
|
// Set dummy AWS env vars to make test deterministic
|
||||||
t.Setenv("AWS_ACCESS_KEY_ID", "test-key")
|
t.Setenv("AWS_ACCESS_KEY_ID", "test-key")
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,11 @@ 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, nil)
|
return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, "", 0, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||||
apiKey, apiBase, proxy, maxTokensField string,
|
apiKey, apiBase, proxy, maxTokensField, userAgent string,
|
||||||
requestTimeoutSeconds int,
|
requestTimeoutSeconds int,
|
||||||
extraBody map[string]any,
|
extraBody map[string]any,
|
||||||
) *HTTPProvider {
|
) *HTTPProvider {
|
||||||
|
|
@ -40,6 +40,7 @@ func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
|
||||||
openai_compat.WithMaxTokensField(maxTokensField),
|
openai_compat.WithMaxTokensField(maxTokensField),
|
||||||
openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
|
openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
|
||||||
openai_compat.WithExtraBody(extraBody),
|
openai_compat.WithExtraBody(extraBody),
|
||||||
|
openai_compat.WithUserAgent(userAgent),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ type Provider struct {
|
||||||
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
|
maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models)
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
extraBody map[string]any // Additional fields to inject into request body
|
extraBody map[string]any // Additional fields to inject into request body
|
||||||
|
userAgent string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Option func(*Provider)
|
type Option func(*Provider)
|
||||||
|
|
@ -66,6 +67,12 @@ func WithMaxTokensField(maxTokensField string) Option {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func WithUserAgent(userAgent string) Option {
|
||||||
|
return func(p *Provider) {
|
||||||
|
p.userAgent = userAgent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func WithRequestTimeout(timeout time.Duration) Option {
|
func WithRequestTimeout(timeout time.Duration) Option {
|
||||||
return func(p *Provider) {
|
return func(p *Provider) {
|
||||||
if timeout > 0 {
|
if timeout > 0 {
|
||||||
|
|
@ -198,6 +205,9 @@ func (p *Provider) Chat(
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if p.userAgent != "" {
|
||||||
|
req.Header.Set("User-Agent", p.userAgent)
|
||||||
|
}
|
||||||
if p.apiKey != "" {
|
if p.apiKey != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue