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",
"api_key": "sk-your-deepseek-key"
},
{
"model_name": "lmstudio-local",
"model": "lmstudio/openai/gpt-oss-20b"
},
{
"model_name": "longcat",
"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) |
| **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 |
@ -710,6 +711,21 @@ For direct Anthropic API access or custom endpoints that only support Anthropic'
</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>
<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) |
| **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 |
@ -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**
```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) |
| **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 | 本地 |
@ -506,6 +507,21 @@ Agent 读取 HEARTBEAT.md
</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>
<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) |
| **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 | 本地 |
@ -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**
```json

View file

@ -154,13 +154,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
}
return provider, modelID, nil
case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia",
case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
"vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
"qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita",
"coding-plan", "alibaba-coding", "qwen-coding", "mimo":
// 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)
}
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.
func getDefaultAPIBase(protocol string) string {
switch protocol {
@ -303,6 +312,8 @@ func getDefaultAPIBase(protocol string) string {
return "https://openrouter.ai/api/v1"
case "litellm":
return "http://localhost:4000/v1"
case "lmstudio":
return "http://localhost:1234/v1"
case "novita":
return "https://api.novita.ai/openai"
case "groq":

View file

@ -121,6 +121,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) {
{"vllm", "vllm"},
{"deepseek", "deepseek"},
{"ollama", "ollama"},
{"lmstudio", "lmstudio"},
{"longcat", "longcat"},
{"modelscope", "modelscope"},
{"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) {
cfg := &config.ModelConfig{
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) {
cfg := &config.ModelConfig{
ModelName: "test-longcat",