feat: add regional CNY/USD pricing and English localization / 增加区域人民币/美元价格与英文国际化 (#6945)

* feat(desktop): add regional pricing and English localization

Problem: Desktop cost estimates assumed a single CNY price table, while automatic session titles and several desktop options remained Chinese-only for English users.

Root cause: Currency selection was coupled to persisted language settings, browser locale never reached controller rebuilds, cost telemetry lacked consistent regional repricing, and fallback copy was not localized at the API boundary.

Fix: Add official CNY/USD regional pricing with auto locale detection and an explicit desktop override, preserve structured currency telemetry and custom prices, localize session titles and missing desktop options, and make generated titles follow the user's language.

Verification: Passed config/boot/serve and full desktop Go suites, frontend typecheck and production build, focused frontend behavior tests, the cache guard, and git diff checks.

* fix: preserve regional pricing and manual topic titles

Problem:
Auto regional pricing could overwrite persisted official price tables, and manually entered default-like topic titles were localized.

Root cause:
Pricing normalization did not retain TOML provenance, while title localization inferred automation from title text alone.

Fix:
Carry regional pricing provenance through config loading, normalization, backfill, and edit replay; only explicit currency choices override persisted tables. Propagate topic title source through desktop runtimes and localize only auto-generated default titles.

Verification:
go test ./internal/config ./internal/boot ./internal/serve -count=1
cd desktop && go test ./... -count=1

* chore: credit regional localization contributors

Context: PR #6945 consolidates regional pricing preservation and English session-title work from three related contributor PRs.

Attribution: Record the source authors with their public GitHub noreply identities so the consolidated repository history preserves their contributions.

Verification: Attribution-only commit; product files are unchanged.

Co-authored-by: dmitri-0 <[email protected]>
Co-authored-by: erphenheimer <[email protected]>
Co-authored-by: delight-f <[email protected]>

* feat(cli): add regional pricing controls

Context: PR #6945 already provides regional DeepSeek pricing in desktop, but CLI startup, in-session settings, and JSON result output did not expose the same currency contract.

Changes: Resolve Auto pricing from the active CLI locale, add global currency config and slash commands, refresh the current controller after language or currency changes, and emit currency-aware total_cost while retaining total_cost_usd as a compatibility alias. Reject mixed-currency totals and prevent project config from overriding user regional preferences.

Verification: go test ./internal/cli ./internal/config ./internal/boot ./internal/control ./internal/i18n -count=1; go test ./... -count=1 with DEEPSEEK_API_KEY unset.

* docs(cli): document regional pricing controls

Documentation:
- Add English and Chinese examples for global currency selection and locale-based Auto.
- Explain structured cost/currency output and the total_cost_usd compatibility alias.
- List the /currency session command and runtime refresh behavior.

Verification:
- go test ./internal/cli -run "Test(ConfigCurrency|RunOutput)" -count=1
- git diff --check

---------

Co-authored-by: dmitri-0 <[email protected]>
Co-authored-by: erphenheimer <[email protected]>
Co-authored-by: delight-f <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-07-28 13:45:15 +08:00
1 parent b907a6b097
commit 2fefeef06f
52 files changed
+2204 -212

No files matched your search

+13 -2
View File
@@ -200,6 +200,7 @@ type App struct {
forceQuit atomic.Bool
backgroundMaximised atomic.Bool
desktopLocale atomic.Int32
trayReady bool
tray *desktopTray
hangWatchdogMu sync.Mutex
@@ -697,7 +698,7 @@ func (a *App) restoreOrBuildTabs() {
if lang == "" {
lang = cfg.Language
}
i18n.DetectLanguage(lang)
a.setDesktopLocale(i18n.DetectLanguage(lang))
}
if cfgErr != nil || singleSurfaceLayoutStyle(startupCfg.DesktopLayoutStyle()) {
f = singleSurfaceTabsFile(f)
@@ -787,6 +788,7 @@ func (a *App) createTabEntryWithID(scope, workspaceRoot, topicID, id string) *Wo
WorkspaceRoot: workspaceRoot,
TopicID: topicID,
TopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),
topicTitleSource: loadTopicTitleSource(topicTitleRoot(scope, workspaceRoot), topicID),
model: model,
tokenMode: boot.TokenModeFull,
mode: tabModeFromAxes(false, toolApprovalMode == control.ToolApprovalYolo),
@@ -2032,6 +2034,7 @@ func (a *App) assignFreshSessionTopic(tab *WorkspaceTab) {
workspaceRoot := tab.WorkspaceRoot
tab.TopicID = topicID
tab.TopicTitle = defaultTopicTitle
tab.topicTitleSource = topicTitleSourceAuto
if current := a.tabs[tab.ID]; current == tab {
a.saveTabsLocked()
}
@@ -2063,6 +2066,7 @@ func (a *App) ensureTabTopicIndexedForUserTurn(tab *WorkspaceTab) {
workspaceRoot := tab.WorkspaceRoot
tab.TopicID = topicID
tab.TopicTitle = defaultTopicTitle
tab.topicTitleSource = topicTitleSourceAuto
if current := a.tabs[tab.ID]; current == tab {
a.saveTabsLocked()
}
@@ -2202,6 +2206,7 @@ func (a *App) clearActiveSessionRuntime(tab *WorkspaceTab, oldCtrl control.Sessi
newCtrl, err := boot.Build(a.bootContext(), boot.Options{
Model: snap.model,
RequireKey: false,
AutoPricingCurrency: a.desktopAutoPricingCurrency(),
Sink: newSink,
WorkspaceRoot: snap.workspaceRoot,
SessionDir: sessionDirForSnapshot(snap),
@@ -2558,7 +2563,7 @@ func (a *App) ForkForTab(tabID string, turn int) (TabMeta, error) {
return TabMeta{}, err
}
topicID := newTopicID()
topicTitle := forkTopicTitle(sourceTitle)
topicTitle := a.forkTopicTitle(sourceTitle)
titleRoot := workspaceRoot
if scope == "global" {
titleRoot = ""
@@ -2584,6 +2589,7 @@ func (a *App) ForkForTab(tabID string, turn int) (TabMeta, error) {
WorkspaceRoot: workspaceRoot,
TopicID: topicID,
TopicTitle: topicTitle,
topicTitleSource: topicTitleSourceManual,
SessionPath: newPath,
model: model,
effort: effort,
@@ -3412,6 +3418,7 @@ func (a *App) openTransientBlankRuntime(scope, workspaceRoot string) error {
Scope: scope,
WorkspaceRoot: actualRoot,
TopicTitle: defaultTopicTitle,
topicTitleSource: topicTitleSourceAuto,
SessionPath: sessionPath,
model: model,
tokenMode: boot.TokenModeFull,
@@ -4053,6 +4060,7 @@ func (a *App) buildSessionRebindCandidate(
ctrl, err := boot.Build(a.bootContext(), boot.Options{
Model: model,
RequireKey: false,
AutoPricingCurrency: a.desktopAutoPricingCurrency(),
Sink: a.desktopControllerSink(sink, cfg.Notifications),
WorkspaceRoot: root,
SessionDir: sessionDir,
@@ -9215,6 +9223,7 @@ func (a *App) SetModelForTab(tabID, name string) error {
newCtrl, err := boot.Build(a.bootContext(), boot.Options{
Model: name,
RequireKey: false,
AutoPricingCurrency: a.desktopAutoPricingCurrency(),
Sink: snap.sink,
WorkspaceRoot: snap.workspaceRoot,
SessionDir: sessionDirForSnapshot(snap),
@@ -9390,6 +9399,7 @@ func (a *App) SetEffortForTab(tabID, level string) error {
newCtrl, err := boot.Build(a.bootContext(), boot.Options{
Model: modelRef,
RequireKey: false,
AutoPricingCurrency: a.desktopAutoPricingCurrency(),
Sink: snap.sink,
WorkspaceRoot: snap.workspaceRoot,
SessionDir: sessionDirForSnapshot(snap),
@@ -9529,6 +9539,7 @@ func (a *App) SetTokenModeForTab(tabID, mode string) error {
newCtrl, err := boot.Build(a.bootContext(), boot.Options{
Model: modelRef,
RequireKey: false,
AutoPricingCurrency: a.desktopAutoPricingCurrency(),
Sink: snap.sink,
WorkspaceRoot: snap.workspaceRoot,
SessionDir: sessionDirForSnapshot(snap),
+1
View File
@@ -4331,6 +4331,7 @@ export default function App() {
showContextWindowRing={sidebarCreation}
context={state.context}
turnCost={state.turnCost}
currency={state.sessionCurrency}
cacheHitTokens={state.usage?.cacheHitTokens}
cacheMissTokens={state.usage?.cacheMissTokens}
balance={state.balance}
@@ -146,6 +146,31 @@ console.log("\ncontext window ring");
dom.window.close();
}
{
const dom = installDom();
installContextPanelMock(async () => contextPanelInfo(0));
const { root } = await renderRing({ turnCost: 0.125, currency: "$" });
const button = document.querySelector(".context-ring") as HTMLButtonElement | null;
if (!button) throw new Error("missing context ring button");
await act(async () => {
button.dispatchEvent(new MouseEvent("mouseover", { bubbles: true, relatedTarget: null }));
await wait(220);
});
const turnCostRow = [...document.querySelectorAll(".context-ring-popover__row")]
.find((row) => row.querySelector(".context-ring-popover__label")?.textContent === "turn cost");
eq(
turnCostRow?.querySelector(".context-ring-popover__value")?.textContent,
"$0.1250",
"turn cost uses the session currency before panel info is available",
);
await act(async () => {
root.unmount();
});
dom.window.close();
}
{
const dom = installDom();
const calls: string[] = [];
+26 -24
View File
@@ -7,7 +7,7 @@ import { DedupIndex, sha256 } from "../lib/attachDedup";
import { app, onFilesDropped } from "../lib/bridge";
import { canUsePromptHistory, composerEnterAction, insertComposerNewline, isFnKeyEvent, promptHistoryDirectionFromEvent } from "../lib/composerKeyboard";
import { cacheGeneration, loadOlder } from "../lib/composerHistory";
import { SPINNER_WORDS, useI18n } from "../lib/i18n";
import { SPINNER_WORDS, useI18n, type Translator } from "../lib/i18n";
import { detectShortcutPlatform, formatShortcutCombo, isReservedComposerHistoryShortcut, matchesShortcut, useShortcutComboLabel } from "../lib/keyboardShortcuts";
import { fallbackCopyText } from "../lib/clipboard";
import {
@@ -440,12 +440,10 @@ function isImeKeyEvent(
// --- past:chats session reference → prompt context (PR-B) ---
// Send-side helpers for "@past:chats" session references. PR-A wired the menu and
// the composer-context card; this layer reads each referenced session through the
// existing PreviewSession API and prepends a compact "user / 助手" transcript to
// existing PreviewSession API and prepends a compact user/assistant transcript to
// submitText so the model sees the referenced chat as background context.
const SESSION_REF_MAX_MESSAGES = 30;
const SESSION_REF_MAX_CHARS = 20_000;
const SESSION_CONTEXT_HEADER = "以下是用户引用的历史会话上下文:";
const SESSION_CONTEXT_FOOTER = "当前用户问题:";
const PAST_CHATS_MENU_ITEM = "past:chats";
// limitSessionMessages keeps the most recent useful messages within a char budget.
@@ -488,14 +486,15 @@ function formatSessionContext(
ref: SessionReference,
messages: HistoryMessage[],
truncated: boolean,
t: Translator,
): string {
const body = messages
.map((m) => `${m.role === "user" ? "用户" : "助手"}:${m.content.trim()}`)
.map((m) => `${m.role === "user" ? t("composer.sessionContextUser") : t("composer.sessionContextAssistant")}: ${m.content.trim()}`)
.join("\n\n");
return [
`[会话:${ref.title}]`,
truncated ? "注意:该会话内容较长,以下只包含最近部分内容。" : "",
body || "注意:该会话没有可引用的用户/助手消息。",
`[${t("composer.sessionContextSession", { title: ref.title })}]`,
truncated ? t("composer.sessionContextTruncated") : "",
body || t("composer.sessionContextEmpty"),
]
.filter(Boolean)
.join("\n");
@@ -503,22 +502,22 @@ function formatSessionContext(
// buildSessionContext reads each referenced session, formats the most recent
// slice, and joins them with a separator. A single failed read must not block
// the others; the user gets a clear "读取失败" note for the bad one and the
// the others; a localized read-failure note marks the bad one and the
// remaining refs still flow through.
async function buildSessionContext(refs: SessionReference[]): Promise<string> {
async function buildSessionContext(refs: SessionReference[], t: Translator): Promise<string> {
if (refs.length === 0) return "";
let context = `${SESSION_CONTEXT_HEADER}\n\n`;
let context = `${t("composer.sessionContextHeader")}\n\n`;
for (const ref of refs) {
try {
const raw = await app.PreviewSession(ref.path);
const limited = limitSessionMessages(asArray(raw));
context += `${formatSessionContext(ref, limited.messages, limited.truncated)}\n\n---\n\n`;
context += `${formatSessionContext(ref, limited.messages, limited.truncated, t)}\n\n---\n\n`;
} catch (error) {
console.error("[past:chats] failed to preview session", ref.path, error);
context += `[会话:${ref.title}]\n注意:该会话读取失败,已跳过。\n\n---\n\n`;
context += `[${t("composer.sessionContextSession", { title: ref.title })}]\n${t("composer.sessionContextReadFailed")}\n\n---\n\n`;
}
}
context += `${SESSION_CONTEXT_FOOTER}\n`;
context += `${t("composer.sessionContextFooter")}\n`;
return context;
}
@@ -571,6 +570,7 @@ export function Composer({
showContextWindowRing = false,
context,
turnCost,
currency,
cacheHitTokens,
cacheMissTokens,
balance,
@@ -641,6 +641,7 @@ export function Composer({
showContextWindowRing?: boolean;
context?: ContextInfo;
turnCost?: number;
currency?: string;
cacheHitTokens?: number;
cacheMissTokens?: number;
balance?: BalanceInfo;
@@ -1947,7 +1948,7 @@ export function Composer({
const currentSessionRefs = sessionRefsRef.current;
const currentSelectedTextRefs = selectedTextRefsRef.current;
const currentPastedBlocks = [...pastedBlocksRef.current];
const sessionContext = currentSessionRefs.length === 0 ? "" : await buildSessionContext(currentSessionRefs);
const sessionContext = currentSessionRefs.length === 0 ? "" : await buildSessionContext(currentSessionRefs, t);
const selectedTextContext = formatSelectedTextContext(currentSelectedTextRefs);
const invocationText = serializeInvocationSubmit(trimmedText, trimmedDraft.invocations);
const baseSubmitText = [expandPastedBlocks(invocationText, currentPastedBlocks), refs].filter(Boolean).join(" ");
@@ -3729,11 +3730,11 @@ export function Composer({
<div className="slashmenu" role="listbox">
{loadingPastChats ? (
<div className="slashmenu__item slashmenu__item--empty">
<span className="slashmenu__name">正在加载历史会话...</span>
<span className="slashmenu__name">{t("composer.pastChatsLoading")}</span>
</div>
) : pastChats.length === 0 ? (
<div className="slashmenu__item slashmenu__item--empty">
<span className="slashmenu__name">暂无历史会话</span>
<span className="slashmenu__name">{t("composer.pastChatsEmpty")}</span>
</div>
) : (
<>
@@ -3742,7 +3743,7 @@ export function Composer({
<input
className="slashmenu__search"
type="text"
placeholder="搜索历史会话…"
placeholder={t("composer.pastChatsSearch")}
value={pastChatQuery}
// In the token-driven flows (typed "#" or the content-menu
// action) focus must stay in the composer: typing there
@@ -3760,7 +3761,7 @@ export function Composer({
</div>
{filteredPastChats.length === 0 ? (
<div className="slashmenu__item slashmenu__item--empty">
<span className="slashmenu__name">没有匹配的历史会话</span>
<span className="slashmenu__name">{t("composer.pastChatsNoMatches")}</span>
</div>
) : (
filteredPastChats.map((session, i) => {
@@ -3778,7 +3779,7 @@ export function Composer({
{preview && <div className="past-chat-hover__preview">{preview}</div>}
{(turns || ts) && (
<div className="past-chat-hover__meta">
{turns && <span>{session.turns} 轮</span>}
{turns && <span>{t("composer.sessionTurns", { n: session.turns })}</span>}
{ts && <span>· {fmtSessionTime(ts)}</span>}
</div>
)}
@@ -3798,7 +3799,7 @@ export function Composer({
<MessageSquare size={13} className="filemenu__icon" />
<span className="slashmenu__name slashmenu__name--file">
{pastChatTitle(session)}
{turns ? ` (${session.turns} 轮)` : ""}
{turns ? ` (${t("composer.sessionTurns", { n: session.turns })})` : ""}
</span>
</button>
</Tooltip>
@@ -3820,7 +3821,7 @@ export function Composer({
}}
>
<span className="slashmenu__name">
{menuMode === "pastChats" ? t("composer.contentCloseSessions") : "← 返回文件列表"}
{menuMode === "pastChats" ? t("composer.contentCloseSessions") : t("composer.backToFiles")}
</span>
</button>
</div>
@@ -3963,11 +3964,11 @@ export function Composer({
<MessageSquare size={15} />
<span>
{ref.title}
{typeof ref.turns === "number" ? ` (${ref.turns} 轮)` : ""}
{typeof ref.turns === "number" ? ` (${t("composer.sessionTurns", { n: ref.turns })})` : ""}
</span>
</span>
</Tooltip>
<Tooltip label="移除引用会话">
<Tooltip label={t("composer.removeSessionReference")}>
<button
type="button"
onClick={() => removeSessionRef(ref.path)}
@@ -4336,6 +4337,7 @@ export function Composer({
context={context}
tabId={tabId}
turnCost={turnCost}
currency={currency}
cacheHitTokens={cacheHitTokens}
cacheMissTokens={cacheMissTokens}
balance={balance}
@@ -15,6 +15,7 @@ interface ContextWindowRingProps {
context?: ContextInfo;
tabId?: string;
turnCost?: number;
currency?: string;
cacheHitTokens?: number;
cacheMissTokens?: number;
balance?: BalanceInfo;
@@ -40,7 +41,7 @@ function fmtDuration(ms: number, t: ReturnType<typeof useI18n>['t']): string {
return t("context.durationMinutesSeconds", { minutes, seconds });
}
export function ContextWindowRing({ enabled = true, context, tabId, turnCost, cacheHitTokens, cacheMissTokens, balance }: ContextWindowRingProps) {
export function ContextWindowRing({ enabled = true, context, tabId, turnCost, currency, cacheHitTokens, cacheMissTokens, balance }: ContextWindowRingProps) {
const { locale, t } = useI18n();
const [open, setOpen] = useState(false);
const [info, setInfo] = useState<ContextPanelInfo | null>(null);
@@ -120,6 +121,7 @@ export function ContextWindowRing({ enabled = true, context, tabId, turnCost, ca
const sessionCost = info?.sessionCost && info.sessionCost > 0
? formatMoneyLocalized(info.sessionCost, info.sessionCurrency, { locale, empty: "dash" })
: undefined;
const turnCostLabel = formatMoneyLocalized(turnCost, info?.sessionCurrency || currency, { locale, empty: "dash" });
return (
<>
@@ -195,7 +197,7 @@ export function ContextWindowRing({ enabled = true, context, tabId, turnCost, ca
{turnCost != null && turnCost > 0 && (
<div className="context-ring-popover__row">
<span className="context-ring-popover__label">{t("status.turnCostLabel")}</span>
<span className="context-ring-popover__value">{turnCost.toFixed(4)}</span>
<span className="context-ring-popover__value">{turnCostLabel}</span>
</div>
)}
{sessionCost && (
@@ -1352,6 +1352,7 @@ function normalizeSettingsView(view: SettingsView | null | undefined): SettingsV
autoApproveTools: Boolean(view.autoApproveTools ?? view.bypass),
bypass: Boolean(view.autoApproveTools ?? view.bypass),
desktopLanguage: normalizeLangPref(view.desktopLanguage),
desktopCurrency: normalizeDesktopCurrency(view.desktopCurrency),
desktopLayoutStyle: normalizeDesktopLayoutStyle(view.desktopLayoutStyle),
desktopTheme: normalizeThemePreference(view.desktopTheme),
desktopThemeStyle: normalizeThemeStyleForTheme(view.desktopThemeStyle, normalizeThemePreference(view.desktopTheme)),
@@ -1365,6 +1366,12 @@ function normalizeSettingsView(view: SettingsView | null | undefined): SettingsV
};
}
type DesktopCurrency = "" | "CNY" | "USD";
function normalizeDesktopCurrency(currency: string | undefined): DesktopCurrency {
return currency === "CNY" || currency === "USD" ? currency : "";
}
type CloseBehavior = "background" | "quit";
function normalizeCloseBehavior(mode: string | undefined): CloseBehavior {
@@ -1509,6 +1516,7 @@ function GeneralSection({ s, busy, apply, agentRunning }: SectionProps & { agent
useEffect(() => () => mouseDragCleanupRef.current?.(), []);
const defaultToolApprovalMode = normalizeToolApprovalMode(s.defaultToolApprovalMode);
const languagePref = normalizeLangPref(s.desktopLanguage);
const desktopCurrency = normalizeDesktopCurrency(s.desktopCurrency);
const desktopLayoutStyle = normalizeDesktopLayoutStyle(s.desktopLayoutStyle);
const [genMusicPreset, setGenMusicPreset] = useState<GenerativePreset>(getGenerativePreset());
const [soundPref, setSoundPref] = useState<SoundWavPref>(getSuccessPreference());
@@ -1680,6 +1688,20 @@ function GeneralSection({ s, busy, apply, agentRunning }: SectionProps & { agent
))}
</div>
</SettingsField>
<SettingsField label={t("settings.currency")}>
<div className="set-seg">
{(["", "CNY", "USD"] as DesktopCurrency[]).map((currency) => (
<button
key={currency || "auto"}
className={`set-seg__btn${desktopCurrency === currency ? " set-seg__btn--on" : ""}`}
disabled={busy || agentRunning}
onClick={() => void apply(() => app.SetDesktopCurrency(currency))}
>
{currency === "" ? t("settings.currencyAuto") : currency}
</button>
))}
</div>
</SettingsField>
<SettingsField label={t("settings.desktopLayoutStyle")}>
<div className="set-seg">
{(["classic", "workbench", "creation"] as const).map((style) => (
@@ -292,8 +292,8 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
if (!open) return null;
const scopeFilterLabel = (filter: string, map: Record<string, string>): string => {
if (filter === "all") return "全部项目";
if (filter === "global") return "全局";
if (filter === "all") return t("heartbeat.filterAllProjects");
if (filter === "global") return t("heartbeat.scopeGlobal");
return map[filter] || filter.split("/").pop() || filter;
};
@@ -314,7 +314,7 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
) : (
<Activity size={16} />
)}
<span>{editing ? t("heartbeat.editTask") : "自动化任务"}</span>
<span>{editing ? t("heartbeat.editTask") : t("heartbeat.scheduler")}</span>
<button
className="heartbeat-modal__close"
onClick={onClose}
@@ -402,7 +402,7 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
type="button"
onClick={() => { setScopeFilter("all"); setScopeFilterOpen(false); }}
>
<span>全部项目</span>
<span>{t("heartbeat.filterAllProjects")}</span>
{scopeFilter === "all" && <Check size={12} className="heartbeat-filter-menu__check" />}
</button>
<button
@@ -412,7 +412,7 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
type="button"
onClick={() => { setScopeFilter("global"); setScopeFilterOpen(false); }}
>
<span>全局</span>
<span>{t("heartbeat.scopeGlobal")}</span>
{scopeFilter === "global" && <Check size={12} className="heartbeat-filter-menu__check" />}
</button>
{(() => {
@@ -483,7 +483,7 @@ export function HeartbeatPanel({ open, onClose, startNew, onOpenTopic }: Heartbe
) : filtered.length === 0 ? (
<div className="heartbeat-empty">
<Heart size={24} />
<span>{tasks.length === 0 ? t("heartbeat.noTasks") : "没有匹配的任务"}</span>
<span>{tasks.length === 0 ? t("heartbeat.noTasks") : t("heartbeat.noMatchingTasks")}</span>
</div>
) : (
<ul className="heartbeat-tasklist">
@@ -621,13 +621,13 @@ function TaskCard({
// ── Cycle Editor ──────────────────────────────────────────────────────────────
const WEEKDAYS = [
{ key: "mon", label: "周一" },
{ key: "tue", label: "周二" },
{ key: "wed", label: "周三" },
{ key: "thu", label: "周四" },
{ key: "fri", label: "周五" },
{ key: "sat", label: "周六" },
{ key: "sun", label: "周日" },
{ key: "mon", labelKey: "heartbeat.weekdayMon" },
{ key: "tue", labelKey: "heartbeat.weekdayTue" },
{ key: "wed", labelKey: "heartbeat.weekdayWed" },
{ key: "thu", labelKey: "heartbeat.weekdayThu" },
{ key: "fri", labelKey: "heartbeat.weekdayFri" },
{ key: "sat", labelKey: "heartbeat.weekdaySat" },
{ key: "sun", labelKey: "heartbeat.weekdaySun" },
] as const;
const ALL_WEEKDAYS = WEEKDAYS.map(w => w.key);
@@ -741,11 +741,11 @@ function CycleEditor({
const MONTHS = Array.from({ length: 12 }, (_, i) => ({
value: String(i + 1),
label: `${i + 1}月`,
label: t("heartbeat.monthOption", { n: i + 1 }),
}));
const DAYS = Array.from({ length: 31 }, (_, i) => ({
value: String(i + 1),
label: `${i + 1}日`,
label: t("heartbeat.dayOption", { n: i + 1 }),
}));
return (
@@ -815,7 +815,7 @@ function CycleEditor({
onClick={() => onDayToggle(wd.key)}
aria-pressed={selectedDays.includes(wd.key)}
>
{wd.label}
{t(wd.labelKey)}
</button>
))}
</div>
@@ -1200,7 +1200,7 @@ function TaskEditor({
className="heartbeat-btn heartbeat-btn--primary"
onClick={() => onSave(draft)}
disabled={!draft.title.trim() || !draft.prompt.trim() || !isDirty}
title={!draft.title.trim() || !draft.prompt.trim() ? "请填写标题和提示词" : !isDirty ? "无修改内容" : undefined}
title={!draft.title.trim() || !draft.prompt.trim() ? t("heartbeat.requiredFields") : !isDirty ? t("heartbeat.noChanges") : undefined}
>
{isNew ? t("heartbeat.add") : t("heartbeat.save")}
</button>
+5
View File
@@ -394,6 +394,7 @@ export interface AppBindings {
SetStatusBarStyle(style: string): Promise<void>;
SetStatusBarItems(items: string[]): Promise<void>;
SetDesktopLanguage(lang: string): Promise<void>;
SetDesktopCurrency(currency: string): Promise<void>;
SetDesktopAppearance(theme: string, style: string): Promise<void>;
ListThemePacks(): Promise<import("./themePack").ThemePackView[]>;
GetActiveThemePack(): Promise<import("./themePack").ThemeActiveView>;
@@ -1516,6 +1517,7 @@ function makeMockApp(): AppBindings {
],
},
desktopLanguage: "",
desktopCurrency: "",
desktopLayoutStyle: "workbench",
desktopTheme: "auto",
desktopThemeStyle: "graphite",
@@ -4003,6 +4005,9 @@ function makeMockApp(): AppBindings {
async SetDesktopLanguage(lang: string) {
settings.desktopLanguage = lang === "en" || lang === "zh" ? lang : "";
},
async SetDesktopCurrency(currency: string) {
settings.desktopCurrency = currency === "CNY" || currency === "USD" ? currency : "";
},
async SetDesktopAppearance(theme: string, style: string) {
settings.desktopTheme = theme === "auto" || theme === "light" ? theme : "dark";
settings.desktopThemeStyle = style;
+1
View File
@@ -1638,6 +1638,7 @@ export interface SettingsView {
agent: AgentView;
bot: BotSettingsView;
desktopLanguage: string; // "" | "en" | "zh"; empty = auto
desktopCurrency?: string; // "" | "CNY" | "USD"; absent/empty = follow language
desktopLayoutStyle: string; // "classic" | "workbench" | "creation"
desktopTheme: string; // "auto" | "dark" | "light"
desktopThemeStyle: string;
+29
View File
@@ -660,6 +660,21 @@ export const en = {
"composer.contentUseCommandsDesc": "Browse available commands and skills",
"composer.contentUseCommandsEmptyOnly": "Available when the input is empty — commands replace the whole message",
"composer.contentCloseSessions": "Close recent sessions",
"composer.pastChatsLoading": "Loading recent sessions…",
"composer.pastChatsEmpty": "No recent sessions",
"composer.pastChatsSearch": "Search recent sessions…",
"composer.pastChatsNoMatches": "No matching sessions",
"composer.sessionTurns": "{n} turns",
"composer.backToFiles": "← Back to files",
"composer.removeSessionReference": "Remove session reference",
"composer.sessionContextHeader": "The user referenced the following earlier session context:",
"composer.sessionContextFooter": "Current user request:",
"composer.sessionContextSession": "Session: {title}",
"composer.sessionContextUser": "User",
"composer.sessionContextAssistant": "Assistant",
"composer.sessionContextTruncated": "Note: This session is long, so only its most recent messages are included.",
"composer.sessionContextEmpty": "Note: This session has no user or assistant messages that can be referenced.",
"composer.sessionContextReadFailed": "Note: This session could not be read and was skipped.",
"composer.searchProjects": "Search projects",
"composer.noProjectMatches": "No matching projects",
"composer.addProject": "Add new project",
@@ -846,6 +861,18 @@ export const en = {
"heartbeat.filterDisabled": "Disabled",
"heartbeat.searchPlaceholder": "Search tasks…",
"heartbeat.filterAllProjects": "All projects",
"heartbeat.noMatchingTasks": "No matching tasks",
"heartbeat.weekdayMon": "Mon",
"heartbeat.weekdayTue": "Tue",
"heartbeat.weekdayWed": "Wed",
"heartbeat.weekdayThu": "Thu",
"heartbeat.weekdayFri": "Fri",
"heartbeat.weekdaySat": "Sat",
"heartbeat.weekdaySun": "Sun",
"heartbeat.monthOption": "Month {n}",
"heartbeat.dayOption": "Day {n}",
"heartbeat.requiredFields": "Enter a title and prompt",
"heartbeat.noChanges": "No changes to save",
"heartbeat.fieldApprovalMode": "Approval mode",
"heartbeat.approvalModeAsk": "Ask",
"heartbeat.approvalModeAuto": "Auto",
@@ -2489,6 +2516,8 @@ export const en = {
"settings.bashOffShort": "Unconfined",
"settings.language": "Language",
"settings.langAuto": "Auto (system)",
"settings.currency": "Pricing currency",
"settings.currencyAuto": "Auto (language)",
"settings.config": "config: {path}",
"settings.generativeMusic": "Generative background music",
"settings.generativeMusicHint": "Ambient music that loops during AI generation",
+29
View File
@@ -650,6 +650,18 @@ export const zhTW: Record<DictKey, string> = {
"heartbeat.filterDisabled": "已禁用",
"heartbeat.searchPlaceholder": "搜索任務名稱…",
"heartbeat.filterAllProjects": "全部項目",
"heartbeat.noMatchingTasks": "沒有符合的任務",
"heartbeat.weekdayMon": "週一",
"heartbeat.weekdayTue": "週二",
"heartbeat.weekdayWed": "週三",
"heartbeat.weekdayThu": "週四",
"heartbeat.weekdayFri": "週五",
"heartbeat.weekdaySat": "週六",
"heartbeat.weekdaySun": "週日",
"heartbeat.monthOption": "{n}月",
"heartbeat.dayOption": "{n}日",
"heartbeat.requiredFields": "請填寫標題和提示詞",
"heartbeat.noChanges": "沒有可儲存的變更",
"heartbeat.fieldApprovalMode": "權限等級",
"heartbeat.approvalModeAsk": "Ask",
"heartbeat.approvalModeAuto": "Auto",
@@ -1713,6 +1725,8 @@ export const zhTW: Record<DictKey, string> = {
"settings.bashOffShort": "不受限執行",
"settings.language": "語言",
"settings.langAuto": "自動(跟隨系統)",
"settings.currency": "計價幣別",
"settings.currencyAuto": "自動(跟隨語言)",
"settings.config": "設定檔:{path}",
// 待辦欄
@@ -2188,6 +2202,21 @@ export const zhTW: Record<DictKey, string> = {
"composer.contentUseCommandsDesc": "瀏覽可用命令和技能",
"composer.contentUseCommandsEmptyOnly": "輸入框為空時可用——命令會構成整條訊息",
"composer.contentCloseSessions": "關閉歷史會話",
"composer.pastChatsLoading": "正在載入歷史會話…",
"composer.pastChatsEmpty": "暫無歷史會話",
"composer.pastChatsSearch": "搜尋歷史會話…",
"composer.pastChatsNoMatches": "沒有符合的歷史會話",
"composer.sessionTurns": "{n} 輪",
"composer.backToFiles": "← 返回檔案列表",
"composer.removeSessionReference": "移除引用會話",
"composer.sessionContextHeader": "以下是使用者引用的歷史會話上下文:",
"composer.sessionContextFooter": "目前使用者問題:",
"composer.sessionContextSession": "會話:{title}",
"composer.sessionContextUser": "使用者",
"composer.sessionContextAssistant": "助手",
"composer.sessionContextTruncated": "注意:該會話內容較長,以下只包含最近部分內容。",
"composer.sessionContextEmpty": "注意:該會話沒有可引用的使用者或助手訊息。",
"composer.sessionContextReadFailed": "注意:該會話讀取失敗,已略過。",
"composer.shellMode": "Shell 模式:給輸入前加 !",
"composer.shellModeOn": "Shell 模式已開:點選移除 !",
"composer.attachImageFailed": "圖片貼上失敗",
+29
View File
@@ -661,6 +661,21 @@ export const zh: Record<DictKey, string> = {
"composer.contentUseCommandsDesc": "浏览可用命令和技能",
"composer.contentUseCommandsEmptyOnly": "输入框为空时可用——命令会构成整条消息",
"composer.contentCloseSessions": "关闭历史会话",
"composer.pastChatsLoading": "正在加载历史会话…",
"composer.pastChatsEmpty": "暂无历史会话",
"composer.pastChatsSearch": "搜索历史会话…",
"composer.pastChatsNoMatches": "没有匹配的历史会话",
"composer.sessionTurns": "{n} 轮",
"composer.backToFiles": "← 返回文件列表",
"composer.removeSessionReference": "移除引用会话",
"composer.sessionContextHeader": "以下是用户引用的历史会话上下文:",
"composer.sessionContextFooter": "当前用户问题:",
"composer.sessionContextSession": "会话:{title}",
"composer.sessionContextUser": "用户",
"composer.sessionContextAssistant": "助手",
"composer.sessionContextTruncated": "注意:该会话内容较长,以下只包含最近部分内容。",
"composer.sessionContextEmpty": "注意:该会话没有可引用的用户或助手消息。",
"composer.sessionContextReadFailed": "注意:该会话读取失败,已跳过。",
"composer.searchProjects": "搜索项目",
"composer.noProjectMatches": "没有匹配的项目",
"composer.addProject": "添加新项目",
@@ -846,6 +861,18 @@ export const zh: Record<DictKey, string> = {
"heartbeat.filterEnabled": "已启用",
"heartbeat.filterDisabled": "已禁用",
"heartbeat.filterAllProjects": "全部项目",
"heartbeat.noMatchingTasks": "没有匹配的任务",
"heartbeat.weekdayMon": "周一",
"heartbeat.weekdayTue": "周二",
"heartbeat.weekdayWed": "周三",
"heartbeat.weekdayThu": "周四",
"heartbeat.weekdayFri": "周五",
"heartbeat.weekdaySat": "周六",
"heartbeat.weekdaySun": "周日",
"heartbeat.monthOption": "{n}月",
"heartbeat.dayOption": "{n}日",
"heartbeat.requiredFields": "请填写标题和提示词",
"heartbeat.noChanges": "无修改内容",
"heartbeat.searchPlaceholder": "搜索任务名称…",
"heartbeat.fieldApprovalMode": "权限等级",
"heartbeat.approvalModeAsk": "Ask",
@@ -2492,6 +2519,8 @@ export const zh: Record<DictKey, string> = {
"settings.bashOffShort": "不受限运行",
"settings.language": "语言",
"settings.langAuto": "自动(跟随系统)",
"settings.currency": "计价币种",
"settings.currencyAuto": "自动(跟随语言)",
"settings.config": "配置文件:{path}",
"settings.generativeMusic": "生成式背景音乐",
"settings.generativeMusicHint": "对话生成过程中播放背景音乐",
+115 -6
View File
@@ -266,6 +266,7 @@ type SettingsView struct {
Agent AgentView `json:"agent"`
Bot BotSettingsView `json:"bot"`
DesktopLanguage string `json:"desktopLanguage"`
DesktopCurrency string `json:"desktopCurrency"`
DesktopLayoutStyle string `json:"desktopLayoutStyle"`
DesktopTheme string `json:"desktopTheme"`
DesktopThemeStyle string `json:"desktopThemeStyle"`
@@ -902,6 +903,7 @@ func (a *App) Settings() SettingsView {
},
Bot: botSettingsView(cfg.Bot),
DesktopLanguage: cfg.DesktopLanguage(),
DesktopCurrency: cfg.DesktopCurrency(),
DesktopLayoutStyle: cfg.DesktopLayoutStyle(),
DesktopTheme: cfg.DesktopTheme(),
DesktopThemeStyle: cfg.DesktopThemeStyle(),
@@ -923,7 +925,7 @@ func (a *App) Settings() SettingsView {
}
added := providerAccessSet(cfg.Desktop.ProviderAccess)
resolver := config.NewCredentialResolverForRoot(root)
v.OfficialProviders = officialProviderViewsForRootWithResolver(officialProviderAddedSet(cfg), cfg.DeepSeekOfficialPricingLanguage(), root, resolver)
v.OfficialProviders = officialProviderViewsForRootWithResolver(officialProviderAddedSet(cfg), a.desktopOfficialPricingLanguage(cfg), root, resolver)
v.ProviderPresets = providerPresetViewsForRootWithResolver(cfg, root, resolver)
for i := range cfg.Providers {
p := &cfg.Providers[i]
@@ -1630,6 +1632,7 @@ func (a *App) rebuildSettingTurnLocked(setting string, tab *WorkspaceTab, admiss
sharedHost := a.lookupSharedHost(snap.sharedHostKey)
ctrl, err := boot.Build(a.bootContext(), boot.Options{
Model: model, RequireKey: false,
AutoPricingCurrency: a.desktopAutoPricingCurrency(),
Sink: snap.sink,
WorkspaceRoot: snap.workspaceRoot,
SessionDir: sessionDirForSnapshot(snap),
@@ -1693,6 +1696,9 @@ func (a *App) rebuildSettingTurnLocked(setting string, tab *WorkspaceTab, admiss
oldCtrl.Close()
}
a.persistTabSessionPath(tab, path)
if setting == "currency" {
a.repriceTabUsageForCurrentCurrency(tab)
}
a.clearDeferredRebuild(tab.ID)
a.notifyTabRuntimeRebuilt(tab)
a.emitReady(a.ctx)
@@ -2999,7 +3005,18 @@ func (a *App) SetStatusBarItems(items []string) error {
// language preference used by model-facing desktop sessions.
func (a *App) SetDesktopLanguage(lang string) error {
responseLanguage := ""
if err := a.applyConfigOnly(func(c *config.Config) error {
pricingChanged := false
if cfg, _, err := a.loadDesktopUserConfigForView(); err == nil && cfg.DesktopCurrency() == "" {
targetCurrency := a.desktopAutoPricingCurrency()
switch strings.ToLower(strings.TrimSpace(lang)) {
case "zh":
targetCurrency = "CNY"
case "en":
targetCurrency = "USD"
}
pricingChanged = a.desktopEffectivePricingCurrency(cfg) != targetCurrency
}
mutate := func(c *config.Config) error {
if err := c.SetDesktopLanguage(lang); err != nil {
return err
}
@@ -3008,21 +3025,113 @@ func (a *App) SetDesktopLanguage(lang string) error {
}
responseLanguage = c.ResponseLanguage()
return nil
}); err != nil {
}
var err error
if pricingChanged {
_, err = a.applyConfigChangeWithWarning("currency", mutate)
} else {
err = a.applyConfigOnly(mutate)
}
if err != nil {
return err
}
if pricingChanged {
a.scheduleCurrencyRefreshForOtherTabs()
}
if strings.TrimSpace(lang) != "" && !strings.EqualFold(strings.TrimSpace(lang), "auto") {
a.setDesktopLocale(lang)
}
a.updateTrayLocale(lang)
a.applyResponseLanguageToLiveControllers(responseLanguage)
return nil
}
// SetDesktopCurrency updates the official pricing region independently from UI
// language. Rebuild the active controller so subsequent usage carries the new
// currency and regional rates through the existing structured cost fields.
func (a *App) SetDesktopCurrency(currency string) error {
_, err := a.applyConfigChangeWithWarning("currency", func(c *config.Config) error {
return c.SetDesktopCurrency(currency)
})
if err == nil {
a.scheduleCurrencyRefreshForOtherTabs()
}
return err
}
func (a *App) scheduleCurrencyRefreshForOtherTabs() {
if a == nil || a.ctx == nil {
return
}
a.mu.RLock()
activeID := a.activeTabID
tabIDs := make([]string, 0, len(a.tabs))
for id, tab := range a.tabs {
if id != activeID && tab != nil && tab.Ctrl != nil && !tab.removed {
tabIDs = append(tabIDs, id)
}
}
a.mu.RUnlock()
for _, id := range tabIDs {
a.scheduleDeferredRebuild(id, "currency")
}
}
func (a *App) scheduleCurrencyRefreshForAllTabs() {
if a == nil {
return
}
a.mu.RLock()
tabIDs := make([]string, 0, len(a.tabs))
for id, tab := range a.tabs {
if tab != nil && tab.Ctrl != nil && !tab.removed {
tabIDs = append(tabIDs, id)
}
}
a.mu.RUnlock()
for _, id := range tabIDs {
a.scheduleDeferredRebuild(id, "currency")
}
}
func (a *App) desktopPricingFollowsDetectedLocale() bool {
cfg, _, err := a.loadDesktopUserConfigForView()
return err == nil && cfg.DesktopPricingFollowsDetectedLocale()
}
func (a *App) desktopEffectivePricingCurrency(cfg *config.Config) string {
if cfg == nil {
return a.desktopAutoPricingCurrency()
}
if cfg.DesktopPricingFollowsDetectedLocale() {
return a.desktopAutoPricingCurrency()
}
return cfg.DeepSeekOfficialPricingCurrency()
}
func (a *App) desktopOfficialPricingLanguage(cfg *config.Config) string {
if a.desktopEffectivePricingCurrency(cfg) == "CNY" {
return "zh"
}
return "en"
}
// SetTrayLocale mirrors the resolved desktop UI language into the native tray
// menu. It is runtime-only; the persisted preference remains [desktop].language.
func (a *App) SetTrayLocale(locale string) error {
if locale != "zh" {
locale = "en"
previousCurrency := a.desktopAutoPricingCurrency()
a.setDesktopLocale(locale)
pricingCurrencyChanged := previousCurrency != a.desktopAutoPricingCurrency()
trayLocale := "en"
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(locale)), "zh") {
trayLocale = "zh"
}
a.updateTrayLocale(locale)
a.updateTrayLocale(trayLocale)
if pricingCurrencyChanged && a.desktopPricingFollowsDetectedLocale() {
a.scheduleCurrencyRefreshForAllTabs()
a.kickDeferredRebuildRetry()
}
a.emitProjectTreeChanged()
return nil
}
+43 -14
View File
@@ -723,20 +723,30 @@ func TestOfficialMimoAPITemplateRemoved(t *testing.T) {
}
}
func TestOfficialDeepSeekTemplateDefaultsToRMBPricing(t *testing.T) {
entries, keyEnv, err := officialProviderTemplate("deepseek", "en")
if err != nil {
t.Fatalf("officialProviderTemplate: %v", err)
}
if keyEnv != "DEEPSEEK_API_KEY" || len(entries) != 1 {
t.Fatalf("template = %v/%q, want one DEEPSEEK_API_KEY entry", entries, keyEnv)
}
got := entries[0]
if got.Prices["deepseek-v4-flash"] == nil || got.Prices["deepseek-v4-flash"].Currency != "¥" || got.Prices["deepseek-v4-flash"].Output != 2 {
t.Fatalf("deepseek-v4-flash price = %+v, want RMB pricing", got.Prices["deepseek-v4-flash"])
}
if got.Prices["deepseek-v4-pro"] == nil || got.Prices["deepseek-v4-pro"].Currency != "¥" || got.Prices["deepseek-v4-pro"].Output != 6 {
t.Fatalf("deepseek-v4-pro price = %+v, want RMB pricing", got.Prices["deepseek-v4-pro"])
func TestOfficialDeepSeekTemplateUsesRegionalPricing(t *testing.T) {
for _, tt := range []struct {
language string
currency string
flashOutput float64
proOutput float64
}{
{language: "en", currency: "$", flashOutput: 0.28, proOutput: 0.87},
{language: "zh", currency: "¥", flashOutput: 2, proOutput: 6},
} {
entries, keyEnv, err := officialProviderTemplate("deepseek", tt.language)
if err != nil {
t.Fatalf("officialProviderTemplate(%s): %v", tt.language, err)
}
if keyEnv != "DEEPSEEK_API_KEY" || len(entries) != 1 {
t.Fatalf("template = %v/%q, want one DEEPSEEK_API_KEY entry", entries, keyEnv)
}
got := entries[0]
if price := got.Prices["deepseek-v4-flash"]; price == nil || price.Currency != tt.currency || price.Output != tt.flashOutput {
t.Fatalf("%s deepseek-v4-flash price = %+v", tt.language, price)
}
if price := got.Prices["deepseek-v4-pro"]; price == nil || price.Currency != tt.currency || price.Output != tt.proOutput {
t.Fatalf("%s deepseek-v4-pro price = %+v", tt.language, price)
}
}
}
@@ -831,6 +841,25 @@ func TestSetDesktopLanguagePersistsResponseLanguageAndUpdatesLiveTabs(t *testing
}
}
func TestSetDesktopCurrencyPersistsRegionalOfficialPricing(t *testing.T) {
isolateDesktopUserDirs(t)
app := NewApp()
if err := app.SetDesktopCurrency("CNY"); err != nil {
t.Fatalf("SetDesktopCurrency: %v", err)
}
view := app.Settings()
if view.DesktopCurrency != "CNY" {
t.Fatalf("Settings().DesktopCurrency = %q, want CNY", view.DesktopCurrency)
}
cfg := config.LoadForEdit(config.UserConfigPath())
flash, ok := cfg.Provider("deepseek-flash")
if !ok || flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != "¥" {
t.Fatalf("saved DeepSeek flash price = %+v, want CNY official price", flash)
}
}
func TestSetReasoningLanguageUpdatesLiveTabControllers(t *testing.T) {
isolateDesktopUserDirs(t)
projectRoot := t.TempDir()
+281 -26
View File
@@ -158,6 +158,7 @@ type WorkspaceTab struct {
SharedHostKey string // opaque key for the shared plugin host (set by buildTabController)
TopicID string // topic within the project
TopicTitle string // display title
topicTitleSource string // auto or manual; controls localization at API boundaries
SessionPath string // exact .jsonl file this tab continues
ReadOnly bool // true for external channel transcripts opened for browsing
Ctrl control.SessionAPI // nil while booting / on error
@@ -621,6 +622,7 @@ func cloneDetachedRuntimeTab(tab *WorkspaceTab, key, path string) *WorkspaceTab
SharedHostKey: tab.SharedHostKey,
TopicID: tab.TopicID,
TopicTitle: tab.TopicTitle,
topicTitleSource: tab.topicTitleSource,
SessionPath: canonicalTabSessionPath(path),
Ctrl: tab.Ctrl,
Label: tab.Label,
@@ -940,18 +942,174 @@ func (t *WorkspaceTab) recordUsage(e event.Event) {
src.CacheMissTokens += cacheMissTokens
src.RequestCount++
if e.Pricing != nil {
currency := e.Pricing.Symbol()
if existing := strings.TrimSpace(t.usageTelemetry.SessionCurrency); existing != "" && existing != currency {
// A scalar total cannot represent mixed currencies. Regional DeepSeek
// changes normally reprice prior usage; this fallback prevents custom
// or otherwise unmappable currencies from being summed together.
t.usageTelemetry.SessionCost = 0
t.usageTelemetry.SessionCostUsd = 0
for sourceName, sourceStats := range t.usageTelemetry.Sources {
sourceStats.SessionCost = 0
sourceStats.SessionCostUsd = 0
sourceStats.SessionCurrency = currency
t.usageTelemetry.Sources[sourceName] = sourceStats
}
src.SessionCost = 0
src.SessionCostUsd = 0
}
cost := e.Pricing.Cost(u)
t.usageTelemetry.SessionCost += cost
t.usageTelemetry.SessionCostUsd = t.usageTelemetry.SessionCost
t.usageTelemetry.SessionCurrency = e.Pricing.Symbol()
t.usageTelemetry.SessionCurrency = currency
src.SessionCost += cost
src.SessionCostUsd = src.SessionCost
src.SessionCurrency = e.Pricing.Symbol()
src.SessionCurrency = currency
}
t.usageTelemetry.Sources[source] = src
t.telemMu.Unlock()
}
func usageStatsAsProviderUsage(stats usageSourceStats) *provider.Usage {
return &provider.Usage{
PromptTokens: stats.PromptTokens,
CompletionTokens: stats.CompletionTokens,
TotalTokens: stats.TotalTokens,
ReasoningTokens: stats.ReasoningTokens,
CacheHitTokens: stats.CacheHitTokens,
CacheMissTokens: stats.CacheMissTokens,
}
}
func sessionStatsAsProviderUsage(stats sessionUsageStats) *provider.Usage {
return &provider.Usage{
PromptTokens: stats.PromptTokens,
CompletionTokens: stats.CompletionTokens,
TotalTokens: stats.TotalTokens,
ReasoningTokens: stats.ReasoningTokens,
CacheHitTokens: stats.CacheHitTokens,
CacheMissTokens: stats.CacheMissTokens,
}
}
// repriceUsage replaces the scalar cost total using per-source pricing. It
// leaves telemetry untouched when any costed source cannot be mapped to one
// currency.
func (t *WorkspaceTab) repriceUsage(pricingBySource map[string]*provider.Pricing) bool {
if t == nil {
return false
}
t.telemMu.Lock()
defer t.telemMu.Unlock()
if t.usageTelemetry.SessionCost <= 0 {
return true
}
if len(t.usageTelemetry.Sources) == 0 {
pricing := pricingBySource[event.UsageSourceExecutor]
if pricing == nil {
return false
}
t.usageTelemetry.SessionCost = pricing.Cost(sessionStatsAsProviderUsage(t.usageTelemetry))
t.usageTelemetry.SessionCostUsd = t.usageTelemetry.SessionCost
t.usageTelemetry.SessionCurrency = pricing.Symbol()
return true
}
currency := ""
total := 0.0
repriced := make(map[string]usageSourceStats, len(t.usageTelemetry.Sources))
for source, stats := range t.usageTelemetry.Sources {
if stats.SessionCost <= 0 {
repriced[source] = stats
continue
}
pricing := pricingBySource[source]
if pricing == nil {
return false
}
symbol := pricing.Symbol()
if currency != "" && currency != symbol {
return false
}
currency = symbol
stats.SessionCost = pricing.Cost(usageStatsAsProviderUsage(stats))
stats.SessionCostUsd = stats.SessionCost
stats.SessionCurrency = symbol
total += stats.SessionCost
repriced[source] = stats
}
if currency == "" {
return true
}
t.usageTelemetry.Sources = repriced
t.usageTelemetry.SessionCost = total
t.usageTelemetry.SessionCostUsd = total
t.usageTelemetry.SessionCurrency = currency
return true
}
func resolveOfficialDeepSeekPricing(cfg *config.Config, ref string) *provider.Pricing {
if cfg == nil || strings.TrimSpace(ref) == "" {
return nil
}
entry, ok := cfg.ResolveModel(ref)
if !ok || !config.IsOfficialDeepSeekProvider(entry) || !config.IsKnownDeepSeekOfficialPricing(entry.Model, entry.Price) {
return nil
}
return entry.Price
}
func firstConfiguredModelRef(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func usagePricingBySource(cfg *config.Config, executorRef string) map[string]*provider.Pricing {
if cfg == nil {
return nil
}
executorPricing := resolveOfficialDeepSeekPricing(cfg, executorRef)
plannerRef := firstConfiguredModelRef(cfg.Agent.PlannerModel, executorRef)
subagentRef := firstConfiguredModelRef(cfg.Agent.SubagentModel, executorRef)
routerRef := firstConfiguredModelRef(cfg.Agent.SubagentModels[event.UsageSourceCapabilityRouter], subagentRef, executorRef)
recoveryRef := firstConfiguredModelRef(cfg.Agent.RecoveryModel, cfg.Agent.GuardianModel, executorRef)
return map[string]*provider.Pricing{
event.UsageSourceExecutor: executorPricing,
event.UsageSourcePlanner: resolveOfficialDeepSeekPricing(cfg, plannerRef),
event.UsageSourceSubagent: resolveOfficialDeepSeekPricing(cfg, subagentRef),
event.UsageSourceCompaction: executorPricing,
event.UsageSourceClassifier: executorPricing,
event.UsageSourceTitle: executorPricing,
event.UsageSourceCapabilityRouter: resolveOfficialDeepSeekPricing(cfg, routerRef),
event.UsageSourceRecoveryReviewer: resolveOfficialDeepSeekPricing(cfg, recoveryRef),
}
}
func (a *App) repriceTabUsageForCurrentCurrency(tab *WorkspaceTab) {
if a == nil || tab == nil {
return
}
a.mu.RLock()
root := tab.WorkspaceRoot
model := tab.model
a.mu.RUnlock()
cfg, err := config.LoadForRoot(root)
if err != nil {
return
}
cfg.ApplyRuntimeAutoPricingCurrency(a.desktopAutoPricingCurrency())
if !tab.repriceUsage(usagePricingBySource(cfg, model)) {
return
}
if path := tab.currentSessionPath(); path != "" {
_ = saveTelemetry(path+".telemetry.json", tab.telemetrySnapshot())
}
}
func (t *WorkspaceTab) telemetrySnapshot() tabTelemetrySnapshot {
t.telemMu.Lock()
defer t.telemMu.Unlock()
@@ -2002,7 +2160,7 @@ func (a *App) tabMeta(tab *WorkspaceTab, active bool) TabMeta {
WorkspaceName: workspaceName(tab.WorkspaceRoot),
WorkspacePath: tab.WorkspaceRoot,
TopicID: tab.TopicID,
TopicTitle: tab.TopicTitle,
TopicTitle: a.localizedTopicTitle(tab.TopicTitle, tab.topicTitleSource),
SessionPath: tab.currentSessionPath(),
ReadOnly: tab.ReadOnly,
Label: tab.Label,
@@ -2223,13 +2381,14 @@ func (a *App) openTopicTabWithActivation(scope, workspaceRoot, topicID, sessionP
}
profile := loadTabSessionProfile(sessionPath)
tab := &WorkspaceTab{
ID: tabID,
Scope: scope,
WorkspaceRoot: actualRoot,
TopicID: topicID,
TopicTitle: topicTitle,
SessionPath: sessionPath,
disabledMCP: map[string]ServerView{},
ID: tabID,
Scope: scope,
WorkspaceRoot: actualRoot,
TopicID: topicID,
TopicTitle: topicTitle,
topicTitleSource: loadTopicTitleSource(topicTitleRoot(scope, workspaceRoot), topicID),
SessionPath: sessionPath,
disabledMCP: map[string]ServerView{},
}
applyTabSessionProfile(tab, profile)
tab.sink = &tabEventSink{tabID: tabID, app: a}
@@ -2458,6 +2617,7 @@ func (a *App) ensureBlankTab(scope, workspaceRoot, forcedTokenMode string) (TabM
WorkspaceRoot: actualRoot,
TopicID: topicID,
TopicTitle: topicTitle,
topicTitleSource: loadTopicTitleSource(topicTitleRoot(scope, workspaceRoot), topicID),
model: inheritedModel,
effort: inheritedEffort,
tokenMode: inheritedTokenMode,
@@ -2507,6 +2667,7 @@ func (a *App) ensureBlankTab(scope, workspaceRoot, forcedTokenMode string) (TabM
WorkspaceRoot: actualRoot,
TopicID: topicID,
TopicTitle: topicTitleForTab(scope, workspaceRoot, topicID),
topicTitleSource: topicTitleSourceAuto,
model: inheritedModel,
effort: inheritedEffort,
tokenMode: inheritedTokenMode,
@@ -2634,6 +2795,7 @@ func resetReusableBlankTabTitle(tab *WorkspaceTab, scope, workspaceRoot string)
}
_ = deleteTopicAutoTitleMeta(titleRoot, topicID)
tab.TopicTitle = defaultTopicTitle
tab.topicTitleSource = topicTitleSourceAuto
return nil
}
@@ -2785,6 +2947,7 @@ func (a *App) SetActiveTab(tabID string) error {
a.emitReady(a.ctx, tabID)
a.emitRuntimeEvent("runtime:rebuilt", tabID)
}
a.kickDeferredRebuildRetry()
return nil
}
@@ -3397,6 +3560,7 @@ func (a *App) buildTabControllerWithContextAdmissionHeld(tab *WorkspaceTab, load
if tabScope == "global" && topicID == "" && len(migratedGlobalTopics) > 0 {
topicID = migratedGlobalTopics[0]
topicTitle := topicTitleForTab("global", "", topicID)
topicSource := loadTopicTitleSource("", topicID)
a.mu.Lock()
if a.tabBuildSupersededLocked(tab, buildGeneration) {
a.mu.Unlock()
@@ -3405,6 +3569,7 @@ func (a *App) buildTabControllerWithContextAdmissionHeld(tab *WorkspaceTab, load
if strings.TrimSpace(tab.TopicID) == "" {
tab.TopicID = topicID
tab.TopicTitle = topicTitle
tab.topicTitleSource = topicSource
a.saveTabsLocked()
} else {
topicID = strings.TrimSpace(tab.TopicID)
@@ -3484,6 +3649,7 @@ func (a *App) buildTabControllerWithContextAdmissionHeld(tab *WorkspaceTab, load
ctrl, err := boot.Build(buildCtx, boot.Options{
Model: model,
RequireKey: false,
AutoPricingCurrency: a.desktopAutoPricingCurrency(),
Sink: sink,
WorkspaceRoot: root,
SessionDir: sessionDir,
@@ -3545,10 +3711,12 @@ func (a *App) buildTabControllerWithContextAdmissionHeld(tab *WorkspaceTab, load
if tabScope == "global" && tabTopicID == "" && len(migratedTopics) > 0 {
topicID := migratedTopics[0]
topicTitle := topicTitleForTab("global", "", topicID)
topicSource := loadTopicTitleSource("", topicID)
a.mu.Lock()
if !a.tabBuildSupersededLocked(tab, buildGeneration) && strings.TrimSpace(tab.TopicID) == "" {
tab.TopicID = topicID
tab.TopicTitle = topicTitle
tab.topicTitleSource = topicSource
tabTopicID = topicID
a.saveTabsLocked()
} else {
@@ -3794,6 +3962,10 @@ func (a *App) applySessionBindingToTab(tab *WorkspaceTab, binding sessionBinding
if topicTitle == "" && topicID != "" {
topicTitle = topicTitleForTab(scope, workspaceRoot, topicID)
}
topicSource := ""
if topicID != "" {
topicSource = loadTopicTitleSource(topicTitleRoot(scope, workspaceRoot), topicID)
}
a.mu.Lock()
current := a.tabs[tab.ID]
@@ -3815,6 +3987,7 @@ func (a *App) applySessionBindingToTab(tab *WorkspaceTab, binding sessionBinding
if topicID != "" {
changed = changed || tab.TopicID != topicID
tab.TopicID = topicID
tab.topicTitleSource = topicSource
}
if topicTitle != "" {
changed = changed || tab.TopicTitle != topicTitle
@@ -4177,7 +4350,7 @@ func (a *App) maybeAutoTitleTopic(tab *WorkspaceTab) bool {
if !updated {
return false
}
a.updateOpenTopicTitle(topicID, nextTitle)
a.updateOpenTopicTitle(topicID, nextTitle, topicTitleSourceAuto)
a.updateTopicSessionTitles(topicID, nextTitle)
a.emitProjectTreeChanged()
return true
@@ -4282,7 +4455,7 @@ func topicTitleFallbackForOpen(workspaceRoot, topicID, sessionPath string) (stri
storedTitle := strings.TrimSpace(loadTopicTitle(workspaceRoot, topicID))
storedSource := strings.TrimSpace(loadTopicTitleSource(workspaceRoot, topicID))
if storedTitle != "" {
if storedSource == topicTitleSourceManual || storedTitle != defaultTopicTitle {
if storedSource == topicTitleSourceManual || !isDefaultTopicTitle(storedTitle) {
return "", "", false
}
}
@@ -4404,7 +4577,7 @@ func topicTitleFromText(text string) string {
if len(runes) > maxRunes {
text = strings.TrimRightFunc(string(runes[:maxRunes]), unicode.IsPunct) + "…"
}
if text == defaultTopicTitle {
if isDefaultTopicTitle(text) {
return ""
}
return text
@@ -5288,10 +5461,70 @@ const (
topicCreatedAtsFile = "desktop-topic-created-at.json"
topicAutoTitlesFile = "desktop-topic-auto-title-meta.json"
defaultTopicTitle = "新的会话"
defaultTopicTitleEn = "New session"
defaultTopicTitleZhTW = "新的會話"
topicTitleSourceAuto = "auto"
topicTitleSourceManual = "manual"
)
const (
desktopLocaleUnknown int32 = iota
desktopLocaleEn
desktopLocaleZh
desktopLocaleZhTW
)
func (a *App) setDesktopLocale(locale string) {
normalized := strings.ToLower(strings.TrimSpace(locale))
switch {
case strings.HasPrefix(normalized, "zh-tw"), strings.HasPrefix(normalized, "zh-hant"):
a.desktopLocale.Store(desktopLocaleZhTW)
case strings.HasPrefix(normalized, "zh"):
a.desktopLocale.Store(desktopLocaleZh)
default:
a.desktopLocale.Store(desktopLocaleEn)
}
}
func (a *App) desktopAutoPricingCurrency() string {
if a != nil {
switch a.desktopLocale.Load() {
case desktopLocaleZh, desktopLocaleZhTW:
return "CNY"
}
}
return "USD"
}
func (a *App) localizedDefaultTopicTitle() string {
switch a.desktopLocale.Load() {
case desktopLocaleZh:
return defaultTopicTitle
case desktopLocaleZhTW:
return defaultTopicTitleZhTW
case desktopLocaleEn:
return defaultTopicTitleEn
default:
return defaultTopicTitle
}
}
func isDefaultTopicTitle(title string) bool {
switch strings.TrimSpace(title) {
case defaultTopicTitle, defaultTopicTitleEn, defaultTopicTitleZhTW:
return true
default:
return false
}
}
func (a *App) localizedTopicTitle(title, source string) string {
if strings.TrimSpace(source) == topicTitleSourceAuto && isDefaultTopicTitle(title) {
return a.localizedDefaultTopicTitle()
}
return title
}
func topicTitlesPath(workspaceRoot string) string {
if workspaceRoot == "" {
return filepath.Join(desktopConfigDir(), "global", topicTitlesFile)
@@ -5579,14 +5812,24 @@ func topicTitleRoot(scope, workspaceRoot string) string {
return workspaceRoot
}
func forkTopicTitle(title string) string {
func (a *App) forkTopicTitle(title string) string {
base := strings.TrimSpace(title)
if base == "" || base == defaultTopicTitle || base == "Global" {
return "分叉会话"
if base == "" || isDefaultTopicTitle(base) || base == "Global" {
switch a.desktopLocale.Load() {
case desktopLocaleEn:
return "Forked session"
case desktopLocaleZhTW:
return "分叉會話"
default:
return "分叉会话"
}
}
if strings.HasSuffix(base, " · 分叉") {
if strings.HasSuffix(base, " · 分叉") || strings.HasSuffix(base, " · fork") {
return base
}
if a.desktopLocale.Load() == desktopLocaleEn {
return base + " · fork"
}
return base + " · 分叉"
}
@@ -5775,7 +6018,7 @@ func setTopicTitleWithSource(workspaceRoot, topicID, title, source string) error
return err
}
if strings.TrimSpace(source) == topicTitleSourceManual ||
(strings.TrimSpace(source) == topicTitleSourceAuto && strings.TrimSpace(title) == defaultTopicTitle) {
(strings.TrimSpace(source) == topicTitleSourceAuto && isDefaultTopicTitle(title)) {
_ = deleteTopicAutoTitleMeta(workspaceRoot, topicID)
}
return nil
@@ -6661,7 +6904,7 @@ func (a *App) CreateTopic(scope, workspaceRoot, title string) (TopicMeta, error)
// just created is immediately visible and selected in the sidebar.
_ = prependTopicInProjectsFile(workspaceRoot, topicID, workspaceRoot != "")
a.emitProjectTreeChanged()
return TopicMeta{ID: topicID, Title: trimmedTitle, CreatedAt: createdAt}, nil
return TopicMeta{ID: topicID, Title: a.localizedTopicTitle(trimmedTitle, titleSource), CreatedAt: createdAt}, nil
}
// RenameProject updates the sidebar-only display title for a project folder.
@@ -6789,7 +7032,7 @@ func (a *App) RenameTopic(topicID, title string) error {
if err := setTopicTitle(p.Root, topicID, trimmed); err != nil {
return err
}
a.updateOpenTopicTitle(topicID, trimmed)
a.updateOpenTopicTitle(topicID, trimmed, topicTitleSourceManual)
a.updateTopicSessionTitles(topicID, trimmed)
a.emitProjectTreeChanged()
return nil
@@ -6801,7 +7044,7 @@ func (a *App) RenameTopic(topicID, title string) error {
if err := setTopicTitle("", topicID, trimmed); err != nil {
return err
}
a.updateOpenTopicTitle(topicID, trimmed)
a.updateOpenTopicTitle(topicID, trimmed, topicTitleSourceManual)
a.updateTopicSessionTitles(topicID, trimmed)
a.emitProjectTreeChanged()
return nil
@@ -6810,7 +7053,7 @@ func (a *App) RenameTopic(topicID, title string) error {
if err := ensureTopicIndexed(scope, workspaceRoot, topicID, trimmed, topicTitleSourceManual); err != nil {
return err
}
a.updateOpenTopicTitle(topicID, trimmed)
a.updateOpenTopicTitle(topicID, trimmed, topicTitleSourceManual)
a.updateTopicSessionTitles(topicID, trimmed)
a.emitProjectTreeChanged()
return nil
@@ -6858,15 +7101,16 @@ func (a *App) findTopicLocation(topicID string) (string, string, bool) {
return "", "", false
}
func (a *App) updateOpenTopicTitle(topicID, title string) {
func (a *App) updateOpenTopicTitle(topicID, title, source string) {
if strings.TrimSpace(topicID) == "" || strings.TrimSpace(title) == "" {
return
}
a.mu.Lock()
defer a.mu.Unlock()
for _, tab := range a.tabs {
for _, tab := range a.runtimeTabsLocked() {
if tab != nil && tab.TopicID == topicID {
tab.TopicTitle = title
tab.topicTitleSource = source
}
}
}
@@ -7230,6 +7474,7 @@ func (s topicSummary) displayTurns() int {
type runtimeSessionStatus struct {
sessionPath string
label string
titleSource string
turns int
createdAt int64
lastActivityAt int64
@@ -7359,6 +7604,10 @@ func (a *App) ListProjectTree() []ProjectNode {
info := sessionInfos[sessionPath]
recovered := sessionInfoIsAutomaticRecovery(info) || isAutomaticRecoverySessionPath(sessionPath)
label := runtimeSessionTreeLabel(tab, info, sessionTitles[sessionPath])
titleSource := tab.topicTitleSource
if strings.TrimSpace(sessionTitles[sessionPath]) != "" {
titleSource = topicTitleSourceManual
}
status := activityStatusForTab(tab)
runtimeStatus := control.RuntimeStatus{}
if tab.Ctrl != nil {
@@ -7368,6 +7617,7 @@ func (a *App) ListProjectTree() []ProjectNode {
runtimeSessionsByTopic[topicSummaryKey(tab.Scope, tab.WorkspaceRoot, tab.TopicID)] = append(runtimeSessionsByTopic[topicSummaryKey(tab.Scope, tab.WorkspaceRoot, tab.TopicID)], runtimeSessionStatus{
sessionPath: sessionPath,
label: label,
titleSource: titleSource,
turns: info.Turns,
createdAt: unixMilliOrZero(info.CreatedAt),
lastActivityAt: unixMilliOrZero(info.LastActivityAt),
@@ -7420,7 +7670,7 @@ func (a *App) ListProjectTree() []ProjectNode {
nodes = append(nodes, ProjectNode{
Key: projectSessionNodeKey(scope, session.sessionPath),
Kind: kind,
Label: session.label,
Label: a.localizedTopicTitle(session.label, session.titleSource),
Root: workspaceRoot,
TopicID: topicID,
SessionPath: session.sessionPath,
@@ -7442,6 +7692,7 @@ func (a *App) ListProjectTree() []ProjectNode {
// Global section.
globalTitleMap := loadTopicTitles("")
globalTitleSources := loadTopicTitleSources("")
globalCreatedMap := loadTopicCreatedAts("")
if len(globalTitleMap) > 0 || len(f.Projects) == 0 {
globalTitle := strings.TrimSpace(f.GlobalTitle)
@@ -7455,7 +7706,7 @@ func (a *App) ListProjectTree() []ProjectNode {
if deletedTopicSet[id] {
continue
}
title := globalTitleMap[id]
title := a.localizedTopicTitle(globalTitleMap[id], globalTitleSources[id])
summaryKey := topicSummaryKey("global", "", id)
summary := topicSummaries[summaryKey]
open, running, status := topicRuntimeStatus(summaryKey)
@@ -7493,6 +7744,7 @@ func (a *App) ListProjectTree() []ProjectNode {
type projectTopics struct {
project desktopProject
titles map[string]string
sources map[string]string
createdAts map[string]int64
}
projectTopicResults := make([]projectTopics, len(f.Projects))
@@ -7505,6 +7757,7 @@ func (a *App) ListProjectTree() []ProjectNode {
projectTopicResults[i] = projectTopics{
project: p,
titles: loadTopicTitles(p.Root),
sources: loadTopicTitleSources(p.Root),
createdAts: loadTopicCreatedAts(p.Root),
}
}()
@@ -7526,6 +7779,7 @@ func (a *App) ListProjectTree() []ProjectNode {
// Gather topics: explicit topic list + all known topic titles.
titleMap := loaded.titles
titleSources := loaded.sources
createdMap := loaded.createdAts
topicIDs := pinnedTopicIDs(orderedTopicIDs(p.Topics, titleMap), p.PinnedTopics)
@@ -7538,6 +7792,7 @@ func (a *App) ListProjectTree() []ProjectNode {
if topicTitle == "" {
topicTitle = defaultTopicTitle
}
topicTitle = a.localizedTopicTitle(topicTitle, titleSources[tid])
summaryKey := topicSummaryKey("project", p.Root, tid)
summary := topicSummaries[summaryKey]
open, running, status := topicRuntimeStatus(summaryKey)
+194
View File
@@ -0,0 +1,194 @@
package main
import (
"testing"
"reasonix/internal/config"
"reasonix/internal/control"
)
func TestDefaultTopicTitleLocalizesAtAPIBoundary(t *testing.T) {
app := &App{}
for _, tt := range []struct {
locale string
want string
}{
{locale: "en-US", want: defaultTopicTitleEn},
{locale: "zh-CN", want: defaultTopicTitle},
{locale: "zh-TW", want: defaultTopicTitleZhTW},
} {
app.setDesktopLocale(tt.locale)
if got := app.localizedTopicTitle(defaultTopicTitle, topicTitleSourceAuto); got != tt.want {
t.Fatalf("locale %q title = %q, want %q", tt.locale, got, tt.want)
}
}
}
func TestManualDefaultTopicTitleIsNotLocalized(t *testing.T) {
app := &App{}
for _, tt := range []struct {
locale string
title string
}{
{locale: "zh-CN", title: defaultTopicTitleEn},
{locale: "en-US", title: defaultTopicTitle},
{locale: "zh-CN", title: defaultTopicTitleZhTW},
} {
app.setDesktopLocale(tt.locale)
if got := app.localizedTopicTitle(tt.title, topicTitleSourceManual); got != tt.title {
t.Fatalf("locale %q manual title = %q, want %q", tt.locale, got, tt.title)
}
}
}
func TestCreateTopicPreservesManualDefaultTitle(t *testing.T) {
isolateDesktopUserDirs(t)
app := &App{}
app.projectTreeChangedHook = func() {}
app.setDesktopLocale("zh-CN")
manual, err := app.CreateTopic("global", "", defaultTopicTitleEn)
if err != nil {
t.Fatalf("CreateTopic manual: %v", err)
}
if manual.Title != defaultTopicTitleEn {
t.Fatalf("manual title = %q, want %q", manual.Title, defaultTopicTitleEn)
}
if got := loadTopicTitleSource("", manual.ID); got != topicTitleSourceManual {
t.Fatalf("manual title source = %q, want %q", got, topicTitleSourceManual)
}
tree := app.ListProjectTree()
if len(tree) == 0 || len(tree[0].Children) == 0 || tree[0].Children[0].Label != defaultTopicTitleEn {
t.Fatalf("manual project-tree title was localized: %+v", tree)
}
automatic, err := app.CreateTopic("global", "", "")
if err != nil {
t.Fatalf("CreateTopic automatic: %v", err)
}
if automatic.Title != defaultTopicTitle {
t.Fatalf("automatic title = %q, want localized %q", automatic.Title, defaultTopicTitle)
}
if err := app.RenameTopic(automatic.ID, defaultTopicTitleEn); err != nil {
t.Fatalf("RenameTopic manual default: %v", err)
}
if got := loadTopicTitleSource("", automatic.ID); got != topicTitleSourceManual {
t.Fatalf("renamed title source = %q, want %q", got, topicTitleSourceManual)
}
tree = app.ListProjectTree()
if len(tree) == 0 || len(tree[0].Children) == 0 || tree[0].Children[0].Label != defaultTopicTitleEn {
t.Fatalf("renamed project-tree title was localized: %+v", tree)
}
}
func TestDefaultTopicTitleVariantsRemainPersistenceSentinels(t *testing.T) {
for _, title := range []string{defaultTopicTitle, defaultTopicTitleEn, defaultTopicTitleZhTW} {
if !isDefaultTopicTitle(title) {
t.Fatalf("title %q was not recognized as a default sentinel", title)
}
}
if got := topicTitleFromText(defaultTopicTitleEn); got != "" {
t.Fatalf("English default title became a generated title: %q", got)
}
}
func TestForkTopicTitleUsesDesktopLocale(t *testing.T) {
app := &App{}
app.setDesktopLocale("en")
if got := app.forkTopicTitle("New session"); got != "Forked session" {
t.Fatalf("English fork title = %q", got)
}
app.setDesktopLocale("zh-TW")
if got := app.forkTopicTitle(defaultTopicTitle); got != "分叉會話" {
t.Fatalf("Traditional Chinese fork title = %q", got)
}
app.desktopLocale.Store(desktopLocaleUnknown)
if got := app.forkTopicTitle(""); got != "分叉会话" {
t.Fatalf("legacy fallback fork title = %q", got)
}
}
func TestSetTrayLocaleSchedulesAutoCurrencyRefresh(t *testing.T) {
isolateDesktopUserDirs(t)
cfg := config.Default()
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save auto config: %v", err)
}
app := NewApp()
app.projectTreeChangedHook = func() {}
activeCtrl := control.New(control.Options{Label: "active"})
inactiveCtrl := control.New(control.Options{Label: "inactive"})
t.Cleanup(activeCtrl.Close)
t.Cleanup(inactiveCtrl.Close)
app.tabs = map[string]*WorkspaceTab{
"active": {ID: "active", Ctrl: activeCtrl},
"inactive": {ID: "inactive", Ctrl: inactiveCtrl},
}
app.activeTabID = "active"
app.setDesktopLocale("en")
if err := app.SetTrayLocale("zh-CN"); err != nil {
t.Fatalf("SetTrayLocale: %v", err)
}
for _, tabID := range []string{"active", "inactive"} {
if !app.deferredRebuildPending(tabID) {
t.Fatalf("currency refresh for %q was not scheduled", tabID)
}
}
}
func TestSetTrayLocaleKeepsExplicitCurrencyRuntime(t *testing.T) {
isolateDesktopUserDirs(t)
cfg := config.Default()
cfg.Desktop.Currency = "USD"
cfg.ApplyDeepSeekOfficialDefaultPricing()
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save explicit currency config: %v", err)
}
app := NewApp()
app.projectTreeChangedHook = func() {}
ctrl := control.New(control.Options{Label: "active"})
t.Cleanup(ctrl.Close)
app.tabs = map[string]*WorkspaceTab{"active": {ID: "active", Ctrl: ctrl}}
app.activeTabID = "active"
app.setDesktopLocale("en")
if err := app.SetTrayLocale("zh-CN"); err != nil {
t.Fatalf("SetTrayLocale: %v", err)
}
if app.deferredRebuildPending("active") {
t.Fatal("explicit USD currency scheduled an automatic locale refresh")
}
}
func TestDesktopEffectivePricingCurrencyUsesLocaleOnlyForAuto(t *testing.T) {
app := NewApp()
app.setDesktopLocale("zh-CN")
cfg := config.Default()
if got := app.desktopEffectivePricingCurrency(cfg); got != "CNY" {
t.Fatalf("auto pricing currency = %q, want CNY", got)
}
cfg.Desktop.Language = "en"
cfg.ApplyDeepSeekOfficialDefaultPricing()
if got := app.desktopEffectivePricingCurrency(cfg); got != "USD" {
t.Fatalf("explicit desktop language pricing currency = %q, want USD", got)
}
cfg.Desktop.Language = ""
cfg.Language = "en"
cfg.ApplyDeepSeekOfficialDefaultPricing()
if got := app.desktopEffectivePricingCurrency(cfg); got != "USD" {
t.Fatalf("explicit CLI language pricing currency = %q, want USD", got)
}
cfg.Language = ""
cfg.Desktop.Currency = "USD"
cfg.ApplyDeepSeekOfficialDefaultPricing()
if got := app.desktopEffectivePricingCurrency(cfg); got != "USD" {
t.Fatalf("explicit currency pricing currency = %q, want USD", got)
}
}
+59
View File
@@ -8,6 +8,7 @@ import (
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/provider"
@@ -89,6 +90,64 @@ func TestWorkspaceTabAggregatesSessionUsageTelemetry(t *testing.T) {
}
}
func TestWorkspaceTabRepricesUsageWithoutMixingCurrencies(t *testing.T) {
tab := &WorkspaceTab{}
tab.recordUsage(event.Event{
Usage: &provider.Usage{PromptTokens: 1_000_000, CompletionTokens: 100_000, TotalTokens: 1_100_000},
UsageSource: event.UsageSourceExecutor,
Pricing: &provider.Pricing{Input: 1, Output: 2, Currency: "CNY"},
})
if ok := tab.repriceUsage(map[string]*provider.Pricing{
event.UsageSourceExecutor: {Input: 0.14, Output: 0.28, Currency: "USD"},
}); !ok {
t.Fatal("repriceUsage rejected a complete source mapping")
}
got := tab.telemetrySnapshot().Usage
want := 0.14 + 0.1*0.28
if got.SessionCurrency != "$" || got.SessionCost != want {
t.Fatalf("repriced usage = %f %q, want %f USD", got.SessionCost, got.SessionCurrency, want)
}
}
func TestRepriceTabUsageUsesDetectedLocaleForAutoCurrency(t *testing.T) {
isolateDesktopUserDirs(t)
cfg := config.Default()
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatalf("save auto config: %v", err)
}
tab := &WorkspaceTab{WorkspaceRoot: t.TempDir(), model: "deepseek-flash/deepseek-v4-flash"}
tab.recordUsage(event.Event{
Usage: &provider.Usage{PromptTokens: 1_000_000, TotalTokens: 1_000_000},
UsageSource: event.UsageSourceExecutor,
Pricing: &provider.Pricing{Input: 0.14, Currency: "USD"},
})
app := NewApp()
app.setDesktopLocale("zh-CN")
app.repriceTabUsageForCurrentCurrency(tab)
got := tab.telemetrySnapshot().Usage
if got.SessionCurrency != "¥" || got.SessionCost != 1 {
t.Fatalf("auto-locale repriced usage = %f %q, want 1 CNY", got.SessionCost, got.SessionCurrency)
}
}
func TestWorkspaceTabDoesNotAddDifferentCurrencies(t *testing.T) {
tab := &WorkspaceTab{}
tab.recordUsage(event.Event{
Usage: &provider.Usage{PromptTokens: 1_000_000, TotalTokens: 1_000_000},
Pricing: &provider.Pricing{Input: 1, Currency: "CNY"},
})
tab.recordUsage(event.Event{
Usage: &provider.Usage{PromptTokens: 1_000_000, TotalTokens: 1_000_000},
Pricing: &provider.Pricing{Input: 0.14, Currency: "USD"},
})
got := tab.telemetrySnapshot().Usage
if got.SessionCurrency != "$" || got.SessionCost != 0.14 {
t.Fatalf("mixed-currency usage = %f %q, want only the current USD bucket", got.SessionCost, got.SessionCurrency)
}
}
func TestWorkspaceTabSubagentUsageDoesNotOverwriteExecutorSessionCache(t *testing.T) {
tab := &WorkspaceTab{}
tab.recordUsage(event.Event{
+32
View File
@@ -69,6 +69,28 @@ different keys. Providers added or removed through setup are also added to or
removed from desktop provider access, so the same models are available in the
desktop app.
### Configure regional pricing currency
Use the user-global currency command to inspect or select the official DeepSeek
regional price table:
```sh
reasonix config currency # show the saved and resolved currency
reasonix config currency auto # follow the resolved locale
reasonix config currency CNY
reasonix config currency USD
```
`auto` resolves Simplified or Traditional Chinese locales to CNY and English or
other locales to USD. An explicit `CNY` or `USD` selection remains independent
from the UI language. This preference is stored in the user config and cannot
be overridden by project `reasonix.toml`; `--local` is therefore not supported.
Custom provider prices are preserved.
In an interactive session, `/currency` shows the saved and resolved values, and
`/currency auto|CNY|USD` changes the preference and refreshes the current
runtime without discarding the conversation.
## One-shot and automation
Use `-p` / `--print` when a script needs only the final answer:
@@ -110,6 +132,8 @@ The final structured object has this shape:
"num_turns": 1,
"result": "...",
"session_id": "...",
"total_cost": 0,
"currency": "USD",
"total_cost_usd": 0,
"usage": {
"input_tokens": 0,
@@ -120,6 +144,13 @@ The final structured object has this shape:
}
```
`total_cost` is denominated in the ISO currency code from `currency`, currently
`CNY` or `USD` for official DeepSeek pricing. `total_cost_usd` remains as a
numeric compatibility alias and mirrors `total_cost`; despite its legacy name,
it is not converted to USD when `currency` is `CNY`. New consumers must use
`total_cost` together with `currency`. A structured run fails instead of
reporting a misleading total if usage contains mixed currencies.
Execution failures use `subtype: "error_during_execution"` and
`is_error: true`. Structured modes keep runtime errors in JSON instead of also
printing a duplicate human-readable error.
@@ -310,6 +341,7 @@ the displayed list matches the commands the TUI accepts.
| `/status` | Show model, effort, cache, Git, background jobs, and profile or balance details. |
| `/work-mode [economy\|balanced\|delivery]` | View or change the runtime profile; `/profile` is an alias. |
| `/theme [auto\|light\|dark\|style]` | View or change the CLI background mode and accent palette. |
| `/currency [auto\|CNY\|USD]` | View or change the user-global official pricing currency and refresh the runtime. |
| `/paste-image` | Read a clipboard image and insert an editable attachment token. |
| `/mouse` | Toggle in-app mouse selection, scrollbar, and wheel handling. |
| `/effort` | View or change reasoning effort. |
+27
View File
@@ -65,6 +65,24 @@ setup 会询问是否共享该凭据;两个 provider 使用不同 Key 时,
setup 添加或删除 provider 时,也会同步维护桌面端 provider access,因此相同模型可以
直接在桌面端使用。
### 配置区域定价货币
使用用户全局货币命令查看或选择 DeepSeek 官方区域价格表:
```sh
reasonix config currency # 显示已保存值和最终解析结果
reasonix config currency auto # 跟随解析后的 locale
reasonix config currency CNY
reasonix config currency USD
```
`auto` 会把简体或繁体中文 locale 解析为 CNY,把英文及其他 locale 解析为 USD。显式
选择 `CNY` 或 `USD` 后,货币不再跟随界面语言。该偏好只保存在用户全局配置中,项目
`reasonix.toml` 无法覆盖,因此不支持 `--local`。自定义 provider 价格不会被修改。
在交互式会话中,`/currency` 显示已保存值和最终解析结果;
`/currency auto|CNY|USD` 会修改偏好并刷新当前运行时,同时保留当前对话。
## 一次性运行与自动化
脚本只需要最终回答时,使用 `-p` / `--print`:
@@ -106,6 +124,8 @@ reasonix run "运行测试" --output-format stream-json
"num_turns": 1,
"result": "...",
"session_id": "...",
"total_cost": 0,
"currency": "CNY",
"total_cost_usd": 0,
"usage": {
"input_tokens": 0,
@@ -116,6 +136,12 @@ reasonix run "运行测试" --output-format stream-json
}
```
`total_cost` 使用 `currency` 给出的 ISO 货币代码计价;DeepSeek 官方价格目前会输出
`CNY` 或 `USD`。`total_cost_usd` 作为数字兼容别名继续保留,并与 `total_cost` 数值
相同;即使 `currency` 为 `CNY`,它也不会按旧字段名自动换算为美元。新接入必须同时读取
`total_cost` 和 `currency`。如果一次结构化运行包含多种货币,Reasonix 会直接报错,
不会输出容易误解的合计金额。
执行失败时使用 `subtype: "error_during_execution"` 和 `is_error: true`。
结构化模式会把运行时错误保留在 JSON 中,不再额外重复输出一份人类可读错误。
@@ -280,6 +306,7 @@ SSH 下远端进程无法读取本机剪贴板,请使用终端粘贴快捷键
| `/status` | 显示模型、effort、cache、Git、后台任务,以及工作模式或余额信息。 |
| `/work-mode [economy\|balanced\|delivery]` | 查看或切换运行时工作模式;`/profile` 是别名。 |
| `/theme [auto\|light\|dark\|style]` | 查看或切换 CLI 背景模式和强调色。 |
| `/currency [auto\|CNY\|USD]` | 查看或切换用户全局官方定价货币,并刷新当前运行时。 |
| `/paste-image` | 读取剪贴板图片并插入可编辑的附件标记。 |
| `/mouse` | 切换应用内鼠标选区、滚动条和滚轮处理。 |
| `/effort` | 查看或切换 reasoning effort。 |
+12
View File
@@ -107,6 +107,11 @@ type Options struct {
// so each tab loads its own config/skills/hooks without changing the process
// cwd — enabling concurrent multi-project sessions.
WorkspaceRoot string
// AutoPricingCurrency supplies a frontend-resolved pricing region when the
// persisted desktop currency and language settings are all automatic. It is
// applied to the in-memory config only and never turns Auto into a persisted
// CNY/USD choice.
AutoPricingCurrency string
// ExtraPlugins are session-scoped MCP servers supplied by a host transport
// (for example ACP session/new). They are connected eagerly for this
// controller but are not persisted to reasonix.toml.
@@ -197,6 +202,7 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
if err != nil {
return nil, err
}
applyRuntimeAutoPricingCurrency(cfg, opts.AutoPricingCurrency)
// Arm the credential-protection layers from the user-global [secrets]
// section before any tool, hook, or plugin subprocess can spawn. Package
// globals are correct here because [secrets] is user-global (project
@@ -1757,6 +1763,12 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
return ctrl, nil
}
func applyRuntimeAutoPricingCurrency(cfg *config.Config, currency string) {
if cfg != nil {
cfg.ApplyRuntimeAutoPricingCurrency(currency)
}
}
func rememberPermissionRule(workspaceRoot, rule string) control.RememberResult {
path := rememberPermissionConfigPath(workspaceRoot)
result := control.RememberResult{Rule: strings.TrimSpace(rule), Path: path}
+39
View File
@@ -54,6 +54,45 @@ func TestAgentKeepPolicyFromConfig(t *testing.T) {
}
}
func TestApplyRuntimeAutoPricingCurrency(t *testing.T) {
tests := []struct {
name string
runtimeCurrency string
desktopCurrency string
desktopLanguage string
language string
wantCurrency string
wantOutput float64
}{
{name: "auto Chinese locale", runtimeCurrency: "CNY", wantCurrency: "¥", wantOutput: 2},
{name: "auto English locale", runtimeCurrency: "USD", wantCurrency: "$", wantOutput: 0.28},
{name: "explicit USD wins", runtimeCurrency: "CNY", desktopCurrency: "USD", wantCurrency: "$", wantOutput: 0.28},
{name: "explicit CNY wins", runtimeCurrency: "USD", desktopCurrency: "CNY", wantCurrency: "¥", wantOutput: 2},
{name: "desktop language wins", runtimeCurrency: "USD", desktopLanguage: "zh", wantCurrency: "¥", wantOutput: 2},
{name: "CLI language wins", runtimeCurrency: "USD", language: "zh", wantCurrency: "¥", wantOutput: 2},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := config.Default()
cfg.Desktop.Currency = tt.desktopCurrency
cfg.Desktop.Language = tt.desktopLanguage
cfg.Language = tt.language
cfg.ApplyDeepSeekOfficialDefaultPricing()
applyRuntimeAutoPricingCurrency(cfg, tt.runtimeCurrency)
deepseek, ok := cfg.Provider("deepseek-flash")
if !ok {
t.Fatal("default DeepSeek provider is missing")
}
price := deepseek.PriceForModel("deepseek-v4-flash")
if price == nil || price.Currency != tt.wantCurrency || price.Output != tt.wantOutput {
t.Fatalf("flash price = %#v, want currency %q output %v", price, tt.wantCurrency, tt.wantOutput)
}
})
}
}
// TestBuildFoldsProjectMemoryIntoSystemPrompt is the end-to-end proof of the
// cache-first wiring: a project REASONIX.md is discovered at boot and folded
// into the session's system message (the cached prefix), and the `remember`
+4 -1
View File
@@ -4049,7 +4049,10 @@ func (m *chatTUI) runSlashCommand(input string) tea.Cmd {
m.runThemeSubcommand(input)
case "/language":
m.echoLocalCommand(input)
m.runLanguageSubcommand(input)
return m.runLanguageSubcommand(input)
case "/currency":
m.echoLocalCommand(input)
return m.runCurrencySubcommand(input)
case "/help":
m.echoLocalCommand(input)
m.showHelp()
+97
View File
@@ -2293,6 +2293,103 @@ func TestLanguageCommandSwitchesImmediatelyAndPersists(t *testing.T) {
}
}
func TestLanguageCommandRefreshesCurrentController(t *testing.T) {
isolateUserConfig(t)
i18n.DetectLanguage("en")
t.Cleanup(func() { i18n.DetectLanguage("en") })
oldCtrl := control.New(control.Options{Label: "deepseek-flash"})
t.Cleanup(oldCtrl.Close)
m := newTestChatTUI()
m.ctrl = oldCtrl
m.modelRef = "deepseek-flash/deepseek-v4-flash"
m.runtimeProfile = "full"
var gotSpec controllerBuildSpec
m.buildController = func(spec controllerBuildSpec, _ []provider.Message, _ string, _ control.SessionAPI) (*control.Controller, error) {
gotSpec = spec
return control.New(control.Options{Label: "deepseek-flash"}), nil
}
cmd := m.runLanguageSubcommand("/language zh")
if cmd == nil {
t.Fatal("/language should queue a controller refresh")
}
next, _ := m.Update(cmd())
m = next.(chatTUI)
t.Cleanup(m.ctrl.Close)
if m.ctrl == oldCtrl {
t.Fatal("/language kept the stale controller after a successful refresh")
}
if gotSpec.ModelRef != m.modelRef || gotSpec.RuntimeProfile != "full" {
t.Fatalf("language refresh spec = %+v", gotSpec)
}
}
func TestCurrencyCommandPersistsAndRefreshesCurrentController(t *testing.T) {
isolateUserConfig(t)
i18n.DetectLanguage("en")
t.Cleanup(func() { i18n.DetectLanguage("en") })
oldCtrl := control.New(control.Options{Label: "deepseek-flash"})
t.Cleanup(oldCtrl.Close)
m := newTestChatTUI()
m.ctrl = oldCtrl
m.modelRef = "deepseek-flash/deepseek-v4-flash"
m.runtimeProfile = "full"
var gotSpec controllerBuildSpec
m.buildController = func(spec controllerBuildSpec, _ []provider.Message, _ string, _ control.SessionAPI) (*control.Controller, error) {
gotSpec = spec
return control.New(control.Options{Label: "deepseek-flash"}), nil
}
cmd := m.runCurrencySubcommand("/currency CNY")
if cmd == nil {
t.Fatal("/currency should queue a controller refresh")
}
cfg := config.LoadForEdit(config.UserConfigPath())
if got := cfg.DesktopCurrency(); got != "CNY" {
t.Fatalf("saved currency = %q, want CNY", got)
}
next, _ := m.Update(cmd())
m = next.(chatTUI)
t.Cleanup(m.ctrl.Close)
if m.ctrl == oldCtrl {
t.Fatal("/currency kept the stale controller after a successful refresh")
}
if gotSpec.ModelRef != m.modelRef || gotSpec.RuntimeProfile != "full" {
t.Fatalf("currency refresh spec = %+v", gotSpec)
}
}
func TestCurrencyRefreshFailureKeepsCurrentController(t *testing.T) {
isolateUserConfig(t)
oldCtrl := control.New(control.Options{Label: "deepseek-flash"})
t.Cleanup(oldCtrl.Close)
m := newTestChatTUI()
m.ctrl = oldCtrl
m.modelRef = "deepseek-flash/deepseek-v4-flash"
m.runtimeProfile = "full"
m.buildController = func(controllerBuildSpec, []provider.Message, string, control.SessionAPI) (*control.Controller, error) {
return nil, errors.New("build failed")
}
cmd := m.runCurrencySubcommand("/currency CNY")
if cmd == nil {
t.Fatal("/currency should queue a controller refresh")
}
next, _ := m.Update(cmd())
m = next.(chatTUI)
if m.ctrl != oldCtrl {
t.Fatal("failed currency refresh replaced the usable controller")
}
if m.modelSwitchPending || m.pendingModelSwitch != nil {
t.Fatal("failed currency refresh left the runtime switch pending")
}
if got := config.LoadForEdit(config.UserConfigPath()).DesktopCurrency(); got != "CNY" {
t.Fatalf("failed refresh should retain the persisted preference, got %q", got)
}
}
func TestLanguageCommandAutoClearsPinnedLanguage(t *testing.T) {
isolateUserConfig(t)
i18n.DetectLanguage("en")
+73
View File
@@ -275,11 +275,21 @@ func cliProfileBuildOptions(modelName string, maxStepsOverride int, requireKey b
PermissionAllow: overrides.PermissionAllow,
AdditionalDirs: overrides.AdditionalDirs,
HeadlessApprovalMode: overrides.HeadlessApprovalMode,
AutoPricingCurrency: cliAutoPricingCurrency(),
Stderr: overrides.Stderr,
OnSessionRecovered: overrides.OnSessionRecovered,
}
}
func cliAutoPricingCurrency() string {
switch i18n.CurrentLanguage() {
case "zh", "zh-TW":
return "CNY"
default:
return "USD"
}
}
type cliPermissionMode struct {
approval string
plan bool
@@ -2220,6 +2230,8 @@ func configCommand(args []string) int {
return configAutoPlanCompatibilityCommand(args[1:])
case "reasoning-language":
return configReasoningLanguageCommand(args[1:])
case "currency":
return configCurrencyCommand(args[1:])
case "telemetry":
return configTelemetryCommand(args[1:])
default:
@@ -2228,6 +2240,60 @@ func configCommand(args []string) int {
}
}
func configCurrencyCommand(args []string) int {
fs := flag.NewFlagSet("config currency", flag.ContinueOnError)
local := fs.Bool("local", false, "unsupported; pricing currency is user-level only")
if err := fs.Parse(args); err != nil {
return 2
}
if *local {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "currency is user-level only; --local is not supported")
return 2
}
rest := fs.Args()
if len(rest) > 1 {
configCurrencyUsage()
return 2
}
if len(rest) == 0 {
cfg, err := config.LoadForRootReadOnly(".")
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
cfg.ApplyRuntimeAutoPricingCurrency(cliAutoPricingCurrency())
fmt.Printf("currency = %q (resolved: %s)\n", pricingCurrencyDisplay(cfg.DesktopCurrency()), cfg.DeepSeekOfficialPricingCurrency())
return 0
}
mode, err := parseCLIPricingCurrency(rest[0])
if err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 2
}
path := config.UserConfigPath()
if path == "" {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "cannot resolve user config path")
return 1
}
unlock := config.LockUserConfigEdits()
defer unlock()
cfg := config.LoadForEdit(path)
if err := cfg.SetDesktopCurrency(mode); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 2
}
resolved := cfg.DeepSeekOfficialPricingCurrency()
if mode == "" && cfg.DesktopLanguage() == "" {
resolved = cliAutoPricingCurrency()
}
if err := cfg.SaveTo(path); err != nil {
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, err)
return 1
}
fmt.Printf("currency = %q (resolved: %s, %s)\n", pricingCurrencyDisplay(mode), resolved, displayPath(path))
return 0
}
var (
cleanupCLITelemetry = telemetry.Cleanup
startCLITelemetryReporter = telemetry.Start
@@ -2395,6 +2461,7 @@ func configReasoningLanguageCommand(args []string) int {
func configUsage() {
fmt.Print(`Usage:
reasonix config reasoning-language [--local] [auto|zh|en]
reasonix config currency [auto|CNY|USD]
reasonix config telemetry [auto|on|off]
`)
}
@@ -2477,3 +2544,9 @@ func configReasoningLanguageUsage() {
reasonix config reasoning-language [--local] [auto|zh|en]
`)
}
func configCurrencyUsage() {
fmt.Print(`Usage:
reasonix config currency [auto|CNY|USD]
`)
}
+51
View File
@@ -687,6 +687,57 @@ func TestConfigReasoningLanguageRejectsAliases(t *testing.T) {
}
}
func TestConfigCurrencyCommandWritesUserConfig(t *testing.T) {
isolateCLIConfigHome(t)
out := captureStdout(t, func() {
if rc := Run([]string{"config", "currency", "CNY"}, "test-version"); rc != 0 {
t.Fatalf("config currency rc = %d, want 0", rc)
}
})
if !strings.Contains(out, `currency = "CNY"`) || !strings.Contains(out, "resolved: CNY") {
t.Fatalf("config currency output = %q", out)
}
cfg := config.LoadForEdit(config.UserConfigPath())
if got := cfg.DesktopCurrency(); got != "CNY" {
t.Fatalf("saved currency = %q, want CNY", got)
}
}
func TestConfigCurrencyAutoUsesResolvedCLILocale(t *testing.T) {
isolateCLIConfigHome(t)
i18n.DetectLanguage("zh-TW")
t.Cleanup(func() { i18n.DetectLanguage("en") })
out := captureStdout(t, func() {
if rc := configCurrencyCommand([]string{"auto"}); rc != 0 {
t.Fatalf("config currency auto rc = %d, want 0", rc)
}
})
if !strings.Contains(out, `currency = "auto"`) || !strings.Contains(out, "resolved: CNY") {
t.Fatalf("config currency auto output = %q", out)
}
cfg := config.LoadForEdit(config.UserConfigPath())
if got := cfg.DesktopCurrency(); got != "" {
t.Fatalf("auto should clear saved currency, got %q", got)
}
}
func TestConfigCurrencyRejectsProjectScope(t *testing.T) {
isolateCLIConfigHome(t)
errOut := captureStderr(t, func() {
if rc := Run([]string{"config", "currency", "--local", "USD"}, "test-version"); rc != 2 {
t.Fatalf("config currency --local rc = %d, want 2", rc)
}
})
if !strings.Contains(errOut, "user-level only") {
t.Fatalf("config currency --local stderr = %q", errOut)
}
if _, err := os.Stat("reasonix.toml"); !os.IsNotExist(err) {
t.Fatalf("config currency --local wrote project config, stat err=%v", err)
}
}
func TestProvidersWithMissingKeysOnlyChecksActiveDefaultModel(t *testing.T) {
cfg := config.Default()
t.Setenv("DEEPSEEK_API_KEY", "")
+14
View File
@@ -489,6 +489,20 @@ func TestSlashArgCompletionLanguage(t *testing.T) {
}
}
func TestSlashArgCompletionCurrency(t *testing.T) {
m := newTestChatTUI()
m.input.SetValue("/currency ")
m.updateCompletion()
if !m.completion.active || m.completion.kind != compSlashArg {
t.Fatalf("/currency should open arg completion: %+v", m.completion)
}
for _, want := range []string{"auto", "CNY", "USD"} {
if !hasLabel(m.completion.items, want) {
t.Fatalf("/currency completion missing %q: %v", want, labels(m.completion.items))
}
}
}
func TestSlashArgCompletionReasoningLanguage(t *testing.T) {
m := newTestChatTUI()
m.input.SetValue("/reasoning-language ")
+104
View File
@@ -0,0 +1,104 @@
package cli
import (
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
"reasonix/internal/config"
"reasonix/internal/i18n"
)
func (m *chatTUI) runCurrencySubcommand(input string) tea.Cmd {
args := tokenizeArgs(input)
if len(args) < 2 {
cfg, err := config.Load()
if err != nil {
m.notice("currency: " + err.Error())
return nil
}
cfg.ApplyRuntimeAutoPricingCurrency(cliAutoPricingCurrency())
m.notice(i18n.M.CurrencyHeader + "\n" + describePricingCurrencies(pricingCurrencyDisplay(cfg.DesktopCurrency()), cfg.DeepSeekOfficialPricingCurrency()) + "\n" + i18n.M.CurrencyHint)
return nil
}
if len(args) > 2 {
m.notice(i18n.M.CurrencyHint)
return nil
}
mode, err := parseCLIPricingCurrency(args[1])
if err != nil {
m.notice(err.Error())
return nil
}
if !m.runtimeSettingChangeReady() {
return nil
}
path := config.UserConfigPath()
if path == "" {
m.notice("currency: cannot resolve user config path")
return nil
}
var resolved string
if err := func() error {
unlock := config.LockUserConfigEdits()
defer unlock()
edit := config.LoadForEdit(path)
if err := edit.SetDesktopCurrency(mode); err != nil {
return err
}
resolved = edit.DeepSeekOfficialPricingCurrency()
if mode == "" && edit.DesktopLanguage() == "" {
resolved = cliAutoPricingCurrency()
}
return edit.SaveTo(path)
}(); err != nil {
m.notice("currency: " + err.Error())
return nil
}
success := fmt.Sprintf(i18n.M.CurrencyChangedFmt, pricingCurrencyDisplay(mode), resolved)
if m.ctrl == nil {
m.notice(success)
return nil
}
return m.scheduleCurrentControllerRebuild("currency", success)
}
func parseCLIPricingCurrency(value string) (string, error) {
switch strings.ToUpper(strings.TrimSpace(value)) {
case "", "AUTO":
return "", nil
case "CNY":
return "CNY", nil
case "USD":
return "USD", nil
default:
return "", fmt.Errorf("pricing currency %q: must be auto|CNY|USD", value)
}
}
func pricingCurrencyDisplay(currency string) string {
if strings.TrimSpace(currency) == "" {
return "auto"
}
return strings.ToUpper(strings.TrimSpace(currency))
}
func describePricingCurrencies(current, resolved string) string {
items := []string{"auto", "CNY", "USD"}
var b strings.Builder
for _, item := range items {
marker := " "
if item == current {
marker = "• "
}
hint := ""
if item == "auto" {
hint = " (" + resolved + ")"
}
fmt.Fprintf(&b, "%s%s%s\n", marker, item, hint)
}
return strings.TrimRight(b.String(), "\n")
}
+18 -8
View File
@@ -6,32 +6,37 @@ import (
"path/filepath"
"strings"
tea "charm.land/bubbletea/v2"
"reasonix/internal/config"
"reasonix/internal/i18n"
)
func (m *chatTUI) runLanguageSubcommand(input string) {
func (m *chatTUI) runLanguageSubcommand(input string) tea.Cmd {
args := tokenizeArgs(input)
if len(args) < 2 {
cfg, err := config.Load()
if err != nil {
m.notice("language: " + err.Error())
return
return nil
}
saved := languageDisplay(cfg.Language)
resolved := i18n.DetectLanguage(cfg.Language)
m.notice(i18n.M.LanguageHeader + "\n" + describeLanguages(saved, resolved) + "\n" + i18n.M.LanguageHint)
return
return nil
}
if len(args) > 2 {
m.notice(i18n.M.LanguageHint)
return
return nil
}
lang, err := normalizeLanguageArg(args[1])
if err != nil {
m.notice(err.Error())
return
return nil
}
if !m.runtimeSettingChangeReady() {
return nil
}
path := config.SourcePath()
if path == "" {
@@ -39,7 +44,7 @@ func (m *chatTUI) runLanguageSubcommand(input string) {
}
if path == "" {
m.notice("language: cannot resolve config path")
return
return nil
}
// Lock the whole load-modify-save cycle, including the user-scope override
// cleanup — its own read-modify-write on the user config. path may be a
@@ -61,7 +66,7 @@ func (m *chatTUI) runLanguageSubcommand(input string) {
return nil
}(); err != nil {
m.notice("language: " + err.Error())
return
return nil
}
resolved := i18n.DetectLanguage(lang)
@@ -69,7 +74,12 @@ func (m *chatTUI) runLanguageSubcommand(input string) {
if m.ctrl != nil {
m.ctrl.SetResponseLanguage(lang)
}
m.notice(fmt.Sprintf(i18n.M.LanguageChangedFmt, languageDisplay(lang), resolved))
success := fmt.Sprintf(i18n.M.LanguageChangedFmt, languageDisplay(lang), resolved)
if m.ctrl == nil {
m.notice(success)
return nil
}
return m.scheduleCurrentControllerRebuild("language", success)
}
// clearUserLanguageOverride drops a stale user-level language override after a
+39 -7
View File
@@ -51,13 +51,17 @@ type runResultUsage struct {
}
type runResult struct {
Type string `json:"type"`
Subtype string `json:"subtype"`
IsError bool `json:"is_error"`
DurationMS int64 `json:"duration_ms"`
NumTurns int `json:"num_turns"`
Result string `json:"result"`
SessionID string `json:"session_id,omitempty"`
Type string `json:"type"`
Subtype string `json:"subtype"`
IsError bool `json:"is_error"`
DurationMS int64 `json:"duration_ms"`
NumTurns int `json:"num_turns"`
Result string `json:"result"`
SessionID string `json:"session_id,omitempty"`
TotalCost float64 `json:"total_cost"`
Currency string `json:"currency,omitempty"`
// TotalCostUSD is the released compatibility alias. It mirrors TotalCost;
// new consumers must pair TotalCost with Currency instead of assuming USD.
TotalCostUSD float64 `json:"total_cost_usd"`
Usage runResultUsage `json:"usage"`
}
@@ -118,6 +122,8 @@ type runOutputSink struct {
final string
usage runResultUsage
cost float64
currency string
mixedCurrencies bool
turns int
sequence uint64
machineToolIDs map[string]string
@@ -150,6 +156,12 @@ func (s *runOutputSink) Emit(e event.Event) {
s.usage.CacheCreationInputTokens += e.Usage.CacheMissTokens
if e.Pricing != nil {
s.cost += e.Pricing.Cost(e.Usage)
currency := pricingCurrencyCode(e.Pricing.Currency)
if s.currency == "" {
s.currency = currency
} else if currency != s.currency {
s.mixedCurrencies = true
}
}
}
if e.Kind == event.TurnDone {
@@ -169,6 +181,9 @@ func (s *runOutputSink) Finalize(sessionID string, started time.Time, runErr err
if s.err != nil {
return s.err
}
if s.mixedCurrencies && s.format != runOutputText && s.format != runOutputEventsJSONL {
return fmt.Errorf("cannot total costs across mixed pricing currencies")
}
if s.format == runOutputText {
if s.final != "" {
_, s.err = fmt.Fprintln(s.out, s.final)
@@ -211,11 +226,28 @@ func (s *runOutputSink) Finalize(sessionID string, started time.Time, runErr err
NumTurns: turns,
Result: resultText,
SessionID: sessionID,
TotalCost: s.cost,
Currency: s.currency,
TotalCostUSD: s.cost,
Usage: s.usage,
})
}
func pricingCurrencyCode(value string) string {
value = strings.TrimSpace(value)
switch strings.ToUpper(value) {
case "", "CNY", "RMB", "CNH", "¥", "¥":
return "CNY"
case "USD", "$", "US$":
return "USD"
default:
if len(value) == 3 {
return strings.ToUpper(value)
}
return value
}
}
func (s *runOutputSink) machineEventRecordFor(e event.Event, sequence uint64) machineEventRecord {
record := machineEventRecord{SchemaVersion: machineSchemaVersion, Sequence: sequence, Kind: machineEventKind(e.Kind)}
switch e.Kind {
+50
View File
@@ -51,6 +51,56 @@ func TestRunOutputJSONResult(t *testing.T) {
}
}
func TestRunOutputJSONIncludesCurrencyAwareCostFields(t *testing.T) {
for _, tt := range []struct {
name string
currency string
wantCode string
}{
{name: "USD", currency: "$", wantCode: "USD"},
{name: "CNY", currency: "¥", wantCode: "CNY"},
} {
t.Run(tt.name, func(t *testing.T) {
var out bytes.Buffer
sink := newRunOutputSink(&out, runOutputJSON)
sink.Emit(event.Event{
Kind: event.Usage,
Usage: &provider.Usage{PromptTokens: 1_000_000, CompletionTokens: 500_000},
Pricing: &provider.Pricing{Input: 1, Output: 2, Currency: tt.currency},
})
if err := sink.Finalize("abc", time.Now(), nil); err != nil {
t.Fatal(err)
}
var result runResult
if err := json.Unmarshal(out.Bytes(), &result); err != nil {
t.Fatal(err)
}
if result.TotalCost != 2 || result.TotalCostUSD != result.TotalCost || result.Currency != tt.wantCode {
t.Fatalf("currency-aware result = %+v", result)
}
})
}
}
func TestRunOutputJSONRejectsMixedPricingCurrencies(t *testing.T) {
var out bytes.Buffer
sink := newRunOutputSink(&out, runOutputJSON)
for _, currency := range []string{"$", "¥"} {
sink.Emit(event.Event{
Kind: event.Usage,
Usage: &provider.Usage{PromptTokens: 1_000_000},
Pricing: &provider.Pricing{Input: 1, Currency: currency},
})
}
err := sink.Finalize("abc", time.Now(), nil)
if err == nil || !strings.Contains(err.Error(), "mixed pricing currencies") {
t.Fatalf("Finalize mixed currencies error = %v", err)
}
if out.Len() != 0 {
t.Fatalf("mixed-currency JSON should not emit a misleading total: %s", out.String())
}
}
func TestRunOutputSessionIDPreservesExistingFormats(t *testing.T) {
const raw = "20260723-120000.000000000-model"
identityKey := bytes.Repeat([]byte{0x41}, machineIdentityKeyBytes)
+79
View File
@@ -0,0 +1,79 @@
package cli
import (
tea "charm.land/bubbletea/v2"
"reasonix/internal/i18n"
)
// runtimeSettingChangeReady guards settings that need a controller rebuild.
// A nil controller is a non-interactive/test surface where persistence can
// still proceed without an in-session refresh.
func (m *chatTUI) runtimeSettingChangeReady() bool {
if m == nil || m.ctrl == nil {
return true
}
if m.buildController == nil {
m.notice(i18n.M.RuntimeRefreshUnavailable)
return false
}
if m.runtimeSwitchBusy() {
m.notice(i18n.M.RuntimeRefreshBusy)
return false
}
if m.modelSwitchPending {
m.notice(i18n.M.RuntimeSwitchPending)
return false
}
return true
}
// scheduleCurrentControllerRebuild refreshes configuration-backed runtime state
// without changing the active model/profile. The old controller remains usable
// until a fully initialized replacement is ready.
func (m *chatTUI) scheduleCurrentControllerRebuild(reason, successNotice string) tea.Cmd {
if m == nil || m.ctrl == nil {
return nil
}
if m.buildController == nil {
m.notice(i18n.M.RuntimeRefreshUnavailable)
return nil
}
if err := m.ctrl.Snapshot(); err != nil {
m.notice(reason + ": snapshot failed: " + err.Error())
}
carried := m.ctrl.History()
resumePath := m.ctrl.SessionPath()
if err := m.rebindSessionLease(resumePath); err != nil {
m.notice(reason + ": " + sessionLeaseHeldNotice(err))
return nil
}
oldCtrl := m.ctrl
build := m.buildController
ref := m.modelRef
profile := m.runtimeProfile
m.modelSwitchPending = true
m.pendingModelSwitch = func() tea.Msg {
c, err := build(controllerBuildSpec{
ModelRef: ref,
RuntimeProfile: profile,
ToolApprovalMode: oldCtrl.ToolApprovalMode(),
PlanMode: oldCtrl.PlanMode(),
}, carried, resumePath, oldCtrl)
if err != nil {
return modelSwitchMsg{ref: ref, failurePrefix: reason, err: err}
}
return modelSwitchMsg{
ref: ref,
ctrl: c,
oldCtrl: oldCtrl,
label: c.Label(),
commands: c.Commands(),
skills: c.SlashSkills(),
host: c.Host(),
successNotice: successNotice,
}
}
return m.pendingModelSwitch
}
+1
View File
@@ -51,6 +51,7 @@ func builtinSlashSpecs() []builtinSlashSpec {
{name: "/reasoning-language", insert: "/reasoning-language ", hint: i18n.M.CmdReasonLang, descend: true, showInHelp: true},
{name: "/theme", insert: "/theme ", hint: i18n.M.CmdTheme, descend: true},
{name: "/language", insert: "/language ", hint: i18n.M.CmdLanguage, descend: true, showInHelp: true},
{name: "/currency", insert: "/currency ", hint: i18n.M.CmdCurrency, descend: true, showInHelp: true},
{name: "/help", insert: "/help", hint: i18n.M.CmdHelp, showInHelp: true},
{name: "/memory", insert: "/memory ", hint: i18n.M.CmdMemory, showInHelp: true},
{name: "/migrate", aliases: []string{"/migration"}, insert: "/migrate", hint: i18n.M.CmdMigrate, showInHelp: true},
+23
View File
@@ -13,6 +13,7 @@ import (
tea "charm.land/bubbletea/v2"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/jobs"
@@ -69,6 +70,28 @@ func TestRuntimeSwitchesRejectRunningBackgroundJobs(t *testing.T) {
t.Fatal("work-mode switch queued a rebuild while a background job was running")
}
})
t.Run("language", func(t *testing.T) {
isolateUserConfig(t)
m := chatTUIWithRunningBackgroundJob(t)
if cmd := m.runLanguageSubcommand("/language zh"); cmd != nil {
t.Fatal("language switch queued a rebuild while a background job was running")
}
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
t.Fatalf("blocked language switch wrote config, stat err=%v", err)
}
})
t.Run("currency", func(t *testing.T) {
isolateUserConfig(t)
m := chatTUIWithRunningBackgroundJob(t)
if cmd := m.runCurrencySubcommand("/currency CNY"); cmd != nil {
t.Fatal("currency switch queued a rebuild while a background job was running")
}
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
t.Fatalf("blocked currency switch wrote config, stat err=%v", err)
}
})
}
// divergedSessionController builds a controller whose in-memory transcript has
+19
View File
@@ -12,6 +12,7 @@ import (
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/i18n"
)
func TestTUIDiagnosticsKeepProcessAndPluginLogsOffTerminal(t *testing.T) {
@@ -125,3 +126,21 @@ func TestCLIProfileBuildOptionsPropagateInteractiveOwners(t *testing.T) {
t.Fatalf("HeadlessApprovalMode = %q, want %q", opts.HeadlessApprovalMode, control.ToolApprovalAuto)
}
}
func TestCLIProfileBuildOptionsUseResolvedLocaleForAutoPricing(t *testing.T) {
defer i18n.DetectLanguage("en")
for _, tt := range []struct {
language string
want string
}{
{language: "en", want: "USD"},
{language: "zh", want: "CNY"},
{language: "zh-TW", want: "CNY"},
} {
i18n.DetectLanguage(tt.language)
opts := cliProfileBuildOptions("provider/model", 0, false, event.Discard, "balanced", cliBuildOverrides{})
if opts.AutoPricingCurrency != tt.want {
t.Errorf("language %q auto pricing currency = %q, want %q", tt.language, opts.AutoPricingCurrency, tt.want)
}
}
}
+195 -8
View File
@@ -31,8 +31,8 @@ func TestBackfillDeepSeekProRestoresPro(t *testing.T) {
pro := hasModel(c, "deepseek-v4-pro")
if pro == nil {
t.Fatal("deepseek-v4-pro not restored")
} else if pro.Price == nil || pro.Price.Output != 6 || pro.Price.Currency != "¥" {
t.Errorf("pro price not the preset: %+v", pro.Price)
} else if pro.Price == nil || pro.Price.Output != 0.87 || pro.Price.Currency != "$" {
t.Errorf("pro price = %+v, want default USD preset", pro.Price)
}
}
@@ -819,8 +819,8 @@ func TestBackfillDeepSeekOfficialPrices(t *testing.T) {
if !ok {
t.Fatal("deepseek provider missing")
}
if p.Prices["deepseek-v4-flash"].Output != 2 || p.Prices["deepseek-v4-pro"].Output != 6 {
t.Fatalf("deepseek prices = %+v, want current V4 flash/pro prices", p.Prices)
if p.Prices["deepseek-v4-flash"].Output != 0.28 || p.Prices["deepseek-v4-flash"].Currency != "$" || p.Prices["deepseek-v4-pro"].Output != 0.87 || p.Prices["deepseek-v4-pro"].Currency != "$" {
t.Fatalf("deepseek prices = %+v, want default USD flash/pro prices", p.Prices)
}
}
@@ -917,6 +917,193 @@ func TestApplyDeepSeekOfficialDefaultPricingKeepsCustomPrice(t *testing.T) {
}
}
func TestLoadForEditAutoCurrencyKeepsPersistedOfficialUSDPrice(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
body := `language = "zh"
[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
price = { cache_hit = 0.0028, input = 0.14, output = 0.28, currency = "$" }
`
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
c := LoadForEdit(path)
flash, ok := c.Provider("deepseek-flash")
if !ok {
t.Fatal("deepseek-flash provider missing")
}
if flash.Price == nil || flash.Price.Output != 0.28 || flash.Price.Currency != "$" {
t.Fatalf("flash price = %+v, want persisted USD preset", flash.Price)
}
if err := c.SetDesktopCurrency("CNY"); err != nil {
t.Fatalf("SetDesktopCurrency CNY: %v", err)
}
if flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != "¥" {
t.Fatalf("flash price = %+v, want explicit CNY preset", flash.Price)
}
}
func TestLoadForRootAutoCurrencyKeepsPersistedOfficialUSDPrice(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
body := `language = "zh"
[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
price = { cache_hit = 0.0028, input = 0.14, output = 0.28, currency = "$" }
`
if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
c, err := LoadForRoot(t.TempDir())
if err != nil {
t.Fatalf("LoadForRoot: %v", err)
}
flash, ok := c.Provider("deepseek-flash")
if !ok {
t.Fatal("deepseek-flash provider missing")
}
if flash.Price == nil || flash.Price.Output != 0.28 || flash.Price.Currency != "$" {
t.Fatalf("flash price = %+v, want persisted USD preset", flash.Price)
}
}
func TestRuntimeAutoCurrencyKeepsPersistedOfficialUSDPrice(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.toml")
body := `[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
price = { cache_hit = 0.0028, input = 0.14, output = 0.28, currency = "$" }
`
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
c := LoadForEdit(path)
c.ApplyRuntimeAutoPricingCurrency("CNY")
flash, ok := c.Provider("deepseek-flash")
if !ok {
t.Fatal("deepseek-flash provider missing")
}
if flash.Price == nil || flash.Price.Output != 0.28 || flash.Price.Currency != "$" {
t.Fatalf("flash price = %+v, want persisted USD preset", flash.Price)
}
}
func TestLoadForRootAutoCurrencyDoesNotMixPartialOfficialPrices(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
body := `language = "zh"
[[providers]]
name = "deepseek"
kind = "openai"
base_url = "https://api.deepseek.com"
models = ["deepseek-v4-flash", "deepseek-v4-pro"]
default = "deepseek-v4-flash"
[providers.prices]
deepseek-v4-flash = { cache_hit = 0.0028, input = 0.14, output = 0.28, currency = "$" }
`
if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
c, err := LoadForRoot(t.TempDir())
if err != nil {
t.Fatalf("LoadForRoot: %v", err)
}
p, ok := c.Provider("deepseek")
if !ok {
t.Fatal("deepseek provider missing")
}
for _, model := range p.Models {
price := p.Prices[model]
if price == nil || price.Currency != "¥" {
t.Fatalf("price for %q = %+v, want consistent CNY table", model, price)
}
}
}
func TestDeepSeekOfficialPricingCurrencyResolution(t *testing.T) {
for _, tt := range []struct {
name string
language string
desktop DesktopConfig
want string
}{
{name: "old config defaults to USD", want: "USD"},
{name: "English auto", desktop: DesktopConfig{Language: "en"}, want: "USD"},
{name: "Chinese auto", desktop: DesktopConfig{Language: "zh"}, want: "CNY"},
{name: "CLI Chinese fallback", language: "zh", want: "CNY"},
{name: "explicit USD wins over Chinese", language: "zh", desktop: DesktopConfig{Language: "zh", Currency: "USD"}, want: "USD"},
{name: "explicit CNY wins over English", language: "en", desktop: DesktopConfig{Language: "en", Currency: "CNY"}, want: "CNY"},
} {
t.Run(tt.name, func(t *testing.T) {
c := &Config{Language: tt.language, Desktop: tt.desktop}
if got := c.DeepSeekOfficialPricingCurrency(); got != tt.want {
t.Fatalf("pricing currency = %q, want %q", got, tt.want)
}
})
}
}
func TestLoadForRootKeepsPricingRegionUserGlobal(t *testing.T) {
home := t.TempDir()
project := t.TempDir()
t.Setenv("REASONIX_HOME", home)
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
if err := os.WriteFile(filepath.Join(home, "config.toml"), []byte("[desktop]\nlanguage = \"en\"\ncurrency = \"USD\"\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte("[desktop]\nlanguage = \"zh\"\ncurrency = \"CNY\"\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := LoadForRoot(project)
if err != nil {
t.Fatalf("LoadForRoot: %v", err)
}
if got := cfg.DesktopCurrency(); got != "USD" {
t.Fatalf("project config overrode user pricing currency: got %q, want USD", got)
}
if got := cfg.DesktopLanguage(); got != "en" {
t.Fatalf("project config overrode user desktop language: got %q, want en", got)
}
}
func TestApplyDeepSeekOfficialDefaultPricingExplicitCurrencyWins(t *testing.T) {
c := Default()
c.Desktop.Language = "zh"
c.Desktop.Currency = "USD"
applyDeepSeekOfficialDefaultPricing(c)
flash, _ := c.Provider("deepseek-flash")
if flash.Price == nil || flash.Price.Output != 0.28 || flash.Price.Currency != "$" {
t.Fatalf("flash price = %+v, want explicit USD preset", flash.Price)
}
c.Desktop.Language = "en"
c.Desktop.Currency = "CNY"
applyDeepSeekOfficialDefaultPricing(c)
if flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != "¥" {
t.Fatalf("flash price = %+v, want explicit CNY preset", flash.Price)
}
}
func TestResetOfficialProviderPricingOnUpgradeRunsOnce(t *testing.T) {
path := filepath.Join(t.TempDir(), "reasonix.toml")
c := &Config{
@@ -968,11 +1155,11 @@ func TestResetOfficialProviderPricingOnUpgradeRunsOnce(t *testing.T) {
if deepseek.Price != nil {
t.Fatalf("deepseek provider-wide price = %+v, want nil after reset", deepseek.Price)
}
if p := deepseek.Prices["deepseek-v4-flash"]; p == nil || p.Currency != "¥" || p.Output != 2 {
t.Fatalf("deepseek flash price = %+v, want RMB default", p)
if p := deepseek.Prices["deepseek-v4-flash"]; p == nil || p.Currency != "$" || p.Output != 0.28 {
t.Fatalf("deepseek flash price = %+v, want USD default", p)
}
if p := deepseek.Prices["deepseek-v4-pro"]; p == nil || p.Currency != "¥" || p.Output != 6 {
t.Fatalf("deepseek pro price = %+v, want RMB default", p)
if p := deepseek.Prices["deepseek-v4-pro"]; p == nil || p.Currency != "$" || p.Output != 0.87 {
t.Fatalf("deepseek pro price = %+v, want USD default", p)
}
mimo, ok := got.Provider("mimo-api")
if !ok {
+23 -2
View File
@@ -175,6 +175,7 @@ type UIConfig struct {
// language, terminal colours, or provider-visible prompt/request data.
type DesktopConfig struct {
Language string `toml:"language"` // auto|en|zh; empty/auto = browser/OS auto-detect
Currency string `toml:"currency"` // user-global auto|CNY|USD pricing preference shared by desktop and CLI
LayoutStyle string `toml:"layout_style"` // classic|workbench|creation; desktop layout style
Theme string `toml:"theme"` // auto|dark|light; empty resolves to auto
ThemeStyle string `toml:"theme_style"` // graphite|aurora|slate|carbon|nocturne|amber and legacy aliases
@@ -314,6 +315,23 @@ func (c *Config) DesktopLanguage() string {
}
}
// DesktopCurrency returns the explicit user-global pricing currency. The
// persisted field keeps its original desktop namespace for compatibility;
// empty means the pricing region follows the desktop/CLI language.
func (c *Config) DesktopCurrency() string {
if c == nil {
return ""
}
switch strings.ToUpper(strings.TrimSpace(c.Desktop.Currency)) {
case "CNY", "RMB", "CNH":
return "CNY"
case "USD":
return "USD"
default:
return ""
}
}
// DesktopTheme normalizes desktop.theme. New desktop users default to the OS
// automatic graphite product look; an explicit auto/light/dark is preserved.
func (c *Config) DesktopTheme() string {
@@ -1191,6 +1209,9 @@ type ProviderEntry struct {
ContextWindow int `toml:"context_window"`
Price *provider.Pricing `toml:"price"` // legacy/provider-wide fallback
Prices map[string]*provider.Pricing `toml:"prices"` // optional per-model prices; keys are model ids
persistedOfficialCurrency string
// Thinking / Effort are provider-kind-specific knobs forwarded to the provider
// via Config.Extra. The anthropic provider reads Thinking="adaptive" to enable
// extended thinking and Effort ("low".."max") to tune depth. The
@@ -1710,8 +1731,8 @@ func Default() *Config {
Weixin: WeixinBotConfig{AccountID: "default", TokenEnv: "WEIXIN_BOT_TOKEN", APIBase: "https://ilinkai.weixin.qq.com"},
},
Providers: []ProviderEntry{
{Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY", BalanceURL: "https://api.deepseek.com/user/balance", ContextWindow: 1_000_000, Price: deepSeekV4FlashPrice()},
{Name: "deepseek-pro", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro", APIKeyEnv: "DEEPSEEK_API_KEY", BalanceURL: "https://api.deepseek.com/user/balance", ContextWindow: 1_000_000, Price: deepSeekV4ProPrice()},
{Name: "deepseek-flash", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-flash", APIKeyEnv: "DEEPSEEK_API_KEY", BalanceURL: "https://api.deepseek.com/user/balance", ContextWindow: 1_000_000, Price: deepSeekV4FlashPriceUSD()},
{Name: "deepseek-pro", Kind: "openai", BaseURL: "https://api.deepseek.com", Model: "deepseek-v4-pro", APIKeyEnv: "DEEPSEEK_API_KEY", BalanceURL: "https://api.deepseek.com/user/balance", ContextWindow: 1_000_000, Price: deepSeekV4ProPriceUSD()},
},
}
}
+32 -7
View File
@@ -134,23 +134,27 @@ func (c *Config) UpsertProvider(e ProviderEntry) error {
}
// UpsertProviderPreservingRuntime applies persisted provider fields while
// retaining credentials and capability state resolved by the latest config
// load. It is used when replaying an optimistic edit log onto fresh state.
// retaining process-only state derived by the latest config load. It is used
// when replaying an optimistic edit log onto fresh state.
func (c *Config) UpsertProviderPreservingRuntime(e ProviderEntry) error {
if current, ok := c.Provider(e.Name); ok && strings.TrimSpace(current.APIKeyEnv) == strings.TrimSpace(e.APIKeyEnv) {
e.resolvedAPIKey = current.resolvedAPIKey
e.resolvedSource = current.resolvedSource
e.visionOverride = current.visionOverride
if current, ok := c.Provider(e.Name); ok {
e.persistedOfficialCurrency = current.persistedOfficialCurrency
if strings.TrimSpace(current.APIKeyEnv) == strings.TrimSpace(e.APIKeyEnv) {
e.resolvedAPIKey = current.resolvedAPIKey
e.resolvedSource = current.resolvedSource
e.visionOverride = current.visionOverride
}
}
return c.UpsertProvider(e)
}
// ProviderEntryConfigSnapshot strips process-only state from a provider copy so
// optimistic edit logs never retain resolved credential values.
// optimistic edit logs contain only persisted configuration.
func ProviderEntryConfigSnapshot(entry ProviderEntry) ProviderEntry {
entry.resolvedAPIKey = ""
entry.resolvedSource = CredentialSource{}
entry.visionOverride = nil
entry.persistedOfficialCurrency = ""
return entry
}
@@ -221,6 +225,27 @@ func (c *Config) SetDesktopLanguage(lang string) error {
return nil
}
// SetDesktopCurrency pins the user-global official pricing region independently
// from language. The name is retained for persisted-schema compatibility.
// Empty/auto follows the language preference.
func (c *Config) SetDesktopCurrency(currency string) error {
overridePersisted := false
switch strings.ToUpper(strings.TrimSpace(currency)) {
case "", "AUTO":
c.Desktop.Currency = ""
case "CNY", "RMB", "CNH":
c.Desktop.Currency = "CNY"
overridePersisted = true
case "USD":
c.Desktop.Currency = "USD"
overridePersisted = true
default:
return fmt.Errorf("desktop currency %q: must be auto|CNY|USD", currency)
}
applyDeepSeekOfficialDefaultPricingWithOverride(c, overridePersisted)
return nil
}
// SetDesktopAppearance sets desktop-only theme preferences. It must not affect
// CLI theme settings or provider-visible request data.
func (c *Config) SetDesktopAppearance(theme, style string) error {
+36 -5
View File
@@ -169,6 +169,33 @@ func TestDesktopPreferencesAreSeparateFromCLI(t *testing.T) {
}
}
func TestDesktopCurrencyNormalizesAndRefreshesOfficialPricing(t *testing.T) {
c := Default()
c.Desktop.Language = "zh"
if err := c.SetDesktopCurrency("usd"); err != nil {
t.Fatalf("SetDesktopCurrency USD: %v", err)
}
if got := c.DesktopCurrency(); got != "USD" {
t.Fatalf("desktop currency = %q, want USD", got)
}
flash, _ := c.Provider("deepseek-flash")
if flash.Price == nil || flash.Price.Output != 0.28 || flash.Price.Currency != "$" {
t.Fatalf("USD flash price = %+v", flash.Price)
}
if err := c.SetDesktopCurrency("auto"); err != nil {
t.Fatalf("SetDesktopCurrency auto: %v", err)
}
if got := c.DesktopCurrency(); got != "" {
t.Fatalf("auto desktop currency = %q, want empty", got)
}
if flash.Price == nil || flash.Price.Output != 2 || flash.Price.Currency != "¥" {
t.Fatalf("auto Chinese flash price = %+v", flash.Price)
}
if err := c.SetDesktopCurrency("EUR"); err == nil {
t.Fatal("SetDesktopCurrency accepted unsupported EUR")
}
}
func TestDesktopLayoutStyleNormalizes(t *testing.T) {
if got := Default().DesktopLayoutStyle(); got != "workbench" {
t.Fatalf("default desktop layout style = %q, want workbench", got)
@@ -1982,23 +2009,24 @@ legacy_preference = "keep"
}
}
func TestProviderEntriesConfigEqualIgnoresResolvedCredentialState(t *testing.T) {
func TestProviderEntriesConfigEqualIgnoresRuntimeState(t *testing.T) {
a := ProviderEntry{Name: "relay", Kind: "openai", BaseURL: "https://relay.example/v1", Model: "m", APIKeyEnv: "RELAY_API_KEY"}
b := a
a.resolvedAPIKey = "old-secret"
a.resolvedSource = CredentialSource{Kind: CredentialSourceCredentials, Label: "old"}
a.persistedOfficialCurrency = "USD"
b.resolvedAPIKey = "new-secret"
b.resolvedSource = CredentialSource{Kind: CredentialSourceEnvironment, Label: "new"}
if !ProviderEntriesConfigEqual(a, b) {
t.Fatal("runtime-only credential state caused a persisted provider conflict")
t.Fatal("runtime-only provider state caused a persisted provider conflict")
}
b.Headers = map[string]string{"X-External": "changed"}
if ProviderEntriesConfigEqual(a, b) {
t.Fatal("persisted provider field change was ignored")
}
snapshot := ProviderEntryConfigSnapshot(a)
if snapshot.resolvedAPIKey != "" || snapshot.resolvedSource != (CredentialSource{}) {
t.Fatal("provider config snapshot retained runtime credential state")
if snapshot.resolvedAPIKey != "" || snapshot.resolvedSource != (CredentialSource{}) || snapshot.persistedOfficialCurrency != "" {
t.Fatal("provider config snapshot retained runtime state")
}
cfg := &Config{Providers: []ProviderEntry{a}}
updated := a
@@ -2009,7 +2037,7 @@ func TestProviderEntriesConfigEqualIgnoresResolvedCredentialState(t *testing.T)
t.Fatal(err)
}
got, _ := cfg.Provider("relay")
if got.APIKey() != "old-secret" || got.Headers["X-Replayed"] != "yes" {
if got.APIKey() != "old-secret" || got.Headers["X-Replayed"] != "yes" || got.persistedOfficialCurrency != "USD" {
t.Fatalf("runtime-preserving upsert = %+v", got)
}
updated.APIKeyEnv = "NEW_RELAY_API_KEY"
@@ -2020,6 +2048,9 @@ func TestProviderEntriesConfigEqualIgnoresResolvedCredentialState(t *testing.T)
if got.resolvedAPIKey != "" || got.resolvedSource != (CredentialSource{}) {
t.Fatal("runtime credential survived an api_key_env change")
}
if got.persistedOfficialCurrency != "USD" {
t.Fatal("pricing provenance was lost after an api_key_env change")
}
}
func TestSaveToExistingProjectRemovesPluginDelta(t *testing.T) {
+41 -4
View File
@@ -74,6 +74,8 @@ func loadForRoot(root string, migrateOnDisk bool) (*Config, error) {
userDefaultModel := cfg.DefaultModel
globalSecrets := cfg.Secrets
globalRemote := cfg.Remote.Clone()
globalDesktopLanguage := cfg.Desktop.Language
globalPricingCurrency := cfg.Desktop.Currency
globalTelemetry := cfg.Telemetry
tomlSources = append(tomlSources, projectTOML)
@@ -88,6 +90,10 @@ func loadForRoot(root string, migrateOnDisk bool) (*Config, error) {
// must not be able to inject hosts, jump chains, or port forwards that
// steer where Reasonix opens connections.
cfg.Remote = globalRemote
// Desktop language and pricing currency are user-level regional preferences.
// A repository must not be able to alter how the user's spend is shown.
cfg.Desktop.Language = globalDesktopLanguage
cfg.Desktop.Currency = globalPricingCurrency
// CLI telemetry is an explicit user-global privacy choice. Project config
// cannot opt a user in or out, including when the global value is absent.
cfg.Telemetry = globalTelemetry
@@ -305,7 +311,12 @@ func backfillDeepSeekPro(c *Config) {
for _, bp := range Default().Providers {
if bp.Name == "deepseek-pro" {
bp.APIKeyEnv = flash.APIKeyEnv
bp.Price = deepSeekV4PriceForModel(c.DeepSeekOfficialPricingLanguage(), proModel)
currency := c.DeepSeekOfficialPricingCurrency()
if c.DesktopCurrency() == "" && flash.persistedOfficialCurrency != "" {
currency = flash.persistedOfficialCurrency
bp.persistedOfficialCurrency = currency
}
bp.Price = deepSeekV4PriceForModel(currency, proModel)
c.Providers = append(c.Providers, bp)
return
}
@@ -316,12 +327,16 @@ func backfillDeepSeekOfficialPrices(c *Config) {
if c == nil {
return
}
defaults := deepSeekV4PricesForConfig(c)
for i := range c.Providers {
p := &c.Providers[i]
if officialProviderKind(p) != "deepseek" {
continue
}
currency := c.DeepSeekOfficialPricingCurrency()
if c.DesktopCurrency() == "" && p.persistedOfficialCurrency != "" {
currency = p.persistedOfficialCurrency
}
defaults := DeepSeekV4PricesForCurrency(currency)
if p.Price != nil {
continue
}
@@ -418,6 +433,7 @@ func mergeTOMLProviders(paths []string) ([]ProviderEntry, map[string]providerSou
if _, err := decodeTOMLFile(path, &f); err != nil {
return nil, nil, nil, false, fmt.Errorf("config %s: %w", path, err)
}
markPersistedDeepSeekOfficialPricing(&f)
if len(f.Providers) == 0 {
continue
}
@@ -662,9 +678,24 @@ func mergeFile(cfg *Config, path string) error {
if _, err := os.Stat(path); err != nil {
return nil
}
if _, err := decodeTOMLFile(path, cfg); err != nil {
meta, err := decodeTOMLFile(path, cfg)
if err != nil {
return fmt.Errorf("config %s: %w", path, err)
}
if meta.IsDefined("providers") {
var persisted Config
if _, err := decodeTOMLFile(path, &persisted); err != nil {
return fmt.Errorf("config %s: %w", path, err)
}
markPersistedDeepSeekOfficialPricing(&persisted)
markers := map[string]string{}
for i := range persisted.Providers {
markers[providerMergeKey(persisted.Providers[i])] = persisted.Providers[i].persistedOfficialCurrency
}
for i := range cfg.Providers {
cfg.Providers[i].persistedOfficialCurrency = markers[providerMergeKey(cfg.Providers[i])]
}
}
return nil
}
@@ -1684,7 +1715,12 @@ func ensureDeepSeekOfficialProvider(c *Config) {
}
if old, ok := c.Provider("deepseek-flash"); ok {
entry = officialProviderFromLegacy(entry, old)
entry.Prices = deepSeekV4PricesForConfig(c)
currency := c.DeepSeekOfficialPricingCurrency()
if c.DesktopCurrency() == "" && old.persistedOfficialCurrency != "" {
currency = old.persistedOfficialCurrency
entry.persistedOfficialCurrency = currency
}
entry.Prices = DeepSeekV4PricesForCurrency(currency)
entry.Models = mergeModelLists([]string{"deepseek-v4-flash", "deepseek-v4-pro"}, old.ModelList())
entry.Default = firstKnownModel(entry.Default, entry.Models, "deepseek-v4-flash")
}
@@ -1730,6 +1766,7 @@ func officialProviderFromLegacy(entry ProviderEntry, old *ProviderEntry) Provide
entry.SupportedEfforts = append([]string(nil), old.SupportedEfforts...)
entry.DefaultEffort = old.DefaultEffort
entry.NoProxy = old.NoProxy
entry.persistedOfficialCurrency = old.persistedOfficialCurrency
return entry
}
+178 -29
View File
@@ -8,18 +8,18 @@ import (
"reasonix/internal/provider"
)
func deepSeekV4FlashPrice() *provider.Pricing {
func deepSeekV4FlashPriceCNY() *provider.Pricing {
return &provider.Pricing{CacheHit: 0.02, Input: 1, Output: 2, Currency: "¥"}
}
func deepSeekV4ProPrice() *provider.Pricing {
func deepSeekV4ProPriceCNY() *provider.Pricing {
return &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: "¥"}
}
func deepSeekV4Prices() map[string]*provider.Pricing {
func deepSeekV4PricesCNY() map[string]*provider.Pricing {
return map[string]*provider.Pricing{
"deepseek-v4-flash": deepSeekV4FlashPrice(),
"deepseek-v4-pro": deepSeekV4ProPrice(),
"deepseek-v4-flash": deepSeekV4FlashPriceCNY(),
"deepseek-v4-pro": deepSeekV4ProPriceCNY(),
}
}
@@ -38,30 +38,105 @@ func deepSeekV4PricesUSD() map[string]*provider.Pricing {
}
}
// DeepSeekV4PricesForLanguage keeps the settings/template call site stable while
// official DeepSeek defaults move to RMB. Persisted prices still win; this is
// only used for templates and missing-default backfills.
// DeepSeekV4PricesForCurrency returns the official regional price table.
// Persisted custom prices still win; this is only used for built-in templates
// and known-default refreshes.
func DeepSeekV4PricesForCurrency(currency string) map[string]*provider.Pricing {
if normalizeDeepSeekPricingCurrency(currency) == "CNY" {
return deepSeekV4PricesCNY()
}
return deepSeekV4PricesUSD()
}
// DeepSeekV4PricesForLanguage is retained for compatibility with older call
// sites. New desktop code should pass an explicit pricing currency.
func DeepSeekV4PricesForLanguage(lang string) map[string]*provider.Pricing {
_ = lang
return deepSeekV4Prices()
if normalizeDeepSeekPricingLanguage(lang) == "zh" {
return DeepSeekV4PricesForCurrency("CNY")
}
return DeepSeekV4PricesForCurrency("USD")
}
func deepSeekV4PricesForConfig(c *Config) map[string]*provider.Pricing {
_ = c
return deepSeekV4Prices()
return DeepSeekV4PricesForCurrency(c.DeepSeekOfficialPricingCurrency())
}
func deepSeekV4PriceForModel(lang, model string) *provider.Pricing {
_ = lang
return clonePricing(deepSeekV4Prices()[strings.TrimSpace(model)])
func deepSeekV4PriceForModel(currency, model string) *provider.Pricing {
return clonePricing(DeepSeekV4PricesForCurrency(currency)[strings.TrimSpace(model)])
}
// DeepSeekOfficialPricingLanguage is retained for settings/template compatibility.
// Official DeepSeek providers now seed RMB prices by default; explicit user
// prices in config still override these defaults.
// DeepSeekOfficialPricingCurrency resolves the regional pricing table used for
// official DeepSeek defaults. An explicit desktop currency wins; auto follows
// desktop language, then CLI language, and finally defaults to USD.
func (c *Config) DeepSeekOfficialPricingCurrency() string {
if c != nil {
if currency := c.DesktopCurrency(); currency != "" {
return currency
}
if normalizeDeepSeekPricingLanguage(c.Desktop.Language) == "zh" {
return "CNY"
}
if normalizeDeepSeekPricingLanguage(c.Desktop.Language) == "en" {
return "USD"
}
if normalizeDeepSeekPricingLanguage(c.Language) == "zh" {
return "CNY"
}
}
return "USD"
}
// DesktopPricingFollowsDetectedLocale reports whether the desktop may supply
// its browser/OS locale as the official pricing region. Explicit currency or
// language preferences always win.
func (c *Config) DesktopPricingFollowsDetectedLocale() bool {
return c != nil && c.DesktopCurrency() == "" && c.DesktopLanguage() == "" && c.ResponseLanguage() == "auto"
}
// ApplyRuntimeAutoPricingCurrency applies a frontend-detected pricing region
// to this in-memory config only. Callers must not persist the resulting copy,
// because doing so would turn the user's Auto choice into an explicit region.
func (c *Config) ApplyRuntimeAutoPricingCurrency(currency string) {
if !c.DesktopPricingFollowsDetectedLocale() {
return
}
normalized := normalizeDeepSeekPricingCurrency(currency)
if normalized == "" {
return
}
c.Desktop.Currency = normalized
applyDeepSeekOfficialDefaultPricingWithOverride(c, false)
}
// DeepSeekOfficialPricingLanguage is retained for older settings/template call
// sites that still express the pricing region as a language.
func (c *Config) DeepSeekOfficialPricingLanguage() string {
_ = c
return "zh"
if c.DeepSeekOfficialPricingCurrency() == "CNY" {
return "zh"
}
return "en"
}
func normalizeDeepSeekPricingCurrency(currency string) string {
switch strings.ToUpper(strings.TrimSpace(currency)) {
case "CNY", "RMB", "CNH", "¥", "¥":
return "CNY"
case "USD", "$", "US$":
return "USD"
default:
return ""
}
}
func normalizeDeepSeekPricingLanguage(lang string) string {
switch strings.ToLower(strings.TrimSpace(lang)) {
case "zh", "zh-cn", "zh-hans", "cn", "chinese", "中文", "zh-tw", "zh-hant", "zh-hk", "zh-mo":
return "zh"
case "en", "en-us", "en-gb", "english":
return "en"
default:
return ""
}
}
// ApplyDeepSeekOfficialDefaultPricing refreshes built-in/official DeepSeek
@@ -72,26 +147,88 @@ func (c *Config) ApplyDeepSeekOfficialDefaultPricing() {
}
func applyDeepSeekOfficialDefaultPricing(c *Config) {
applyDeepSeekOfficialDefaultPricingWithOverride(c, c != nil && c.DesktopCurrency() != "")
}
func applyDeepSeekOfficialDefaultPricingWithOverride(c *Config, overridePersisted bool) {
if c == nil {
return
}
lang := c.DeepSeekOfficialPricingLanguage()
currency := c.DeepSeekOfficialPricingCurrency()
for i := range c.Providers {
p := &c.Providers[i]
if officialProviderKind(p) != "deepseek" {
continue
}
if isKnownDeepSeekOfficialPricing(p.Model, p.Price) {
p.Price = deepSeekV4PriceForModel(lang, p.Model)
if isKnownDeepSeekOfficialPricing(p.Model, p.Price) && (overridePersisted || p.persistedOfficialCurrency == "") {
p.Price = deepSeekV4PriceForModel(currency, p.Model)
}
for model, price := range p.Prices {
if isKnownDeepSeekOfficialPricing(model, price) {
p.Prices[model] = deepSeekV4PriceForModel(lang, model)
if isKnownDeepSeekOfficialPricing(model, price) && (overridePersisted || p.persistedOfficialCurrency == "") {
p.Prices[model] = deepSeekV4PriceForModel(currency, model)
}
}
}
}
// markPersistedDeepSeekOfficialPricing records which recognized regional
// prices came from TOML. Auto locale refreshes preserve those values, while an
// explicit currency choice can still replace them with the selected table.
func markPersistedDeepSeekOfficialPricing(c *Config) {
if c == nil {
return
}
for i := range c.Providers {
p := &c.Providers[i]
if officialProviderKind(p) != "deepseek" {
continue
}
p.persistedOfficialCurrency = completeDeepSeekOfficialPricingCurrency(p)
if c.ConfigVersion >= Default().ConfigVersion && isStandardDeepSeekProviderTemplate(p) {
p.persistedOfficialCurrency = ""
}
}
}
func isStandardDeepSeekProviderTemplate(p *ProviderEntry) bool {
if p == nil || officialProviderKind(p) != "deepseek" {
return false
}
return strings.TrimSpace(p.APIKeyEnv) == "DEEPSEEK_API_KEY" &&
strings.TrimSpace(p.BalanceURL) == "https://api.deepseek.com/user/balance" &&
p.ContextWindow == 1_000_000
}
func completeDeepSeekOfficialPricingCurrency(p *ProviderEntry) string {
if p == nil {
return ""
}
models := p.ModelList()
if len(models) == 1 && isKnownDeepSeekOfficialPricing(models[0], p.Price) {
return normalizeDeepSeekPricingCurrency(p.Price.Currency)
}
if len(models) == 0 || p.Price != nil {
return ""
}
currency := ""
for _, model := range models {
price := p.Prices[strings.TrimSpace(model)]
if !isKnownDeepSeekOfficialPricing(model, price) {
return ""
}
nextCurrency := normalizeDeepSeekPricingCurrency(price.Currency)
if nextCurrency == "" {
return ""
}
if currency == "" {
currency = nextCurrency
} else if currency != nextCurrency {
return ""
}
}
return currency
}
func mimoV25ProPrice() *provider.Pricing {
return &provider.Pricing{CacheHit: 0.025, Input: 3, Output: 6, Currency: "¥"}
}
@@ -217,16 +354,15 @@ func resetOfficialProviderPricingDefaults(c *Config) {
p := &c.Providers[i]
switch {
case officialProviderKind(p) == "deepseek":
resetDeepSeekOfficialPricing(p)
resetDeepSeekOfficialPricing(p, deepSeekV4PricesForConfig(c))
}
}
}
func resetDeepSeekOfficialPricing(p *ProviderEntry) {
func resetDeepSeekOfficialPricing(p *ProviderEntry, defaults map[string]*provider.Pricing) {
if p == nil {
return
}
defaults := deepSeekV4Prices()
p.Price = nil
if strings.TrimSpace(p.Model) != "" && len(p.Models) == 0 {
if price := defaults[strings.TrimSpace(p.Model)]; price != nil {
@@ -250,7 +386,7 @@ func isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool
if model == "" || price == nil {
return false
}
for _, prices := range []map[string]*provider.Pricing{deepSeekV4Prices(), deepSeekV4PricesUSD()} {
for _, prices := range []map[string]*provider.Pricing{deepSeekV4PricesCNY(), deepSeekV4PricesUSD()} {
if samePricing(price, prices[model]) {
return true
}
@@ -258,6 +394,19 @@ func isKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool
return false
}
// IsOfficialDeepSeekProvider reports whether an entry targets DeepSeek's
// official API endpoint. Desktop telemetry uses this after a regional-currency
// change so custom endpoints and rates stay untouched.
func IsOfficialDeepSeekProvider(p *ProviderEntry) bool {
return officialProviderKind(p) == "deepseek"
}
// IsKnownDeepSeekOfficialPricing reports whether price is one of Reasonix's
// built-in DeepSeek regional defaults for model.
func IsKnownDeepSeekOfficialPricing(model string, price *provider.Pricing) bool {
return isKnownDeepSeekOfficialPricing(model, price)
}
func samePricing(a, b *provider.Pricing) bool {
if a == nil || b == nil {
return false
+5
View File
@@ -97,6 +97,11 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
} else {
b.WriteString("# language = \"zh\" # desktop UI language; empty/auto = browser/OS auto-detect\n")
}
if currency := c.DesktopCurrency(); currency != "" {
fmt.Fprintf(&b, "currency = %q # official pricing currency: CNY|USD; empty/auto follows language\n", currency)
} else {
b.WriteString("# currency = \"USD\" # official pricing currency: CNY|USD; empty/auto follows language\n")
}
fmt.Fprintf(&b, "layout_style = %q # desktop layout: classic|workbench|creation\n", c.DesktopLayoutStyle())
fmt.Fprintf(&b, "theme = %q # desktop only: auto|dark|light\n", c.DesktopTheme())
if style := c.DesktopThemeStyle(); style != "" {
+3 -2
View File
@@ -760,6 +760,7 @@ func TestScopedRenderSeparatesUserAndProjectConfig(t *testing.T) {
c := Default()
c.Language = "zh"
c.Desktop.Language = "zh"
c.Desktop.Currency = "CNY"
c.Desktop.Theme = "dark"
c.Desktop.ThemeStyle = "graphite"
c.Desktop.CloseBehavior = "background"
@@ -771,7 +772,7 @@ func TestScopedRenderSeparatesUserAndProjectConfig(t *testing.T) {
c.Agent.RecoveryTemperature = 0.2
user := RenderTOMLForScope(c, RenderScopeUser)
for _, want := range []string{"config_version = 5", "[desktop]", `theme = "dark"`, `close_behavior = "background"`, `status_bar_style = "text"`, `default_tool_approval_mode = "auto"`, `check_updates = false`, `update_channel = "preview"`, `recovery_model = "deepseek-pro"`, "[notifications]", "[tools.shell]"} {
for _, want := range []string{"config_version = 5", "[desktop]", `currency = "CNY"`, `theme = "dark"`, `close_behavior = "background"`, `status_bar_style = "text"`, `default_tool_approval_mode = "auto"`, `check_updates = false`, `update_channel = "preview"`, `recovery_model = "deepseek-pro"`, "[notifications]", "[tools.shell]"} {
if !strings.Contains(user, want) {
t.Fatalf("user render missing %q:\n%s", want, user)
}
@@ -930,7 +931,7 @@ func TestRenderTOMLRoundTripsPerModelPrices(t *testing.T) {
Models: []string{"deepseek-v4-flash", "deepseek-v4-pro"},
Default: "deepseek-v4-flash",
APIKeyEnv: "DEEPSEEK_API_KEY",
Prices: deepSeekV4Prices(),
Prices: DeepSeekV4PricesForCurrency("CNY"),
}}
var got Config
+14 -1
View File
@@ -46,7 +46,7 @@ type ArgData struct {
// the token being typed and the byte offset where that token begins, so a caller
// replaces just that token. Only structured commands participate (/mcp /model
// /skills /plugins /hooks /effort /goal /reasoning-language
// /theme /language);
// /theme /language /currency);
// others yield nil. Single source of truth for CLI + desktop.
func SlashArgItems(line string, d ArgData) ([]SlashItem, int) {
cmdEnd := strings.IndexAny(line, " \t")
@@ -80,6 +80,8 @@ func SlashArgItems(line string, d ArgData) ([]SlashItem, int) {
raw = themeArgItems(prior)
case "/language":
raw = languageArgItems(prior)
case "/currency":
raw = currencyArgItems(prior)
default:
return nil, from
}
@@ -120,6 +122,17 @@ func languageArgItems(prior []string) []SlashItem {
}
}
func currencyArgItems(prior []string) []SlashItem {
if len(prior) != 1 {
return nil
}
return []SlashItem{
{Label: "auto", Insert: "auto", Hint: "follow the resolved CLI locale"},
{Label: "CNY", Insert: "CNY", Hint: "Chinese yuan pricing"},
{Label: "USD", Insert: "USD", Hint: "US dollar pricing"},
}
}
func themeArgItems(prior []string) []SlashItem {
if len(prior) > 1 {
return nil
+5
View File
@@ -152,6 +152,11 @@ func TestSlashArgItems(t *testing.T) {
if !has(items, "auto") || !has(items, "zh") || !has(items, "en") || has(items, "中文") {
t.Errorf("/reasoning-language should offer only auto/zh/en; got %v", labelsOf(items))
}
// /currency
items, _ = SlashArgItems("/currency ", data)
if !has(items, "auto") || !has(items, "CNY") || !has(items, "USD") {
t.Errorf("/currency should offer only auto/CNY/USD; got %v", labelsOf(items))
}
// /theme
items, _ = SlashArgItems("/theme ", data)
if !has(items, "auto") || !has(items, "light") || !has(items, "graphite") || !has(items, "glacier") {
+31 -14
View File
@@ -160,16 +160,21 @@ type Messages struct {
AskSubmitHint string // submit-tab keyboard hint
// output style listing (/output-style).
OutputStyleNone string // no styles available
OutputStyleHeader string // header above the listing
OutputStyleHint string // how to select one
ThemeHeader string // header above the /theme listing
ThemeHint string // how to select a theme
ThemeChangedFmt string // "/theme <name>" succeeded
ThemeUnknownFmt string // "/theme <name>" unknown
LanguageHeader string // header above the /language listing
LanguageHint string // how to select a language
LanguageChangedFmt string // "/language <tag>" succeeded, %s = saved tag, %s = resolved tag
OutputStyleNone string // no styles available
OutputStyleHeader string // header above the listing
OutputStyleHint string // how to select one
ThemeHeader string // header above the /theme listing
ThemeHint string // how to select a theme
ThemeChangedFmt string // "/theme <name>" succeeded
ThemeUnknownFmt string // "/theme <name>" unknown
LanguageHeader string // header above the /language listing
LanguageHint string // how to select a language
LanguageChangedFmt string // "/language <tag>" succeeded, %s = saved tag, %s = resolved tag
CurrencyHeader string // header above the /currency listing
CurrencyHint string // how to select a pricing currency
CurrencyChangedFmt string // "/currency <mode>" succeeded, %s = saved mode, %s = resolved currency
RuntimeRefreshBusy string // runtime-affecting setting cannot change while work is active
RuntimeRefreshUnavailable string // current session cannot rebuild after a runtime-affecting setting change
// context compaction card (CompactionStarted / CompactionDone events).
CompactionWorking string // shown while the summarizer runs
@@ -241,6 +246,7 @@ type Messages struct {
CmdOutputStyle string // /output-style
CmdTheme string // /theme
CmdLanguage string // /language
CmdCurrency string // /currency
CmdSkill string // /skills
CmdVerbose string // /verbose
CmdReloadCmd string // /reload-cmd
@@ -597,7 +603,17 @@ func (m Messages) ProviderStatusMessage(status int) string {
// M is the active catalogue. DetectLanguage replaces it; English is the
// default so any code path that runs before detection still has text.
var M = English
var (
M = English
currentLanguage = "en"
)
// CurrentLanguage returns the language tag installed by the latest
// DetectLanguage call. It lets frontends reuse the resolved locale without
// re-reading the environment and accidentally ignoring an explicit override.
func CurrentLanguage() string {
return currentLanguage
}
// DetectLanguage selects a catalogue from override (e.g. cfg.Language) or the
// environment and installs it as M. Returns the resolved tag ("en", "zh") so
@@ -626,14 +642,15 @@ func setLanguage(tag string) string {
switch tag {
case "zh-tw", "zh-TW":
M = ChineseTraditional
return "zh-TW"
currentLanguage = "zh-TW"
case "zh":
M = Chinese
return "zh"
currentLanguage = "zh"
default:
M = English
return "en"
currentLanguage = "en"
}
return currentLanguage
}
// normalize maps a locale string (e.g. "zh_CN.UTF-8", "zh-Hans-CN", "Chinese
+6
View File
@@ -151,4 +151,10 @@ func TestDetectLanguagePriority(t *testing.T) {
if got := DetectLanguage("zh"); got != "zh" {
t.Errorf("override=zh: got %q, want zh", got)
}
if got := CurrentLanguage(); got != "zh" {
t.Errorf("current language = %q, want zh", got)
}
if got := DetectLanguage("zh-TW"); got != "zh-TW" || CurrentLanguage() != "zh-TW" {
t.Errorf("traditional Chinese current language = %q/%q, want zh-TW", got, CurrentLanguage())
}
}
+16 -10
View File
@@ -131,16 +131,21 @@ var English = Messages{
DiffFoldEnabledFmt: "diff folded to %d lines (/diff-fold to expand)",
DiffFoldDisabled: "diff expanded — showing all lines (/diff-fold to fold)",
OutputStyleNone: "no output styles available",
OutputStyleHeader: "output styles:",
OutputStyleHint: "set agent.output_style in reasonix.toml to apply one (takes effect next session)",
ThemeHeader: "themes:",
ThemeHint: "switch with /theme <auto|light|dark|style>",
ThemeChangedFmt: "theme switched to %s / %s",
ThemeUnknownFmt: "unknown theme %q",
LanguageHeader: "languages:",
LanguageHint: "switch with /language <auto|en|zh>",
LanguageChangedFmt: "language set to %s (resolved: %s)",
OutputStyleNone: "no output styles available",
OutputStyleHeader: "output styles:",
OutputStyleHint: "set agent.output_style in reasonix.toml to apply one (takes effect next session)",
ThemeHeader: "themes:",
ThemeHint: "switch with /theme <auto|light|dark|style>",
ThemeChangedFmt: "theme switched to %s / %s",
ThemeUnknownFmt: "unknown theme %q",
LanguageHeader: "languages:",
LanguageHint: "switch with /language <auto|en|zh>",
LanguageChangedFmt: "language set to %s (resolved: %s)",
CurrencyHeader: "pricing currency:",
CurrencyHint: "switch with /currency <auto|CNY|USD>",
CurrencyChangedFmt: "pricing currency set to %s (resolved: %s)",
RuntimeRefreshBusy: "finish or cancel active work and stop background jobs before changing this setting",
RuntimeRefreshUnavailable: "runtime refresh is unavailable in this session",
CompactionWorking: "compacting conversation…",
CompactionTitle: "Context compacted",
@@ -252,6 +257,7 @@ var English = Messages{
CmdOutputStyle: "list output styles",
CmdTheme: "switch CLI theme",
CmdLanguage: "switch CLI language",
CmdCurrency: "switch pricing currency",
CmdSkill: "manage skills",
CmdVerbose: "toggle thinking text",
CmdReloadCmd: "reload custom commands",
+16 -10
View File
@@ -132,16 +132,21 @@ var Chinese = Messages{
DiffFoldEnabledFmt: "diff 已折叠至 %d 行(/diff-fold 展开)",
DiffFoldDisabled: "diff 已展开 — 显示全部行(/diff-fold 折叠)",
OutputStyleNone: "没有可用的输出风格",
OutputStyleHeader: "输出风格:",
OutputStyleHint: "在 reasonix.toml 设置 agent.output_style 即可启用(下次会话生效)",
ThemeHeader: "主题:",
ThemeHint: "使用 /theme <auto|light|dark|style> 切换",
ThemeChangedFmt: "已切换主题为 %s / %s",
ThemeUnknownFmt: "未知主题 %q",
LanguageHeader: "语言:",
LanguageHint: "使用 /language <auto|en|zh> 切换",
LanguageChangedFmt: "语言已设为 %s(当前解析为:%s)",
OutputStyleNone: "没有可用的输出风格",
OutputStyleHeader: "输出风格:",
OutputStyleHint: "在 reasonix.toml 设置 agent.output_style 即可启用(下次会话生效)",
ThemeHeader: "主题:",
ThemeHint: "使用 /theme <auto|light|dark|style> 切换",
ThemeChangedFmt: "已切换主题为 %s / %s",
ThemeUnknownFmt: "未知主题 %q",
LanguageHeader: "语言:",
LanguageHint: "使用 /language <auto|en|zh> 切换",
LanguageChangedFmt: "语言已设为 %s(当前解析为:%s)",
CurrencyHeader: "计价货币:",
CurrencyHint: "使用 /currency <auto|CNY|USD> 切换",
CurrencyChangedFmt: "计价货币已设为 %s(当前解析为:%s)",
RuntimeRefreshBusy: "请先完成或取消当前工作,并停止后台任务后再修改此设置",
RuntimeRefreshUnavailable: "本会话不支持刷新运行时",
CompactionWorking: "正在压缩对话…",
CompactionTitle: "上下文已压缩",
@@ -253,6 +258,7 @@ var Chinese = Messages{
CmdOutputStyle: "列出输出风格",
CmdTheme: "切换 CLI 主题",
CmdLanguage: "切换 CLI 语言",
CmdCurrency: "切换计价货币",
CmdSkill: "管理 skills",
CmdVerbose: "切换 thinking 原文显示",
CmdReloadCmd: "重载自定义命令",
+16 -10
View File
@@ -126,16 +126,21 @@ var ChineseTraditional = Messages{
PlanModeReadOnlyCommandTrustFailedFmt: "儲存計劃模式唯讀命令信任 %s 失敗:%v",
DiffFoldedFmt: "… 還有 %d 行",
OutputStyleNone: "沒有可用的輸出風格",
OutputStyleHeader: "輸出風格:",
OutputStyleHint: "在 reasonix.toml 設定 agent.output_style 即可啟用(下次會話生效)",
ThemeHeader: "主題:",
ThemeHint: "使用 /theme <auto|light|dark|style> 切換",
ThemeChangedFmt: "已切換主題為 %s / %s",
ThemeUnknownFmt: "未知主題 %q",
LanguageHeader: "語言:",
LanguageHint: "使用 /language <auto|en|zh|zh-TW> 切換",
LanguageChangedFmt: "語言已設為 %s(當前解析為:%s)",
OutputStyleNone: "沒有可用的輸出風格",
OutputStyleHeader: "輸出風格:",
OutputStyleHint: "在 reasonix.toml 設定 agent.output_style 即可啟用(下次會話生效)",
ThemeHeader: "主題:",
ThemeHint: "使用 /theme <auto|light|dark|style> 切換",
ThemeChangedFmt: "已切換主題為 %s / %s",
ThemeUnknownFmt: "未知主題 %q",
LanguageHeader: "語言:",
LanguageHint: "使用 /language <auto|en|zh|zh-TW> 切換",
LanguageChangedFmt: "語言已設為 %s(當前解析為:%s)",
CurrencyHeader: "計價貨幣:",
CurrencyHint: "使用 /currency <auto|CNY|USD> 切換",
CurrencyChangedFmt: "計價貨幣已設為 %s(目前解析為:%s)",
RuntimeRefreshBusy: "請先完成或取消目前工作,並停止背景任務後再修改此設定",
RuntimeRefreshUnavailable: "本會話不支援重新整理執行階段",
CompactionWorking: "正在壓縮對話…",
CompactionTitle: "上下文已壓縮",
@@ -240,6 +245,7 @@ var ChineseTraditional = Messages{
CmdOutputStyle: "列出輸出風格",
CmdTheme: "切換 CLI 主題",
CmdLanguage: "切換 CLI 語言",
CmdCurrency: "切換計價貨幣",
CmdSkill: "管理 skills",
CmdVerbose: "切換 thinking 原文顯示",
CmdReloadCmd: "重載自定義命令",
+1 -1
View File
@@ -1190,7 +1190,7 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request) {
writeJSON(w, sess)
}
const titlePrompt = `Generate a very short title (3-5 words max) for this conversation based on the user's first message. Reply with ONLY the title, no quotes, no punctuation at the end.`
const titlePrompt = `Generate a very short title (3-5 words max) for this conversation based on the user's first message. Use the same language as the user's message. Reply with ONLY the title, no quotes, no punctuation at the end.`
// generateTitle calls a lightweight LLM to produce a short session title.
// Returns empty string on any error — callers should fall back to a preview.
+6
View File
@@ -20,6 +20,12 @@ import (
"reasonix/internal/provider"
)
func TestTitlePromptRequiresUserMessageLanguage(t *testing.T) {
if !strings.Contains(titlePrompt, "same language as the user's message") {
t.Fatalf("title prompt does not preserve the user's language: %q", titlePrompt)
}
}
// fakeRunner stands in for an agent.Runner: it records the composed input and
// returns without emitting model events, so the controller's TurnDone is the
// observable signal.
+5 -2
View File
@@ -14,6 +14,8 @@ theme = "auto" # auto|dark|light; controls CLI colors only; REASONIX_THEME can
# cursor_shape = "bar" # block|underline|bar; text input cursor shape; slim default avoids covering CJK characters
[desktop]
# language = "auto" # auto|en|zh; auto follows the browser/OS locale
# currency = "auto" # auto|CNY|USD; explicitly set it independently from language
layout_style = "classic" # classic|workbench|creation; desktop layout style
# theme = "auto" # desktop only: auto|dark|light
# theme_style = "graphite" # graphite|aurora|slate|carbon|nocturne|amber and legacy aliases
@@ -62,8 +64,9 @@ api_key_env = "DEEPSEEK_API_KEY"
context_window = 1000000
# Optional per-model context budgets. Omitted models inherit context_window.
# model_overrides = { "deepseek-v4-flash" = { context_window = 1000000 } }
# Official DeepSeek providers seed RMB prices by default.
# Set prices explicitly only when you want to pin a currency/rate.
# Official DeepSeek providers use CNY or USD regional prices from
# [desktop].currency; auto follows the selected language and defaults to USD.
# Set prices explicitly only when you want to pin custom rates.
# prices = { "deepseek-v4-flash" = { cache_hit = 0.02, input = 1, output = 2, currency = "¥" }, "deepseek-v4-pro" = { cache_hit = 0.025, input = 3, output = 6, currency = "¥" } } # per 1M tokens
# DeepSeek thinking is always on; effort: high | max. Omit for auto (provider default).
effort = "high"