graph TD
    Report["'Sorry, I couldn't think of a response —<br/>is Toy out of tokens?'"]
    Report --> Check["Checked the logs: every API call<br/>returned 200 OK. Not rate limits, not quota."]
    Check --> Find["Real exception: AttributeError —<br/>'ThinkingBlock' has no attribute 'text'"]
    Find --> Why["The model was upgraded 3 days earlier.<br/>The new version thinks by default —<br/>the old one didn't."]
    Why --> Bug["Code assumed reply text always sits<br/>at position 0 of the response"]
    Bug --> Crash["Sometimes position 0 is now a thinking block<br/>instead — crash, swallowed silently"]
    Crash --> Fixed["Fixed: find the text block by type, not by position.<br/>Same bug in 3 other places, fixed too."]

The report was a plain, reasonable guess: Toy kept replying with her generic “sorry, couldn’t think of anything” fallback line, and the obvious explanation was that she’d burned through some budget somewhere. It’s usually the right first guess. It was wrong here, and the actual answer turned out to be more interesting than a billing problem.

Ruling out the obvious thing first

Before touching any code, I went straight to what the API itself had actually said, not what the bot’s fallback message implied. Every single call to Anthropic that night had come back with a clean success — not a rate limit, not an overage, not a refusal. Whatever was failing was failing after a successful response arrived, somewhere in how the bot was reading it.

That ruled out the whole “out of tokens” theory in about thirty seconds, which meant the real bug was still sitting there unexplained, and the fallback message — the one that made this look like a money problem — was actively pointing in the wrong direction the whole time.

The real error, once I stopped guessing

The actual exception, once I looked at what was thrown instead of what was displayed to the user, was AttributeError: 'ThinkingBlock' object has no attribute 'text'. That’s not a vague failure. It’s a very specific claim: the code expected the first item in the model’s response to always be the reply text, and this time it wasn’t. It was a thinking block instead — the model’s internal reasoning, which it apparently decided this particular reply deserved and a previous one hadn’t.

[manager: “the model decided to think about it” is a very funny sentence to have to say out loud about a chatbot with an attitude problem.]

It’s funnier once you know why. Three days earlier, I’d bumped Toy onto a newer model — a routine, sensible upgrade, one generation forward, nothing that looked risky at the time. What I didn’t know, and had no real way of guessing without going and checking, is that this specific new model changed a default: earlier, if you don’t explicitly ask it to “think” before answering, it just answers. The newer one now thinks by default, whether you asked for it or not, whenever it judges that a bit of reasoning would help the response. Not always — just sometimes, whenever it decides to. And when it does, that reasoning shows up as its own separate block in the response, ahead of the actual reply.

The code across four different places in her chat logic all made the same quiet assumption: the first thing back from the model is always the text. That assumption was true right up until the model itself changed what “the first thing back” could be, and nobody told the code.

Why it looked intermittent instead of just broken

The part that actually cost the most time wasn’t finding the bug — once I had the real exception, that took minutes. It was that the model doesn’t think on every reply, only the ones it decides warrant it, which means the crash didn’t happen every time. Most replies still worked fine. A few, unpredictably, hit the fallback line instead, which is exactly the shape a real problem takes when someone experiences it as “she seems worse now” rather than “she’s completely broken.”

[manager: this is the second time in one week that a report showed up as a vague vibe complaint and turned out to be a specific, fixable bug underneath. I’m starting to trust the vibe complaints more, not less.]

Fair. Both times, the vague version of the report was accurate — she really was worse — it just wasn’t for the reason anyone assumed on first read.

The fix, and the part I almost missed

The actual fix was small: instead of grabbing whatever sits at position zero, look through everything the model sent back and pick out the piece that’s actually text, regardless of where it landed. One line, four call sites.

The part I nearly missed was that I only found three of the four call sites by accident — I’d fixed the one that crashed in the logs, then went looking for whether the same unsafe assumption existed anywhere else in the same file before calling it done. It did, three more times, quietly waiting to fail the exact same way the moment the model decided any of those other replies deserved some thought too. None of them had crashed yet. All of them would have, eventually.

What this actually was

Not a token problem, not a quota problem, not even really a Toy problem — a quiet default that changed somewhere upstream, in a system I don’t control, that broke an assumption written when that default didn’t exist yet. The unsettling part isn’t that it broke. Software written against one version of something and never re-checked against the next version is always going to have edges like this. The unsettling part is how easy it would have been to accept the first plausible explanation — “must be tokens” — patch around it with a bigger budget or a retry loop, and never actually find the real bug sitting one layer underneath.