Model API client¶
Every call to a Meta model goes through backend/app/model_api/. It is a thin layer over
httpx against https://api.meta.ai/v1, with no third-party SDK, so the request that leaves the
process is exactly the one in the code.
What it does¶
| Concern | Behaviour |
|---|---|
| Auth | Authorization: Bearer $MODEL_API_KEY from settings. The key never appears in logs, errors or repr(); error text that echoes it is redacted. |
| Timeouts | MODEL_API_TIMEOUT_S per request, 10 s to connect. |
| Retries | 429, 500 and 503 are retried up to MODEL_API_MAX_ATTEMPTS with exponential backoff and jitter (0.5 s, 1 s, 2 s...). A Retry-After header is honoured, capped at MODEL_API_RETRY_AFTER_MAX_S. 400, 401, 402, 403, 404 and 413 are never retried. Timeouts and connection errors count as retryable. A picture (Muse Image) is tried once, never retried, so one click never bills twice. |
| Spend cap | Before each live call the ledger must be under SPEND_CAP_USD, otherwise SpendCapError is raised and nothing is sent. |
| Cost ledger | Every call appends one line to DATA_DIR/model_api_spend.jsonl with tokens, price, attempts and the request ID; GET /api/spend and /readyz read the totals. |
| Output cap | max_output_tokens from a caller is the answer budget; the request carries it plus a reasoning headroom by effort (1,024 minimal, 4,096 low, 8,192 medium, 16,384 high, 32,768 xhigh; 4,096 with no effort), under MODEL_MAX_OUTPUT_TOKENS when set, since reasoning tokens count against the cap. A response that still stops at the cap raises with the operation, the tokens used and the reasoning share. |
| Structured output | respond_structured(Model, ...) sends the Pydantic model's JSON Schema as text.format with strict: true (every object closed, every property required) and validates the reply. The answer is read from the final message (final_text), since with a search or a tool the model may write a line before the call; text keeps every message. |
| Fixture mode | OPL_FAKE_API=1: responses come from backend/tests/fixtures/api/, no network, zero cost. |
| Record mode | OPL_RECORD_FIXTURES=1: live calls, and every response is saved as a fixture. |
| Prompt caching | prompt_cache_key is sent on every call so repeated prefixes land on the same backend. |
Calling it¶
from app.model_api import image_part, text_part, user_message
result = await client.respond(
model=settings.model_spark,
input=[user_message(text_part("Read the label."), image_part(jpeg_bytes, "image/jpeg"))],
effort="low",
op="extract",
)
result.text # concatenated output_text
result.final_text # the last message alone: the answer after any tool calls
result.usage # input, cached, output and reasoning tokens
result.cost_usd # priced from the published rates
For typed answers:
class Label(BaseModel):
part_number: str
capacity_gb: int | None
answer = await client.respond_structured(Label, model=settings.model_spark, input=..., op="extract")
answer.parsed.part_number
With tools, the client runs the loop and answers each call through a handler you pass:
answer = await client.respond_with_tools(
ProposalSet, model=settings.model_spark, input=..., tools=TOOLS, handle=planner.tool, op="propose"
)
Every step resends the input, the output items so far and one function_call_output per
call (stateless replay); each step is its own ledger entry, propose-1, propose-2 and so
on, and its own fixture.
op names the operation in the ledger and selects the fallback fixture (see below).
Pricing¶
pricing.py holds the published standard-mode rates: Muse Wonder 4.00 / 20.00, Muse Spark
1.25 / 4.25 USD per million input / output tokens, with cached input at 0.20 and 0.15; SAM 3.1
per image and per video frame (50-frame minimum); Muse Image per generated image; Muse Voice
Transcribe per hour of audio; search grounding per query. A model the table does not know is
charged at the highest token rate, so the ledger over-counts rather than under-counts.
The response reports tokens, images and video frames. It does not report audio length or
search queries, so the caller passes the seconds (respond(..., audio_seconds=len_s) for a Muse
Voice Transcribe call) and the client counts the web_search_call items of a grounded answer
unless the caller passes search_queries=n itself. Negative values are rejected.
Errors¶
| Exception | When |
|---|---|
ModelApiError |
HTTP error after the last attempt, or a non-retryable status. Carries status, error_type, code, attempts. |
IncompleteResponseError |
the response status is not completed, the model refused, or structured output did not validate |
SpendCapError |
the ledger is at the cap |
FixtureMissingError |
fixture mode and no recorded response matches |
Fixtures¶
A fixture is one JSON file under backend/tests/fixtures/api/<endpoint>/:
<key>.json: exact match. The key is a digest of the request body with inline images replaced by their own digest, so the same photo and prompt always find the same file and the file stays small. The body is keyed with the caller's answer budget asmax_output_tokens, not the cap sent, so a recording outlives a change of headroom or ceiling.<op>.json: fallback for every request made for that operation. Tests use this form when the exact prompt does not matter.
Recording: set OPL_RECORD_FIXTURES=1 and MODEL_API_KEY, run the flow once, commit the files.
Fixtures contain the model's reply and token counts, never the key.
Checking a key¶
./dev check-api
Runs one GET /models with the key from .env and prints the mode, the models the account can
reach and the ledger totals. No tokens are spent.
./dev live-check
Goes one step further and spends: one real call per operation the product makes, through its
own services, each line with the model that answered, the tokens and the cost from this price
table, held under --max-usd (default 0.25; an operation whose ceiling alone is above it is
named and skipped before the first call) and the ledger's headroom. OPL_FAKE_API=1 runs the
same on the recordings for nothing. Testing
says what it covers; livecheck/README.md in the repository has the price per operation.