diff --git a/.env.example b/.env.example index ec05fafd..6b3d228f 100644 --- a/.env.example +++ b/.env.example @@ -251,22 +251,23 @@ LS_PORT=42100 # not advance the success timestamp. # DEVIN_CONNECT_CATALOG_TTL_MS=300000 # -# --- Optional wire-tag decoders (frame-verified 2026-07-05; default off) --- +# --- Wire-tag decoders (calibrated fields have safe defaults) --- # actual_model_uid: report the concrete model that served a request (differs # from the requested selector for router models). Frame-verified at metadata #7.9. # DEVIN_CONNECT_ACTUAL_MODEL_TAG=9 # Native tool_call decode (repeated ChatToolCall). Off = prompt emulation owns # tool calls. Only set once calibrated against a paid tool-using capture. # DEVIN_CONNECT_TOOL_CALL_TAGS=outer=6,id=1,name=2,arguments_json=3 -# Billing / cache-usage decode (metadata #7 varints). DEFAULT: cache_read_tokens=5 +# Billing / cache-usage decode. Plain tags read metadata #7; ^N reads a top-level +# field. Numeric values support varint, fixed64/double, and fixed32/float. +# DEFAULT: cache_read_tokens=5,cache_write_tokens=4,committed_acu_cost=^22 # — CONFIRMED 2026-07-23 on a paid teams account (miss dumped {2,3,6}, hit # {2,3,5,6}; tag5=3840 + tag2=436 == 4276 == the miss request's prompt_tokens), -# so cached input shows up as prompt_tokens_details.cached_tokens instead of being -# billed as fresh in the dashboard (#220). Harmless on free accounts: the counter -# is zero there and protobuf omits zero-valued scalars, so nothing is decoded. -# Set this to override the map, or to `off` to decode nothing. Calibrate further -# tags (credit_cost / committed_*) on a PAID token with DEVIN_CONNECT_DEBUG_META=1. -# DEVIN_CONNECT_BILLING_TAGS=cache_read_tokens=5 +# and tag4 was confirmed 2026-07-25 for cache writes. A paid upstream capture +# confirmed top-level #22 as committed_acu_cost encoded as a double. +# Harmless on free accounts: absent/zero protobuf scalars decode to no entry. +# Setting this replaces the full default map; use `off` to decode nothing. +# DEVIN_CONNECT_BILLING_TAGS=cache_read_tokens=5,cache_write_tokens=4,committed_acu_cost=^22 # Send the role-priming chunk eagerly (pre-2.0.146 behavior). Default defers it # until the first real delta so first-connect transient errors keep recovery armed. # DEVIN_CONNECT_EAGER_PRIME=1 diff --git a/docs/DEVIN-CONNECT-CUTOVER.md b/docs/DEVIN-CONNECT-CUTOVER.md index 6f87f112..7312f187 100644 --- a/docs/DEVIN-CONNECT-CUTOVER.md +++ b/docs/DEVIN-CONNECT-CUTOVER.md @@ -332,50 +332,50 @@ decoded `model_uid` is a sane concrete selector. ### 8.4 Surface billing cost in usage (credit/acu) -The response carries `credit_cost`/`committed_credit_cost`/`committed_acu_cost`, -dropped today. These are absent on free tier (zero-valued → not encoded), so the -tags can only be pinned from a paid response. Once known: +The response carries `credit_cost`/`committed_credit_cost`/`committed_acu_cost`. +Paid upstream verification pinned `committed_acu_cost` to top-level tag `#22`, +encoded as fixed64/double. It ships in the default map with the two +paid-verified cache-token tags: ```sh -# .env (tags are EXAMPLES — pin the real ones from a paid capture): -DEVIN_CONNECT_BILLING_TAGS="credit_cost=6,committed_credit_cost=7,committed_acu_cost=8" +# Optional explicit override (setting this replaces the entire default map): +DEVIN_CONNECT_BILLING_TAGS="cache_read_tokens=5,cache_write_tokens=4,committed_acu_cost=^22" ``` -`chat()` and the streaming `finish` event then carry a `billing` object. Unset = -no billing keys, zero behavioral change. +`chat()` and the streaming `finish` event carry a `billing` object. Account spend +keeps ACU in the independent `acuCost` field (never mixed with credits), and the +Dashboard account detail shows fractional ACU. Set the env var to `off` to disable +billing/cache decoding. ### 8.5 Discover unknown metadata tags (the calibration master-key) -§8.4 and §8.6 both need integer tags that only appear on a paid/cached response. -The discovery tool is a single env flag — it dumps every varint subfield of the -#7 metadata sub-message to the log so you can read the tags straight off a real -capture: +Unknown credit fields still need paid calibration. The discovery tool dumps +top-level, metadata, and nested fields, including varint, fixed64/double, and +fixed32/float values: ```sh DEVIN_CONNECT_DEBUG_META=1 -# log line: DEVIN_CONNECT meta dump (tag=value varints): {"2":389,"3":72,"6":6,...} -# #2 = prompt_tokens, #3 = completion_tokens (known). Any NEW tag carrying a -# credit/acu cost or a cache-token count is your value to pin below. +# log line: DEVIN_CONNECT meta dump (tag=value fields): {"2":389,"3":72,"6":6,...} +# top-level #22 fixed64/double is committed_acu_cost (paid-verified). ``` Free-tier baseline (verified 2026-06-30 on `swe-1-6-slow`): the terminal frame carries only `{2: prompt, 3: completion, 6: provider}` — no cost, no cache tokens (free tier doesn't bill or cache, and zero-valued protobuf fields aren't -encoded). That's exactly why §8.4/§8.6 are paid-only. +encoded). That is why these coordinates required paid captures to calibrate. ### 8.6 Surface prompt-cache tokens in usage -`ModelUsageStats` carries `cache_read_tokens` / `cache_write_tokens` (recon -verified field names). Absent on free tier (no caching). Once a paid/cached -capture reveals the tags via §8.5, pin them on the SAME billing-tags var — the +`ModelUsageStats` carries `cache_read_tokens` / `cache_write_tokens`. Paid A/B +captures pinned them to metadata tags `#5` and `#4`; both ship by default. The decoder routes cache_* into `usage` (OpenAI-standard shapes) instead of billing: ```sh -DEVIN_CONNECT_BILLING_TAGS="credit_cost=6,cache_read_tokens=14,cache_write_tokens=15" +DEVIN_CONNECT_BILLING_TAGS="cache_read_tokens=5,cache_write_tokens=4,committed_acu_cost=^22" ``` -`usage` then gains `prompt_tokens_details.cached_tokens` (from cache_read) and -`cache_creation_input_tokens` (from cache_write). Unset = neither key present. +`usage` gains `prompt_tokens_details.cached_tokens` (from cache_read) and +`cache_creation_input_tokens` (from cache_write). ### 8.7 finish_reason calibration (already live, free-tier safe) diff --git a/scripts/devin-connect-calibrate.mjs b/scripts/devin-connect-calibrate.mjs index c9c3f015..1a1aa451 100644 --- a/scripts/devin-connect-calibrate.mjs +++ b/scripts/devin-connect-calibrate.mjs @@ -30,6 +30,8 @@ */ import { readFileSync, writeFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; +import { config } from '../src/config.js'; const REAL = process.env.CALIBRATE_REAL === '1'; const TIMEOUT_MS = Number(process.env.CALIBRATE_TIMEOUT_MS || 90000); @@ -83,24 +85,26 @@ export const FREE_BASELINE = { // ─── Targets: the five blocked unknowns, each with the env var that arms its // already-shipped decoder, the wire shape it appears as, and the task it unblocks. export const TARGETS = [ - { key: 'actual_model_uid', env: 'DEVIN_CONNECT_ACTUAL_MODEL_TAG', scope: 'top', shape: 'string', task: '#47', note: 'concrete model behind a router turn' }, + { key: 'actual_model_uid', env: 'DEVIN_CONNECT_ACTUAL_MODEL_TAG', scope: 'meta', shape: 'string', task: '#47', note: 'concrete model behind a router turn (#7.9)' }, { key: 'tool_calls', env: 'DEVIN_CONNECT_TOOL_CALL_TAGS', scope: 'top', shape: 'message', task: '#49', note: 'repeated ChatToolCall (delta_tool_calls)' }, - { key: 'billing', env: 'DEVIN_CONNECT_BILLING_TAGS', scope: 'meta', shape: 'varint', task: '#46', note: 'credit_cost / committed_*_cost' }, + { key: 'billing', env: 'DEVIN_CONNECT_BILLING_TAGS', scope: 'top/meta', shape: 'numeric', task: '#239', note: 'paid-verified committed_acu_cost; credit fields pending' }, { key: 'cache_tokens', env: 'DEVIN_CONNECT_BILLING_TAGS', scope: 'meta', shape: 'varint', task: '#46', note: 'cache_read_tokens / cache_write_tokens' }, { key: 'image_tag', env: 'DEVIN_CONNECT_IMAGE_TAG', scope: 'request', shape: 'message', task: '#29', note: 'vision images field — needs the dedicated image-calibrate sweep' }, ]; -export function resolveToken(env = process.env) { +export function resolveToken( + env = process.env, + defaultAccountsFile = join(config.sharedDataDir || config.dataDir, 'accounts.json'), +) { if (env.CONNECT_SMOKE_TOKEN) return env.CONNECT_SMOKE_TOKEN.trim(); for (const k of ['DEVIN_CONNECT_TOKEN', 'DEVIN_SESSION_TOKEN', 'WINDSURF_SESSION_TOKEN']) { if (env[k]) return env[k].trim(); } try { - const accountsUrl = env.CALIBRATE_ACCOUNTS_FILE - ? new URL(`file://${env.CALIBRATE_ACCOUNTS_FILE.replace(/\\/g, '/')}`) - : new URL('../accounts.json', import.meta.url); - const accounts = JSON.parse(readFileSync(accountsUrl, 'utf8')); - const first = accounts.find((a) => a.apiKey); + const accountsFile = env.CALIBRATE_ACCOUNTS_FILE || defaultAccountsFile; + const accounts = JSON.parse(readFileSync(accountsFile, 'utf8')); + const first = accounts.find((a) => a?.status === 'active' && a?.apiKey) + || accounts.find((a) => a?.apiKey); if (first) return first.apiKey; } catch { /* none */ } return ''; @@ -111,18 +115,38 @@ export function resolveOutPath(env = process.env) { } /** - * Classify one observed top-level/meta tag (not in the baseline) into the target + * Classify one observed top-level/meta/sub-message tag into the target * bucket its wire shape best fits. Pure + exported for the self-test. * - meta varint → billing / cache_tokens (#46) - * - top string → actual_model_uid (#47) + * - top #22 f64 → committed_acu_cost (#239) + * - sub #7.9 str → actual_model_uid (#47) * - top message → tool_calls (#49) */ export function classifyTag({ scope, tag, kind, preview, topTag, path }) { const loc = path || (topTag != null ? `${topTag}.${tag}` : tag); + // Paid upstream capture: committed_acu_cost is top-level #22 + // encoded as fixed64/double, with Response Statistics #28.2.4.2 echoing the + // same value as fixed32/float. This is paid-verified, not a shape guess. + if (scope === 'top' && tag === 22 && (kind === 'fixed64' || kind === 'fixed32')) { + return { bucket: 'billing/acu', targets: ['billing'], task: '#239', + detail: `top ${kind} #22=${preview} — paid-verified committed_acu_cost` }; + } if (scope === 'meta' && kind === 'varint') { return { bucket: 'billing/cache', targets: ['billing', 'cache_tokens'], task: '#46', detail: `meta varint #${tag}=${preview} — credit_cost / cache token candidate` }; } + // Paid captures put the provider name at top-level #21. Treating every new + // printable top-level string as actual_model_uid produced the bogus env + // DEVIN_CONNECT_ACTUAL_MODEL_TAG=21 for the literal value "anthropic". + if (scope === 'top' && tag === 21 && kind === 'string') { + return { bucket: 'provider', targets: [], task: '#239', + detail: `top string #21="${preview}" — provider (not actual_model_uid)` }; + } + // actual_model_uid is live-verified at metadata #7.9. + if (scope === 'sub' && String(loc) === '7.9' && kind === 'string') { + return { bucket: 'actual_model_uid', targets: ['actual_model_uid'], task: '#47', + detail: `sub #7.9="${preview}" — actual_model_uid` }; + } if (scope === 'top' && kind === 'string') { return { bucket: 'actual_model_uid', targets: ['actual_model_uid'], task: '#47', detail: `top string #${tag}="${preview}" — actual_model_uid candidate` }; @@ -136,8 +160,9 @@ export function classifyTag({ scope, tag, kind, preview, topTag, path }) { // is where credit_cost / committed_*_cost / cache tokens most likely live when // they don't ride the #7 meta block. Strings inside are model-id / stop-reason. if (scope === 'sub' && kind === 'varint') { - // Informational only (targets: []): the shipped billing decoder reads the #7 - // meta block, so a #28 inner varint must NOT auto-fill DEVIN_CONNECT_BILLING_TAGS. + // Informational only (targets: []): billing coordinates support #7 metadata + // and top-level fields, not arbitrary nested #28 paths, so this must NOT + // auto-fill DEVIN_CONNECT_BILLING_TAGS. // It's surfaced for the operator to inspect, with a dedicated env hint below. return { bucket: 'sub-billing', targets: [], task: '#46', detail: `sub #${loc} varint=${preview} — billing/usage/stop-metadata candidate` }; @@ -160,6 +185,9 @@ export function classifyTag({ scope, tag, kind, preview, topTag, path }) { */ export function aggregateDumps(frameDumps, metaDumps, subDumps) { const classify = (v) => { + if (v && typeof v === 'object' && typeof v.kind === 'string' && 'preview' in v) { + return { ...v }; + } if (typeof v === 'number') return { kind: 'varint', preview: v }; if (typeof v === 'string' && /^$/.test(v)) return { kind: 'message', preview: v }; return { kind: 'string', preview: String(v).slice(0, 48) }; @@ -265,11 +293,18 @@ export async function runCalibration({ token, model = DEFAULT_MODEL, prompt = DE for (const c of candidates) for (const t of c.targets) (byTarget[t] ||= []).push(c); if (byTarget.actual_model_uid?.length) envLines.push(`DEVIN_CONNECT_ACTUAL_MODEL_TAG=${byTarget.actual_model_uid[0].tag}`); if (byTarget.tool_calls?.length) envLines.push(`# tool_calls outer candidate at tag ${byTarget.tool_calls[0].tag} — confirm subfields then set:\n# DEVIN_CONNECT_TOOL_CALL_TAGS="outer=${byTarget.tool_calls[0].tag},id=?,name=?,arguments_json=?"`); - const billingTags = (byTarget.billing || byTarget.cache_tokens || []).map((c) => c.tag); + const acu = (byTarget.billing || []).find((c) => c.scope === 'top' && c.tag === 22 + && (c.kind === 'fixed64' || c.kind === 'fixed32')); + if (acu) { + envLines.push('DEVIN_CONNECT_BILLING_TAGS="cache_read_tokens=5,cache_write_tokens=4,committed_acu_cost=^22"'); + } + const billingTags = (byTarget.billing || byTarget.cache_tokens || []) + .filter((c) => c.scope === 'meta' && c.kind === 'varint') + .map((c) => c.tag); if (billingTags.length) envLines.push(`# meta varint candidates at tags [${billingTags.join(',')}] — map to credit_cost/cache_*; then set DEVIN_CONNECT_BILLING_TAGS / cache via DEVIN_CONNECT_BILLING_TAGS`); // Sub-message inner varints (e.g. the #28 trailer): informational — the shipped - // billing decoder reads the #7 meta block, so these are NOT auto-wired. Surface - // them so the operator can decide whether #28 carries the billing/usage fields. + // billing decoder cannot address arbitrary nested paths, so these are NOT + // auto-wired. Surface them so the operator can inspect the #28 usage trailer. const subVarints = candidates.filter((c) => c.scope === 'sub' && c.kind === 'varint'); if (subVarints.length) { // Group by the parent path (everything but the leaf tag) so nested counters @@ -281,7 +316,7 @@ export async function runCalibration({ token, model = DEFAULT_MODEL, prompt = DE (byParent[parent] ||= []).push(`${segs[segs.length - 1]}=${c.preview}`); } for (const [parent, fields] of Object.entries(byParent)) { - envLines.push(`# sub-message #${parent} inner varints: {${fields.join(', ')}} — inspect for credit_cost/cache/stop-metadata (NOT auto-wired; #7-meta drives billing decode today)`); + envLines.push(`# sub-message #${parent} inner varints: {${fields.join(', ')}} — inspect for credit_cost/cache/stop-metadata (NOT auto-wired; nested paths are informational)`); } } @@ -308,6 +343,9 @@ async function selfTest() { assert(classifyTag({ scope: 'meta', tag: 10, kind: 'varint', preview: 42 }).bucket === 'billing/cache', 'meta varint → billing/cache'); assert(classifyTag({ scope: 'top', tag: 8, kind: 'string', preview: 'claude-opus' }).bucket === 'actual_model_uid', 'top string → actual_model_uid'); assert(classifyTag({ scope: 'top', tag: 12, kind: 'message', preview: '' }).bucket === 'tool_calls', 'top message → tool_calls'); + assert(classifyTag({ scope: 'top', tag: 22, kind: 'fixed64', preview: 0.0006735 }).bucket === 'billing/acu', 'top #22 double → ACU billing'); + assert(classifyTag({ scope: 'top', tag: 21, kind: 'string', preview: 'anthropic' }).bucket === 'provider', 'top #21 anthropic → provider, not model'); + assert(classifyTag({ scope: 'sub', topTag: 7, tag: 9, path: '7.9', kind: 'string', preview: 'claude-sonnet-4-6-thinking' }).bucket === 'actual_model_uid', 'sub #7.9 → actual model'); assert(classifyTag({ scope: 'sub', topTag: 28, tag: 3, kind: 'varint', preview: 42 }).bucket === 'sub-billing', 'sub varint → sub-billing'); assert(classifyTag({ scope: 'sub', topTag: 28, tag: 3, kind: 'varint', preview: 42 }).targets.length === 0, 'sub varint NOT auto-wired'); assert(classifyTag({ scope: 'sub', topTag: 28, tag: 1, kind: 'string', preview: 'stop' }).bucket === 'sub-metadata', 'sub string → sub-metadata'); @@ -315,7 +353,8 @@ async function selfTest() { // aggregate + findCandidates against the free baseline const frameDumps = [ { 1: 'bot-x', 9: 'thinking', 17: 'uuid' }, // all baseline → no candidates - { 1: 'bot-x', 3: 'PONG', 4: 2, 8: 'claude-opus-4-8', 12: '' }, // #8 actual_model, #12 tool_calls + { 1: 'bot-x', 3: 'PONG', 4: 2, 8: 'claude-opus-4-8', 12: '', + 22: { kind: 'fixed64', preview: 0.0006735000060871243, raw: '00000040ba11463f' } }, ]; const metaDumps = [{ 6: 6, 14: 1500, 15: 200 }]; // #14/#15 new varints → billing/cache // #28 trailer — the recurring "Response Statistics" container captured on PAID-1 @@ -333,6 +372,7 @@ async function selfTest() { const buckets = report.candidates.map((c) => c.bucket).sort(); assert(report.candidates.some((c) => c.scope === 'top' && c.tag === 8 && c.bucket === 'actual_model_uid'), 'found actual_model_uid #8'); assert(report.candidates.some((c) => c.scope === 'top' && c.tag === 12 && c.bucket === 'tool_calls'), 'found tool_calls #12'); + assert(report.candidates.some((c) => c.scope === 'top' && c.tag === 22 && c.bucket === 'billing/acu'), 'found paid-verified ACU #22'); assert(report.candidates.filter((c) => c.scope === 'meta' && c.bucket === 'billing/cache').length === 2, 'found 2 billing/cache meta varints'); assert(!report.candidates.some((c) => c.tag === 6), 'baseline meta #6 not flagged'); assert(!report.candidates.some((c) => c.scope === 'top' && [1, 3, 4, 9, 17].includes(c.tag)), 'baseline top tags not flagged'); @@ -347,6 +387,7 @@ async function selfTest() { assert(report.envLines.some((l) => l === 'DEVIN_CONNECT_ACTUAL_MODEL_TAG=8'), 'emits actual_model env line'); assert(report.envLines.some((l) => /outer=12/.test(l)), 'emits tool_call outer candidate'); assert(report.envLines.some((l) => /14,15/.test(l)), 'emits billing meta candidates'); + assert(report.envLines.some((l) => /committed_acu_cost=\^22/.test(l)), 'emits ACU #22 mapping'); assert(report.envLines.some((l) => /sub-message #28\.2 inner varints/.test(l) && /3=1200/.test(l) && /4=34/.test(l)), 'emits nested sub #28.2 informational env hint'); // status table reflects discoveries + already-set env diff --git a/src/auth.js b/src/auth.js index e95c79dc..954c5647 100644 --- a/src/auth.js +++ b/src/auth.js @@ -584,7 +584,7 @@ function _serializeAccounts() { // zero; losing it is harmless (backoff just restarts) so it's best-effort. _breakerStreak: a._breakerStreak || 0, // K8: persist the lifetime spend accumulator (monotonic across restarts). - _totalSpend: a._totalSpend || { requests: 0, totalTokens: 0, promptTokens: 0, completionTokens: 0, creditCost: 0 }, + _totalSpend: a._totalSpend || { requests: 0, totalTokens: 0, promptTokens: 0, completionTokens: 0, creditCost: 0, acuCost: 0 }, // C5: persisted rolling-hour health window (pruned at save time so the // file never carries stale/out-of-window events across restarts). _health: Array.isArray(a._health) ? pruneHealthWindow(a, Date.now()) : [], @@ -731,7 +731,7 @@ function _deserializeAccount(a, now = Date.now()) { // K8: per-account lifetime spend accumulator. Monotonic, survives log // rotation and restarts. `?? 0` so an older accounts.json (written before // this field existed) loads as zero rather than NaN. Shape: - // { requests, totalTokens, promptTokens, completionTokens, creditCost }. + // { requests, totalTokens, promptTokens, completionTokens, creditCost, acuCost }. _totalSpend: (a._totalSpend && typeof a._totalSpend === 'object') ? { requests: Number(a._totalSpend.requests) || 0, @@ -739,8 +739,9 @@ function _deserializeAccount(a, now = Date.now()) { promptTokens: Number(a._totalSpend.promptTokens) || 0, completionTokens: Number(a._totalSpend.completionTokens) || 0, creditCost: Number(a._totalSpend.creditCost) || 0, + acuCost: Number(a._totalSpend.acuCost) || 0, } - : { requests: 0, totalTokens: 0, promptTokens: 0, completionTokens: 0, creditCost: 0 }, + : { requests: 0, totalTokens: 0, promptTokens: 0, completionTokens: 0, creditCost: 0, acuCost: 0 }, // C5: restore the rolling health window; drop anything already out of // the 1h window at load so a long-stopped process starts clean. _health: Array.isArray(a._health) @@ -2987,12 +2988,13 @@ export function reportSuccess(apiKey) { * OpenAI-shaped usage object. Monotonic and independent of the rolling stats * window / log rotation, so the dashboard can show "which account burns fastest" * and drive rotation/retirement decisions. Lazy-persisted via markDirty (the - * periodic flush writes it — no hot-path fsync). creditCost only accrues when a - * paid token surfaced billing (DEVIN_CONNECT_BILLING_TAGS calibrated). + * periodic flush writes it — no hot-path fsync). creditCost / acuCost only accrue + * when the upstream surfaced calibrated billing fields. They remain separate: + * credits and ACUs are different units and must never be added together. * * @param {string} apiKey * @param {object|null} usage { prompt_tokens, completion_tokens, total_tokens } - * @param {object} [opts] { creditCost?: number } + * @param {object} [opts] { creditCost?: number, acuCost?: number } */ /** * Full billable cost of a request, independent of whatever shape total_tokens is in. @@ -3027,12 +3029,12 @@ export function fullBillableTokens(usage) { return Math.max(total, prompt + completion + cacheWrite); } -export function recordAccountSpend(apiKey, usage, { creditCost = 0 } = {}) { +export function recordAccountSpend(apiKey, usage, { creditCost = 0, acuCost = 0 } = {}) { if (!apiKey) return; const account = accounts.find(a => a.apiKey === apiKey); if (!account) return; if (!account._totalSpend || typeof account._totalSpend !== 'object') { - account._totalSpend = { requests: 0, totalTokens: 0, promptTokens: 0, completionTokens: 0, creditCost: 0 }; + account._totalSpend = { requests: 0, totalTokens: 0, promptTokens: 0, completionTokens: 0, creditCost: 0, acuCost: 0 }; } const s = account._totalSpend; // Clamped for the same reason fullBillableTokens clamps: these are CUMULATIVE counters, so @@ -3057,6 +3059,7 @@ export function recordAccountSpend(apiKey, usage, { creditCost = 0 } = {}) { s.completionTokens += completion; s.totalTokens += total; s.creditCost += Math.max(0, Number(creditCost) || 0); + s.acuCost = (Number(s.acuCost) || 0) + Math.max(0, Number(acuCost) || 0); markDirty(); } @@ -3289,6 +3292,41 @@ function accountUserStatusSummary(userStatus) { }; } +/** + * Discover ACU accounting from data, never from a plan/tier label. + * + * GetUserStatus is authoritative for the current upstream cycle. The local + * DEVIN_CONNECT accumulator is a privacy-safe fallback when an older upstream + * omits PlanStatus.acu_consumed; it is explicitly marked lifetime-local so the + * UI cannot mistake it for a billing-cycle counter. + */ +export function getAccountAcuUsage(account) { + const finiteNonNegative = (value) => { + if (value == null || value === '') return null; + const n = Number(value); + return Number.isFinite(n) && n >= 0 ? n : null; + }; + const upstreamConsumed = finiteNonNegative(account?.credits?.acuConsumed); + const upstreamLimit = finiteNonNegative(account?.credits?.acuLimit); + const localLifetime = finiteNonNegative(account?._totalSpend?.acuCost); + + if (upstreamConsumed != null) { + return { + consumed: upstreamConsumed, + limit: upstreamLimit, + source: 'get_user_status', + }; + } + if (localLifetime != null && localLifetime > 0) { + return { + consumed: localLifetime, + limit: null, + source: 'local_billing', + }; + } + return null; +} + function publicAccount(a, now, { view = 'full' } = {}) { const rpmLimit = rpmLimitFor(a); const rpmUsed = pruneRpmHistory(a, now); @@ -3311,6 +3349,9 @@ function publicAccount(a, now, { view = 'full' } = {}) { rpmUsed, rpmLimit, credits: cr, + // Identifier-free ACU projection. This is intentionally derived from + // numeric capability fields rather than planName/tier strings. + acuUsage: getAccountAcuUsage(a), blockedModelCount: (a.blockedModels || []).length, tierModelCount: tierModels.length, userStatus: accountUserStatusSummary(a.userStatus), @@ -3344,8 +3385,9 @@ function publicAccount(a, now, { view = 'full' } = {}) { promptTokens: a._totalSpend.promptTokens || 0, completionTokens: a._totalSpend.completionTokens || 0, creditCost: a._totalSpend.creditCost || 0, + acuCost: a._totalSpend.acuCost || 0, } - : { requests: 0, totalTokens: 0, promptTokens: 0, completionTokens: 0, creditCost: 0 }, + : { requests: 0, totalTokens: 0, promptTokens: 0, completionTokens: 0, creditCost: 0, acuCost: 0 }, capabilities: a.capabilities || {}, modelRateLimits: a._modelRateLimits ? Object.fromEntries( Object.entries(a._modelRateLimits).filter(([, v]) => v > now) @@ -3499,9 +3541,11 @@ export async function refreshCredits(id) { const { getUserStatus } = await import('./windsurf-api.js'); const proxy = getEffectiveProxy(account.id) || null; const status = await getUserStatus(account.apiKey, proxy); - // Drop the huge raw payload before persisting — keep it only in memory for - // downstream callers (e.g. model catalog cache) to inspect once. - const { raw, ...persist } = status; + // Drop the raw payload before persisting or returning it. It contains + // account/org identifiers and deployment URLs; all dashboard consumers need + // is the normalized, identifier-free quota projection below. + const persist = { ...status }; + delete persist.raw; account.credits = persist; // B: on-demand balance + billing period. The REST getUserStatus `raw` is a // PARSED JSON object, not protobuf bytes — feeding it to decodeUserStatusFull @@ -3523,6 +3567,12 @@ export async function refreshCredits(id) { if (billing.balance != null) account.credits.balance = billing.balance; if (billing.periodStart) account.credits.periodStart = billing.periodStart; if (billing.periodEnd) account.credits.periodEnd = billing.periodEnd; + if (billing.acuConsumed != null && account.credits.acuConsumed == null) { + account.credits.acuConsumed = billing.acuConsumed; + } + if (billing.acuLimit != null && account.credits.acuLimit == null) { + account.credits.acuLimit = billing.acuLimit; + } // Only a PAIRED rate table (selector-keyed object) is usable. An unpaired // array means the catalog fetch failed, and positional floats without their // selectors cannot be attributed to a model. @@ -3570,9 +3620,7 @@ export async function refreshCredits(id) { } } saveAccounts(); - // Surface the raw response once so the caller can decide whether to mine - // the bundled model catalog from it. - return { ok: true, credits: persist, raw }; + return { ok: true, credits: account.credits }; } catch (e) { const msg = e.message || String(e); log.warn(`refreshCredits ${id} failed: ${msg}`); diff --git a/src/dashboard/api.js b/src/dashboard/api.js index 1b291207..ca99d625 100644 --- a/src/dashboard/api.js +++ b/src/dashboard/api.js @@ -1242,7 +1242,12 @@ export async function handleDashboardApi(method, subpath, body, req, res) { const creditRefresh = subpath.match(/^\/accounts\/([^/]+)\/refresh-credits$/); if (creditRefresh && method === 'POST') { const r = await refreshCredits(creditRefresh[1]); - return json(res, r.ok ? 200 : 400, r); + // Defense in depth: never forward a raw GetUserStatus body. It may contain + // enterprise/org/user identifiers even though refreshCredits currently + // returns only its normalized projection. + const safe = { ...(r || {}) }; + delete safe.raw; + return json(res, safe.ok ? 200 : 400, safe); } // POST /accounts/:id/web-search — run one upstream web search on this account. diff --git a/src/dashboard/i18n/en.json b/src/dashboard/i18n/en.json index fed92571..86a4392d 100644 --- a/src/dashboard/i18n/en.json +++ b/src/dashboard/i18n/en.json @@ -212,8 +212,13 @@ "dailyShort": "D", "weeklyShort": "W", "promptShort": "P", + "acu": "ACU this cycle", + "acuCycle": "Current-cycle usage reported by GetUserStatus", + "acuLocalLifetime": "Local lifetime total from DEVIN_CONNECT billing", + "acuUpstreamLimit": "Upstream ACU limit", + "acuLimitNA": "Upstream returned no ACU limit", "columnTitle": "Quota Usage", - "columnTooltip": "Two bars indicate:\nD — Daily premium-request remaining %\nW — Weekly premium-request remaining % (main signal for Trial accounts)\n\nHigher = more left = safer\nHover each bar for reset time", + "columnTooltip": "Personal quota accounts show daily/weekly remaining percentages. If those quotas are absent and ACU accounting is detected, this column shows ACU usage and any upstream limit instead.", "dailyDetail": "Daily {{pct}}% remaining (resets {{reset}})", "weeklyDetail": "Weekly {{pct}}% remaining (resets {{reset}})", "promptDetail": "Prompt credits {{remain}} / {{limit}} remaining ({{pct}}%)", @@ -1103,6 +1108,12 @@ "weekly": "Weekly", "promptCredits": "Prompt credits", "flexCredits": "Flex credits", + "acuUsed": "ACU consumed", + "acuLimit": "ACU limit", + "acuLimitNA": "Not returned by upstream", + "acuSource": "Measurement", + "acuCycle": "GetUserStatus · current cycle", + "acuLocalLifetime": "DEVIN_CONNECT · local lifetime total", "used": "used", "fetchedAt": "Fetched", "notApplicable": "Not applicable to this plan" @@ -1126,7 +1137,8 @@ "spendTitle": "Lifetime spend", "spendRequests": "Requests", "spendTokens": "Total tokens", - "spendCredits": "Credit cost" + "spendCredits": "Credit cost", + "spendAcus": "ACU cost" } } }, diff --git a/src/dashboard/i18n/zh-CN.json b/src/dashboard/i18n/zh-CN.json index 289cd1c9..9cb7b8e6 100644 --- a/src/dashboard/i18n/zh-CN.json +++ b/src/dashboard/i18n/zh-CN.json @@ -212,8 +212,13 @@ "dailyShort": "日", "weeklyShort": "周", "promptShort": "词", + "acu": "本周期 ACU", + "acuCycle": "GetUserStatus 返回的本周期用量", + "acuLocalLifetime": "DEVIN_CONNECT 本地累计用量", + "acuUpstreamLimit": "上游 ACU 配额", + "acuLimitNA": "上游未返回 ACU 配额", "columnTitle": "配额使用情况", - "columnTooltip": "两条进度条分别代表:\n日 — 每日高级请求剩余百分比(重置频繁)\n周 — 每周高级请求剩余百分比(Trial 账号重点看这条)\n\n数值越高 = 剩余越多 = 越安全\n悬停单条进度条可查看重置时间", + "columnTooltip": "个人配额账号显示每日/每周剩余百分比;未返回这些配额但检测到 ACU 计费时,改为显示 ACU 用量及上游返回的配额。", "dailyDetail": "每日剩余 {{pct}}%(于 {{reset}} 重置)", "weeklyDetail": "每周剩余 {{pct}}%(于 {{reset}} 重置)", "promptDetail": "本月提示词剩余 {{remain}} / {{limit}}({{pct}}%)", @@ -1103,6 +1108,12 @@ "weekly": "每周", "promptCredits": "提示词额度", "flexCredits": "Flex 额度", + "acuUsed": "ACU 已用", + "acuLimit": "ACU 配额", + "acuLimitNA": "上游未返回", + "acuSource": "计量来源", + "acuCycle": "GetUserStatus · 本周期", + "acuLocalLifetime": "DEVIN_CONNECT · 本地累计", "used": "已用", "fetchedAt": "数据时间", "notApplicable": "本套餐不适用" @@ -1126,7 +1137,8 @@ "spendTitle": "累计花费", "spendRequests": "请求数", "spendTokens": "总 token 数", - "spendCredits": "信用花费" + "spendCredits": "信用花费", + "spendAcus": "ACU 消耗" } } }, diff --git a/src/dashboard/index.html b/src/dashboard/index.html index 9f971f17..200d9d9b 100644 --- a/src/dashboard/index.html +++ b/src/dashboard/index.html @@ -2659,7 +2659,7 @@

账号管理

- +
ID标签层级RPM配额可用模型状态错误最后使用Key操作
ID标签层级RPM配额可用模型状态错误最后使用Key操作
@@ -6594,10 +6594,42 @@

控制台登录

: `-`; // Credit / quota cell — collapses the legacy credit contract and the - // newer daily/weekly percent contract into a single progress bar. + // newer daily/weekly percent contract into a single progress bar. ACU is + // discovered from numeric upstream/local accounting signals; planName is + // display-only and must never decide whether this branch is active. const cr = a.credits || null; + const acu = a.acuUsage || null; + const hasPersonalQuota = !!cr && ( + cr.dailyPercent != null + || cr.weeklyPercent != null + || Number(cr.prompt?.limit) > 0 + || Number(cr.flex?.limit) > 0 + ); + const showAcuQuota = !!acu && !hasPersonalQuota; + const fmtAcu = (value) => Number(value).toLocaleString(undefined, { maximumFractionDigits: 7 }); let creditCell; - if (!cr) { + if (showAcuQuota) { + const used = Math.max(0, Number(acu.consumed) || 0); + const limit = acu.limit != null && Number.isFinite(Number(acu.limit)) && Number(acu.limit) >= 0 ? Number(acu.limit) : null; + const pct = limit > 0 ? Math.min(100, used / limit * 100) : null; + const visiblePct = pct != null && pct > 0 ? Math.max(1.5, pct) : 0; + const fetchedAgo = cr?.fetchedAt ? Math.round((Date.now() - cr.fetchedAt) / 60000) + 'm ago' : ''; + const source = acu.source === 'get_user_status' + ? I18n.t('creditsBar.acuCycle') + : I18n.t('creditsBar.acuLocalLifetime'); + creditCell = `
+
+ ${I18n.t('creditsBar.acu')} + ${fetchedAgo ? `${fetchedAgo}` : ''} +
+
+ ${fmtAcu(used)} + ${limit != null ? ` / ${fmtAcu(limit)}` : ' ACU'} +
+ ${limit > 0 ? `
` : ''} +
${limit != null ? I18n.t('creditsBar.acuUpstreamLimit') : I18n.t('creditsBar.acuLimitNA')}
+
`; + } else if (!cr) { creditCell = `${I18n.t('status.notFetched')}`; } else if (cr.lastError && cr.percent == null && !cr.prompt?.limit) { creditCell = `${I18n.t('status.fetchFailed')}`; @@ -6782,6 +6814,12 @@

控制台登录

renderAccountDetail(a) { const cr = a.credits || {}; const us = a.userStatus || {}; + const acu = a.acuUsage || null; + const hasPersonalQuota = cr.dailyPercent != null + || cr.weeklyPercent != null + || Number(cr.prompt?.limit) > 0 + || Number(cr.flex?.limit) > 0; + const showAcuQuota = !!acu && !hasPersonalQuota; const catalog = this._modelsCatalog || {}; const T = (k, v) => I18n.t('account.detail.' + k, v); const barColor = (v) => v == null ? 'var(--text-dim)' : v <= 10 ? 'var(--error)' : v <= 30 ? 'var(--warn)' : 'var(--success)'; @@ -6795,6 +6833,7 @@

控制台登录

if (s < 86400) return `${Math.floor(s/3600)}h ${T('ago')}`; return `${Math.floor(s/86400)}d ${T('ago')}`; }; + const fmtAcu = (value) => Number(value).toLocaleString(undefined, { maximumFractionDigits: 7 }); const renderBar = (label, pct, resetAt, noDataText) => { const isN = pct == null; const v = isN ? 0 : Math.max(0, Math.min(100, pct)); @@ -6876,13 +6915,27 @@

控制台登录

${T('quota.title')}
- ${renderBar(T('quota.daily'), cr.dailyPercent, cr.dailyResetAt, I18n.t('creditsBar.dailyNA'))} - ${renderBar(T('quota.weekly'), cr.weeklyPercent, cr.weeklyResetAt, I18n.t('creditsBar.weeklyNA'))} -
- ${cr.prompt?.limit ? `${T('quota.promptCredits')}${cr.prompt.remaining ?? 0} / ${cr.prompt.limit} (${T('quota.used')} ${cr.prompt.used ?? 0})` : `${T('quota.promptCredits')}${T('quota.notApplicable')}`} - ${cr.flex?.limit ? `${T('quota.flexCredits')}${cr.flex.remaining ?? 0} / ${cr.flex.limit} (${T('quota.used')} ${cr.flex.used ?? 0})` : `${T('quota.flexCredits')}${T('quota.notApplicable')}`} - ${T('quota.fetchedAt')}${fmtAgo(cr.fetchedAt)} -
+ ${showAcuQuota ? (() => { + const used = Math.max(0, Number(acu.consumed) || 0); + const limit = acu.limit != null && Number.isFinite(Number(acu.limit)) && Number(acu.limit) >= 0 ? Number(acu.limit) : null; + const pct = limit > 0 ? Math.min(100, used / limit * 100) : null; + const visiblePct = pct != null && pct > 0 ? Math.max(1.5, pct) : 0; + const source = acu.source === 'get_user_status' ? T('quota.acuCycle') : T('quota.acuLocalLifetime'); + return `
+ ${T('quota.acuUsed')}${fmtAcu(used)} ACU + ${T('quota.acuLimit')}${limit != null ? `${fmtAcu(limit)} ACU` : `${T('quota.acuLimitNA')}`} + ${T('quota.acuSource')}${source} + ${T('quota.fetchedAt')}${fmtAgo(cr.fetchedAt)} +
+ ${limit > 0 ? `
` : ''}`; + })() : ` + ${renderBar(T('quota.daily'), cr.dailyPercent, cr.dailyResetAt, I18n.t('creditsBar.dailyNA'))} + ${renderBar(T('quota.weekly'), cr.weeklyPercent, cr.weeklyResetAt, I18n.t('creditsBar.weeklyNA'))} +
+ ${cr.prompt?.limit ? `${T('quota.promptCredits')}${cr.prompt.remaining ?? 0} / ${cr.prompt.limit} (${T('quota.used')} ${cr.prompt.used ?? 0})` : `${T('quota.promptCredits')}${T('quota.notApplicable')}`} + ${cr.flex?.limit ? `${T('quota.flexCredits')}${cr.flex.remaining ?? 0} / ${cr.flex.limit} (${T('quota.used')} ${cr.flex.used ?? 0})` : `${T('quota.flexCredits')}${T('quota.notApplicable')}`} + ${T('quota.fetchedAt')}${fmtAgo(cr.fetchedAt)} +
`}
${this.renderSpendPolicyCard(a)} @@ -6919,13 +6972,15 @@

控制台登录

${(() => { // K8: lifetime spend — helps decide which account to rotate/retire. - const sp = a.totalSpend || { requests: 0, totalTokens: 0, creditCost: 0 }; + const sp = a.totalSpend || { requests: 0, totalTokens: 0, creditCost: 0, acuCost: 0 }; const fmt = (n) => (Number(n) || 0).toLocaleString(); const credits = Number(sp.creditCost) || 0; + const acus = Number(sp.acuCost) || 0; return `
${T('runtime.spendRequests')}${fmt(sp.requests)} ${T('runtime.spendTokens')}${fmt(sp.totalTokens)} ${credits > 0 ? `${T('runtime.spendCredits')}${(Math.round(credits * 100) / 100).toLocaleString(undefined, { maximumFractionDigits: 2 })}` : ''} + ${acus > 0 && !showAcuQuota ? `${T('runtime.spendAcus')}${acus.toLocaleString(undefined, { maximumFractionDigits: 7 })}` : ''}
`; })()} diff --git a/src/devin-connect-catalog.js b/src/devin-connect-catalog.js index 03d08be5..5cf027c3 100644 --- a/src/devin-connect-catalog.js +++ b/src/devin-connect-catalog.js @@ -152,6 +152,17 @@ function intField(fields, num) { return f ? Number(f.value) : null; } +/** Read a protobuf numeric field without guessing its fixed-width encoding. */ +function numericField(fields, num) { + const f = fields.find((x) => x.field === num && (x.wireType === 0 || x.wireType === 1 || x.wireType === 5)); + if (!f) return null; + let value = null; + if (f.wireType === 0) value = Number(f.value); + else if (f.wireType === 1 && f.value.length === 8) value = f.value.readDoubleLE(0); + else if (f.wireType === 5 && f.value.length === 4) value = f.value.readFloatLE(0); + return Number.isFinite(value) && value >= 0 ? value : null; +} + /** * Decode a GetCliModelConfigsResponse into a flat list of model entries. * @@ -210,6 +221,8 @@ export function decodePlanName(raw) { * #1.13.16 = balance (varint, micro-dollar — divide by 1e6 for USD) * #1.13.17 = billing period start (varint, epoch seconds) * #1.13.18 = billing period end (varint, epoch seconds) + * #1.13.19 = ACU consumed (double; Devin-capable plans) + * #1.13.20 = ACU limit (double; optional account/org policy) * #1.13.1.21 (repeated) = per-model credit rate table (fixed32 f32, paired to catalog order) * * Returns { plan, isPaid, balance, balanceUnit, periodStart, periodEnd, rateTable }. @@ -229,6 +242,8 @@ export function decodePlanName(raw) { * balanceUnit: 'micro-usd'|null, * periodStart: Date|null, * periodEnd: Date|null, + * acuConsumed: number|null, + * acuLimit: number|null, * rateTable: Object|Array|null * }} */ @@ -240,6 +255,8 @@ export function decodeUserStatusFull(raw, catalog = null) { balanceUnit: null, periodStart: null, periodEnd: null, + acuConsumed: null, + acuLimit: null, rateTable: null, }; @@ -274,6 +291,14 @@ export function decodeUserStatusFull(raw, catalog = null) { if (periodStartEpoch != null) result.periodStart = periodStartEpoch * 1000; if (periodEndEpoch != null) result.periodEnd = periodEndEpoch * 1000; + // Current Devin CLI descriptors append PlanStatus.acu_consumed and + // acu_limit after the legacy reset/billing fields. They are fractional + // doubles in live Enterprise responses; numericField also tolerates + // float/varint encodings so an upstream wire-width change degrades to a + // value instead of silently hiding ACU support. + result.acuConsumed = numericField(billing, 19); + result.acuLimit = numericField(billing, 20); + // #1.13.1 = plan detail const planField = billing.find((x) => x.field === 1 && x.wireType === 2); if (planField) { @@ -398,7 +423,7 @@ export async function fetchUserStatus({ token, signal, env = process.env, withCa return status; } -export const __testing = { buildClientMetadata, strField, intField, PROVIDER_NAMES }; +export const __testing = { buildClientMetadata, strField, intField, numericField, PROVIDER_NAMES }; /** * Zero-billable liveness check for a DEVIN_CONNECT session token. diff --git a/src/devin-connect.js b/src/devin-connect.js index 77dfce90..dede24a0 100644 --- a/src/devin-connect.js +++ b/src/devin-connect.js @@ -1236,7 +1236,8 @@ const FIELD = Object.freeze({ CONTENT: 3, FINISH: 5, META: 7, REASONING: 9 }); // The top-level #5 finish signal maps to the OpenAI finish_reason vocabulary in // mapFinishReason() below (live-anchored 2→'stop', the rest calibratable). -// Billing passthrough (GROUNDWORK, opt-in via DEVIN_CONNECT_BILLING_TAGS). +// Billing and cache-usage passthrough (configuration-driven, with paid-verified +// defaults for cache tokens and committed ACU). // // The static recon (P2-apiserver-methods-fields.md §2.4) verifies that the // response carries `credit_cost`, `committed_credit_cost`, `committed_acu_cost` @@ -1247,15 +1248,9 @@ const FIELD = Object.freeze({ CONTENT: 3, FINISH: 5, META: 7, REASONING: 9 }); // so the fields are physically ABSENT from every free-account capture we have. // This is the same shape as the vision image-tag (also un-calibratable on free). // -// So billing decode is configuration-driven, default-OFF: until an operator -// runs the calibration on a PAID token and pins the real tags, nothing is -// parsed and usage carries no billing keys (zero regression). The env var maps -// logical billing keys to the integer tag observed in the metadata sub-message: -// -// DEVIN_CONNECT_BILLING_TAGS="credit_cost=6,committed_credit_cost=7,committed_acu_cost=8" -// -// All tags are read from the #7 metadata sub-message as varints. A future paid -// calibration run (scripts/devin-connect-paid-verify.mjs style) discovers them. +// DEVIN_CONNECT_BILLING_TAGS maps logical keys to protobuf tags. Plain N reads +// the #7 metadata sub-message; ^N reads the top-level response. Numeric fields +// may be varint, fixed64/double, or fixed32/float. // // CONFIRMED 2026-07-23 (paid teams account, live A/B — issue #220): the cache-read // counter is tag 5. Two requests sharing a long system prefix: round-1 (miss) meta @@ -1266,8 +1261,8 @@ const FIELD = Object.freeze({ CONTENT: 3, FINISH: 5, META: 7, REASONING: 9 }); // surfaces prompt_tokens_details.cached_tokens. This also settles #220: caching is // billed correctly (hit cost measured at 17.8% of miss); the gap was purely that // the dashboard couldn't SEE the cache split, not that credits were over-spent. -// credit_cost / committed_* remain declaration-order-only and still need their own -// paid calibration run. +// credit_cost / committed_credit_cost remain declaration-order-only and still +// need their own paid calibration run. // CONFIRMED 2026-07-25 (same paid account): the cache-WRITE counter is tag 4. // Claude-family selectors split prompt input the way Anthropic's own API does — // fresh input in tag 2, cache-creation in tag 4 — so a cache-writing turn reported @@ -1280,14 +1275,19 @@ const FIELD = Object.freeze({ CONTENT: 3, FINISH: 5, META: 7, REASONING: 9 }); // input rides tag 2, matching OpenAI's no-charge-for-cache-write model), so the // default is a no-op there rather than a mis-read. // -// Both tags are calibration-confirmed, so they ship ON by default — otherwise +// These three tags are calibration-confirmed, so they ship ON by default — otherwise // prompt_tokens_details.cached_tokens / cache_creation_input_tokens are always 0 // and the dashboard silently mis-attributes cached and cache-written input (#220). +// committed_acu_cost was verified against a paid upstream response: top-level +// #22 arrived as fixed64/double +// 0.0006735000060871243, and Response Statistics #28.2.4.2 echoed the same value +// as fixed32/float. It is safe on non-Enterprise/free accounts because an absent +// scalar produces no field and therefore no billing entry. // Safe on free accounts too: the counters are zero there, and protobuf omits // zero-valued scalars, so the tags are simply absent and nothing is decoded. // Operators can override the whole map (or drop the defaults) via // DEVIN_CONNECT_BILLING_TAGS; set it to `off` to decode nothing at all. -const DEFAULT_BILLING_TAGS = 'cache_read_tokens=5,cache_write_tokens=4'; +const DEFAULT_BILLING_TAGS = 'cache_read_tokens=5,cache_write_tokens=4,committed_acu_cost=^22'; function parseBillingTagMap(env = process.env) { const configured = String(env.DEVIN_CONNECT_BILLING_TAGS ?? '').trim(); @@ -1301,9 +1301,8 @@ function parseBillingTagMap(env = process.env) { // metadata sub-message, stored as -N so the decode path can tell them apart // without a second map. The four .proto reimplementations put credit_cost at // top-level #14 (committed_acu_cost #22, quota basis points #26, overage cents - // #27), while every tag calibrated so far lived in #7 — so both locations have - // to be expressible. Example, once a paid capture confirms it: - // DEVIN_CONNECT_BILLING_TAGS="cache_read_tokens=5,cache_write_tokens=4,credit_cost=^14" + // #27), while cache counters live in #7 — so both locations have to be + // expressible. #22 is paid-verified; #14 remains a static candidate. const topLevel = typeof tag === 'string' && tag.startsWith('^'); const n0 = Number.parseInt(topLevel ? tag.slice(1) : tag, 10); const n = topLevel && Number.isInteger(n0) && n0 > 0 ? -n0 : n0; @@ -1322,6 +1321,32 @@ function parseBillingTagMap(env = process.env) { return Object.keys(map).length ? map : null; } +// Billing scalars are not one protobuf wire type. Token counters and the legacy +// credit fixtures are varints, while the paid upstream wire carries fractional +// committed_acu_cost as fixed64/double. Accept the three numeric scalar encodings +// the parser supports; reject NaN/Infinity and non-numeric fields so an incorrect +// operator tag cannot poison a monotonic spend counter. +function readNumericProtoField(fields, tag) { + for (const f of getAllFields(fields, tag)) { + let value = null; + if (f.wireType === 0) value = Number(f.value); + else if (f.wireType === 1 && f.value.length === 8) value = f.value.readDoubleLE(0); + else if (f.wireType === 5 && f.value.length === 4) value = f.value.readFloatLE(0); + if (Number.isFinite(value)) return value; + } + return null; +} + +function fixedWidthDumpEntry(f) { + if (f.wireType === 1 && f.value.length === 8) { + return { kind: 'fixed64', preview: f.value.readDoubleLE(0), raw: f.value.toString('hex') }; + } + if (f.wireType === 5 && f.value.length === 4) { + return { kind: 'fixed32', preview: f.value.readFloatLE(0), raw: f.value.toString('hex') }; + } + return null; +} + // Native tool-call DECODE (GROUNDWORK, opt-in via DEVIN_CONNECT_TOOL_CALL_TAGS). // // The response carries `delta_tool_calls` (repeated ChatToolCall). Per the @@ -1582,8 +1607,10 @@ function decodeInnerFields(buf, depth) { } bucket[sf.field] = entry; } - } else if (sf.wireType === 5) bucket[sf.field] = { kind: 'fixed32', preview: sf.value.toString('hex') }; - else if (sf.wireType === 1) bucket[sf.field] = { kind: 'fixed64', preview: sf.value.toString('hex') }; + } else if (sf.wireType === 5 || sf.wireType === 1) { + const entry = fixedWidthDumpEntry(sf); + if (entry) bucket[sf.field] = entry; + } } return Object.keys(bucket).length ? bucket : null; } @@ -1698,9 +1725,9 @@ export function decodeFrame(payload, opts = {}) { usage = { prompt: prompt ? prompt.value : 0, completion: completion.value }; } // Billing/usage passthrough: opt-in, only when an operator has pinned the - // tags. Each is a varint; absent fields (free tier / un-billed / un-cached) - // yield nothing. cache_*_tokens are usage stats → folded into `usage`; the - // cost fields are billing → into `billing`. + // tags. Numeric scalars may be varint, fixed64/double, or fixed32/float; + // absent fields (free tier / un-billed / un-cached) yield nothing. + // cache_*_tokens are usage stats → folded into `usage`; cost fields → billing. const billingTags = opts.billingTags; if (billingTags) { for (const [key, tag] of Object.entries(billingTags)) { @@ -1715,12 +1742,12 @@ export function decodeFrame(payload, opts = {}) { // point at the top level without a second env var, and keeps the sub-message // default untouched for the tags that were measured there. if (tag < 0) continue; // handled in the top-level pass below - const f = getField(mf, tag, 0); - if (f == null) continue; + const value = readNumericProtoField(mf, tag); + if (value == null) continue; if (key === 'cache_read_tokens' || key === 'cache_write_tokens') { - (usage ||= { prompt: prompt ? prompt.value : 0, completion: completion ? completion.value : 0 })[key] = Number(f.value); + (usage ||= { prompt: prompt ? prompt.value : 0, completion: completion ? completion.value : 0 })[key] = value; } else { - (billing ||= {})[key] = Number(f.value); + (billing ||= {})[key] = value; } } } @@ -1733,6 +1760,10 @@ export function decodeFrame(payload, opts = {}) { metaDump = {}; for (const f of mf) { if (f.wireType === 0) metaDump[f.field] = Number(f.value); + else if (f.wireType === 1 || f.wireType === 5) { + const entry = fixedWidthDumpEntry(f); + if (entry) metaDump[f.field] = entry; + } } } } @@ -1741,12 +1772,12 @@ export function decodeFrame(payload, opts = {}) { // response can carry credit_cost at the top level with no #7 sub-message at all // (the sub-message holds token counts, and a billed turn is not obliged to // report them), so nesting this would silently skip exactly the frames it - // exists for. Same varint-or-nothing rule as the sub-message pass. + // exists for. Same numeric-scalar rule as the sub-message pass. if (opts.billingTags) { for (const [key, tag] of Object.entries(opts.billingTags)) { if (tag >= 0) continue; // sub-message tags were handled above - const f = getField(fields, -tag, 0); - if (f == null) continue; + const value = readNumericProtoField(fields, -tag); + if (value == null) continue; if (key === 'cache_read_tokens' || key === 'cache_write_tokens') { // Token counts stay usage, wherever they were read from. Routing them into // `billing` because of the location they were pinned at would move @@ -1754,9 +1785,9 @@ export function decodeFrame(payload, opts = {}) { // dashboard's cache split — the exact mis-attribution #220 was about. // prompt/completion live inside the `if (meta)` block above, so recompute // the usage defaults here rather than reference block-scoped locals. - (usage ||= { prompt: Number(getField(fields, 2, 0)?.value ?? 0), completion: Number(getField(fields, 3, 0)?.value ?? 0) })[key] = Number(f.value); + (usage ||= { prompt: Number(getField(fields, 2, 0)?.value ?? 0), completion: Number(getField(fields, 3, 0)?.value ?? 0) })[key] = value; } else { - (billing ||= {})[key] = Number(f.value); + (billing ||= {})[key] = value; } } } @@ -1769,6 +1800,10 @@ export function decodeFrame(payload, opts = {}) { topLevelDump = {}; for (const f of fields) { if (f.wireType === 0 && f.field !== FIELD.FINISH) topLevelDump[f.field] = Number(f.value); + else if (f.wireType === 1 || f.wireType === 5) { + const entry = fixedWidthDumpEntry(f); + if (entry) topLevelDump[f.field] = entry; + } } } @@ -1811,6 +1846,10 @@ export function decodeFrame(payload, opts = {}) { const subDump = {}; for (const f of fields) { if (f.wireType === 0) frameDump[f.field] = Number(f.value); + else if (f.wireType === 1 || f.wireType === 5) { + const entry = fixedWidthDumpEntry(f); + if (entry) frameDump[f.field] = entry; + } else if (f.wireType === 2 && f.value.length <= 64) { const s = f.value.toString('utf8'); if (/^[\x20-\x7e]+$/.test(s)) frameDump[f.field] = s; // printable preview only @@ -2424,16 +2463,14 @@ export async function* streamChat({ // wire-01: decode across frame boundaries so split multi-byte chars survive. const content = contentBytes ? contentDecoder.write(contentBytes) : ''; const reasoning = reasoningBytes ? reasoningDecoder.write(reasoningBytes) : ''; - // Calibration (DEBUG-gated, default OFF): emit the raw frame payload hex - // so a probe can re-decode it WIDE — the production frameDump/metaDump - // only collect wireType 0/2 and miss wt1(double)/wt5(float), which is - // exactly where billing cost fields (likely doubles) would hide. Pure - // additive; only under DEVIN_CONNECT_DUMP_RAW. + // Calibration (DEBUG-gated, default OFF): emit raw frame payload hex for + // exact offline re-decoding. Structured dumps already expose wire types + // 0/1/2/5, including fixed64/double and fixed32/float billing values. if (env.DEVIN_CONNECT_DUMP_RAW === '1') { queue.push({ type: 'raw-frame', endStream: false, hex: frame.payload.toString('hex') }); } if (frameDump) log.info(`DEVIN_CONNECT frame dump (top-level tag=value): ${JSON.stringify(frameDump)}`); - if (metaDump) log.info(`DEVIN_CONNECT meta dump (tag=value varints): ${JSON.stringify(metaDump)}`); + if (metaDump) log.info(`DEVIN_CONNECT meta dump (tag=value fields): ${JSON.stringify(metaDump)}`); if (subDump) log.info(`DEVIN_CONNECT sub-message dump (top-tag → inner tag=value): ${JSON.stringify(subDump)}`); // When dumping, also surface the raw dumps as a structured event so a // calibration consumer can aggregate tags without scraping logs. Pure diff --git a/src/handlers/chat.js b/src/handlers/chat.js index c8960dd8..79337f14 100644 --- a/src/handlers/chat.js +++ b/src/handlers/chat.js @@ -2507,17 +2507,21 @@ export function shouldAutoFallback(body, context, result) { } /** - * Credit cost of one connect request, from the calibrated billing passthrough. + * Separate spend units of one connect request, from the calibrated billing + * passthrough. Credits and ACUs are not interchangeable. * - * Returns 0 when the tags are not calibrated (the default) so per-account spend is - * unchanged on those deployments. `committed_acu_cost` is the ACU figure #239 asks - * for; `credit_cost` is the credit figure. They are different units, so prefer the - * credit one for the credit column and never add them together. + * Returns zeros when a billing field is absent so per-account spend is unchanged. + * `committed_acu_cost` is the fractional ACU figure #239 asks for; + * `credit_cost` is the legacy credit figure. */ -function connectCreditCost(billing) { - if (!billing || typeof billing !== 'object') return 0; +function connectBillingSpend(billing) { + if (!billing || typeof billing !== 'object') return { creditCost: 0, acuCost: 0 }; const credit = Number(billing.credit_cost ?? billing.committed_credit_cost); - return Number.isFinite(credit) && credit > 0 ? credit : 0; + const acu = Number(billing.committed_acu_cost); + return { + creditCost: Number.isFinite(credit) && credit > 0 ? credit : 0, + acuCost: Number.isFinite(acu) && acu > 0 ? acu : 0, + }; } export async function handleChatCompletions(body, context = {}) { @@ -3397,7 +3401,7 @@ async function _handleChatCompletionsInner(body, context = {}) { // before → "Token 用量分布" empty on connect deployments. try { recordTokenUsage(_sr?.usage); } catch {} // K8: attribute the spend to THIS account (per-account lifetime total). - try { recordAccountSpend(a?.apiKey, _sr?.usage, { creditCost: connectCreditCost(_sr?.billing) }); } catch {} + try { recordAccountSpend(a?.apiKey, _sr?.usage, connectBillingSpend(_sr?.billing)); } catch {} finalizeConnectAccount(a, { model: reqModelName, selector, startTime: ccStart, err: null }); return { kind: 'ok', sr: _sr }; } catch (err) { @@ -3427,7 +3431,7 @@ async function _handleChatCompletionsInner(body, context = {}) { try { const _sr2 = await streamChatCompletion(params, send, connectMeta); try { recordTokenUsage(_sr2?.usage); } catch {} - try { recordAccountSpend(a?.apiKey, _sr2?.usage, { creditCost: connectCreditCost(_sr2?.billing) }); } catch {} + try { recordAccountSpend(a?.apiKey, _sr2?.usage, connectBillingSpend(_sr2?.billing)); } catch {} finalizeConnectAccount(a, { model: reqModelName, selector, startTime: ccStart, err: null }); return { kind: 'ok', sr: _sr2 }; } catch (retryErr) { @@ -3457,7 +3461,7 @@ async function _handleChatCompletionsInner(body, context = {}) { try { const _sr3 = await streamChatCompletion({ ...connectParams, token: freshKey }, send, connectMeta); try { recordTokenUsage(_sr3?.usage); } catch {} - try { recordAccountSpend(currentApiKeyForId(a.id, a.apiKey), _sr3?.usage, { creditCost: connectCreditCost(_sr3?.billing) }); } catch {} + try { recordAccountSpend(currentApiKeyForId(a.id, a.apiKey), _sr3?.usage, connectBillingSpend(_sr3?.billing)); } catch {} finalizeConnectAccount(a, { model: reqModelName, selector, startTime: ccStart, err: null }); return { kind: 'ok', sr: _sr3 }; } catch (retryErr) { @@ -3687,7 +3691,7 @@ async function _handleChatCompletionsInner(body, context = {}) { // when usage is absent (recordTokenUsage guards null). try { recordTokenUsage(r.out?.body?.usage); } catch {} // K8: per-account lifetime spend (non-stream connect path). - try { recordAccountSpend(acct ? currentApiKeyForId(acct.id, acct.apiKey) : null, r.out?.body?.usage, { creditCost: connectCreditCost(r.out?.body?._windsurf_billing) }); } catch {} + try { recordAccountSpend(acct ? currentApiKeyForId(acct.id, acct.apiKey) : null, r.out?.body?.usage, connectBillingSpend(r.out?.body?._windsurf_billing)); } catch {} // Session continuity: commit the completed request→response pair so the // next turn resolves to this session_id via pair-chain overlap. The // response tool_calls already ride the OpenAI function shape here. @@ -6442,4 +6446,4 @@ function streamResponse(id, created, model, modelKey, provider, messages, cascad // path funnels through (connect via acquireConnectAccount, both cascade paths directly), // so queue-on-pin is tested by driving it end to end rather than by re-implementing the // wait loop in a fixture — a fixture would pass while the real hook was wired wrong. -export const __testing = { waitForAccount, waitForOwnPin }; +export const __testing = { waitForAccount, waitForOwnPin, connectBillingSpend }; diff --git a/src/windsurf-api.js b/src/windsurf-api.js index 6c875dc2..9a6a10eb 100644 --- a/src/windsurf-api.js +++ b/src/windsurf-api.js @@ -311,8 +311,23 @@ function normalizeUserStatus(data) { const ps = data?.userStatus?.planStatus || {}; const plan = ps.planInfo || {}; + // Capability discovery, not plan-name detection: newer Devin-backed plans + // expose fractional ACU accounting directly on PlanStatus. Keep absence as + // null (never manufacture a zero), and never persist the raw UserStatus where + // org/user identifiers and deployment URLs also live. + const nonNegativeNumber = (value) => { + if (value == null || value === '') return null; + const n = Number(value); + return Number.isFinite(n) && n >= 0 ? n : null; + }; + // Legacy values come in hundredths; divide by 100 for display. - const legacyDiv = (n) => (typeof n === 'number' ? n / 100 : null); + // Devin-backed plans use -1 as a legacy-credit "not applicable" sentinel. + // Treat it as absence so it cannot render as -0.01 credits or masquerade as + // a personal quota alongside ACU accounting. + const legacyDiv = (n) => ( + typeof n === 'number' && Number.isFinite(n) && n >= 0 ? n / 100 : null + ); // Unix timestamps may be numeric or string depending on server version. const asUnix = (v) => { @@ -338,6 +353,8 @@ function normalizeUserStatus(data) { dailyResetAt: asUnix(ps.dailyQuotaResetAtUnix), weeklyResetAt: asUnix(ps.weeklyQuotaResetAtUnix), overageBalance: typeof ps.overageBalanceMicros === 'number' ? ps.overageBalanceMicros / 1_000_000 : null, + acuConsumed: nonNegativeNumber(ps.acuConsumed ?? ps.acu_consumed), + acuLimit: nonNegativeNumber(ps.acuLimit ?? ps.acu_limit), prompt: { limit: legacyDiv(plan.monthlyPromptCredits), used: legacyDiv(ps.usedPromptCredits), diff --git a/test/acu-discovery.test.js b/test/acu-discovery.test.js new file mode 100644 index 00000000..9e27353f --- /dev/null +++ b/test/acu-discovery.test.js @@ -0,0 +1,110 @@ +import { afterEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { getAccountAcuUsage } from '../src/auth.js'; +import { __setWindsurfApiPostJsonForTest, getUserStatus } from '../src/windsurf-api.js'; + +afterEach(() => __setWindsurfApiPostJsonForTest(null)); + +describe('ACU capability discovery', () => { + it('normalizes fractional ACU fields from Cascade GetUserStatus', async () => { + __setWindsurfApiPostJsonForTest(async () => ({ + status: 200, + raw: '{}', + data: { + userStatus: { + planStatus: { + planInfo: { planName: 'Unrecognized future plan' }, + acuConsumed: 0.1554225, + acuLimit: 10000, + }, + }, + }, + })); + + const status = await getUserStatus('fixture-session-token'); + assert.equal(status.acuConsumed, 0.1554225); + assert.equal(status.acuLimit, 10000); + }); + + it('treats negative legacy quota sentinels as absent when ACU accounting is present', async () => { + __setWindsurfApiPostJsonForTest(async () => ({ + status: 200, + raw: '{}', + data: { + userStatus: { + planStatus: { + planInfo: { + planName: 'Unrecognized future plan', + monthlyPromptCredits: -1, + monthlyFlexCreditPurchaseAmount: -1, + }, + usedPromptCredits: -1, + availablePromptCredits: -1, + usedFlexCredits: -1, + availableFlexCredits: -1, + acuConsumed: 0, + }, + }, + }, + })); + + const status = await getUserStatus('fixture-session-token'); + assert.deepEqual(status.prompt, { limit: null, used: null, remaining: null }); + assert.deepEqual(status.flex, { limit: null, used: null, remaining: null }); + assert.equal(status.acuConsumed, 0); + }); + + it('prefers the upstream cycle snapshot over the local lifetime counter', () => { + assert.deepEqual(getAccountAcuUsage({ + credits: { acuConsumed: 0.25, acuLimit: 50 }, + _totalSpend: { acuCost: 0.5 }, + }), { + consumed: 0.25, + limit: 50, + source: 'get_user_status', + }); + }); + + it('keeps a reported zero ACU as a discovered accounting signal', () => { + assert.deepEqual(getAccountAcuUsage({ + credits: { acuConsumed: 0, acuLimit: null }, + }), { + consumed: 0, + limit: null, + source: 'get_user_status', + }); + }); + + it('does not manufacture zero consumption from a limit-only status', () => { + assert.equal(getAccountAcuUsage({ + credits: { acuLimit: 50 }, + }), null); + + assert.deepEqual(getAccountAcuUsage({ + credits: { acuLimit: 50 }, + _totalSpend: { acuCost: 0.25 }, + }), { + consumed: 0.25, + limit: null, + source: 'local_billing', + }); + }); + + it('falls back to DEVIN_CONNECT billing without inspecting plan identifiers', () => { + assert.deepEqual(getAccountAcuUsage({ + credits: { planName: 'anything-at-all' }, + _totalSpend: { acuCost: 0.0006735 }, + }), { + consumed: 0.0006735, + limit: null, + source: 'local_billing', + }); + }); + + it('does not invent ACU support when neither upstream nor billing reports it', () => { + assert.equal(getAccountAcuUsage({ + credits: { planName: 'Cognition Platform (Enterprise)' }, + _totalSpend: { acuCost: 0 }, + }), null); + }); +}); diff --git a/test/auth-total-spend.test.js b/test/auth-total-spend.test.js index dea394a5..f4f0ef46 100644 --- a/test/auth-total-spend.test.js +++ b/test/auth-total-spend.test.js @@ -25,7 +25,7 @@ describe('K8 — per-account lifetime spend', () => { const a = addTestAccount(); const pub = getAccountPublic(a.id); assert.deepEqual(pub.totalSpend, { - requests: 0, totalTokens: 0, promptTokens: 0, completionTokens: 0, creditCost: 0, + requests: 0, totalTokens: 0, promptTokens: 0, completionTokens: 0, creditCost: 0, acuCost: 0, }); }); @@ -53,6 +53,17 @@ describe('K8 — per-account lifetime spend', () => { assert.equal(getAccountPublic(a.id).totalSpend.creditCost, 0.25); }); + it('accrues fractional acuCost independently from credits', () => { + const a = addTestAccount(); + recordAccountSpend(a.apiKey, { total_tokens: 5 }, { + creditCost: 2.5, + acuCost: 0.0006735000060871243, + }); + const spend = getAccountPublic(a.id).totalSpend; + assert.equal(spend.creditCost, 2.5); + assert.equal(spend.acuCost, 0.0006735000060871243); + }); + it('is a safe no-op for an unknown apiKey', () => { assert.doesNotThrow(() => recordAccountSpend('no-such-key', { total_tokens: 999 })); }); @@ -68,7 +79,9 @@ describe('K8 — per-account lifetime spend', () => { it('survives a serialize → load round-trip (monotonic across restart)', () => { const a = addTestAccount(); - recordAccountSpend(a.apiKey, { prompt_tokens: 200, completion_tokens: 40, total_tokens: 240 }, { creditCost: 1.5 }); + recordAccountSpend(a.apiKey, { prompt_tokens: 200, completion_tokens: 40, total_tokens: 240 }, { + creditCost: 1.5, acuCost: 0.0006735, + }); const serialized = __serializeAccounts().find(x => x.id === a.id); assert.ok(serialized._totalSpend, 'persisted'); assert.equal(serialized._totalSpend.totalTokens, 240); @@ -77,6 +90,7 @@ describe('K8 — per-account lifetime spend', () => { assert.equal(restored._totalSpend.totalTokens, 240); assert.equal(restored._totalSpend.promptTokens, 200); assert.equal(restored._totalSpend.creditCost, 1.5); + assert.equal(restored._totalSpend.acuCost, 0.0006735); assert.equal(restored._totalSpend.requests, 1); }); @@ -85,7 +99,7 @@ describe('K8 — per-account lifetime spend', () => { const legacy = { id: 'legacy-1', apiKey: 'legacy-key', status: 'active' }; const restored = __deserializeAccount(legacy); assert.deepEqual(restored._totalSpend, { - requests: 0, totalTokens: 0, promptTokens: 0, completionTokens: 0, creditCost: 0, + requests: 0, totalTokens: 0, promptTokens: 0, completionTokens: 0, creditCost: 0, acuCost: 0, }); }); }); diff --git a/test/billing-userstatus-decode.test.js b/test/billing-userstatus-decode.test.js index de7f8974..2b6dcf09 100644 --- a/test/billing-userstatus-decode.test.js +++ b/test/billing-userstatus-decode.test.js @@ -21,6 +21,7 @@ import { writeMessageField, writeVarintField, writeStringField, + writeFixed64Field, writeFixed32Field, } from '../src/proto.js'; import { decodeUserStatusFull } from '../src/devin-connect-catalog.js'; @@ -42,12 +43,20 @@ function buildPlanDetail({ name, rates = [] }) { } /** Helper: build #1.13 (billing block) */ -function buildBillingBlock({ balance, periodStart, periodEnd, plan }) { +function buildDoubleField(field, value) { + const buf = Buffer.allocUnsafe(8); + buf.writeDoubleLE(value, 0); + return writeFixed64Field(field, buf); +} + +function buildBillingBlock({ balance, periodStart, periodEnd, acuConsumed, acuLimit, plan }) { const billingFields = []; if (plan) billingFields.push(buildPlanDetail(plan)); if (balance != null) billingFields.push(writeVarintField(16, balance)); if (periodStart != null) billingFields.push(writeVarintField(17, periodStart)); if (periodEnd != null) billingFields.push(writeVarintField(18, periodEnd)); + if (acuConsumed != null) billingFields.push(buildDoubleField(19, acuConsumed)); + if (acuLimit != null) billingFields.push(buildDoubleField(20, acuLimit)); return writeMessageField(13, Buffer.concat(billingFields)); } @@ -91,6 +100,19 @@ test('decodeUserStatusFull: paid account with full billing ledger', () => { assert.equal(result.rateTable[4], 12); }); +test('decodeUserStatusFull: discovers fractional ACU accounting without a plan-name gate', () => { + const billing = buildBillingBlock({ + acuConsumed: 0.1554225, + acuLimit: 10000, + plan: { name: 'Unrecognized future plan', rates: [] }, + }); + + const result = decodeUserStatusFull(buildUserStatusResponse({ billing })); + + assert.equal(result.acuConsumed, 0.1554225); + assert.equal(result.acuLimit, 10000); +}); + test('decodeUserStatusFull: catalog pairing (rates → selectors)', () => { const billing = buildBillingBlock({ balance: 100_000_000, @@ -131,6 +153,8 @@ test('decodeUserStatusFull: free account (minimal billing)', () => { assert.equal(result.balanceUnit, null); assert.equal(result.periodStart, null); assert.equal(result.periodEnd, null); + assert.equal(result.acuConsumed, null); + assert.equal(result.acuLimit, null); assert.equal(result.rateTable, null); }); diff --git a/test/connect-rate-table-wiring.test.js b/test/connect-rate-table-wiring.test.js index 744f982b..803a5714 100644 --- a/test/connect-rate-table-wiring.test.js +++ b/test/connect-rate-table-wiring.test.js @@ -24,6 +24,7 @@ import { recordAccountSpend, getCurrentlyFreeConnectSelectors, isConnectSelectorCurrentlyFree, isConnectSelectorBlockedByDrought, } from '../src/auth.js'; +import { __testing as chatTesting } from '../src/handlers/chat.js'; const FREE_SELECTOR = 'swe-1-6-slow'; const PROMO_SELECTOR = 'glm-5-2-none'; @@ -144,6 +145,16 @@ describe('rate table → currently-free selectors (#235)', () => { }); describe('per-request credit cost reaches per-account spend (#239)', () => { + it('maps credit and ACU into separate spend units', () => { + assert.deepEqual(chatTesting.connectBillingSpend({ + credit_cost: 2.5, + committed_acu_cost: 0.0006735, + }), { + creditCost: 2.5, + acuCost: 0.0006735, + }); + }); + it('accumulates creditCost across requests', () => { const acct = mk({ weeklyPercent: 50 }); const usage = { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }; @@ -156,14 +167,26 @@ describe('per-request credit cost reaches per-account spend (#239)', () => { assert.equal(row.totalSpend.requests, 2); }); - it('stays at zero when billing tags are not calibrated', () => { - // The default on every deployment that has not calibrated against a paid token. - // Must be a no-op rather than a NaN or a crash. + it('accumulates fractional ACU separately from credit cost', () => { + const acct = mk({ weeklyPercent: 50 }); + const usage = { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }; + + recordAccountSpend(acct.apiKey, usage, { acuCost: 0.0006735 }); + recordAccountSpend(acct.apiKey, usage, { acuCost: 0.00125 }); + + const row = getAccountList().find((a) => a.id === acct.id); + assert.equal(row.totalSpend.acuCost, 0.0019235); + assert.equal(row.totalSpend.creditCost, 0); + }); + + it('stays at zero when billing fields are absent', () => { + // Missing billing fields must be a no-op rather than a NaN or a crash. const acct = mk({ weeklyPercent: 50 }); recordAccountSpend(acct.apiKey, { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }); const row = getAccountList().find((a) => a.id === acct.id); assert.equal(row.totalSpend.creditCost, 0); + assert.equal(row.totalSpend.acuCost, 0); assert.equal(row.totalSpend.requests, 1); }); }); diff --git a/test/dashboard-syntax.test.js b/test/dashboard-syntax.test.js index 224c6945..5fde5151 100644 --- a/test/dashboard-syntax.test.js +++ b/test/dashboard-syntax.test.js @@ -65,6 +65,26 @@ test('dashboard drought banners expose restriction fail-open state', () => { assert.equal(typeof zh.drought.restrictionFailOpen, 'string'); }); +test('dashboard account detail renders fractional ACU separately from credits', () => { + const html = readFileSync(join(root, 'src/dashboard/index.html'), 'utf8'); + const auth = readFileSync(join(root, 'src/auth.js'), 'utf8'); + const api = readFileSync(join(root, 'src/dashboard/api.js'), 'utf8'); + const en = JSON.parse(readFileSync(join(root, 'src/dashboard/i18n/en.json'), 'utf8')); + const zh = JSON.parse(readFileSync(join(root, 'src/dashboard/i18n/zh-CN.json'), 'utf8')); + + assert.match(html, /const acus = Number\(sp\.acuCost\) \|\| 0/); + assert.match(html, /const showAcuQuota = !!acu && !hasPersonalQuota/); + assert.match(html, /acu\.source === 'get_user_status'/); + assert.doesNotMatch(html, /Cognition Platform \(Enterprise\).*showAcuQuota/); + assert.match(html, /runtime\.spendAcus/); + assert.equal(en.account.detail.runtime.spendAcus, 'ACU cost'); + assert.equal(zh.account.detail.runtime.spendAcus, 'ACU 消耗'); + assert.equal(en.account.detail.quota.acuUsed, 'ACU consumed'); + assert.equal(zh.account.detail.quota.acuUsed, 'ACU 已用'); + assert.match(auth, /delete persist\.raw/); + assert.match(api, /delete safe\.raw/); +}); + test('dashboard proxy and abnormal-account tables use paged account summaries', () => { const html = readFileSync(join(root, 'src/dashboard/index.html'), 'utf8'); assert.match(html, /id="proxy-accounts-pagination"/); diff --git a/test/devin-connect-calibrate.test.js b/test/devin-connect-calibrate.test.js index 73a36991..10195ad0 100644 --- a/test/devin-connect-calibrate.test.js +++ b/test/devin-connect-calibrate.test.js @@ -12,10 +12,12 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { spawn } from 'node:child_process'; +import { writeFileSync, unlinkSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { - classifyTag, aggregateDumps, findCandidates, runCalibration, statusTable, + classifyTag, aggregateDumps, findCandidates, resolveToken, runCalibration, statusTable, FREE_BASELINE, TARGETS, } from '../scripts/devin-connect-calibrate.mjs'; @@ -54,6 +56,21 @@ describe('devin-connect calibrate harness — gating', () => { }); }); +describe('resolveToken — persisted account lookup', () => { + it('uses the configured account file and prefers an active account', () => { + const file = join(tmpdir(), `windsurfapi-calibrate-${process.pid}-${Date.now()}.json`); + writeFileSync(file, JSON.stringify([ + { status: 'disabled', apiKey: 'disabled-token' }, + { status: 'active', apiKey: 'active-token' }, + ])); + try { + assert.equal(resolveToken({}, file), 'active-token'); + } finally { + unlinkSync(file); + } + }); +}); + describe('classifyTag — wire-shape → target bucket', () => { it('routes a meta varint to billing/cache (#46)', () => { const r = classifyTag({ scope: 'meta', tag: 14, kind: 'varint', preview: 1500 }); @@ -68,12 +85,37 @@ describe('classifyTag — wire-shape → target bucket', () => { assert.equal(r.task, '#47'); }); + it('does not misclassify paid provider #21="anthropic" as actual_model_uid', () => { + const r = classifyTag({ scope: 'top', tag: 21, kind: 'string', preview: 'anthropic' }); + assert.equal(r.bucket, 'provider'); + assert.deepEqual(r.targets, []); + }); + + it('recognises the live actual model at metadata #7.9', () => { + const r = classifyTag({ + scope: 'sub', topTag: 7, tag: 9, path: '7.9', + kind: 'string', preview: 'claude-sonnet-4-6-thinking', + }); + assert.equal(r.bucket, 'actual_model_uid'); + assert.deepEqual(r.targets, ['actual_model_uid']); + }); + it('routes a top-level sub-message to tool_calls (#49)', () => { const r = classifyTag({ scope: 'top', tag: 12, kind: 'message', preview: '' }); assert.equal(r.bucket, 'tool_calls'); assert.equal(r.task, '#49'); }); + it('routes paid-verified top-level #22 fixed64 to committed ACU billing (#239)', () => { + const r = classifyTag({ + scope: 'top', tag: 22, kind: 'fixed64', preview: 0.0006735000060871243, + }); + assert.equal(r.bucket, 'billing/acu'); + assert.equal(r.task, '#239'); + assert.deepEqual(r.targets, ['billing']); + assert.match(r.detail, /committed_acu_cost/); + }); + it('marks an unrecognized shape as unknown with no targets', () => { const r = classifyTag({ scope: 'meta', tag: 99, kind: 'message', preview: '' }); assert.equal(r.bucket, 'unknown'); @@ -93,6 +135,18 @@ describe('aggregateDumps — per-frame dumps → tag inventory', () => { assert.equal(inv.meta[14].kind, 'varint'); assert.equal(inv.meta[14].preview, 1500); }); + + it('preserves structured fixed-width values emitted by decodeFrame', () => { + const raw = '00000040ba11463f'; + const acu = 0.0006735000060871243; + const inv = aggregateDumps( + [{ 22: { kind: 'fixed64', preview: acu, raw } }], + [], + ); + assert.deepEqual(inv.top[22], { kind: 'fixed64', preview: acu, raw }); + const { candidates } = findCandidates(inv); + assert.ok(candidates.some((c) => c.tag === 22 && c.bucket === 'billing/acu')); + }); }); describe('findCandidates — diff against the free baseline', () => { @@ -135,6 +189,22 @@ describe('runCalibration — env-line generation', () => { assert.ok(report.envLines.some((l) => /14,15/.test(l))); }); + it('emits the paid-verified top-level ACU mapping for a #22 double', async () => { + const report = await runCalibration({ + real: false, + deps: { + frameDumps: [{ + 22: { + kind: 'fixed64', + preview: 0.0006735000060871243, + raw: '00000040ba11463f', + }, + }], + }, + }); + assert.ok(report.envLines.some((l) => /committed_acu_cost=\^22/.test(l))); + }); + it('surfaces a probe error without throwing', async () => { // No real path, no deps dumps → empty inventory, modelAlive defaults true. const report = await runCalibration({ real: false, deps: {} }); diff --git a/test/devin-connect-finish-callsite.test.js b/test/devin-connect-finish-callsite.test.js index f33bcaf7..e58f0e5e 100644 --- a/test/devin-connect-finish-callsite.test.js +++ b/test/devin-connect-finish-callsite.test.js @@ -13,7 +13,9 @@ import { describe, it, afterEach } from 'node:test'; import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { streamChat, __setRequestImpl, normalizeConnectUsage } from '../src/devin-connect.js'; -import { writeStringField, writeVarintField, writeMessageField } from '../src/proto.js'; +import { + writeStringField, writeVarintField, writeMessageField, writeFixed64Field, +} from '../src/proto.js'; import { wrapEnvelope, endOfStreamEnvelope } from '../src/connect.js'; const TOKEN = 'devin-session-token$test.jwt.sig'; @@ -104,6 +106,26 @@ describe('the finish event calls normalizeConnectUsage (call-site guard)', () => }); }); +describe('the finish event preserves paid ACU billing (call-site guard)', () => { + it('carries top-level #22 fixed64 through the default billing map', async () => { + const acu = 0.0006735000060871243; + const raw = Buffer.alloc(8); + raw.writeDoubleLE(acu, 0); + + const finish = await finishEventFrom([ + Buffer.concat([ + writeStringField(3, 'ok'), + writeVarintField(5, 2), + metaFrame({ prompt: 12, completion: 3 }), + writeFixed64Field(22, raw), + ]), + ]); + + assert.ok(finish, 'the stream must reach its terminal finish event'); + assert.deepEqual(finish.billing, { committed_acu_cost: acu }); + }); +}); + describe('the finish event resolves truncation from usage (call-site guard)', () => { it('reports "length" when completion_tokens hits the cap the caller requested', async () => { // Nothing in the enum says "truncated" — only 2 and 4 are pinned by live diff --git a/test/devin-connect-openai.test.js b/test/devin-connect-openai.test.js index 8b348fa7..f09faf0c 100644 --- a/test/devin-connect-openai.test.js +++ b/test/devin-connect-openai.test.js @@ -84,6 +84,16 @@ describe('toChatCompletion (non-stream)', () => { assert.equal('usage' in body, false); }); + it('preserves fractional ACU billing for the account-spend caller', async () => { + const acu = 0.0006735000060871243; + __setStreamChatForTest(fakeStream([ + { type: 'content', text: 'ok' }, + { type: 'finish', reason: 'stop', usage: null, billing: { committed_acu_cost: acu } }, + ])); + const { body } = await toChatCompletion({ model: 'm', messages: [] }); + assert.deepEqual(body._windsurf_billing, { committed_acu_cost: acu }); + }); + it('uses a stable id/created when supplied', async () => { __setStreamChatForTest(fakeStream(SAMPLE)); const { body } = await toChatCompletion({ model: 'm', messages: [] }, { id: 'chatcmpl-fixed', created: 123 }); @@ -194,6 +204,18 @@ describe('streamChatCompletion (SSE)', () => { assert.equal(frames.some(f => f.choices.length === 0), false); }); + it('returns fractional ACU billing to the handler without exposing it in SSE chunks', async () => { + const acu = 0.0006735000060871243; + __setStreamChatForTest(fakeStream([ + { type: 'content', text: 'ok' }, + { type: 'finish', reason: 'stop', usage: null, billing: { committed_acu_cost: acu } }, + ])); + const { send, frames } = collectSend(); + const result = await streamChatCompletion({ model: 'm', messages: [] }, send); + assert.deepEqual(result.billing, { committed_acu_cost: acu }); + assert.ok(frames.every((frame) => !('_windsurf_billing' in frame) && !('billing' in frame))); + }); + it('streams each content delta as its own chunk (verbatim, not coalesced)', async () => { __setStreamChatForTest(fakeStream([ { type: 'content', text: 'a' }, @@ -1452,4 +1474,3 @@ describe('think-text reroute (connect layer, DEVIN_CONNECT_THINKTEXT_REROUTE)', assert.equal(body.choices[0].message.content, 'ok'); }); }); - diff --git a/test/devin-connect.test.js b/test/devin-connect.test.js index 8e2e99f1..da7307c6 100644 --- a/test/devin-connect.test.js +++ b/test/devin-connect.test.js @@ -18,7 +18,7 @@ import { mergeToolCallFragment, } from '../src/devin-connect.js'; import { - writeStringField, writeVarintField, writeMessageField, + writeStringField, writeVarintField, writeMessageField, writeFixed64Field, parseFields, getField, getAllFields, } from '../src/proto.js'; import { wrapEnvelope, endOfStreamEnvelope } from '../src/connect.js'; @@ -651,13 +651,42 @@ describe('decodeFrame', () => { assert.deepEqual(decodeFrame(payload, { billingTags }).billing, { committed_credit_cost: 1400 }); }); - it('parseBillingTagMap: defaults to cache_read_tokens=5 + cache_write_tokens=4, parses pairs, rejects garbage', () => { + it('decodes paid-verified committed_acu_cost from top-level #22 fixed64/double', () => { + // A paid upstream capture showed top-level #22 carrying the exact same + // fractional ACU as Response Statistics #28.2.4.2. The old decoder forced + // every configured billing tag through wire type 0 (varint), so this real + // double was invisible even when committed_acu_cost=^22 was configured. + const acu = 0.0006735000060871243; + const raw = Buffer.alloc(8); + raw.writeDoubleLE(acu, 0); + const payload = Buffer.concat([ + writeStringField(1, 'bot-enterprise'), + writeFixed64Field(22, raw), + ]); + + const d = decodeFrame(payload, { + billingTags: { committed_acu_cost: -22 }, + dumpMeta: true, + }); + + assert.equal(d.billing.committed_acu_cost, acu); + assert.deepEqual(d.frameDump[22], { + kind: 'fixed64', preview: acu, raw: raw.toString('hex'), + }); + }); + + it('parseBillingTagMap: defaults to calibrated cache + ACU tags, parses pairs, rejects garbage', () => { const { parseBillingTagMap } = __testing; - // Unset → the calibrated defaults (#220 + 2026-07-25): + // Unset → the calibrated defaults (#220, 2026-07-25, #239): // tag 5 = cache_read (confirmed on GPT+Claude paid accounts) // tag 4 = cache_write (confirmed on Claude paid account — GPT carries no tag 4) - assert.deepEqual(parseBillingTagMap({}), { cache_read_tokens: 5, cache_write_tokens: 4 }); - assert.deepEqual(parseBillingTagMap({ DEVIN_CONNECT_BILLING_TAGS: ' ' }), { cache_read_tokens: 5, cache_write_tokens: 4 }); + // top-level tag 22 = committed_acu_cost (paid fixed64/double capture) + assert.deepEqual(parseBillingTagMap({}), { + cache_read_tokens: 5, cache_write_tokens: 4, committed_acu_cost: -22, + }); + assert.deepEqual(parseBillingTagMap({ DEVIN_CONNECT_BILLING_TAGS: ' ' }), { + cache_read_tokens: 5, cache_write_tokens: 4, committed_acu_cost: -22, + }); // Explicit opt-out decodes nothing at all. assert.equal(parseBillingTagMap({ DEVIN_CONNECT_BILLING_TAGS: 'off' }), null); assert.equal(parseBillingTagMap({ DEVIN_CONNECT_BILLING_TAGS: 'OFF' }), null); diff --git a/test/strict-usage-total.test.js b/test/strict-usage-total.test.js index a5b300d8..70724a5c 100644 --- a/test/strict-usage-total.test.js +++ b/test/strict-usage-total.test.js @@ -187,7 +187,7 @@ describe('cost accounting stays honest under BOTH shapes', () => { recordAccountSpend(a.apiKey, { prompt_tokens: -500, completion_tokens: -10, total_tokens: -510 }, { creditCost: -7 }); const spend = getAccountInternal(a.id)._totalSpend; - for (const k of ['totalTokens', 'promptTokens', 'completionTokens', 'creditCost']) { + for (const k of ['totalTokens', 'promptTokens', 'completionTokens', 'creditCost', 'acuCost']) { assert.equal(spend[k], 0, `${k} went to ${spend[k]} — these are CUMULATIVE counters, so one malformed upstream ` + 'usage block would drag it negative permanently and no later request could undo it'); @@ -211,12 +211,12 @@ describe('the spend tally is monotonic across every counter', () => { const a = seed('mono'); // _totalSpend is created lazily by the first recordAccountSpend, so default the // counters rather than reading undefined and comparing it with >=. - const ZERO = { requests: 0, totalTokens: 0, promptTokens: 0, completionTokens: 0, creditCost: 0 }; + const ZERO = { requests: 0, totalTokens: 0, promptTokens: 0, completionTokens: 0, creditCost: 0, acuCost: 0 }; const snap = () => ({ ...ZERO, ...(getAccountInternal(a.id)._totalSpend || {}) }); const t0 = snap(); recordAccountSpend(a.apiKey, - { prompt_tokens: -900, completion_tokens: -40, total_tokens: -1000 }, { creditCost: -3 }); + { prompt_tokens: -900, completion_tokens: -40, total_tokens: -1000 }, { creditCost: -3, acuCost: -0.5 }); const t1 = snap(); recordAccountSpend(a.apiKey, buildUsageBody(SERVER_USAGE, [], 'x'), { creditCost: 2 }); const t2 = snap(); @@ -235,9 +235,9 @@ describe('the spend tally is monotonic across every counter', () => { it('NaN and non-numeric usage fields cannot poison the running total', () => { const a = seed('nan'); recordAccountSpend(a.apiKey, - { prompt_tokens: NaN, completion_tokens: 'x', total_tokens: undefined }, { creditCost: NaN }); + { prompt_tokens: NaN, completion_tokens: 'x', total_tokens: undefined }, { creditCost: NaN, acuCost: NaN }); const s = getAccountInternal(a.id)._totalSpend; - for (const k of ['totalTokens', 'promptTokens', 'completionTokens', 'creditCost']) { + for (const k of ['totalTokens', 'promptTokens', 'completionTokens', 'creditCost', 'acuCost']) { assert.ok(Number.isFinite(s[k]), `${k} is ${s[k]} — one NaN would make it NaN forever`); assert.equal(s[k], 0); }