Merge branch 'main' into feat/channel-tool-feedback-animation

# Conflicts:
#	web/backend/api/session.go
This commit is contained in:
lxowalle 2026-04-22 13:57:19 +08:00
commit bbab5388e2
59 changed files with 2468 additions and 514 deletions

View file

@ -59,7 +59,7 @@ func authLoginOpenAI(useDeviceCode bool, noBrowser bool) error {
// Update or add openai in ModelList
foundOpenAI := false
for i := range appCfg.ModelList {
if isOpenAIModel(appCfg.ModelList[i].Model) {
if isOpenAIModel(appCfg.ModelList[i]) {
appCfg.ModelList[i].AuthMethod = "oauth"
foundOpenAI = true
break
@ -130,7 +130,7 @@ func authLoginGoogleAntigravity(noBrowser bool) error {
// Update or add antigravity in ModelList
foundAntigravity := false
for i := range appCfg.ModelList {
if isAntigravityModel(appCfg.ModelList[i].Model) {
if isAntigravityModel(appCfg.ModelList[i]) {
appCfg.ModelList[i].AuthMethod = "oauth"
foundAntigravity = true
break
@ -206,7 +206,7 @@ func authLoginAnthropicSetupToken() error {
if err == nil {
found := false
for i := range appCfg.ModelList {
if isAnthropicModel(appCfg.ModelList[i].Model) {
if isAnthropicModel(appCfg.ModelList[i]) {
appCfg.ModelList[i].AuthMethod = "oauth"
found = true
break
@ -282,7 +282,7 @@ func authLoginPasteToken(provider string) error {
// Update ModelList
found := false
for i := range appCfg.ModelList {
if isAnthropicModel(appCfg.ModelList[i].Model) {
if isAnthropicModel(appCfg.ModelList[i]) {
appCfg.ModelList[i].AuthMethod = "token"
found = true
break
@ -300,7 +300,7 @@ func authLoginPasteToken(provider string) error {
// Update ModelList
found := false
for i := range appCfg.ModelList {
if isOpenAIModel(appCfg.ModelList[i].Model) {
if isOpenAIModel(appCfg.ModelList[i]) {
appCfg.ModelList[i].AuthMethod = "token"
found = true
break
@ -342,15 +342,15 @@ func authLogoutCmd(provider string) error {
for i := range appCfg.ModelList {
switch provider {
case "openai":
if isOpenAIModel(appCfg.ModelList[i].Model) {
if isOpenAIModel(appCfg.ModelList[i]) {
appCfg.ModelList[i].AuthMethod = ""
}
case "anthropic":
if isAnthropicModel(appCfg.ModelList[i].Model) {
if isAnthropicModel(appCfg.ModelList[i]) {
appCfg.ModelList[i].AuthMethod = ""
}
case "google-antigravity", "antigravity":
if isAntigravityModel(appCfg.ModelList[i].Model) {
if isAntigravityModel(appCfg.ModelList[i]) {
appCfg.ModelList[i].AuthMethod = ""
}
}
@ -484,22 +484,20 @@ func authModelsCmd() error {
return nil
}
// isAntigravityModel checks if a model string belongs to antigravity provider
func isAntigravityModel(model string) bool {
return model == "antigravity" ||
model == "google-antigravity" ||
strings.HasPrefix(model, "antigravity/") ||
strings.HasPrefix(model, "google-antigravity/")
// isAntigravityModel checks if a model config belongs to an Antigravity provider.
func isAntigravityModel(modelCfg *config.ModelConfig) bool {
protocol, _ := providers.ExtractProtocol(modelCfg)
return protocol == "antigravity" || protocol == "google-antigravity"
}
// isOpenAIModel checks if a model string belongs to openai provider
func isOpenAIModel(model string) bool {
return model == "openai" ||
strings.HasPrefix(model, "openai/")
// isOpenAIModel checks if a model config belongs to the OpenAI provider.
func isOpenAIModel(modelCfg *config.ModelConfig) bool {
protocol, _ := providers.ExtractProtocol(modelCfg)
return protocol == "openai"
}
// isAnthropicModel checks if a model string belongs to anthropic provider
func isAnthropicModel(model string) bool {
return model == "anthropic" ||
strings.HasPrefix(model, "anthropic/")
// isAnthropicModel checks if a model config belongs to the Anthropic provider.
func isAnthropicModel(modelCfg *config.ModelConfig) bool {
protocol, _ := providers.ExtractProtocol(modelCfg)
return protocol == "anthropic"
}

View file

@ -3,12 +3,12 @@ package status
import (
"fmt"
"os"
"strings"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cliui"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
)
func statusCmd() {
@ -44,12 +44,13 @@ func statusCmd() {
// not depend on a legacy cfg.Providers field (which may not exist under some
// build tags). We infer provider availability from model_list entries.
hasProtocolKey := func(protocol string) bool {
prefix := protocol + "/"
want := providers.NormalizeProvider(protocol)
for _, m := range cfg.ModelList {
if m == nil {
continue
}
if strings.HasPrefix(m.Model, prefix) && m.APIKey() != "" {
got, _ := providers.ExtractProtocol(m)
if got == want && m.APIKey() != "" {
return true
}
}
@ -67,12 +68,13 @@ func statusCmd() {
return "", false
}
findProtocolBase := func(protocol string) (string, bool) {
prefix := protocol + "/"
want := providers.NormalizeProvider(protocol)
for _, m := range cfg.ModelList {
if m == nil {
continue
}
if strings.HasPrefix(m.Model, prefix) && m.APIBase != "" {
got, _ := providers.ExtractProtocol(m)
if got == want && m.APIBase != "" {
return m.APIBase, true
}
}

View file

@ -0,0 +1,89 @@
package status
import (
"bytes"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
oldStdout := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe() error = %v", err)
}
os.Stdout = w
fn()
_ = w.Close()
os.Stdout = oldStdout
defer r.Close()
var buf bytes.Buffer
if _, err := io.Copy(&buf, r); err != nil {
t.Fatalf("io.Copy() error = %v", err)
}
return buf.String()
}
func TestStatusCmd_RecognizesProviderFieldWithoutModelPrefix(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
workspace := filepath.Join(tmpDir, "workspace")
if err := os.MkdirAll(workspace, 0o755); err != nil {
t.Fatalf("os.MkdirAll() error = %v", err)
}
t.Setenv(config.EnvConfig, configPath)
t.Setenv(config.EnvHome, tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "gpt-5.4",
Workspace: workspace,
Provider: "openai",
MaxTokens: 65536,
Temperature: nil,
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "gpt-5.4",
Provider: "openai",
Model: "gpt-5.4",
APIBase: "https://api.openai.com/v1",
APIKeys: config.SimpleSecureStrings("test-key"),
Enabled: true,
},
{
ModelName: "qwen-plus",
Provider: "qwen",
Model: "qwen-plus",
APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
APIKeys: config.SimpleSecureStrings("test-key"),
Enabled: true,
},
},
}
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("config.SaveConfig() error = %v", err)
}
output := captureStdout(t, statusCmd)
if !strings.Contains(output, "OpenAI API: \u2713") {
t.Fatalf("status output missing OpenAI provider: %s", output)
}
if !strings.Contains(output, "Qwen API: \u2713") {
t.Fatalf("status output missing Qwen provider: %s", output)
}
}

View file

@ -494,7 +494,7 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
### Model Configuration (model_list)
> **What's New?** PicoClaw now uses a **model-centric** configuration approach. Simply specify `vendor/model` format (e.g., `zhipu/glm-4.7`) to add new providers — **zero code changes required!**
> **What's New?** PicoClaw now prefers explicit `provider` + native `model` configuration (for example `"provider": "zhipu", "model": "glm-4.7"`). The legacy single-field `provider/model` form remains supported for compatibility when `provider` is omitted.
This design also enables **multi-agent support** with flexible provider selection:
@ -547,7 +547,8 @@ chmod 600 ~/.picoclaw/.security.yml
"model_list": [
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4"
"provider": "openai",
"model": "gpt-5.4"
// api_key loaded from .security.yml
}
],
@ -571,31 +572,31 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
#### All Supported Vendors
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
| Vendor | `provider` Value | Default API Base | Protocol | API Key |
| ----------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) |
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) |
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | — |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) |
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) |
| **ModelScope (魔搭)** | `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | — |
| **OpenAI** | `openai` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
| **Anthropic** | `anthropic` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
| **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
| **DeepSeek** | `deepseek` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) |
| **Groq** | `groq` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
| **Moonshot** | `moonshot` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
| **通义千问 (Qwen)** | `qwen` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
| **NVIDIA** | `nvidia` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
| **Ollama** | `ollama` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
| **LM Studio** | `lmstudio` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) |
| **OpenRouter** | `openrouter` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
| **LiteLLM Proxy** | `litellm` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
| **VLLM** | `vllm` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
| **VolcEngine (Doubao)** | `volcengine` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| **神算云** | `shengsuanyun` | `https://router.shengsuanyun.com/api/v1` | OpenAI | — |
| **BytePlus** | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) |
| **Vivgrid** | `vivgrid` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
| **LongCat** | `longcat` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) |
| **ModelScope (魔搭)** | `modelscope` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) |
| **Antigravity** | `antigravity` | Google Cloud | Custom | OAuth only |
| **GitHub Copilot** | `github-copilot` | `localhost:4321` | gRPC | — |
#### Basic Configuration
@ -604,22 +605,26 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
"model_list": [
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["sk-your-api-key"]
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-your-openai-key"]
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"provider": "anthropic",
"model": "claude-sonnet-4.6",
"api_keys": ["sk-ant-your-key"]
},
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"provider": "zhipu",
"model": "glm-4.7",
"api_keys": ["your-zhipu-key"]
}
],
@ -635,6 +640,13 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
>
> **Note**: The `enabled` field can be set to `false` to disable a model entry without removing it. When omitted, it defaults to `true` during migration for models that have API keys.
Resolution rules:
- Prefer explicit `"provider": "openai", "model": "gpt-5.4"`.
- If `provider` is set, PicoClaw sends `model` unchanged.
- If `provider` is omitted, PicoClaw treats the first `/` segment in `model` as the provider and everything after that first `/` as the runtime model ID.
- This means `"model": "openrouter/openai/gpt-5.4"` still works as a compatibility form and sends `openai/gpt-5.4` to OpenRouter.
#### Vendor-Specific Examples
> **Tip**: You can omit `api_key` fields and store them in `.security.yml` for better security. See [Security Configuration](#-security-configuration-recommended).
@ -645,7 +657,8 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
```json
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4"
"provider": "openai",
"model": "gpt-5.4"
// api_key: set in .security.yml
}
```
@ -658,7 +671,8 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest"
"provider": "volcengine",
"model": "ark-code-latest"
// api_key: set in .security.yml
}
```
@ -671,7 +685,8 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
```json
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7"
"provider": "zhipu",
"model": "glm-4.7"
// api_key: set in .security.yml
}
```
@ -684,7 +699,8 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
```json
{
"model_name": "deepseek-chat",
"model": "deepseek/deepseek-chat"
"provider": "deepseek",
"model": "deepseek-chat"
// api_key: set in .security.yml
}
```
@ -697,7 +713,8 @@ For complete documentation, see [`../security/security_configuration.md`](../sec
```json
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6"
"provider": "anthropic",
"model": "claude-sonnet-4.6"
// api_key: set in .security.yml
}
```
@ -709,7 +726,8 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
```json
{
"model_name": "claude-opus-4-6",
"model": "anthropic-messages/claude-opus-4-6",
"provider": "anthropic-messages",
"model": "claude-opus-4-6",
"api_keys": ["sk-ant-your-key"],
"api_base": "https://api.anthropic.com"
}
@ -725,7 +743,8 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
```json
{
"model_name": "llama3",
"model": "ollama/llama3"
"provider": "ollama",
"model": "llama3"
}
```
@ -737,12 +756,13 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
```json
{
"model_name": "lmstudio-local",
"model": "lmstudio/openai/gpt-oss-20b"
"provider": "lmstudio",
"model": "openai/gpt-oss-20b"
}
```
`api_base` defaults to `http://localhost:1234/v1`. API key is optional unless your LM Studio server enables authentication.<br/>
PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio/` prefix before sending requests, so `lmstudio/openai/gpt-oss-20b` sends `openai/gpt-oss-20b` to the LM Studio server.
With explicit `provider`, PicoClaw sends `openai/gpt-oss-20b` unchanged to LM Studio. The legacy compatibility form `"model": "lmstudio/openai/gpt-oss-20b"` still resolves to the same upstream model ID when `provider` is omitted.
</details>
@ -752,13 +772,14 @@ PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio
```json
{
"model_name": "my-custom-model",
"model": "openai/custom-model",
"provider": "openai",
"model": "custom-model",
"api_base": "https://my-proxy.com/v1"
// api_key: set in .security.yml
}
```
PicoClaw strips only the outer `litellm/` prefix before sending the request, so `litellm/lite-gpt4` sends `lite-gpt4`, while `litellm/openai/gpt-4o` sends `openai/gpt-4o`.
With explicit `provider`, PicoClaw sends `model` unchanged. That means `"provider": "litellm", "model": "lite-gpt4"` sends `lite-gpt4`, while `"provider": "litellm", "model": "openai/gpt-4o"` sends `openai/gpt-4o`. The legacy compatibility forms `litellm/lite-gpt4` and `litellm/openai/gpt-4o` still resolve the same way when `provider` is omitted.
</details>
@ -783,7 +804,8 @@ model_list:
"model_list": [
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api.openai.com/v1"
// api_keys loaded from .security.yml
}
@ -798,13 +820,15 @@ model_list:
"model_list": [
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_keys": ["sk-key1"]
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_keys": ["sk-key2"]
}
@ -864,7 +888,7 @@ This keeps the runtime lightweight while making new OpenAI-compatible backends m
{
"agents": {
"defaults": {
"model": "anthropic/claude-opus-4-5"
"model_name": "claude-opus-4-5"
}
},
"session": {

View file

@ -425,7 +425,7 @@ Agent 读取 HEARTBEAT.md
### 模型配置 (model_list)
> **新特性:** PicoClaw 现在采用**以模型为中心**的配置方式。只需指定 `vendor/model` 格式(例如 `zhipu/glm-4.7`)即可接入新提供商——**无需修改任何代码!**
> **新特性:** PicoClaw 现在优先推荐显式 `provider` + 原生 `model` 的配置方式,例如 `"provider": "zhipu", "model": "glm-4.7"`。如果未设置 `provider`,旧的单字段 `provider/model` 写法仍然兼容。
这一设计同时支持**多 Agent**场景,灵活选择提供商:
@ -436,31 +436,31 @@ Agent 读取 HEARTBEAT.md
#### 所有支持的厂商
| 厂商 | `model` 前缀 | 默认 API Base | 协议 | API Key |
| 厂商 | `provider` 值 | 默认 API Base | 协议 | API Key |
| ----------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取](https://platform.openai.com) |
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取](https://console.anthropic.com) |
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取](https://platform.deepseek.com) |
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [获取](https://aistudio.google.com/api-keys) |
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取](https://console.groq.com) |
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取](https://platform.moonshot.cn) |
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取](https://dashscope.console.aliyun.com) |
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取](https://build.nvidia.com) |
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需 Key |
| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) |
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取](https://openrouter.ai/keys) |
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理 Key |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取](https://cerebras.ai) |
| **火山引擎 (豆包)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | — |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取](https://www.byteplus.com) |
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [获取](https://vivgrid.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取](https://longcat.chat/platform) |
| **ModelScope (魔搭)** | `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取](https://modelscope.cn/my/tokens) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | 仅 OAuth |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | — |
| **OpenAI** | `openai` | `https://api.openai.com/v1` | OpenAI | [获取](https://platform.openai.com) |
| **Anthropic** | `anthropic` | `https://api.anthropic.com/v1` | Anthropic | [获取](https://console.anthropic.com) |
| **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
| **DeepSeek** | `deepseek` | `https://api.deepseek.com/v1` | OpenAI | [获取](https://platform.deepseek.com) |
| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [获取](https://aistudio.google.com/api-keys) |
| **Groq** | `groq` | `https://api.groq.com/openai/v1` | OpenAI | [获取](https://console.groq.com) |
| **Moonshot** | `moonshot` | `https://api.moonshot.cn/v1` | OpenAI | [获取](https://platform.moonshot.cn) |
| **通义千问 (Qwen)** | `qwen` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取](https://dashscope.console.aliyun.com) |
| **NVIDIA** | `nvidia` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取](https://build.nvidia.com) |
| **Ollama** | `ollama` | `http://localhost:11434/v1` | OpenAI | 本地(无需 Key |
| **LM Studio** | `lmstudio` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) |
| **OpenRouter** | `openrouter` | `https://openrouter.ai/api/v1` | OpenAI | [获取](https://openrouter.ai/keys) |
| **LiteLLM Proxy** | `litellm` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理 Key |
| **VLLM** | `vllm` | `http://localhost:8000/v1` | OpenAI | 本地 |
| **Cerebras** | `cerebras` | `https://api.cerebras.ai/v1` | OpenAI | [获取](https://cerebras.ai) |
| **火山引擎 (豆包)** | `volcengine` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| **神算云** | `shengsuanyun` | `https://router.shengsuanyun.com/api/v1` | OpenAI | — |
| **BytePlus** | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取](https://www.byteplus.com) |
| **Vivgrid** | `vivgrid` | `https://api.vivgrid.com/v1` | OpenAI | [获取](https://vivgrid.com) |
| **LongCat** | `longcat` | `https://api.longcat.chat/openai` | OpenAI | [获取](https://longcat.chat/platform) |
| **ModelScope (魔搭)** | `modelscope` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取](https://modelscope.cn/my/tokens) |
| **Antigravity** | `antigravity` | Google Cloud | Custom | 仅 OAuth |
| **GitHub Copilot** | `github-copilot` | `localhost:4321` | gRPC | — |
#### 基础配置
@ -469,22 +469,26 @@ Agent 读取 HEARTBEAT.md
"model_list": [
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["sk-your-api-key"]
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-your-openai-key"]
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"provider": "anthropic",
"model": "claude-sonnet-4.6",
"api_keys": ["sk-ant-your-key"]
},
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"provider": "zhipu",
"model": "glm-4.7",
"api_keys": ["your-zhipu-key"]
}
],
@ -496,6 +500,13 @@ Agent 读取 HEARTBEAT.md
}
```
解析规则:
- 推荐显式写成 `"provider": "openai", "model": "gpt-5.4"`
- 如果设置了 `provider`PicoClaw 会将 `model` 原样发送。
- 如果未设置 `provider`PicoClaw 会把 `model` 第一个 `/` 之前的字段当作 provider并把第一个 `/` 之后的全部内容当作最终模型 ID。
- 这意味着 `"model": "openrouter/openai/gpt-5.4"` 这样的兼容写法仍然可用,并会把 `openai/gpt-5.4` 发送给 OpenRouter。
#### 各厂商配置示例
<details>
@ -504,7 +515,8 @@ Agent 读取 HEARTBEAT.md
```json
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-..."]
}
```
@ -517,7 +529,8 @@ Agent 读取 HEARTBEAT.md
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["sk-..."]
}
```
@ -530,7 +543,8 @@ Agent 读取 HEARTBEAT.md
```json
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"provider": "zhipu",
"model": "glm-4.7",
"api_keys": ["your-key"]
}
```
@ -543,7 +557,8 @@ Agent 读取 HEARTBEAT.md
```json
{
"model_name": "deepseek-chat",
"model": "deepseek/deepseek-chat",
"provider": "deepseek",
"model": "deepseek-chat",
"api_keys": ["sk-..."]
}
```
@ -556,7 +571,8 @@ Agent 读取 HEARTBEAT.md
```json
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"provider": "anthropic",
"model": "claude-sonnet-4.6",
"api_keys": ["sk-ant-your-key"]
}
```
@ -568,7 +584,8 @@ Agent 读取 HEARTBEAT.md
```json
{
"model_name": "claude-opus-4-6",
"model": "anthropic-messages/claude-opus-4-6",
"provider": "anthropic-messages",
"model": "claude-opus-4-6",
"api_keys": ["sk-ant-your-key"],
"api_base": "https://api.anthropic.com"
}
@ -584,7 +601,8 @@ Agent 读取 HEARTBEAT.md
```json
{
"model_name": "llama3",
"model": "ollama/llama3"
"provider": "ollama",
"model": "llama3"
}
```
@ -596,12 +614,13 @@ Agent 读取 HEARTBEAT.md
```json
{
"model_name": "lmstudio-local",
"model": "lmstudio/openai/gpt-oss-20b"
"provider": "lmstudio",
"model": "openai/gpt-oss-20b"
}
```
`api_base` 默认是 `http://localhost:1234/v1`。除非你在 LM Studio 侧启用了认证,否则不需要配置 API Key。
PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首个 `lmstudio/` 前缀,因此 `lmstudio/openai/gpt-oss-20b` 会发送 `openai/gpt-oss-20b`
显式设置 `provider`PicoClaw 会把 `openai/gpt-oss-20b` 原样发送给 LM Studio。旧的兼容写法 `"model": "lmstudio/openai/gpt-oss-20b"` 在未设置 `provider` 时也会解析成相同的上游模型 ID
</details>
@ -611,13 +630,14 @@ PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首
```json
{
"model_name": "my-custom-model",
"model": "openai/custom-model",
"provider": "openai",
"model": "custom-model",
"api_base": "https://my-proxy.com/v1",
"api_keys": ["sk-..."]
}
```
PicoClaw 只剥离最外层的 `litellm/` 前缀再发送请求,因此 `litellm/lite-gpt4` 发送 `lite-gpt4`,而 `litellm/openai/gpt-4o` 发送 `openai/gpt-4o`
显式设置 `provider`PicoClaw 会将 `model` 原样发送。因此 `"provider": "litellm", "model": "lite-gpt4"` 会发送 `lite-gpt4`,而 `"provider": "litellm", "model": "openai/gpt-4o"` 会发送 `openai/gpt-4o`。旧的兼容写法 `litellm/lite-gpt4``litellm/openai/gpt-4o` 在未设置 `provider` 时也会得到相同结果
</details>
@ -630,13 +650,15 @@ PicoClaw 只剥离最外层的 `litellm/` 前缀再发送请求,因此 `litell
"model_list": [
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_keys": ["sk-key1"]
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_keys": ["sk-key2"]
}
@ -691,7 +713,7 @@ PicoClaw 按协议族路由提供商:
{
"agents": {
"defaults": {
"model": "anthropic/claude-opus-4-5"
"model_name": "claude-opus-4-5"
}
},
"session": {

View file

@ -94,19 +94,22 @@ picoclaw onboard
"model_list": [
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["sk-your-api-key"],
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["your-api-key"],
"request_timeout": 300
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"provider": "anthropic",
"model": "claude-sonnet-4.6",
"api_keys": ["your-anthropic-key"]
}
],

View file

@ -93,19 +93,22 @@ picoclaw onboard
"model_list": [
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["sk-your-api-key"],
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["your-api-key"],
"request_timeout": 300
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"provider": "anthropic",
"model": "claude-sonnet-4.6",
"api_keys": ["your-anthropic-key"]
}
],

View file

@ -33,7 +33,7 @@
### Model Configuration (model_list)
> **What's New?** PicoClaw now uses a **model-centric** configuration approach. Simply specify `vendor/model` format (e.g., `zhipu/glm-4.7`) to add new providers—**zero code changes required!**
> **What's New?** PicoClaw now prefers explicit `provider` + native `model` configuration (for example `"provider": "zhipu", "model": "glm-4.7"`). The legacy single-field `provider/model` form remains supported for compatibility when `provider` is omitted.
For agent dispatch and light-model routing examples, see the [Routing Guide](routing-guide.md).
@ -46,35 +46,35 @@ This design also enables **multi-agent support** with flexible provider selectio
#### 📋 All Supported Vendors
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
| Vendor | `provider` Value | Default API Base | Protocol | API Key |
| ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- |
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
| **Venice AI** | `venice/` | `https://api.venice.ai/api/v1` | OpenAI | [Get Key](https://venice.ai) |
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
| **Z.AI Coding Plan** | `openai/` | `https://api.z.ai/api/coding/paas/v4` | OpenAI | [Get Key](https://z.ai/manage-apikey/apikey-list) |
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) |
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) |
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) |
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) |
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) |
| **Xiaomi MiMo** | `mimo/` | `https://api.xiaomimimo.com/v1` | OpenAI | [Get Key](https://platform.xiaomimimo.com) |
| **Azure OpenAI** | `azure/` | `https://{resource}.openai.azure.com` | Azure | [Get Key](https://portal.azure.com) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
| **OpenAI** | `openai` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
| **Venice AI** | `venice` | `https://api.venice.ai/api/v1` | OpenAI | [Get Key](https://venice.ai) |
| **Anthropic** | `anthropic` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
| **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
| **Z.AI Coding Plan** | `openai` | `https://api.z.ai/api/coding/paas/v4` | OpenAI | [Get Key](https://z.ai/manage-apikey/apikey-list) |
| **DeepSeek** | `deepseek` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [Get Key](https://aistudio.google.com/api-keys) |
| **Groq** | `groq` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
| **Moonshot** | `moonshot` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
| **通义千问 (Qwen)** | `qwen` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
| **NVIDIA** | `nvidia` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
| **Ollama** | `ollama` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
| **LM Studio** | `lmstudio` | `http://localhost:1234/v1` | OpenAI | Optional (local default: no key) |
| **OpenRouter** | `openrouter` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
| **LiteLLM Proxy** | `litellm` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
| **VLLM** | `vllm` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
| **VolcEngine (Doubao)** | `volcengine` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| **神算云** | `shengsuanyun` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) |
| **Vivgrid** | `vivgrid` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
| **LongCat** | `longcat` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) |
| **ModelScope (魔搭)**| `modelscope` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) |
| **Xiaomi MiMo** | `mimo` | `https://api.xiaomimimo.com/v1` | OpenAI | [Get Key](https://platform.xiaomimimo.com) |
| **Azure OpenAI** | `azure` | `https://{resource}.openai.azure.com` | Azure | [Get Key](https://portal.azure.com) |
| **Antigravity** | `antigravity` | Google Cloud | Custom | OAuth only |
| **GitHub Copilot** | `github-copilot` | `localhost:4321` | gRPC | - |
#### Basic Configuration
@ -83,22 +83,26 @@ This design also enables **multi-agent support** with flexible provider selectio
"model_list": [
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["sk-your-api-key"]
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-your-openai-key"]
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"provider": "anthropic",
"model": "claude-sonnet-4.6",
"api_keys": ["sk-ant-your-key"]
},
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"provider": "zhipu",
"model": "glm-4.7",
"api_keys": ["your-zhipu-key"]
}
],
@ -115,7 +119,8 @@ This design also enables **multi-agent support** with flexible provider selectio
| 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`) |
| `provider` | string | No | Preferred provider identifier. When present, PicoClaw sends `model` unchanged to that provider |
| `model` | string | Yes | Native model ID when `provider` is set. If `provider` is omitted, the legacy `provider/model` form is still supported |
| `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 |
@ -129,6 +134,22 @@ This design also enables **multi-agent support** with flexible provider selectio
| `fallbacks` | string[] | No | Fallback model names for automatic failover |
| `enabled` | bool | No | Whether this model entry is active (default: `true`) |
#### Provider / Model Resolution
PicoClaw resolves `provider` and the runtime model ID using these rules:
- If `provider` is set, `model` is used as-is.
- If `provider` is omitted, PicoClaw treats the first `/` segment in `model` as the provider and everything after that first `/` as the runtime model ID.
Examples:
| Config | Resolved Provider | Model Sent Upstream |
| --- | --- | --- |
| `"provider": "openai", "model": "gpt-5.4"` | `openai` | `gpt-5.4` |
| `"model": "openai/gpt-5.4"` | `openai` | `gpt-5.4` |
| `"provider": "openrouter", "model": "openai/gpt-5.4"` | `openrouter` | `openai/gpt-5.4` |
| `"model": "openrouter/openai/gpt-5.4"` | `openrouter` | `openai/gpt-5.4` |
#### 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.
@ -140,7 +161,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
"model_list": [
{
"model_name": "voice-gemini",
"model": "gemini/gemini-2.5-flash",
"provider": "gemini",
"model": "gemini-2.5-flash",
"api_keys": ["your-gemini-key"]
}
],
@ -163,7 +185,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
```json
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-..."]
}
```
@ -173,7 +196,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["sk-..."]
}
```
@ -183,7 +207,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
```json
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"provider": "zhipu",
"model": "glm-4.7",
"api_keys": ["your-key"]
}
```
@ -193,7 +218,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
```json
{
"model_name": "glm-4.7",
"model": "openai/glm-4.7",
"provider": "openai",
"model": "glm-4.7",
"api_keys": ["your-z.ai-key"],
"api_base": "https://api.z.ai/api/coding/paas/v4"
}
@ -204,7 +230,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
```json
{
"model_name": "deepseek-chat",
"model": "deepseek/deepseek-chat",
"provider": "deepseek",
"model": "deepseek-chat",
"api_keys": ["sk-..."]
}
```
@ -214,7 +241,8 @@ If `voice.model_name` is not configured, PicoClaw will continue to fall back to
```json
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"provider": "anthropic",
"model": "claude-sonnet-4.6",
"api_keys": ["sk-ant-your-key"]
}
```
@ -228,7 +256,8 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
```json
{
"model_name": "claude-opus-4-6",
"model": "anthropic-messages/claude-opus-4-6",
"provider": "anthropic-messages",
"model": "claude-opus-4-6",
"api_keys": ["sk-ant-your-key"],
"api_base": "https://api.anthropic.com"
}
@ -246,7 +275,8 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
```json
{
"model_name": "llama3",
"model": "ollama/llama3"
"provider": "ollama",
"model": "llama3"
}
```
@ -255,19 +285,21 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
```json
{
"model_name": "lmstudio-local",
"model": "lmstudio/openai/gpt-oss-20b"
"provider": "lmstudio",
"model": "openai/gpt-oss-20b"
}
```
`api_base` defaults to `http://localhost:1234/v1`. API key is optional unless your LM Studio server enables authentication.<br/>
PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio/` prefix before sending requests, so `lmstudio/openai/gpt-oss-20b` sends `openai/gpt-oss-20b` to the LM Studio server.
With explicit `provider`, PicoClaw sends `openai/gpt-oss-20b` unchanged to the LM Studio server. The legacy compatibility form `"model": "lmstudio/openai/gpt-oss-20b"` still resolves to the same upstream model ID when `provider` is omitted.
**Custom Proxy/API**
```json
{
"model_name": "my-custom-model",
"model": "openai/custom-model",
"provider": "openai",
"model": "custom-model",
"api_base": "https://my-proxy.com/v1",
"api_keys": ["sk-..."],
"user_agent": "MyApp/1.0",
@ -280,13 +312,14 @@ PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio
```json
{
"model_name": "lite-gpt4",
"model": "litellm/lite-gpt4",
"provider": "litellm",
"model": "lite-gpt4",
"api_base": "http://localhost:4000/v1",
"api_keys": ["sk-..."]
}
```
PicoClaw strips only the outer `litellm/` prefix before sending the request, so proxy aliases like `litellm/lite-gpt4` send `lite-gpt4`, while `litellm/openai/gpt-4o` sends `openai/gpt-4o`.
With explicit `provider`, PicoClaw sends `model` unchanged. That means `"provider": "litellm", "model": "lite-gpt4"` sends `lite-gpt4`, while `"provider": "litellm", "model": "openai/gpt-4o"` sends `openai/gpt-4o`. The legacy compatibility forms `litellm/lite-gpt4` and `litellm/openai/gpt-4o` still resolve the same way when `provider` is omitted.
**Z.AI Coding Plan**
@ -295,7 +328,8 @@ If the standard Zhipu endpoint (`https://open.bigmodel.cn/api/paas/v4`) returns
```json
{
"model_name": "glm-4.7",
"model": "openai/glm-4.7",
"provider": "openai",
"model": "glm-4.7",
"api_keys": ["your-zhipu-api-key"],
"api_base": "https://api.z.ai/api/coding/paas/v4"
}
@ -312,13 +346,15 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical
"model_list": [
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_keys": ["sk-key1"]
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_keys": ["sk-key2"]
}
@ -337,18 +373,21 @@ It also applies cooldown tracking per candidate to avoid immediately retrying a
"model_list": [
{
"model_name": "qwen-main",
"model": "openai/qwen3.5:cloud",
"provider": "openai",
"model": "qwen3.5:cloud",
"api_base": "https://api.example.com/v1",
"api_keys": ["sk-main"]
},
{
"model_name": "deepseek-backup",
"model": "deepseek/deepseek-chat",
"provider": "deepseek",
"model": "deepseek-chat",
"api_keys": ["sk-backup-1"]
},
{
"model_name": "gemini-backup",
"model": "gemini/gemini-2.5-flash",
"provider": "gemini",
"model": "gemini-2.5-flash",
"api_keys": ["sk-backup-2"]
}
],
@ -396,7 +435,8 @@ The old `providers` configuration is **deprecated** and has been removed in V2.
"model_list": [
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"provider": "zhipu",
"model": "glm-4.7",
"api_keys": ["your-key"]
}
],
@ -465,7 +505,7 @@ picoclaw agent -m "Hello"
{
"agents": {
"defaults": {
"model_name": "anthropic/claude-opus-4-5"
"model_name": "claude-opus-4-5"
}
},
"session": {

View file

@ -32,7 +32,7 @@
<a id="模型配置-model_list"></a>
### 模型配置 (model_list)
> **新功能!** PicoClaw 现在采用**以模型为中心**的配置方式。只需使用 `厂商/模型` 格式(如 `zhipu/glm-4.7`)即可添加新的 provider——**无需修改任何代码!**
> **新功能!** PicoClaw 现在优先推荐显式 `provider` + 原生 `model` 的配置方式,例如 `"provider": "zhipu", "model": "glm-4.7"`。如果未设置 `provider`,旧的单字段 `provider/model` 写法仍然兼容。
如果你想看 agent 分发和轻量模型路由的完整示例,请看 [路由使用指南](routing-guide.zh.md)。
@ -45,33 +45,33 @@
#### 📋 所有支持的厂商
| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key |
| 厂商 | `provider` 值 | 默认 API Base | 协议 | 获取 API Key |
| ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- |
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) |
| **Venice AI** | `venice/` | `https://api.venice.ai/api/v1` | OpenAI | [获取密钥](https://venice.ai) |
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) |
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) |
| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [获取密钥](https://aistudio.google.com/api-keys) |
| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) |
| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) |
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) |
| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) |
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) |
| **LM Studio** | `lmstudio/` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) |
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理密钥 |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
| **火山引擎Doubao** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取密钥](https://www.byteplus.com) |
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [获取密钥](https://vivgrid.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) |
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取 Token](https://modelscope.cn/my/tokens) |
| **小米 MiMo** | `mimo/` | `https://api.xiaomimimo.com/v1` | OpenAI | [获取密钥](https://platform.xiaomimimo.com) |
| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
| **OpenAI** | `openai` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) |
| **Venice AI** | `venice` | `https://api.venice.ai/api/v1` | OpenAI | [获取密钥](https://venice.ai) |
| **Anthropic** | `anthropic` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) |
| **智谱 AI (GLM)** | `zhipu` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
| **DeepSeek** | `deepseek` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) |
| **Google Gemini** | `gemini` | `https://generativelanguage.googleapis.com/v1beta` | Gemini | [获取密钥](https://aistudio.google.com/api-keys) |
| **Groq** | `groq` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) |
| **Moonshot** | `moonshot` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) |
| **通义千问 (Qwen)** | `qwen` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) |
| **NVIDIA** | `nvidia` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) |
| **Ollama** | `ollama` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) |
| **LM Studio** | `lmstudio` | `http://localhost:1234/v1` | OpenAI | 可选(本地默认无需密钥) |
| **OpenRouter** | `openrouter` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
| **LiteLLM Proxy** | `litellm` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理密钥 |
| **VLLM** | `vllm` | `http://localhost:8000/v1` | OpenAI | 本地 |
| **Cerebras** | `cerebras` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
| **火山引擎Doubao** | `volcengine` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| **神算云** | `shengsuanyun` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取密钥](https://www.byteplus.com) |
| **Vivgrid** | `vivgrid` | `https://api.vivgrid.com/v1` | OpenAI | [获取密钥](https://vivgrid.com) |
| **LongCat** | `longcat` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) |
| **ModelScope (魔搭)**| `modelscope` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取 Token](https://modelscope.cn/my/tokens) |
| **小米 MiMo** | `mimo` | `https://api.xiaomimimo.com/v1` | OpenAI | [获取密钥](https://platform.xiaomimimo.com) |
| **Antigravity** | `antigravity` | Google Cloud | 自定义 | 仅 OAuth |
| **GitHub Copilot** | `github-copilot` | `localhost:4321` | gRPC | - |
#### 基础配置示例
@ -80,22 +80,26 @@
"model_list": [
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["sk-your-api-key"]
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-your-openai-key"]
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"provider": "anthropic",
"model": "claude-sonnet-4.6",
"api_keys": ["sk-ant-your-key"]
},
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"provider": "zhipu",
"model": "glm-4.7",
"api_keys": ["your-zhipu-key"]
}
],
@ -112,7 +116,8 @@
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `model_name` | string | 是 | 在 agent 配置中引用此模型的唯一名称 |
| `model` | string | 是 | 厂商/模型标识符(如 `openai/gpt-5.4``azure/gpt-5.4``anthropic/claude-sonnet-4.6` |
| `provider` | string | 否 | 推荐的 provider 标识。设置后PicoClaw 会将 `model` 原样发送给该 provider |
| `model` | string | 是 | 当设置 `provider` 时,这里填写 provider 原生模型 ID。若未设置 `provider`,仍兼容旧的 `provider/model` 写法 |
| `api_keys` | string[] | 是* | 认证密钥。多个密钥可按请求轮换。本地 providerOllama、LM Studio、VLLM不需要 |
| `api_base` | string | 否 | 覆盖默认的 API 端点 URL |
| `proxy` | string | 否 | 此模型条目的 HTTP 代理 URL |
@ -126,6 +131,22 @@
| `fallbacks` | string[] | 否 | 自动故障转移的备用模型名称 |
| `enabled` | bool | 否 | 是否启用此模型条目(默认:`true` |
#### `provider` / `model` 解析规则
PicoClaw 按下面的规则解析 `provider` 和最终发给上游的模型 ID
- 如果设置了 `provider`,则直接使用 `model`
- 如果未设置 `provider`,则把 `model` 中第一个 `/` 之前的字段当作 provider第一个 `/` 之后的全部内容当作最终模型 ID。
示例:
| 配置 | 解析后的 Provider | 实际发送的模型 ID |
| --- | --- | --- |
| `"provider": "openai", "model": "gpt-5.4"` | `openai` | `gpt-5.4` |
| `"model": "openai/gpt-5.4"` | `openai` | `gpt-5.4` |
| `"provider": "openrouter", "model": "openai/gpt-5.4"` | `openrouter` | `openai/gpt-5.4` |
| `"model": "openrouter/openai/gpt-5.4"` | `openrouter` | `openai/gpt-5.4` |
#### 语音转录
你可以通过 `voice.model_name` 为语音转录指定一个专用模型。这样可以直接复用已经配置好的、支持音频输入的多模态 provider而不必只依赖 Groq。
@ -137,7 +158,8 @@
"model_list": [
{
"model_name": "voice-gemini",
"model": "gemini/gemini-2.5-flash",
"provider": "gemini",
"model": "gemini-2.5-flash",
"api_keys": ["your-gemini-key"]
}
],
@ -160,7 +182,8 @@
```json
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-..."]
}
```
@ -170,7 +193,8 @@
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"provider": "volcengine",
"model": "ark-code-latest",
"api_keys": ["sk-..."]
}
```
@ -180,7 +204,8 @@
```json
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"provider": "zhipu",
"model": "glm-4.7",
"api_keys": ["your-key"]
}
```
@ -190,7 +215,8 @@
```json
{
"model_name": "deepseek-chat",
"model": "deepseek/deepseek-chat",
"provider": "deepseek",
"model": "deepseek-chat",
"api_keys": ["sk-..."]
}
```
@ -200,7 +226,8 @@
```json
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"provider": "anthropic",
"model": "claude-sonnet-4.6",
"auth_method": "oauth"
}
```
@ -214,7 +241,8 @@
```json
{
"model_name": "claude-opus-4-6",
"model": "anthropic-messages/claude-opus-4-6",
"provider": "anthropic-messages",
"model": "claude-opus-4-6",
"api_keys": ["sk-ant-your-key"],
"api_base": "https://api.anthropic.com"
}
@ -232,7 +260,8 @@
```json
{
"model_name": "llama3",
"model": "ollama/llama3"
"provider": "ollama",
"model": "llama3"
}
```
@ -241,19 +270,21 @@
```json
{
"model_name": "lmstudio-local",
"model": "lmstudio/openai/gpt-oss-20b"
"provider": "lmstudio",
"model": "openai/gpt-oss-20b"
}
```
`api_base` 默认是 `http://localhost:1234/v1`。除非你在 LM Studio 侧启用了认证,否则不需要配置 API Key。
PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首个 `lmstudio/` 前缀,因此 `lmstudio/openai/gpt-oss-20b` 会发送 `openai/gpt-oss-20b`
显式设置 `provider`PicoClaw 会把 `openai/gpt-oss-20b` 原样发送给 LM Studio。旧的兼容写法 `"model": "lmstudio/openai/gpt-oss-20b"` 在未设置 `provider` 时也会解析成相同的上游模型 ID
**自定义代理/API**
```json
{
"model_name": "my-custom-model",
"model": "openai/custom-model",
"provider": "openai",
"model": "custom-model",
"api_base": "https://my-proxy.com/v1",
"api_keys": ["sk-..."],
"user_agent": "MyApp/1.0",
@ -266,13 +297,14 @@ PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首
```json
{
"model_name": "lite-gpt4",
"model": "litellm/lite-gpt4",
"provider": "litellm",
"model": "lite-gpt4",
"api_base": "http://localhost:4000/v1",
"api_keys": ["sk-..."]
}
```
PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/lite-gpt4` 会发送 `lite-gpt4`,而 `litellm/openai/gpt-4o` 会发送 `openai/gpt-4o`
显式设置 `provider`PicoClaw 会将 `model` 原样发送。因此 `"provider": "litellm", "model": "lite-gpt4"` 会发送 `lite-gpt4`,而 `"provider": "litellm", "model": "openai/gpt-4o"` 会发送 `openai/gpt-4o`。旧的兼容写法 `litellm/lite-gpt4``litellm/openai/gpt-4o` 在未设置 `provider` 时也会得到相同结果
#### 负载均衡
@ -283,13 +315,15 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l
"model_list": [
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_keys": ["sk-key1"]
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_keys": ["sk-key2"]
}
@ -308,18 +342,21 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l
"model_list": [
{
"model_name": "qwen-main",
"model": "openai/qwen3.5:cloud",
"provider": "openai",
"model": "qwen3.5:cloud",
"api_base": "https://api.example.com/v1",
"api_keys": ["sk-main"]
},
{
"model_name": "deepseek-backup",
"model": "deepseek/deepseek-chat",
"provider": "deepseek",
"model": "deepseek-chat",
"api_keys": ["sk-backup-1"]
},
{
"model_name": "gemini-backup",
"model": "gemini/gemini-2.5-flash",
"provider": "gemini",
"model": "gemini-2.5-flash",
"api_keys": ["sk-backup-2"]
}
],
@ -367,7 +404,8 @@ PicoClaw 在发送请求前仅去除外层 `litellm/` 前缀,因此 `litellm/l
"model_list": [
{
"model_name": "glm-4.7",
"model": "zhipu/glm-4.7",
"provider": "zhipu",
"model": "glm-4.7",
"api_keys": ["your-key"]
}
],
@ -436,7 +474,7 @@ picoclaw agent -m "你好"
{
"agents": {
"defaults": {
"model_name": "anthropic/claude-opus-4-5"
"model_name": "claude-opus-4-5"
}
},
"session": {

View file

@ -69,12 +69,14 @@ This guide explains how to configure both for real deployments.
"model_list": [
{
"model_name": "gpt-main",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-main"]
},
{
"model_name": "flash-light",
"model": "gemini/gemini-2.0-flash-exp",
"provider": "gemini",
"model": "gemini-2.0-flash-exp",
"api_keys": ["sk-light"]
}
],

View file

@ -69,12 +69,14 @@ PicoClaw 里用户能直接感知到的“路由”主要有两部分:
"model_list": [
{
"model_name": "gpt-main",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-main"]
},
{
"model_name": "flash-light",
"model": "gemini/gemini-2.0-flash-exp",
"provider": "gemini",
"model": "gemini-2.0-flash-exp",
"api_keys": ["sk-light"]
}
],

View file

@ -8,7 +8,7 @@ The new `model_list` configuration offers several advantages:
- **Zero-code provider addition**: Add OpenAI-compatible providers with configuration only
- **Load balancing**: Configure multiple endpoints for the same model
- **Protocol-based routing**: Use prefixes like `openai/`, `anthropic/`, etc.
- **Explicit provider resolution**: Prefer `provider` + native `model`, with legacy `provider/model` compatibility when needed
- **Cleaner configuration**: Model-centric instead of vendor-centric
## Timeline
@ -54,18 +54,21 @@ The new `model_list` configuration offers several advantages:
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-your-openai-key"],
"api_base": "https://api.openai.com/v1"
},
{
"model_name": "claude-sonnet-4.6",
"model": "anthropic/claude-sonnet-4.6",
"provider": "anthropic",
"model": "claude-sonnet-4.6",
"api_keys": ["sk-ant-your-key"]
},
{
"model_name": "deepseek",
"model": "deepseek/deepseek-chat",
"provider": "deepseek",
"model": "deepseek-chat",
"api_keys": ["sk-your-deepseek-key"]
}
],
@ -79,40 +82,46 @@ The new `model_list` configuration offers several advantages:
> **Note**: The `enabled` field can be omitted — during V1→V2 migration it is auto-inferred (models with API keys or the `local-model` name are enabled by default). For new configs, you can explicitly set `"enabled": false` to disable a model entry without removing it.
## Protocol Prefixes
## Provider / Model Resolution
The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
Preferred format:
| Prefix | Description | Example |
|--------|-------------|---------|
| `openai/` | OpenAI API (default) | `openai/gpt-5.4` |
| `anthropic/` | Anthropic API | `anthropic/claude-opus-4` |
| `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` |
| `gemini/` | Google Gemini API | `gemini/gemini-2.0-flash-exp` |
| `claude-cli/` | Claude CLI (local) | `claude-cli/claude-sonnet-4.6` |
| `codex-cli/` | Codex CLI (local) | `codex-cli/codex-4` |
| `github-copilot/` | GitHub Copilot | `github-copilot/gpt-4o` |
| `openrouter/` | OpenRouter | `openrouter/anthropic/claude-sonnet-4.6` |
| `groq/` | Groq API | `groq/llama-3.1-70b` |
| `deepseek/` | DeepSeek API | `deepseek/deepseek-chat` |
| `cerebras/` | Cerebras API | `cerebras/llama-3.3-70b` |
| `qwen/` | Alibaba Qwen | `qwen/qwen-max` |
| `zhipu/` | Zhipu AI | `zhipu/glm-4` |
| `nvidia/` | NVIDIA NIM | `nvidia/llama-3.1-nemotron-70b` |
| `ollama/` | Ollama (local) | `ollama/llama3` |
| `vllm/` | vLLM (local) | `vllm/my-model` |
| `moonshot/` | Moonshot AI | `moonshot/moonshot-v1-8k` |
| `shengsuanyun/` | ShengSuanYun | `shengsuanyun/deepseek-v3` |
| `volcengine/` | Volcengine | `volcengine/doubao-pro-32k` |
```json
{
"provider": "openai",
"model": "gpt-5.4"
}
```
**Note**: If no prefix is specified, `openai/` is used as the default.
Legacy compatibility format:
```json
{
"model": "openai/gpt-5.4"
}
```
Resolution rules:
1. If `provider` is set, PicoClaw sends `model` unchanged.
2. If `provider` is omitted, PicoClaw treats the first `/` segment in `model` as the provider and everything after that first `/` as the runtime model ID.
Examples:
| Config | Resolved Provider | Model Sent Upstream |
|--------|-------------------|---------------------|
| `"provider": "openai", "model": "gpt-5.4"` | `openai` | `gpt-5.4` |
| `"model": "openai/gpt-5.4"` | `openai` | `gpt-5.4` |
| `"provider": "openrouter", "model": "google/gemini-2.0-flash-exp:free"` | `openrouter` | `google/gemini-2.0-flash-exp:free` |
| `"model": "openrouter/google/gemini-2.0-flash-exp:free"` | `openrouter` | `google/gemini-2.0-flash-exp:free` |
## ModelConfig Fields
| Field | Required | Description |
|-------|----------|-------------|
| `model_name` | Yes | User-facing alias for the model |
| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.4`) |
| `provider` | No | Preferred provider identifier. When set, `model` is sent unchanged |
| `model` | Yes | Native model ID when `provider` is set, or legacy `provider/model` when `provider` is omitted |
| `api_base` | No | API endpoint URL |
| `api_keys` | No | API authentication keys (array; supports multiple keys for load balancing) |
| `enabled` | No | Whether this model entry is active. Defaults to `true` during migration for models with API keys or named `local-model`. Set to `false` to disable. |
@ -136,7 +145,8 @@ There are two ways to configure load balancing:
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-key1", "sk-key2", "sk-key3"],
"api_base": "https://api.openai.com/v1"
}
@ -162,19 +172,22 @@ model_list:
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-key1"],
"api_base": "https://api1.example.com/v1"
},
{
"model_name": "gpt4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-key2"],
"api_base": "https://api2.example.com/v1"
},
{
"model_name": "gpt4",
"model": "openai/gpt-5.4",
"provider": "openai",
"model": "gpt-5.4",
"api_keys": ["sk-key3"],
"api_base": "https://api3.example.com/v1"
}
@ -193,7 +206,8 @@ With `model_list`, adding a new provider requires zero code changes:
"model_list": [
{
"model_name": "my-custom-llm",
"model": "openai/my-model-v1",
"provider": "openai",
"model": "my-model-v1",
"api_keys": ["your-api-key"],
"api_base": "https://api.your-provider.com/v1"
}
@ -201,7 +215,7 @@ With `model_list`, adding a new provider requires zero code changes:
}
```
Just specify `openai/` as the protocol (or omit it for the default), and provide your provider's API base URL.
Just set `provider` to `openai` (or another supported provider), and provide your provider's API base URL.
## Backward Compatibility
@ -216,7 +230,7 @@ During the migration period, your existing V0/V1 config will be auto-migrated to
- [ ] Identify all providers you're currently using
- [ ] Create `model_list` entries for each provider
- [ ] Use appropriate protocol prefixes
- [ ] Prefer explicit `provider` values and native model IDs
- [ ] Update `agents.defaults.model_name` to reference the new `model_name`
- [ ] Test that all models work correctly
- [ ] Remove or comment out the old `providers` section
@ -234,10 +248,10 @@ model "xxx" not found in model_list or providers
### Unknown protocol error
```
unknown protocol "xxx" in model "xxx/model-name"
unknown provider "xxx" in model "xxx/model-name"
```
**Solution**: Use a supported protocol prefix. See the [Protocol Prefixes](#protocol-prefixes) table above.
**Solution**: Use a supported `provider` value, or use the legacy `provider/model` compatibility form correctly. See [Provider / Model Resolution](#provider--model-resolution).
### Missing API key error

View file

@ -7,16 +7,22 @@
- `Error creating provider: model "openrouter/free" not found in model_list`
- OpenRouter returns 400: `"free is not a valid model ID"`
**Cause:** The `model` field in your `model_list` entry is what gets sent to the API. For OpenRouter you must use the **full** model ID, not a shorthand.
**Cause:** PicoClaw now resolves provider/model in two steps:
- **Wrong:** `"model": "free"` → OpenRouter receives `free` and rejects it.
- **Right:** `"model": "openrouter/free"` → OpenRouter receives `openrouter/free` (auto free-tier routing).
- If `provider` is set, the `model` field is sent to that provider unchanged.
- If `provider` is omitted, PicoClaw infers the provider from the first `/` segment and sends everything after that first `/` as the runtime model ID.
For OpenRouter free-tier routing, the preferred config is explicit `provider`.
- **Wrong:** `"model": "free"` → no OpenRouter provider is selected, so `free` is not a valid OpenRouter model route.
- **Right:** `"provider": "openrouter", "model": "free"` → OpenRouter receives `free`.
- **Also supported:** `"model": "openrouter/free"` → provider resolves to `openrouter`, runtime model ID resolves to `free`.
**Fix:** In `~/.picoclaw/config.json` (or your config path):
1. **agents.defaults.model_name** must match a `model_name` in `model_list` (e.g. `"openrouter-free"`).
2. That entrys **model** must be a valid OpenRouter model ID, for example:
- `"openrouter/free"` auto free-tier
2. That entry should preferably set **provider** to `openrouter`, and **model** should be a valid OpenRouter model ID, for example:
- `"free"` auto free-tier
- `"google/gemini-2.0-flash-exp:free"`
- `"meta-llama/llama-3.1-8b-instruct:free"`
@ -32,8 +38,9 @@ Example snippet:
"model_list": [
{
"model_name": "openrouter-free",
"model": "openrouter/free",
"api_key": "sk-or-v1-YOUR_OPENROUTER_KEY",
"provider": "openrouter",
"model": "free",
"api_keys": ["sk-or-v1-YOUR_OPENROUTER_KEY"],
"api_base": "https://openrouter.ai/api/v1"
}
]

View file

@ -9,16 +9,22 @@
- `Error creating provider: model "openrouter/free" not found in model_list`
- OpenRouter 返回 400`"free is not a valid model ID"`
**原因:** `model_list` 条目中的 `model` 字段是发送给 API 的内容。对于 OpenRouter你必须使用**完整的**模型 ID而不是简写。
**原因:** PicoClaw 现在按两步解析 provider 和 model
- **错误:** `"model": "free"` → OpenRouter 收到 `free` 并拒绝。
- **正确:** `"model": "openrouter/free"` → OpenRouter 收到 `openrouter/free`(自动免费层路由)。
- 如果设置了 `provider`,则会把 `model` 原样发送给该 provider。
- 如果未设置 `provider`,则会把 `model` 第一个 `/` 之前的字段当作 provider并把第一个 `/` 之后的全部内容当作最终发送的模型 ID。
对于 OpenRouter 免费层路由,推荐显式设置 `provider`
- **错误:** `"model": "free"` → 不会选中 OpenRouter`free` 也不是可直接路由的 OpenRouter 模型配置。
- **正确:** `"provider": "openrouter", "model": "free"` → OpenRouter 收到 `free`
- **也兼容:** `"model": "openrouter/free"` → provider 解析为 `openrouter`,最终模型 ID 解析为 `free`
**修复方法:** 在 `~/.picoclaw/config.json`(或你的配置路径)中:
1. **agents.defaults.model_name** 必须匹配 `model_list` 中的某个 `model_name`(例如 `"openrouter-free"`)。
2. 该条目的 **model** 必须是有效的 OpenRouter 模型 ID例如
- `"openrouter/free"` 自动免费层
2. 该条目推荐显式设置 **provider**`openrouter`,并在 **model** 中填写有效的 OpenRouter 模型 ID例如
- `"free"` 自动免费层
- `"google/gemini-2.0-flash-exp:free"`
- `"meta-llama/llama-3.1-8b-instruct:free"`
@ -34,8 +40,9 @@
"model_list": [
{
"model_name": "openrouter-free",
"model": "openrouter/free",
"api_key": "sk-or-v1-YOUR_OPENROUTER_KEY",
"provider": "openrouter",
"model": "free",
"api_keys": ["sk-or-v1-YOUR_OPENROUTER_KEY"],
"api_base": "https://openrouter.ai/api/v1"
}
]

View file

@ -39,20 +39,23 @@ Set `rpm` on any model in `model_list`:
```yaml
model_list:
- model_name: gpt-4o-free
model: openai/gpt-4o
provider: openai
model: gpt-4o
api_base: https://api.openai.com/v1
rpm: 3 # max 3 requests per minute
api_keys:
- sk-...
- model_name: claude-haiku
model: anthropic/claude-haiku-4-5
provider: anthropic
model: claude-haiku-4-5
rpm: 60 # 60 rpm (Anthropic free tier)
api_keys:
- sk-ant-...
- model_name: local-llm
model: openai/llama3
provider: ollama
model: llama3
api_base: http://localhost:11434/v1
# no rpm → unrestricted
```
@ -68,7 +71,8 @@ When a model has fallbacks configured, each candidate is rate-limited **independ
```yaml
model_list:
- model_name: gpt4-with-fallback
model: openai/gpt-4o
provider: openai
model: gpt-4o
rpm: 5
fallbacks:
- gpt-4o-mini # must also be in model_list; its own rpm applies

View file

@ -105,6 +105,25 @@ func buildArtifactTags(store media.MediaStore, refs []string) []string {
return tags
}
func buildProviderAttachments(store media.MediaStore, refs []string) []providers.Attachment {
if store == nil || len(refs) == 0 {
return nil
}
attachments := make([]providers.Attachment, 0, len(refs))
for _, ref := range refs {
attachment := providers.Attachment{Ref: ref}
if _, meta, err := store.ResolveWithMeta(ref); err == nil {
attachment.Filename = meta.Filename
attachment.ContentType = meta.ContentType
attachment.Type = inferMediaType(meta.Filename, meta.ContentType)
}
attachments = append(attachments, attachment)
}
return attachments
}
// detectMIME determines the MIME type from metadata or magic-bytes detection.
// Returns empty string if detection fails.
func detectMIME(localPath string, meta media.MediaMeta) string {

View file

@ -129,7 +129,7 @@ func useTestSideQuestionProvider(al *AgentLoop, provider providers.LLMProvider)
al.providerFactory = func(mc *config.ModelConfig) (providers.LLMProvider, string, error) {
model := provider.GetDefaultModel()
if mc != nil {
if _, modelID := providers.ExtractProtocol(mc.Model); modelID != "" {
if _, modelID := providers.ExtractProtocol(mc); modelID != "" {
model = modelID
}
}
@ -1052,6 +1052,9 @@ func TestProcessMessage_MediaToolHandledSkipsFollowUpLLMAndFinalText(t *testing.
if last.Role != "assistant" || last.Content != "Requested output delivered via tool attachment." {
t.Fatalf("expected handled assistant summary in history, got %+v", last)
}
if len(last.Attachments) != 1 {
t.Fatalf("expected handled assistant summary attachments in history, got %+v", last.Attachments)
}
}
func TestProcessMessage_HandledToolProcessesQueuedSteeringBeforeReturning(t *testing.T) {

View file

@ -969,7 +969,11 @@ func TestAgentLoop_HookRespond_InterruptSkipsRemaining(t *testing.T) {
resultCh <- result{resp: resp, err: err}
}()
time.Sleep(50 * time.Millisecond)
select {
case <-tool1ExecCh:
case <-time.After(3 * time.Second):
t.Fatal("timeout waiting for tool execution to start")
}
if err := al.InterruptGraceful("stop now"); err != nil {
t.Fatalf("InterruptGraceful failed: %v", err)

View file

@ -270,8 +270,8 @@ func populateCandidateProvidersFromNames(
map[string]any{"name": name, "error": err.Error()})
continue
}
protocol, modelID := providers.ExtractProtocol(strings.TrimSpace(mc.Model))
key := providers.ModelKey(providers.NormalizeProvider(protocol), modelID)
protocol, modelID := providers.ExtractProtocol(mc)
key := providers.ModelKey(protocol, modelID)
if _, exists := out[key]; exists {
continue
}

View file

@ -104,6 +104,7 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
name string
aliasName string
modelName string
provider string
apiBase string
wantProvider string
wantModel string
@ -124,6 +125,15 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
wantProvider: "openai",
wantModel: "glm-5",
},
{
name: "explicit provider overrides model prefix",
aliasName: "nvidia-gpt",
modelName: "z-ai/glm-5.1",
provider: "nvidia",
apiBase: "https://integrate.api.nvidia.com/v1",
wantProvider: "nvidia",
wantModel: "z-ai/glm-5.1",
},
}
for _, tt := range tests {
@ -145,6 +155,7 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
{
ModelName: tt.aliasName,
Model: tt.modelName,
Provider: tt.provider,
APIBase: tt.apiBase,
},
},
@ -218,6 +229,43 @@ func TestNewAgentInstance_PreservesDistinctLimiterIdentityForSharedResolvedModel
}
}
func TestNewAgentInstance_PreservesConfigIdentityForExplicitProviderModelRef(t *testing.T) {
tmpDir := t.TempDir()
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
ModelName: "nvidia/z-ai/glm-5.1",
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "nvidia-glm",
Provider: "nvidia",
Model: "z-ai/glm-5.1",
RPM: 7,
},
},
}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{})
if len(agent.Candidates) != 1 {
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
}
candidate := agent.Candidates[0]
if candidate.Provider != "nvidia" || candidate.Model != "z-ai/glm-5.1" {
t.Fatalf("candidate = %s/%s, want nvidia/z-ai/glm-5.1", candidate.Provider, candidate.Model)
}
if candidate.IdentityKey != "model_name:nvidia-glm" {
t.Fatalf("identity key = %q, want %q", candidate.IdentityKey, "model_name:nvidia-glm")
}
if candidate.RPM != 7 {
t.Fatalf("RPM = %d, want 7", candidate.RPM)
}
}
func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) {
workspace := t.TempDir()
mediaDir := media.TempDir()

View file

@ -37,14 +37,14 @@ func candidateFromModelConfig(
return providers.FallbackCandidate{}, false
}
ref := providers.ParseModelRef(ensureProtocolModel(mc.Model), defaultProvider)
if ref == nil {
protocol, modelID := providers.ExtractProtocol(mc)
if strings.TrimSpace(modelID) == "" {
return providers.FallbackCandidate{}, false
}
return providers.FallbackCandidate{
Provider: ref.Provider,
Model: ref.Model,
Provider: protocol,
Model: modelID,
RPM: mc.RPM,
IdentityKey: modelConfigIdentityKey(mc),
}, true
@ -60,6 +60,12 @@ func lookupModelConfigByRef(cfg *config.Config, raw string) *config.ModelConfig
return mc
}
rawRef := providers.ParseModelRef(raw, "")
rawKey := ""
if rawRef != nil && strings.TrimSpace(rawRef.Provider) != "" && strings.TrimSpace(rawRef.Model) != "" {
rawKey = providers.ModelKey(rawRef.Provider, rawRef.Model)
}
for i := range cfg.ModelList {
mc := cfg.ModelList[i]
if mc == nil {
@ -72,10 +78,13 @@ func lookupModelConfigByRef(cfg *config.Config, raw string) *config.ModelConfig
if fullModel == raw {
return mc
}
_, modelID := providers.ExtractProtocol(fullModel)
protocol, modelID := providers.ExtractProtocol(mc)
if modelID == raw {
return mc
}
if rawKey != "" && providers.ModelKey(protocol, modelID) == rawKey {
return mc
}
}
return nil

View file

@ -33,6 +33,7 @@ func (p *Pipeline) ExecuteTools(
ts.setPhase(TurnPhaseTools)
messages := exec.messages
handledAttachments := make([]providers.Attachment, 0)
toolLoop:
for i, tc := range normalizedToolCalls {
@ -139,6 +140,11 @@ toolLoop:
})
hookResult.IsError = true
hookResult.ForLLM = fmt.Sprintf("failed to deliver attachment: %v", err)
} else {
handledAttachments = append(
handledAttachments,
buildProviderAttachments(al.mediaStore, hookResult.Media)...,
)
}
} else if al.bus != nil {
al.bus.PublishOutboundMedia(ctx, outboundMedia)
@ -498,6 +504,11 @@ toolLoop:
"error": err.Error(),
})
toolResult = tools.ErrorResult(fmt.Sprintf("failed to deliver attachment: %v", err)).WithError(err)
} else {
handledAttachments = append(
handledAttachments,
buildProviderAttachments(al.mediaStore, toolResult.Media)...,
)
}
} else if al.bus != nil {
al.bus.PublishOutboundMedia(ctx, outboundMedia)
@ -651,11 +662,12 @@ toolLoop:
// No pending steering: finalize or break depending on allResponsesHandled
if exec.allResponsesHandled {
summaryMsg := providers.Message{
Role: "assistant",
Content: handledToolResponseSummary,
Role: "assistant",
Content: handledToolResponseSummary,
Attachments: append([]providers.Attachment(nil), handledAttachments...),
}
if !ts.opts.NoHistory {
ts.agent.Sessions.AddMessage(ts.sessionKey, summaryMsg.Role, summaryMsg.Content)
ts.agent.Sessions.AddFullMessage(ts.sessionKey, summaryMsg)
ts.recordPersistedMessage(summaryMsg)
ts.ingestMessage(turnCtx, al, summaryMsg)
if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil {

View file

@ -19,16 +19,16 @@ type TranscriptionResponse struct {
Duration float64 `json:"duration,omitempty"`
}
func supportsAudioTranscription(model string) bool {
protocol, _ := providers.ExtractProtocol(model)
func supportsAudioTranscription(modelCfg *config.ModelConfig) bool {
protocol, _ := providers.ExtractProtocol(modelCfg)
switch protocol {
case "openai", "azure", "azure-openai",
"litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl",
"qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita",
"coding-plan", "alibaba-coding", "qwen-coding":
"coding-plan", "alibaba-coding", "qwen-coding", "zai":
// These protocols all go through the OpenAI-compatible or Azure provider path in
// providers.CreateProviderFromConfig, so they are the only ones that can supply
// the audio media payload shape expected by NewAudioModelTranscriber.
@ -41,15 +41,15 @@ func supportsAudioTranscription(model string) bool {
}
}
func supportsWhisperTranscription(model string) bool {
protocol, _ := providers.ExtractProtocol(model)
func supportsWhisperTranscription(modelCfg *config.ModelConfig) bool {
protocol, _ := providers.ExtractProtocol(modelCfg)
switch protocol {
case "openai", "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl",
"qwen-us", "dashscope-us", "mistral", "avian", "minimax", "longcat", "modelscope", "novita",
"coding-plan", "alibaba-coding", "qwen-coding", "mimo":
"coding-plan", "alibaba-coding", "qwen-coding", "zai", "mimo":
return true
default:
return false
@ -61,11 +61,11 @@ func whisperModelID(modelCfg *config.ModelConfig) string {
return ""
}
if !supportsWhisperTranscription(modelCfg.Model) {
if !supportsWhisperTranscription(modelCfg) {
return ""
}
_, modelID := providers.ExtractProtocol(strings.TrimSpace(modelCfg.Model))
_, modelID := providers.ExtractProtocol(modelCfg)
if strings.Contains(strings.ToLower(modelID), "whisper") {
return modelID
}
@ -77,14 +77,14 @@ func transcriberFromModelConfig(modelCfg *config.ModelConfig) Transcriber {
return nil
}
protocol, _ := providers.ExtractProtocol(modelCfg.Model)
protocol, _ := providers.ExtractProtocol(modelCfg)
if protocol == "elevenlabs" && modelCfg.APIKey() != "" {
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase)
}
if modelID := whisperModelID(modelCfg); modelID != "" {
return NewWhisperTranscriber(modelCfg)
}
if supportsAudioTranscription(modelCfg.Model) {
if supportsAudioTranscription(modelCfg) {
return NewAudioModelTranscriber(modelCfg)
}
return nil
@ -95,7 +95,7 @@ func fallbackTranscriberFromModelConfig(modelCfg *config.ModelConfig) Transcribe
return nil
}
protocol, _ := providers.ExtractProtocol(modelCfg.Model)
protocol, _ := providers.ExtractProtocol(modelCfg)
if protocol == "elevenlabs" && modelCfg.APIKey() != "" {
return NewElevenLabsTranscriber(modelCfg.APIKey(), modelCfg.APIBase)
}

View file

@ -32,7 +32,7 @@ func NewWhisperTranscriber(modelCfg *config.ModelConfig) *WhisperTranscriber {
return nil
}
protocol, modelID := providers.ExtractProtocol(modelCfg.Model)
protocol, modelID := providers.ExtractProtocol(modelCfg)
if modelID == "" {
modelID = strings.TrimSpace(modelCfg.Model)
}

View file

@ -24,7 +24,7 @@ func providerFromModelConfig(mc *config.ModelConfig) TTSProvider {
return nil
}
protocol, modelID := providers.ExtractProtocol(mc.Model)
protocol, modelID := providers.ExtractProtocol(mc)
if modelID == "" {
modelID = strings.TrimSpace(mc.Model)
}

View file

@ -5,7 +5,11 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"mime"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
@ -264,6 +268,10 @@ func (c *PicoChannel) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case "/ws", "/ws/":
c.handleWebSocket(w, r)
default:
if strings.HasPrefix(path, "/media/") {
c.handleMediaDownload(w, r)
return
}
http.NotFound(w, r)
}
}
@ -427,6 +435,206 @@ func (c *PicoChannel) SendPlaceholder(ctx context.Context, chatID string) (strin
return msgID, nil
}
// SendMedia implements channels.MediaSender for the Pico web UI.
// Media is delivered as a normal assistant message carrying structured
// attachments plus an authenticated same-origin download URL.
func (c *PicoChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
if !c.IsRunning() {
return nil, channels.ErrNotRunning
}
store := c.GetMediaStore()
if store == nil {
return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
}
attachments := make([]map[string]any, 0, len(msg.Parts))
caption := ""
for _, part := range msg.Parts {
localPath, meta, err := store.ResolveWithMeta(part.Ref)
if err != nil {
logger.ErrorCF("pico", "Failed to resolve media ref", map[string]any{
"ref": part.Ref,
"error": err.Error(),
})
continue
}
filename := strings.TrimSpace(part.Filename)
if filename == "" {
filename = strings.TrimSpace(meta.Filename)
}
if filename == "" {
filename = filepath.Base(localPath)
}
contentType := strings.TrimSpace(part.ContentType)
if contentType == "" {
contentType = strings.TrimSpace(meta.ContentType)
}
if contentType == "" {
contentType = "application/octet-stream"
}
attachmentType := strings.TrimSpace(part.Type)
if attachmentType == "" {
attachmentType = picoInferAttachmentType(filename, contentType)
}
attachmentURL, err := picoDownloadURLForRef(part.Ref)
if err != nil {
logger.ErrorCF("pico", "Failed to build media download URL", map[string]any{
"ref": part.Ref,
"error": err.Error(),
})
continue
}
attachments = append(attachments, map[string]any{
"type": attachmentType,
"url": attachmentURL,
"filename": filename,
"content_type": contentType,
})
if caption == "" && strings.TrimSpace(part.Caption) != "" {
caption = strings.TrimSpace(part.Caption)
}
}
if len(attachments) == 0 {
return nil, fmt.Errorf("no deliverable media parts: %w", channels.ErrSendFailed)
}
msgID := uuid.New().String()
outMsg := newMessage(TypeMessageCreate, map[string]any{
PayloadKeyContent: caption,
"attachments": attachments,
"message_id": msgID,
})
if err := c.broadcastToSession(msg.ChatID, outMsg); err != nil {
return nil, err
}
return []string{msgID}, nil
}
func picoDownloadURLForRef(ref string) (string, error) {
refID, err := picoMediaRefID(ref)
if err != nil {
return "", err
}
return "/pico/media/" + url.PathEscape(refID), nil
}
func picoMediaRefID(ref string) (string, error) {
refID := strings.TrimSpace(strings.TrimPrefix(ref, "media://"))
if refID == "" || strings.Contains(refID, "/") {
return "", fmt.Errorf("invalid media ref %q", ref)
}
return refID, nil
}
func picoInferAttachmentType(filename, contentType string) string {
contentType = strings.ToLower(strings.TrimSpace(contentType))
filename = strings.ToLower(strings.TrimSpace(filename))
switch {
case strings.HasPrefix(contentType, "image/"):
return "image"
case strings.HasPrefix(contentType, "audio/"):
return "audio"
case strings.HasPrefix(contentType, "video/"):
return "video"
}
switch ext := filepath.Ext(filename); ext {
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg":
return "image"
case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus":
return "audio"
case ".mp4", ".avi", ".mov", ".webm", ".mkv":
return "video"
default:
return "file"
}
}
func picoAllowsInlineDisplay(filename, contentType string) bool {
contentType = strings.ToLower(strings.TrimSpace(contentType))
filename = strings.ToLower(strings.TrimSpace(filename))
if strings.Contains(contentType, "svg") || filepath.Ext(filename) == ".svg" {
return false
}
return picoInferAttachmentType(filename, contentType) == "image"
}
func (c *PicoChannel) handleMediaDownload(w http.ResponseWriter, r *http.Request) {
if !c.IsRunning() {
http.Error(w, "channel not running", http.StatusServiceUnavailable)
return
}
if !c.authenticate(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
refID := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(r.URL.Path, "/pico/media/"), "/"))
if refID == "" {
http.NotFound(w, r)
return
}
store := c.GetMediaStore()
if store == nil {
http.Error(w, "media store unavailable", http.StatusServiceUnavailable)
return
}
localPath, meta, err := store.ResolveWithMeta("media://" + refID)
if err != nil {
http.NotFound(w, r)
return
}
file, err := os.Open(localPath)
if err != nil {
http.Error(w, "failed to open media", http.StatusInternalServerError)
return
}
defer file.Close()
info, err := file.Stat()
if err != nil {
http.Error(w, "failed to stat media", http.StatusInternalServerError)
return
}
filename := strings.TrimSpace(meta.Filename)
if filename == "" {
filename = filepath.Base(localPath)
}
contentType := strings.TrimSpace(meta.ContentType)
if contentType == "" {
contentType = "application/octet-stream"
}
dispositionType := "attachment"
if picoAllowsInlineDisplay(filename, contentType) {
dispositionType = "inline"
}
if cd := mime.FormatMediaType(dispositionType, map[string]string{"filename": filename}); cd != "" {
w.Header().Set("Content-Disposition", cd)
}
w.Header().Set("Content-Type", contentType)
http.ServeContent(w, r, filename, info.ModTime(), file)
}
// broadcastToSession sends a message to all connections with a matching session.
func (c *PicoChannel) broadcastToSession(chatID string, msg PicoMessage) error {
// chatID format: "pico:<sessionID>"

View file

@ -4,12 +4,17 @@ import (
"context"
"errors"
"fmt"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/media"
)
func newTestPicoChannel(t *testing.T) *PicoChannel {
@ -151,6 +156,98 @@ func TestBroadcastToSession_TargetsOnlyRequestedSession(t *testing.T) {
}
}
func TestSendMedia_ResolvesMediaBeforeDelivery(t *testing.T) {
ch := newTestPicoChannel(t)
store := media.NewFileMediaStore()
ch.SetMediaStore(store)
if err := ch.Start(context.Background()); err != nil {
t.Fatalf("Start() error = %v", err)
}
defer ch.Stop(context.Background())
localPath := filepath.Join(t.TempDir(), "report.txt")
if err := os.WriteFile(localPath, []byte("attachment body"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
ref, err := store.Store(localPath, media.MediaMeta{
Filename: "report.txt",
ContentType: "text/plain",
}, "test-scope")
if err != nil {
t.Fatalf("Store() error = %v", err)
}
closedConn := &picoConn{id: "closed", sessionID: "sess-1"}
closedConn.closed.Store(true)
ch.addConnForTest(closedConn)
_, err = ch.SendMedia(context.Background(), bus.OutboundMediaMessage{
ChatID: "pico:sess-1",
Parts: []bus.MediaPart{{
Ref: ref,
Type: "file",
Filename: "report.txt",
ContentType: "text/plain",
}},
})
if !errors.Is(err, channels.ErrSendFailed) {
t.Fatalf("SendMedia() error = %v, want ErrSendFailed", err)
}
}
func TestPicoDownloadURLForRef(t *testing.T) {
got, err := picoDownloadURLForRef("media://attachment-1")
if err != nil {
t.Fatalf("picoDownloadURLForRef() error = %v", err)
}
if got != "/pico/media/attachment-1" {
t.Fatalf("picoDownloadURLForRef() = %q, want %q", got, "/pico/media/attachment-1")
}
}
func TestHandleMediaDownload_ServesStoredFile(t *testing.T) {
ch := newTestPicoChannel(t)
store := media.NewFileMediaStore()
ch.SetMediaStore(store)
if err := ch.Start(context.Background()); err != nil {
t.Fatalf("Start() error = %v", err)
}
defer ch.Stop(context.Background())
localPath := filepath.Join(t.TempDir(), "report.txt")
if err := os.WriteFile(localPath, []byte("downloadable"), 0o600); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
ref, err := store.Store(localPath, media.MediaMeta{
Filename: "report.txt",
ContentType: "text/plain",
}, "test-scope")
if err != nil {
t.Fatalf("Store() error = %v", err)
}
refID := strings.TrimPrefix(ref, "media://")
req := httptest.NewRequest("GET", "/pico/media/"+refID, nil)
req.Header.Set("Authorization", "Bearer test-token")
rec := httptest.NewRecorder()
ch.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("status = %d, want 200", rec.Code)
}
if body := rec.Body.String(); body != "downloadable" {
t.Fatalf("body = %q, want %q", body, "downloadable")
}
if got := rec.Header().Get("Content-Type"); got != "text/plain" {
t.Fatalf("Content-Type = %q, want %q", got, "text/plain")
}
}
func (c *PicoChannel) addConnForTest(pc *picoConn) {
c.connsMu.Lock()
defer c.connsMu.Unlock()

View file

@ -523,15 +523,16 @@ type VoiceConfig struct {
// ModelConfig represents a model-centric provider configuration.
// It allows adding new providers (especially OpenAI-compatible ones) via configuration only.
// The model field uses protocol prefix format: [protocol/]model-identifier
// Supported protocols include openai, anthropic, antigravity, claude-cli,
// The Model field may be either a plain model identifier or a provider-prefixed
// identifier such as "openai/gpt-5.4" or "nvidia/z-ai/glm-5.1".
// Supported providers include openai, anthropic, antigravity, claude-cli,
// codex-cli, github-copilot, and named OpenAI-compatible protocols such as
// groq, deepseek, modelscope, and novita.
// Default protocol is "openai" if no prefix is specified.
type ModelConfig struct {
// Required fields
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")
Provider string `json:"provider"` // Provider name for routing and selection. When empty, provider resolution infers it from Model.
Model string `json:"model"` // Model identifier, optionally provider-prefixed.
// HTTP-based providers
APIBase string `json:"api_base,omitempty"` // API endpoint URL
@ -1411,6 +1412,7 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig {
// Create a copy for the additional key
additionalEntry := &ModelConfig{
ModelName: expandedName,
Provider: m.Provider,
Model: m.Model,
APIBase: m.APIBase,
APIKeys: SimpleSecureStrings(keys[i]),
@ -1434,6 +1436,7 @@ func expandMultiKeyModels(models []*ModelConfig) []*ModelConfig {
// Create the primary entry with first key and fallbacks
primaryEntry := &ModelConfig{
ModelName: originalName,
Provider: m.Provider,
Model: m.Model,
APIBase: m.APIBase,
Proxy: m.Proxy,

View file

@ -1944,7 +1944,7 @@ func TestDefaultConfig_MinimaxExtraBody(t *testing.T) {
var minimaxCfg *ModelConfig
for i := range cfg.ModelList {
if cfg.ModelList[i].Model == "minimax/MiniMax-M2.5" {
if cfg.ModelList[i].Provider == "minimax" && cfg.ModelList[i].Model == "MiniMax-M2.5" {
minimaxCfg = cfg.ModelList[i]
break
}

View file

@ -61,129 +61,148 @@ func DefaultConfig() *Config {
// Zhipu AI (智谱) - https://open.bigmodel.cn/usercenter/apikeys
{
ModelName: "glm-4.7",
Model: "zhipu/glm-4.7",
Provider: "zhipu",
Model: "glm-4.7",
APIBase: "https://open.bigmodel.cn/api/paas/v4",
},
// OpenAI - https://platform.openai.com/api-keys
{
ModelName: "gpt-5.4",
Model: "openai/gpt-5.4",
Provider: "openai",
Model: "gpt-5.4",
APIBase: "https://api.openai.com/v1",
},
// Anthropic Claude - https://console.anthropic.com/settings/keys
{
ModelName: "claude-sonnet-4.6",
Model: "anthropic/claude-sonnet-4.6",
Provider: "anthropic",
Model: "claude-sonnet-4.6",
APIBase: "https://api.anthropic.com/v1",
},
// DeepSeek - https://platform.deepseek.com/
{
ModelName: "deepseek-chat",
Model: "deepseek/deepseek-chat",
Provider: "deepseek",
Model: "deepseek-chat",
APIBase: "https://api.deepseek.com/v1",
},
// Venice AI - https://venice.ai
{
ModelName: "venice-uncensored",
Model: "venice/venice-uncensored",
Provider: "venice",
Model: "venice-uncensored",
APIBase: "https://api.venice.ai/api/v1",
},
// Google Gemini - https://ai.google.dev/
{
ModelName: "gemini-2.0-flash",
Model: "gemini/gemini-2.0-flash-exp",
Provider: "gemini",
Model: "gemini-2.0-flash-exp",
APIBase: "https://generativelanguage.googleapis.com/v1beta",
},
// Qwen (通义千问) - https://dashscope.console.aliyun.com/apiKey
{
ModelName: "qwen-plus",
Model: "qwen/qwen-plus",
Provider: "qwen",
Model: "qwen-plus",
APIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
},
// Moonshot (月之暗面) - https://platform.moonshot.cn/console/api-keys
{
ModelName: "moonshot-v1-8k",
Model: "moonshot/moonshot-v1-8k",
Provider: "moonshot",
Model: "moonshot-v1-8k",
APIBase: "https://api.moonshot.cn/v1",
},
// Groq - https://console.groq.com/keys
{
ModelName: "llama-3.3-70b",
Model: "groq/llama-3.3-70b-versatile",
Provider: "groq",
Model: "llama-3.3-70b-versatile",
APIBase: "https://api.groq.com/openai/v1",
},
// OpenRouter (100+ models) - https://openrouter.ai/keys
{
ModelName: "openrouter-auto",
Model: "openrouter/auto",
Provider: "openrouter",
Model: "auto",
APIBase: "https://openrouter.ai/api/v1",
},
{
ModelName: "openrouter-gpt-5.4",
Model: "openrouter/openai/gpt-5.4",
Provider: "openrouter",
Model: "openai/gpt-5.4",
APIBase: "https://openrouter.ai/api/v1",
},
// NVIDIA - https://build.nvidia.com/
{
ModelName: "nemotron-4-340b",
Model: "nvidia/nemotron-4-340b-instruct",
Provider: "nvidia",
Model: "nemotron-4-340b-instruct",
APIBase: "https://integrate.api.nvidia.com/v1",
},
// Cerebras - https://inference.cerebras.ai/
{
ModelName: "cerebras-llama-3.3-70b",
Model: "cerebras/llama-3.3-70b",
Provider: "cerebras",
Model: "llama-3.3-70b",
APIBase: "https://api.cerebras.ai/v1",
},
// Vivgrid - https://vivgrid.com
{
ModelName: "vivgrid-auto",
Model: "vivgrid/auto",
Provider: "vivgrid",
Model: "auto",
APIBase: "https://api.vivgrid.com/v1",
},
// Volcengine (火山引擎) - https://console.volcengine.com/ark
{
ModelName: "ark-code-latest",
Model: "volcengine/ark-code-latest",
Provider: "volcengine",
Model: "ark-code-latest",
APIBase: "https://ark.cn-beijing.volces.com/api/v3",
},
{
ModelName: "doubao-pro",
Model: "volcengine/doubao-pro-32k",
Provider: "volcengine",
Model: "doubao-pro-32k",
APIBase: "https://ark.cn-beijing.volces.com/api/v3",
},
// ShengsuanYun (神算云)
{
ModelName: "deepseek-v3",
Model: "shengsuanyun/deepseek-v3",
Provider: "shengsuanyun",
Model: "deepseek-v3",
APIBase: "https://api.shengsuanyun.com/v1",
},
// Antigravity (Google Cloud Code Assist) - OAuth only
{
ModelName: "gemini-flash",
Model: "antigravity/gemini-3-flash",
Provider: "antigravity",
Model: "gemini-3-flash",
AuthMethod: "oauth",
},
// GitHub Copilot - https://github.com/settings/tokens
{
ModelName: "copilot-gpt-5.4",
Model: "github-copilot/gpt-5.4",
Provider: "github-copilot",
Model: "gpt-5.4",
APIBase: "http://localhost:4321",
AuthMethod: "oauth",
},
@ -191,33 +210,38 @@ func DefaultConfig() *Config {
// Ollama (local) - https://ollama.com
{
ModelName: "llama3",
Model: "ollama/llama3",
Provider: "ollama",
Model: "llama3",
APIBase: "http://localhost:11434/v1",
},
// Mistral AI - https://console.mistral.ai/api-keys
{
ModelName: "mistral-small",
Model: "mistral/mistral-small-latest",
Provider: "mistral",
Model: "mistral-small-latest",
APIBase: "https://api.mistral.ai/v1",
},
// Avian - https://avian.io
{
ModelName: "deepseek-v3.2",
Model: "avian/deepseek/deepseek-v3.2",
Provider: "avian",
Model: "deepseek/deepseek-v3.2",
APIBase: "https://api.avian.io/v1",
},
{
ModelName: "kimi-k2.5",
Model: "avian/moonshotai/kimi-k2.5",
Provider: "avian",
Model: "moonshotai/kimi-k2.5",
APIBase: "https://api.avian.io/v1",
},
// Minimax - https://api.minimaxi.com/
{
ModelName: "MiniMax-M2.5",
Model: "minimax/MiniMax-M2.5",
Provider: "minimax",
Model: "MiniMax-M2.5",
APIBase: "https://api.minimaxi.com/v1",
ExtraBody: map[string]any{"reasoning_split": true},
},
@ -225,28 +249,32 @@ func DefaultConfig() *Config {
// LongCat - https://longcat.chat/platform
{
ModelName: "LongCat-Flash-Thinking",
Model: "longcat/LongCat-Flash-Thinking",
Provider: "longcat",
Model: "LongCat-Flash-Thinking",
APIBase: "https://api.longcat.chat/openai",
},
// ModelScope (魔搭社区) - https://modelscope.cn/my/tokens
{
ModelName: "modelscope-qwen",
Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
Provider: "modelscope",
Model: "Qwen/Qwen3-235B-A22B-Instruct-2507",
APIBase: "https://api-inference.modelscope.cn/v1",
},
// VLLM (local) - http://localhost:8000
{
ModelName: "local-model",
Model: "vllm/custom-model",
Provider: "vllm",
Model: "custom-model",
APIBase: "http://localhost:8000/v1",
},
// LM Studio (local) - http://localhost:1234
{
ModelName: "lmstudio-local",
Model: "lmstudio/openai/gpt-oss-20b",
Provider: "lmstudio",
Model: "openai/gpt-oss-20b",
APIBase: "http://localhost:1234/v1",
},
@ -254,7 +282,8 @@ func DefaultConfig() *Config {
// model_name is a user-friendly alias; the model field's path after "azure/" is your deployment name
{
ModelName: "azure-gpt5",
Model: "azure/my-gpt5-deployment",
Provider: "azure",
Model: "my-gpt5-deployment",
APIBase: "https://your-resource.openai.azure.com",
},
},

View file

@ -188,6 +188,7 @@ func TestExpandMultiKeyModels_Deduplication(t *testing.T) {
func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) {
modelCfg := &ModelConfig{
ModelName: "gpt-4",
Provider: "openrouter",
Model: "openai/gpt-4o",
APIBase: "https://api.example.com",
Proxy: "http://proxy:8080",
@ -206,6 +207,9 @@ func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) {
if primary.APIBase != "https://api.example.com" {
t.Errorf("expected api_base preserved, got %q", primary.APIBase)
}
if primary.Provider != "openrouter" {
t.Errorf("expected provider preserved, got %q", primary.Provider)
}
if primary.Proxy != "http://proxy:8080" {
t.Errorf("expected proxy preserved, got %q", primary.Proxy)
}
@ -224,6 +228,9 @@ func TestExpandMultiKeyModels_PreservesOtherFields(t *testing.T) {
// Check additional entry also preserves fields
additional := result[0]
if additional.Provider != "openrouter" {
t.Errorf("expected additional provider preserved, got %q", additional.Provider)
}
if additional.APIBase != "https://api.example.com" {
t.Errorf("expected additional api_base preserved, got %q", additional.APIBase)
}

View file

@ -41,6 +41,7 @@ var protocolMetaByName = map[string]protocolMeta{
"vivgrid": {defaultAPIBase: "https://api.vivgrid.com/v1"},
"volcengine": {defaultAPIBase: "https://ark.cn-beijing.volces.com/api/v3"},
"qwen": {defaultAPIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1"},
"qwen-portal": {defaultAPIBase: "https://dashscope.aliyuncs.com/compatible-mode/v1"},
"qwen-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"},
"qwen-international": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"},
"dashscope-intl": {defaultAPIBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"},
@ -51,6 +52,7 @@ var protocolMetaByName = map[string]protocolMeta{
"qwen-coding": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/v1"},
"coding-plan-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"},
"alibaba-coding-anthropic": {defaultAPIBase: "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic"},
"zai": {defaultAPIBase: "https://api.z.ai/api/coding/paas/v4"},
"vllm": {defaultAPIBase: "http://localhost:8000/v1", emptyAPIKeyAllowed: true},
"mistral": {defaultAPIBase: "https://api.mistral.ai/v1"},
"avian": {defaultAPIBase: "https://api.avian.io/v1"},
@ -84,19 +86,43 @@ func createCodexAuthProvider() (LLMProvider, error) {
return NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil
}
// ExtractProtocol extracts the protocol prefix and model identifier from a model string.
// If no prefix is specified, it defaults to "openai".
// ExtractProtocol extracts the effective protocol and model identifier from a
// model configuration.
//
// The explicit Provider field takes precedence. When Provider is empty, the
// protocol is inferred from Model. Plain model names default to "openai".
// Provider-prefixed models strip the first slash-separated segment from the
// returned model ID.
//
// The returned protocol is normalized to the provider's canonical spelling.
// Examples:
// - "openai/gpt-4o" -> ("openai", "gpt-4o")
// - "anthropic/claude-sonnet-4.6" -> ("anthropic", "claude-sonnet-4.6")
// - "gpt-4o" -> ("openai", "gpt-4o") // default protocol
func ExtractProtocol(model string) (protocol, modelID string) {
model = strings.TrimSpace(model)
protocol, modelID, found := strings.Cut(model, "/")
// - Model "openai/gpt-4o" -> ("openai", "gpt-4o")
// - Model "nvidia/z-ai/glm-5.1" -> ("nvidia", "z-ai/glm-5.1")
// - Provider "nvidia", Model "z-ai/glm-5.1" -> ("nvidia", "z-ai/glm-5.1")
// - Provider "openai", Model "openai/gpt-4o" -> ("openai", "openai/gpt-4o")
// - Model "gpt-4o" -> ("openai", "gpt-4o")
func ExtractProtocol(cfg *config.ModelConfig) (protocol, modelID string) {
if cfg == nil {
return "", ""
}
model := strings.TrimSpace(cfg.Model)
if provider := strings.TrimSpace(cfg.Provider); provider != "" {
return NormalizeProvider(provider), model
}
if model == "" {
return "", ""
}
protocol, rest, found := strings.Cut(model, "/")
if !found {
return "openai", model
}
return protocol, modelID
protocol = strings.TrimSpace(protocol)
if protocol == "" {
return "", strings.TrimSpace(rest)
}
return NormalizeProvider(protocol), strings.TrimSpace(rest)
}
// ResolveAPIBase returns the configured API base, or the protocol default when
@ -108,16 +134,16 @@ func ResolveAPIBase(cfg *config.ModelConfig) string {
if apiBase := strings.TrimSpace(cfg.APIBase); apiBase != "" {
return strings.TrimRight(apiBase, "/")
}
protocol, _ := ExtractProtocol(cfg.Model)
protocol, _ := ExtractProtocol(cfg)
return strings.TrimRight(getDefaultAPIBase(protocol), "/")
}
// CreateProviderFromConfig creates a provider based on the ModelConfig.
// It uses the protocol prefix in the Model field to determine which provider to create.
// It uses ExtractProtocol to determine which provider to create.
// Supported protocol families include OpenAI-compatible prefixes (e.g., openai, openrouter, groq),
// Azure OpenAI, Amazon Bedrock, Anthropic (including messages), and various CLI/compatibility shims.
// See the switch on protocol in this function for the authoritative list.
// Returns the provider, the model ID (without protocol prefix), and any error.
// Returns the provider, the effective model ID from ExtractProtocol, and any error.
func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) {
if cfg == nil {
return nil, "", fmt.Errorf("config is nil")
@ -127,7 +153,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
return nil, "", fmt.Errorf("model is required")
}
protocol, modelID := ExtractProtocol(cfg.Model)
protocol, modelID := ExtractProtocol(cfg)
userAgent := cfg.UserAgent
if userAgent == "" {
@ -220,9 +246,9 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "nvidia", "venice",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-portal", "qwen-intl", "qwen-international", "dashscope-intl",
"qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita",
"coding-plan", "alibaba-coding", "qwen-coding", "mimo":
"coding-plan", "alibaba-coding", "qwen-coding", "zai", "mimo":
// All other OpenAI-compatible HTTP providers
if cfg.APIKey() == "" && cfg.APIBase == "" && !isEmptyAPIKeyAllowed(protocol) {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)

View file

@ -19,68 +19,103 @@ import (
func TestExtractProtocol(t *testing.T) {
tests := []struct {
name string
model string
config *config.ModelConfig
wantProtocol string
wantModelID string
}{
{
name: "openai with prefix",
model: "openai/gpt-4o",
config: &config.ModelConfig{Model: "openai/gpt-4o"},
wantProtocol: "openai",
wantModelID: "gpt-4o",
},
{
name: "anthropic with prefix",
model: "anthropic/claude-sonnet-4.6",
config: &config.ModelConfig{Model: "anthropic/claude-sonnet-4.6"},
wantProtocol: "anthropic",
wantModelID: "claude-sonnet-4.6",
},
{
name: "no prefix - defaults to openai",
model: "gpt-4o",
config: &config.ModelConfig{Model: "gpt-4o"},
wantProtocol: "openai",
wantModelID: "gpt-4o",
},
{
name: "groq with prefix",
model: "groq/llama-3.1-70b",
config: &config.ModelConfig{Model: "groq/llama-3.1-70b"},
wantProtocol: "groq",
wantModelID: "llama-3.1-70b",
},
{
name: "empty string",
model: "",
wantProtocol: "openai",
config: &config.ModelConfig{Model: ""},
wantProtocol: "",
wantModelID: "",
},
{
name: "with whitespace",
model: " openai/gpt-4 ",
config: &config.ModelConfig{Model: " openai/gpt-4 "},
wantProtocol: "openai",
wantModelID: "gpt-4",
},
{
name: "multiple slashes",
model: "nvidia/meta/llama-3.1-8b",
config: &config.ModelConfig{Model: "nvidia/meta/llama-3.1-8b"},
wantProtocol: "nvidia",
wantModelID: "meta/llama-3.1-8b",
},
{
name: "normalizes provider",
config: &config.ModelConfig{Model: "z.ai/glm-5.1"},
wantProtocol: "zai",
wantModelID: "glm-5.1",
},
{
name: "azure with prefix",
model: "azure/my-gpt5-deployment",
config: &config.ModelConfig{Model: "azure/my-gpt5-deployment"},
wantProtocol: "azure",
wantModelID: "my-gpt5-deployment",
},
{
name: "explicit provider keeps model",
config: &config.ModelConfig{Provider: "nvidia", Model: "z-ai/glm-5.1"},
wantProtocol: "nvidia",
wantModelID: "z-ai/glm-5.1",
},
{
name: "explicit provider preserves matching prefix",
config: &config.ModelConfig{Provider: "openai", Model: "openai/gpt-4o"},
wantProtocol: "openai",
wantModelID: "openai/gpt-4o",
},
{
name: "explicit provider preserves aliased prefix",
config: &config.ModelConfig{Provider: "qwen", Model: "qwen/qwen-plus"},
wantProtocol: "qwen-portal",
wantModelID: "qwen/qwen-plus",
},
{
name: "empty provider segment",
config: &config.ModelConfig{Model: "/gpt-4o"},
wantProtocol: "",
wantModelID: "gpt-4o",
},
{
name: "nil config",
wantProtocol: "",
wantModelID: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
protocol, modelID := ExtractProtocol(tt.model)
protocol, modelID := ExtractProtocol(tt.config)
if protocol != tt.wantProtocol {
t.Errorf("ExtractProtocol(%q) protocol = %q, want %q", tt.model, protocol, tt.wantProtocol)
t.Errorf("ExtractProtocol() protocol = %q, want %q", protocol, tt.wantProtocol)
}
if modelID != tt.wantModelID {
t.Errorf("ExtractProtocol(%q) modelID = %q, want %q", tt.model, modelID, tt.wantModelID)
t.Errorf("ExtractProtocol() modelID = %q, want %q", modelID, tt.wantModelID)
}
})
}
@ -106,6 +141,50 @@ func TestCreateProviderFromConfig_OpenAI(t *testing.T) {
}
}
func TestCreateProviderFromConfig_UsesExplicitProvider(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-explicit-provider",
Model: "z-ai/glm-5.1",
Provider: "nvidia",
}
cfg.SetAPIKey("test-key")
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
t.Fatalf("CreateProviderFromConfig() error = %v", err)
}
if provider == nil {
t.Fatal("CreateProviderFromConfig() returned nil provider")
}
if modelID != "z-ai/glm-5.1" {
t.Fatalf("modelID = %q, want z-ai/glm-5.1", modelID)
}
if got := ResolveAPIBase(cfg); got != "https://integrate.api.nvidia.com/v1" {
t.Fatalf("ResolveAPIBase() = %q, want NVIDIA default API base", got)
}
}
func TestCreateProviderFromConfig_PreservesExplicitProviderPrefixedModel(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-openai",
Provider: "openai",
Model: "openai/gpt-4o",
APIBase: "https://api.example.com/v1",
}
cfg.SetAPIKey("test-key")
provider, modelID, err := CreateProviderFromConfig(cfg)
if err != nil {
t.Fatalf("CreateProviderFromConfig() error = %v", err)
}
if provider == nil {
t.Fatal("CreateProviderFromConfig() returned nil provider")
}
if modelID != "openai/gpt-4o" {
t.Fatalf("modelID = %q, want %q", modelID, "openai/gpt-4o")
}
}
func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
tests := []struct {
name string
@ -701,8 +780,9 @@ func TestCreateProviderFromConfig_QwenInternationalAlias(t *testing.T) {
if provider == nil {
t.Fatal("CreateProviderFromConfig() returned nil provider")
}
if modelID != "qwen-max" {
t.Errorf("modelID = %q, want %q", modelID, "qwen-max")
wantModelID := "qwen-max"
if modelID != wantModelID {
t.Errorf("modelID = %q, want %q", modelID, wantModelID)
}
if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("expected *HTTPProvider, got %T", provider)
@ -735,8 +815,9 @@ func TestCreateProviderFromConfig_QwenUSAlias(t *testing.T) {
if provider == nil {
t.Fatal("CreateProviderFromConfig() returned nil provider")
}
if modelID != "qwen-max" {
t.Errorf("modelID = %q, want %q", modelID, "qwen-max")
wantModelID := "qwen-max"
if modelID != wantModelID {
t.Errorf("modelID = %q, want %q", modelID, wantModelID)
}
if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("expected *HTTPProvider, got %T", provider)
@ -769,8 +850,9 @@ func TestCreateProviderFromConfig_CodingPlanAnthropic(t *testing.T) {
if provider == nil {
t.Fatal("CreateProviderFromConfig() returned nil provider")
}
if modelID != "claude-sonnet-4-20250514" {
t.Errorf("modelID = %q, want %q", modelID, "claude-sonnet-4-20250514")
wantModelID := "claude-sonnet-4-20250514"
if modelID != wantModelID {
t.Errorf("modelID = %q, want %q", modelID, wantModelID)
}
// coding-plan-anthropic uses Anthropic Messages provider
// Verify it's the anthropic messages provider by checking interface

View file

@ -63,10 +63,19 @@ type ContentBlock struct {
CacheControl *CacheControl `json:"cache_control,omitempty"`
}
type Attachment struct {
Type string `json:"type,omitempty"`
Ref string `json:"ref,omitempty"`
URL string `json:"url,omitempty"`
Filename string `json:"filename,omitempty"`
ContentType string `json:"content_type,omitempty"`
}
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
Media []string `json:"media,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
SystemParts []ContentBlock `json:"system_parts,omitempty"` // structured system blocks for cache-aware adapters
ToolCalls []ToolCall `json:"tool_calls,omitempty"`

View file

@ -19,6 +19,7 @@ type (
GoogleExtra = protocoltypes.GoogleExtra
ContentBlock = protocoltypes.ContentBlock
CacheControl = protocoltypes.CacheControl
Attachment = protocoltypes.Attachment
)
type LLMProvider interface {

View file

@ -87,7 +87,7 @@ func hasModelConfiguration(m *config.ModelConfig) bool {
apiKey := strings.TrimSpace(m.APIKey())
if authMethod == "oauth" || authMethod == "token" {
if provider, ok := oauthProviderForModel(m.Model); ok {
if provider, ok := oauthProviderForModel(m); ok {
cred, err := oauthGetCredential(provider)
if err != nil || cred == nil {
return false
@ -123,7 +123,7 @@ func requiresRuntimeProbe(m *config.ModelConfig) bool {
return true
}
protocol := modelProtocol(m.Model)
protocol := modelProtocol(m)
switch protocol {
case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot":
@ -172,7 +172,7 @@ func (s *modelProbeCacheState) probe(cacheKey string, probeFunc func() bool) boo
func runLocalModelProbe(m *config.ModelConfig) bool {
apiBase := modelProbeAPIBase(m)
protocol, modelID := splitModel(m.Model)
protocol, modelID := splitModel(m)
switch protocol {
case "ollama":
return probeOllamaModelFunc(apiBase, modelID)
@ -191,7 +191,7 @@ func runLocalModelProbe(m *config.ModelConfig) bool {
}
func modelProbeCacheKey(m *config.ModelConfig) string {
protocol, modelID := splitModel(m.Model)
protocol, modelID := splitModel(m)
apiBaseRaw := modelProbeAPIBase(m)
apiBase := strings.ToLower(strings.TrimRight(strings.TrimSpace(apiBaseRaw), "/"))
@ -384,7 +384,7 @@ func modelProbeAPIBase(m *config.ModelConfig) string {
return normalizeModelProbeAPIBase(apiBase)
}
protocol := modelProtocol(m.Model)
protocol := modelProtocol(m)
if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) {
return providers.DefaultAPIBaseForProtocol(protocol)
}
@ -419,8 +419,8 @@ func normalizeModelProbeAPIBase(raw string) string {
return u.String()
}
func oauthProviderForModel(model string) (string, bool) {
switch modelProtocol(model) {
func oauthProviderForModel(m *config.ModelConfig) (string, bool) {
switch modelProtocol(m) {
case "openai":
return oauthProviderOpenAI, true
case "anthropic":
@ -432,18 +432,14 @@ func oauthProviderForModel(model string) (string, bool) {
}
}
func modelProtocol(model string) string {
protocol, _ := splitModel(model)
func modelProtocol(m *config.ModelConfig) string {
protocol, _ := splitModel(m)
return protocol
}
func splitModel(model string) (protocol, modelID string) {
model = strings.ToLower(strings.TrimSpace(model))
protocol, _, found := strings.Cut(model, "/")
if !found {
return "openai", model
}
return protocol, strings.TrimSpace(model[strings.Index(model, "/")+1:])
func splitModel(m *config.ModelConfig) (protocol, modelID string) {
protocol, modelID = providers.ExtractProtocol(m)
return strings.ToLower(strings.TrimSpace(protocol)), strings.ToLower(strings.TrimSpace(modelID))
}
func hasLocalAPIBase(raw string) bool {

View file

@ -6,10 +6,12 @@ import (
"io"
"net/http"
"strconv"
"strings"
"sync"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
)
// registerModelRoutes binds model list management endpoints to the ServeMux.
@ -26,6 +28,7 @@ func (h *Handler) registerModelRoutes(mux *http.ServeMux) {
type modelResponse struct {
Index int `json:"index"`
ModelName string `json:"model_name"`
Provider string `json:"provider,omitempty"`
Model string `json:"model"`
APIBase string `json:"api_base,omitempty"`
APIKey string `json:"api_key"`
@ -73,10 +76,12 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
models := make([]modelResponse, 0, len(cfg.ModelList))
for i, m := range cfg.ModelList {
provider, modelID := providers.ExtractProtocol(m)
models = append(models, modelResponse{
Index: i,
ModelName: m.ModelName,
Model: m.Model,
Provider: provider,
Model: modelID,
APIBase: m.APIBase,
APIKey: maskAPIKey(m.APIKey()),
Proxy: m.Proxy,
@ -176,6 +181,12 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
}
defer r.Body.Close()
var rawFields map[string]json.RawMessage
if err = json.Unmarshal(body, &rawFields); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
type custom struct {
config.ModelConfig
APIKey string `json:"api_key"`
@ -226,6 +237,35 @@ func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) {
} else if len(mc.CustomHeaders) == 0 {
mc.CustomHeaders = nil
}
// Preserve the existing Provider when the caller omits it. This keeps the
// update API backward-compatible for clients that haven't started sending
// the new field yet, while still allowing explicit clearing via "".
if _, ok := rawFields["provider"]; !ok {
mc.Provider = cfg.ModelList[idx].Provider
// Older clients still round-trip the legacy model field only. When the
// stored config encodes provider/model in Model and has no explicit
// Provider field yet, continue preserving that hidden provider prefix.
// This keeps provider-omitted updates backward-compatible even when an
// older client edits the visible model ID.
if strings.TrimSpace(cfg.ModelList[idx].Provider) == "" {
existingProtocol, existingModelID := providers.ExtractProtocol(cfg.ModelList[idx])
existingRawModel := strings.TrimSpace(cfg.ModelList[idx].Model)
incomingModel := strings.TrimSpace(mc.Model)
if existingRawModel != "" && existingRawModel != existingModelID && incomingModel != "" {
if incomingModel == existingModelID {
mc.Model = existingRawModel
} else if strings.Contains(incomingModel, "/") && !strings.Contains(existingModelID, "/") {
// Older clients never saw the hidden provider prefix for simple
// legacy entries such as "openai/gpt-4o". If they now send an
// explicit provider/model string, treat it as the caller's full
// intent instead of re-applying the old hidden prefix.
mc.Model = incomingModel
} else if !strings.HasPrefix(incomingModel, existingProtocol+"/") {
mc.Model = existingProtocol + "/" + incomingModel
}
}
}
}
cfg.ModelList[idx] = &mc.ModelConfig

View file

@ -392,6 +392,49 @@ func TestHandleListModels_StatusMarksUnreachableLocalModel(t *testing.T) {
}
}
func TestHandleListModels_RuntimeProbeUsesExplicitProviderField(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetOAuthHooks(t)
resetModelProbeHooks(t)
var gotProbe string
probeOpenAICompatibleModelFunc = func(apiBase, modelID, apiKey string) bool {
gotProbe = apiBase + "|" + modelID + "|" + apiKey
return true
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []*config.ModelConfig{{
ModelName: "vllm-local",
Provider: "vllm",
Model: "custom-model",
APIBase: "http://127.0.0.1:8000/v1",
}}
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if gotProbe != "http://127.0.0.1:8000/v1|custom-model|" {
t.Fatalf("probe = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model|")
}
}
func TestHandleAddModel_PersistsAPIKey(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
@ -430,6 +473,76 @@ func TestHandleAddModel_PersistsAPIKey(t *testing.T) {
}
}
func TestHandleAddModel_PersistsProvider(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
"model_name":"nvidia-glm",
"provider":"nvidia",
"model":"z-ai/glm-5.1",
"api_key":"nv-key"
}`))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
added := cfg.ModelList[len(cfg.ModelList)-1]
if added.Provider != "nvidia" {
t.Fatalf("provider = %q, want %q", added.Provider, "nvidia")
}
if added.Model != "z-ai/glm-5.1" {
t.Fatalf("model = %q, want %q", added.Model, "z-ai/glm-5.1")
}
}
func TestHandleAddModel_PreservesExplicitProviderPrefixedModel(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/models", bytes.NewBufferString(`{
"model_name":"openai-gpt",
"provider":"openai",
"model":"openai/gpt-4o-mini",
"api_key":"sk-openai"
}`))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
added := cfg.ModelList[len(cfg.ModelList)-1]
if got := added.Provider; got != "openai" {
t.Fatalf("provider = %q, want %q", got, "openai")
}
if got := added.Model; got != "openai/gpt-4o-mini" {
t.Fatalf("model = %q, want %q", got, "openai/gpt-4o-mini")
}
}
func TestHandleAddModel_PersistsCustomHeaders(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
@ -536,6 +649,370 @@ func TestHandleUpdateModel_CustomHeadersPreserveAndClear(t *testing.T) {
}
}
func TestHandleUpdateModel_PersistsProvider(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []*config.ModelConfig{{
ModelName: "editable",
Model: "gpt-4o",
Provider: "openai",
}}
if err = config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
"model_name":"editable",
"provider":"openrouter",
"model":"openai/gpt-4o"
}`))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
updated, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
if got := updated.ModelList[0].Provider; got != "openrouter" {
t.Fatalf("provider = %q, want %q", got, "openrouter")
}
}
func TestHandleUpdateModel_PreservesExplicitProviderPrefixedModel(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []*config.ModelConfig{{
ModelName: "editable",
Model: "gpt-4o",
Provider: "openai",
}}
if err = config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
"model_name":"editable",
"provider":"openai",
"model":"openai/gpt-5.4"
}`))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
updated, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
if got := updated.ModelList[0].Provider; got != "openai" {
t.Fatalf("provider = %q, want %q", got, "openai")
}
if got := updated.ModelList[0].Model; got != "openai/gpt-5.4" {
t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4")
}
}
func TestHandleListModels_PreservesExplicitProviderPrefixedModel(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []*config.ModelConfig{{
ModelName: "openrouter-auto-explicit",
Provider: "openrouter",
Model: "openrouter/auto",
}}
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Models []modelResponse `json:"models"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Models) != 1 {
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
}
if got := resp.Models[0].Provider; got != "openrouter" {
t.Fatalf("provider = %q, want %q", got, "openrouter")
}
if got := resp.Models[0].Model; got != "openrouter/auto" {
t.Fatalf("model = %q, want %q", got, "openrouter/auto")
}
}
func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmitted(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []*config.ModelConfig{{
ModelName: "legacy-openrouter",
Model: "openrouter/openai/gpt-5.4",
}}
if err = config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
// Simulate an older client: it reads GET /api/models, ignores the new
// provider field, then PUTs the visible model string back unchanged.
recList := httptest.NewRecorder()
reqList := httptest.NewRequest(http.MethodGet, "/api/models", nil)
mux.ServeHTTP(recList, reqList)
if recList.Code != http.StatusOK {
t.Fatalf("list status = %d, want %d, body=%s", recList.Code, http.StatusOK, recList.Body.String())
}
var listResp struct {
Models []modelResponse `json:"models"`
}
if err = json.Unmarshal(recList.Body.Bytes(), &listResp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(listResp.Models) != 1 {
t.Fatalf("len(models) = %d, want 1", len(listResp.Models))
}
if got := listResp.Models[0].Provider; got != "openrouter" {
t.Fatalf("provider = %q, want %q", got, "openrouter")
}
if got := listResp.Models[0].Model; got != "openai/gpt-5.4" {
t.Fatalf("model = %q, want %q", got, "openai/gpt-5.4")
}
recUpdate := httptest.NewRecorder()
reqUpdate := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
"model_name":"legacy-openrouter",
"model":"openai/gpt-5.4"
}`))
reqUpdate.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(recUpdate, reqUpdate)
if recUpdate.Code != http.StatusOK {
t.Fatalf("update status = %d, want %d, body=%s", recUpdate.Code, http.StatusOK, recUpdate.Body.String())
}
updated, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
if got := updated.ModelList[0].Provider; got != "" {
t.Fatalf("provider = %q, want empty", got)
}
if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.4" {
t.Fatalf("model = %q, want %q", got, "openrouter/openai/gpt-5.4")
}
}
func TestHandleUpdateModel_PreservesLegacyModelPrefixWhenProviderOmittedAndModelChanges(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []*config.ModelConfig{{
ModelName: "legacy-openrouter",
Model: "openrouter/openai/gpt-5.4",
}}
if err = config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/api/models/0", bytes.NewBufferString(`{
"model_name":"legacy-openrouter",
"model":"openai/gpt-5.5"
}`))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
updated, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
if got := updated.ModelList[0].Provider; got != "" {
t.Fatalf("provider = %q, want empty", got)
}
if got := updated.ModelList[0].Model; got != "openrouter/openai/gpt-5.5" {
t.Fatalf("model = %q, want %q", got, "openrouter/openai/gpt-5.5")
}
}
func TestHandleListModels_ReturnsProviderField(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []*config.ModelConfig{{
ModelName: "nvidia-glm",
Provider: "nvidia",
Model: "z-ai/glm-5.1",
APIKeys: config.SimpleSecureStrings("nv-key"),
}}
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Models []modelResponse `json:"models"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Models) != 1 {
t.Fatalf("len(models) = %d, want 1", len(resp.Models))
}
if got := resp.Models[0].Provider; got != "nvidia" {
t.Fatalf("provider = %q, want %q", got, "nvidia")
}
}
func TestHandleListModels_ReturnsEffectiveProviderField(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
cfg.ModelList = []*config.ModelConfig{
{
ModelName: "plain-openai",
Model: "gpt-4o",
},
{
ModelName: "explicit-google",
Provider: "google",
Model: "gemini-2.5-pro",
},
{
ModelName: "explicit-qwen-intl",
Provider: "qwen-international",
Model: "qwen3-coder-plus",
},
}
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/models", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Models []modelResponse `json:"models"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Models) != 3 {
t.Fatalf("len(models) = %d, want 3", len(resp.Models))
}
if got := resp.Models[0].Provider; got != "openai" {
t.Fatalf("provider[0] = %q, want %q", got, "openai")
}
if got := resp.Models[0].Model; got != "gpt-4o" {
t.Fatalf("model[0] = %q, want %q", got, "gpt-4o")
}
if got := resp.Models[1].Provider; got != "gemini" {
t.Fatalf("provider[1] = %q, want %q", got, "gemini")
}
if got := resp.Models[1].Model; got != "gemini-2.5-pro" {
t.Fatalf("model[1] = %q, want %q", got, "gemini-2.5-pro")
}
if got := resp.Models[2].Provider; got != "qwen-intl" {
t.Fatalf("provider[2] = %q, want %q", got, "qwen-intl")
}
if got := resp.Models[2].Model; got != "qwen3-coder-plus" {
t.Fatalf("model[2] = %q, want %q", got, "qwen3-coder-plus")
}
}
// TestHandleSetDefaultModel_RejectsNonexistentModel tests that setting a non-existent
// model as default returns 404. This covers the case where virtual models (which are
// filtered by SaveConfig) cannot be set as default.

View file

@ -746,7 +746,7 @@ func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error {
found := false
for i := range cfg.ModelList {
if modelBelongsToProvider(provider, cfg.ModelList[i].Model) {
if modelBelongsToProvider(provider, cfg.ModelList[i]) {
cfg.ModelList[i].AuthMethod = authMethod
found = true
}
@ -759,18 +759,15 @@ func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error {
return oauthSaveConfig(h.configPath, cfg)
}
func modelBelongsToProvider(provider, model string) bool {
lower := strings.ToLower(strings.TrimSpace(model))
func modelBelongsToProvider(provider string, modelCfg *config.ModelConfig) bool {
protocol, _ := providers.ExtractProtocol(modelCfg)
switch provider {
case oauthProviderOpenAI:
return lower == "openai" || strings.HasPrefix(lower, "openai/")
return protocol == "openai"
case oauthProviderAnthropic:
return lower == "anthropic" || strings.HasPrefix(lower, "anthropic/")
return protocol == "anthropic"
case oauthProviderGoogleAntigravity:
return lower == "antigravity" ||
lower == "google-antigravity" ||
strings.HasPrefix(lower, "antigravity/") ||
strings.HasPrefix(lower, "google-antigravity/")
return protocol == "antigravity" || protocol == "google-antigravity"
default:
return false
}
@ -781,19 +778,22 @@ func defaultModelConfigForProvider(provider, authMethod string) *config.ModelCon
case oauthProviderOpenAI:
return &config.ModelConfig{
ModelName: "gpt-5.4",
Model: "openai/gpt-5.4",
Provider: "openai",
Model: "gpt-5.4",
AuthMethod: authMethod,
}
case oauthProviderAnthropic:
return &config.ModelConfig{
ModelName: "claude-sonnet-4.6",
Model: "anthropic/claude-sonnet-4.6",
Provider: "anthropic",
Model: "claude-sonnet-4.6",
AuthMethod: authMethod,
}
case oauthProviderGoogleAntigravity:
return &config.ModelConfig{
ModelName: "gemini-flash",
Model: "antigravity/gemini-3-flash",
Provider: "antigravity",
Model: "gemini-3-flash",
AuthMethod: authMethod,
}
default:

View file

@ -214,6 +214,54 @@ func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) {
}
}
func TestOAuthLogoutClearsAuthMethodForExplicitProviderField(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
resetOAuthHooks(t)
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig error: %v", err)
}
cfg.ModelList = append(cfg.ModelList, &config.ModelConfig{
ModelName: "gpt-5.4",
Provider: "openai",
Model: "gpt-5.4",
AuthMethod: "oauth",
})
if err = config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig error: %v", err)
}
if err = auth.SetCredential(oauthProviderOpenAI, &auth.AuthCredential{
AccessToken: "token-before-logout",
Provider: oauthProviderOpenAI,
AuthMethod: "oauth",
}); err != nil {
t.Fatalf("SetCredential error: %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/oauth/logout", bytes.NewBufferString(`{"provider":"openai"}`))
req.Header.Set("Content-Type", "application/json")
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
updated, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig error: %v", err)
}
if got := updated.ModelList[len(updated.ModelList)-1].AuthMethod; got != "" {
t.Fatalf("auth_method = %q, want empty", got)
}
}
func setupOAuthTestEnv(t *testing.T) (string, func()) {
t.Helper()

View file

@ -24,6 +24,8 @@ func (h *Handler) registerPicoRoutes(mux *http.ServeMux) {
// This allows the frontend to connect via the same port as the web UI,
// avoiding the need to expose extra ports for WebSocket communication.
mux.HandleFunc("GET /pico/ws", h.handleWebSocketProxy())
mux.HandleFunc("GET /pico/media/{id}", h.handlePicoMediaProxy())
mux.HandleFunc("HEAD /pico/media/{id}", h.handlePicoMediaProxy())
}
// createWsProxy creates a reverse proxy to the current gateway WebSocket endpoint.
@ -55,6 +57,53 @@ func (h *Handler) createWsProxy(origProtocol string, upstreamProtocol string) *h
return wsProxy
}
func (h *Handler) createPicoHTTPProxy(token string) *httputil.ReverseProxy {
return &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
target := h.gatewayProxyURL()
r.SetURL(target)
r.Out.Header.Set("Authorization", "Bearer "+token)
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
logger.Errorf("Failed to proxy Pico HTTP request: %v", err)
http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway)
},
}
}
func (h *Handler) gatewayAvailableForProxy() bool {
gateway.mu.Lock()
ensurePicoTokenCachedLocked(h.configPath)
cachedPID := gateway.pidData
trackedCmd := gateway.cmd
gateway.mu.Unlock()
if pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil); pidData != nil {
gateway.mu.Lock()
gateway.pidData = pidData
setGatewayRuntimeStatusLocked("running")
gateway.mu.Unlock()
return true
}
if cachedPID == nil {
return false
}
if isCmdProcessAliveLocked(trackedCmd) {
return true
}
gateway.mu.Lock()
if gateway.cmd == trackedCmd {
gateway.pidData = nil
setGatewayRuntimeStatusLocked("stopped")
}
available := gateway.pidData != nil
gateway.mu.Unlock()
return available
}
func decodePicoSettings(cfg *config.Config) (config.PicoSettings, bool) {
if cfg == nil {
return config.PicoSettings{}, false
@ -101,37 +150,7 @@ func (h *Handler) writePicoInfoResponse(
// on the upstream gateway request.
func (h *Handler) handleWebSocketProxy() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gateway.mu.Lock()
ensurePicoTokenCachedLocked(h.configPath)
cachedPID := gateway.pidData
trackedCmd := gateway.cmd
gateway.mu.Unlock()
gatewayAvailable := false
// Prefer fresh PID file data when available.
if pidData := h.sanitizeGatewayPidData(ppid.ReadPidFileWithCheck(globalConfigDir()), nil); pidData != nil {
gateway.mu.Lock()
gateway.pidData = pidData
setGatewayRuntimeStatusLocked("running")
gatewayAvailable = true
gateway.mu.Unlock()
} else if cachedPID != nil {
// No PID file now: keep availability only while tracked process is
// still alive (covers short PID-file races at startup/restart).
if isCmdProcessAliveLocked(trackedCmd) {
gatewayAvailable = true
} else {
gateway.mu.Lock()
if gateway.cmd == trackedCmd {
gateway.pidData = nil
setGatewayRuntimeStatusLocked("stopped")
}
gatewayAvailable = gateway.pidData != nil
gateway.mu.Unlock()
}
}
if !gatewayAvailable {
if !h.gatewayAvailableForProxy() {
logger.Warnf("Gateway not available for WebSocket proxy")
http.Error(w, "Gateway not available", http.StatusServiceUnavailable)
return
@ -153,6 +172,29 @@ func (h *Handler) handleWebSocketProxy() http.HandlerFunc {
}
}
func (h *Handler) handlePicoMediaProxy() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !h.gatewayAvailableForProxy() {
logger.Warnf("Gateway not available for Pico media proxy")
http.Error(w, "Gateway not available", http.StatusServiceUnavailable)
return
}
gateway.mu.Lock()
uiToken := gateway.picoToken
gateway.mu.Unlock()
token := tokenPrefix + uiToken
if token == "" {
logger.Warnf("Missing Pico token for media proxy")
http.Error(w, "Invalid Pico token", http.StatusForbidden)
return
}
h.createPicoHTTPProxy(token).ServeHTTP(w, r)
}
}
// handleGetPicoInfo returns non-secret Pico connection info for the launcher UI.
//
// GET /api/pico/info

View file

@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
@ -701,6 +702,54 @@ func TestHandleWebSocketProxyLoadsPidDataOnDemand(t *testing.T) {
}
}
func TestCreatePicoHTTPProxyInjectsGatewayAuth(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
cfg := config.DefaultConfig()
cfg.Gateway.Host = "127.0.0.1"
cfg.Gateway.Port = 18790
bc := cfg.Channels["pico"]
bc.Enabled = true
decoded, err := bc.GetDecoded()
if err != nil {
t.Fatalf("GetDecoded() error = %v", err)
}
decoded.(*config.PicoSettings).SetToken("ui-token")
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
proxy := h.createPicoHTTPProxy(tokenPrefix + "test-token" + "ui-token")
var capturedPath string
var capturedAuth string
proxy.Transport = roundTripFunc(func(req *http.Request) (*http.Response, error) {
capturedPath = req.URL.Path
capturedAuth = req.Header.Get("Authorization")
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("proxied")),
Request: req,
}, nil
})
req := httptest.NewRequest(http.MethodGet, "/pico/media/attachment-1", nil)
rec := httptest.NewRecorder()
proxy.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if capturedPath != "/pico/media/attachment-1" {
t.Fatalf("capturedPath = %q, want %q", capturedPath, "/pico/media/attachment-1")
}
expected := "Bearer " + tokenPrefix + "test-token" + "ui-token"
if capturedAuth != expected {
t.Fatalf("Authorization = %q, want %q", capturedAuth, expected)
}
}
func TestHandleWebSocketProxyRejectsStalePidDataAfterProcessExit(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("HOME", tmpDir)
@ -849,3 +898,9 @@ func mustGatewayTestPort(t *testing.T, rawURL string) int {
return port
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return fn(req)
}

View file

@ -46,9 +46,17 @@ type sessionListItem struct {
}
type sessionChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
Media []string `json:"media,omitempty"`
Role string `json:"role"`
Content string `json:"content"`
Media []string `json:"media,omitempty"`
Attachments []sessionChatAttachment `json:"attachments,omitempty"`
}
type sessionChatAttachment struct {
Type string `json:"type,omitempty"`
URL string `json:"url,omitempty"`
Filename string `json:"filename,omitempty"`
ContentType string `json:"content_type,omitempty"`
}
// legacyPicoSessionPrefix is the legacy key prefix used by older Pico JSON/JSONL
@ -398,10 +406,12 @@ func (h *Handler) findLegacyPicoSession(dir, sessionID string) (picoLegacySessio
}
func buildSessionListItem(sessionID string, sess sessionFile, toolFeedbackMaxArgsLength int) sessionListItem {
transcript := visibleSessionMessages(sess.Messages, toolFeedbackMaxArgsLength)
preview := ""
for _, msg := range sess.Messages {
for _, msg := range transcript {
if msg.Role == "user" {
preview = sessionMessagePreview(msg)
preview = sessionChatMessagePreview(msg)
}
if preview != "" {
break
@ -414,13 +424,11 @@ func buildSessionListItem(sessionID string, sess sessionFile, toolFeedbackMaxArg
}
title := preview
validMessageCount := len(visibleSessionMessages(sess.Messages, toolFeedbackMaxArgsLength))
return sessionListItem{
ID: sessionID,
Title: title,
Preview: preview,
MessageCount: validMessageCount,
MessageCount: len(transcript),
Created: sess.Created.Format(time.RFC3339),
Updated: sess.Updated.Format(time.RFC3339),
}
@ -441,16 +449,25 @@ func truncateRunes(s string, maxLen int) string {
return string(runes[:maxLen]) + "..."
}
func sessionMessageVisible(msg providers.Message) bool {
return strings.TrimSpace(msg.Content) != "" || len(msg.Media) > 0
func sessionChatMessageVisible(msg sessionChatMessage) bool {
return strings.TrimSpace(msg.Content) != "" || len(msg.Media) > 0 || len(msg.Attachments) > 0
}
func sessionMessagePreview(msg providers.Message) string {
func sessionChatMessagePreview(msg sessionChatMessage) string {
if content := strings.TrimSpace(msg.Content); content != "" {
return content
}
if len(msg.Attachments) > 0 {
if strings.EqualFold(strings.TrimSpace(msg.Attachments[0].Type), "image") {
return "[image]"
}
return "[attachment]"
}
if len(msg.Media) > 0 {
return "[image]"
if strings.HasPrefix(strings.TrimSpace(msg.Media[0]), "data:image/") {
return "[image]"
}
return "[attachment]"
}
return ""
}
@ -459,17 +476,21 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
transcript := make([]sessionChatMessage, 0, len(messages))
for _, msg := range messages {
attachments := sessionAttachments(msg)
switch msg.Role {
case "tool":
continue
case "user":
if sessionMessageVisible(msg) {
transcript = append(transcript, sessionChatMessage{
Role: "user",
Content: msg.Content,
Media: append([]string(nil), msg.Media...),
})
chatMsg := sessionChatMessage{
Role: "user",
Content: msg.Content,
Media: append([]string(nil), msg.Media...),
Attachments: attachments,
}
if sessionChatMessageVisible(chatMsg) {
transcript = append(transcript, chatMsg)
}
case "assistant":
@ -501,15 +522,25 @@ func visibleSessionMessages(messages []providers.Message, toolFeedbackMaxArgsLen
// Pico web chat can persist both visible `message` tool output and a
// later plain assistant reply in the same turn. Hide only the fixed
// internal summary that marks handled tool delivery.
if !sessionMessageVisible(msg) || assistantMessageInternalOnly(msg) {
content := msg.Content
if assistantMessageInternalOnly(msg) {
if len(attachments) == 0 {
continue
}
content = ""
}
chatMsg := sessionChatMessage{
Role: "assistant",
Content: content,
Media: append([]string(nil), msg.Media...),
Attachments: attachments,
}
if !sessionChatMessageVisible(chatMsg) {
continue
}
transcript = append(transcript, sessionChatMessage{
Role: "assistant",
Content: msg.Content,
Media: append([]string(nil), msg.Media...),
})
transcript = append(transcript, chatMsg)
}
}
@ -564,11 +595,88 @@ func toolSummaryContainsContent(summary, content string) bool {
return hasBody && strings.TrimSpace(body) == content
}
func sessionAttachments(msg providers.Message) []sessionChatAttachment {
if len(msg.Attachments) == 0 {
return nil
}
attachments := make([]sessionChatAttachment, 0, len(msg.Attachments))
for _, attachment := range msg.Attachments {
urlValue, ok := sessionAttachmentURL(attachment)
if !ok {
continue
}
attachmentType := strings.TrimSpace(attachment.Type)
if attachmentType == "" {
attachmentType = sessionAttachmentType(attachment)
}
attachments = append(attachments, sessionChatAttachment{
Type: attachmentType,
URL: urlValue,
Filename: strings.TrimSpace(attachment.Filename),
ContentType: strings.TrimSpace(attachment.ContentType),
})
}
if len(attachments) == 0 {
return nil
}
return attachments
}
func sessionAttachmentURL(attachment providers.Attachment) (string, bool) {
if rawURL := strings.TrimSpace(attachment.URL); rawURL != "" {
return rawURL, true
}
ref := strings.TrimSpace(attachment.Ref)
if ref == "" {
return "", false
}
if strings.HasPrefix(ref, "media://") {
// Persisted session history must only expose durable attachment locations.
// media:// refs depend on the live in-memory MediaStore and may stop
// resolving after a restart or cleanup, so omit them from reopened history.
return "", false
}
return ref, true
}
func sessionAttachmentType(attachment providers.Attachment) string {
contentType := strings.ToLower(strings.TrimSpace(attachment.ContentType))
filename := strings.ToLower(strings.TrimSpace(attachment.Filename))
rawRef := strings.ToLower(strings.TrimSpace(attachment.Ref))
rawURL := strings.ToLower(strings.TrimSpace(attachment.URL))
switch {
case strings.HasPrefix(contentType, "image/"),
strings.HasPrefix(rawRef, "data:image/"),
strings.HasPrefix(rawURL, "data:image/"):
return "image"
case strings.HasPrefix(contentType, "audio/"):
return "audio"
case strings.HasPrefix(contentType, "video/"):
return "video"
}
switch ext := filepath.Ext(filename); ext {
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg":
return "image"
case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus":
return "audio"
case ".mp4", ".avi", ".mov", ".webm", ".mkv":
return "video"
default:
return "file"
}
}
func assistantMessageTransientThought(msg providers.Message) bool {
return strings.TrimSpace(msg.Content) == "" &&
strings.TrimSpace(msg.ReasoningContent) != "" &&
len(msg.ToolCalls) == 0 &&
len(msg.Media) == 0
len(msg.Media) == 0 &&
len(msg.Attachments) == 0
}
func assistantMessageInternalOnly(msg providers.Message) bool {

View file

@ -218,6 +218,136 @@ func TestHandleGetSession_JSONLStorage(t *testing.T) {
}
}
func TestHandleGetSession_HidesHandledToolAttachmentsBackedByMediaRefs(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
dir := sessionsTestDir(t, configPath)
store, err := memory.NewJSONLStore(dir)
if err != nil {
t.Fatalf("NewJSONLStore() error = %v", err)
}
sessionKey := legacyPicoSessionPrefix + "attachment-history"
for _, msg := range []providers.Message{
{Role: "user", Content: "send me the report"},
{
Role: "assistant",
Content: handledToolResponseSummaryText,
Attachments: []providers.Attachment{{
Type: "file",
Ref: "media://attachment-1",
Filename: "report.txt",
ContentType: "text/plain",
}},
},
} {
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
t.Fatalf("AddFullMessage() error = %v", err)
}
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/sessions/attachment-history", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Messages []sessionChatMessage `json:"messages"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Messages) != 1 {
t.Fatalf("len(resp.Messages) = %d, want 1", len(resp.Messages))
}
if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "send me the report" {
t.Fatalf("message = %#v, want only user request", resp.Messages[0])
}
}
func TestHandleGetSession_ExposesHandledToolAttachmentsWithDurableURL(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
dir := sessionsTestDir(t, configPath)
store, err := memory.NewJSONLStore(dir)
if err != nil {
t.Fatalf("NewJSONLStore() error = %v", err)
}
sessionKey := legacyPicoSessionPrefix + "attachment-history-durable"
for _, msg := range []providers.Message{
{Role: "user", Content: "send me the report"},
{
Role: "assistant",
Content: handledToolResponseSummaryText,
Attachments: []providers.Attachment{{
Type: "file",
URL: "https://example.com/report.txt",
Filename: "report.txt",
ContentType: "text/plain",
}},
},
} {
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
t.Fatalf("AddFullMessage() error = %v", err)
}
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/sessions/attachment-history-durable", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp struct {
Messages []sessionChatMessage `json:"messages"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if len(resp.Messages) != 2 {
t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages))
}
assistant := resp.Messages[1]
if assistant.Role != "assistant" {
t.Fatalf("assistant role = %q, want assistant", assistant.Role)
}
if assistant.Content != "" {
t.Fatalf("assistant content = %q, want empty string", assistant.Content)
}
if len(assistant.Attachments) != 1 {
t.Fatalf("len(assistant.Attachments) = %d, want 1", len(assistant.Attachments))
}
if assistant.Attachments[0].URL != "https://example.com/report.txt" {
t.Fatalf(
"attachment url = %q, want %q",
assistant.Attachments[0].URL,
"https://example.com/report.txt",
)
}
if assistant.Attachments[0].Filename != "report.txt" {
t.Fatalf("attachment filename = %q, want %q", assistant.Attachments[0].Filename, "report.txt")
}
}
func TestHandleSessions_JSONLScopeDiscovery(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()

View file

@ -6,6 +6,7 @@ import { refreshGatewayState } from "@/store/gateway"
export interface ModelInfo {
index: number
model_name: string
provider?: string
model: string
api_base?: string
api_key: string

View file

@ -15,6 +15,12 @@ export interface SessionDetail {
role: "user" | "assistant"
content: string
media?: string[]
attachments?: {
type?: "image" | "audio" | "video" | "file"
url: string
filename?: string
content_type?: string
}[]
}[]
summary: string
created: string

View file

@ -3,6 +3,8 @@ import {
IconCheck,
IconChevronDown,
IconCopy,
IconDownload,
IconFileText,
} from "@tabler/icons-react"
import { useAtom } from "jotai"
import { useState } from "react"
@ -16,21 +18,30 @@ import remarkGfm from "remark-gfm"
import { Button } from "@/components/ui/button"
import { formatMessageTime } from "@/hooks/use-pico-chat"
import { cn } from "@/lib/utils"
import { showThoughtsAtom } from "@/store/chat"
import { type ChatAttachment, showThoughtsAtom } from "@/store/chat"
interface AssistantMessageProps {
content: string
attachments?: ChatAttachment[]
isThought?: boolean
timestamp?: string | number
}
export function AssistantMessage({
content,
attachments = [],
isThought = false,
timestamp = "",
}: AssistantMessageProps) {
const { t } = useTranslation()
const [isCopied, setIsCopied] = useState(false)
const hasText = content.trim().length > 0
const imageAttachments = attachments.filter(
(attachment) => attachment.type === "image",
)
const fileAttachments = attachments.filter(
(attachment) => attachment.type !== "image",
)
const [isExpanded, setIsExpanded] = useAtom(showThoughtsAtom)
const formattedTimestamp =
timestamp !== "" ? formatMessageTime(timestamp) : ""
@ -83,7 +94,7 @@ export function AssistantMessage({
/>
</div>
)}
{(!isThought || isExpanded) && (
{(!isThought || isExpanded) && hasText && (
<div
className={cn(
"prose dark:prose-invert prose-pre:my-2 prose-pre:overflow-x-auto prose-pre:rounded-lg prose-pre:border prose-pre:bg-zinc-100 prose-pre:p-0 prose-pre:text-zinc-900 dark:prose-pre:bg-zinc-950 dark:prose-pre:text-zinc-100 max-w-none [overflow-wrap:anywhere] break-words",
@ -100,11 +111,61 @@ export function AssistantMessage({
</ReactMarkdown>
</div>
)}
{!isThought && (
{(imageAttachments.length > 0 || fileAttachments.length > 0) && (
<div
className={cn("flex flex-col gap-3", hasText ? "px-4 pb-4" : "p-4")}
>
{imageAttachments.length > 0 && (
<div className="flex flex-wrap gap-3">
{imageAttachments.map((attachment, index) => (
<a
key={`${attachment.url}-${index}`}
href={attachment.url}
target="_blank"
rel="noreferrer"
className="overflow-hidden rounded-xl border"
>
<img
src={attachment.url}
alt={attachment.filename || "Attachment"}
className="max-h-72 max-w-full object-cover"
/>
</a>
))}
</div>
)}
{fileAttachments.length > 0 && (
<div className="flex flex-col gap-2">
{fileAttachments.map((attachment, index) => (
<a
key={`${attachment.url}-${index}`}
href={attachment.url}
download={attachment.filename}
className="bg-background/70 hover:bg-background/90 flex items-center justify-between gap-3 rounded-xl border px-3 py-2 transition-colors"
>
<span className="flex min-w-0 items-center gap-2">
<IconFileText className="text-muted-foreground size-4 shrink-0" />
<span className="truncate text-sm">
{attachment.filename || "Download attachment"}
</span>
</span>
<IconDownload className="text-muted-foreground size-4 shrink-0" />
</a>
))}
</div>
)}
</div>
)}
{!isThought && hasText && (
<Button
variant="ghost"
size="icon"
className="bg-background/50 hover:bg-background/80 absolute top-2 right-2 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100"
className={cn(
"bg-background/50 hover:bg-background/80 absolute top-2 right-2 h-7 w-7 opacity-0 transition-opacity group-hover:opacity-100",
)}
onClick={handleCopy}
>
{isCopied ? (

View file

@ -311,6 +311,7 @@ export function ChatPage() {
{msg.role === "assistant" ? (
<AssistantMessage
content={msg.content}
attachments={msg.attachments}
isThought={msg.kind === "thought"}
timestamp={msg.timestamp}
/>

View file

@ -24,6 +24,7 @@ import { Textarea } from "@/components/ui/textarea"
interface AddForm {
modelName: string
provider: string
model: string
apiBase: string
apiKey: string
@ -41,6 +42,7 @@ interface AddForm {
const EMPTY_ADD_FORM: AddForm = {
modelName: "",
provider: "",
model: "",
apiBase: "",
apiKey: "",
@ -119,9 +121,11 @@ export function AddModelSheet({
setServerError("")
try {
const modelName = form.modelName.trim()
const provider = form.provider.trim()
const modelId = form.model.trim()
await addModel({
model_name: modelName,
provider: provider || undefined,
model: modelId,
api_base: form.apiBase.trim() || undefined,
api_key: form.apiKey.trim() || undefined,
@ -186,6 +190,17 @@ export function AddModelSheet({
)}
</Field>
<Field
label={t("models.field.provider")}
hint={t("models.field.providerHint")}
>
<Input
value={form.provider}
onChange={setField("provider")}
placeholder={t("models.field.providerPlaceholder")}
/>
</Field>
<Field
label={t("models.add.modelId")}
hint={t("models.add.modelIdHint")}

View file

@ -23,6 +23,8 @@ import {
import { Textarea } from "@/components/ui/textarea"
interface EditForm {
provider: string
modelId: string
apiKey: string
apiBase: string
proxy: string
@ -52,6 +54,8 @@ export function EditModelSheet({
}: EditModelSheetProps) {
const { t } = useTranslation()
const [form, setForm] = useState<EditForm>({
provider: "",
modelId: "",
apiKey: "",
apiBase: "",
proxy: "",
@ -72,6 +76,8 @@ export function EditModelSheet({
useEffect(() => {
if (model) {
setForm({
provider: model.provider ?? "",
modelId: model.model,
apiKey: "",
apiBase: model.api_base ?? "",
proxy: model.proxy ?? "",
@ -103,12 +109,17 @@ export function EditModelSheet({
const handleSave = async () => {
if (!model) return
if (!form.modelId.trim()) {
setError(t("models.add.errorRequired"))
return
}
setSaving(true)
setError("")
try {
await updateModel(model.index, {
model_name: model.model_name,
model: model.model,
provider: form.provider.trim(),
model: form.modelId.trim(),
api_base: form.apiBase || undefined,
api_key: form.apiKey || undefined,
proxy: form.proxy || undefined,
@ -166,6 +177,29 @@ export function EditModelSheet({
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="space-y-5 px-6 py-5">
<Field
label={t("models.field.provider")}
hint={t("models.field.providerHint")}
>
<Input
value={form.provider}
onChange={setField("provider")}
placeholder={t("models.field.providerPlaceholder")}
/>
</Field>
<Field
label={t("models.add.modelId")}
hint={t("models.add.modelIdHint")}
>
<Input
value={form.modelId}
onChange={setField("modelId")}
placeholder={t("models.add.modelIdPlaceholder")}
className="font-mono text-sm"
/>
</Field>
{!isOAuth && (
<Field
label={t("models.field.apiKey")}

View file

@ -20,19 +20,28 @@ const PROVIDER_PRIORITY: Record<string, number> = {
zhipu: 4,
deepseek: 5,
openrouter: 6,
qwen: 7,
moonshot: 8,
groq: 9,
"github-copilot": 10,
antigravity: 11,
nvidia: 12,
cerebras: 13,
shengsuanyun: 14,
ollama: 15,
vllm: 16,
mistral: 17,
avian: 18,
mimo: 19,
"qwen-portal": 7,
"qwen-intl": 8,
moonshot: 9,
groq: 10,
"github-copilot": 11,
antigravity: 12,
nvidia: 13,
cerebras: 14,
shengsuanyun: 15,
venice: 16,
vivgrid: 17,
minimax: 18,
longcat: 19,
modelscope: 20,
mistral: 21,
avian: 22,
azure: 23,
ollama: 24,
vllm: 25,
lmstudio: 26,
zai: 27,
mimo: 28,
}
interface ProviderGroup {
@ -95,10 +104,10 @@ export function ModelsPage() {
const grouped: Record<string, { label: string; models: ModelInfo[] }> = {}
for (const model of models) {
const providerKey = getProviderKey(model.model)
const providerKey = getProviderKey(model.provider)
if (!grouped[providerKey]) {
grouped[providerKey] = {
label: getProviderLabel(model.model),
label: getProviderLabel(model.provider),
models: [],
}
}

View file

@ -3,9 +3,11 @@ import { useMemo, useState } from "react"
const PROVIDER_ICON_SLUGS: Record<string, string> = {
openai: "openai",
anthropic: "anthropic",
azure: "microsoftazure",
gemini: "googlegemini",
deepseek: "deepseek",
qwen: "alibabacloud",
"qwen-portal": "alibabacloud",
"qwen-intl": "alibabacloud",
groq: "groq",
openrouter: "openrouter",
nvidia: "nvidia",
@ -20,9 +22,11 @@ const PROVIDER_ICON_SLUGS: Record<string, string> = {
const PROVIDER_DOMAINS: Record<string, string> = {
openai: "openai.com",
anthropic: "anthropic.com",
azure: "azure.com",
gemini: "gemini.google.com",
deepseek: "deepseek.com",
qwen: "qwenlm.ai",
"qwen-portal": "qwenlm.ai",
"qwen-intl": "alibabacloud.com",
moonshot: "moonshot.ai",
groq: "groq.com",
openrouter: "openrouter.ai",
@ -33,11 +37,18 @@ const PROVIDER_DOMAINS: Record<string, string> = {
antigravity: "antigravity.google",
"github-copilot": "github.com",
ollama: "ollama.com",
lmstudio: "lmstudio.ai",
mistral: "mistral.ai",
avian: "avian.io",
vllm: "vllm.ai",
zhipu: "zhipuai.cn",
zai: "z.ai",
mimo: "xiaomi.com",
venice: "venice.ai",
vivgrid: "vivgrid.com",
minimax: "minimaxi.com",
longcat: "longcat.chat",
modelscope: "modelscope.cn",
}
interface ProviderIconProps {

View file

@ -1,9 +1,11 @@
const PROVIDER_LABELS: Record<string, string> = {
openai: "OpenAI",
anthropic: "Anthropic",
azure: "Azure OpenAI",
gemini: "Google Gemini",
deepseek: "DeepSeek",
qwen: "Qwen (阿里云)",
"qwen-portal": "Qwen (阿里云)",
"qwen-intl": "Qwen International",
moonshot: "Moonshot (月之暗面)",
groq: "Groq",
openrouter: "OpenRouter",
@ -14,21 +16,37 @@ const PROVIDER_LABELS: Record<string, string> = {
antigravity: "Google Code Assist",
"github-copilot": "GitHub Copilot",
ollama: "Ollama (local)",
lmstudio: "LM Studio (local)",
mistral: "Mistral AI",
avian: "Avian",
vllm: "VLLM (local)",
zhipu: "Zhipu AI (智谱)",
zai: "Z.ai",
mimo: "Xiaomi MiMo",
venice: "Venice AI",
vivgrid: "Vivgrid",
minimax: "MiniMax",
longcat: "LongCat",
modelscope: "ModelScope (魔搭社区)",
}
export function getProviderKey(model: string): string {
return model.split("/")[0]
const PROVIDER_ALIASES: Record<string, string> = {
qwen: "qwen-portal",
"qwen-international": "qwen-intl",
"dashscope-intl": "qwen-intl",
"z.ai": "zai",
"z-ai": "zai",
google: "gemini",
"google-antigravity": "antigravity",
}
export function getProviderLabel(model: string): string {
const prefix = getProviderKey(model)
const labels: Record<string, string> = {
...PROVIDER_LABELS,
}
return labels[prefix] ?? prefix
export function getProviderKey(provider?: string): string {
const normalized = provider?.trim().toLowerCase()
if (!normalized) return "openai"
return PROVIDER_ALIASES[normalized] ?? normalized
}
export function getProviderLabel(provider?: string): string {
const prefix = getProviderKey(provider)
return PROVIDER_LABELS[prefix] ?? prefix
}

View file

@ -2,16 +2,37 @@ import { getSessionHistory } from "@/api/sessions"
import { normalizeUnixTimestamp } from "@/features/chat/state"
import type { ChatAttachment, ChatMessage } from "@/store/chat"
function toChatAttachments(media?: string[]): ChatAttachment[] | undefined {
if (!media || media.length === 0) {
return undefined
}
function toChatAttachments({
media,
attachments,
}: {
media?: string[]
attachments?: {
type?: "image" | "audio" | "video" | "file"
url: string
filename?: string
content_type?: string
}[]
}): ChatAttachment[] | undefined {
const normalizedAttachments = attachments
?.filter((attachment) => attachment.url)
.map(
(attachment) =>
({
type: attachment.type ?? "file",
url: attachment.url,
filename: attachment.filename,
contentType: attachment.content_type,
}) satisfies ChatAttachment,
)
const attachments = media
const legacyMediaAttachments = (media ?? [])
.filter((item) => item.startsWith("data:image/"))
.map((url) => ({ type: "image" as const, url }))
return attachments.length > 0 ? attachments : undefined
const merged = [...(normalizedAttachments ?? []), ...legacyMediaAttachments]
return merged.length > 0 ? merged : undefined
}
export async function loadSessionMessages(
@ -25,7 +46,10 @@ export async function loadSessionMessages(
role: message.role,
content: message.content,
kind: message.role === "assistant" ? "normal" : undefined,
attachments: toChatAttachments(message.media),
attachments: toChatAttachments({
media: message.media,
attachments: message.attachments,
}),
timestamp: fallbackTime,
}))
}
@ -46,7 +70,10 @@ function normalizeMessageTimestamp(timestamp: number | string): string {
function messageSignature(message: ChatMessage): string {
const attachmentSignature = (message.attachments ?? [])
.map((attachment) => `${attachment.type}\u0001${attachment.url}`)
.map(
(attachment) =>
`${attachment.type}\u0001${attachment.url}\u0001${attachment.filename ?? ""}`,
)
.join("\u0002")
return `${message.role}\u0000${message.content}\u0000${normalizeMessageTimestamp(

View file

@ -3,6 +3,7 @@ import { toast } from "sonner"
import { normalizeUnixTimestamp } from "@/features/chat/state"
import {
type AssistantMessageKind,
type ChatAttachment,
type ContextUsage,
updateChatStore,
} from "@/store/chat"
@ -25,6 +26,52 @@ function hasAssistantKindPayload(payload: Record<string, unknown>): boolean {
return typeof payload.thought === "boolean"
}
function parseAttachments(
payload: Record<string, unknown>,
): ChatAttachment[] | undefined {
const raw = payload.attachments
if (!Array.isArray(raw)) {
return undefined
}
const attachments: ChatAttachment[] = []
for (const item of raw) {
if (!item || typeof item !== "object") {
continue
}
const attachment = item as Record<string, unknown>
const url = typeof attachment.url === "string" ? attachment.url : ""
if (!url) {
continue
}
const type =
attachment.type === "audio" ||
attachment.type === "video" ||
attachment.type === "file" ||
attachment.type === "image"
? attachment.type
: "file"
const filename =
typeof attachment.filename === "string" ? attachment.filename : undefined
const contentType =
typeof attachment.content_type === "string"
? attachment.content_type
: undefined
attachments.push({
type,
url,
...(filename ? { filename } : {}),
...(contentType ? { contentType } : {}),
})
}
return attachments.length > 0 ? attachments : undefined
}
function parseContextUsage(
payload: Record<string, unknown>,
): ContextUsage | undefined {
@ -54,10 +101,12 @@ export function handlePicoMessage(
const payload = message.payload || {}
switch (message.type) {
case "message.create": {
case "message.create":
case "media.create": {
const content = (payload.content as string) || ""
const messageId = (payload.message_id as string) || `pico-${Date.now()}`
const kind = parseAssistantMessageKind(payload)
const attachments = parseAttachments(payload)
const contextUsage = parseContextUsage(payload)
const timestamp =
message.timestamp !== undefined &&
@ -73,6 +122,7 @@ export function handlePicoMessage(
role: "assistant",
content,
kind,
attachments,
timestamp,
},
],
@ -87,6 +137,7 @@ export function handlePicoMessage(
const messageId = payload.message_id as string
const hasKind = hasAssistantKindPayload(payload)
const kind = parseAssistantMessageKind(payload)
const attachments = parseAttachments(payload)
if (!messageId) {
break
}
@ -98,6 +149,7 @@ export function handlePicoMessage(
...msg,
content,
...(hasKind ? { kind } : {}),
...(attachments ? { attachments } : {}),
}
: msg,
),

View file

@ -244,8 +244,8 @@
"modelNamePlaceholder": "e.g. my-gpt4",
"modelNameHint": "A short name used to identify this model in conversations.",
"modelId": "Model Identifier",
"modelIdPlaceholder": "e.g. openai/gpt-4o",
"modelIdHint": "Format: protocol/model-id. Supported: openai, anthropic, gemini, groq, …",
"modelIdPlaceholder": "e.g. gpt-4o or openai/gpt-4o",
"modelIdHint": "If Provider is not specified, values such as openai/gpt-4o are interpreted using the provider/model format. If Provider is specified, this field is treated as the canonical model ID and is not parsed for a provider prefix.",
"errorRequired": "This field is required.",
"errorDuplicateModelName": "Model alias already exists. Please use a different name.",
"saveError": "Failed to add model",
@ -260,6 +260,9 @@
"toggle": "Advanced options"
},
"field": {
"provider": "Provider",
"providerPlaceholder": "e.g. openai",
"providerHint": "Optional. If specified, this value is used as the effective provider, and Model Identifier is interpreted as the canonical model ID.",
"apiBase": "API Base URL",
"apiKey": "API Key",
"apiKeyPlaceholder": "Enter your API key",

View file

@ -244,8 +244,8 @@
"modelNamePlaceholder": "例如 my-gpt4",
"modelNameHint": "用于在对话中识别此模型的简短名称。",
"modelId": "模型标识符",
"modelIdPlaceholder": "例如 openai/gpt-4o",
"modelIdHint": "格式:协议/模型ID。支持openai、anthropic、gemini、groq 等。",
"modelIdPlaceholder": "例如 gpt-4o 或 openai/gpt-4o",
"modelIdHint": "未指定 Provider 时,诸如 openai/gpt-4o 的值将按 provider/model 格式解析。已指定 Provider 时,此字段将作为规范模型 ID 使用,不再解析其中的 provider 前缀。",
"errorRequired": "此字段为必填项。",
"errorDuplicateModelName": "模型别名已存在,请使用其他名称。",
"saveError": "添加模型失败",
@ -260,6 +260,9 @@
"toggle": "高级选项"
},
"field": {
"provider": "Provider",
"providerPlaceholder": "例如 openai",
"providerHint": "可选。指定后,将以该值作为最终 provider并将“模型标识符”字段解释为规范模型 ID。",
"apiBase": "API Base URL",
"apiKey": "API Key",
"apiKeyPlaceholder": "请输入 API Key",

View file

@ -6,9 +6,10 @@ import {
} from "@/features/chat/state"
export interface ChatAttachment {
type: "image"
type: "image" | "audio" | "video" | "file"
url: string
filename?: string
contentType?: string
}
export type AssistantMessageKind = "normal" | "thought"

View file

@ -29,6 +29,10 @@ export default defineConfig({
target: "http://localhost:18800",
changeOrigin: true,
},
"/pico/media": {
target: "http://localhost:18800",
changeOrigin: true,
},
"/pico/ws": {
target: "ws://localhost:18800",
ws: true,