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})