feat(lanes,run,pricing): Changes sidebar editor, staged groups, image drop, auto pricing
- Changes panel at full screen: the whole Files view (finder, expand-all, tree) becomes a left sidebar and the Files tab is dropped; a working-copy file opens straight into the editor (Save/Cancel live once the text differs, Cancel restores it, Whole file toggles back to the diff), and the page is height-locked so the editor no longer collapses with no live run. - Overview splits into Staged Changes / Changes groups in the work range. - A changed file's whole tree row takes its status tint. - Drag an image onto the lane terminal: POST /api/run/drop-image saves it (png/jpeg/gif/webp, random name, 0600, pruned after a day) and the path is pasted into the pane, which is how claude attaches it. - Claude Opus 5.5 gets its own pricing row (4/20/0.2/5/8, fast 8/40); claude-opus-5% had been pricing it as Opus 5. - server/lib/pricing-sync.js: a model family used in token_usage with no row of its own is inserted from LiteLLM's public price list, never updated; off with AGENTHUB_PRICING_SYNC=0. repo-links allows exactly that one third-party GitHub raw URL.
This commit is contained in:
@@ -324,3 +324,42 @@ describe("calculateCost — date-effective (intro) pricing", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DEFAULT_PRICING — Opus 5.5", () => {
|
||||
it("prices claude-opus-5-5 at its own rates, not Opus 5's", () => {
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
process.env.DASHBOARD_DB_PATH = path.join(
|
||||
os.tmpdir(),
|
||||
`pricing-opus55-${Date.now()}-${process.pid}.db`
|
||||
);
|
||||
const { DEFAULT_PRICING } = require("../db");
|
||||
const rules = DEFAULT_PRICING.map(([p, n, i, o, cr, cw, cw1h, fi, fo]) => ({
|
||||
model_pattern: p,
|
||||
display_name: n,
|
||||
input_per_mtok: i,
|
||||
output_per_mtok: o,
|
||||
cache_read_per_mtok: cr,
|
||||
cache_write_per_mtok: cw,
|
||||
cache_write_1h_per_mtok: cw1h,
|
||||
fast_input_per_mtok: fi,
|
||||
fast_output_per_mtok: fo,
|
||||
}));
|
||||
// A real session: Claude Code's /cost reported $2.72 for exactly these
|
||||
// counts (all cache writes at the 1h tier); Opus 5's rates gave $4.86.
|
||||
const r = calculateCost(
|
||||
[
|
||||
bucket({
|
||||
model: "claude-opus-5-5",
|
||||
input_tokens: 102,
|
||||
output_tokens: 25_408,
|
||||
cache_read_tokens: 5_866_977,
|
||||
cache_write_tokens: 129_432,
|
||||
cache_write_1h_tokens: 129_432,
|
||||
}),
|
||||
],
|
||||
rules
|
||||
);
|
||||
assert.equal(r.total_cost.toFixed(2), "2.72");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @file Tests for the automatic pricing sync: a model seen in token_usage with
|
||||
* no row of its own gets one from the price list, an edited row is never
|
||||
* overwritten, and a bad list or a failed fetch changes nothing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
|
||||
process.env.DASHBOARD_DB_PATH = path.join(
|
||||
os.tmpdir(),
|
||||
`pricing-sync-${Date.now()}-${process.pid}.db`
|
||||
);
|
||||
const { db } = require("../db");
|
||||
const { syncPricing, familyOf, displayName, _resetCache } = require("../lib/pricing-sync");
|
||||
|
||||
// The real LiteLLM entry for Opus 5.5, trimmed.
|
||||
const LIST = {
|
||||
"claude-opus-9-9": {
|
||||
litellm_provider: "anthropic",
|
||||
input_cost_per_token: 0.000004,
|
||||
output_cost_per_token: 0.00002,
|
||||
cache_read_input_token_cost: 2e-7,
|
||||
cache_creation_input_token_cost: 0.000005,
|
||||
cache_creation_input_token_cost_above_1hr: 0.000008,
|
||||
supports_fast_mode: true,
|
||||
provider_specific_entry: { fast: 2 },
|
||||
},
|
||||
};
|
||||
|
||||
function useModel(model) {
|
||||
db.prepare("INSERT OR IGNORE INTO sessions (id, status, started_at) VALUES (?, 'active', ?)").run(
|
||||
`s-${model}`,
|
||||
new Date().toISOString()
|
||||
);
|
||||
db.prepare("INSERT INTO token_usage (session_id, model) VALUES (?, ?)").run(`s-${model}`, model);
|
||||
}
|
||||
const row = (p) => db.prepare("SELECT * FROM model_pricing WHERE model_pattern = ?").get(p);
|
||||
|
||||
beforeEach(() => {
|
||||
_resetCache();
|
||||
db.prepare("DELETE FROM token_usage").run();
|
||||
db.prepare("DELETE FROM model_pricing WHERE model_pattern LIKE 'claude-opus-9%'").run();
|
||||
});
|
||||
|
||||
describe("pricing sync", () => {
|
||||
it("reduces an id to its family and names it", () => {
|
||||
assert.equal(familyOf("claude-opus-5-5-20260101"), "claude-opus-5-5");
|
||||
assert.equal(familyOf("gpt-4"), null);
|
||||
assert.equal(familyOf("claude-x%"), null);
|
||||
assert.equal(displayName("claude-opus-5-5"), "Claude Opus 5.5");
|
||||
});
|
||||
|
||||
it("adds a row for a used model that only a broader pattern matched", async () => {
|
||||
db.prepare(
|
||||
"INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok) VALUES ('claude-opus-9%', 'Broad', 5)"
|
||||
).run();
|
||||
useModel("claude-opus-9-9-20260101");
|
||||
const added = await syncPricing(db, { fetchList: async () => LIST });
|
||||
assert.deepEqual(added, ["claude-opus-9-9%"]);
|
||||
const r = row("claude-opus-9-9%");
|
||||
assert.equal(r.display_name, "Claude Opus 9.9");
|
||||
assert.deepEqual([r.input_per_mtok, r.output_per_mtok, r.cache_read_per_mtok], [4, 20, 0.2]);
|
||||
assert.deepEqual([r.cache_write_per_mtok, r.cache_write_1h_per_mtok], [5, 8]);
|
||||
assert.deepEqual([r.fast_input_per_mtok, r.fast_output_per_mtok], [8, 40]);
|
||||
});
|
||||
|
||||
it("never overwrites a row that exists, and fetches nothing when none is missing", async () => {
|
||||
db.prepare(
|
||||
"INSERT INTO model_pricing (model_pattern, display_name, input_per_mtok) VALUES ('claude-opus-9-9%', 'Mine', 1)"
|
||||
).run();
|
||||
useModel("claude-opus-9-9");
|
||||
let fetched = false;
|
||||
await syncPricing(db, {
|
||||
fetchList: async () => {
|
||||
fetched = true;
|
||||
return LIST;
|
||||
},
|
||||
});
|
||||
assert.equal(fetched, false);
|
||||
assert.equal(row("claude-opus-9-9%").input_per_mtok, 1);
|
||||
});
|
||||
|
||||
it("skips absurd rates and survives a failed fetch", async () => {
|
||||
useModel("claude-opus-9-9");
|
||||
const bad = { "claude-opus-9-9": { ...LIST["claude-opus-9-9"], input_cost_per_token: 5 } };
|
||||
assert.deepEqual(await syncPricing(db, { fetchList: async () => bad }), []);
|
||||
_resetCache();
|
||||
const failed = await syncPricing(db, {
|
||||
fetchList: async () => {
|
||||
throw new Error("offline");
|
||||
},
|
||||
});
|
||||
assert.deepEqual(failed, []);
|
||||
assert.equal(row("claude-opus-9-9%"), undefined);
|
||||
});
|
||||
});
|
||||
@@ -79,7 +79,9 @@ const RULES = [
|
||||
// a Gitea /raw/branch/<ref>/ URL instead.
|
||||
re: /raw\.githubusercontent\.com\/[\w-]+\/[\w.-]+/g,
|
||||
fix: "use a same-origin path, or a Gitea /raw/branch/<ref>/ URL",
|
||||
skip: [],
|
||||
// A third-party project that really is on GitHub: the public model price
|
||||
// list server/lib/pricing-sync.js reads.
|
||||
skip: [/raw\.githubusercontent\.com\/BerriAI\/litellm$/],
|
||||
},
|
||||
{
|
||||
name: "GitHub Pages host for a Smartgift site",
|
||||
|
||||
@@ -26,7 +26,11 @@ function fetchJson(p, opts = {}) {
|
||||
const url = new URL(p, BASE);
|
||||
const headers = { ...(opts.headers || {}) };
|
||||
let body;
|
||||
if (opts.body !== undefined) {
|
||||
if (opts.raw) {
|
||||
body = opts.raw.buf;
|
||||
headers["Content-Type"] = opts.raw.type;
|
||||
headers["Content-Length"] = body.length;
|
||||
} else if (opts.body !== undefined) {
|
||||
body = Buffer.from(JSON.stringify(opts.body));
|
||||
headers["Content-Type"] = "application/json";
|
||||
headers["Content-Length"] = body.length;
|
||||
@@ -203,3 +207,45 @@ describe("/api/run", () => {
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/run/drop-image", () => {
|
||||
let srv;
|
||||
before(async () => {
|
||||
srv = http.createServer(createApp());
|
||||
await new Promise((r) => srv.listen(0, r));
|
||||
BASE = `http://127.0.0.1:${srv.address().port}`;
|
||||
});
|
||||
after(() => new Promise((r) => srv.close(r)));
|
||||
|
||||
const PNG = Buffer.from("89504e470d0a1a0a0000", "hex");
|
||||
|
||||
it("saves the image under a random name, owner-only, and returns its path", async () => {
|
||||
const r = await fetchJson("/api/run/drop-image", {
|
||||
method: "POST",
|
||||
raw: { type: "image/png", buf: PNG },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.match(path.basename(r.body.path), /^[0-9a-f]{16}\.png$/);
|
||||
assert.ok(r.body.path.startsWith(os.tmpdir()));
|
||||
assert.deepEqual(fs.readFileSync(r.body.path), PNG);
|
||||
assert.equal(fs.statSync(r.body.path).mode & 0o777, 0o600);
|
||||
fs.unlinkSync(r.body.path);
|
||||
});
|
||||
|
||||
it("refuses anything that is not one of the image types", async () => {
|
||||
const r = await fetchJson("/api/run/drop-image", {
|
||||
method: "POST",
|
||||
raw: { type: "text/x-shellscript", buf: Buffer.from("rm -rf ~") },
|
||||
});
|
||||
assert.equal(r.status, 400);
|
||||
});
|
||||
|
||||
it("refuses a cross-origin browser request", async () => {
|
||||
const r = await fetchJson("/api/run/drop-image", {
|
||||
method: "POST",
|
||||
headers: { Origin: "https://evil.example" },
|
||||
raw: { type: "image/png", buf: PNG },
|
||||
});
|
||||
assert.equal(r.status, 403);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user