feat: add model input type support for local image handling

- Add Input field to ModelConfig for specifying supported input types (text, image)
- Add SupportedInput field to AgentInstance, loaded from model config
- Modify resolveMediaRefs to handle images based on model capabilities:
  - If model supports image: encode to base64 data URL
  - If model doesn't support image: inject local path for tool processing
- Update findMatches to support lookup by both model_name and model field
- Add injectPathTags to replace [image] tags with actual file paths
- Add tests for GetModelConfig by model field lookup
This commit is contained in:
Fushan Wen 2026-04-02 14:24:45 +08:00
parent b114dcaeb1
commit e37aef3df5
18 changed files with 187 additions and 58 deletions

View file

@ -650,7 +650,8 @@ For complete documentation, see [`security_configuration.md`](security_configura
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_keys": ["sk-your-openai-key"]
"api_keys": ["sk-your-openai-key"],
"input": ["text", "image"]
},
{
"model_name": "claude-sonnet-4.6",
@ -793,7 +794,8 @@ PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio
{
"model_name": "my-custom-model",
"model": "openai/custom-model",
"api_base": "https://my-proxy.com/v1"
"api_base": "https://my-proxy.com/v1",
"input": ["text"]
// api_key: set in .security.yml
}
```
@ -824,7 +826,8 @@ model_list:
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api.openai.com/v1"
"api_base": "https://api.openai.com/v1",
"input": ["text", "image"]
// api_keys loaded from .security.yml
}
]
@ -840,13 +843,15 @@ model_list:
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_keys": ["sk-key1"]
"api_keys": ["sk-key1"],
"input": ["text", "image"]
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_keys": ["sk-key2"]
"api_keys": ["sk-key2"],
"input": ["text", "image"]
}
]
}

View file

@ -334,8 +334,8 @@ Configurez plusieurs endpoints pour le même nom de modèle — PicoClaw effectu
```json
{
"model_list": [
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_keys": ["sk-key1"] },
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_keys": ["sk-key2"] }
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_keys": ["sk-key1"], "input": ["text", "image"] },
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_keys": ["sk-key2"], "input": ["text", "image"] }
]
}
```

View file

@ -107,6 +107,7 @@ Cette conception permet également le **support multi-agents** avec une sélecti
| `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 |
| `input` | string[] | Non | Types d'entrée supportés par le modèle. Valeurs valides : `"text"`, `"image"`. Par défaut : `["text"]`. Lorsqu'un modèle ne supporte pas l'entrée d'images, les images sont téléchargées localement pour un traitement par outils au lieu d'être envoyées à l'API du modèle |
| `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) |
@ -125,7 +126,8 @@ Cette conception permet également le **support multi-agents** avec une sélecti
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_keys": ["sk-..."]
"api_keys": ["sk-..."],
"input": ["text", "image"]
}
```
@ -237,13 +239,15 @@ Configurez plusieurs endpoints pour le même nom de modèle — PicoClaw effectu
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_keys": ["sk-key1"]
"api_keys": ["sk-key1"],
"input": ["text", "image"]
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_keys": ["sk-key2"]
"api_keys": ["sk-key2"],
"input": ["text", "image"]
}
]
}

View file

@ -335,8 +335,8 @@ HEARTBEAT_OK を返信 ユーザーが直接結果を受信
```json
{
"model_list": [
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_keys": ["sk-key1"] },
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_keys": ["sk-key2"] }
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_keys": ["sk-key1"], "input": ["text", "image"] },
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_keys": ["sk-key2"], "input": ["text", "image"] }
]
}
```

View file

@ -107,6 +107,7 @@
| `model` | string | はい | ベンダー/モデル識別子(例:`openai/gpt-5.4``azure/gpt-5.4``anthropic/claude-sonnet-4.6` |
| `api_keys` | string[] | はい* | 認証キー。複数キーでリクエストごとのローテーションが可能。ローカル providerOllama、LM Studio、VLLMには不要 |
| `api_base` | string | いいえ | デフォルトの API エンドポイント URL を上書き |
| `input` | string[] | いいえ | モデルがサポートする入力タイプ。有効な値:`"text"``"image"`。デフォルト:`["text"]`。モデルが画像入力をサポートしない場合、画像はモデル API に送信されず、ローカルにダウンロードされてツール処理されます |
| `proxy` | string | いいえ | このモデルエントリの HTTP プロキシ URL |
| `user_agent` | string | いいえ | カスタム `User-Agent` リクエストヘッダーOpenAI 互換、Anthropic、Azure provider で対応) |
| `request_timeout` | int | いいえ | リクエストタイムアウト(秒)。デフォルト値は provider により異なる |
@ -125,7 +126,8 @@
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_keys": ["sk-..."]
"api_keys": ["sk-..."],
"input": ["text", "image"]
}
```
@ -248,13 +250,15 @@ PicoClaw はリクエスト送信前に外側の `litellm/` プレフィック
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_keys": ["sk-key1"]
"api_keys": ["sk-key1"],
"input": ["text", "image"]
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_keys": ["sk-key2"]
"api_keys": ["sk-key2"],
"input": ["text", "image"]
}
]
}

View file

@ -116,6 +116,7 @@ This design also enables **multi-agent support** with flexible provider selectio
| `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 |
| `input` | string[] | No | Input types supported by the model. Valid values: `"text"`, `"image"`. Default: `["text"]`. When a model doesn't support image input, images are downloaded locally for tool processing instead of being sent to the model API |
| `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) |
@ -161,7 +162,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_keys": ["sk-..."]
"api_keys": ["sk-..."],
"input": ["text", "image"]
}
```
@ -311,13 +313,15 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_keys": ["sk-key1"]
"api_keys": ["sk-key1"],
"input": ["text", "image"]
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_keys": ["sk-key2"]
"api_keys": ["sk-key2"],
"input": ["text", "image"]
}
]
}

View file

@ -335,8 +335,8 @@ Configure múltiplos endpoints para o mesmo nome de modelo — PicoClaw fará ro
```json
{
"model_list": [
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_keys": ["sk-key1"] },
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_keys": ["sk-key2"] }
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_keys": ["sk-key1"], "input": ["text", "image"] },
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_keys": ["sk-key2"], "input": ["text", "image"] }
]
}
```

View file

@ -107,6 +107,7 @@ Este design também permite **suporte multi-agente** com seleção flexível de
| `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 |
| `input` | string[] | Não | Tipos de entrada suportados pelo modelo. Valores válidos: `"text"`, `"image"`. Padrão: `["text"]`. Quando um modelo não suporta entrada de imagem, as imagens são baixadas localmente para processamento por ferramentas em vez de enviadas à API do modelo |
| `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) |
@ -125,7 +126,8 @@ Este design também permite **suporte multi-agente** com seleção flexível de
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_keys": ["sk-..."]
"api_keys": ["sk-..."],
"input": ["text", "image"]
}
```
@ -237,13 +239,15 @@ Configure múltiplos endpoints para o mesmo nome de modelo — o PicoClaw fará
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_keys": ["sk-key1"]
"api_keys": ["sk-key1"],
"input": ["text", "image"]
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_keys": ["sk-key2"]
"api_keys": ["sk-key2"],
"input": ["text", "image"]
}
]
}

View file

@ -335,8 +335,8 @@ Cấu hình nhiều endpoint cho cùng tên mô hình — PicoClaw sẽ tự đ
```json
{
"model_list": [
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_keys": ["sk-key1"] },
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_keys": ["sk-key2"] }
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_keys": ["sk-key1"], "input": ["text", "image"] },
{ "model_name": "gpt-5.4", "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_keys": ["sk-key2"], "input": ["text", "image"] }
]
}
```

View file

@ -107,6 +107,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr
| `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 |
| `input` | string[] | Không | Các loại đầu vào được hỗ trợ bởi mô hình. Giá trị hợp lệ: `"text"`, `"image"`. Mặc định: `["text"]`. Khi mô hình không hỗ trợ đầu vào hình ảnh, hình ảnh được tải xuống cục bộ để xử lý bằng công cụ thay vì gửi đến API mô hì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) |
@ -125,7 +126,8 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_keys": ["sk-..."]
"api_keys": ["sk-..."],
"input": ["text", "image"]
}
```
@ -237,13 +239,15 @@ Cấu hình nhiều endpoint cho cùng tên mô hình — PicoClaw sẽ tự đ
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_keys": ["sk-key1"]
"api_keys": ["sk-key1"],
"input": ["text", "image"]
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_keys": ["sk-key2"]
"api_keys": ["sk-key2"],
"input": ["text", "image"]
}
]
}

View file

@ -392,7 +392,8 @@ Agent 读取 HEARTBEAT.md
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_keys": ["sk-your-openai-key"]
"api_keys": ["sk-your-openai-key"],
"input": ["text", "image"]
},
{
"model_name": "claude-sonnet-4.6",
@ -413,6 +414,10 @@ Agent 读取 HEARTBEAT.md
}
```
> **安全提示:** 你可以将 `api_keys` 字段从配置中移除,存储在 `.security.yml` 中。详见上方的 [安全配置](#-安全配置推荐)。
>
> **注意:** `enabled` 字段可设置为 `false` 以禁用某个模型条目而无需删除它。省略时,迁移过程中对于有 API Key 的模型默认为 `true`
#### 各厂商配置示例
<details>
@ -530,7 +535,8 @@ PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首
"model_name": "my-custom-model",
"model": "openai/custom-model",
"api_base": "https://my-proxy.com/v1",
"api_keys": ["sk-..."]
"api_keys": ["sk-..."],
"input": ["text"]
}
```
@ -549,13 +555,15 @@ PicoClaw 只剥离最外层的 `litellm/` 前缀再发送请求,因此 `litell
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_keys": ["sk-key1"]
"api_keys": ["sk-key1"],
"input": ["text", "image"]
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_keys": ["sk-key2"]
"api_keys": ["sk-key2"],
"input": ["text", "image"]
}
]
}

View file

@ -112,6 +112,7 @@
| `model` | string | 是 | 厂商/模型标识符(如 `openai/gpt-5.4``azure/gpt-5.4``anthropic/claude-sonnet-4.6` |
| `api_keys` | string[] | 是* | 认证密钥。多个密钥可按请求轮换。本地 providerOllama、LM Studio、VLLM不需要 |
| `api_base` | string | 否 | 覆盖默认的 API 端点 URL |
| `input` | string[] | 否 | 模型支持的输入类型。有效值:`"text"``"image"`。默认:`["text"]`。当模型不支持图片输入时,图片会下载到本地进行工具处理,而不是发送到模型 API |
| `proxy` | string | 否 | 此模型条目的 HTTP 代理 URL |
| `user_agent` | string | 否 | 自定义 `User-Agent` 请求头(支持 OpenAI 兼容、Anthropic 和 Azure provider |
| `request_timeout` | int | 否 | 请求超时时间(秒),默认值因 provider 而异 |
@ -157,7 +158,8 @@
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_keys": ["sk-..."]
"api_keys": ["sk-..."],
"input": ["text", "image"]
}
```
@ -281,13 +283,15 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_keys": ["sk-key1"]
"api_keys": ["sk-key1"],
"input": ["text", "image"]
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_keys": ["sk-key2"]
"api_keys": ["sk-key2"],
"input": ["text", "image"]
}
]
}

View file

@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
@ -33,6 +34,7 @@ type AgentInstance struct {
ContextWindow int
SummarizeMessageThreshold int
SummarizeTokenPercent int
SupportedInput []string
Provider providers.LLMProvider
Sessions session.SessionStore
ContextBuilder *ContextBuilder
@ -53,6 +55,11 @@ type AgentInstance struct {
LightProvider providers.LLMProvider
}
// SupportsInput checks if the agent supports a specific input type.
func (a *AgentInstance) SupportsInput(inputType string) bool {
return slices.Contains(a.SupportedInput, inputType)
}
// NewAgentInstance creates an agent instance from config.
func NewAgentInstance(
agentCfg *config.AgentConfig,
@ -157,8 +164,13 @@ func NewAgentInstance(
}
var thinkingLevelStr string
var supportedInput []string
if mc, err := cfg.GetModelConfig(model); err == nil {
thinkingLevelStr = mc.ThinkingLevel
supportedInput = mc.Input
}
if len(supportedInput) == 0 {
supportedInput = []string{"text"}
}
thinkingLevel := parseThinkingLevel(thinkingLevelStr)
@ -220,6 +232,7 @@ func NewAgentInstance(
ContextWindow: contextWindow,
SummarizeMessageThreshold: summarizeMessageThreshold,
SummarizeTokenPercent: summarizeTokenPercent,
SupportedInput: supportedInput,
Provider: provider,
Sessions: sessions,
ContextBuilder: contextBuilder,

View file

@ -347,8 +347,9 @@ func registerSharedTools(
// resolve media:// refs in the same way the main AgentLoop does.
// This keeps subagent vision support working even when the optimized
// sub-turn spawner path is unavailable.
supportsImage := agent.SupportsInput("image")
subagentManager.SetMediaResolver(func(msgs []providers.Message) []providers.Message {
return resolveMediaRefs(msgs, al.mediaStore, cfg.Agents.Defaults.GetMaxMediaSize())
return resolveMediaRefs(msgs, al.mediaStore, cfg.Agents.Defaults.GetMaxMediaSize(), supportsImage)
})
// Set the spawner that links into AgentLoop's turnState
@ -1732,7 +1733,9 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er
cfg := al.GetConfig()
maxMediaSize := cfg.Agents.Defaults.GetMaxMediaSize()
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
supportsImage := ts.agent.SupportsInput("image")
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize, supportsImage)
if !ts.opts.NoHistory {
toolDefs := ts.agent.Tools.ToProviderDefs()
@ -1764,7 +1767,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er
ts.opts.SenderID, ts.opts.SenderDisplayName,
activeSkillNames(ts.agent, ts.opts)...,
)
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize, supportsImage)
}
}
@ -1849,7 +1852,7 @@ turnLoop:
// Inject pending steering messages
if len(pendingMessages) > 0 {
resolvedPending := resolveMediaRefs(pendingMessages, al.mediaStore, maxMediaSize)
resolvedPending := resolveMediaRefs(pendingMessages, al.mediaStore, maxMediaSize, supportsImage)
totalContentLen := 0
for i, pm := range pendingMessages {
messages = append(messages, resolvedPending[i])
@ -1915,7 +1918,7 @@ turnLoop:
// before entering the loop; only subsequent iterations can contain new
// tool-generated media refs that need base64 encoding.
if iteration > 1 {
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize, supportsImage)
}
callMessages := messages

View file

@ -21,11 +21,18 @@ import (
)
// resolveMediaRefs resolves media:// refs in messages.
// Images are base64-encoded into the Media array for multimodal LLMs.
// Non-image files (documents, audio, video) have their local path injected
// Images are base64-encoded into the Media array for multimodal LLMs that support image input.
// If the model doesn't support image input, images are treated like other media files:
// their local path is injected into Content so the agent can access them via file tools.
// Non-image files (documents, audio, video) always have their local path injected
// into Content so the agent can access them via file tools like read_file.
// Returns a new slice; original messages are not mutated.
func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message {
func resolveMediaRefs(
messages []providers.Message,
store media.MediaStore,
maxSize int,
supportsImage bool,
) []providers.Message {
if store == nil {
return messages
}
@ -67,7 +74,7 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS
mime := detectMIME(localPath, meta)
if strings.HasPrefix(mime, "image/") {
if strings.HasPrefix(mime, "image/") && supportsImage {
dataURL := encodeImageToDataURL(localPath, mime, info, maxSize)
if dataURL != "" {
resolved = append(resolved, dataURL)
@ -160,9 +167,11 @@ func encodeImageToDataURL(localPath, mime string, info os.FileInfo, maxSize int)
}
// buildPathTag creates a structured tag exposing the local file path.
// Tag type is derived from MIME: [audio:/path], [video:/path], or [file:/path].
// Tag type is derived from MIME: [image:/path], [audio:/path], [video:/path], or [file:/path].
func buildPathTag(mime, localPath string) string {
switch {
case strings.HasPrefix(mime, "image/"):
return "[image:" + localPath + "]"
case strings.HasPrefix(mime, "audio/"):
return "[audio:" + localPath + "]"
case strings.HasPrefix(mime, "video/"):
@ -178,6 +187,8 @@ func injectPathTags(content string, tags []string) string {
for _, tag := range tags {
var generic string
switch {
case strings.HasPrefix(tag, "[image:"):
generic = "[image]"
case strings.HasPrefix(tag, "[audio:"):
generic = "[audio]"
case strings.HasPrefix(tag, "[video:"):

View file

@ -2600,7 +2600,7 @@ func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) {
messages := []providers.Message{
{Role: "user", Content: "describe this", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize, true)
if len(result[0].Media) != 1 {
t.Fatalf("expected 1 resolved media, got %d", len(result[0].Media))
@ -2627,7 +2627,7 @@ func TestResolveMediaRefs_SkipsOversizedFile(t *testing.T) {
{Role: "user", Content: "hi", Media: []string{ref}},
}
// Use a tiny limit (1KB) so the file is oversized
result := resolveMediaRefs(messages, store, 1024)
result := resolveMediaRefs(messages, store, 1024, true)
if len(result[0].Media) != 0 {
t.Fatalf("expected 0 media (oversized), got %d", len(result[0].Media))
@ -2647,7 +2647,7 @@ func TestResolveMediaRefs_UnknownTypeInjectsPath(t *testing.T) {
messages := []providers.Message{
{Role: "user", Content: "hi", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize, true)
if len(result[0].Media) != 0 {
t.Fatalf("expected 0 media entries, got %d", len(result[0].Media))
@ -2662,7 +2662,7 @@ func TestResolveMediaRefs_PassesThroughNonMediaRefs(t *testing.T) {
messages := []providers.Message{
{Role: "user", Content: "hi", Media: []string{"https://example.com/img.png"}},
}
result := resolveMediaRefs(messages, nil, config.DefaultMaxMediaSize)
result := resolveMediaRefs(messages, nil, config.DefaultMaxMediaSize, true)
if len(result[0].Media) != 1 || result[0].Media[0] != "https://example.com/img.png" {
t.Fatalf("expected passthrough of non-media:// URL, got %v", result[0].Media)
@ -2687,7 +2687,7 @@ func TestResolveMediaRefs_DoesNotMutateOriginal(t *testing.T) {
}
originalRef := original[0].Media[0]
resolveMediaRefs(original, store, config.DefaultMaxMediaSize)
resolveMediaRefs(original, store, config.DefaultMaxMediaSize, true)
if original[0].Media[0] != originalRef {
t.Fatal("resolveMediaRefs mutated original message slice")
@ -2707,7 +2707,7 @@ func TestResolveMediaRefs_UsesMetaContentType(t *testing.T) {
messages := []providers.Message{
{Role: "user", Content: "hi", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize, true)
if len(result[0].Media) != 1 {
t.Fatalf("expected 1 media, got %d", len(result[0].Media))
@ -2729,7 +2729,7 @@ func TestResolveMediaRefs_PDFInjectsFilePath(t *testing.T) {
messages := []providers.Message{
{Role: "user", Content: "report.pdf [file]", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize, true)
if len(result[0].Media) != 0 {
t.Fatalf("expected 0 media (non-image), got %d", len(result[0].Media))
@ -2751,7 +2751,7 @@ func TestResolveMediaRefs_AudioInjectsAudioPath(t *testing.T) {
messages := []providers.Message{
{Role: "user", Content: "voice.ogg [audio]", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize, true)
if len(result[0].Media) != 0 {
t.Fatalf("expected 0 media, got %d", len(result[0].Media))
@ -2773,7 +2773,7 @@ func TestResolveMediaRefs_VideoInjectsVideoPath(t *testing.T) {
messages := []providers.Message{
{Role: "user", Content: "clip.mp4 [video]", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize, true)
if len(result[0].Media) != 0 {
t.Fatalf("expected 0 media, got %d", len(result[0].Media))
@ -2795,7 +2795,7 @@ func TestResolveMediaRefs_NoGenericTagAppendsPath(t *testing.T) {
messages := []providers.Message{
{Role: "user", Content: "here is my data", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize, true)
expected := "here is my data [file:" + csvPath + "]"
if result[0].Content != expected {
@ -2815,7 +2815,7 @@ func TestResolveMediaRefs_EmptyContentGetsPathTag(t *testing.T) {
messages := []providers.Message{
{Role: "user", Content: "", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize, true)
expected := "[file:" + docPath + "]"
if result[0].Content != expected {
@ -2844,7 +2844,7 @@ func TestResolveMediaRefs_MixedImageAndFile(t *testing.T) {
messages := []providers.Message{
{Role: "user", Content: "check these [file]", Media: []string{imgRef, fileRef}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize, true)
if len(result[0].Media) != 1 {
t.Fatalf("expected 1 media (image only), got %d", len(result[0].Media))

View file

@ -7,6 +7,7 @@ import (
"math/rand"
"os"
"path/filepath"
"slices"
"strings"
"sync/atomic"
"time"
@ -578,6 +579,10 @@ type ModelConfig struct {
ModelName string `json:"model_name"` // User-facing alias for the model
Model string `json:"model"` // Protocol/model-identifier (e.g., "openai/gpt-4o", "anthropic/claude-sonnet-4.6")
// Input types supported by this model (e.g., ["text"], ["text", "image"])
// Default is ["text"] if not specified
Input []string `json:"input,omitempty"`
// HTTP-based providers
APIBase string `json:"api_base,omitempty"` // API endpoint URL
Proxy string `json:"proxy,omitempty"` // HTTP proxy URL
@ -622,6 +627,15 @@ func (c *ModelConfig) IsVirtual() bool {
return c.isVirtual
}
// SupportsInput checks if the model supports a specific input type.
// If Input is empty or nil, it defaults to ["text"].
func (c *ModelConfig) SupportsInput(inputType string) bool {
if len(c.Input) == 0 {
return inputType == "text"
}
return slices.Contains(c.Input, inputType)
}
// Validate checks if the ModelConfig has all required fields.
func (c *ModelConfig) Validate() error {
if c.ModelName == "" {
@ -1156,7 +1170,8 @@ func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {
return matches[idx], nil
}
// findMatches finds all ModelConfig entries with the given model_name.
// findMatches finds all ModelConfig entries with the given model_name or model field.
// It first tries to match by model_name, then by model field if no match is found.
func (c *Config) findMatches(modelName string) []*ModelConfig {
var matches []*ModelConfig
for i := range c.ModelList {
@ -1164,6 +1179,14 @@ func (c *Config) findMatches(modelName string) []*ModelConfig {
matches = append(matches, c.ModelList[i])
}
}
if len(matches) > 0 {
return matches
}
for i := range c.ModelList {
if c.ModelList[i].Model == modelName {
matches = append(matches, c.ModelList[i])
}
}
return matches
}

View file

@ -144,6 +144,48 @@ func TestGetModelConfig_Concurrent(t *testing.T) {
}
}
func TestGetModelConfig_ByModelField(t *testing.T) {
cfg := &Config{
Version: CurrentVersion,
ModelList: []*ModelConfig{
{ModelName: "test-model", Model: "openai/gpt-4o", APIKeys: SimpleSecureStrings("key1")},
{ModelName: "other-model", Model: "anthropic/claude", APIKeys: SimpleSecureStrings("key2")},
},
}
result, err := cfg.GetModelConfig("openai/gpt-4o")
if err != nil {
t.Fatalf("GetModelConfig() error = %v", err)
}
if result.Model != "openai/gpt-4o" {
t.Errorf("Model = %q, want %q", result.Model, "openai/gpt-4o")
}
if result.ModelName != "test-model" {
t.Errorf("ModelName = %q, want %q", result.ModelName, "test-model")
}
}
func TestGetModelConfig_PrefersModelNameOverModel(t *testing.T) {
cfg := &Config{
Version: CurrentVersion,
ModelList: []*ModelConfig{
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("key1")},
{ModelName: "openai/gpt-4", Model: "openai/gpt-4-turbo", APIKeys: SimpleSecureStrings("key2")},
},
}
result, err := cfg.GetModelConfig("gpt-4")
if err != nil {
t.Fatalf("GetModelConfig() error = %v", err)
}
if result.Model != "openai/gpt-4" {
t.Errorf("Model = %q, want %q", result.Model, "openai/gpt-4")
}
if result.ModelName != "gpt-4" {
t.Errorf("ModelName = %q, want %q", result.ModelName, "gpt-4")
}
}
func TestAgentDefaultsV0_JSON_BackwardCompat(t *testing.T) {
tests := []struct {
name string