opencode prices and Go limits, settings out of .env
- Fetch prices for opencode (Zen and Go) incl. limits, image input, reasoning,
long-context tiers and peak-hour/date factors; costs apply them per request
- Record the cost opencode reports per response; track Go allowance in 5h/7d/month
windows against a provider plan limit (budget table, status overlay)
- Settings → Config file: move values of ${NAME} references into the config
- Examples, presets and forms keep keys in the config instead of .env
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
70367211c1
commit
bfa1f641d7
23 files changed
+723
-69
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { Fragment } from "react";
|
||||
import { Icon } from "./Icon";
|
||||
import { useFetch } from "../hooks";
|
||||
import { fmtCost, fmtPercent, useT } from "../i18n";
|
||||
@@ -11,9 +12,32 @@ interface BudgetRow {
|
||||
warning: boolean;
|
||||
exceeded: boolean;
|
||||
images?: boolean;
|
||||
plan?: Plan;
|
||||
remote?: { currency?: string; remaining?: number | null; limit?: number | null; used?: number | null; spent_month?: number; scope?: string; error?: string };
|
||||
}
|
||||
|
||||
/** opencode Go: usage windows against the plan's limits (USD). */
|
||||
export interface Plan {
|
||||
limit: number;
|
||||
windows: { window: "5h" | "week" | "month"; used: number; limit: number; ratio: number }[];
|
||||
}
|
||||
|
||||
/** Three small meters (5 hours, week, month) for a subscription plan. */
|
||||
export function PlanUsage({ plan }: { plan: Plan }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="plan-usage">
|
||||
{plan.windows.map((w) => (
|
||||
<div key={w.window} className="plan-window" title={`${fmtCost(w.used, "USD")} / ${fmtCost(w.limit, "USD")}`}>
|
||||
<span className="plan-label">{t(`budget.window.${w.window}`)}</span>
|
||||
<Bar share={w.ratio} warning={w.ratio >= 0.8} exceeded={w.ratio >= 1} />
|
||||
<span className="plan-pct">{fmtPercent(w.ratio)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface BudgetData {
|
||||
currency: string;
|
||||
total: BudgetRow;
|
||||
@@ -62,20 +86,30 @@ export function BudgetPanel() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.provider}>
|
||||
<td>
|
||||
{r.provider === "total" ? <b>{t("stats.total")}</b> : r.provider}
|
||||
{r.images && <span className="chip" style={{ marginLeft: 6 }}>{t("nav.images")}</span>}
|
||||
</td>
|
||||
<td className="num">{fmtCost(r.spent, data.currency)}</td>
|
||||
<td className="num">{r.budget ? fmtCost(r.budget, data.currency) : "–"}</td>
|
||||
<td>{r.share !== null && <Bar share={r.share} warning={r.warning} exceeded={r.exceeded} />}</td>
|
||||
<td className="num" style={{ color: r.exceeded ? "var(--red)" : r.warning ? "var(--accent2)" : undefined }}>
|
||||
{r.remaining !== null ? fmtCost(r.remaining, data.currency) : "–"}
|
||||
{r.share !== null && <span className="muted"> ({fmtPercent(r.share)})</span>}
|
||||
</td>
|
||||
<td>{r.provider === "total" ? null : <Remote r={r.remote} t={t} />}</td>
|
||||
</tr>
|
||||
<Fragment key={r.provider}>
|
||||
<tr>
|
||||
<td>
|
||||
{r.provider === "total" ? <b>{t("stats.total")}</b> : r.provider}
|
||||
{r.images && <span className="chip" style={{ marginLeft: 6 }}>{t("nav.images")}</span>}
|
||||
</td>
|
||||
<td className="num">{fmtCost(r.spent, data.currency)}</td>
|
||||
<td className="num">{r.budget ? fmtCost(r.budget, data.currency) : "–"}</td>
|
||||
<td>{r.share !== null && <Bar share={r.share} warning={r.warning} exceeded={r.exceeded} />}</td>
|
||||
<td className="num" style={{ color: r.exceeded ? "var(--red)" : r.warning ? "var(--accent2)" : undefined }}>
|
||||
{r.remaining !== null ? fmtCost(r.remaining, data.currency) : "–"}
|
||||
{r.share !== null && <span className="muted"> ({fmtPercent(r.share)})</span>}
|
||||
</td>
|
||||
<td>{r.provider === "total" ? null : <Remote r={r.remote} t={t} />}</td>
|
||||
</tr>
|
||||
{r.plan && (
|
||||
<tr className="plan-row">
|
||||
<td className="muted">{t("budget.plan", { v: fmtCost(r.plan.limit, "USD") })}</td>
|
||||
<td colSpan={5}>
|
||||
<PlanUsage plan={r.plan} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
+102
-11
@@ -257,7 +257,7 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
chromium: c.chromium ?? "chromium",
|
||||
widths: (c.widths ?? []).join(", "),
|
||||
images_enabled: cfg.config.images?.enabled ?? false,
|
||||
images_key: cfg.config.images?.api_key === MASK ? "" : (cfg.config.images?.api_key ?? "${FAL_KEY}"),
|
||||
images_key: cfg.config.images?.api_key === MASK ? "" : (cfg.config.images?.api_key ?? ""),
|
||||
images_model: cfg.config.images?.default_model ?? "fal-ai/flux/schnell",
|
||||
images_dir: cfg.config.images?.output_dir ?? "generated",
|
||||
});
|
||||
@@ -344,7 +344,7 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
|
||||
<label>{t("settings.imagesEnabled")}</label>
|
||||
<input type="checkbox" checked={form.images_enabled} onChange={(e) => setForm({ ...form, images_enabled: e.target.checked })} style={{ justifySelf: "start" }} />
|
||||
<label>{t("settings.imagesKey")}</label>
|
||||
<input type="password" value={form.images_key} placeholder={cfg.config.images?.api_key === MASK ? t("settings.apiKeyStored") : "${FAL_KEY}"} onChange={(e) => setForm({ ...form, images_key: e.target.value })} />
|
||||
<input type="password" value={form.images_key} placeholder={cfg.config.images?.api_key === MASK ? t("settings.apiKeyStored") : "fal.ai API key"} onChange={(e) => setForm({ ...form, images_key: e.target.value })} />
|
||||
<label>{t("settings.imagesDefault")}</label>
|
||||
<select value={form.images_model} onChange={(e) => setForm({ ...form, images_model: e.target.value })}>
|
||||
{(cfg.config.images?.models ?? []).map((m: any) => (
|
||||
@@ -385,14 +385,16 @@ interface ModelRow {
|
||||
max_concurrency?: number;
|
||||
fallback?: string;
|
||||
options?: Record<string, unknown>;
|
||||
price_tiers?: { above_context: number; input_price?: number; output_price?: number; cache_read_price?: number; cache_write_price?: number }[];
|
||||
price_multipliers?: { factor: number; days?: string[]; hours_utc?: [string, string][]; from?: string; until?: string }[];
|
||||
}
|
||||
|
||||
const PRESETS: Record<string, { kind: string; base_url: string; api_key: string; auth_prefix?: string }> = {
|
||||
anthropic: { kind: "anthropic", base_url: "https://api.anthropic.com/v1", api_key: "${ANTHROPIC_API_KEY}" },
|
||||
gemini: { kind: "openai", base_url: "https://generativelanguage.googleapis.com/v1beta/openai", api_key: "${GEMINI_API_KEY}" },
|
||||
"opencode-go": { kind: "openai", base_url: "https://opencode.ai/inference/go/openai/v1", api_key: "${OPENCODE_API_KEY}" },
|
||||
fal: { kind: "openai", base_url: "https://fal.run/openrouter/router/openai/v1", api_key: "${FAL_KEY}", auth_prefix: "Key " },
|
||||
openrouter: { kind: "openai", base_url: "https://openrouter.ai/api/v1", api_key: "${OPENROUTER_API_KEY}" },
|
||||
anthropic: { kind: "anthropic", base_url: "https://api.anthropic.com/v1", api_key: "" },
|
||||
gemini: { kind: "openai", base_url: "https://generativelanguage.googleapis.com/v1beta/openai", api_key: "" },
|
||||
"opencode-go": { kind: "openai", base_url: "https://opencode.ai/inference/go/openai/v1", api_key: "" },
|
||||
fal: { kind: "openai", base_url: "https://fal.run/openrouter/router/openai/v1", api_key: "", auth_prefix: "Key " },
|
||||
openrouter: { kind: "openai", base_url: "https://openrouter.ai/api/v1", api_key: "" },
|
||||
ollama: { kind: "openai", base_url: "http://host.docker.internal:11434/v1", api_key: "" },
|
||||
custom: { kind: "openai", base_url: "http://", api_key: "" },
|
||||
};
|
||||
@@ -401,11 +403,23 @@ function normalizeModels(models: Json[]): ModelRow[] {
|
||||
return (models ?? []).map((m) => (typeof m === "string" ? { id: m } : { ...m }));
|
||||
}
|
||||
|
||||
/** Readable summary of long-context tiers and time factors (tooltip); empty if there are none. */
|
||||
function priceExtras(m: ModelRow): string {
|
||||
const lines = [
|
||||
...(m.price_tiers ?? []).map((t) => `> ${Math.round(t.above_context / 1000)}k context: ${[t.input_price, t.output_price].map((x) => x ?? "–").join(" / ")}`),
|
||||
...(m.price_multipliers ?? []).map((x) =>
|
||||
[`×${x.factor}`, x.days?.join(","), x.hours_utc?.map(([a, b]) => `${a}–${b} UTC`).join(", "), x.from && `from ${x.from}`, x.until && `until ${x.until}`].filter(Boolean).join(" "),
|
||||
),
|
||||
];
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function cleanModel(m: ModelRow) {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(m)) {
|
||||
if (v === undefined || v === "" || (typeof v === "number" && Number.isNaN(v))) continue;
|
||||
if (v === false && (k === "reasoning" || k === "vision")) continue;
|
||||
if (Array.isArray(v) && v.length === 0) continue;
|
||||
out[k] = v;
|
||||
}
|
||||
return out;
|
||||
@@ -428,7 +442,20 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
const f = byId.get(m.id);
|
||||
if (!f) return m;
|
||||
n++;
|
||||
return { ...m, input_price: f.input_price ?? m.input_price, output_price: f.output_price ?? m.output_price, cache_read_price: f.cache_read_price ?? m.cache_read_price, cache_write_price: f.cache_write_price ?? m.cache_write_price, context: m.context ?? f.context };
|
||||
return {
|
||||
...m,
|
||||
input_price: f.input_price ?? m.input_price,
|
||||
output_price: f.output_price ?? m.output_price,
|
||||
cache_read_price: f.cache_read_price ?? m.cache_read_price,
|
||||
cache_write_price: f.cache_write_price ?? m.cache_write_price,
|
||||
// Long-context tiers and time factors replace the stored ones when the provider publishes them.
|
||||
price_tiers: f.price_tiers ?? m.price_tiers,
|
||||
price_multipliers: f.price_multipliers ?? m.price_multipliers,
|
||||
context: m.context ?? f.context,
|
||||
max_output: m.max_output ?? f.max_output,
|
||||
vision: m.vision || !!f.vision,
|
||||
reasoning: m.reasoning || !!f.reasoning,
|
||||
};
|
||||
}),
|
||||
);
|
||||
setPriceNote(t("settings.pricesFetched", { n }));
|
||||
@@ -454,6 +481,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
{ path: ["providers", name, "auth_prefix"], value: form.auth_prefix ? form.auth_prefix : null },
|
||||
{ path: ["providers", name, "max_concurrency"], value: form.max_concurrency ? Number(form.max_concurrency) : null },
|
||||
{ path: ["providers", name, "monthly_budget"], value: form.monthly_budget ? Number(form.monthly_budget) : null },
|
||||
{ path: ["providers", name, "plan_limit"], value: form.plan_limit ? Number(form.plan_limit) : null },
|
||||
];
|
||||
// Empty key field = keep the stored (masked) key.
|
||||
if (form.api_key !== "" || p.api_key !== MASK) patches.push({ path: ["providers", name, "api_key"], value: form.api_key });
|
||||
@@ -533,7 +561,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
<input
|
||||
type="password"
|
||||
value={form.api_key}
|
||||
placeholder={p.api_key === MASK ? t("settings.apiKeyStored") : "${ENV_VAR}"}
|
||||
placeholder={p.api_key === MASK ? t("settings.apiKeyStored") : ""}
|
||||
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
|
||||
/>
|
||||
<div className="hint">{t("settings.apiKeyHint")}</div>
|
||||
@@ -541,10 +569,17 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
<input value={form.auth_prefix ?? ""} placeholder={form.kind === "openai" ? "Bearer " : ""} onChange={(e) => setForm({ ...form, auth_prefix: e.target.value })} />
|
||||
<label>{t("settings.monthlyBudget")}</label>
|
||||
<input type="number" min={0} step="1" value={form.monthly_budget ?? ""} placeholder="–" onChange={(e) => setForm({ ...form, monthly_budget: e.target.value })} />
|
||||
{/opencode\.ai\/(inference|zen)\/go/.test(form.base_url ?? "") && (
|
||||
<>
|
||||
<label>{t("settings.planLimit")}</label>
|
||||
<input type="number" min={0} step="1" value={form.plan_limit ?? ""} placeholder="–" onChange={(e) => setForm({ ...form, plan_limit: e.target.value })} />
|
||||
<div className="hint">{t("settings.planLimitHint")}</div>
|
||||
</>
|
||||
)}
|
||||
{form.kind === "anthropic" && (
|
||||
<>
|
||||
<label>{t("settings.adminKey")}</label>
|
||||
<input type="password" value={form.admin_key} placeholder={p.admin_key === MASK ? t("settings.apiKeyStored") : "${ANTHROPIC_ADMIN_KEY}"} onChange={(e) => setForm({ ...form, admin_key: e.target.value })} />
|
||||
<input type="password" value={form.admin_key} placeholder={p.admin_key === MASK ? t("settings.apiKeyStored") : "sk-ant-admin01-…"} onChange={(e) => setForm({ ...form, admin_key: e.target.value })} />
|
||||
<div className="hint">{t("settings.adminKeyHint")}</div>
|
||||
</>
|
||||
)}
|
||||
@@ -555,7 +590,7 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
<h3 style={{ marginTop: 16 }}>
|
||||
{t("settings.models")}
|
||||
<span className="spacer" />
|
||||
{form.base_url?.includes("openrouter.ai") && (
|
||||
{(form.base_url?.includes("openrouter.ai") || form.base_url?.includes("opencode.ai")) && (
|
||||
<button className="small" onClick={fetchPrices}>
|
||||
{t("settings.fetchPrices")}
|
||||
</button>
|
||||
@@ -621,6 +656,11 @@ function ProviderEditor({ name, p, reload, status }: { name: string; p: Json; re
|
||||
{(["input_price", "output_price", "cache_read_price", "cache_write_price"] as const).map((k) => (
|
||||
<td key={k}>
|
||||
<input type="number" step="0.01" value={m[k] ?? ""} onChange={(e) => setModel(i, { [k]: num(e.target.value) })} />
|
||||
{k === "cache_write_price" && priceExtras(m) && (
|
||||
<span className="chip price-extra" title={priceExtras(m)}>
|
||||
+{(m.price_tiers?.length ?? 0) + (m.price_multipliers?.length ?? 0)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
<td>
|
||||
@@ -974,6 +1014,56 @@ function Permissions({ cfg, reload }: { cfg: ConfigResponse; reload: () => void
|
||||
|
||||
// --- raw ----------------------------------------------------------------------
|
||||
|
||||
/** ${VAR} references left in the config: move their values from the environment into it. */
|
||||
function EnvRefs({ onDone }: { onDone: () => void }) {
|
||||
const t = useT();
|
||||
const { data, reload } = useFetch<{ set: string[]; unset: string[] }>("/config/inline-env");
|
||||
const [busy, setBusy] = useState(false);
|
||||
if (!data || (data.set.length === 0 && data.unset.length === 0)) return null;
|
||||
const apply = async () => {
|
||||
if (!(await confirmDialog({ title: t("settings.envTitle"), message: t("settings.envConfirm", { names: data.set.join(", ") }), confirmLabel: t("settings.envApply") }))) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await post<{ set: string[] }>("/config/inline-env");
|
||||
toast(t("settings.envDone", { n: r.set.length }), "success");
|
||||
reload();
|
||||
onDone();
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="panel" style={{ marginBottom: 12 }}>
|
||||
<h3>
|
||||
<Icon name="file-text" /> {t("settings.envTitle")}
|
||||
</h3>
|
||||
<div className="hint" style={{ marginBottom: 8 }}>
|
||||
{t("settings.envHint")}
|
||||
</div>
|
||||
{data.set.length > 0 && (
|
||||
<div className="row" style={{ gap: 8, flexWrap: "wrap", alignItems: "center" }}>
|
||||
{data.set.map((n) => (
|
||||
<span key={n} className="chip mono">
|
||||
{n}
|
||||
</span>
|
||||
))}
|
||||
<span className="spacer" />
|
||||
<button className="primary small" onClick={apply} disabled={busy}>
|
||||
{t("settings.envApply")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{data.unset.length > 0 && (
|
||||
<div className="hint" style={{ marginTop: 8 }}>
|
||||
{t("settings.envUnset")} <span className="mono">{data.unset.join(", ")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Raw({ reload }: { reload: () => void }) {
|
||||
const t = useT();
|
||||
const [text, setText] = useState<string>();
|
||||
@@ -984,6 +1074,7 @@ function Raw({ reload }: { reload: () => void }) {
|
||||
if (text === undefined) return <div className="empty">{t("common.loading")}</div>;
|
||||
return (
|
||||
<>
|
||||
<EnvRefs onDone={() => get<{ text: string }>("/config/raw").then((r) => setText(r.text))} />
|
||||
<div className="notice">{t("settings.rawHint")}</div>
|
||||
<textarea className="raw-editor" spellCheck={false} value={text} onChange={(e) => setText(e.target.value)} />
|
||||
{view}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { get, post } from "../api";
|
||||
import { useT, type Key } from "../i18n";
|
||||
import { fmtPercent, useT, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import { Icon } from "./Icon";
|
||||
import type { Plan } from "./Budget";
|
||||
|
||||
type Level = "ok" | "warn" | "err" | "off";
|
||||
|
||||
@@ -13,6 +14,7 @@ interface ProviderState {
|
||||
checked_at?: number;
|
||||
error?: string | null;
|
||||
models: number;
|
||||
plan?: Plan | null;
|
||||
}
|
||||
interface McpEntry {
|
||||
state: "connected" | "connecting" | "failed";
|
||||
@@ -38,7 +40,16 @@ function combine(levels: Level[]): Level {
|
||||
return "err";
|
||||
}
|
||||
|
||||
const providerLevel = (p: ProviderState): Level => (p.state === "ok" ? "ok" : p.state === "unknown" ? "warn" : "err");
|
||||
/** The fullest window of a subscription plan (5 hours, week, month). */
|
||||
const fullest = (plan?: Plan | null) => plan?.windows.reduce((a, b) => (b.ratio > a.ratio ? b : a));
|
||||
|
||||
const providerLevel = (p: ProviderState): Level => {
|
||||
const health: Level = p.state === "ok" ? "ok" : p.state === "unknown" ? "warn" : "err";
|
||||
const w = fullest(p.plan);
|
||||
// A used-up plan blocks requests just like a rejected key.
|
||||
const plan: Level = !w ? "ok" : w.ratio >= 1 ? "err" : w.ratio >= 0.8 ? "warn" : "ok";
|
||||
return health === "err" || plan === "err" ? "err" : health === "warn" || plan === "warn" ? "warn" : "ok";
|
||||
};
|
||||
const mcpLevel = (m: McpEntry): Level => (m.state === "connected" ? "ok" : m.state === "connecting" ? "warn" : "err");
|
||||
|
||||
function Led({ level, title }: { level: Level; title?: string }) {
|
||||
@@ -164,7 +175,10 @@ export function StatusLeds() {
|
||||
<div key={name} className="status-row" title={p.error ?? undefined}>
|
||||
<Led level={providerLevel(p)} />
|
||||
<span className="grow mono">{name}</span>
|
||||
<span className="hint">{providerText(p)}</span>
|
||||
<span className="hint">
|
||||
{providerText(p)}
|
||||
{fullest(p.plan) && ` · ${t("status.plan", { pct: fmtPercent(fullest(p.plan)!.ratio), window: t(`budget.window.${fullest(p.plan)!.window}` as Key) })}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user