mouaif 0.3.0
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/README.md +140 -0
- package/bin/mouaif.js +281 -0
- package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
- package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
- package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
- package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
- package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
- package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
- package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
- package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
- package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
- package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
- package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
- package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
- package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
- package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
- package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
- package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
- package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
- package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
- package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
- package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
- package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
- package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
- package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
- package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
- package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
- package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
- package/frontend/dist/assets/index-CANPYzQg.css +1 -0
- package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
- package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
- package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
- package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
- package/frontend/dist/icons/favicon-32.png +0 -0
- package/frontend/dist/icons/icon-180-apple.png +0 -0
- package/frontend/dist/icons/icon-192.png +0 -0
- package/frontend/dist/icons/icon-512.png +0 -0
- package/frontend/dist/icons/icon-maskable-512.png +0 -0
- package/frontend/dist/index.html +83 -0
- package/frontend/dist/manifest.webmanifest +33 -0
- package/frontend/dist/sw.js +482 -0
- package/package.json +98 -0
- package/scripts/patch-zimmerframe.js +58 -0
- package/src/access-auth.js +515 -0
- package/src/agentFeatures.js +294 -0
- package/src/agentFiles.js +164 -0
- package/src/agentSkills.js +147 -0
- package/src/agents.js +230 -0
- package/src/ai-chat.js +21 -0
- package/src/ai-endpoints.js +1880 -0
- package/src/ai-stream.js +2048 -0
- package/src/ai.js +68 -0
- package/src/auth.js +391 -0
- package/src/chatdb.js +816 -0
- package/src/chats.js +275 -0
- package/src/custom-actions.js +65 -0
- package/src/files.js +431 -0
- package/src/hideFileContent.js +327 -0
- package/src/http-server.js +535 -0
- package/src/index.js +15 -0
- package/src/inspector.js +731 -0
- package/src/inspectorProfiles.js +503 -0
- package/src/live-chat.js +107 -0
- package/src/mcp.js +1517 -0
- package/src/messages.js +238 -0
- package/src/modelList.js +137 -0
- package/src/notifications.js +52 -0
- package/src/oauth-anthropic.js +280 -0
- package/src/oauth-github-copilot.js +417 -0
- package/src/oauth-mcp.js +216 -0
- package/src/oauth-openrouter.js +285 -0
- package/src/package-version.js +20 -0
- package/src/projects.js +285 -0
- package/src/promptProfiles.js +256 -0
- package/src/prompts.js +384 -0
- package/src/providerShapes.js +44 -0
- package/src/providers/base.js +41 -0
- package/src/providers/index.js +25 -0
- package/src/push.js +315 -0
- package/src/qr.js +192 -0
- package/src/restart.js +47 -0
- package/src/server-handlers-access.js +306 -0
- package/src/server-handlers-actions.js +100 -0
- package/src/server-handlers-ai.js +248 -0
- package/src/server-handlers-auth.js +273 -0
- package/src/server-handlers-chats.js +1436 -0
- package/src/server-handlers-git.js +467 -0
- package/src/server-handlers-mcp-oauth.js +56 -0
- package/src/server-handlers-misc.js +783 -0
- package/src/server-handlers-projects.js +289 -0
- package/src/server-handlers-prompts.js +259 -0
- package/src/server-handlers-push.js +102 -0
- package/src/server-handlers-settings.js +406 -0
- package/src/server-handlers-tools.js +654 -0
- package/src/server-handlers-transcribe.js +399 -0
- package/src/server-shared.js +780 -0
- package/src/server-web-static.js +191 -0
- package/src/settings.js +898 -0
- package/src/statusBar.js +541 -0
- package/src/tags.js +414 -0
- package/src/toolFeedback.js +225 -0
- package/src/tools/ask.js +154 -0
- package/src/tools/authorization.js +932 -0
- package/src/tools/files.js +1150 -0
- package/src/tools/progress.js +71 -0
- package/src/tools/restart.js +32 -0
- package/src/tools/searchEngine.js +957 -0
- package/src/tools/shell.js +341 -0
- package/src/tools/subagent.js +47 -0
- package/src/tools/task.js +234 -0
- package/src/tools/webpreview.js +448 -0
- package/src/trace.js +103 -0
- package/src/transcribe.js +683 -0
- package/src/usage.js +389 -0
- package/src/util.js +151 -0
package/src/usage.js
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Usage metrics — cost and live token speed for chat turns.
|
|
4
|
+
//
|
|
5
|
+
// Decision: docs/decisions.md §14. The AI client (src/ai.js) already
|
|
6
|
+
// emits `usage_input`, `usage_output`, and `done({ usage })` events on
|
|
7
|
+
// every chat. This module is a pure derivation layer: it consumes those
|
|
8
|
+
// numbers, the model record, and the resolved app-level pricing table
|
|
9
|
+
// and produces:
|
|
10
|
+
//
|
|
11
|
+
// - cost: { input, output, total, currency, known }
|
|
12
|
+
// - per-turn rate: tokens per second over the streaming window
|
|
13
|
+
// - formatted strings ready to drop into the chat UI
|
|
14
|
+
//
|
|
15
|
+
// No new SSE events, no new REST surface, no new runtime dependencies.
|
|
16
|
+
// The chat UI (frontend/src/components/chat/Chat.jsx) imports formatCost /
|
|
17
|
+
// formatTokPerSecond; the Settings UI lets the user edit the
|
|
18
|
+
// app-level pricing table through the existing /api/settings/app
|
|
19
|
+
// surface.
|
|
20
|
+
|
|
21
|
+
// ---- Built-in pricing table -------------------------------------------
|
|
22
|
+
//
|
|
23
|
+
// Best-effort defaults for model ids that the providers ship today.
|
|
24
|
+
// Adding a new model id is a one-line edit. The cost line falls back
|
|
25
|
+
// to `--` when the id is unknown; we deliberately do not hallucinate
|
|
26
|
+
// a price. The values are in USD per 1 000 tokens (input / output).
|
|
27
|
+
//
|
|
28
|
+
// Sourced from each provider's public pricing page at the time of
|
|
29
|
+
// this commit. They are best-effort and a future revision may add
|
|
30
|
+
// a "last verified" stamp.
|
|
31
|
+
const BUILTIN_PRICING = Object.freeze({
|
|
32
|
+
// OpenAI (https://openai.com/api/pricing/, as of mid-2026)
|
|
33
|
+
'gpt-4o': { inputPer1K: 0.00250, outputPer1K: 0.01000 },
|
|
34
|
+
'gpt-4o-mini': { inputPer1K: 0.00015, outputPer1K: 0.00060 },
|
|
35
|
+
'gpt-4.1': { inputPer1K: 0.00200, outputPer1K: 0.00800 },
|
|
36
|
+
'gpt-4.1-mini': { inputPer1K: 0.00040, outputPer1K: 0.00160 },
|
|
37
|
+
'gpt-4.1-nano': { inputPer1K: 0.00010, outputPer1K: 0.00040 },
|
|
38
|
+
'o3': { inputPer1K: 0.01000, outputPer1K: 0.04000 },
|
|
39
|
+
'o3-mini': { inputPer1K: 0.00110, outputPer1K: 0.00440 },
|
|
40
|
+
'o4-mini': { inputPer1K: 0.00110, outputPer1K: 0.00440 },
|
|
41
|
+
'gpt-5': { inputPer1K: 0.00125, outputPer1K: 0.01000 },
|
|
42
|
+
'gpt-5-mini': { inputPer1K: 0.00025, outputPer1K: 0.00200 },
|
|
43
|
+
'gpt-5-nano': { inputPer1K: 0.00005, outputPer1K: 0.00040 },
|
|
44
|
+
// Anthropic (https://www.anthropic.com/pricing)
|
|
45
|
+
'claude-3-5-sonnet-latest':{ inputPer1K: 0.00300, outputPer1K: 0.01500 },
|
|
46
|
+
'claude-3-5-haiku-latest': { inputPer1K: 0.00080, outputPer1K: 0.00400 },
|
|
47
|
+
'claude-3-opus-latest': { inputPer1K: 0.01500, outputPer1K: 0.07500 },
|
|
48
|
+
// The dated 3.5 snapshots are the same models as the `-latest` aliases
|
|
49
|
+
// above at the same list price — they ship in ANTHROPIC_MODEL_CATALOG, so
|
|
50
|
+
// leaving them out meant a cost line that read `--` for a model we could
|
|
51
|
+
// price exactly.
|
|
52
|
+
'claude-3-5-sonnet-20241022': { inputPer1K: 0.00300, outputPer1K: 0.01500 },
|
|
53
|
+
'claude-3-5-haiku-20241022': { inputPer1K: 0.00080, outputPer1K: 0.00400 },
|
|
54
|
+
'claude-sonnet-4': { inputPer1K: 0.00300, outputPer1K: 0.01500 },
|
|
55
|
+
'claude-sonnet-4-5': { inputPer1K: 0.00300, outputPer1K: 0.01500 },
|
|
56
|
+
'claude-sonnet-4.5': { inputPer1K: 0.00300, outputPer1K: 0.01500 },
|
|
57
|
+
// Newer generation. Verified against the live vendor page
|
|
58
|
+
// (https://www.anthropic.com/pricing), not just the catalog fixture — the
|
|
59
|
+
// fixture had Haiku 4.5 at $0.8/$4, which is Haiku *3.5*'s rate and had
|
|
60
|
+
// been wrong here since this table was written. See
|
|
61
|
+
// scripts/test-model-pricing-coverage.js, which fails when a catalog id
|
|
62
|
+
// has no price here.
|
|
63
|
+
'claude-sonnet-5': { inputPer1K: 0.00200, outputPer1K: 0.01000 },
|
|
64
|
+
'claude-opus-4.5': { inputPer1K: 0.00500, outputPer1K: 0.02500 },
|
|
65
|
+
// Haiku 4.5 is $1/$5, not $0.8/$4 — the latter is Haiku 3.5 (the
|
|
66
|
+
// `claude-3-5-haiku-*` rows above), and the two were conflated here.
|
|
67
|
+
'claude-haiku-4-5': { inputPer1K: 0.00100, outputPer1K: 0.00500 },
|
|
68
|
+
'claude-haiku-4.5': { inputPer1K: 0.00100, outputPer1K: 0.00500 },
|
|
69
|
+
'claude-opus-4': { inputPer1K: 0.01500, outputPer1K: 0.07500 },
|
|
70
|
+
'claude-opus-4-1': { inputPer1K: 0.01500, outputPer1K: 0.07500 },
|
|
71
|
+
'claude-opus-4.1': { inputPer1K: 0.01500, outputPer1K: 0.07500 },
|
|
72
|
+
// Google Gemini (https://ai.google.dev/pricing)
|
|
73
|
+
'gemini-2.5-pro': { inputPer1K: 0.00125, outputPer1K: 0.01000 },
|
|
74
|
+
'gemini-2.5-flash': { inputPer1K: 0.00030, outputPer1K: 0.00250 },
|
|
75
|
+
'gemini-2.0-flash': { inputPer1K: 0.00010, outputPer1K: 0.00040 },
|
|
76
|
+
'gemini-1.5-pro': { inputPer1K: 0.00125, outputPer1K: 0.00500 },
|
|
77
|
+
'gemini-1.5-flash': { inputPer1K: 0.000075, outputPer1K: 0.00030 },
|
|
78
|
+
// Mistral (https://mistral.ai/pricing)
|
|
79
|
+
'mistral-large-latest': { inputPer1K: 0.00200, outputPer1K: 0.00600 },
|
|
80
|
+
'mistral-medium-latest': { inputPer1K: 0.00270, outputPer1K: 0.00810 },
|
|
81
|
+
'mistral-small-latest': { inputPer1K: 0.00020, outputPer1K: 0.00060 },
|
|
82
|
+
'codestral-latest': { inputPer1K: 0.00030, outputPer1K: 0.00090 },
|
|
83
|
+
'ministral-3b-latest': { inputPer1K: 0.00004, outputPer1K: 0.00004 },
|
|
84
|
+
'ministral-8b-latest': { inputPer1K: 0.00010, outputPer1K: 0.00010 },
|
|
85
|
+
'pixtral-large-latest': { inputPer1K: 0.00200, outputPer1K: 0.00600 },
|
|
86
|
+
// Groq (https://groq.com/pricing)
|
|
87
|
+
'llama-3.3-70b-versatile': { inputPer1K: 0.00059, outputPer1K: 0.00079 },
|
|
88
|
+
'llama-3.1-8b-instant': { inputPer1K: 0.00005, outputPer1K: 0.00008 },
|
|
89
|
+
'llama3-8b-8192': { inputPer1K: 0.00005, outputPer1K: 0.00008 },
|
|
90
|
+
'llama3-70b-8192': { inputPer1K: 0.00059, outputPer1K: 0.00079 },
|
|
91
|
+
'mixtral-8x7b-32768': { inputPer1K: 0.00024, outputPer1K: 0.00024 },
|
|
92
|
+
'gemma2-9b-it': { inputPer1K: 0.00020, outputPer1K: 0.00020 },
|
|
93
|
+
'qwen-qwq-32b': { inputPer1K: 0.00012, outputPer1K: 0.00018 },
|
|
94
|
+
// DeepSeek (https://api-docs.deepseek.com/quick_start/pricing)
|
|
95
|
+
'deepseek-chat': { inputPer1K: 0.00027, outputPer1K: 0.00110 },
|
|
96
|
+
'deepseek-reasoner': { inputPer1K: 0.00055, outputPer1K: 0.00219 },
|
|
97
|
+
'deepseek-coder': { inputPer1K: 0.00014, outputPer1K: 0.00028 }
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const DEFAULT_CURRENCY = 'USD';
|
|
101
|
+
|
|
102
|
+
// ---- Pricing resolution ----------------------------------------------
|
|
103
|
+
|
|
104
|
+
// resolvePricing(model, app) -> { inputPer1K, outputPer1K, source } | null
|
|
105
|
+
// Walks the resolution order documented in §14:
|
|
106
|
+
// 1. model.pricing (per-project, most specific)
|
|
107
|
+
// 2. app.modelPricing[modelId] (app-level override)
|
|
108
|
+
// 3. BUILTIN_PRICING[modelId] (best-effort defaults)
|
|
109
|
+
// 4. null (the UI renders `--`)
|
|
110
|
+
//
|
|
111
|
+
// `model` may be the project model record, the resolved model (with
|
|
112
|
+
// provider hydrated in), or just `{ id }` — only `id` and `pricing`
|
|
113
|
+
// are read.
|
|
114
|
+
// `app` is the app-level settings object (settings.getApp()).
|
|
115
|
+
function resolvePricing(model, app) {
|
|
116
|
+
if (!model || typeof model !== 'object' || !model.id) return null;
|
|
117
|
+
const id = model.id;
|
|
118
|
+
const fromModel = pickPricing(model.pricing);
|
|
119
|
+
if (fromModel) return Object.assign({ source: 'model' }, fromModel);
|
|
120
|
+
const appPricing = app && app.modelPricing;
|
|
121
|
+
if (appPricing && typeof appPricing === 'object') {
|
|
122
|
+
const fromApp = pickPricing(appPricing[id]);
|
|
123
|
+
if (fromApp) return Object.assign({ source: 'app' }, fromApp);
|
|
124
|
+
}
|
|
125
|
+
const fromBuiltIn = builtinPricingForId(id);
|
|
126
|
+
if (fromBuiltIn) return Object.assign({ source: 'builtin' }, fromBuiltIn);
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function builtinPricingForId(id) {
|
|
131
|
+
if (!id) return null;
|
|
132
|
+
if (BUILTIN_PRICING[id]) return BUILTIN_PRICING[id];
|
|
133
|
+
// OpenRouter model ids are vendor-prefixed (e.g. openai/gpt-5-mini,
|
|
134
|
+
// anthropic/claude-sonnet-4.5). Reuse the built-in vendor price when
|
|
135
|
+
// the suffix exactly matches a known native model id.
|
|
136
|
+
const slash = String(id).lastIndexOf('/');
|
|
137
|
+
if (slash >= 0) {
|
|
138
|
+
const suffix = String(id).slice(slash + 1);
|
|
139
|
+
if (BUILTIN_PRICING[suffix]) return BUILTIN_PRICING[suffix];
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function pickPricing(raw) {
|
|
145
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
146
|
+
const input = numberOrNull(raw.inputPer1K);
|
|
147
|
+
const output = numberOrNull(raw.outputPer1K);
|
|
148
|
+
if (input == null && output == null) return null;
|
|
149
|
+
const out = { inputPer1K: input || 0, outputPer1K: output || 0 };
|
|
150
|
+
// Optional per-model prompt-cache pricing factors. When present they
|
|
151
|
+
// override the standard 10% / 125% tiers for this model (see computeCost);
|
|
152
|
+
// when absent the defaults apply. Carried through the resolution chain
|
|
153
|
+
// so an app-level or model-level override can tune a single model.
|
|
154
|
+
const read = factorOrNull(raw.cacheReadFactor);
|
|
155
|
+
const write = factorOrNull(raw.cacheWriteFactor);
|
|
156
|
+
if (read != null) out.cacheReadFactor = read;
|
|
157
|
+
if (write != null) out.cacheWriteFactor = write;
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function numberOrNull(v) {
|
|
162
|
+
if (typeof v === 'number' && isFinite(v) && v >= 0) return v;
|
|
163
|
+
if (typeof v === 'string' && v.trim() !== '' && isFinite(Number(v)) && Number(v) >= 0) {
|
|
164
|
+
return Number(v);
|
|
165
|
+
}
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// factorOrNull(v) — same acceptance as numberOrNull but returns null for
|
|
170
|
+
// 0 / missing so pickPricing only carries factors the user actually set.
|
|
171
|
+
function factorOrNull(v) {
|
|
172
|
+
if (v == null || v === '') return null;
|
|
173
|
+
const n = numberOrNull(v);
|
|
174
|
+
return n == null || n === 0 ? null : n;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---- Cost calculation -------------------------------------------------
|
|
178
|
+
|
|
179
|
+
// Anthropic prompt-cache pricing factors (docs: https://docs.claude.com/en/docs/build-with-claude/prompt-caching#pricing).
|
|
180
|
+
// Cache reads are billed at 10% of the base input rate; cache writes
|
|
181
|
+
// (creating/refreshing a cache entry) at 125% of the base input rate.
|
|
182
|
+
// These are the DEFAULTS and are uniform across Claude models today, but
|
|
183
|
+
// a pricing record can override them per model (see resolvePricing:
|
|
184
|
+
// `pricing.cacheReadFactor` / `pricing.cacheWriteFactor`) because the
|
|
185
|
+
// provider has changed these rates before and could again, or a future
|
|
186
|
+
// model could ship its own tiers.
|
|
187
|
+
const DEFAULT_CACHE_READ_FACTOR = 0.10;
|
|
188
|
+
const DEFAULT_CACHE_WRITE_FACTOR = 1.25;
|
|
189
|
+
|
|
190
|
+
// computeCost({ model, usage, app }) -> { input, output, total, currency, known }
|
|
191
|
+
//
|
|
192
|
+
// `usage` is `{ promptTokens, completionTokens, cacheReadTokens?,
|
|
193
|
+
// cacheCreationTokens? }` — the shape on the `done` event from the AI
|
|
194
|
+
// client (decision §10), with the two Anthropic cache fields optional.
|
|
195
|
+
// Missing keys are treated as 0. `model` and `app` are passed straight
|
|
196
|
+
// through to resolvePricing. Returns a `known: false` object when no
|
|
197
|
+
// pricing is found so the UI can render `--` cleanly.
|
|
198
|
+
//
|
|
199
|
+
// Cost is computed at the provider's tiered rates: cached reads at
|
|
200
|
+
// 10% of input, cache writes at 125% of input, and everything else
|
|
201
|
+
// (uncached prompt + completions) at the base rates. The factors come
|
|
202
|
+
// from the resolved pricing record (per-model overrides) with the
|
|
203
|
+
// defaults above. The `input` bucket keeps the base uncached prompt cost
|
|
204
|
+
// so a `usage` block with no cache fields prices identically to before
|
|
205
|
+
// this feature.
|
|
206
|
+
function computeCost({ model, usage, app } = {}) {
|
|
207
|
+
const u = usage || {};
|
|
208
|
+
const promptTokens = num(u.promptTokens);
|
|
209
|
+
const completionTokens = num(u.completionTokens);
|
|
210
|
+
const cacheReadTokens = num(u.cacheReadTokens);
|
|
211
|
+
const cacheCreationTokens = num(u.cacheCreationTokens);
|
|
212
|
+
const pricing = resolvePricing(model, app);
|
|
213
|
+
if (!pricing) {
|
|
214
|
+
return {
|
|
215
|
+
input: 0,
|
|
216
|
+
output: 0,
|
|
217
|
+
total: 0,
|
|
218
|
+
currency: DEFAULT_CURRENCY,
|
|
219
|
+
known: false
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
const cacheReadFactor = factorOrDefault(pricing.cacheReadFactor, DEFAULT_CACHE_READ_FACTOR);
|
|
223
|
+
const cacheWriteFactor = factorOrDefault(pricing.cacheWriteFactor, DEFAULT_CACHE_WRITE_FACTOR);
|
|
224
|
+
// Cached reads/writes are a subset of the reported prompt tokens, so
|
|
225
|
+
// the uncached input portion is what remains after subtracting them
|
|
226
|
+
// (never below zero — a provider reporting odd numbers must not
|
|
227
|
+
// produce negative cost).
|
|
228
|
+
const cachedTokens = cacheReadTokens + cacheCreationTokens;
|
|
229
|
+
const uncachedInput = Math.max(0, promptTokens - cachedTokens);
|
|
230
|
+
const input = (uncachedInput / 1000) * pricing.inputPer1K
|
|
231
|
+
+ (cacheReadTokens / 1000) * pricing.inputPer1K * cacheReadFactor
|
|
232
|
+
+ (cacheCreationTokens / 1000) * pricing.inputPer1K * cacheWriteFactor;
|
|
233
|
+
const output = (completionTokens / 1000) * pricing.outputPer1K;
|
|
234
|
+
const total = input + output;
|
|
235
|
+
return {
|
|
236
|
+
input,
|
|
237
|
+
output,
|
|
238
|
+
total,
|
|
239
|
+
currency: DEFAULT_CURRENCY,
|
|
240
|
+
known: true
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// factorOrDefault(v, fallback) -> number
|
|
245
|
+
// A pricing factor must be a finite number >= 0 to be honored; anything
|
|
246
|
+
// else (absent, null, a bad value in a user-edited .mouaif.json) falls
|
|
247
|
+
// back to the standard factor.
|
|
248
|
+
function factorOrDefault(v, fallback) {
|
|
249
|
+
if (typeof v === 'number' && isFinite(v) && v >= 0) return v;
|
|
250
|
+
if (typeof v === 'string' && v.trim() !== '' && isFinite(Number(v)) && Number(v) >= 0) return Number(v);
|
|
251
|
+
return fallback;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function num(v) {
|
|
255
|
+
const n = Number(v);
|
|
256
|
+
return isFinite(n) && n >= 0 ? n : 0;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---- Formatting -------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
// formatCost(amount, { locale } = {}) -> string
|
|
262
|
+
//
|
|
263
|
+
// Format a USD amount with 2–5 fractional digits. Values below
|
|
264
|
+
// 0.000005 round to `$0.00000`; $0 renders as `$0.00` so the row is
|
|
265
|
+
// never empty. The decimal separator respects the user's locale; the
|
|
266
|
+
// currency symbol is always `$` because pricing is USD-only (per §14).
|
|
267
|
+
//
|
|
268
|
+
// We don't use `Intl.NumberFormat`'s `style: 'currency'` because
|
|
269
|
+
// some locales (e.g. en-US) emit `US$` for that formatter, which
|
|
270
|
+
// would break the iOS-style list aesthetic. The decimal separator
|
|
271
|
+
// is still locale-aware via `Intl.NumberFormat({ useGrouping })`
|
|
272
|
+
// without the currency style, then we prepend a literal `$`.
|
|
273
|
+
function formatCost(amount, opts) {
|
|
274
|
+
const a = Number(amount);
|
|
275
|
+
if (!isFinite(a) || a < 0) return '--';
|
|
276
|
+
if (a === 0) return '$0.00';
|
|
277
|
+
const locale = (opts && opts.locale) || defaultLocale();
|
|
278
|
+
const digits = a < 0.01 ? 5 : a < 1 ? 5 : 2;
|
|
279
|
+
// toLocaleString picks the locale's decimal separator. We use
|
|
280
|
+
// maximumFractionDigits: 5 to match the spec ("2–5 significant
|
|
281
|
+
// figures") while still letting `0.00012` read as `0.00012`
|
|
282
|
+
// instead of `0.0001`.
|
|
283
|
+
const body = new Intl.NumberFormat(locale, {
|
|
284
|
+
useGrouping: false,
|
|
285
|
+
minimumFractionDigits: 2,
|
|
286
|
+
maximumFractionDigits: digits
|
|
287
|
+
}).format(a);
|
|
288
|
+
return '$' + body;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// formatTokPerSecond(rate) -> string
|
|
292
|
+
//
|
|
293
|
+
// Returns "37 tok/s" or "12.3 tok/s" depending on the magnitude.
|
|
294
|
+
// Renders `--` when the rate is unknown (NaN / negative / null).
|
|
295
|
+
function formatTokPerSecond(rate) {
|
|
296
|
+
const r = Number(rate);
|
|
297
|
+
if (!isFinite(r) || r < 0) return '--';
|
|
298
|
+
if (r === 0) return '0 tok/s';
|
|
299
|
+
if (r >= 100) return Math.round(r) + ' tok/s';
|
|
300
|
+
if (r >= 10) return r.toFixed(1).replace(/\.0$/, '') + ' tok/s';
|
|
301
|
+
return r.toFixed(2).replace(/\.?0+$/, '') + ' tok/s';
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// formatTokens(n) -> string (e.g. 243 -> "243", 14830 -> "14.8K")
|
|
305
|
+
//
|
|
306
|
+
// Used by the per-turn meta line so large numbers don't push the row
|
|
307
|
+
// past 360 px. Matches the project's existing chat style.
|
|
308
|
+
function formatTokens(n) {
|
|
309
|
+
const v = Number(n);
|
|
310
|
+
if (!isFinite(v) || v < 0) return '--';
|
|
311
|
+
if (v < 1000) return String(Math.round(v));
|
|
312
|
+
if (v < 1000000) return (v / 1000).toFixed(v < 10000 ? 1 : 0) + 'K';
|
|
313
|
+
return (v / 1000000).toFixed(v < 10000000 ? 2 : 1) + 'M';
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function defaultLocale() {
|
|
317
|
+
try {
|
|
318
|
+
if (typeof Intl !== 'undefined' && Intl.DateTimeFormat) {
|
|
319
|
+
return Intl.DateTimeFormat().resolvedOptions().locale || 'en-US';
|
|
320
|
+
}
|
|
321
|
+
} catch { /* swallow */ }
|
|
322
|
+
return 'en-US';
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---- Live token-speed counter ----------------------------------------
|
|
326
|
+
|
|
327
|
+
// A tiny accumulator for the per-turn streaming window. The chat UI
|
|
328
|
+
// holds one of these per active turn; the SSE callbacks poke samples
|
|
329
|
+
// in as deltas arrive.
|
|
330
|
+
//
|
|
331
|
+
// const counter = createCounter();
|
|
332
|
+
// on('message', (delta) => counter.add(estimateDeltaTokens(delta)));
|
|
333
|
+
// on('done', () => finalizeTurn(counter));
|
|
334
|
+
//
|
|
335
|
+
// We don't ask the upstream for per-delta token counts (no provider
|
|
336
|
+
// emits them) so the per-delta estimate is character-based: every 4
|
|
337
|
+
// characters of text is ~1 token. This is intentionally rough — the
|
|
338
|
+
// spec calls the counter "informational", and the final rate uses
|
|
339
|
+
// the upstream's authoritative `completionTokens` from `done`.
|
|
340
|
+
//
|
|
341
|
+
// finalizeTokens: the real completion token count from the upstream
|
|
342
|
+
// (or null if the stream ended before the upstream reported usage).
|
|
343
|
+
// When provided, the rate is `completionTokens / streamingMs`; when
|
|
344
|
+
// absent, we fall back to the sum of delta estimates.
|
|
345
|
+
function createCounter() {
|
|
346
|
+
let startedAt = 0;
|
|
347
|
+
let lastAt = 0;
|
|
348
|
+
let estimatedTokens = 0;
|
|
349
|
+
return {
|
|
350
|
+
add(deltaText) {
|
|
351
|
+
const now = Date.now();
|
|
352
|
+
if (!startedAt) startedAt = now;
|
|
353
|
+
lastAt = now;
|
|
354
|
+
// Rough heuristic: ~4 chars per token. We could weight this by
|
|
355
|
+
// word boundaries, but the counter is purely client-side
|
|
356
|
+
// ("informational", per decision §14) and the final value is
|
|
357
|
+
// overridden by the upstream's `done` payload anyway.
|
|
358
|
+
const len = typeof deltaText === 'string' ? deltaText.length : 0;
|
|
359
|
+
if (len > 0) estimatedTokens += len / 4;
|
|
360
|
+
},
|
|
361
|
+
// Returns the current best-guess rate. `finalizeTokens` is the
|
|
362
|
+
// upstream-reported completion token count; when it is null the
|
|
363
|
+
// heuristic sum is used instead.
|
|
364
|
+
rate(finalizeTokens) {
|
|
365
|
+
if (!startedAt) return 0;
|
|
366
|
+
const ms = (lastAt || Date.now()) - startedAt;
|
|
367
|
+
if (ms <= 0) return 0;
|
|
368
|
+
const tokens = (typeof finalizeTokens === 'number' && isFinite(finalizeTokens))
|
|
369
|
+
? finalizeTokens
|
|
370
|
+
: estimatedTokens;
|
|
371
|
+
return (tokens / ms) * 1000;
|
|
372
|
+
},
|
|
373
|
+
// Resets the counter for a new turn. Called after `done` is
|
|
374
|
+
// received (or after the chat is re-entered) so the next user
|
|
375
|
+
// message starts from 0.
|
|
376
|
+
reset() { startedAt = 0; lastAt = 0; estimatedTokens = 0; }
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
module.exports = {
|
|
381
|
+
BUILTIN_PRICING,
|
|
382
|
+
builtinPricingForId,
|
|
383
|
+
resolvePricing,
|
|
384
|
+
computeCost,
|
|
385
|
+
formatCost,
|
|
386
|
+
formatTokPerSecond,
|
|
387
|
+
formatTokens,
|
|
388
|
+
createCounter
|
|
389
|
+
};
|
package/src/util.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Leaf utility helpers shared across the server. This module deliberately
|
|
4
|
+
// requires nothing else so any domain module can pull from it without
|
|
5
|
+
// creating a require cycle. Helpers that used to be copy-pasted into
|
|
6
|
+
// several files (err, joinUrl, the first-*string finders) live here so
|
|
7
|
+
// there is exactly one definition.
|
|
8
|
+
|
|
9
|
+
// err(code, message, extra) — a typed Error with a machine-readable
|
|
10
|
+
// `code`. `extra`, when given, is copied onto the error (path, size,
|
|
11
|
+
// maxBytes, ...). Shared by mcp.js, tags.js, files.js and the
|
|
12
|
+
// ai-endpoints error helpers, all of which previously had an identical
|
|
13
|
+
// private copy.
|
|
14
|
+
function err(code, message, extra) {
|
|
15
|
+
const e = new Error(message);
|
|
16
|
+
e.code = code;
|
|
17
|
+
if (extra) Object.assign(e, extra);
|
|
18
|
+
return e;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// joinUrl(base, path) — concatenate a base URL and a relative/absolute
|
|
22
|
+
// path without doubling or dropping the single separating slash.
|
|
23
|
+
// Previously duplicated in ai-endpoints.js and ai-stream.js.
|
|
24
|
+
function joinUrl(base, path) {
|
|
25
|
+
if (!base) return path;
|
|
26
|
+
if (base.endsWith('/') && path.startsWith('/')) return base + path.slice(1);
|
|
27
|
+
if (!base.endsWith('/') && !path.startsWith('/')) return base + '/' + path;
|
|
28
|
+
return base + path;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// firstStringField(obj, names) — return the first present string value
|
|
32
|
+
// under one of `names`, else ''. Used by the streaming parsers to pick
|
|
33
|
+
// a reasoning/thinking field out of a provider delta whose key varies
|
|
34
|
+
// (reasoning, reasoning_content, thinking, ...). Semantically distinct
|
|
35
|
+
// from firstStringValue below (this one checks named keys, in order).
|
|
36
|
+
function firstStringField(obj, names) {
|
|
37
|
+
if (!obj || typeof obj !== 'object') return '';
|
|
38
|
+
for (const name of names) {
|
|
39
|
+
if (typeof obj[name] === 'string') return obj[name];
|
|
40
|
+
}
|
|
41
|
+
return '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// firstStringValue(value) — return the first string found among an
|
|
45
|
+
// object's own values, else ''. Used to derive a human-readable
|
|
46
|
+
// summary from a tool-call arguments object whose exact shape is not
|
|
47
|
+
// known ahead of time.
|
|
48
|
+
function firstStringValue(value) {
|
|
49
|
+
if (!value || typeof value !== 'object') return '';
|
|
50
|
+
for (const item of Object.values(value)) if (typeof item === 'string') return item;
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// qs(q, name) — read a query-string value without the longhand
|
|
55
|
+
// `typeof q.x === 'string' ? q.x : ''` that was repeated ~40 times
|
|
56
|
+
// across the server handlers. Mirrors readJsonBody's string-only
|
|
57
|
+
// coercion: a non-string query value (e.g. ?a=1&a=2 arriving as an
|
|
58
|
+
// array) safely becomes ''.
|
|
59
|
+
function qs(q, name) {
|
|
60
|
+
return (q && typeof q[name] === 'string') ? q[name] : '';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// safeDecode(value) — decodeURIComponent that never throws. Every REST
|
|
64
|
+
// route that takes an id out of the path decodes the raw segment before
|
|
65
|
+
// handing it to a domain module; a malformed escape (`/api/chats/%zz`,
|
|
66
|
+
// `/api/projects/%zz/tags`, ...) used to raise a URIError from inside an
|
|
67
|
+
// async handler, which the HTTP dispatcher cannot catch: it surfaced as
|
|
68
|
+
// an unhandled rejection and, under Node's default
|
|
69
|
+
// `--unhandled-rejections=throw`, exited the process. Returning the raw
|
|
70
|
+
// segment on failure keeps the request a normal 400/404. Callers echo
|
|
71
|
+
// the value back through sendJSON, so nothing is interpreted as a path.
|
|
72
|
+
function safeDecode(value) {
|
|
73
|
+
if (typeof value !== 'string') return '';
|
|
74
|
+
try { return decodeURIComponent(value); } catch { return value; }
|
|
75
|
+
}
|
|
76
|
+
// projectModelRecord(record) — the only fields a project's `.mouaif.json`
|
|
77
|
+
// model entry may contribute to a request. Project models are identity and
|
|
78
|
+
// selection metadata only (decision §3): transport and credentials always
|
|
79
|
+
// come from the app-level provider connection. The project file is
|
|
80
|
+
// committed with the project, so a `baseUrl`, `apiKey`, `auth`, `headers`
|
|
81
|
+
// or `token` written there must never reach a request builder — it would
|
|
82
|
+
// point a global credential at an attacker-controlled endpoint.
|
|
83
|
+
//
|
|
84
|
+
// Allow-list, not deny-list: a field added later is ignored until it is
|
|
85
|
+
// listed here, instead of silently flowing through.
|
|
86
|
+
const PROJECT_MODEL_FIELDS = [
|
|
87
|
+
'id', 'provider', 'label', 'contextWindow', 'pricing',
|
|
88
|
+
'thinking', 'thinkingLevel', 'maxOutputTokens', 'maxTokens',
|
|
89
|
+
// Dictation. A transcription model carries its own descriptor — the request
|
|
90
|
+
// family, an optional non-conventional endpoint path, a language hint, a
|
|
91
|
+
// prompt, or the plain `true` that marks it as a dictation choice. Without
|
|
92
|
+
// it in this allow-list the field would be stripped before the request
|
|
93
|
+
// builder ever sees it, and every model would fall back to the inferred
|
|
94
|
+
// family.
|
|
95
|
+
'transcription',
|
|
96
|
+
// The request family the dictation catalog classified a live row under
|
|
97
|
+
// (`openai-audio` for a model that can hear but has no
|
|
98
|
+
// /audio/transcriptions entry). It rides the same path as `transcription`
|
|
99
|
+
// and is honoured by transcribe.kindForModel, so the picker's "Sends as"
|
|
100
|
+
// read-out is what the request does.
|
|
101
|
+
'kind'
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
function projectModelRecord(record) {
|
|
105
|
+
const out = {};
|
|
106
|
+
if (!record || typeof record !== 'object') return out;
|
|
107
|
+
for (const key of PROJECT_MODEL_FIELDS) {
|
|
108
|
+
if (record[key] !== undefined) out[key] = record[key];
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// errCodeToHttpStatus(code, def) — one source of truth for mapping the
|
|
114
|
+
// typed error codes the domain modules throw (files, tags, mcp, the
|
|
115
|
+
// project/file/tags/prompt/agent handlers) to HTTP status codes. The
|
|
116
|
+
// project and file handlers previously each hand-enumerated overlapping
|
|
117
|
+
// subsets of this map in their own <x>ErrorStatus switch; prompt/agents
|
|
118
|
+
// shared a smaller 422/400/500 trio. `def` is the fallback (projects/add
|
|
119
|
+
// default 400, the model-facing ones default 500).
|
|
120
|
+
const HTTP_STATUS_BY_CODE = {
|
|
121
|
+
EBADPATH: 400,
|
|
122
|
+
EBADINPUT: 400,
|
|
123
|
+
EOUTSIDE_PROJECT: 403,
|
|
124
|
+
EOUTSIDE_HOME: 403,
|
|
125
|
+
ENOENT: 404,
|
|
126
|
+
ENOTDIR: 400,
|
|
127
|
+
ENOTFILE: 400,
|
|
128
|
+
EISDIR: 400,
|
|
129
|
+
EACCES: 403,
|
|
130
|
+
EEXIST: 409,
|
|
131
|
+
EBINARY: 415,
|
|
132
|
+
ENOTIMAGE: 415,
|
|
133
|
+
ETOOLARGE: 413,
|
|
134
|
+
EREAD: 500,
|
|
135
|
+
MOUAIF_PROJECT_PARSE_ERROR: 422
|
|
136
|
+
};
|
|
137
|
+
function errCodeToHttpStatus(code, def) {
|
|
138
|
+
return HTTP_STATUS_BY_CODE[code] || (def === undefined ? 500 : def);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = {
|
|
142
|
+
err,
|
|
143
|
+
joinUrl,
|
|
144
|
+
qs,
|
|
145
|
+
safeDecode,
|
|
146
|
+
projectModelRecord,
|
|
147
|
+
PROJECT_MODEL_FIELDS,
|
|
148
|
+
firstStringField,
|
|
149
|
+
firstStringValue,
|
|
150
|
+
errCodeToHttpStatus
|
|
151
|
+
};
|