dsh-session-fee 0.0.0-stage → 0.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/PRICES.md +79 -0
- package/README.md +205 -2
- package/client.js +379 -0
- package/cordis.patch.yml +5 -0
- package/icon.svg +9 -0
- package/index.js +87 -0
- package/lib/catalog.js +228 -0
- package/lib/config.js +68 -0
- package/lib/pricing.js +405 -0
- package/lib/projection.js +295 -0
- package/lib/reload.js +68 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +64 -4
- package/prices.example.json +71 -0
- package/prices.json +213 -0
package/lib/catalog.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Catalog providers whose list prices are first-party USD prices. Chinese
|
|
7
|
+
* vendors are left out on purpose: their models should be priced in CNY from
|
|
8
|
+
* the price book, not from an international USD list.
|
|
9
|
+
*/
|
|
10
|
+
export const DEFAULT_CATALOG_PROVIDERS = Object.freeze([
|
|
11
|
+
"openai-codex",
|
|
12
|
+
"openai",
|
|
13
|
+
"anthropic",
|
|
14
|
+
"google-vertex",
|
|
15
|
+
"google",
|
|
16
|
+
"xai",
|
|
17
|
+
"mistral",
|
|
18
|
+
"github-copilot"
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/** DSH provider id → catalog providers searched before the general list. */
|
|
22
|
+
export const DEFAULT_PROVIDER_HINTS = Object.freeze({
|
|
23
|
+
codex: ["openai-codex", "openai"],
|
|
24
|
+
grok: ["xai"],
|
|
25
|
+
claude: ["anthropic"],
|
|
26
|
+
antigravity: ["google", "anthropic"],
|
|
27
|
+
copilot: ["github-copilot"]
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
function readJson(path) {
|
|
31
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function packageRootFrom(file, name) {
|
|
35
|
+
let directory = dirname(file);
|
|
36
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
37
|
+
const manifest = join(directory, "package.json");
|
|
38
|
+
if (existsSync(manifest)) {
|
|
39
|
+
try {
|
|
40
|
+
if (readJson(manifest).name === name) return directory;
|
|
41
|
+
} catch {
|
|
42
|
+
// keep walking
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const parent = dirname(directory);
|
|
46
|
+
if (parent === directory) break;
|
|
47
|
+
directory = parent;
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Find an installed package: the host resolver first, then node_modules above the host entry script. */
|
|
53
|
+
function locatePackage(name) {
|
|
54
|
+
try {
|
|
55
|
+
const root = packageRootFrom(fileURLToPath(import.meta.resolve(name)), name);
|
|
56
|
+
if (root !== null) return root;
|
|
57
|
+
} catch {
|
|
58
|
+
// not resolvable from this plugin; fall through
|
|
59
|
+
}
|
|
60
|
+
const entry = process.argv[1];
|
|
61
|
+
if (typeof entry === "string" && entry !== "") {
|
|
62
|
+
let directory = dirname(resolve(entry));
|
|
63
|
+
for (let depth = 0; depth < 12; depth += 1) {
|
|
64
|
+
const candidate = join(directory, "node_modules", ...name.split("/"));
|
|
65
|
+
if (existsSync(join(candidate, "package.json"))) return candidate;
|
|
66
|
+
const parent = dirname(directory);
|
|
67
|
+
if (parent === directory) break;
|
|
68
|
+
directory = parent;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function validCost(cost) {
|
|
75
|
+
if (typeof cost !== "object" || cost === null) return false;
|
|
76
|
+
for (const key of ["input", "output"]) if (typeof cost[key] !== "number" || !Number.isFinite(cost[key]) || cost[key] < 0) return false;
|
|
77
|
+
// An all-zero list price means "free tier / unknown", not a market price.
|
|
78
|
+
return cost.input > 0 || cost.output > 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function finite(value, fallback) {
|
|
82
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function toRule(label, cost) {
|
|
86
|
+
const base = {
|
|
87
|
+
input: cost.input,
|
|
88
|
+
output: cost.output,
|
|
89
|
+
cacheRead: finite(cost.cacheRead, 0),
|
|
90
|
+
cacheWrite: finite(cost.cacheWrite, 0)
|
|
91
|
+
};
|
|
92
|
+
const tiers = (Array.isArray(cost.tiers) ? cost.tiers : [])
|
|
93
|
+
.filter((tier) => Number.isInteger(tier?.inputTokensAbove) && tier.inputTokensAbove > 0)
|
|
94
|
+
.map((tier) => ({
|
|
95
|
+
above: tier.inputTokensAbove,
|
|
96
|
+
input: finite(tier.input, base.input),
|
|
97
|
+
output: finite(tier.output, base.output),
|
|
98
|
+
cacheRead: finite(tier.cacheRead, base.cacheRead),
|
|
99
|
+
cacheWrite: finite(tier.cacheWrite, base.cacheWrite)
|
|
100
|
+
}))
|
|
101
|
+
.sort((left, right) => left.above - right.above);
|
|
102
|
+
return { name: label, currency: "USD", unit: 1_000_000, ...base, tiers, when: null };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Candidate catalog ids for one DSH model id, most literal first. */
|
|
106
|
+
export function modelIdVariants(model) {
|
|
107
|
+
const variants = [];
|
|
108
|
+
const add = (value) => {
|
|
109
|
+
if (value !== "" && !variants.includes(value)) variants.push(value);
|
|
110
|
+
};
|
|
111
|
+
const lower = model.toLowerCase();
|
|
112
|
+
add(lower);
|
|
113
|
+
if (lower.includes("/")) add(lower.slice(lower.lastIndexOf("/") + 1));
|
|
114
|
+
for (const value of [...variants]) {
|
|
115
|
+
if (value.endsWith("-thinking")) add(value.slice(0, -"-thinking".length));
|
|
116
|
+
}
|
|
117
|
+
for (const value of [...variants]) {
|
|
118
|
+
// claude-sonnet-4.5 (Copilot style) ↔ claude-sonnet-4-5 (Anthropic style)
|
|
119
|
+
if (value.startsWith("claude-")) {
|
|
120
|
+
add(value.replace(/(\d)\.(\d)/g, "$1-$2"));
|
|
121
|
+
add(value.replace(/(\d)-(\d)(?!\d{3})/g, "$1.$2"));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return variants;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const HOST_PI_AI = "@earendil-works/pi-ai";
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build the fallback price index for routes the price book does not cover.
|
|
131
|
+
* Earlier layers win:
|
|
132
|
+
* 1. `sources`: pi-ai installs or package names you point to (newest first);
|
|
133
|
+
* 2. DSH's own pi-ai (`includeHost`).
|
|
134
|
+
* Missing layers are reported in `errors`, never thrown.
|
|
135
|
+
*/
|
|
136
|
+
export function loadCatalog({
|
|
137
|
+
sources = [],
|
|
138
|
+
includeHost = true,
|
|
139
|
+
providers = DEFAULT_CATALOG_PROVIDERS,
|
|
140
|
+
hints = DEFAULT_PROVIDER_HINTS,
|
|
141
|
+
baseDirectory = process.cwd()
|
|
142
|
+
}) {
|
|
143
|
+
const index = new Map(providers.map((provider) => [provider, new Map()]));
|
|
144
|
+
const loaded = [];
|
|
145
|
+
const errors = [];
|
|
146
|
+
const seenDirectories = new Set();
|
|
147
|
+
|
|
148
|
+
function add(provider, id, cost, version) {
|
|
149
|
+
const table = index.get(provider);
|
|
150
|
+
if (table === void 0 || typeof id !== "string" || !validCost(cost)) return false;
|
|
151
|
+
const key = id.toLowerCase();
|
|
152
|
+
if (table.has(key)) return false;
|
|
153
|
+
table.set(key, toRule(`pi-ai ${version} · ${provider}/${id}`, cost));
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function loadPackage(spec) {
|
|
158
|
+
const directory = isAbsolute(spec) || spec.startsWith(".") ? resolve(baseDirectory, spec) : locatePackage(spec);
|
|
159
|
+
if (directory === null || !existsSync(join(directory, "package.json"))) {
|
|
160
|
+
errors.push(`catalog source not found: ${spec}`);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (seenDirectories.has(directory)) return;
|
|
164
|
+
seenDirectories.add(directory);
|
|
165
|
+
let version = "?";
|
|
166
|
+
try {
|
|
167
|
+
version = readJson(join(directory, "package.json")).version ?? "?";
|
|
168
|
+
} catch (error) {
|
|
169
|
+
errors.push(`unreadable package.json in ${directory}: ${error.message}`);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const dataDirectory = join(directory, "dist", "providers", "data");
|
|
173
|
+
if (!existsSync(dataDirectory)) {
|
|
174
|
+
errors.push(`no dist/providers/data in ${directory}`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const available = new Set(readdirSync(dataDirectory).filter((name) => name.endsWith(".json")).map((name) => name.slice(0, -5)));
|
|
178
|
+
let models = 0;
|
|
179
|
+
for (const provider of providers) {
|
|
180
|
+
if (!available.has(provider)) continue;
|
|
181
|
+
let data;
|
|
182
|
+
try {
|
|
183
|
+
data = readJson(join(dataDirectory, `${provider}.json`));
|
|
184
|
+
} catch (error) {
|
|
185
|
+
errors.push(`unreadable ${provider}.json in ${directory}: ${error.message}`);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
for (const group of Object.values(data ?? {})) {
|
|
189
|
+
for (const [key, entry] of Object.entries(group ?? {})) {
|
|
190
|
+
if (!key.startsWith("chat:") || (entry?.type ?? "chat") !== "chat") continue;
|
|
191
|
+
if (add(provider, entry?.id, entry?.cost, version)) models += 1;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
loaded.push({ kind: "pi-ai", spec, directory, version, models });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
for (const spec of sources) loadPackage(spec);
|
|
199
|
+
if (includeHost) loadPackage(HOST_PI_AI);
|
|
200
|
+
|
|
201
|
+
function lookup(provider, model) {
|
|
202
|
+
const table = index.get(provider.toLowerCase());
|
|
203
|
+
if (table === void 0) return null;
|
|
204
|
+
for (const id of modelIdVariants(model)) {
|
|
205
|
+
const rule = table.get(id);
|
|
206
|
+
if (rule !== void 0) return { provider, rule };
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function find(dshProvider, model) {
|
|
212
|
+
const key = dshProvider.toLowerCase();
|
|
213
|
+
const order = [...(hints[key] ?? []), ...(index.has(key) ? [key] : []), ...providers];
|
|
214
|
+
for (const provider of new Set(order)) {
|
|
215
|
+
const hit = lookup(provider, model);
|
|
216
|
+
if (hit !== null) return hit;
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
sources: loaded,
|
|
223
|
+
errors,
|
|
224
|
+
fingerprint: JSON.stringify([loaded, providers, hints]),
|
|
225
|
+
lookup,
|
|
226
|
+
find
|
|
227
|
+
};
|
|
228
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { DEFAULT_CATALOG_PROVIDERS, DEFAULT_PROVIDER_HINTS } from "./catalog.js";
|
|
2
|
+
|
|
3
|
+
function plain(value) {
|
|
4
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function strings(value, path, fallback) {
|
|
8
|
+
if (value === void 0) return [...fallback];
|
|
9
|
+
if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string" || entry.trim() === "")) {
|
|
10
|
+
throw new Error(`${path} must be a list of non-empty strings`);
|
|
11
|
+
}
|
|
12
|
+
return value.map((entry) => entry.trim());
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function known(value, path, keys) {
|
|
16
|
+
for (const key of Object.keys(value)) if (!keys.includes(key)) throw new Error(`${path} has unknown key "${key}"`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Validate the plugin config and fill defaults.
|
|
21
|
+
* - priceBook: price-book path, relative to this package or absolute.
|
|
22
|
+
* - catalog: fallback USD list prices for models the price book lacks. Order:
|
|
23
|
+
* `sources` (pi-ai installs you point to), then DSH's own pi-ai when
|
|
24
|
+
* `includeHost` is on.
|
|
25
|
+
* - addReasoningToOutput: providers whose outputTokens exclude reasoningTokens.
|
|
26
|
+
*/
|
|
27
|
+
export function normalizeConfig(input) {
|
|
28
|
+
const raw = input ?? {};
|
|
29
|
+
if (!plain(raw)) throw new Error("config must be an object");
|
|
30
|
+
known(raw, "config", ["priceBook", "catalog", "addReasoningToOutput"]);
|
|
31
|
+
if (raw.priceBook !== void 0 && (typeof raw.priceBook !== "string" || raw.priceBook.trim() === "")) {
|
|
32
|
+
throw new Error("config.priceBook must be a non-empty string");
|
|
33
|
+
}
|
|
34
|
+
const catalog = raw.catalog ?? {};
|
|
35
|
+
if (!plain(catalog)) throw new Error("config.catalog must be an object");
|
|
36
|
+
known(catalog, "config.catalog", ["enabled", "sources", "includeHost", "providers", "hints"]);
|
|
37
|
+
if (catalog.enabled !== void 0 && typeof catalog.enabled !== "boolean") throw new Error("config.catalog.enabled must be true or false");
|
|
38
|
+
if (catalog.includeHost !== void 0 && typeof catalog.includeHost !== "boolean") throw new Error("config.catalog.includeHost must be true or false");
|
|
39
|
+
const hints = catalog.hints ?? DEFAULT_PROVIDER_HINTS;
|
|
40
|
+
if (!plain(hints)) throw new Error("config.catalog.hints must map provider ids to lists");
|
|
41
|
+
return {
|
|
42
|
+
priceBook: raw.priceBook?.trim() ?? "./prices.json",
|
|
43
|
+
catalog: {
|
|
44
|
+
enabled: catalog.enabled ?? true,
|
|
45
|
+
sources: strings(catalog.sources, "config.catalog.sources", []),
|
|
46
|
+
includeHost: catalog.includeHost ?? true,
|
|
47
|
+
providers: strings(catalog.providers, "config.catalog.providers", DEFAULT_CATALOG_PROVIDERS),
|
|
48
|
+
hints: Object.fromEntries(Object.entries(hints).map(([key, list]) => [key, strings(list, `config.catalog.hints.${key}`, [])]))
|
|
49
|
+
},
|
|
50
|
+
addReasoningToOutput: strings(raw.addReasoningToOutput, "config.addReasoningToOutput", ["antigravity"])
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Standard Schema (https://standardschema.dev) wrapper, the interface DSH's loader validates plugin config with. */
|
|
55
|
+
export const Config = {
|
|
56
|
+
"~standard": {
|
|
57
|
+
version: 1,
|
|
58
|
+
vendor: "dsh-session-cost",
|
|
59
|
+
validate(value) {
|
|
60
|
+
try {
|
|
61
|
+
return { value: normalizeConfig(value) };
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return { issues: [{ message: error.message }] };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
parse: normalizeConfig
|
|
68
|
+
};
|