Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 5 additions & 1 deletion desktop/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -5850,7 +5850,8 @@ func (a *App) ConnectKey(apiKey string) error {
if _, err := billing.FetchWithClient(ctx, nil, onboardingBalanceURL, apiKey); err != nil {
return fmt.Errorf("validate: %w", err)
}
if err := upsertDotEnv(onboardingKeyEnv, apiKey); err != nil {
warning, err := a.saveProviderCredential(onboardingKeyEnv, apiKey)
if err != nil {
return fmt.Errorf("save: %w", err)
}
if err := a.rebuild(); err != nil {
Expand All @@ -5861,5 +5862,8 @@ func (a *App) ConnectKey(apiKey string) error {
}
a.mu.Unlock()
}
if warning != "" {
return fmt.Errorf("%s", warning)
}
return nil
}
2 changes: 2 additions & 0 deletions desktop/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -815,6 +815,8 @@ func TestSettingsDoesNotInferBuiltInsWithoutKeys(t *testing.T) {

func TestAddOfficialProviderAccessReplacesLegacyProviderWithoutModel(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("DEEPSEEK_API_KEY", "")
os.Unsetenv("DEEPSEEK_API_KEY")
if err := os.MkdirAll(filepath.Dir(config.UserConfigPath()), 0o755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
Expand Down
19 changes: 18 additions & 1 deletion desktop/frontend/src/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,8 @@ function normalizeProviderView(p: ProviderView): ProviderView {
modelsUrl: p.modelsUrl ?? "",
reasoningProtocol: normalizeReasoningProtocol(p.reasoningProtocol),
supportedEfforts: asArray(p.supportedEfforts),
keySource: p.keySource ?? "",
keySourcePath: p.keySourcePath ?? "",
};
}

Expand Down Expand Up @@ -3589,6 +3591,8 @@ type ProviderAccessGroup = {
providers: ProviderView[];
apiKeyEnv: string;
keySet: boolean;
keySource?: string;
keySourcePath?: string;
baseUrl: string;
kind: string;
models: string[];
Expand Down Expand Up @@ -3845,6 +3849,7 @@ function ProviderAccessCard({
<span>{group.kind}</span>
<span>{group.baseUrl}</span>
<span>{group.apiKeyEnv || t("common.none")}</span>
{group.keySource && <span title={group.keySourcePath || undefined}>{t("settings.keySource", { source: group.keySource })}</span>}
</div>

<div className="provider-card-block">
Expand Down Expand Up @@ -4028,6 +4033,8 @@ function providerAccessGroups(providers: ProviderView[], t: ReturnType<typeof us
if (existing) {
existing.providers.push(p);
existing.keySet = existing.keySet || p.keySet;
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]);
continue;
}
Expand All @@ -4039,6 +4046,8 @@ function providerAccessGroups(providers: ProviderView[], t: ReturnType<typeof us
providers: [p],
apiKeyEnv: p.apiKeyEnv,
keySet: p.keySet,
keySource: p.keySource,
keySourcePath: p.keySourcePath,
baseUrl: p.baseUrl,
kind: p.kind,
models: uniqueStrings(p.models),
Expand Down Expand Up @@ -4302,7 +4311,10 @@ function ProviderEditor({
{initial && onSaveKey && keyEnv && (
<>
<div className="provider-key-status provider-key-status--managed provider-key-status--compact">
<span>{initial.keySet ? t("settings.configuredKey", { env: keyEnv }) : t("settings.notConfiguredKey", { env: keyEnv })}</span>
<span title={initial.keySourcePath || undefined}>
{initial.keySet ? t("settings.configuredKey", { env: keyEnv }) : t("settings.notConfiguredKey", { env: keyEnv })}
{initial.keySource ? ` · ${t("settings.keySource", { source: initial.keySource })}` : ""}
</span>
{initial.keySet && onClearKey && (
<InlineConfirmButton
label={t("settings.clearKey")}
Expand Down Expand Up @@ -4487,6 +4499,11 @@ function ProviderEditor({
{initial && onSaveKey && apiKeyEnv.trim() && (
<>
<label className="set-label">{t("settings.providerKey")}</label>
{initial.keySource && (
<div className="mem-hint" title={initial.keySourcePath || undefined}>
{t("settings.keySource", { source: initial.keySource })}
</div>
)}
<KeyField
apiKeyEnv={apiKeyEnv.trim()}
busy={busy || fetchingModels}
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 @@ -653,6 +653,8 @@ export interface ProviderView {
default: string;
apiKeyEnv: string;
keySet: boolean; // the env var currently resolves to a value
keySource?: string;
keySourcePath?: string;
balanceUrl: string; // optional wallet-balance endpoint; "" disables the readout
contextWindow: number;
reasoningProtocol: string; // auto|deepseek|openai|none; empty = auto/model registry
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 @@ -1242,6 +1242,7 @@ export const en = {
"settings.keyStatus": "Key",
"settings.configuredKey": "Saved {env}",
"settings.notConfiguredKey": "No key saved for {env}",
"settings.keySource": "Source: {source}",
"settings.moreModels": "{n} more",
"settings.permissionsModeHint": "Controls the default behavior for file writes, shell commands, and other writer actions.",
"settings.modeAskShort": "Prompt before writers",
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 @@ -759,6 +759,7 @@ export const zhTW: Record<DictKey, string> = {
"settings.keyStatus": "金鑰",
"settings.configuredKey": "已填寫 {env}",
"settings.notConfiguredKey": "未填寫 {env}",
"settings.keySource": "來源:{source}",
"settings.moreModels": "還有 {n} 個",
"settings.permissionsModeHint": "控制寫檔案、執行命令等寫操作預設如何被處理。",
"settings.modeAskShort": "寫操作前詢問",
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 @@ -1244,6 +1244,7 @@ export const zh: Record<DictKey, string> = {
"settings.keyStatus": "密钥",
"settings.configuredKey": "已填写 {env}",
"settings.notConfiguredKey": "未填写 {env}",
"settings.keySource": "来源:{source}",
"settings.moreModels": "还有 {n} 个",
"settings.permissionsModeHint": "控制写文件、执行命令等写操作默认如何被处理。",
"settings.modeAskShort": "写操作前询问",
Expand Down
72 changes: 63 additions & 9 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
KeySource string `json:"keySource,omitempty"`
KeySourcePath string `json:"keySourcePath,omitempty"`
BalanceURL string `json:"balanceUrl"`
ContextWindow int `json:"contextWindow"`
ReasoningProtocol string `json:"reasoningProtocol"`
Expand Down Expand Up @@ -272,17 +274,24 @@ func removeProviderAccess(c *config.Config, names ...string) {
}

func providerViewFromEntry(p config.ProviderEntry, builtIn, added bool) ProviderView {
return providerViewFromEntryForRoot(p, builtIn, added, ".")
}

func providerViewFromEntryForRoot(p config.ProviderEntry, builtIn, added bool, root string) ProviderView {
models := p.ChatModelList()
visionModels := p.VisionModels
visionModelsSet := p.Vision || p.VisionModels != nil
if p.Vision {
visionModels = models
}
key := config.ResolveCredentialForRoot(root, p.APIKeyEnv)
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: p.APIKeyEnv != "" && os.Getenv(p.APIKeyEnv) != "",
KeySet: key.Set,
KeySource: key.Source.Label,
KeySourcePath: key.Source.Path,
BalanceURL: p.BalanceURL,
ContextWindow: p.ContextWindow,
ReasoningProtocol: p.ReasoningProtocol,
Expand All @@ -292,14 +301,18 @@ func providerViewFromEntry(p config.ProviderEntry, builtIn, added bool) Provider
}

func officialProviderViews(added map[string]bool, pricingLanguage string) []ProviderView {
return officialProviderViewsForRoot(added, pricingLanguage, ".")
}

func officialProviderViewsForRoot(added map[string]bool, pricingLanguage, root string) []ProviderView {
var out []ProviderView
for _, kind := range []string{"deepseek", "mimo-api", "mimo-token-plan"} {
entries, _, err := officialProviderTemplate(kind, pricingLanguage)
if err != nil {
continue
}
for _, entry := range entries {
out = append(out, providerViewFromEntry(entry, true, added[entry.Name]))
out = append(out, providerViewFromEntryForRoot(entry, true, added[entry.Name], root))
}
}
return out
Expand Down Expand Up @@ -414,10 +427,11 @@ func (a *App) Settings() SettingsView {
Bypass: ctrl != nil && ctrl.AutoApproveTools(),
}
added := providerAccessSet(cfg.Desktop.ProviderAccess)
v.OfficialProviders = officialProviderViews(officialProviderAddedSet(cfg), cfg.DeepSeekOfficialPricingLanguage())
root := a.activeWorkspaceRoot()
v.OfficialProviders = officialProviderViewsForRoot(officialProviderAddedSet(cfg), cfg.DeepSeekOfficialPricingLanguage(), root)
for i := range cfg.Providers {
p := &cfg.Providers[i]
v.Providers = append(v.Providers, providerViewFromEntry(*p, isOfficialBuiltInProvider(*p), added[p.Name]))
v.Providers = append(v.Providers, providerViewFromEntryForRoot(*p, isOfficialBuiltInProvider(*p), added[p.Name], root))
}
return v
}
Expand Down Expand Up @@ -660,6 +674,30 @@ func (a *App) activeWorkspaceRoot() string {
return "."
}

func (a *App) saveProviderCredential(apiKeyEnv, value string) (string, error) {
apiKeyEnv = strings.TrimSpace(apiKeyEnv)
value = strings.TrimSpace(value)
root := a.activeWorkspaceRoot()
before := config.ResolveCredentialForRoot(root, apiKeyEnv)
if err := upsertDotEnv(apiKeyEnv, value); err != nil {
return "", err
}
return providerCredentialShadowWarning(apiKeyEnv, value, root, before), nil
}

func providerCredentialShadowWarning(apiKeyEnv, value, root string, before config.CredentialResolution) string {
if before.Set && before.Source.Kind == config.CredentialSourceEnvironment && before.Value != "" && before.Value != value {
Comment thread
SivanCola marked this conversation as resolved.
Outdated
return fmt.Sprintf("saved %s to Reasonix credentials, but an existing environment variable with the same name can override it after restart; update or remove that environment variable", apiKeyEnv)
}
current := config.ResolveCredentialForRoot(root, apiKeyEnv)
for _, source := range current.Shadowed {
if source.Kind == config.CredentialSourceProjectEnv {
return fmt.Sprintf("saved %s to Reasonix credentials, but this workspace's project .env also defines %s and can override it after restart; update or remove that project .env entry", apiKeyEnv, apiKeyEnv)
}
}
return ""
}

func projectConfigPathForRoot(root string) string {
if strings.TrimSpace(root) == "" || root == "." {
return "reasonix.toml"
Expand Down Expand Up @@ -1044,12 +1082,15 @@ func (a *App) AddOfficialProviderAccess(kind, key string) error {
if err != nil {
return err
}
keyWarning := ""
if strings.TrimSpace(key) != "" && keyEnv != "" {
if err := upsertDotEnv(keyEnv, key); err != nil {
var err error
keyWarning, err = a.saveProviderCredential(keyEnv, key)
if err != nil {
return err
}
}
return a.applyConfigChange(func(c *config.Config) error {
if err := a.applyConfigChange(func(c *config.Config) error {
names := make([]string, 0, len(entries))
for _, e := range entries {
if err := c.UpsertProvider(e); err != nil {
Expand All @@ -1059,7 +1100,13 @@ func (a *App) AddOfficialProviderAccess(kind, key string) error {
}
addProviderAccess(c, names...)
return nil
})
}); err != nil {
return err
}
if keyWarning != "" {
return fmt.Errorf("%s", keyWarning)
}
return nil
}

// FetchProviderModels probes the provider's OpenAI-compatible model-list
Expand Down Expand Up @@ -1312,10 +1359,17 @@ func (a *App) SetProviderKey(apiKeyEnv, value string) error {
if err := a.ensureActiveTabRebuildAllowed("provider key"); err != nil {
return err
}
if err := upsertDotEnv(apiKeyEnv, value); err != nil {
warning, err := a.saveProviderCredential(apiKeyEnv, value)
if err != nil {
return err
}
return a.rebuild()
if err := a.rebuild(); err != nil {
return err
}
if warning != "" {
return fmt.Errorf("%s", warning)
Comment thread
SivanCola marked this conversation as resolved.
Outdated
}
return nil
}

// ClearProviderKey removes a provider secret from the global credential store
Expand Down
46 changes: 46 additions & 0 deletions desktop/settings_app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,52 @@ func TestProviderViewFromEntry_MigratesProviderWideVision(t *testing.T) {
}
}

func TestProviderViewFromEntryShowsKeySource(t *testing.T) {
isolateDesktopUserDirs(t)
t.Setenv("TEST_PROVIDER_KEY_SOURCE", "")
os.Unsetenv("TEST_PROVIDER_KEY_SOURCE")
if _, err := config.SetCredential("TEST_PROVIDER_KEY_SOURCE", "sk-test"); err != nil {
t.Fatalf("SetCredential: %v", err)
}

view := providerViewFromEntry(config.ProviderEntry{
Name: "custom",
APIKeyEnv: "TEST_PROVIDER_KEY_SOURCE",
}, false, true)
if !view.KeySet {
t.Fatal("KeySet = false, want true")
}
if view.KeySource == "" || !strings.Contains(view.KeySource, "credentials") {
t.Fatalf("KeySource = %q, want credentials source", view.KeySource)
}
}

func TestSetProviderKeyWarnsWhenProjectEnvWillShadowSavedKey(t *testing.T) {
isolateDesktopUserDirs(t)
project := t.TempDir()
if err := os.WriteFile(filepath.Join(project, ".env"), []byte("TEST_PROVIDER_SHADOW=old-key\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("TEST_PROVIDER_SHADOW", "")
os.Unsetenv("TEST_PROVIDER_SHADOW")

app := &App{
tabs: map[string]*WorkspaceTab{"project": {ID: "project", WorkspaceRoot: project}},
activeTabID: "project",
}
err := app.SetProviderKey("TEST_PROVIDER_SHADOW", "new-key")
if err == nil || !strings.Contains(err.Error(), "project .env") {
t.Fatalf("SetProviderKey error = %v, want project .env shadow warning", err)
}
data, readErr := os.ReadFile(config.UserCredentialsPath())
if readErr != nil {
t.Fatalf("read credentials: %v", readErr)
}
if !strings.Contains(string(data), "TEST_PROVIDER_SHADOW=new-key") {
t.Fatalf("saved credentials missing new key:\n%s", data)
}
}

func TestFetchProviderModelsFiltersNonChatModels(t *testing.T) {
t.Setenv("TEST_PROVIDER_KEY", "test-key")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
2 changes: 2 additions & 0 deletions internal/boot/boot.go
Original file line number Diff line number Diff line change
Expand Up @@ -1049,6 +1049,7 @@ func NewProvider(e *config.ProviderEntry) (provider.Provider, error) {
// NewProviderWithProxy builds a provider.Provider with the configured ordinary
// network proxy settings.
func NewProviderWithProxy(e *config.ProviderEntry, proxy netclient.ProxySpec) (provider.Provider, error) {
keyResolution := config.ResolveCredential(e.APIKeyEnv)
return provider.New(e.Kind, provider.Config{
Name: e.Name,
BaseURL: e.BaseURL,
Expand All @@ -1059,6 +1060,7 @@ func NewProviderWithProxy(e *config.ProviderEntry, proxy netclient.ProxySpec) (p
// default_effort when the user has not explicitly selected /effort.
Extra: map[string]any{
"api_key_env": e.APIKeyEnv,
"api_key_source": keyResolution.Source.Label,
"thinking": e.Thinking,
"effort": config.EffectiveEffort(e),
"reasoning_protocol": config.ReasoningProtocolForEntry(e),
Expand Down
Loading