Gemini 4 Argon API in Python: Tested Code for Tools, JSON and Streaming
Gemini 4 Argon API Python tutorial: call google/gemini-4-argon with requests or the openai SDK, stream, call tools, get JSON Schema output, send images and handle errors. Every snippet run live.

My first real failure with the Gemini 4 Argon API was a blank answer. I asked a counting question with max_tokens=300, got HTTP 200, finish_reason: "length" and content: "". The usage block explained it: 297 of the 300 completion tokens were reasoning tokens I never saw, and I paid for all 300. That one response shapes most of the code below.
This is a Gemini 4 Argon API Python tutorial for developers who want working code, not a feature list. It calls google/gemini-4-argon through SandBase’s OpenAI-compatible Chat Completions endpoint, with plain requests and with the openai SDK. Every snippet ran against the live model on 2026-10-14 between 18:27 and 18:36 UTC, and every cost below comes from GET /v1/tasks/<id>/cost, not from my arithmetic.
Key takeaway
- Point the
openaiSDK athttps://api.sandbase.ai/v1and use the model idgoogle/gemini-4-argon. Streaming, parallel tool calls,response_formatJSON Schema and base64 image input all worked on the first try.- Hidden reasoning shares the
max_tokensbudget. At 300 tokens Argon returned an empty answer and was billed for 300 output tokens; at 16,000 it answered in 1,381.reasoning_effortis accepted but not validated:"low","high"and"banana"all returned HTTP 200. On one hard prompt, median reasoning tokens were 888 (low), 1,144 (unset) and 1,409 (high) over 3 runs each. A hint, not proof.- A request with no
messagescame back as HTTP 503, not 400, so a naive “retry every 5xx” loop retries a bug. Validate before you send.- The 35 billed calls in the first test pass cost $0.30 at SandBase’s list price of $4 / $20 per million tokens.
Before you start
You need Python 3.10+, a SandBase API key in SANDBASE_API_KEY, and two packages. I ran everything with openai==2.8.1 and requests==2.32.5.
python3 -m venv .venv && source .venv/bin/activate
pip install "openai==2.8.1" "requests==2.32.5"
export SANDBASE_API_KEY="your-key"
Two facts decide how you write the code. First, price: SandBase lists Argon at $4 per million input tokens and $20 per million output tokens, with cached input at 0.05x. That is Google’s standard rate after its introductory $2 / $10 period ends on November 30, 2026, so today it is twice what Google charges direct. The pricing breakdown covers that in detail. Second, limits: the model page shows a 1M context window and a 65.5K max output. Google’s 1M unified input-output limit is a Gemini API figure; through SandBase today, plan for about 65K output tokens per response.
The SandBase model page lists the exact model id used in every snippet, the $4 / $20 list price behind the costs below, and the 65.5K max output that bounds max_tokens (captured 2026-10-14).
Step 1: one call with requests
No SDK at all. This is the shape every other step builds on.
import os
import requests
BASE = "https://api.sandbase.ai/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['SANDBASE_API_KEY']}"}
resp = requests.post(
f"{BASE}/chat/completions",
headers=HEADERS,
json={
"model": "google/gemini-4-argon",
"messages": [
{"role": "system", "content": "Answer in one sentence."},
{"role": "user", "content": "What does HTTP status 429 mean for an API client?"},
],
"max_tokens": 1024, # covers hidden reasoning tokens AND the visible answer
},
timeout=120,
)
resp.raise_for_status()
body = resp.json()
choice = body["choices"][0]
print("id:", body["id"])
print("finish_reason:", choice["finish_reason"])
print("answer:", choice["message"]["content"])
print("usage:", body["usage"])
# What SandBase actually billed for this completion
cost = requests.get(f"{BASE}/tasks/{body['id']}/cost", headers=HEADERS, timeout=30).json()
print("billed:", cost["cost"], cost["currency"], "| settled:", cost["settled"])
Output at 18:28:30 UTC, trimmed to the fields that matter:
id: 1f83291f-8136-4572-8e06-706fbfdc1a5d
finish_reason: stop
answer: An HTTP status 429 ("Too Many Requests") means the API client has exceeded the server's allowed rate limit ...
usage: {'completion_tokens': 45, 'completion_tokens_details': {'reasoning_tokens': 0}, 'prompt_tokens': 55, ...}
billed: 0.001120 USD | settled: True
The bill checks out: 55 × $4/M + 45 × $20/M = $0.00112. Keep the id. It is the only handle you have for cost reconciliation later.
Step 2: the openai SDK with base_url
The SandBase Chat Completions guide says existing OpenAI SDK code only needs a new key and base URL. That held up. There is no SandBase SDK; this is the stock openai package.
import os
from openai import OpenAI
client = OpenAI(
base_url="https://api.sandbase.ai/v1",
api_key=os.environ["SANDBASE_API_KEY"],
timeout=120,
max_retries=2, # SDK default; retries 408/409/429/5xx and connection errors
)
completion = client.chat.completions.create(
model="google/gemini-4-argon",
messages=[{"role": "user", "content": "Name the three HTTP methods most REST APIs use for writes."}],
max_tokens=1024,
)
choice = completion.choices[0]
print("id:", completion.id)
print("finish_reason:", choice.finish_reason)
print("answer:", choice.message.content)
u = completion.usage
print("prompt:", u.prompt_tokens, "| completion:", u.completion_tokens,
"| reasoning:", u.completion_tokens_details.reasoning_tokens)
At 18:28:34 UTC it answered POST, PUT and PATCH in a short Markdown list, with 44 prompt and 143 completion tokens, 0 of them reasoning. Billed: $0.003036. Notice the answer came back as Markdown even though I didn’t ask for it. If you render into plain text, say so in the system prompt.
SandBase’s Chat Completions guide documents the base URL, the image_url content parts and the stream_options.include_usage flag used in Steps 2, 3 and 6 (captured 2026-10-14).
Step 3: streaming, with usage at the end
import os
import time
from openai import OpenAI
client = OpenAI(base_url="https://api.sandbase.ai/v1", api_key=os.environ["SANDBASE_API_KEY"])
start = time.monotonic()
first_token_at = None
parts, usage, finish, completion_id = [], None, None, None
stream = client.chat.completions.create(
model="google/gemini-4-argon",
messages=[{"role": "user", "content": "Write a 4-line poem about rate limits."}],
max_tokens=2048,
stream=True,
stream_options={"include_usage": True}, # ask for a final chunk carrying usage
)
for chunk in stream:
completion_id = completion_id or chunk.id
if chunk.usage: # final chunk: usage, usually with empty choices
usage = chunk.usage
for choice in chunk.choices:
if choice.delta and choice.delta.content:
if first_token_at is None:
first_token_at = time.monotonic() - start
parts.append(choice.delta.content)
print(choice.delta.content, end="", flush=True)
if choice.finish_reason:
finish = choice.finish_reason
print()
print("id:", completion_id)
print("finish_reason:", finish)
print(f"first visible token after {first_token_at:.1f}s, total {time.monotonic() - start:.1f}s")
print("usage:", usage)
At 18:28:39 UTC the first visible token arrived after 2.7 s and the stream finished at 3.0 s, with usage of 42 in / 46 out. That is one run from my network, not a latency benchmark.
One snag worth knowing before you build a ledger. Streamed chunks carry an id like chatcmpl-14e87e61-e706-..., while non-streamed responses use a bare UUID. GET /v1/tasks/chatcmpl-14e87e61-.../cost returned HTTP 404 “task not found”; the same request with the prefix stripped returned the cost ($0.001088). So: task_id = completion_id.removeprefix("chatcmpl-").
Step 4: function calling, full round trip
The pattern is the standard one: send tools, run whatever the model asks for, append one tool message per call with the matching tool_call_id, and ask again. The tool here is a local dictionary so the result is checkable.
import json
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.sandbase.ai/v1", api_key=os.environ["SANDBASE_API_KEY"])
MODEL = "google/gemini-4-argon"
# A local, deterministic "tool" so the result is checkable.
ORDERS = {"A-1001": {"status": "shipped", "carrier": "DHL", "eta": "2026-10-17"},
"A-1002": {"status": "processing", "carrier": None, "eta": None}}
def get_order_status(order_id: str) -> dict:
return ORDERS.get(order_id, {"error": f"unknown order {order_id}"})
TOOLS = [{
"type": "function",
"function": {
"name": "get_order_status",
"description": "Look up shipping status for one order id.",
"parameters": {
"type": "object",
"properties": {"order_id": {"type": "string", "description": "Order id like A-1001"}},
"required": ["order_id"],
},
},
}]
messages = [{"role": "user", "content": "Where are orders A-1001 and A-1002? One line each."}]
ids, total_in, total_out = [], 0, 0
for turn in range(5): # hard cap so a confused model can't loop forever
resp = client.chat.completions.create(model=MODEL, messages=messages, tools=TOOLS, max_tokens=2048)
ids.append(resp.id)
total_in += resp.usage.prompt_tokens
total_out += resp.usage.completion_tokens
msg = resp.choices[0].message
print(f"turn {turn}: finish_reason={resp.choices[0].finish_reason} tool_calls={len(msg.tool_calls or [])}")
if not msg.tool_calls:
print("answer:", msg.content)
break
# Echo the assistant turn back exactly, including tool_calls, before adding tool results.
messages.append(msg.model_dump(exclude_none=True))
for call in msg.tool_calls:
args = json.loads(call.function.arguments or "{}")
result = get_order_status(**args)
print(f" -> {call.function.name}({args}) = {result}")
messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result)})
else:
raise RuntimeError("tool loop did not finish in 5 turns")
print("completion ids:", ids)
print("tokens in/out:", total_in, total_out)
Argon asked for both orders in a single turn (two parallel tool_calls), then answered on turn 1 without inventing anything for the order that has no carrier.
Real output from Step 4 at 18:33 UTC, rendered as an image: two parallel tool calls on turn 0, a grounded answer on turn 1, and the per-turn bills from /v1/tasks/<id>/cost (JSON trimmed to cost and usage).
The two turns cost $0.001668 and $0.002000. Turn 1’s prompt (255 tokens) is twice turn 0’s (127) because the whole conversation, tool results included, goes back each time. In a ten-turn agent, that growth is your bill.
Step 5: structured output with response_format
import json
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.sandbase.ai/v1", api_key=os.environ["SANDBASE_API_KEY"])
SCHEMA = {
"type": "object",
"properties": {
"vendor": {"type": "string"},
"invoice_number": {"type": "string"},
"total_usd": {"type": "number"},
"line_items": {
"type": "array",
"items": {
"type": "object",
"properties": {"description": {"type": "string"}, "amount_usd": {"type": "number"}},
"required": ["description", "amount_usd"],
"additionalProperties": False,
},
},
},
"required": ["vendor", "invoice_number", "total_usd", "line_items"],
"additionalProperties": False,
}
TEXT = ("Invoice INV-2291 from Northwind Hosting. Dedicated server October: $1,200.00. "
"Backup storage 2 TB: $80.00. Support plan: $150.00. Amount due: $1,430.00.")
resp = client.chat.completions.create(
model="google/gemini-4-argon",
messages=[{"role": "user", "content": f"Extract the invoice fields.\n\n{TEXT}"}],
response_format={"type": "json_schema",
"json_schema": {"name": "invoice", "strict": True, "schema": SCHEMA}},
max_tokens=2048,
)
choice = resp.choices[0]
if choice.finish_reason != "stop":
raise RuntimeError(f"incomplete JSON risk: finish_reason={choice.finish_reason}")
data = json.loads(choice.message.content) # raises if the model returned non-JSON
missing = [k for k in SCHEMA["required"] if k not in data]
if missing:
raise ValueError(f"missing keys: {missing}")
items_sum = round(sum(i["amount_usd"] for i in data["line_items"]), 2)
print("id:", resp.id)
print(json.dumps(data, indent=2))
print("line items sum:", items_sum, "| matches total:", items_sum == data["total_usd"])
At 18:33:14 UTC it returned bare JSON with all three line items, total_usd: 1430.0, and the sum check passed. 397 in / 102 out, $0.003628. I also tried to break it: same mechanism, a two-field schema, and a prompt asking for “a friendly paragraph of prose”. The response was {"city": "Tokyo", "country": "Japan"}, no prose. One run, so treat it as encouraging rather than guaranteed. SandBase’s own docs say schema enforcement support varies by model, which is why the snippet still parses, checks finish_reason and checks required keys.
Step 6: image input
Vision uses OpenAI-style content parts. A local file goes in as a base64 data URL. I drew a four-bar chart with known values (Mon 42, Tue 77, Wed 31, Thu 64) so the answer could be graded.
import base64
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.sandbase.ai/v1", api_key=os.environ["SANDBASE_API_KEY"])
with open("chart.png", "rb") as f:
data_url = "data:image/png;base64," + base64.b64encode(f.read()).decode()
resp = client.chat.completions.create(
model="google/gemini-4-argon",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": "Read every bar label and value in this chart, then say which day is highest. "
"Format: Day=value, ... ; highest=Day"},
{"type": "image_url", "image_url": {"url": data_url}},
],
}],
max_tokens=2048,
)
print("id:", resp.id)
print("finish_reason:", resp.choices[0].finish_reason)
print("answer:", resp.choices[0].message.content)
print("usage: in", resp.usage.prompt_tokens, "out", resp.usage.completion_tokens)
Answer at 18:34:04 UTC: Mon=42, Tue=77, Wed=31, Thu=64 ; highest=Tue. All correct. The 640×400 PNG plus the instruction cost 1,153 prompt tokens, so the image was most of the $0.005152 bill. Budget images as input tokens, not as free attachments.
Step 7: max_tokens and finish_reason
Back to the blank answer from the opening. Argon reasons before it answers, and on SandBase those reasoning tokens are counted inside completion_tokens and billed at the $20/M output rate. max_tokens caps both together. So a cap that looks generous for a one-word answer can be spent entirely on thinking.
import os
from openai import OpenAI
client = OpenAI(base_url="https://api.sandbase.ai/v1", api_key=os.environ["SANDBASE_API_KEY"])
MODEL = "google/gemini-4-argon"
PROMPT = "How many 7-digit positive integers have a digit sum of exactly 30? Reply with the number only."
def ask(prompt: str, max_tokens: int, retry_budget: int = 16000):
resp = client.chat.completions.create(model=MODEL, max_tokens=max_tokens,
messages=[{"role": "user", "content": prompt}])
c, u = resp.choices[0], resp.usage
print(f"max_tokens={max_tokens}: finish_reason={c.finish_reason} content={c.message.content!r} "
f"completion={u.completion_tokens} reasoning={u.completion_tokens_details.reasoning_tokens} id={resp.id}")
if c.finish_reason == "length":
if max_tokens >= retry_budget:
raise RuntimeError("still truncated at the retry budget; shorten the task instead")
# Hidden reasoning shares this budget, so a short visible answer can still be cut off.
return ask(prompt, retry_budget, retry_budget)
return c.message.content
print("final:", ask(PROMPT, max_tokens=300))
The correct answer, brute-forced locally, is 453,438.
Real output from Step 7 and the reasoning-effort probe (18:30 to 18:35 UTC): a 300-token cap produced an empty answer with 297 reasoning tokens, and three effort settings produced overlapping reasoning-token ranges.
This wasn’t a fluke: across three runs of this exact script, the 300-token call stopped at 297 reasoning tokens with empty content every time. The truncated call still cost $0.006220 (55 in, 300 out). The retry cost $0.027840 for 1,381 output tokens, of which 10 were the visible answer. Three rules follow. Check finish_reason on every response, not just content. Treat empty content plus length as “budget too small”, not “model refused”. And don’t set max_tokens tight to save money on reasoning models; set it to fit, and control cost with the prompt.
At the other end, max_tokens=65536 was accepted, and so was 70,000, which is above the 65,536 maximum in the model’s schema. Both calls only had to say “OK”, so I can’t tell you whether 70,000 is clamped or passed through. Don’t rely on more than about 65K of output.
Reasoning effort: what I tested and what happened
SandBase’s model description says Argon “always reasons before answering with low, medium or high effort”, but the model’s request schema doesn’t list an effort field. So I tested two spellings before writing a word about them.
On an easy prompt (a divisibility count, answer 400), reasoning_effort set to "low", "high" and "banana", plus an OpenRouter-style reasoning: {"effort": ...} object, all returned HTTP 200 and the right answer, with reasoning tokens scattered between 0 and 209 regardless of setting. On the hard digit-sum prompt, three runs per setting:
reasoning_effort | Reasoning tokens per run | Median | Correct |
|---|---|---|---|
| unset | 1,144 / 1,010 / 1,325 | 1,144 | 3/3 |
"low" | 888 / 993 / 700 | 888 | 3/3 |
"high" | 1,706 / 1,042 / 1,409 | 1,409 | 3/3 |
The order is the one you’d expect, and every “low” run came in below every unset run. But “high” overlaps “unset”, N is 3, and an invalid value is accepted silently. My reading: SandBase passes the field through or ignores it, and I can’t distinguish those from outside. If you send reasoning_effort, use "low", "medium" or "high", measure reasoning_tokens on your own prompts, and don’t treat a 200 as confirmation that the setting took effect. Those nine hard-prompt calls cost $0.208 together, the largest line in this tutorial.
Step 8: error handling and retries
I sent the usual mistakes with raw requests to see the real status codes:
| Mistake | HTTP | Body (trimmed) |
|---|---|---|
| Wrong API key | 401 | {"error":"invalid API key"} |
google/gemini-4-argon-preview | 404 | type: not_found_error, “model not found” |
gemini-4-argon (no vendor prefix) | 404 | same |
No messages field | 503 | type: server_error, “Input required: specify “prompt” or “messages"" |
Two things surprised me. The 401 body is a plain string under error, while the others are OpenAI-style objects, so read errors defensively. And the missing-messages bug comes back as a 503, which most generic retry policies, the openai SDK’s included, treat as transient. None of these responses carried a Retry-After header. Google’s model card also lists “occasional slowness or timeout issues” as a known limitation, so a generous timeout and bounded retries are worth having.
import os
import random
import time
import requests
BASE = "https://api.sandbase.ai/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['SANDBASE_API_KEY']}"}
RETRYABLE = {429, 500, 502, 503, 504}
class ArgonError(RuntimeError):
pass
def chat(payload: dict, max_attempts: int = 3, timeout: float = 180) -> dict:
if not payload.get("messages"):
raise ArgonError("messages is empty; SandBase answers this with a 503, so catch it locally")
for attempt in range(1, max_attempts + 1):
try:
r = requests.post(f"{BASE}/chat/completions", headers=HEADERS, json=payload, timeout=timeout)
except (requests.Timeout, requests.ConnectionError) as exc:
status, detail, retry_after = None, repr(exc), None
else:
if r.status_code == 200:
return r.json()
status, detail, retry_after = r.status_code, r.text[:200], r.headers.get("retry-after")
if status not in RETRYABLE:
raise ArgonError(f"HTTP {status}, not retrying: {detail}")
if attempt == max_attempts:
raise ArgonError(f"gave up after {attempt} attempts, last: HTTP {status} {detail}")
delay = float(retry_after) if retry_after else min(30, 2 ** attempt) + random.random()
print(f" attempt {attempt}: HTTP {status}, sleeping {delay:.1f}s")
time.sleep(delay)
msgs = [{"role": "user", "content": "Reply with OK."}]
for label, payload in [
("good request", {"model": "google/gemini-4-argon", "messages": msgs, "max_tokens": 256}),
("typo in model id", {"model": "google/gemini-4-argn", "messages": msgs, "max_tokens": 256}),
("messages missing", {"model": "google/gemini-4-argon", "max_tokens": 256}),
]:
try:
body = chat(payload)
print(label, "-> ok:", body["id"], body["choices"][0]["message"]["content"])
except ArgonError as exc:
print(label, "-> error:", exc)
The first version of this wrapper, run at 18:35:59 UTC, had no messages guard. A good request returned “OK”, the typo’d model id failed fast on the 404, and the empty request burned two retries and about 7 seconds on 503s before giving up. The guard at the top of chat() is the fix: with it, the third case fails locally without a network call. With the SDK, catch openai.NotFoundError and openai.AuthenticationError and don’t retry them; I confirmed the 404 surfaces as NotFoundError with the body above. One more rule from SandBase’s docs that matters for Step 4: don’t auto-retry a turn whose tool side effects may already have happened.
What the whole tutorial cost
| Step | Billed calls | Cost |
|---|---|---|
| Steps 1 to 6, plus the schema “prose” probe | 8 | $0.019 |
| Step 7 (truncated + retry) | 2 | $0.034 |
| Step 8 and error probes (successful calls only) | 4 | $0.001 |
| Reasoning-effort probes | 20 | $0.241 |
| First smoke test | 1 | $0.004 |
| Total, first pass | 35 | $0.299 |
Failed requests (401, 404, 503) returned no completion id, so there was nothing to bill. Every row is the sum of /v1/tasks/<id>/cost responses. After editing, I extracted all eight Python blocks from this post and ran them unchanged between 18:42 and 18:44 UTC. All eight exited 0 with the same behavior: two parallel tool calls, a schema-valid invoice, a correct chart reading, and the 300-token truncation again (297 reasoning tokens, empty content, in both reruns). That pass billed another $0.053 reconciled, plus about $0.025 for one Step 7 rerun whose ids I hadn’t printed yet, estimated from its usage.
FAQ
Can I use the openai Python SDK with Gemini 4 Argon?
Yes. Set base_url="https://api.sandbase.ai/v1", pass your SandBase key, and use model="google/gemini-4-argon". Chat, streaming, tools, JSON Schema output and image input all worked with openai==2.8.1.
Why is my Gemini 4 Argon response empty?
Check finish_reason. If it is length, hidden reasoning used up max_tokens before the visible answer started. Raise max_tokens; reasoning tokens are billed as output either way.
Does SandBase support reasoning_effort for Gemini 4 Argon?
It accepts the field (any value, including invalid ones) without error. On one hard prompt, “low” used fewer reasoning tokens and “high” more, over three runs each, but the ranges overlapped. Measure it on your own workload.
How do I get the cost of one call?
Call GET https://api.sandbase.ai/v1/tasks/<completion id>/cost with the same Bearer key. For streamed calls, strip the chatcmpl- prefix from the chunk id first.
What is the maximum output through SandBase?
The model page lists 65.5K max output and the schema caps max_tokens at 65,536. Google’s 1M unified limit applies to its own Gemini API.
Limits of this tutorial and next steps
Everything above is one run per snippet (three per setting for effort), from one network, on one day. Latency figures are anecdotes. I didn’t test audio, video or PDF input, prompt caching, or the continuation protocol Google mentions in its launch post. Benchmarks are a separate question: where Argon ranks independently and how it compares with Claude Opus 5.5 and GPT-6 Astra have their own posts, and our pre-launch access note has the background from before GA.
To run the code yourself, grab a key and the model id from the Gemini 4 Argon model page. Start with Step 7’s ask() and Step 8’s chat(); they’re the two pieces I’d keep in any production client.