From f8831c75bdabb4379528ea21501e384f76fb5555 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 8 Oct 2026 12:43:32 -0700 Subject: [PATCH] Whistlegraph 114: everyone on hosted inference, owner included, paying in braincells - Generation ignores relay grants: every account defaults to hosted Claude Sonnet 5.5; the menu offers Sonnet 5.5, Opus 5.5 and DeepSeek. A saved relay model falls back to the default. - Server: remove the owner's uncapped inference. @jeffrey draws the free allowance and then braincells like everyone else. This also applies to Aesel's hosted inference. /api/easel-credits keeps unlimited:false for installed clients. --- apple/whistlegraph/README.md | 2 +- .../Resources/Web/generation-policy.mjs | 26 +++++------- .../Tests/generation-policy.test.mjs | 42 +++++-------------- .../whistlegraph/Tests/native-bridge.test.cjs | 14 +++---- .../Whistlegraph.xcodeproj/project.pbxproj | 6 +-- apple/whistlegraph/project.yml | 2 +- system/backend/easel-policy.mjs | 5 --- system/netlify/functions/easel-credits.mjs | 3 +- system/netlify/functions/easel-inference.mjs | 8 ++-- system/tests/easel-braincells.test.mjs | 10 ++--- system/tests/easel-credits.test.mjs | 17 ++------ 11 files changed, 43 insertions(+), 92 deletions(-) diff --git a/apple/whistlegraph/README.md b/apple/whistlegraph/README.md index a251ff0740..d7a831a04f 100644 --- a/apple/whistlegraph/README.md +++ b/apple/whistlegraph/README.md @@ -81,7 +81,7 @@ The browser check uses Puppeteer and Chrome with mock inference; it makes no mod Every generated edit runs syntax/API checks and waits for matching-source runtime feedback before the visual review. Client errors trigger at most one repair across code and visual checks. A candidate that still fails restores the previous saved version; its failure remains visible after the restored frame paints. Missing or stale evidence cannot trigger a paid repair or save a version. There is no opt-out setting. -Everyone's default is hosted Claude Sonnet 5.5 (OpenRouter, billed in braincells) with 16,384 output tokens, 4,096 thinking tokens, 12 tool rounds and two continuations; its single repair keeps the model with four rounds. Claude Opus 5.5 and DeepSeek V4.1 Flash are selectable in Brain. DeepSeek keeps a lean budget (4,096 output tokens, four rounds, thinking off); its repair uses DeepSeek V4 Pro, or V4.1 Flash when the request carries chalk, since V4 Pro accepts no images. Accounts the relay grants personal access default to Claude Opus 5 through the relay and can also pick GPT-6 Astra. Generation has no wall-clock deadline; each turn is bounded by its rounds, continuations and output tokens, and by Stop. Remote requests and remote edit descriptions use the same 96-grapheme, single-line limit as phone typing; oversized requests are rejected before generation. Diagnostic context and source code are separate from the short user request. Visual review has a 90-second deadline (300 seconds through the personal relay); Claude reviews request minimal reasoning because OpenRouter rejects disabled reasoning for Claude 5.5. These bound work, not cost. A painted frame is execution evidence, not visual acceptance. +Everyone's default is hosted Claude Sonnet 5.5 (OpenRouter, billed in braincells) with 16,384 output tokens, 4,096 thinking tokens, 12 tool rounds and two continuations; its single repair keeps the model with four rounds. Claude Opus 5.5 and DeepSeek V4.1 Flash are selectable in Brain. DeepSeek keeps a lean budget (4,096 output tokens, four rounds, thinking off); its repair uses DeepSeek V4 Pro, or V4.1 Flash when the request carries chalk, since V4 Pro accepts no images. Every account, the owner included, uses hosted inference and pays in braincells; the personal relay is not used for generation. Generation has no wall-clock deadline; each turn is bounded by its rounds, continuations and output tokens, and by Stop. Remote requests and remote edit descriptions use the same 96-grapheme, single-line limit as phone typing; oversized requests are rejected before generation. Diagnostic context and source code are separate from the short user request. Visual review has a 90-second deadline (300 seconds through the personal relay); Claude reviews request minimal reasoning because OpenRouter rejects disabled reasoning for Claude 5.5. These bound work, not cost. A painted frame is execution evidence, not visual acceptance. ## Pixel size diff --git a/apple/whistlegraph/Resources/Web/generation-policy.mjs b/apple/whistlegraph/Resources/Web/generation-policy.mjs index 23e2efb3e7..20ad01a915 100644 --- a/apple/whistlegraph/Resources/Web/generation-policy.mjs +++ b/apple/whistlegraph/Resources/Web/generation-policy.mjs @@ -1,30 +1,24 @@ -// Everyone's default is hosted Claude Sonnet (OpenRouter, billed in braincells). +// Everyone, the owner included, uses hosted inference (OpenRouter) and pays +// in braincells. Claude Sonnet is the default. export const DEFAULT_MODEL = 'anthropic/claude-sonnet-5.5'; export const FLASH_MODEL = 'deepseek/deepseek-v4.1-flash'; export const REPAIR_MODEL = 'deepseek/deepseek-v4-pro'; -// Served only through the personal relay, for accounts it grants. -const RELAY_MODELS = ['anthropic/claude-opus-5','openai/gpt-6-astra']; export const MODEL_LABELS = { 'anthropic/claude-sonnet-5.5':'Claude Sonnet 5.5', 'anthropic/claude-opus-5.5':'Claude Opus 5.5', 'deepseek/deepseek-v4.1-flash':'DeepSeek V4.1 Flash', 'deepseek/deepseek-v4-pro':'DeepSeek V4 Pro', - 'anthropic/claude-opus-5':'Claude Opus 5 · personal', - 'openai/gpt-6-astra':'GPT-6 Astra · personal', }; -export function modelChoices(handle, {personalAccess=false}={}) { - return Object.entries(MODEL_LABELS).filter(([id])=>personalAccess||!RELAY_MODELS.includes(id)).map(([id,label])=>({id,label})); +export function modelChoices() { + return Object.entries(MODEL_LABELS).map(([id,label])=>({id,label})); } -// Personal models follow the relay's access grant, never a handle or preference. -// Claude turns get the full budget for everyone; DeepSeek keeps its lean one. -// DeepSeek V4 Pro is text-only on OpenRouter (404 "No endpoints found that support -// image input"), so a repair that carries chalk stays on the image-capable Flash. -export function generationProfile(handle, {repair=false,model='',personalAccess=false,image=false}={}) { - const selected=modelChoices(handle,{personalAccess}).some(option=>option.id===model)?model:''; - const chosen=selected||(personalAccess?'anthropic/claude-opus-5':DEFAULT_MODEL); +// Claude turns get the full budget; DeepSeek keeps its lean one. DeepSeek V4 Pro +// is text-only on OpenRouter (404 "No endpoints found that support image input"), +// so a repair that carries chalk stays on the image-capable Flash. +export function generationProfile(handle, {repair=false,model='',image=false}={}) { + const chosen=Object.hasOwn(MODEL_LABELS,model)?model:DEFAULT_MODEL; const resolved=repair&&!image&&chosen===FLASH_MODEL?REPAIR_MODEL:chosen; - const personalRelay=personalAccess&&RELAY_MODELS.includes(resolved); - if(!resolved.startsWith('deepseek/'))return {personalRelay,model:resolved,maxTokens:16384,rounds:repair?4:12,outputContinuations:repair?1:2,reasoning:{max_tokens:4096},thinking:{type:'enabled',budget_tokens:4096}}; + if(!resolved.startsWith('deepseek/'))return {personalRelay:false,model:resolved,maxTokens:16384,rounds:repair?4:12,outputContinuations:repair?1:2,reasoning:{max_tokens:4096},thinking:{type:'enabled',budget_tokens:4096}}; return {personalRelay:false,model:resolved,maxTokens:4096,rounds:repair?2:4,outputContinuations:repair?0:1,reasoning:repair?{max_tokens:1024}:{effort:'none'},thinking:repair?{type:'enabled',budget_tokens:1024}:{type:'disabled'}}; } export const GENERATION_INSTRUCTIONS = 'This surface is Whistlegraph. The person watches the piece being made. A local starter may already be visible: its source is the real current piece; refine it instead of discarding it. For an existing piece, make small revision-checked edit_piece calls instead of resending unchanged code. Use write_piece to create the initial piece or for a necessary rewrite. No prose preamble. Each ask becomes one internal version; complete edit checkpoints are preview layers within it. Make the first visible working drawing in 3–6 lines whenever possible. The UI previews complete paint statements and exact replacement checkpoints while streaming: put required declarations and a complete exported paint function first; avoid long data tables or helper code before that first drawing. Then refine in small complete write_piece checkpoints only as needed to satisfy the request. Do not add unrequested animation or features. For animation use Number(paintCount) from the paint API, or explicit state advanced in sim; there is no numeric frame API. A request for a spoken narrator needs audible speak(), not just written notes; use the speech guide. Use numeric RGB ink(r,g,b) or six-digit hex colors like "#ffffff"; three-digit CSS hex colors are unsupported. Use AC ink alpha on the 0–255 scale and size geometry relative to the screen. Every saved piece must have working helpers and a caption; never finish with placeholder or empty drawing helpers. line(x1,y1,x2,y2) draws ONE segment; its fifth argument is thickness, never another coordinate. For a path, loop over adjacent point pairs with separate four-argument line calls; never spread three or more point pairs into line. Use the native oval(x,y,radiusX,radiusY,filled) for ellipses instead of approximating them with overlapping circles. Fit the entire composition, including radii and stroke thickness, inside the screen with margins; use at most 90% of the fitting scale and center it. Do not repeat the prompt or add interaction instructions, help text, usage hints, or captions inside or outside the picture unless explicitly requested. Use the default write font for other in-piece text unless the person requests another. Use ac_preview for runtime observations; this phone has no desktop frame-capture service. Once the requested change is painted and runtime-checked, finish the generation turn. The app then captures four timed frames of the actual phone preview and reviews them against the latest request and selected branch before saving. A failed visual check can return concrete mismatches for one narrow repair; do not claim visual success yourself. Never fake progress or tests. Keep one line `export const caption = "…"` in the piece: a plain sentence under 120 characters naming the subject, its mood, what actually moves and what the person can actually do, if anything; never invent motion or interactions in the caption. Update it whenever an edit changes any of that. It is the piece\'s memory of itself and comes back to you with every follow-up. Preserve the current piece and the original intent of the selected branch on revisions. Short follow-ups modify that existing world; do not replace its subject, characters, message, interactions, or sound without a request to do so. Treat uncertain speech transcriptions conservatively in context instead of inventing a new subject from an ambiguous word. Adjectives of culture, nationality or place (Latina, Japanese, Nordic, tropical) describe how things look: palette, patterns, dress, flora, light. They never change the language of any text or speech unless the request mentions words, text, language or voice. Do not turn ordinary spoken requests into text posters, transcript timelines, waveform charts, or word-timed animation unless explicitly requested. No publishing: these are private local previews.'; diff --git a/apple/whistlegraph/Tests/generation-policy.test.mjs b/apple/whistlegraph/Tests/generation-policy.test.mjs index 0b0cb27785..3ce490d06f 100644 --- a/apple/whistlegraph/Tests/generation-policy.test.mjs +++ b/apple/whistlegraph/Tests/generation-policy.test.mjs @@ -1,42 +1,23 @@ import assert from 'node:assert/strict'; -import {generationProfile,DEFAULT_MODEL,FLASH_MODEL,REPAIR_MODEL} from '../Resources/Web/generation-policy.mjs'; +import {generationProfile,modelChoices,DEFAULT_MODEL,FLASH_MODEL,REPAIR_MODEL} from '../Resources/Web/generation-policy.mjs'; assert.equal(DEFAULT_MODEL,'anthropic/claude-sonnet-5.5'); -for(const handle of ['', 'fixture', 'Jeffrey', 'jeffrey-other']) { - // Everyone gets hosted Sonnet with the full Claude budget. - assert.equal(generationProfile(handle).model,DEFAULT_MODEL); - assert.equal(generationProfile(handle).rounds,12); - assert.equal(generationProfile(handle).maxTokens,16384); - assert.equal(generationProfile(handle).personalRelay,false); +for(const handle of ['', 'fixture', 'jeffrey', 'Jeffrey']) for(const personalAccess of [false,true]) { + // Everyone, the owner and relay-granted accounts included, uses hosted Sonnet. + const p=generationProfile(handle,{personalAccess}); + assert.equal(p.model,DEFAULT_MODEL);assert.equal(p.personalRelay,false); + assert.equal(p.rounds,12);assert.equal(p.maxTokens,16384); assert.equal(generationProfile(handle,{repair:true}).model,DEFAULT_MODEL); assert.equal(generationProfile(handle,{model:'anthropic/claude-opus-5.5'}).model,'anthropic/claude-opus-5.5'); - assert.equal(generationProfile(handle,{model:'anthropic/claude-opus-5.5'}).personalRelay,false); + // Relay models are no longer offered; a saved one falls back to the default. + for(const relay of ['anthropic/claude-opus-5','openai/gpt-6-astra'])assert.equal(generationProfile(handle,{model:relay,personalAccess}).model,DEFAULT_MODEL); // DeepSeek keeps its lean budget and its text-only repair model. assert.equal(generationProfile(handle,{model:FLASH_MODEL}).maxTokens,4096); assert.equal(generationProfile(handle,{model:FLASH_MODEL,repair:true}).model,REPAIR_MODEL); // The text-only repair model cannot take the chalk PNG. assert.equal(generationProfile(handle,{model:FLASH_MODEL,repair:true,image:true}).model,FLASH_MODEL); } -assert.equal(generationProfile('jeffrey',{personalAccess:true}).model,'anthropic/claude-opus-5'); -assert.equal(generationProfile('jeffrey',{personalAccess:true}).rounds,12); -assert.equal(generationProfile('jeffrey',{personalAccess:true}).maxTokens,16384); -assert.equal(generationProfile('jeffrey',{personalAccess:true,repair:true}).rounds,4); -console.log('PASS hosted Sonnet default for everyone; relay models only with access.'); - -assert.equal(generationProfile('fixture',{model:'anthropic/claude-opus-5'}).model,DEFAULT_MODEL); -assert.equal(generationProfile('jeffrey',{personalAccess:true,model:DEFAULT_MODEL}).model,DEFAULT_MODEL); -assert.equal(generationProfile('jeffrey',{personalAccess:true,model:FLASH_MODEL,repair:true}).model,REPAIR_MODEL); -assert.equal(generationProfile('jeffrey',{personalAccess:true}).personalRelay,true); -assert.equal(!!generationProfile('fixture').personalRelay,false); -assert.equal(generationProfile('jeffrey',{personalAccess:true,model:DEFAULT_MODEL}).personalRelay,false); - -assert.equal(generationProfile('jeffrey',{personalAccess:true,model:'openai/gpt-6-astra'}).personalRelay,true); -assert.equal(generationProfile('jeffrey',{personalAccess:true,model:'openai/gpt-6-astra'}).model,'openai/gpt-6-astra'); -for (const handle of ['', 'fixture', 'Jeffrey', 'jeffrey-other']) assert.equal(generationProfile(handle,{model:'openai/gpt-6-astra'}).model,DEFAULT_MODEL); -const trial={personalAccess:true}; -assert.equal(generationProfile('fifi',trial).model,'anthropic/claude-opus-5'); -assert.equal(generationProfile('fifi',{...trial,model:'openai/gpt-6-astra'}).personalRelay,true); -assert.equal(generationProfile('fifi',{...trial,model:DEFAULT_MODEL}).personalRelay,false); -assert.equal(generationProfile('fifi',{model:'anthropic/claude-opus-5'}).model,DEFAULT_MODEL); +assert.deepEqual(modelChoices().map(m=>m.id),['anthropic/claude-sonnet-5.5','anthropic/claude-opus-5.5',FLASH_MODEL,REPAIR_MODEL]); +console.log('PASS hosted Sonnet default for everyone; no relay models.'); const {hasPersonalAccess,fetchPersonalAccess}=await import('../Resources/Web/personal-access.mjs'); const access={personal:true,providers:['claude','codex'],expiresAt:new Date(2000).toISOString()}; assert.equal(hasPersonalAccess(access,1999),true);assert.equal(hasPersonalAccess(access,2000),false); @@ -44,6 +25,3 @@ assert.equal(hasPersonalAccess({personal:true}),false); assert.equal(await fetchPersonalAccess('x',{fetch:async()=>({ok:false})}),null); assert.equal(await fetchPersonalAccess('x',{fetch:async()=>{throw Error('offline')}}),null); console.log('PASS verified trial capability, expiry and fail-closed discovery.'); - -// The handle alone grants nothing; access comes from the relay. -assert.equal(generationProfile('jeffrey').model,DEFAULT_MODEL); diff --git a/apple/whistlegraph/Tests/native-bridge.test.cjs b/apple/whistlegraph/Tests/native-bridge.test.cjs index b370e17e24..b45ce77d32 100644 --- a/apple/whistlegraph/Tests/native-bridge.test.cjs +++ b/apple/whistlegraph/Tests/native-bridge.test.cjs @@ -106,13 +106,13 @@ const server = http.createServer(async (req, res) => { assert.deepEqual(errors,[]); if(process.argv.includes('--trial')) { await page.evaluate(()=>whistlegraphEngineEvent({kind:'account',token:'fixture-only'})); - await page.waitForFunction(()=>__nativeMessages.some(m=>m.action==='snapshot'&&m.snapshot.inference?.provider==='Personal Claude')); + // A relay grant no longer changes generation: everyone is on hosted Sonnet. + await page.waitForFunction(()=>__nativeMessages.some(m=>m.action==='snapshot'&&m.snapshot.handle==='fifi'&&m.snapshot.inference?.provider)); const settings=await page.evaluate(()=>__nativeMessages.filter(m=>m.action==='snapshot').at(-1).snapshot.inference); - assert.equal(settings.selection,'anthropic/claude-opus-5'); - assert.ok(settings.models.some(m=>m.id==='openai/gpt-6-astra')); - await page.evaluate(()=>whistlegraphNativeCommand({action:'setModel',text:'openai/gpt-6-astra'})); - await page.waitForFunction(()=>__nativeMessages.filter(m=>m.action==='snapshot').at(-1).snapshot.inference.provider==='Personal Codex'); - console.log('PASS Fifi capability selects personal Opus and offers Codex without owner identity. No provider call.');return; + assert.equal(settings.provider,'OpenRouter'); + assert.equal(settings.selection,'anthropic/claude-sonnet-5.5'); + assert.ok(!settings.models.some(m=>m.id==='openai/gpt-6-astra'||m.id==='anthropic/claude-opus-5')); + console.log('PASS a relay-granted account still uses hosted Sonnet and sees no relay models. No provider call.');return; } if(process.argv.includes('--streaming')) { await page.evaluate(()=>{ @@ -130,7 +130,7 @@ const server = http.createServer(async (req, res) => { await page.evaluate(()=>whistlegraphAsk('Draw a clover with a bee')); const state=await page.evaluate(()=>({ledger:JSON.parse(localStorage.getItem('whistlegraph-source-versions')),messages:__nativeMessages,phase:document.getElementById('live-phase').textContent})); assert.equal(state.ledger.head,1,JSON.stringify(state)); - assert.equal(inferenceBodies[0].model,process.argv.includes('--jeffrey')?'anthropic/claude-opus-5':'anthropic/claude-sonnet-5.5'); + assert.equal(inferenceBodies[0].model,'anthropic/claude-sonnet-5.5','owner and trial accounts use the hosted default too'); assert.equal(inferenceBodies[0].max_tokens,16384); assert.equal(requests,2,'provisional failures do not buy repair inference'); assert.equal(visualRequests,1,'final candidate is still visually checked'); diff --git a/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj b/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj index 5b35febe0d..810a190065 100644 --- a/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj +++ b/apple/whistlegraph/Whistlegraph.xcodeproj/project.pbxproj @@ -622,7 +622,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 113; + CURRENT_PROJECT_VERSION = 114; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -707,7 +707,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 113; + CURRENT_PROJECT_VERSION = 114; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_NS_ASSERTIONS = NO; @@ -767,7 +767,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 113; + CURRENT_PROJECT_VERSION = 114; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = FB5948YR3S; ENABLE_STRICT_OBJC_MSGSEND = YES; diff --git a/apple/whistlegraph/project.yml b/apple/whistlegraph/project.yml index 4e3eff108a..e9542a128a 100644 --- a/apple/whistlegraph/project.yml +++ b/apple/whistlegraph/project.yml @@ -16,7 +16,7 @@ settings: CODE_SIGN_STYLE: Automatic SWIFT_VERSION: "5.0" MARKETING_VERSION: "0.1.0" - CURRENT_PROJECT_VERSION: "113" + CURRENT_PROJECT_VERSION: "114" targets: Whistlegraph: type: application diff --git a/system/backend/easel-policy.mjs b/system/backend/easel-policy.mjs index 95ab87bfc0..110b400519 100644 --- a/system/backend/easel-policy.mjs +++ b/system/backend/easel-policy.mjs @@ -88,8 +88,3 @@ export function inferenceBudgetFailure(budget, handle) { } return null; } - -// Only the authenticated, verified owner account may run without a spending cap. -export function unlimitedBraincells(user, handle, adminSub = process.env.ADMIN_SUB) { - return !!adminSub && user?.sub === adminSub && user?.email_verified === true && handle === 'jeffrey'; -} diff --git a/system/netlify/functions/easel-credits.mjs b/system/netlify/functions/easel-credits.mjs index 3788e8e81d..27a7f1cdd7 100644 --- a/system/netlify/functions/easel-credits.mjs +++ b/system/netlify/functions/easel-credits.mjs @@ -1,4 +1,3 @@ -import { unlimitedBraincells } from '../../backend/easel-policy.mjs'; // Read the signed-in handle's existing daily allowance; never a vendor balance. export function createHandler({ authorize, getHandleOrEmail, checkBudget, paidBalance = async () => 0, offer = null, creditPack = {amount: 500, credits: 1_000_000} }) { const reply = (statusCode, value) => ({ statusCode, headers: { @@ -24,7 +23,7 @@ export function createHandler({ authorize, getHandleOrEmail, checkBudget, paidBa const valueUSD = cells => Math.round(cells * creditPack.amount / creditPack.credits * 1e6) / 1e8; const dollars = { currency: "USD", free: valueUSD(budget.remaining), purchased: valueUSD(purchased), total: valueUSD(budget.remaining + purchased) }; - return reply(200, { unlimited: unlimitedBraincells(user, handle.slice(1)), dollars, purchased, offer, handle, unit: "braincells", remaining: budget.remaining, + return reply(200, { unlimited: false, dollars, purchased, offer, handle, unit: "braincells", remaining: budget.remaining, used: budget.used, limit: budget.budget, day: budget.day, resetsAt: new Date(Date.parse(budget.day + "T00:00:00Z") + 86400000).toISOString() }); } catch { return reply(503, { error: "Allowance unavailable" }); } diff --git a/system/netlify/functions/easel-inference.mjs b/system/netlify/functions/easel-inference.mjs index 30f362d902..810745bda6 100644 --- a/system/netlify/functions/easel-inference.mjs +++ b/system/netlify/functions/easel-inference.mjs @@ -33,7 +33,7 @@ import { stream } from "@netlify/functions"; import { inferenceProviderFailure } from "../../backend/easel-provider-error.mjs"; import { relayInference } from "../../backend/easel-stream.mjs"; -import { EASEL_MODELS as MODELS, inferenceRequest, inferenceBudgetFailure, unlimitedBraincells } from "../../backend/easel-policy.mjs"; +import { EASEL_MODELS as MODELS, inferenceRequest, inferenceBudgetFailure } from "../../backend/easel-policy.mjs"; const OPENROUTER = "https://openrouter.ai/api/v1/messages"; @@ -69,7 +69,7 @@ export const handler = stream(async (event) => { } // Who is asking, and may they? - let handle = "", userSub = "", unlimited = false; + let handle = "", userSub = ""; try { const { authorize, getHandleOrEmail } = await import("../../backend/authorization.mjs"); const user = await Promise.race([ @@ -81,7 +81,6 @@ export const handler = stream(async (event) => { const handleOrEmail = await getHandleOrEmail(user.sub); if (typeof handleOrEmail === "string" && handleOrEmail.startsWith("@")) { handle = handleOrEmail.slice(1); - unlimited = unlimitedBraincells(user, handle); } } catch (error) { // Unlike /api/ask, a failed check here refuses rather than falling back: @@ -114,7 +113,8 @@ export const handler = stream(async (event) => { } catch (error) { console.log("🪙 easel: budget unavailable —", error.message); } - const budgetFailure = unlimited ? null : inferenceBudgetFailure(budget, handle); + // Every handle, the owner's included, draws the free allowance and then braincells. + const budgetFailure = inferenceBudgetFailure(budget, handle); let paidHold = null; if (budgetFailure) { if (budgetFailure.statusCode !== 429) return fail(budgetFailure.statusCode, budgetFailure.message); diff --git a/system/tests/easel-braincells.test.mjs b/system/tests/easel-braincells.test.mjs index 0007cacae8..3e4af3bc70 100644 --- a/system/tests/easel-braincells.test.mjs +++ b/system/tests/easel-braincells.test.mjs @@ -3,7 +3,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {authorizePaidRequest,braincellRate,braincellsFromCost,BRAINCELLS_PER_USD,CREDIT_PACK,DAILY_PAID_BRAINCELL_CAP,INFERENCE_MARKUP,OUT_OF_BRAINCELLS,reserve,settle,settleDurably,reconcileWallet,HOLD_LIFETIME_MS,usageBraincells} from '../backend/easel-paid-credits.mjs'; -import {DEFAULT_EASEL_MODEL,EASEL_MODELS,HOSTED_MAX_TOKENS,inferenceRequest,unlimitedBraincells} from '../backend/easel-policy.mjs'; +import {DEFAULT_EASEL_MODEL,EASEL_MODELS,HOSTED_MAX_TOKENS,inferenceRequest} from '../backend/easel-policy.mjs'; import {relayInference} from '../backend/easel-stream.mjs'; // Just enough of a Mongo collection for the wallet's conditional updates. @@ -209,10 +209,6 @@ test('stream cancellation aborts upstream and waits for one settlement',async()= assert.deepEqual([aborted,settled,cancelled],[1,1,1]); }); -test('only verified Jeffrey matching the configured admin account has uncapped inference',()=>{ - const user={sub:'owner',email_verified:true}; - assert.equal(unlimitedBraincells(user,'jeffrey','owner'),true); - for(const [u,h,admin] of [[user,'tester','owner'],[user,'jeffrey','other'],[user,'jeffrey',''],[{sub:'owner'},'jeffrey','owner'],[{sub:'owner',email_verified:'true'},'jeffrey','owner'],[null,'jeffrey','owner']]) { - assert.equal(unlimitedBraincells(u,h,admin),false); - } +test('no account has uncapped inference; the owner pays in braincells too',async()=>{ + assert.equal((await import('../backend/easel-policy.mjs')).unlimitedBraincells,undefined); }); diff --git a/system/tests/easel-credits.test.mjs b/system/tests/easel-credits.test.mjs index a1bd1dc4c2..8a33fc64e4 100644 --- a/system/tests/easel-credits.test.mjs +++ b/system/tests/easel-credits.test.mjs @@ -50,18 +50,7 @@ test("unknown allowances and malformed purchased balances never become dollar ba ); }); -test('uncapped status uses the verified account, never a claimed request handle', async () => { - const previous = process.env.ADMIN_SUB; - process.env.ADMIN_SUB = 'owner'; - try { - const owner = {...deps, authorize: async () => ({sub:'owner',email_verified:true}), getHandleOrEmail: async () => '@jeffrey'}; - const result = await createHandler(owner)(event); - assert.equal(JSON.parse(result.body).unlimited, true); - const spoof = await createHandler({...owner, authorize: async () => ({sub:'other',email_verified:true})})({...event, body:JSON.stringify({handle:'jeffrey',unlimited:true})}); - assert.equal(JSON.parse(spoof.body).unlimited, false); - const unverified = await createHandler({...owner, authorize: async () => ({sub:'owner',email_verified:false})})(event); - assert.equal(JSON.parse(unverified.body).unlimited, false); - } finally { - if (previous === undefined) delete process.env.ADMIN_SUB; else process.env.ADMIN_SUB = previous; - } +test('every account, the verified owner included, reports a capped allowance', async () => { + const owner = {...deps, authorize: async () => ({sub:'owner',email_verified:true}), getHandleOrEmail: async () => '@jeffrey'}; + assert.equal(JSON.parse((await createHandler(owner)(event)).body).unlimited, false); }); -- 2.51.2