feat(provider): add lmstudio vendor and local no-key behavior

This commit is contained in:
lc6464 2026-03-30 20:43:27 +08:00
parent 187b2c2185
commit 47b95ef3fa
No known key found for this signature in database
GPG key ID: 53C61B42FEC71D6D
7 changed files with 211 additions and 2 deletions

View file

@ -48,6 +48,10 @@
"model": "deepseek/deepseek-chat", "model": "deepseek/deepseek-chat",
"api_key": "sk-your-deepseek-key" "api_key": "sk-your-deepseek-key"
}, },
{
"model_name": "lmstudio-local",
"model": "lmstudio/openai/gpt-oss-20b"
},
{ {
"model_name": "longcat", "model_name": "longcat",
"model": "longcat/LongCat-Flash-Thinking", "model": "longcat/LongCat-Flash-Thinking",

View file

@ -563,6 +563,7 @@ For complete documentation, see [`security_configuration.md`](security_configura
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | | **通义千问 (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) | | **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) | | **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) | | **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 | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
@ -710,6 +711,21 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
</details> </details>
<details>
<summary><b>LM Studio (local)</b></summary>
```json
{
"model_name": "lmstudio-local",
"model": "lmstudio/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.
</details>
<details> <details>
<summary><b>Custom Proxy / LiteLLM</b></summary> <summary><b>Custom Proxy / LiteLLM</b></summary>

View file

@ -56,6 +56,7 @@ This design also enables **multi-agent support** with flexible provider selectio
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) | | **通义千问 (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) | | **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) | | **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) | | **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 | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
@ -226,6 +227,18 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
} }
``` ```
**LM Studio (local)**
```json
{
"model_name": "lmstudio-local",
"model": "lmstudio/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.
**Custom Proxy/API** **Custom Proxy/API**
```json ```json

View file

@ -365,6 +365,7 @@ Agent 读取 HEARTBEAT.md
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取](https://dashscope.console.aliyun.com) | | **通义千问 (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) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取](https://build.nvidia.com) |
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需 Key | | **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) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取](https://openrouter.ai/keys) |
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理 Key | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理 Key |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
@ -506,6 +507,21 @@ Agent 读取 HEARTBEAT.md
</details> </details>
<details>
<summary><b>LM Studio本地</b></summary>
```json
{
"model_name": "lmstudio-local",
"model": "lmstudio/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`
</details>
<details> <details>
<summary><b>自定义代理 / LiteLLM</b></summary> <summary><b>自定义代理 / LiteLLM</b></summary>

View file

@ -53,6 +53,7 @@
| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) | | **通义千问 (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) | | **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) |
| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) | | **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) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理密钥 | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | 你的 LiteLLM 代理密钥 |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
@ -211,6 +212,18 @@
} }
``` ```
**LM Studio本地**
```json
{
"model_name": "lmstudio-local",
"model": "lmstudio/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`
**自定义代理/API** **自定义代理/API**
```json ```json

View file

@ -154,13 +154,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
} }
return provider, modelID, nil return provider, modelID, nil
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl", "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
"qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita", "qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita",
"coding-plan", "alibaba-coding", "qwen-coding", "mimo": "coding-plan", "alibaba-coding", "qwen-coding", "mimo":
// All other OpenAI-compatible HTTP providers // All other OpenAI-compatible HTTP providers
if cfg.APIKey() == "" && cfg.APIBase == "" { if cfg.APIKey() == "" && cfg.APIBase == "" && !isEmptyAPIKeyAllowed(protocol) {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
} }
apiBase := cfg.APIBase apiBase := cfg.APIBase
@ -294,6 +294,15 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
} }
} }
func isEmptyAPIKeyAllowed(protocol string) bool {
switch protocol {
case "ollama", "vllm", "lmstudio":
return true
default:
return false
}
}
// getDefaultAPIBase returns the default API base URL for a given protocol. // getDefaultAPIBase returns the default API base URL for a given protocol.
func getDefaultAPIBase(protocol string) string { func getDefaultAPIBase(protocol string) string {
switch protocol { switch protocol {
@ -303,6 +312,8 @@ func getDefaultAPIBase(protocol string) string {
return "https://openrouter.ai/api/v1" return "https://openrouter.ai/api/v1"
case "litellm": case "litellm":
return "http://localhost:4000/v1" return "http://localhost:4000/v1"
case "lmstudio":
return "http://localhost:1234/v1"
case "novita": case "novita":
return "https://api.novita.ai/openai" return "https://api.novita.ai/openai"
case "groq": case "groq":

View file

@ -121,6 +121,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
{"vllm", "vllm"}, {"vllm", "vllm"},
{"deepseek", "deepseek"}, {"deepseek", "deepseek"},
{"ollama", "ollama"}, {"ollama", "ollama"},
{"lmstudio", "lmstudio"},
{"longcat", "longcat"}, {"longcat", "longcat"},
{"modelscope", "modelscope"}, {"modelscope", "modelscope"},
{"mimo", "mimo"}, {"mimo", "mimo"},
@ -153,6 +154,12 @@ func TestGetDefaultAPIBase_LiteLLM(t *testing.T) {
} }
} }
func TestGetDefaultAPIBase_LMStudio(t *testing.T) {
if got := getDefaultAPIBase("lmstudio"); got != "http://localhost:1234/v1" {
t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "lmstudio", got, "http://localhost:1234/v1")
}
}
func TestCreateProviderFromConfig_LiteLLM(t *testing.T) { func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
cfg := &config.ModelConfig{ cfg := &config.ModelConfig{
ModelName: "test-litellm", ModelName: "test-litellm",
@ -173,6 +180,135 @@ func TestCreateProviderFromConfig_LiteLLM(t *testing.T) {
} }
} }
func TestCreateProviderFromConfig_LMStudio_WithAPIKey(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-lmstudio",
Model: "lmstudio/openai/gpt-oss-20b",
}
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-oss-20b" {
t.Errorf("modelID = %q, want %q", modelID, "openai/gpt-oss-20b")
}
if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("expected *HTTPProvider, got %T", provider)
}
}
func TestCreateProviderFromConfig_LMStudio_NoAPIKey(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-lmstudio",
Model: "lmstudio/openai/gpt-oss-20b",
}
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-oss-20b" {
t.Errorf("modelID = %q, want %q", modelID, "openai/gpt-oss-20b")
}
if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("expected *HTTPProvider, got %T", provider)
}
}
func TestCreateProviderFromConfig_Ollama_WithAPIKey(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-ollama",
Model: "ollama/llama3.1:8b",
}
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 != "llama3.1:8b" {
t.Errorf("modelID = %q, want %q", modelID, "llama3.1:8b")
}
if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("expected *HTTPProvider, got %T", provider)
}
}
func TestCreateProviderFromConfig_Ollama_NoAPIKey(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-ollama",
Model: "ollama/llama3.1:8b",
}
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 != "llama3.1:8b" {
t.Errorf("modelID = %q, want %q", modelID, "llama3.1:8b")
}
if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("expected *HTTPProvider, got %T", provider)
}
}
func TestCreateProviderFromConfig_VLLM_WithAPIKey(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-vllm",
Model: "vllm/Qwen/Qwen3-8B",
}
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 != "Qwen/Qwen3-8B" {
t.Errorf("modelID = %q, want %q", modelID, "Qwen/Qwen3-8B")
}
if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("expected *HTTPProvider, got %T", provider)
}
}
func TestCreateProviderFromConfig_VLLM_NoAPIKey(t *testing.T) {
cfg := &config.ModelConfig{
ModelName: "test-vllm",
Model: "vllm/Qwen/Qwen3-8B",
}
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 != "Qwen/Qwen3-8B" {
t.Errorf("modelID = %q, want %q", modelID, "Qwen/Qwen3-8B")
}
if _, ok := provider.(*HTTPProvider); !ok {
t.Fatalf("expected *HTTPProvider, got %T", provider)
}
}
func TestCreateProviderFromConfig_LongCat(t *testing.T) { func TestCreateProviderFromConfig_LongCat(t *testing.T) {
cfg := &config.ModelConfig{ cfg := &config.ModelConfig{
ModelName: "test-longcat", ModelName: "test-longcat",