feat: per-tool settings, persistent settings file, Codex and Antigravity sessions, tokens and hooks (#40 B, C1, C2, D)
Part B: Model Pricing, Hook Configuration and CLAUDE_HOME move from Settings → Dashboard to the Claude Code tab's AgentHub sub-tab (ClaudeAgentHubSettings.tsx); CODEX_HOME is editable in Codex → AgentHub. A home that is or contains the OS home is refused (EBADHOME). Settings persistence: dashboard-saved settings go to ~/.config/agenthub/settings.env (AGENTHUB_SETTINGS_FILE), outside the plugin cache an update wipes. Load order: real env > the checkout's .env > that file. The first CLAUDE_HOME change pins DASHBOARD_DATA_DIR so the DB stays put. C1: codex-usage.js reads Codex rollout journals into sessions (new column tool) and token_usage, tracked per file in codex_rollouts; forks deduped by total vector, sub-agent threads folded into the root; pricing-sync also prices OpenAI models; PricingSettings with a vendor filter. C2: codex-hooks.js installs AgentHub's hooks into $CODEX_HOME/hooks.json on a click, through a stable forwarder; Codex trust is never written. The hook route creates tool='codex' sessions and folds sub-agent hooks into the root. D: antigravity-usage.js reads agy's per-conversation SQLite files (tokens from ModelUsageStats) into tool='antigravity' sessions; antigravity-hooks.js installs the named hook `agenthub` (PreInvocation, PostToolUse, Stop — never PreToolUse) into ~/.gemini/config/hooks.json; Settings → Antigravity tab; Gemini pricing. Whole-change review fixes: sameOriginGuard on both home PUTs, routers.missing on a CLAUDE_HOME change, reset-pricing scope, GET /api/sessions?tool=, clear-data covering the new tables, Codex child-thread events moving no status, hooks owning status only while current, EFOREIGN for a forwarder that is not ours, agy quiet window and turn rules, AGENTHUB_SETTINGS_FILE honoured from .env. Docs, OpenAPI and the CLAUDE.md marker updated. Server 2141, client 779.
This commit is contained in:
+96
-17
@@ -1,14 +1,24 @@
|
||||
/**
|
||||
* @file Automatic pricing for Claude models the dashboard has seen but has no
|
||||
* rate row of its own for. Every model id in `token_usage` is reduced to its
|
||||
* family (a `-YYYYMMDD` snapshot suffix dropped); a family with no
|
||||
* @file Automatic pricing for Claude, OpenAI (a Codex session's — see
|
||||
* codex-usage.js) and Gemini (an Antigravity session's — antigravity-usage.js)
|
||||
* models the dashboard has seen but has no rate row of its own
|
||||
* for. Every model id in `token_usage` is reduced to its family (a
|
||||
* `-YYYYMMDD` / `-YYYY-MM-DD` snapshot suffix dropped); a family with no
|
||||
* `<family>%` row is looked up in LiteLLM's public price list and INSERTED —
|
||||
* never updated, so a rate the user edited in Settings always wins. "Its own
|
||||
* row" is the test, not "some row matches": Opus 5.5 was silently priced as
|
||||
* Opus 5 by the broader `claude-opus-5%` pattern.
|
||||
*
|
||||
* The list is fetched only when something is missing, at most once per
|
||||
* CACHE_MS. Off with AGENTHUB_PRICING_SYNC=0|false|off.
|
||||
* CACHE_MS. Off with AGENTHUB_PRICING_SYNC=0|false|off. An entry is taken
|
||||
* only from its own vendor (`anthropic` for claude-…, `openai` for gpt-…,
|
||||
* o<n>…, codex-…, Vertex AI or the Gemini API for gemini-…); a router's model id (`clinepass/…`, `Highest`) is left to
|
||||
* the user.
|
||||
*
|
||||
* ponytail: OpenAI's long-context (>272k) and priority/flex tiers are not
|
||||
* modelled — Codex's context window stays under 272k and runs on the default
|
||||
* tier; add a service_tier bucket if that changes. Gemini's >200k-token rate
|
||||
* is not modelled either (the base rate is used).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -20,38 +30,78 @@ const CACHE_MS = 24 * 60 * 60_000;
|
||||
const MAX_PER_MTOK = 1000;
|
||||
|
||||
let cached = null; // { at, list }
|
||||
let failedAt = -Infinity; // last failed fetch
|
||||
|
||||
/** `claude-opus-5-5-20260101` → `claude-opus-5-5`; null for anything else. */
|
||||
const OPENAI = /^(gpt-|o\d|codex-)[a-z0-9.-]*$/;
|
||||
const GEMINI = /^gemini-[a-z0-9.-]+$/;
|
||||
const PROVIDERS = {
|
||||
claude: ["anthropic"],
|
||||
gemini: ["vertex_ai-language-models", "gemini"],
|
||||
openai: ["openai"],
|
||||
};
|
||||
const vendorOf = (fam) =>
|
||||
fam.startsWith("claude-") ? "claude" : fam.startsWith("gemini-") ? "gemini" : "openai";
|
||||
|
||||
/**
|
||||
* `claude-opus-5-5-20260101` → `claude-opus-5-5`, `gpt-5.4-2026-03-05` →
|
||||
* `gpt-5.4`; null for anything else.
|
||||
*/
|
||||
function familyOf(model) {
|
||||
if (typeof model !== "string") return null;
|
||||
const fam = model.replace(/-\d{8}$/, "");
|
||||
return /^claude-[a-z0-9-]+$/.test(fam) ? fam : null;
|
||||
if (model.startsWith("claude-")) {
|
||||
const fam = model.replace(/-\d{8}$/, "");
|
||||
return /^claude-[a-z0-9-]+$/.test(fam) ? fam : null;
|
||||
}
|
||||
if (model.startsWith("gemini-")) return GEMINI.test(model) ? model : null;
|
||||
const fam = model.replace(/-\d{4}-\d{2}-\d{2}$/, "");
|
||||
return OPENAI.test(fam) ? fam : null;
|
||||
}
|
||||
|
||||
/** `claude-opus-5-5` → `Claude Opus 5.5`. */
|
||||
const cap = (w) => w[0].toUpperCase() + w.slice(1);
|
||||
|
||||
/**
|
||||
* `claude-opus-5-5` → `Claude Opus 5.5`, `gpt-5.6-sol` → `GPT-5.6 Sol`,
|
||||
* `gemini-3.8-flash` → `Gemini 3.8 Flash`.
|
||||
*/
|
||||
function displayName(fam) {
|
||||
if (fam.startsWith("gemini-")) return fam.split("-").map(cap).join(" ");
|
||||
if (!fam.startsWith("claude-")) {
|
||||
const [head, ...rest] = fam.split("-");
|
||||
if (head === "gpt" && rest.length) {
|
||||
const [ver, ...words] = rest;
|
||||
return [`GPT-${ver}`, ...words.map(cap)].join(" ");
|
||||
}
|
||||
return [head === "codex" ? "Codex" : head, ...rest.map(cap)].join(" ");
|
||||
}
|
||||
const [, ...parts] = fam.split("-");
|
||||
const words = [];
|
||||
const nums = [];
|
||||
for (const p of parts) (/^\d+$/.test(p) ? nums : words).push(p);
|
||||
const name = words.map((w) => w[0].toUpperCase() + w.slice(1)).join(" ");
|
||||
const name = words.map(cap).join(" ");
|
||||
return `Claude ${name}${nums.length ? " " + nums.join(".") : ""}`;
|
||||
}
|
||||
|
||||
/** One LiteLLM entry → a model_pricing row, or null when it is not usable. */
|
||||
function toRow(fam, e) {
|
||||
if (!e || e.litellm_provider !== "anthropic") return null;
|
||||
const claude = fam.startsWith("claude-");
|
||||
if (!e || !PROVIDERS[vendorOf(fam)].includes(e.litellm_provider)) return null;
|
||||
const per = (v) => (typeof v === "number" ? v * 1e6 : NaN);
|
||||
const input = per(e.input_cost_per_token);
|
||||
const output = per(e.output_cost_per_token);
|
||||
// Anthropic's published multipliers stand in for a field the list omits.
|
||||
// Published multipliers stand in for a field the list omits: Anthropic
|
||||
// charges cache writes (1.25x / 2x), OpenAI does not.
|
||||
const read = per(e.cache_read_input_token_cost ?? e.input_cost_per_token * 0.1);
|
||||
const w5m = per(e.cache_creation_input_token_cost ?? e.input_cost_per_token * 1.25);
|
||||
const w1h = per(e.cache_creation_input_token_cost_above_1hr ?? e.input_cost_per_token * 2);
|
||||
const w5m = per(
|
||||
e.cache_creation_input_token_cost ?? (claude ? e.input_cost_per_token * 1.25 : 0)
|
||||
);
|
||||
const w1h = per(
|
||||
e.cache_creation_input_token_cost_above_1hr ??
|
||||
(claude ? e.input_cost_per_token * 2 : (e.cache_creation_input_token_cost ?? 0))
|
||||
);
|
||||
const rates = [input, output, read, w5m, w1h];
|
||||
if (!rates.every((r) => Number.isFinite(r) && r >= 0 && r <= MAX_PER_MTOK)) return null;
|
||||
if (input === 0 && output === 0) return null;
|
||||
const fast = e.supports_fast_mode ? Number(e.provider_specific_entry?.fast) : 0;
|
||||
const fast = claude && e.supports_fast_mode ? Number(e.provider_specific_entry?.fast) : 0;
|
||||
const fastOk = Number.isFinite(fast) && fast > 0 && fast <= 10;
|
||||
// Rounded: 0.000004 * 1e6 is 3.9999999999999996, which Settings would show.
|
||||
const r = (n) => Math.round(n * 1e6) / 1e6;
|
||||
@@ -98,7 +148,17 @@ async function syncPricing(db, { fetchList = defaultFetch, now = Date.now() } =
|
||||
try {
|
||||
const missing = missingFamilies(db);
|
||||
if (missing.length === 0) return [];
|
||||
if (!cached || now - cached.at > CACHE_MS) cached = { at: now, list: await fetchList() };
|
||||
if (!cached || now - cached.at > CACHE_MS) {
|
||||
// A failed fetch is retried at the sync's own cadence, not on every
|
||||
// caller (the Codex poll asks every 15 s while a session is active).
|
||||
if (now - failedAt < CHECK_MS) return [];
|
||||
try {
|
||||
cached = { at: now, list: await fetchList() };
|
||||
} catch (err) {
|
||||
failedAt = now;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const insert = db.prepare(
|
||||
`INSERT OR IGNORE INTO model_pricing (model_pattern, display_name, input_per_mtok,
|
||||
output_per_mtok, cache_read_per_mtok, cache_write_per_mtok, cache_write_1h_per_mtok,
|
||||
@@ -129,14 +189,33 @@ async function syncPricing(db, { fetchList = defaultFetch, now = Date.now() } =
|
||||
}
|
||||
}
|
||||
|
||||
function syncEnabled() {
|
||||
return !/^(0|false|off)$/i.test(process.env.AGENTHUB_PRICING_SYNC || "");
|
||||
}
|
||||
|
||||
function startPricingSync(db) {
|
||||
if (/^(0|false|off)$/i.test(process.env.AGENTHUB_PRICING_SYNC || "")) return;
|
||||
if (!syncEnabled()) return;
|
||||
void syncPricing(db);
|
||||
setInterval(() => void syncPricing(db), CHECK_MS).unref();
|
||||
}
|
||||
|
||||
function _resetCache() {
|
||||
cached = null;
|
||||
failedAt = -Infinity;
|
||||
}
|
||||
|
||||
module.exports = { syncPricing, startPricingSync, familyOf, displayName, toRow, _resetCache };
|
||||
/** Test seam: pretend the price list was just fetched. */
|
||||
function _setCache(list, at = Date.now()) {
|
||||
cached = { at, list };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
syncPricing,
|
||||
syncEnabled,
|
||||
startPricingSync,
|
||||
familyOf,
|
||||
displayName,
|
||||
toRow,
|
||||
_resetCache,
|
||||
_setCache,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user