From e34610e180744f5947a40cc9350dbae0a99871f4 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Sat, 10 Oct 2026 00:28:23 -0700 Subject: [PATCH] Braincells: a hold prices input as input; the worker edits in place; an empty brain says so A 25,000-character piece was refused as out of braincells with 386,000 in the wallet: the hold priced every input token at the output rate, four braincells a token for Sonnet, so one call held over 400,000 for work that cost 90,000. The hold now prices input at the input rate and only the output allowance at the output rate; fixed-tariff models are unchanged. The worker turns on layered edits like the phone, so a revision is small exact replacements instead of a 16,000-token rewrite each round, and it tells the phone which tool is streaming so the code ticker parses edits. On the phone a refused turn marks the brain empty: the top-bar icon goes red with a badge, the meter says so in red, and the failed row offers the brain in place of Try again, since trying again would fail the same way. The mark clears when the wallet grows or a turn goes through. --- apple/whistlegraph/Resources/Web/engine.mjs | 17 +++++++---- .../whistlegraph/Sources/BrainSettings.swift | 29 +++++++++++++++---- .../Sources/WhistlegraphScreen.swift | 14 +++++++-- lith/whistlegraph-worker.mjs | 13 +++++---- system/backend/easel-paid-credits.mjs | 20 +++++++++++-- system/backend/whistlegraph-turns.mjs | 2 +- system/tests/easel-paid-credits.test.mjs | 13 ++++++++- 7 files changed, 86 insertions(+), 22 deletions(-) diff --git a/apple/whistlegraph/Resources/Web/engine.mjs b/apple/whistlegraph/Resources/Web/engine.mjs index 877b4bba12..27abd38c33 100644 --- a/apple/whistlegraph/Resources/Web/engine.mjs +++ b/apple/whistlegraph/Resources/Web/engine.mjs @@ -66,6 +66,12 @@ const signIn=document.createElement('button');signIn.id='connect-ac';signIn.text let accountToken='',accountHandle='',accountPalette=[],accountVerification=Promise.resolve(); let personalAccess=null,turnPersonalAccess=false; let turnHandle='',turnModel='',activeModel='',braincells=null,braincellsError='',creditsRequest=0; +// The wallet as of the last refusal. A refused turn marks the brain empty +// until the wallet grows past that or a turn goes through, so the icon says +// so instead of a row that fails quietly each time. +let braincellsEmptyAt=null; +function noteBraincells(message){if(/out of braincells/i.test(String(message||'')))braincellsEmptyAt=braincells&&braincells.unlimited!==true?braincells.remaining+braincells.purchased:0;} +function braincellsEmpty(){return braincellsEmptyAt!==null||(!!braincells&&braincells.unlimited!==true&&braincells.remaining+braincells.purchased<=0);} function selectedModel(handle=accountHandle){try{return localStorage.getItem('whistlegraph-model-'+handle)||'';}catch{return '';}} function profile(repair=false){return generationProfile(busy?turnHandle:accountHandle,{repair,image:busy&&!!inputData(turnRequest)?.drawing,personalAccess:busy?turnPersonalAccess:hasPersonalAccess(personalAccess),model:busy?turnModel:selectedModel()});} async function refreshBraincells(){ @@ -80,13 +86,13 @@ async function refreshBraincells(){ const value=await response.json(); if(![value.remaining,value.used,value.limit,value.purchased].every(v=>Number.isFinite(v)&&v>=0))throw Error('Braincells unavailable'); if(token!==currentToken||request!==creditsRequest)return; - braincells=value;braincellsError='';benchmark('braincellsLoaded'); + braincells=value;braincellsError='';benchmark('braincellsLoaded');if(braincellsEmptyAt!==null&&(value.unlimited===true||value.remaining+value.purchased>braincellsEmptyAt))braincellsEmptyAt=null; }catch{if(token!==currentToken||request!==creditsRequest)return;braincells=null;braincellsError='Could not load braincells. Check your connection and tap Refresh.';benchmark('braincellsFailed');} nativeSnapshot(); } function inferenceSnapshot(){ const model=activeModel||profile().model,receipt=activeReceipt?.value||receipts.rows.at(-1)?.receipt; - return {model,label:MODEL_LABELS[model]||model,provider:profile().personalRelay?(model.startsWith('openai/')?'Personal Codex':'Personal Claude'):'OpenRouter',selection:profile().model,models:modelChoices(accountHandle,{personalAccess:hasPersonalAccess(personalAccess)}),braincells,braincellsError,threadCost:receipts.cost.snapshot(), + return {model,label:MODEL_LABELS[model]||model,provider:profile().personalRelay?(model.startsWith('openai/')?'Personal Codex':'Personal Claude'):'OpenRouter',selection:profile().model,models:modelChoices(accountHandle,{personalAccess:hasPersonalAccess(personalAccess)}),braincells,braincellsError,empty:braincellsEmpty(),threadCost:receipts.cost.snapshot(), usage:receipt?{inputTokens:receipt.rounds.reduce((n,r)=>n+(r.usage?.inputTokens||0),0),outputTokens:receipt.rounds.reduce((n,r)=>n+(r.usage?.outputTokens||0),0),rounds:receipt.rounds.length,repairs:receipt.repairs,status:receipt.status,cost:{usd:receipt.rounds.reduce((n,r)=>n+(r.usage?.costUSD||0),0),partial:receipt.rounds.some(r=>r.usage?.costUSD==null && (r.httpStatus==null || r.httpStatus<400)),estimated:receipt.rounds.some(r=>r.usage?.estimated)}}:null}; } function paintHandle(handle,colors=handleCharacterColors('@'+handle)){ @@ -364,7 +370,7 @@ function compileStream() { } function saved() { try { localStorage.setItem(storageKey,source); } catch {} updateFeed(); } function review(show) { $('speak').disabled=busy; $('speak-label').textContent=busy?'Working…':'Hold to talk'; } -function end() { clearTimeout(compileTimer);compileTimer=null; if(provisional && previewSource!==source){render(source||'export function paint({wipe}) {wipe("black");}');provisional='';} busy=false;activeModel='';void refreshBraincells(); updateFeed(); clearInterval(timer); $('live-stop').hidden=true; review(source!==previous); window.whistlegraphWorkFinished?.(); if(pendingAdopt){const next=pendingAdopt;pendingAdopt=null;adoptLedger(next);} } +function end() { if(turnSucceeded)braincellsEmptyAt=null; clearTimeout(compileTimer);compileTimer=null; if(provisional && previewSource!==source){render(source||'export function paint({wipe}) {wipe("black");}');provisional='';} busy=false;activeModel='';void refreshBraincells(); updateFeed(); clearInterval(timer); $('live-stop').hidden=true; review(source!==previous); window.whistlegraphWorkFinished?.(); if(pendingAdopt){const next=pendingAdopt;pendingAdopt=null;adoptLedger(next);} } let pendingAdopt=null; function adoptLedger(ledger){ try{ @@ -426,7 +432,7 @@ function makeServer({repair=false}={}){ if(method==='item/completed' && params.item?.status?.startsWith('failed')) {log(params.item.status);benchmark('toolFailed',{message:params.item.status});} if(method==='turn/usage'){log('Usage · '+(params.usage.output_tokens??0)+' output tokens');nativeSnapshot();} if(method==='turn/completed'){turnSucceeded=!params.turn.error&¶ms.turn.status==='completed';turnError=inferenceError(params.turn.error)||(params.turn.status==='interrupted'?'Stopped':''); - if(params.turn.error){phase('Could not finish');log(turnError);} + if(params.turn.error){noteBraincells(params.turn.error?.message);phase('Could not finish');log(turnError);} else if(params.turn.status==='interrupted'){phase('Stopped');log('Stopped by you');} else {phase(painted?'Checking picture…':source?'Waiting for preview…':'No piece written');log('Model finished');} } @@ -668,6 +674,7 @@ async function submitRemoteTurn({text,displayText,drawing}){ }catch(error){remoteFailed(error.message||String(error),null);} } function remoteFailed(message,draft){ + noteBraincells(message);message=inferenceError({message})||message; lastAttempt={...(lastAttempt||{request:''}),status:message==='Stopped'?'interrupted':'failed',error:message,finishedAt:new Date().toISOString()}; if(activeAttempt)saveAttempt(localStorage,storageKey,{...activeAttempt,status:'failed'}); if(draft)try{localStorage.setItem(storageKey+'-draft',JSON.stringify({source:draft,request:lastAttempt.request,error:message,parent:versions.head.id,createdAt:new Date().toISOString()}));}catch{} @@ -686,7 +693,7 @@ async function watchRemoteTurn(job){ const p=state.progress; turnProgress={phase:state.status==='queued'?'Waiting for a worker…':(p?.phase||'Working on the knot…'),round:p?.round||0,worker:state.claimedBy||p?.worker||'',startedAt:state.claimedAt||state.createdAt||'',steps:[{at:state.createdAt||'',text:'queued on the knot'},...(p?.steps||[])].slice(-16),said:p?.said||''}; if(state.status==='queued')phase('Waiting for a worker…'); - else if(p?.phase){phase(p.phase+(p.round>1?' · round '+p.round:''));if(p.tail){outputStream=streamedCode(p.tail,'write_piece').slice(-6000);$('live-code').textContent=outputStream;$('live-details').open=true;}} + else if(p?.phase){phase(p.phase+(p.round>1?' · round '+p.round:''));if(p.tail){outputStream=streamedCode(p.tail,p.tool||'write_piece').slice(-6000);$('live-code').textContent=outputStream;$('live-details').open=true;}} nativeSnapshot(); if(Date.now()-startedAt>15*60000){remoteFailed('The knot did not finish in fifteen minutes. Try again.',state.checkpoint||null);return;} continue; diff --git a/apple/whistlegraph/Sources/BrainSettings.swift b/apple/whistlegraph/Sources/BrainSettings.swift index 421c7b6d48..ab9406a677 100644 --- a/apple/whistlegraph/Sources/BrainSettings.swift +++ b/apple/whistlegraph/Sources/BrainSettings.swift @@ -25,6 +25,8 @@ struct InferenceSnapshot: Decodable { let models: [Model] let braincells: Braincells? let braincellsError: String + /// The last turn was refused for braincells, or the wallet reads zero. + let empty: Bool? let usage: Usage? struct ThreadCost: Decodable { let usd: Double; let partial: Bool; let estimated: Bool? } let threadCost: ThreadCost? @@ -32,17 +34,27 @@ struct InferenceSnapshot: Decodable { struct BrainButton: View { @ObservedObject var session: WhistlegraphSession + @Binding var showing: Bool let beforeOpening: () -> Void - @State private var showing = false + private var empty: Bool { session.snapshot.inference?.empty == true } var body: some View { Button { beforeOpening(); ButtonSounds.play(.pop) session.command("refreshBraincells"); showing = true } label: { + // An empty brain is not a quiet failure: the icon goes red and wears a badge. Image(systemName: "brain").font(.system(size: 25, weight: .semibold)) + .foregroundStyle(empty ? AnyShapeStyle(.red) : AnyShapeStyle(.primary)) + .overlay(alignment: .bottomTrailing) { + if empty { + Image(systemName: "exclamationmark.circle.fill").font(.system(size: 13, weight: .bold)) + .symbolRenderingMode(.palette).foregroundStyle(.white, .red).offset(x: 4, y: 3) + } + } .frame(width: 44, height: 44).contentShape(Rectangle()) } - .accessibilityLabel("Braincells").accessibilityIdentifier("brain-settings") + .accessibilityLabel("Braincells").accessibilityValue(empty ? "Empty" : "") + .accessibilityIdentifier("brain-settings") .sheet(isPresented: $showing) { BrainSettings(session: session) } } } @@ -59,7 +71,7 @@ struct BrainSettings: View { Label("Sign in for your daily braincells", systemImage: "brain") Button("Sign in") { dismiss(); session.command("signIn") } } else if let balance = session.snapshot.inference?.braincells { - BraincellMeter(balance: balance) + BraincellMeter(balance: balance, empty: session.snapshot.inference?.empty == true) } else if let message = session.snapshot.inference?.braincellsError, !message.isEmpty { Text(message).foregroundStyle(.secondary) Button("Try again") { session.command("refreshBraincells") } @@ -117,6 +129,7 @@ struct BrainSettings: View { private struct BraincellMeter: View { let balance: InferenceSnapshot.Braincells + var empty = false @Environment(\.accessibilityReduceMotion) private var reduceMotion private var unlimited: Bool { balance.unlimited == true } private var total: Double { balance.remaining + balance.purchased } @@ -129,8 +142,8 @@ private struct BraincellMeter: View { VStack(spacing: 18) { HStack(spacing: 16) { Image(systemName: "brain") - .font(.system(size: 42, weight: .medium)).foregroundStyle(.purple) - .padding(14).background(.purple.opacity(0.1), in: Circle()) + .font(.system(size: 42, weight: .medium)).foregroundStyle(empty ? .red : .purple) + .padding(14).background((empty ? Color.red : Color.purple).opacity(0.1), in: Circle()) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 2) { Text(unlimited ? "Unlimited" : cells(total)) @@ -143,6 +156,12 @@ private struct BraincellMeter: View { .accessibilityValue(unlimited ? "Unlimited" : cells(total)) .accessibilityIdentifier("brain-balance") }.frame(maxWidth: .infinity, alignment: .leading) + if empty { + Label("Empty. Your last request was refused. Add braincells below" + (reset != nil ? ", or wait for the refill." : "."), systemImage: "exclamationmark.triangle.fill") + .font(.subheadline.weight(.semibold)).foregroundStyle(.red) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityIdentifier("brain-empty") + } if !unlimited { VStack(spacing: 8) { HStack { diff --git a/apple/whistlegraph/Sources/WhistlegraphScreen.swift b/apple/whistlegraph/Sources/WhistlegraphScreen.swift index a4c1411f1c..285b87111c 100644 --- a/apple/whistlegraph/Sources/WhistlegraphScreen.swift +++ b/apple/whistlegraph/Sources/WhistlegraphScreen.swift @@ -82,6 +82,7 @@ struct WhistlegraphScreen: View { @State private var chalkDrag: CGFloat = 0 private var chalkReveal: CGFloat { session.performanceCapture ? 1 : min(1, chalkDrag / 80) } @State private var showComposer = false + @State private var brainShowing = false @State private var showTV = false @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.colorScheme) private var colorScheme @@ -208,7 +209,7 @@ struct WhistlegraphScreen: View { .accessibilityLabel("Send chalk").accessibilityIdentifier("drawing-send") .disabled(!canTalk || session.capturePhase != .idle) } - BrainButton(session: session) { showComposer = false } + BrainButton(session: session, showing: $brainShowing) { showComposer = false } }.font(.title3).buttonStyle(.plain).frame(minHeight: 44) if drawing.full { Text("Chalk full · send or undo a stroke").font(.caption) } if session.capturePhase == .recording || session.capturePhase == .opening { @@ -245,7 +246,7 @@ struct WhistlegraphScreen: View { if session.verifyingAIAccount { ProgressView("Checking your account…").accessibilityIdentifier("account-verifying") } if !narrator.isPlaying && session.snapshot.wareID == "roblox" { RobloxRoomControls(session: session) } if !narrator.isPlaying && (session.snapshot.hasPiece || session.snapshot.hasHistory || session.snapshot.busy || session.snapshot.attempt?.status == "failed") { - VersionFeed(snapshot: session.snapshot, foreground: paper, selectionColor: paper, textSize: session.layout.historySize, disabled: session.snapshot.busy || session.capturePhase != .idle, holdSelection: drawing.hasInk, stop: { session.command("stop") }, retry: { session.command("retry") }, keepDraft: { session.command("keepDraft") }, discardDraft: { session.command("discardDraft") }, discardAttempt: { session.command("discardAttempt") }) { narrator.select($0, session: session) } + VersionFeed(snapshot: session.snapshot, foreground: paper, selectionColor: paper, textSize: session.layout.historySize, disabled: session.snapshot.busy || session.capturePhase != .idle, holdSelection: drawing.hasInk, stop: { session.command("stop") }, retry: { session.command("retry") }, keepDraft: { session.command("keepDraft") }, discardDraft: { session.command("discardDraft") }, discardAttempt: { session.command("discardAttempt") }, openBrain: { showComposer = false; session.command("refreshBraincells"); brainShowing = true }) { narrator.select($0, session: session) } } else if !narrator.isPlaying { Spacer(minLength: 0) } } .padding(.horizontal, narrator.isPlaying ? 0 : session.layout.pageInset) @@ -390,6 +391,8 @@ struct VersionFeed: View { var keepDraft: () -> Void = {} var discardDraft: () -> Void = {} var discardAttempt: () -> Void = {} + /// Opens the braincell meter; a try refused for braincells offers it in place of Try again. + var openBrain: () -> Void = {} @State private var attemptSwipe: CGFloat = 0 let select: (Int) -> Void @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -461,8 +464,15 @@ struct VersionFeed: View { Button { ButtonSounds.play(.pop); keepDraft() } label: { Image(systemName: "checkmark").font(.system(size: 20, weight: .bold)).frame(width: 44, height: 44) } .disabled(disabled).accessibilityLabel("Keep this try").accessibilityIdentifier("draft-keep") } + if attempt.error.hasPrefix("Out of braincells") { + // Trying again would only fail the same way; the brain is where the fix is. + Button { ButtonSounds.play(.pop); openBrain() } label: { + Image(systemName: "brain").font(.system(size: 22, weight: .bold)).foregroundStyle(.red).frame(width: 44, height: 44) + }.accessibilityLabel("Get braincells").accessibilityIdentifier("attempt-brain") + } else { Button { ButtonSounds.play(.press); retry() } label: { Image(systemName: "arrow.clockwise").font(.system(size: 20, weight: .bold)).frame(width: 44, height: 44) } .disabled(disabled).accessibilityLabel("Try again").accessibilityIdentifier("attempt-retry") + } } }.frame(height: rowHeight).padding(.horizontal, 10) .offset(x: attemptSwipe) diff --git a/lith/whistlegraph-worker.mjs b/lith/whistlegraph-worker.mjs index 38eaf9fd3e..103b6e2dfc 100644 --- a/lith/whistlegraph-worker.mjs +++ b/lith/whistlegraph-worker.mjs @@ -62,10 +62,10 @@ export async function runTurn({job, thread, pool, onCheckpoint = () => {}, onPro const settingsFor = repair => generationProfile('', {repair, model: job.model || '', image: !!job.request.drawing}); let completed = false, error = '', server = null, checkpointAt = 0, renders = 0, lastPainted = null, repairs = 0, feedback = null, lastProof = null, round = 0, progressAt = 0; const checks = [], notes = []; - let tail = '', phaseNow = 'Waiting for model…', said = '', steps = []; + let tail = '', phaseNow = 'Waiting for model…', said = '', steps = [], tool = ''; const step = text => { if (steps.at(-1)?.text !== text) steps.push({at: new Date().toISOString(), text: String(text).slice(0, 160)}); }; step('claimed by ' + WORKER); - const progress = (phase, {force = false} = {}) => { phaseNow = phase; const now = Date.now(); if (force || now - progressAt > 2500) { progressAt = now; void onProgress({phase, round, streamed: tail.length, tail: tail.slice(-3000), steps: steps.slice(-16), said}); } }; + const progress = (phase, {force = false} = {}) => { phaseNow = phase; const now = Date.now(); if (force || now - progressAt > 2500) { progressAt = now; void onProgress({phase, round, streamed: tail.length, tail: tail.slice(-3000), tool, steps: steps.slice(-16), said}); } }; // Every write is painted at once and the result handed back to the model // as runtime feedback, as the phone does: the edit contract tells it to // inspect ac_preview, and without that it rewrote one piece twelve rounds @@ -84,17 +84,20 @@ export async function runTurn({job, thread, pool, onCheckpoint = () => {}, onPro const generate = async (task, repair) => { const settings = settingsFor(repair); server = new AcServer({cwd, piece: {file, checkpoint: async () => { source = readFileSync(file, 'utf8'); await paint(); const now = Date.now(); if (now - checkpointAt > 5000) { checkpointAt = now; await onCheckpoint(source); } }}, - token: async () => 'worker', fetch: workerFetch(job.owner, settings), site: SITE, preview: !!pool, frameCapture: false, model: settings.model, + // Edits in place, as on the phone: a full rewrite of a big piece is 16k + // output tokens a round at five times the input price, and the round + // cap cut those off (2026-10-10). + token: async () => 'worker', fetch: workerFetch(job.owner, settings), site: SITE, preview: !!pool, frameCapture: false, model: settings.model, layeredEdits: true, rounds: settings.rounds, outputContinuations: settings.outputContinuations, reasoning: settings.reasoning, thinking: settings.thinking, developerInstructions: GENERATION_INSTRUCTIONS + '\n' + WARE_INSTRUCTIONS}); server.runtimeFeedback = () => feedback; completed = false; error = ''; let streamed = 0, lastNote = Date.now(), thinking = false, writing = false, saying = ''; - tail = ''; said = ''; step(repair ? 'repair round started' : 'asked the model (' + settings.model.split('/').pop() + ')'); progress(repair ? 'Repairing…' : 'Waiting for model…', {force: true}); + tail = ''; said = ''; tool = ''; step(repair ? 'repair round started' : 'asked the model (' + settings.model.split('/').pop() + ')'); progress(repair ? 'Repairing…' : 'Waiting for model…', {force: true}); server.on('notification', ({method, params}) => { if (method === 'turn/completed') { completed = !params.turn.error && params.turn.status === 'completed'; error = params.turn.error ? String(params.turn.error.message || params.turn.error) : ''; } if (method === 'item/reasoning/delta') { if (!thinking) { thinking = true; step('thinking'); } progress('Thinking…'); } - if (method === 'item/modelCode/delta') { if (!writing) { writing = true; step(repair ? 'rewriting' : 'writing'); } streamed += params.delta?.length || 0; tail = (tail + (params.delta || '')).slice(-6000); progress(repair ? 'Repairing…' : 'Writing…'); if (Date.now() - lastNote > 20000) { lastNote = Date.now(); log('streaming', job.code, streamed, 'chars', repair ? '(repair)' : ''); } } + if (method === 'item/modelCode/delta') { if (params.tool && params.tool !== tool) { tool = params.tool; tail = ''; } if (!writing) { writing = true; step(tool === 'edit_piece' ? 'editing in place' : repair ? 'rewriting' : 'writing'); } streamed += params.delta?.length || 0; tail = (tail + (params.delta || '')).slice(-6000); progress(repair ? 'Repairing…' : 'Writing…'); if (Date.now() - lastNote > 20000) { lastNote = Date.now(); log('streaming', job.code, streamed, 'chars', repair ? '(repair)' : ''); } } if (method === 'turn/started') round += 1; if (method === 'turn/usage') { log('usage', job.code, params.usage?.output_tokens ?? 0, 'output tokens'); step(`round ${round}: ${(params.usage?.output_tokens ?? 0).toLocaleString('en-US')} tokens out`); thinking = false; writing = false; } if (method === 'item/started') log('tool', job.code, params.item?.tool || params.item?.type || '?'); diff --git a/system/backend/easel-paid-credits.mjs b/system/backend/easel-paid-credits.mjs index 6333474133..e46d086c55 100644 --- a/system/backend/easel-paid-credits.mjs +++ b/system/backend/easel-paid-credits.mjs @@ -171,12 +171,26 @@ export function braincellRate(model,inputBound=0){ return base; } export const OUT_OF_BRAINCELLS='Out of braincells — buy more from the braincell meter in Aesel, or switch provider with /provider. Free braincells reset at midnight UTC.'; -// Hold the worst case this request could cost: its input and its whole output -// allowance at the model's rate, so a larger max_tokens holds more. +// Input priced as input. The consumption rate prices every token as output, +// which is right for the no-cost fallback but holds five times too much for +// the input of a long piece: a 25,000-character piece needed a 400,000 +// braincell hold for a call that cost 90,000, and a wallet with 386,000 left +// was refused as empty (2026-10-10). Models with a fixed tariff have no split. +export function inputBraincellRate(model,inputBound=0){ + const price=OPEN_PRICES[model]; + if(!price||braincellRate(model,inputBound)!==BRAINCELL_RATES[model])return braincellRate(model,inputBound); + return Math.ceil(price[0]*INFERENCE_MARKUP*BRAINCELLS_PER_USD/1e4)/100; +} +// The most this request can cost: its input at the input rate, its whole +// output allowance at the output rate, so a larger max_tokens holds more. +export function holdBraincells({model,body,maxTokens}) { + const inputBound=reservationSize(body,0); + return Math.ceil(inputBound*inputBraincellRate(model,inputBound)+maxTokens*braincellRate(model,inputBound)); +} export async function authorizePaidRequest({user,model,body,maxTokens,now=new Date(),withWallets:using=withWallets}) { const inputBound=reservationSize(body,0); const rate=braincellRate(model,inputBound); - const amount=Math.ceil(reservationSize(body,maxTokens)*rate); + const amount=holdBraincells({model,body,maxTokens}); return using(async wallets=>{ const prior=await wallets.findOne({_id:user}); await reconcileWallet(prior,wallets,{now}); diff --git a/system/backend/whistlegraph-turns.mjs b/system/backend/whistlegraph-turns.mjs index 45ae517110..888e833f7a 100644 --- a/system/backend/whistlegraph-turns.mjs +++ b/system/backend/whistlegraph-turns.mjs @@ -93,7 +93,7 @@ export function mongoTurnQueue(collection, {now = () => new Date()} = {}) { const update = {$set: {heartbeatAt: now().toISOString()}}; if (typeof checkpoint === 'string') update.$set.checkpoint = checkpoint; // What the phone shows while it waits: the phase, the round, the tail of the code being written. - if (progress && typeof progress === 'object') update.$set.progress = {phase: String(progress.phase || '').slice(0, 80), round: Number(progress.round) || 0, streamed: Number(progress.streamed) || 0, tail: String(progress.tail || '').slice(-3000), + if (progress && typeof progress === 'object') update.$set.progress = {phase: String(progress.phase || '').slice(0, 80), round: Number(progress.round) || 0, streamed: Number(progress.streamed) || 0, tail: String(progress.tail || '').slice(-3000), tool: String(progress.tool || '').slice(0, 32), steps: (Array.isArray(progress.steps) ? progress.steps : []).slice(-16).map(s => ({at: String(s.at || ''), text: String(s.text || '').slice(0, 160)})), said: String(progress.said || '').slice(0, 280), worker, at: now().toISOString()}; const row = await collection.findOneAndUpdate({_id: id, status: 'running', claimedBy: worker}, update, {returnDocument: 'after'}); diff --git a/system/tests/easel-paid-credits.test.mjs b/system/tests/easel-paid-credits.test.mjs index fb30e5fb58..4c4e5e55ed 100644 --- a/system/tests/easel-paid-credits.test.mjs +++ b/system/tests/easel-paid-credits.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {paidCheckout,CREDIT_PACK,reservationSize,fulfillCheckout,reserve,settle,settleDurably,reconcileWallet,DAILY_PAID_BRAINCELL_CAP,refundCheckout,braincellRate} from '../backend/easel-paid-credits.mjs'; +import {paidCheckout,CREDIT_PACK,reservationSize,fulfillCheckout,reserve,settle,settleDurably,reconcileWallet,DAILY_PAID_BRAINCELL_CAP,refundCheckout,braincellRate,inputBraincellRate,holdBraincells} from '../backend/easel-paid-credits.mjs'; import {createHandler} from '../netlify/functions/easel-checkout.mjs'; const paid={id:'cs_test_123',mode:'payment',payment_status:'paid',status:'complete',livemode:false,amount_total:500,currency:'usd',client_reference_id:'user1',metadata:{type:'ac-credits',pack:CREDIT_PACK.id,userSub:'user1'}}; test('only a matching, confirmed server offer can grant credit',()=>{ @@ -79,3 +79,14 @@ test('braincells are one currency with model and long-context consumption rates' assert.throws(()=>braincellRate('invented')); assert.equal(paidCheckout({...paid,metadata:{...paid.metadata,pack:'luna-1m-v1'}}).credits,1_000_000); }); +test('a hold prices input as input and only the output allowance as output',()=>{ + assert.equal(braincellRate('anthropic/claude-sonnet-5.5'),4); + assert.equal(inputBraincellRate('anthropic/claude-sonnet-5.5'),0.8); + assert.equal(inputBraincellRate('openai/gpt-5.6-luna'),1,'a fixed tariff has no input price'); + assert.equal(inputBraincellRate('openai/gpt-5.6-luna',300000),2,'long context keeps its consumption rate'); + const body={system:'s'.repeat(10000),messages:[{role:'user',content:'x'.repeat(25000)}]}; + const input=reservationSize(body,0); + assert.equal(holdBraincells({model:'anthropic/claude-sonnet-5.5',body,maxTokens:16384}),Math.ceil(input*0.8+16384*4)); + assert.ok(holdBraincells({model:'anthropic/claude-sonnet-5.5',body,maxTokens:16384})