I signed up for a free-tier LLM API, hit a credit-card prompt before I'd made a single call, and assumed it meant I was about to get billed. I was wrong about that one. I was not wrong that the SDK code I wrote next would break twice before I got a real response back.
TL;DR: Sakura AI Engine's free tier (3,000 chat completions/month, hosting OpenAI's open-weight gpt-oss-120b) broke 3 assumptions in a row: that a credit-card prompt on a free plan means billing, that Anthropic's Python SDK authenticates the same way against every compatible endpoint, and that content[0] in a response is always the text you asked for. Only the last two are real bugs. Both have a one-line fix once you know they exist.
401 - Unauthorized
auth_token="<key>"
instead of api_key="<key>"
when the endpoint expects Bearer auth instead of an x-api-key
header. Same mechanism as Claude Code's ANTHROPIC_AUTH_TOKEN
.'ThinkingBlock' object has no attribute 'text'
resp.content[0]
. Filter instead: [b.text for b in resp.content if b.type == "text"]
.Sakura Internet, a Japanese cloud provider, opened a free tier for an API called "AI Engine": 3,000 chat completions and 50 audio-related requests per month, hosting gpt-oss-120b
(OpenAI's open-weight model) behind both an OpenAI-compatible endpoint and an Anthropic-Messages-API-compatible endpoint. Sign-up asked for 5 things in order: a member ID, a project, a phone number verified by SMS, a credit card, and finally an account token in the form <UUID>:<secret>
, shown exactly once.
The credit card step made me stop. A "free" plan asking for a card reads like a billing trap. It isn't, in this case — the free "foundation model" plan only rate-limits you past 3,000 requests/month, and a separate pay-as-you-go plan is the only one that meters and bills overage. The two plans don't auto-convert into each other; the card is identity verification, not a billing trigger. That part turned out to be a non-issue. What actually blocked me was the code.
The OpenAI-compatible endpoint worked on the first try:
from openai import OpenAI
client = OpenAI(
api_key="<UUID>:<secret>",
base_url="https://api.ai.sakura.ad.jp/v1",
)
resp = client.chat.completions.create(
model="gpt-oss-120b",
messages=[{"role": "user", "content": "Hello, who are you? Introduce yourself in one line."}],
)
print(resp.choices[0].message.content)
Output: ChatGPT
. (gpt-oss-120b
is an open-weight model OpenAI released; its training data appears to include ChatGPT-authored text, which is the likely reason it introduces itself that way.)
Then I pointed the same account token at the Anthropic-compatible endpoint, using Anthropic's own Python SDK, and swapped in the field name I already knew:
from anthropic import Anthropic
client = Anthropic(
api_key="<UUID>:<secret>",
base_url="https://api.ai.sakura.ad.jp",
)
resp = client.messages.create(
model="gpt-oss-120b",
max_tokens=500,
messages=[{"role": "user", "content": "Hello, who are you?"}],
)
That raised:
anthropic.AuthenticationError: Error code: 401 - {'error': {'message': 'Unauthorized'}}
Same token, same model, same provider account — just a different SDK, and a 401. The Anthropic SDK's api_key=
sends an x-api-key
header by default. Sakura AI Engine's Anthropic-compatible endpoint expects Bearer auth instead, which the SDK only sends when you use auth_token=
. This is the exact mechanism Claude Code itself uses when it's pointed at a gateway through the ANTHROPIC_AUTH_TOKEN
environment variable — so once I remembered that, the fix was a one-word field swap:
from anthropic import Anthropic
client = Anthropic(
auth_token="<UUID>:<secret>", # not api_key=
base_url="https://api.ai.sakura.ad.jp",
)
resp = client.messages.create(
model="gpt-oss-120b",
max_tokens=500,
messages=[{"role": "user", "content": "Hello, who are you?"}],
)
print(resp.content)
That returned 200, and printed:
[ThinkingBlock(thinking='...', type='thinking'), TextBlock(text='ChatGPT', type='text')]
Which is where the second bug showed up. My original code, written before I'd seen this shape, read resp.content[0].text
. gpt-oss-120b
is a reasoning model, and the Anthropic-compatible endpoint puts a ThinkingBlock
first in the content
array, not the answer. ThinkingBlock
has no .text
attribute, so that line threw:
AttributeError: 'ThinkingBlock' object has no attribute 'text'
The fix is to stop assuming position and filter by type instead:
text_blocks = [b.text for b in resp.content if b.type == "text"]
print(text_blocks[0] if text_blocks else "(no text block)")
That printed ChatGPT
— the same answer the OpenAI-compatible endpoint gave, confirming both endpoints were routing to the same model the whole time. The auth header and the response shape were the only things standing in the way.
| blocker | what broke | the fix |
|---|---|---|
| Credit card on a free plan | Looked like a billing trigger before any call was made | Non-issue: free "foundation model" plan only rate-limits past 3,000 req/month; the metered plan is separate and doesn't auto-activate |
| Anthropic SDK auth | ||
api_key= → AuthenticationError: Error code: 401 - {'error': {'message': 'Unauthorized'}} |
||
auth_token= (Bearer auth — same mechanism as Claude Code's ANTHROPIC_AUTH_TOKEN ) |
||
| Reasoning model response shape | ||
resp.content[0].text → AttributeError: 'ThinkingBlock' object has no attribute 'text' |
||
[b.text for b in resp.content if b.type == "text"] |
The first row cost time worrying, not debugging. The other two cost actual stack traces, and both trace back to the same root cause once you line them up.
Call it the compatibility mirage: an endpoint that accepts the same request shape and the same model name as the SDK you already know isn't necessarily compatible with everything that SDK assumes. The parts that silently differ are the parts you can't see in a quick glance at the docs — the auth handshake and the response schema.
Matching the URL and the payload shape isn't enough. You also have to match how the client authenticates and how it parses what comes back — and a "compatible" endpoint can diverge on both without ever changing the request you send.
I'd guess this isn't unique to Sakura AI Engine. Any provider exposing an Anthropic-Messages-API-shaped endpoint with Bearer auth will hit the same 401 until auth_token=
replaces api_key=
. And any client code written against content[0]
will break the moment a reasoning model puts a ThinkingBlock
first — regardless of which provider is serving it.
No, not on the free "foundation model" plan — you get rate-limited, not billed. Only a separate pay-as-you-go plan meters and charges for overage, and the two plans don't auto-convert into each other.
Because api_key=
sends an x-api-key
header by default, and endpoints that expect Bearer auth (Authorization: Bearer <token>
) need auth_token=
instead. This is the same mechanism Claude Code uses through the ANTHROPIC_AUTH_TOKEN
environment variable when talking to a gateway.
resp.content[0]
not always the text response?
Reasoning models such as gpt-oss-120b
can return a ThinkingBlock
as the first element of the content
array instead of the answer. ThinkingBlock
has no .text
attribute, so indexing position 0 and reading .text
throws AttributeError
. Filter by block.type == "text"
instead of assuming index 0 is the answer.
Yes — in this case both endpoints routed to the same underlying gpt-oss-120b
model and returned the same text (ChatGPT
) once the auth header and the response parsing were both fixed.
If you're integrating a reasoning model behind any Anthropic-Messages-API-compatible endpoint:
content[0]
or .content[0].text
. If you find it, you have the same latent bug this article does — it just hasn't fired yet because your model hasn't returned a thinking block first.auth_token=
before assuming the key itself is wrong.content[0]
assumption with a type filter: [b.text for b in resp.content if b.type == "text"]
.Minimal repro, end to end: save the fixed auth_token=
client code above as sakura_hello.py
, then:
pip install anthropic
python sakura_hello.py
bash
$ python sakura_hello.py
[ThinkingBlock(thinking='...', type='thinking'), TextBlock(text='ChatGPT', type='text')]
ChatGPT
None of these three took more than a few minutes to fix once I found them. All three would have been avoidable with five minutes of reading the response schema first — which is exactly the five minutes I skipped.
Sho Naka (nomurasan). I test with a real API key before I trust what the docs imply. Verified end-to-end against Sakura AI Engine's live API and a real account token.
This is an adapted English rewrite of an essay I first wrote in Japanese, on Qiita. I worked with AI to shape and restructure the piece for a dev.to audience; the code, the errors, and the conclusions are my own, verified against a live API key.