Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 96 additions & 0 deletions desktop/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1152,6 +1152,102 @@ func TestModelsForTabOnlyListsProviderAccessWhenConfigured(t *testing.T) {
}
}

func TestModelsForTabListsCustomMultiModelProviderWithoutMetadata(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("LOCAL_API_KEY", "sk-test")
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
if err := os.WriteFile(config.UserConfigPath(), []byte(`
default_model = "local/model-a"

[desktop]
provider_access = ["local"]

[[providers]]
name = "local"
kind = "openai"
base_url = "http://127.0.0.1:23333/v1"
models = ["model-a", "model-b"]
default = "model-a"
api_key_env = "LOCAL_API_KEY"
`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}

models := NewApp().Models()
refs := modelRefsFromView(models)
for _, want := range []string{"local/model-a", "local/model-b"} {
if !refs[want] {
t.Fatalf("Models() refs = %+v, missing %s", models, want)
}
}
if len(models) != 2 {
t.Fatalf("Models() len = %d, want 2: %+v", len(models), models)
}
}

func TestModelsForTabListsKeylessCustomMultiModelProvider(t *testing.T) {
isolateDesktopUserDirs(t)
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
if err := os.WriteFile(config.UserConfigPath(), []byte(`
default_model = "local/model-a"

[desktop]
provider_access = ["local"]

[[providers]]
name = "local"
kind = "openai"
base_url = "http://127.0.0.1:23333/v1"
models = ["model-a", "model-b"]
default = "model-a"
`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}

models := NewApp().Models()
refs := modelRefsFromView(models)
for _, want := range []string{"local/model-a", "local/model-b"} {
if !refs[want] {
t.Fatalf("Models() refs = %+v, missing %s", models, want)
}
}
}

func TestModelsForTabListsLoopbackCustomProviderWithMissingKeyEnv(t *testing.T) {
isolateDesktopUserDirs(t)
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
if err := os.WriteFile(config.UserConfigPath(), []byte(`
default_model = "local/model-a"

[desktop]
provider_access = ["local"]

[[providers]]
name = "local"
kind = "openai"
base_url = "http://127.0.0.1:23333/v1"
models = ["model-a", "model-b"]
default = "model-a"
api_key_env = "LOCAL_API_KEY"
`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}

models := NewApp().Models()
refs := modelRefsFromView(models)
for _, want := range []string{"local/model-a", "local/model-b"} {
if !refs[want] {
t.Fatalf("Models() refs = %+v, missing %s", models, want)
}
}
}

func TestModelsForTabListsMimoAPIPaidAccess(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("MIMO_API_KEY", "sk-test")
Expand Down
19 changes: 19 additions & 0 deletions desktop/frontend/src/__tests__/provider-model-refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
isLikelyChatModel,
isLikelyVisionModel,
mergedFetchedProviderModels,
providerApiKeyEnvForSave,
providerDefaultModel,
providerModelCandidates,
} from "../lib/providerModels";
Expand Down Expand Up @@ -118,5 +119,23 @@ eq(
"falls back to first saved model when default is unavailable",
);

eq(
providerApiKeyEnvForSave("Local Gateway", "", ""),
"",
"keeps custom provider keyless when no key env or key value is supplied",
);

eq(
providerApiKeyEnvForSave("Local Gateway", "", "sk-test"),
"LOCAL_GATEWAY_API_KEY",
"creates a key env when saving an inline key for a new custom provider",
);

eq(
providerApiKeyEnvForSave("Local Gateway", "GATEWAY_KEY", ""),
"GATEWAY_KEY",
"preserves an explicitly configured key env",
);

console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
17 changes: 4 additions & 13 deletions desktop/frontend/src/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { asArray } from "../lib/array";
import { useDeferredClose } from "../lib/useMountTransition";
import { app } from "../lib/bridge";
import { normalizeLangPref, useI18n, useT, type DictKey, type LangPref } from "../lib/i18n";
import { inferredVisionModels, mergedFetchedProviderModels, providerDefaultModel, providerModelCandidates } from "../lib/providerModels";
import { apiKeyEnvFromProviderName, inferredVisionModels, mergedFetchedProviderModels, providerApiKeyEnvForSave, providerDefaultModel, providerModelCandidates } from "../lib/providerModels";
import { useUpdater } from "../lib/useUpdater";
import {
THEME_STYLES,
Expand Down Expand Up @@ -4174,15 +4174,6 @@ function parseBotListInput(value: string): string[] {
.filter(Boolean));
}

function apiKeyEnvFromProviderName(name: string): string {
const stem = name
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
return stem ? `${stem}_API_KEY` : "CUSTOM_API_KEY";
}

function ProviderEditor({
initial,
kinds,
Expand Down Expand Up @@ -4265,7 +4256,7 @@ function ProviderEditor({
setFetchStatus(null);
setFetchErr(null);
try {
const effectiveApiKeyEnv = apiKeyEnv.trim() || apiKeyEnvFromProviderName(name);
const effectiveApiKeyEnv = providerApiKeyEnvForSave(name, apiKeyEnv, keyDraft);
if (!apiKeyEnv.trim()) setApiKeyEnv(effectiveApiKeyEnv);
if (keyDraft.trim()) await app.SetProviderKey(effectiveApiKeyEnv, keyDraft.trim());
const fetched = await app.FetchProviderModels({
Expand Down Expand Up @@ -4307,7 +4298,7 @@ function ProviderEditor({
const save = async () => {
const ms = parseProviderListInput(models);
const vms = parseProviderListInput(visionModels).filter((model) => ms.includes(model));
const effectiveApiKeyEnv = apiKeyEnv.trim() || apiKeyEnvFromProviderName(name);
const effectiveApiKeyEnv = providerApiKeyEnvForSave(name, apiKeyEnv, keyDraft);
if (keyDraft.trim()) await app.SetProviderKey(effectiveApiKeyEnv, keyDraft.trim());
onSave({
name: name.trim(),
Expand Down Expand Up @@ -4370,7 +4361,7 @@ function ProviderEditor({
.split(",")
.map((m) => m.trim())
.filter(Boolean);
const canFetch = Boolean(name.trim() && baseUrl.trim() && (keyDraft.trim() || apiKeyEnv.trim()));
const canFetch = Boolean(name.trim() && baseUrl.trim());

const protocolField = initial ? (
<select className="mem-select" value={kind} onChange={(e) => setKind(e.target.value)}>
Expand Down
15 changes: 15 additions & 0 deletions desktop/frontend/src/lib/providerModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ export function providerDefaultModel(currentDefault: string, models: string[]):
return currentDefault && models.includes(currentDefault) ? currentDefault : models[0] ?? "";
}

export function providerApiKeyEnvForSave(name: string, apiKeyEnv: string, keyDraft: string): string {
const explicit = apiKeyEnv.trim();
if (explicit) return explicit;
return keyDraft.trim() ? apiKeyEnvFromProviderName(name) : "";
}

export function apiKeyEnvFromProviderName(name: string): string {
const stem = name
.trim()
.toUpperCase()
.replace(/[^A-Z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
return stem ? `${stem}_API_KEY` : "CUSTOM_API_KEY";
}

export function isLikelyChatModel(model: string): boolean {
const lower = model.trim().toLowerCase();
if (!lower) return false;
Expand Down
52 changes: 47 additions & 5 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package config
import (
"fmt"
"log/slog"
"net/netip"
"net/url"
"os"
"path/filepath"
Expand Down Expand Up @@ -2030,7 +2031,7 @@ func NormalizeLegacyDesktopProviderAccess(c *Config) {
}
for i := range c.Providers {
p := &c.Providers[i]
if p.Configured() {
if p.Configured() && len(p.ModelList()) > 0 {
add(p.Name)
}
}
Expand Down Expand Up @@ -2867,10 +2868,51 @@ func (e *ProviderEntry) APIKey() string {
return os.Getenv(e.APIKeyEnv)
}

// Configured reports whether the provider's api_key_env is set — the same check
// Validate enforces, so pickers can filter on it.
// RequiresAPIKey reports whether this provider should be hidden/validated when
// its configured api_key_env is empty. A blank api_key_env means the provider is
// intentionally no-auth. Local OpenAI-compatible gateways often keep a legacy
// api_key_env in config even though they accept unauthenticated requests, so
// loopback/private endpoints are also allowed to run without a resolved key.
func (e *ProviderEntry) RequiresAPIKey() bool {
if e == nil {
return false
}
if strings.TrimSpace(e.APIKeyEnv) == "" {
return providerBaseURLRequiresAPIKey(e.BaseURL)
}
return !providerBaseURLAllowsMissingAPIKey(e.BaseURL)
}

func providerBaseURLRequiresAPIKey(raw string) bool {
switch officialProviderHost(raw) {
case "api.deepseek.com", "api.xiaomimimo.com", "token-plan-cn.xiaomimimo.com", "api.minimaxi.com", "api.openai.com":
return true
default:
return false
}
}

func providerBaseURLAllowsMissingAPIKey(raw string) bool {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil {
return false
}
host := strings.Trim(strings.ToLower(u.Hostname()), "[]")
if host == "localhost" || strings.HasSuffix(host, ".localhost") {
return true
}
addr, err := netip.ParseAddr(host)
if err != nil {
return false
}
return addr.IsLoopback() || addr.IsPrivate() || addr.IsLinkLocalUnicast()
}

// Configured reports whether the provider is selectable. Providers that do not
// require an API key are configured by definition; providers that name an env var
// require that variable to resolve unless their endpoint is local/private.
func (e *ProviderEntry) Configured() bool {
return e.APIKey() != ""
return e != nil && (!e.RequiresAPIKey() || e.APIKey() != "")
}

// ResolveSystemPrompt returns the system prompt, reading system_prompt_file if set.
Expand Down Expand Up @@ -2911,7 +2953,7 @@ func (c *Config) Validate(model string) error {
if e.BaseURL == "" {
return fmt.Errorf("provider %q: base_url is required", model)
}
if e.APIKey() == "" {
if e.RequiresAPIKey() && e.APIKey() == "" {
return fmt.Errorf("provider %q: missing env %s", model, e.APIKeyEnv)
}
return nil
Expand Down
32 changes: 28 additions & 4 deletions internal/config/configured_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ package config

import "testing"

// TestProviderConfigured verifies Configured tracks whether the api_key_env
// resolves to a non-empty value — the same key check Validate enforces at build
// time, so model pickers can filter on it.
// TestProviderConfigured verifies Configured tracks whether the provider can be
// selected. Providers with no api_key_env are explicit no-auth providers; if an
// env var is configured, it must resolve to a non-empty value.
func TestProviderConfigured(t *testing.T) {
t.Setenv("REASONIX_TEST_KEY", "secret")
t.Setenv("REASONIX_TEST_EMPTY", "")
Expand All @@ -17,11 +17,35 @@ func TestProviderConfigured(t *testing.T) {
{"key set", ProviderEntry{APIKeyEnv: "REASONIX_TEST_KEY"}, true},
{"key env empty", ProviderEntry{APIKeyEnv: "REASONIX_TEST_EMPTY"}, false},
{"key env unset", ProviderEntry{APIKeyEnv: "REASONIX_TEST_MISSING"}, false},
{"no api_key_env", ProviderEntry{}, false},
{"loopback key env unset", ProviderEntry{BaseURL: "http://127.0.0.1:23333/v1", APIKeyEnv: "REASONIX_TEST_MISSING"}, true},
{"official endpoint without key env", ProviderEntry{BaseURL: "https://api.deepseek.com"}, false},
{"no api_key_env", ProviderEntry{}, true},
}
for _, c := range cases {
if got := c.p.Configured(); got != c.want {
t.Errorf("%s: Configured() = %v, want %v", c.name, got, c.want)
}
}
}

func TestValidateAllowsNoAuthProvider(t *testing.T) {
c := &Config{
DefaultModel: "local/model-a",
Providers: []ProviderEntry{{
Name: "local",
Kind: "openai",
BaseURL: "http://127.0.0.1:23333/v1",
Models: []string{"model-a", "model-b"},
Default: "model-a",
}},
}

if err := c.Validate("local/model-b"); err != nil {
t.Fatalf("Validate no-auth local provider: %v", err)
}

c.Providers[0].APIKeyEnv = "LOCAL_API_KEY"
if err := c.Validate("local/model-b"); err != nil {
t.Fatalf("Validate loopback local provider with missing key env: %v", err)
}
}
2 changes: 1 addition & 1 deletion internal/config/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ func (e *ProviderEntry) FetchModels(ctx context.Context) ([]string, error) {
return nil, fmt.Errorf("fetch models: provider %q has no base_url", e.Name)
}
key := e.APIKey()
if key == "" {
if e.RequiresAPIKey() && key == "" {
return nil, fmt.Errorf("fetch models: provider %q has no API key (set %s in .env)", e.Name, e.APIKeyEnv)
}
candidates, err := BuildModelFetchURLs(e.BaseURL, e.ModelsURL)
Expand Down
22 changes: 22 additions & 0 deletions internal/config/fetch_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,25 @@ func TestProviderFetchModelsFallsBackToV1Models(t *testing.T) {
t.Fatalf("got %v, want [model-a model-b]", got)
}
}

func TestProviderFetchModelsAllowsNoAuthEndpoint(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "" {
http.Error(w, "unexpected auth header", http.StatusBadRequest)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{
"data": []map[string]string{{"id": "local-b"}, {"id": "local-a"}},
})
}))
defer srv.Close()

p := ProviderEntry{Name: "local", BaseURL: srv.URL}
got, err := p.FetchModels(context.Background())
if err != nil {
t.Fatalf("FetchModels no-auth: %v", err)
}
if len(got) != 2 || got[0] != "local-a" || got[1] != "local-b" {
t.Fatalf("got %v, want [local-a local-b]", got)
}
}
4 changes: 3 additions & 1 deletion internal/provider/openai/fetch_models.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ func FetchModels(ctx context.Context, baseURL, apiKey string) ([]string, error)
if err != nil {
return nil, fmt.Errorf("fetch models: build request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
if strings.TrimSpace(apiKey) != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
req.Header.Set("Accept", "application/json")

resp, err := cli.Do(req)
Expand Down
4 changes: 3 additions & 1 deletion internal/provider/openai/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,9 @@ func (c *client) Stream(ctx context.Context, req provider.Request) (<-chan provi
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
if c.apiKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
}
httpReq.Header.Set("Accept", "text/event-stream")
return httpReq, nil
}
Expand Down