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
3 changes: 3 additions & 0 deletions desktop/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,9 @@ func TestSettingsLoadsActiveWorkspaceCredentialsWithUserConfig(t *testing.T) {
if !p.KeySet {
t.Fatalf("workspace provider keySet = false, want true from active workspace .env: %+v", p)
}
if !p.Configured {
t.Fatalf("workspace provider configured = false, want true from active workspace .env: %+v", p)
}
return
}
}
Expand Down
14 changes: 14 additions & 0 deletions desktop/frontend/src/__tests__/provider-model-refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
mergedFetchedProviderModels,
providerApiKeyEnvForSave,
providerDefaultModel,
providerIsConfigured,
providerModelCandidates,
providerRequiresKey,
} from "../lib/providerModels";

let passed = 0;
Expand Down Expand Up @@ -137,5 +139,17 @@ eq(
"preserves an explicitly configured key env",
);

eq(
[
providerRequiresKey({ apiKeyEnv: "" }),
providerIsConfigured({ apiKeyEnv: "", keySet: false }),
providerIsConfigured({ apiKeyEnv: "LOCAL_API_KEY", keySet: false, requiresKey: false }),
providerIsConfigured({ apiKeyEnv: "REMOTE_API_KEY", keySet: false, requiresKey: true }),
providerIsConfigured({ apiKeyEnv: "REMOTE_API_KEY", keySet: true, requiresKey: true }),
],
[false, true, true, false, true],
"separates provider selectability from key presence for no-auth providers",
);

console.log(`\n${passed} passed, ${failed} failed, ${passed + failed} total`);
if (failed > 0) process.exit(1);
39 changes: 27 additions & 12 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 { apiKeyEnvFromProviderName, inferredVisionModels, mergedFetchedProviderModels, providerApiKeyEnvForSave, providerDefaultModel, providerModelCandidates } from "../lib/providerModels";
import { apiKeyEnvFromProviderName, inferredVisionModels, mergedFetchedProviderModels, providerApiKeyEnvForSave, providerDefaultModel, providerIsConfigured, providerModelCandidates, providerRequiresKey } from "../lib/providerModels";
import { useUpdater } from "../lib/useUpdater";
import {
THEME_STYLES,
Expand Down Expand Up @@ -569,7 +569,7 @@ function ShortcutsSection() {
function allRefs(s: SettingsView): string[] {
const out: string[] = [];
for (const p of s.providers) {
if (!p.added || !p.keySet) continue;
if (!p.added || !providerIsConfigured(p)) continue;
for (const m of p.models) out.push(`${p.name}/${m}`);
}
return out;
Expand Down Expand Up @@ -754,6 +754,7 @@ function normalizeBotMappingScope(scope: unknown, workspaceRoot: unknown): "glob

function normalizeProviderView(p: ProviderView): ProviderView {
const visionModels = asArray(p.visionModels);
const requiresKey = providerRequiresKey(p);
return {
...p,
builtIn: Boolean(p.builtIn),
Expand All @@ -764,6 +765,8 @@ function normalizeProviderView(p: ProviderView): ProviderView {
modelsUrl: p.modelsUrl ?? "",
reasoningProtocol: normalizeReasoningProtocol(p.reasoningProtocol),
supportedEfforts: asArray(p.supportedEfforts),
requiresKey,
configured: providerIsConfigured({ ...p, requiresKey }),
keySource: p.keySource ?? "",
keySourcePath: p.keySourcePath ?? "",
};
Expand Down Expand Up @@ -2966,7 +2969,7 @@ function ModelsSection({ s, busy, apply, backgroundApply }: ModelsSectionProps)
const defaultProviderView = s.providers.find((p) => p.name === defaultProvider);
const modelIssue = !defaultProviderView
? t("settings.modelUnavailable", { ref: defaultRef || t("common.none") })
: !defaultProviderView.keySet
: !providerIsConfigured(defaultProviderView)
? t("settings.modelNeedsKey", { provider: modelProviderLabel(defaultProvider, defaultProviderView, t) })
: "";
const agent = s.agent ?? { temperature: 0, maxSteps: 0, plannerMaxSteps: 12, systemPrompt: "", coldResumePrune: true, reasoningLanguage: "auto" };
Expand All @@ -2979,11 +2982,11 @@ function ModelsSection({ s, busy, apply, backgroundApply }: ModelsSectionProps)
const groups = providerAccessGroups(s.providers.filter((p) => p.added), t);
const candidates = groups
.map((group) => {
const provider = group.providers.find((p) => p.keySet && p.apiKeyEnv && p.baseUrl);
const provider = group.providers.find((p) => providerIsConfigured(p) && p.baseUrl);
return provider ? { group, provider } : null;
})
.filter((item): item is { group: ProviderAccessGroup; provider: ProviderView } => Boolean(item));
const refreshKey = candidates.map(({ group, provider }) => `${group.id}:${provider.apiKeyEnv}`).join("|");
const refreshKey = candidates.map(({ group, provider }) => `${group.id}:${provider.apiKeyEnv || provider.name}:${provider.baseUrl}`).join("|");
if (!refreshKey || autoRefreshKeyRef.current === refreshKey) return;
autoRefreshKeyRef.current = refreshKey;

Expand Down Expand Up @@ -3211,6 +3214,7 @@ function ModelPicker({
groupID,
label: firstProvider ? providerGroupLabel(firstProvider, t) : groupID,
keySet: providerViews.some((p) => p.keySet),
requiresKey: providerViews.every((p) => providerRequiresKey(p)),
options: uniqueModelOptions(options.filter((opt) => modelOptionGroupID(opt) === groupID)),
};
})
Expand Down Expand Up @@ -3279,7 +3283,7 @@ function ModelPicker({
<div className="settings-model-picker__group" key={group.groupID}>
<div className="settings-model-picker__group-title">
<span>{group.label}</span>
<small>{group.keySet ? t("settings.keySet") : t("settings.noKey")}</small>
<small>{providerKeyStatusLabel(group, t)}</small>
</div>
{group.options.map((opt) => (
<button
Expand Down Expand Up @@ -3319,10 +3323,15 @@ function modelOptionFromRef(ref: string, s: SettingsView): ModelPickerOption | n
}

function modelOptionMeta(option: ModelPickerOption, t: ReturnType<typeof useT>): string {
const key = option.providerView?.keySet ? t("settings.keySet") : t("settings.noKey");
const key = option.providerView ? providerKeyStatusLabel(option.providerView, t) : t("settings.noKey");
return `${modelProviderLabel(option.provider, option.providerView, t)} · ${key}`;
}

function providerKeyStatusLabel(provider: { keySet: boolean; requiresKey?: boolean; apiKeyEnv?: string }, t: ReturnType<typeof useT>): string {
if (!providerRequiresKey(provider)) return t("settings.noKeyRequired");
return provider.keySet ? t("settings.keySet") : t("settings.noKey");
}

function modelProviderLabel(provider: string, providerView: ProviderView | undefined, t: ReturnType<typeof useT>): string {
return providerView ? providerGroupLabel(providerView, t) : provider;
}
Expand Down Expand Up @@ -3623,6 +3632,8 @@ type ProviderAccessGroup = {
providers: ProviderView[];
apiKeyEnv: string;
keySet: boolean;
requiresKey: boolean;
configured: boolean;
keySource?: string;
keySourcePath?: string;
baseUrl: string;
Expand Down Expand Up @@ -3835,7 +3846,7 @@ function ProviderAccessCard({
{group.builtIn ? t("settings.builtinProviderBadge") : t("settings.customProviderBadge")}
</span>
<span className={`badge ${group.keySet ? "badge--project" : "badge--feedback"}`}>
{group.keySet ? t("settings.keySet") : t("settings.noKey")}
{providerKeyStatusLabel(group, t)}
</span>
</div>
<div className="provider-access-card__desc">{group.description}</div>
Expand All @@ -3853,7 +3864,7 @@ function ProviderAccessCard({
)}
<button
className="btn btn--small"
disabled={busy || fetching || !group.baseUrl || !group.apiKeyEnv || !group.keySet}
disabled={busy || fetching || !group.baseUrl || !group.configured}
onClick={onRefresh}
>
{fetching ? t("settings.fetchingModels") : t("settings.fetchModels")}
Expand Down Expand Up @@ -3885,8 +3896,8 @@ function ProviderAccessCard({
</div>

<div className="provider-card-block">
<div className="provider-card-block__label">{t(group.keySet ? "settings.enabledModels" : "settings.modelList")}</div>
<div className="provider-model-chips" aria-label={t(group.keySet ? "settings.enabledModels" : "settings.modelList")}>
<div className="provider-card-block__label">{t(group.configured ? "settings.enabledModels" : "settings.modelList")}</div>
<div className="provider-model-chips" aria-label={t(group.configured ? "settings.enabledModels" : "settings.modelList")}>
{visibleModels.length > 0 ? visibleModels.map((model) => (
<span className="provider-model-chip" key={model}>
{model}
Expand All @@ -3898,7 +3909,7 @@ function ProviderAccessCard({
</span>
)}
</div>
{!group.keySet && (
{!group.configured && group.requiresKey && (
<div className="provider-card-status provider-card-status--warn">
{t("settings.modelsRequireKey")}
</div>
Expand Down Expand Up @@ -4065,6 +4076,8 @@ function providerAccessGroups(providers: ProviderView[], t: ReturnType<typeof us
if (existing) {
existing.providers.push(p);
existing.keySet = existing.keySet || p.keySet;
existing.requiresKey = existing.requiresKey && providerRequiresKey(p);
existing.configured = existing.configured || providerIsConfigured(p);
if (!existing.keySource && p.keySource) existing.keySource = p.keySource;
if (!existing.keySourcePath && p.keySourcePath) existing.keySourcePath = p.keySourcePath;
existing.models = uniqueStrings([...existing.models, ...p.models]);
Expand All @@ -4078,6 +4091,8 @@ function providerAccessGroups(providers: ProviderView[], t: ReturnType<typeof us
providers: [p],
apiKeyEnv: p.apiKeyEnv,
keySet: p.keySet,
requiresKey: providerRequiresKey(p),
configured: providerIsConfigured(p),
keySource: p.keySource,
keySourcePath: p.keySourcePath,
baseUrl: p.baseUrl,
Expand Down
3 changes: 2 additions & 1 deletion desktop/frontend/src/lib/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type * as GeneratedApp from "../../wailsjs/go/main/App";

import { addBreadcrumb } from "./breadcrumbs";
import { t } from "./i18n";
import { providerRequiresKey } from "./providerModels";
import { DEFAULT_STATUS_BAR_ITEMS, normalizeStatusBarItems } from "./statusBarItems";
import { modeWithAutoApproveTools, modeWithPlan, normalizeCollaborationMode, normalizeMode, normalizeTokenMode, normalizeToolApprovalMode } from "./types";

Expand Down Expand Up @@ -2394,7 +2395,7 @@ function makeMockApp(): AppBindings {
},
async FetchProviderModels(p: ProviderView) {
if (!p.baseUrl.trim()) throw new Error(t("settings.fetchModelsMissingBaseUrl"));
if (!p.apiKeyEnv.trim()) throw new Error(t("settings.fetchModelsMissingKeyEnv"));
if (providerRequiresKey(p) && !p.apiKeyEnv.trim()) throw new Error(t("settings.fetchModelsMissingKeyEnv"));
await delay(350);
if (p.baseUrl.includes("deepseek")) return ["deepseek-v4-flash", "deepseek-v4-pro"];
if (p.baseUrl.includes("mimo") || p.baseUrl.includes("xiaomimimo")) return ["mimo-v2.5", "mimo-v2.5-pro"];
Expand Down
10 changes: 10 additions & 0 deletions desktop/frontend/src/lib/providerModels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ export function providerDefaultModel(currentDefault: string, models: string[]):
return currentDefault && models.includes(currentDefault) ? currentDefault : models[0] ?? "";
}

export function providerRequiresKey(provider: { requiresKey?: boolean; apiKeyEnv?: string }): boolean {
if (typeof provider.requiresKey === "boolean") return provider.requiresKey;
return Boolean((provider.apiKeyEnv ?? "").trim());
}

export function providerIsConfigured(provider: { configured?: boolean; requiresKey?: boolean; apiKeyEnv?: string; keySet?: boolean }): boolean {
if (typeof provider.configured === "boolean") return provider.configured;
return !providerRequiresKey(provider) || Boolean(provider.keySet);
}

export function providerApiKeyEnvForSave(name: string, apiKeyEnv: string, keyDraft: string): string {
const explicit = apiKeyEnv.trim();
if (explicit) return explicit;
Expand Down
2 changes: 2 additions & 0 deletions desktop/frontend/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,8 @@ export interface ProviderView {
default: string;
apiKeyEnv: string;
keySet: boolean; // the env var currently resolves to a value
requiresKey?: boolean; // false for explicit no-auth providers
configured?: boolean; // selectable: key is set or no key is required
keySource?: string;
keySourcePath?: string;
balanceUrl: string; // optional wallet-balance endpoint; "" disables the readout
Expand Down
1 change: 1 addition & 0 deletions desktop/frontend/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,7 @@ export const en = {
"settings.notificationSoundPreview": "Preview",
"settings.keySet": "key set",
"settings.noKey": "no key",
"settings.noKeyRequired": "no key required",
"settings.cantDeleteDefault": "Can't delete the default provider",
"settings.cantRemoveDefault": "Can't remove access for the default provider",
"settings.deleteProvider": "Delete provider",
Expand Down
1 change: 1 addition & 0 deletions desktop/frontend/src/locales/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,7 @@ export const zhTW: Record<DictKey, string> = {
"settings.stepLimit.custom": "自訂",
"settings.keySet": "已設金鑰",
"settings.noKey": "無金鑰",
"settings.noKeyRequired": "無需金鑰",
"settings.cantDeleteDefault": "無法刪除預設模型服務",
"settings.cantRemoveDefault": "無法移除預設模型服務接入",
"settings.deleteProvider": "刪除模型服務",
Expand Down
1 change: 1 addition & 0 deletions desktop/frontend/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -830,6 +830,7 @@ export const zh: Record<DictKey, string> = {
"settings.notificationSoundPreview": "预览",
"settings.keySet": "已设密钥",
"settings.noKey": "无密钥",
"settings.noKeyRequired": "无需密钥",
"settings.cantDeleteDefault": "无法删除默认模型服务",
"settings.cantRemoveDefault": "无法移除默认模型服务接入",
"settings.deleteProvider": "删除模型服务",
Expand Down
5 changes: 5 additions & 0 deletions desktop/settings_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ type ProviderView struct {
Default string `json:"default"`
APIKeyEnv string `json:"apiKeyEnv"`
KeySet bool `json:"keySet"` // the env var currently resolves to a non-empty value
RequiresKey bool `json:"requiresKey"`
Configured bool `json:"configured"` // selectable: either key is present or no key is required
KeySource string `json:"keySource,omitempty"`
KeySourcePath string `json:"keySourcePath,omitempty"`
BalanceURL string `json:"balanceUrl"`
Expand Down Expand Up @@ -285,11 +287,14 @@ func providerViewFromEntryForRoot(p config.ProviderEntry, builtIn, added bool, r
visionModels = models
}
key := config.ResolveCredentialForRootGlobalFirst(root, p.APIKeyEnv)
requiresKey := p.RequiresAPIKey()
return ProviderView{
Name: p.Name, BuiltIn: builtIn, Added: added, Kind: p.Kind, BaseURL: p.BaseURL,
Models: nonNil(models), VisionModels: nonNil(providerVisionModels(models, visionModels)), VisionModelsSet: visionModelsSet, ModelsURL: p.ModelsURL, Default: p.DefaultModel(),
APIKeyEnv: p.APIKeyEnv,
KeySet: key.Set,
RequiresKey: requiresKey,
Configured: !requiresKey || key.Set,
KeySource: key.Source.Label,
KeySourcePath: key.Source.Path,
BalanceURL: p.BalanceURL,
Expand Down
52 changes: 52 additions & 0 deletions desktop/settings_app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,63 @@ func TestProviderViewFromEntryShowsKeySource(t *testing.T) {
if !view.KeySet {
t.Fatal("KeySet = false, want true")
}
if !view.Configured {
t.Fatal("Configured = false, want true from resolved credentials")
}
if view.KeySource == "" || !strings.Contains(view.KeySource, "credentials") {
t.Fatalf("KeySource = %q, want credentials source", view.KeySource)
}
}

func TestProviderViewFromEntryExposesNoAuthAvailability(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("LOCAL_API_KEY", "")
os.Unsetenv("LOCAL_API_KEY")

noAuth := providerViewFromEntry(config.ProviderEntry{
Name: "local",
Kind: "openai",
BaseURL: "http://127.0.0.1:23333/v1",
Models: []string{"model-a"},
}, false, true)
if noAuth.RequiresKey {
t.Fatal("no-auth provider RequiresKey = true, want false")
}
if !noAuth.Configured {
t.Fatal("no-auth provider Configured = false, want true")
}
if noAuth.KeySet {
t.Fatal("no-auth provider KeySet = true, want false")
}

legacyLoopback := providerViewFromEntry(config.ProviderEntry{
Name: "local",
Kind: "openai",
BaseURL: "http://127.0.0.1:23333/v1",
Models: []string{"model-a"},
APIKeyEnv: "LOCAL_API_KEY",
}, false, true)
if legacyLoopback.RequiresKey {
t.Fatal("loopback provider with missing legacy key env RequiresKey = true, want false")
}
if !legacyLoopback.Configured {
t.Fatal("loopback provider with missing legacy key env Configured = false, want true")
}

official := providerViewFromEntry(config.ProviderEntry{
Name: "deepseek",
Kind: "openai",
BaseURL: "https://api.deepseek.com",
Models: []string{"deepseek-v4-flash"},
}, true, true)
if !official.RequiresKey {
t.Fatal("official provider RequiresKey = false, want true")
}
if official.Configured {
t.Fatal("official provider without key Configured = true, want false")
}
}

func TestSetProviderKeyWarnsWhenProjectEnvWillShadowSavedKey(t *testing.T) {
isolateDesktopUserDirs(t)
project := t.TempDir()
Expand Down
2 changes: 1 addition & 1 deletion internal/boot/boot.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
// A resolvable model whose API key env is unset would otherwise build fine
// (RequireKey is false so the UI stays reachable) and then fail silently on the
// first request, showing as an empty/dead model. Surface the cause up front.
if !opts.RequireKey && entry.APIKeyEnv != "" && entry.APIKey() == "" {
if !opts.RequireKey && entry.RequiresAPIKey() && entry.APIKey() == "" {
sink.Emit(event.Event{Kind: event.Notice, Text: fmt.Sprintf("model %q is selected but its API key %s is not set — requests will fail until you set it", modelName, entry.APIKeyEnv)})
}
jm := jobs.NewManager(sink, jobs.WithStalledWarningAfter(time.Duration(cfg.BackgroundJobStalledWarningSeconds())*time.Second))
Expand Down
36 changes: 36 additions & 0 deletions internal/boot/model_error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,39 @@ api_key_env = "`+keyEnv+`"
t.Fatalf("expected a notice naming the unset key env %q; got %v", keyEnv, notices)
}
}

func TestBuildDoesNotNoticeMissingAPIKeyForNoAuthLoopback(t *testing.T) {
const keyEnv = "REASONIX_LOCAL_GATEWAY_KEY_FOR_TEST"
dir := robustTempDir(t)
t.Chdir(dir)
t.Setenv(keyEnv, "")
writeFile(t, dir, "reasonix.toml", `
default_model = "local/model-a"

[[providers]]
name = "local"
kind = "openai"
base_url = "http://127.0.0.1:23333/v1"
models = ["model-a"]
api_key_env = "`+keyEnv+`"
`)

var notices []string
ctrl, err := Build(context.Background(), Options{
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
notices = append(notices, e.Text)
}
}),
})
if err != nil {
t.Fatalf("Build should allow no-auth loopback provider without a key: %v", err)
}
defer ctrl.Close()

for _, n := range notices {
if strings.Contains(n, keyEnv) {
t.Fatalf("did not expect missing-key notice for loopback no-auth provider; got %v", notices)
}
}
}