Skip to content
Open
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
14 changes: 14 additions & 0 deletions desktop/frontend/src/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,8 @@ function ProviderEditor({
// Empty when unset so the placeholder (and its "0 = default" hint) reads instead
// of a bare "0"; saved back as 0.
const [ctx, setCtx] = useState(initial?.contextWindow ? String(initial.contextWindow) : "");
const [supportedEfforts, setSupportedEfforts] = useState((initial?.supportedEfforts ?? []).join(", "));
const [defaultEffort, setDefaultEffort] = useState(initial?.defaultEffort ?? "");

// Offer the kinds the kernel actually registered; if the stored kind is a
// legacy/unknown one, keep it as an option so editing doesn't silently change it.
Expand All @@ -519,6 +521,10 @@ function ProviderEditor({
.split(",")
.map((m) => m.trim())
.filter(Boolean);
const se = supportedEfforts
.split(",")
.map((s) => s.trim())
.filter(Boolean);
onSave({
name: name.trim(),
kind: kind.trim() || kinds[0] || "openai",
Expand All @@ -529,6 +535,8 @@ function ProviderEditor({
keySet: initial?.keySet ?? false,
balanceUrl: balanceUrl.trim(),
contextWindow: Number(ctx) || 0,
supportedEfforts: se,
defaultEffort: defaultEffort.trim(),
});
};

Expand All @@ -552,6 +560,12 @@ function ProviderEditor({
<label className="set-label">{t("settings.providerContextWindow")}</label>
<input className="mem-input" placeholder={t("settings.contextWindowPlaceholder")} value={ctx} onChange={(e) => setCtx(e.target.value)} inputMode="numeric" />
<div className="mem-hint">{t("settings.contextWindowHint")}</div>
<label className="set-label">{t("settings.supportedEfforts")}</label>
<input className="mem-input" placeholder={t("settings.supportedEffortsPlaceholder")} value={supportedEfforts} onChange={(e) => setSupportedEfforts(e.target.value)} />
<div className="mem-hint">{t("settings.supportedEffortsHint")}</div>
<label className="set-label">{t("settings.defaultEffort")}</label>
<input className="mem-input" placeholder={t("settings.defaultEffortPlaceholder")} value={defaultEffort} onChange={(e) => setDefaultEffort(e.target.value)} />
<div className="mem-hint">{t("settings.defaultEffortHint")}</div>
<div className="prov-card__actions">
<button className="btn btn--small" onClick={onCancel} disabled={busy}>
{t("common.cancel")}
Expand Down
4 changes: 2 additions & 2 deletions desktop/frontend/src/lib/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,8 +377,8 @@ function makeMockApp(): AppBindings {
defaultModel: "deepseek-flash",
plannerModel: "",
providers: [
{ name: "deepseek-flash", kind: "openai", baseUrl: "https://api.deepseek.com", models: ["deepseek-v4-flash"], default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000 },
{ name: "mimo-pro", kind: "openai", baseUrl: "https://api.xiaomimimo.com/v1", models: ["mimo-v2.5-pro"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", keySet: false, balanceUrl: "", contextWindow: 1_000_000 },
{ name: "deepseek-flash", kind: "openai", baseUrl: "https://api.deepseek.com", models: ["deepseek-v4-flash"], default: "deepseek-v4-flash", apiKeyEnv: "DEEPSEEK_API_KEY", keySet: true, balanceUrl: "https://api.deepseek.com/user/balance", contextWindow: 1_000_000, supportedEfforts: [], defaultEffort: "" },
{ name: "mimo-pro", kind: "openai", baseUrl: "https://api.xiaomimimo.com/v1", models: ["mimo-v2.5-pro"], default: "mimo-v2.5-pro", apiKeyEnv: "MIMO_API_KEY", keySet: false, balanceUrl: "", contextWindow: 1_000_000, supportedEfforts: [], defaultEffort: "" },
],
permissions: { mode: "ask", allow: ["ls", "read_file"], ask: [], deny: ["bash(rm *)"] },
sandbox: { bash: "enforce", network: true, workspaceRoot: "", allowWrite: [] },
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 @@ -327,6 +327,8 @@ export interface ProviderView {
keySet: boolean; // the env var currently resolves to a value
balanceUrl: string; // optional wallet-balance endpoint; "" disables the readout
contextWindow: number;
supportedEfforts: string[]; // custom /effort levels; empty = use built-in Kind/BaseURL default
defaultEffort: string; // /effort level when user picks "auto" or unset; "" = supportedEfforts[0]
}

// BalanceInfo is the wallet-balance readout (desktop/app.go Balance). available
Expand Down
6 changes: 6 additions & 0 deletions desktop/frontend/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,12 @@ export const en = {
"settings.providerContextWindow": "Context window",
"settings.contextWindowPlaceholder": "tokens (0 = provider default)",
"settings.contextWindowHint": "Max tokens to keep in context for this provider. Leave 0 to use the provider's default.",
"settings.supportedEfforts": "Supported efforts",
"settings.supportedEffortsPlaceholder": "e.g. low, medium, high",
"settings.supportedEffortsHint": "Comma-separated /effort levels this provider exposes. Leave empty to keep the built-in levels for this kind (DeepSeek: high|max, Anthropic: low|medium|high|xhigh|max).",
"settings.defaultEffort": "Default effort",
"settings.defaultEffortPlaceholder": "e.g. high",
"settings.defaultEffortHint": "Used when /effort is \"auto\" or unset. Must be one of the supported efforts above; falls back to the first entry when empty or unknown.",
"settings.proxyMode": "Proxy mode",
"settings.proxyMode.auto": "auto",
"settings.proxyMode.env": "env",
Expand Down
6 changes: 6 additions & 0 deletions desktop/frontend/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,12 @@ export const zh: Record<DictKey, string> = {
"settings.providerContextWindow": "上下文窗口",
"settings.contextWindowPlaceholder": "token 数(0 = 模型服务默认值)",
"settings.contextWindowHint": "该模型服务在上下文中保留的最大 token 数。填 0 表示使用模型服务默认值。",
"settings.supportedEfforts": "支持的 effort 等级",
"settings.supportedEffortsPlaceholder": "如 low, medium, high",
"settings.supportedEffortsHint": "该模型服务在 /effort 命令中暴露的等级(逗号分隔)。留空则使用内置等级(DeepSeek:high|max,Anthropic:low|medium|high|xhigh|max)。",
"settings.defaultEffort": "默认 effort",
"settings.defaultEffortPlaceholder": "如 high",
"settings.defaultEffortHint": "当 /effort 为 auto 或未设置时使用。必须在上方\"支持的 effort 等级\"中;为空或不在列表中时回退到第一个等级。",
"settings.proxyMode": "代理模式",
"settings.proxyMode.auto": "自动",
"settings.proxyMode.env": "环境变量",
Expand Down
32 changes: 19 additions & 13 deletions desktop/settings_app.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,17 @@ import (
// --- read ---

type ProviderView struct {
Name string `json:"name"`
Kind string `json:"kind"`
BaseURL string `json:"baseUrl"`
Models []string `json:"models"`
Default string `json:"default"`
APIKeyEnv string `json:"apiKeyEnv"`
KeySet bool `json:"keySet"` // the env var currently resolves to a non-empty value
BalanceURL string `json:"balanceUrl"`
ContextWindow int `json:"contextWindow"`
Name string `json:"name"`
Kind string `json:"kind"`
BaseURL string `json:"baseUrl"`
Models []string `json:"models"`
Default string `json:"default"`
APIKeyEnv string `json:"apiKeyEnv"`
KeySet bool `json:"keySet"` // the env var currently resolves to a non-empty value
BalanceURL string `json:"balanceUrl"`
ContextWindow int `json:"contextWindow"`
SupportedEfforts []string `json:"supportedEfforts"`
DefaultEffort string `json:"defaultEffort"`
}

type PermissionsView struct {
Expand Down Expand Up @@ -150,10 +152,12 @@ func (a *App) Settings() SettingsView {
v.Providers = append(v.Providers, ProviderView{
Name: p.Name, Kind: p.Kind, BaseURL: p.BaseURL,
Models: nonNil(p.ModelList()), Default: p.DefaultModel(),
APIKeyEnv: p.APIKeyEnv,
KeySet: p.APIKeyEnv != "" && os.Getenv(p.APIKeyEnv) != "",
BalanceURL: p.BalanceURL,
ContextWindow: p.ContextWindow,
APIKeyEnv: p.APIKeyEnv,
KeySet: p.APIKeyEnv != "" && os.Getenv(p.APIKeyEnv) != "",
BalanceURL: p.BalanceURL,
ContextWindow: p.ContextWindow,
SupportedEfforts: nonNil(p.SupportedEfforts),
DefaultEffort: p.DefaultEffort,
})
}
return v
Expand Down Expand Up @@ -299,6 +303,8 @@ func (a *App) SaveProvider(p ProviderView) error {
e := config.ProviderEntry{
Name: p.Name, Kind: p.Kind, BaseURL: p.BaseURL,
APIKeyEnv: p.APIKeyEnv, BalanceURL: strings.TrimSpace(p.BalanceURL), ContextWindow: p.ContextWindow,
SupportedEfforts: p.SupportedEfforts,
DefaultEffort: p.DefaultEffort,
}
if len(p.Models) > 0 {
e.Model = p.Models[0] // also satisfies validateProvider's model requirement
Expand Down
9 changes: 9 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,15 @@ type ProviderEntry struct {
// Empty = provider default.
Thinking string `toml:"thinking"`
Effort string `toml:"effort"`
// SupportedEfforts lists the /effort levels this provider/model exposes.
// When non-empty, it overrides the built-in defaults derived from
// Kind/BaseURL and makes /effort configurable. "auto" is the implicit
// prefix — always accepted. DefaultEffort resolves it; omit DefaultEffort
// (or set one outside this list) to fall back to SupportedEfforts[0].
SupportedEfforts []string `toml:"supported_efforts"`
// DefaultEffort is the /effort level used when the user picks "auto" or
// has not set Effort. Ignored when SupportedEfforts is empty.
DefaultEffort string `toml:"default_effort"`
// NoProxy reaches this provider's base_url directly, never through the proxy.
// For China-only endpoints a foreign-exit proxy resets the TLS handshake (#2803).
NoProxy bool `toml:"no_proxy"`
Expand Down
94 changes: 94 additions & 0 deletions internal/config/edit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -556,3 +556,97 @@ func TestSetNetworkRejectsIncompleteCustomProxy(t *testing.T) {
t.Fatal("custom proxy without server/port should be rejected")
}
}

func TestEffortCapabilityCustomSupportedEfforts(t *testing.T) {
e := &ProviderEntry{
Name: "custom",
Kind: "openai",
BaseURL: "https://example.com",
SupportedEfforts: []string{"low", "medium", "high"},
DefaultEffort: "high",
}
cap := EffortCapabilityForEntry(e)
if !cap.Supported {
t.Fatalf("expected supported, got %+v", cap)
}
wantLevels := []string{"auto", "low", "medium", "high"}
if len(cap.Levels) != len(wantLevels) {
t.Fatalf("levels = %v, want %v", cap.Levels, wantLevels)
}
for i, l := range wantLevels {
if cap.Levels[i] != l {
t.Errorf("levels[%d] = %q, want %q", i, cap.Levels[i], l)
}
}
if cap.Default != "high" {
t.Errorf("default = %q, want high", cap.Default)
}
}

func TestNormalizeEffortCustomSupportedEfforts(t *testing.T) {
e := &ProviderEntry{
Name: "custom",
Kind: "openai",
BaseURL: "https://example.com",
SupportedEfforts: []string{"low", "medium", "high"},
}
for in, want := range map[string]string{"auto": "", "low": "low", "MEDIUM": "medium", "high": "high"} {
got, err := NormalizeEffort(e, in)
if err != nil || got != want {
t.Fatalf("NormalizeEffort(%q) = %q/%v, want %q/nil", in, got, err, want)
}
}
for _, bad := range []string{"max", "xhigh", "", " "} {
if _, err := NormalizeEffort(e, bad); err == nil {
t.Errorf("NormalizeEffort(%q) should be rejected", bad)
}
}
}

func TestNormalizeEffortCustomDefaultEffort(t *testing.T) {
e := &ProviderEntry{
Name: "custom",
Kind: "openai",
BaseURL: "https://example.com",
SupportedEfforts: []string{"low", "medium", "high"},
DefaultEffort: "xhigh", // not in the list — must fall back to the first level
}
cap := EffortCapabilityForEntry(e)
if cap.Default != "low" {
t.Fatalf("default = %q, want low (first of supported_efforts)", cap.Default)
}
// Omitting DefaultEffort also falls back to the first level.
e2 := *e
e2.DefaultEffort = ""
if cap := EffortCapabilityForEntry(&e2); cap.Default != "low" {
t.Errorf("empty default = %q, want low", cap.Default)
}
// /effort auto still maps to "" regardless of DefaultEffort.
if got, err := NormalizeEffort(e, "auto"); err != nil || got != "" {
t.Fatalf("NormalizeEffort(auto) = %q/%v, want empty/nil", got, err)
}
}

func TestEffortCapabilityEmptySupportedEffortsNotConfigurable(t *testing.T) {
// mimo-pro without SupportedEfforts: no built-in heuristic, /effort must reject.
e := &ProviderEntry{
Name: "mimo-pro",
Kind: "openai",
BaseURL: "https://token-plan-cn.xiaomimimo.com/v1",
Model: "mimo-v2.5-pro",
}
if cap := EffortCapabilityForEntry(e); cap.Supported {
t.Fatalf("mimo-pro without SupportedEfforts should not be configurable, got %+v", cap)
}
if _, err := NormalizeEffort(e, "high"); err == nil {
t.Fatal("NormalizeEffort should reject level for unsupported provider")
}
// `supported_efforts = []` (empty slice) is treated like nil — the v2 design
// has no way to opt out of the built-in heuristic; users either configure
// levels or leave the field unset.
e2 := *e
e2.SupportedEfforts = []string{}
if cap := EffortCapabilityForEntry(&e2); cap.Supported {
t.Fatalf("empty supported_efforts should also fall through to the heuristic, got %+v", cap)
}
}
25 changes: 25 additions & 0 deletions internal/config/effort.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ type EffortCapability struct {
// provider entry. Provider implementations still decide how a stored effort is
// serialized into requests.
func EffortCapabilityForEntry(e *ProviderEntry) EffortCapability {
if e != nil && len(e.SupportedEfforts) > 0 {
levels := make([]string, 0, len(e.SupportedEfforts)+1)
levels = append(levels, "auto")
levels = append(levels, e.SupportedEfforts...)
def := e.DefaultEffort
if def == "" || !containsString(e.SupportedEfforts, def) {
def = e.SupportedEfforts[0]
}
return EffortCapability{Supported: true, Levels: levels, Default: def}
}
switch {
case isDeepSeekEntry(e):
return EffortCapability{Supported: true, Levels: []string{"auto", "high", "max"}, Default: "high"}
Expand All @@ -38,6 +48,12 @@ func NormalizeEffort(e *ProviderEntry, raw string) (string, error) {
if level == "auto" {
return "", nil
}
if e != nil && len(e.SupportedEfforts) > 0 {
if containsString(e.SupportedEfforts, level) {
return level, nil
}
return "", fmt.Errorf("usage: /effort auto|%s", strings.Join(e.SupportedEfforts, "|"))
}
switch {
case isDeepSeekEntry(e):
switch level {
Expand Down Expand Up @@ -89,3 +105,12 @@ func isDeepSeekEntry(e *ProviderEntry) bool {
host := strings.ToLower(u.Hostname())
return host == "api.deepseek.com" || strings.HasSuffix(host, ".deepseek.com")
}

func containsString(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}
6 changes: 6 additions & 0 deletions internal/config/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,12 @@ func RenderTOML(c *Config) string {
if p.Effort != "" {
fmt.Fprintf(&b, "effort = %q\n", p.Effort)
}
if len(p.SupportedEfforts) > 0 {
fmt.Fprintf(&b, "supported_efforts = %s # custom /effort levels exposed by this provider; overrides the built-in Kind/BaseURL default\n", renderStringArray(p.SupportedEfforts))
}
if p.DefaultEffort != "" {
fmt.Fprintf(&b, "default_effort = %q # used when /effort is auto or unset; must be one of supported_efforts\n", p.DefaultEffort)
}
if p.NoProxy {
b.WriteString("no_proxy = true # reach this base_url directly, never via the proxy\n")
}
Expand Down
15 changes: 15 additions & 0 deletions reasonix.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,21 @@ model = "mimo-v2-flash" # thinking-mode off by default; cheaper / fas
api_key_env = "MIMO_API_KEY"
context_window = 65536

# Custom /effort levels for a provider. When supported_efforts is set, the
# /effort command exposes these levels; default_effort is what "/effort auto"
# (or unset) resolves to. Leave both commented to keep the built-in defaults
# (DeepSeek: high|max; Anthropic: low|medium|high|xhigh|max). Other kinds
# (e.g. mimo-pro) don't expose /effort unless you opt in here.
#
# [[providers]]
# name = "mimo-custom"
# kind = "openai"
# base_url = "https://token-plan-cn.xiaomimimo.com/v1"
# model = "mimo-v2.5-pro"
# api_key_env = "MIMO_API_KEY"
# supported_efforts = ["low", "medium", "high"]
# default_effort = "high"

# Anthropic (Claude) — the "anthropic" kind speaks the Messages API directly (no
# OpenAI shim). base_url is optional (defaults to https://api.anthropic.com). Note:
# this provider does not enable extended thinking and does not send temperature —
Expand Down
Loading