From 6d849de31dbd469eb4aaef036fd3928c8d8f7d4f Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 1 Oct 2026 09:14:32 -0700 Subject: [PATCH] Relay: forward Anthropic's thinking field; Walkieware sends thinking disabled A direct probe of OpenRouter with the production key settled which switch DeepSeek v4.1 flash honors on the messages endpoint: Anthropic's own thinking:{type:"disabled"} brought its thinking to zero, as did the chat completions endpoint, while every `reasoning` form (effort none, enabled false, max_tokens 0) left thinking running. The relay now validates and forwards `thinking` (disabled, or enabled with a bounded budget), AcServer carries it, Walkieware sends it beside the reasoning hint, and the replay harness sends it by default. Co-Authored-By: Claude Fable 5.1 --- aesel/src/ac-server.mjs | 4 ++++ aesel/test/ac-server.test.mjs | 4 ++-- apple/walkieware/Resources/Web/engine.mjs | 2 +- apple/walkieware/Tests/replay-edit.mjs | 2 +- system/backend/easel-policy.mjs | 18 +++++++++++++++++- system/netlify/functions/easel-inference.mjs | 5 +++-- system/tests/easel-braincells.test.mjs | 10 ++++++++++ 7 files changed, 38 insertions(+), 7 deletions(-) diff --git a/aesel/src/ac-server.mjs b/aesel/src/ac-server.mjs index 0a67fd73c3..25186f2e8a 100644 --- a/aesel/src/ac-server.mjs +++ b/aesel/src/ac-server.mjs @@ -141,6 +141,8 @@ export class AcServer extends EventEmitter { // hidden thinking streams nothing visible, and on Walkieware it measured // as two thirds of all output tokens and ten seconds before the first line. reasoning = null, + // Anthropic's `thinking` field, forwarded as given; the relay bounds it. + thinking = null, } = {}) { super(); this.cwd = cwd; @@ -154,6 +156,7 @@ export class AcServer extends EventEmitter { this.layeredEdits = layeredEdits; this.outputContinuations = Math.max(0, Math.min(8, outputContinuations)); this.reasoning = reasoning && typeof reasoning === "object" ? reasoning : null; + this.thinking = thinking && typeof thinking === "object" ? thinking : null; this.approvals = new Map(); this.model = (Object.hasOwn(models, model) ? models[model] : model) || fallbackModel; // No model named on the relay is Automatic: the request names none, and @@ -415,6 +418,7 @@ export class AcServer extends EventEmitter { // A workspace turn writes whole files; the relay allows it up to 32,000. max_tokens: this.endpoint || this.workspace ? 32000 : 8192, ...(this.reasoning ? {reasoning: this.reasoning} : {}), + ...(this.thinking ? {thinking: this.thinking} : {}), ...(this.endpoint ? {stream: true} : {}), }), }), {controller, timeoutMs:this.networkTimeouts.connect}); } diff --git a/aesel/test/ac-server.test.mjs b/aesel/test/ac-server.test.mjs index f7df3df8e9..70f89c6bd3 100644 --- a/aesel/test/ac-server.test.mjs +++ b/aesel/test/ac-server.test.mjs @@ -398,9 +398,9 @@ test('a reasoning object is forwarded as given, and absent by default',async t=> const dir=await mkdtemp(join(tmpdir(),'ac-reasoning-'));t.after(()=>rm(dir,{recursive:true,force:true})); const file=join(dir,'piece.mjs');await writeFile(file,'export function paint(){}'); for(const reasoning of [undefined,{effort:'none'},'none']){ - let sent;const engine=new AcServer({cwd:dir,piece:{file},reasoning,jev:null,token:()=> 'test',fetch:async(url,options)=>{sent=JSON.parse(options.body);return serving(say('done'))();}}); + let sent;const engine=new AcServer({cwd:dir,piece:{file},reasoning,thinking:reasoning&&typeof reasoning==='object'?{type:'disabled'}:undefined,jev:null,token:()=> 'test',fetch:async(url,options)=>{sent=JSON.parse(options.body);return serving(say('done'))();}}); await engine.startTurn('test'); - if(reasoning&&typeof reasoning==='object')assert.deepEqual(sent.reasoning,reasoning);else assert.equal('reasoning' in sent,false); + if(reasoning&&typeof reasoning==='object'){assert.deepEqual(sent.reasoning,reasoning);assert.deepEqual(sent.thinking,{type:'disabled'});}else{assert.equal('reasoning' in sent,false);assert.equal('thinking' in sent,false);} } }); diff --git a/apple/walkieware/Resources/Web/engine.mjs b/apple/walkieware/Resources/Web/engine.mjs index 1855436856..a61426e004 100644 --- a/apple/walkieware/Resources/Web/engine.mjs +++ b/apple/walkieware/Resources/Web/engine.mjs @@ -205,7 +205,7 @@ vfs.setWriteHandler((path,value)=>{ const guides = vfs.preload(['pieces.md','screen.md','hand.md','kidlisp.md','api.json'].map(name=>'/easel/context/'+name)); function makeServer(){ const value=new AcServer({cwd:'/piece',piece:{file,checkpoint:async()=>{const target=source.trimEnd();for(let i=0;i<100;i++){if(painted&&lastPaintedSource.trimEnd()===target)return;await new Promise(resolve=>setTimeout(resolve,20));}}},frameCapture:false,layeredEdits:true,token:()=>token,model:window.__walkiewareModel||DEFAULT_MODEL, - fetch:async(...args)=>{benchmark('requestDispatched');const response=await globalThis.fetch(...args);benchmark('inferenceHeaders',{status:response.status});return response;},preview:true,rounds:12,outputContinuations:4,reasoning:{effort:'none'}, + fetch:async(...args)=>{benchmark('requestDispatched');const response=await globalThis.fetch(...args);benchmark('inferenceHeaders',{status:response.status});return response;},preview:true,rounds:12,outputContinuations:4,reasoning:{effort:'none'},thinking:{type:'disabled'}, developerInstructions:GENERATION_INSTRUCTIONS}); value.runtimeFeedback=()=>feedback; value.on('notification',({method,params})=>{ diff --git a/apple/walkieware/Tests/replay-edit.mjs b/apple/walkieware/Tests/replay-edit.mjs index b2a498fe40..aaa5d3a38b 100644 --- a/apple/walkieware/Tests/replay-edit.mjs +++ b/apple/walkieware/Tests/replay-edit.mjs @@ -22,7 +22,7 @@ const t0=performance.now();const now=()=>Math.round(performance.now()-t0); const rounds=[];let round=null;let painted=initial;let feedback=null; const log=(...a)=>console.log(String(now()).padStart(6)+'ms',...a); const server=new AcServer({cwd,piece:{file,checkpoint:async()=>{painted=await readFile(file,'utf8');feedback={rendered:true,logs:[],updatedAt:new Date().toISOString()};}}, - model,token:()=>token,preview:true,rounds:12,outputContinuations:4,jev:null,reasoning:process.env.REASONING_JSON?JSON.parse(process.env.REASONING_JSON):process.env.REASONING==='on'?null:{effort:'none'},frameCapture:false,layeredEdits:true,developerInstructions:GENERATION_INSTRUCTIONS, + model,token:()=>token,preview:true,rounds:12,outputContinuations:4,jev:null,reasoning:process.env.REASONING_JSON?JSON.parse(process.env.REASONING_JSON):process.env.REASONING==='on'?null:{effort:'none'},thinking:process.env.THINKING_JSON?JSON.parse(process.env.THINKING_JSON):process.env.REASONING==='on'?null:{type:'disabled'},frameCapture:false,layeredEdits:true,developerInstructions:GENERATION_INSTRUCTIONS, fetch:async(url,options)=>{ round={n:rounds.length+1,request:now(),bodyBytes:Buffer.byteLength(options.body),tools:[],deltaBytes:0};rounds.push(round); const body=JSON.parse(options.body);round.messages=body.messages.length;round.systemBytes=JSON.stringify(body.system).length;round.toolDefs=body.tools?.length; diff --git a/system/backend/easel-policy.mjs b/system/backend/easel-policy.mjs index 9d82aa3506..68983ad55f 100644 --- a/system/backend/easel-policy.mjs +++ b/system/backend/easel-policy.mjs @@ -32,7 +32,23 @@ export function inferenceRequest(body) { const wanted = body.max_tokens === undefined ? DEFAULT_MAX_TOKENS : body.max_tokens; if (!Number.isSafeInteger(wanted) || wanted < 1) throw new Error("max_tokens must be a positive integer."); if (!Array.isArray(body.messages) || body.messages.length === 0) throw new Error("At least one message is required."); - return { model, maxTokens: Math.min(wanted, HOSTED_MAX_TOKENS), reasoning: reasoningRequest(body.reasoning) }; + return { model, maxTokens: Math.min(wanted, HOSTED_MAX_TOKENS), reasoning: reasoningRequest(body.reasoning), thinking: thinkingRequest(body.thinking) }; +} + +// Anthropic's own `thinking` field. Measured 2026-10-01: on OpenRouter's +// messages endpoint this is the one shape DeepSeek v4.1 flash honors — +// {type:"disabled"} drops its thinking to zero where every `reasoning` +// form above left thousands of hidden tokens. +export function thinkingRequest(thinking) { + if (thinking === undefined || thinking === null) return null; + if (typeof thinking !== "object" || Array.isArray(thinking)) throw new Error("thinking must be an object."); + if (thinking.type === "disabled") return { type: "disabled" }; + if (thinking.type === "enabled") { + const budget = thinking.budget_tokens; + if (!Number.isSafeInteger(budget) || budget < 1024 || budget > HOSTED_MAX_TOKENS) throw new Error("thinking.budget_tokens must be an integer from 1024 up to the hosted ceiling."); + return { type: "enabled", budget_tokens: budget }; + } + throw new Error("thinking.type must be enabled or disabled."); } // OpenRouter's `reasoning` object, forwarded only in the shapes its docs name. diff --git a/system/netlify/functions/easel-inference.mjs b/system/netlify/functions/easel-inference.mjs index 715cd53545..d3330fd496 100644 --- a/system/netlify/functions/easel-inference.mjs +++ b/system/netlify/functions/easel-inference.mjs @@ -98,8 +98,8 @@ export const handler = stream(async (event) => { return fail(400, `Malformed request: ${error.message}`); } - let model, maxTokens, reasoning; - try { ({ model, maxTokens, reasoning } = inferenceRequest(body)); } + let model, maxTokens, reasoning, thinking; + try { ({ model, maxTokens, reasoning, thinking } = inferenceRequest(body)); } catch (error) { return fail(400, error.message); } // Has this handle spent its day? Over budget is a refusal here rather than a @@ -153,6 +153,7 @@ export const handler = stream(async (event) => { messages: body.messages, tools: body.tools, ...(reasoning ? { reasoning } : {}), + ...(thinking ? { thinking } : {}), stream: true, }), }); diff --git a/system/tests/easel-braincells.test.mjs b/system/tests/easel-braincells.test.mjs index 170e76b27b..328effdd40 100644 --- a/system/tests/easel-braincells.test.mjs +++ b/system/tests/easel-braincells.test.mjs @@ -73,6 +73,16 @@ test('a reasoning object passes through in the documented shapes only',()=>{ assert.equal(inferenceRequest({messages,reasoning:{}}).reasoning,null,'an empty object sends nothing'); }); +test('Anthropic-style thinking passes through as disabled or a bounded budget',()=>{ + const messages=[{role:'user',content:'hi'}]; + assert.equal(inferenceRequest({messages}).thinking,null); + assert.deepEqual(inferenceRequest({messages,thinking:{type:'disabled',extra:1}}).thinking,{type:'disabled'},'Walkieware turns thinking off'); + assert.deepEqual(inferenceRequest({messages,thinking:{type:'enabled',budget_tokens:2048}}).thinking,{type:'enabled',budget_tokens:2048}); + assert.throws(()=>inferenceRequest({messages,thinking:{type:'enabled',budget_tokens:10}}),/budget_tokens/); + assert.throws(()=>inferenceRequest({messages,thinking:{type:'sometimes'}}),/thinking\.type/); + assert.throws(()=>inferenceRequest({messages,thinking:'off'}),/must be an object/); +}); + test('a reported cost is billed at twice the price, at pack value',()=>{ assert.equal(INFERENCE_MARKUP,2); assert.equal(BRAINCELLS_PER_USD,CREDIT_PACK.credits/(CREDIT_PACK.amount/100)); -- 2.51.2