Documentation · Scoring API
Scoring API
Beta. This reference describes the beta interface and may change.
Endpoint
POST https://api.spinf.com/v1/score
Authorization: Bearer ssk-...
Content-Type: application/json
POST https://api.spinf.com/v1/chat/completions accepts the same body, so the OpenAI SDKs work as they are: put
the scoring block in extra_body. Its response is the same, plus "choices": [].
Create API keys in the console. Keep them on your servers: the scoring API refuses API keys sent from a browser. To try calls without code, use the playground.
How a question works
A question is a template: text read after your content, ending where the answer goes, marked {?}. You list
the options (the answers) to score at that point, and spinf returns the probability of each one.
template: "\n\nWill {commodity} prices rise or fall next month?\nAnswer:{?}"
options: [" rise", " fall"]
{name}is a placeholder, filled fromcombinations: one template asks many questions (crude oil, natural gas, …). The content is read once and shared by every question.{?}must end the template: the model reads what comes right after it.- Options are written as they would follow the template, usually with a leading space (
" rise"). Don't put a space before{?}as well: the two spaces tokenize badly and the request is rejected (option_boundary). - Write
{{and}}for literal braces.
Writing good templates
- Content first. The template is read after the content: start it with a line break (
"\n\n…") and end it right where the answer goes. - Short, distinct answers. One word each works best (
" rise"/" fall"), written as the model would continue the template. - The template is part of the question. Rewording it changes what the model is asked, and so
p. Naming the answers ("… Answer rise or fall.") lowers the residual, but it can also move the lift. Check a template on a few examples you know the answer to before you rely on it, and keep it fixed once you compare scores over time. - Skip the floor you already have. The floor depends only on the question, so you can keep the
floor_pof an earlier call and send"empty_floor": false. Floors from different calls typically agree to within a few hundredths (reads packed in one call move slightly together); recompute them whensystem_fingerprintchanges.
Request, one content
{
"model": "spinf-12b",
"messages": [
{ "role": "user", "content": [ { "type": "text", "text": "OPEC+ agreed on Sunday to extend its output cuts…" } ] }
],
"scoring": {
"queries": [
{
"id": "direction",
"template": "\n\nWill {commodity} prices rise or fall next month?\nAnswer:{?}",
"options": [" rise", " fall"],
"combinations": [ { "commodity": "crude oil" }, { "commodity": "natural gas" } ]
},
{
"id": "topic",
"template": "\n\nThe article is mainly about{?}",
"options": [" oil", " natural gas", " coal"]
}
]
}
}
Request, many contents
Send inputs instead of messages. The queries in scoring.queries are shared: every input is scored with all
of them, or only those listed in its query_ids. An input can add its own queries (same shape). Packing many
contents into one call is cheaper: one per-call minimum, and the empty floor of a shared question is computed once.
Here sector asks both articles, and dir asks Apple's input a grid ("all": every company × metric):
{
"model": "spinf-12b",
"inputs": [
{ "id": "apple",
"messages": [ { "role": "user", "content": "Apple reported record iPhone revenue while Mac margins slipped." } ] },
{ "id": "exxon",
"messages": [ { "role": "user", "content": "Exxon cut output after OPEC+ extended its supply cuts." } ],
"query_ids": ["sector"] }
],
"scoring": {
"queries": [
{ "id": "sector",
"template": "\n\nThis article is mainly about the{?}",
"options": [" technology sector", " energy sector", " banking sector"] },
{ "id": "dir",
"template": "\n\nWill {company}'s {metric} rise or fall next quarter?\nAnswer:{?}",
"options": [" rise", " fall"],
"combinations": "all",
"values": { "company": ["Apple"], "metric": ["revenue", "margin"] } }
]
}
}
An input with its own question:
{ "id": "a3", "messages": [ … ],
"queries": [ { "id": "own", "template": "\n\nIs this good news for markets?\nAnswer:{?}", "options": [" yes", " no"] } ] }
Fields
| Field | Rules |
|---|---|
model | Default spinf-12b, or its standard name google/gemma-4-12B (Models). spinf-31b is on request: it answers only for the accounts it is enabled for (else 403 model_not_enabled), and reads text and images (no audio: 400 media_not_supported). Images still need image input enabled for your account. |
messages / inputs | Exactly one of them. messages is one content (its input id is "0"). inputs[].id: unique, up to 128 characters. |
| content | The parts of an input's messages, read as one piece of content, up to 4,096 tokens: text in order (images, in preview, are read first). A message content is a string or a list of parts: {"type": "text", "text": …}, and images and audio when enabled for your account (preview, see Images, video and audio). Roles are accepted but not used: the text is read as it is, with no chat formatting. |
queries[].id | Unique within an input, up to 128 characters. |
queries[].template | Up to 5 distinct placeholders {name} (letters, digits, _) and exactly one {?}, at the end. Up to 256 tokens once filled. |
queries[].options | 1 to 1,000 answers, each 1 to 5 tokens, no duplicates (ignoring case when case_insensitive). |
queries[].combinations | When the template has placeholders: a list of objects {name: value} with exactly its placeholders, or "all" with values = {name: [value, …]} for every combination. Up to 1,000 per query; values up to 256 characters. |
queries[].empty_floor | Optional: overrides scoring.empty_floor for this question. |
scoring.empty_floor | Default true: every question is also scored with empty content (see below). false: not computed and not billed; the floor fields are null. |
scoring.case_insensitive | Default true: each answer counts all its case variants (rise, Rise, RISE, with and without the leading space). false: the exact string only. |
endpoint | Optional: run the call on one specific endpoint. Admin-granted: your account must be cleared for it (ask us), or it is one of your dedicated deployments. The call runs there only, with no fallback: if the endpoint is busy or down the call fails (503 endpoint_unavailable, retry later or send it without endpoint). Absent: automatic routing across our capacity. |
Limits: up to 10,000 questions (templates × combinations) per content, a request body up to 2 MB, and up to 2M processed tokens per request.
Images, video and audio (preview, on request)
Preview. Image, video and audio input is in preview and not generally available: we enable it per account on request (contact us). Formats, limits and scores may change.
Content can include images and audio besides text, as OpenAI content parts. They are shared by every question of the input, like text:
{ "role": "user", "content": [
{ "type": "text", "text": "The chart in this filing:" },
{ "type": "image_url", "image_url": { "url": "data:image/png;base64,iVBORw0KGgo…", "detail": "auto" } },
{ "type": "text", "text": "Listen to this clip from the earnings call." },
{ "type": "input_audio", "input_audio": { "data": "UklGRiQ…", "format": "wav" } }
] }
- Where they are read: images are read first, before the text of the content, where the model reads them best; audio stays where you put it, so a short text part before it can introduce it.
- Images:
data:URLs only (png,jpeg,webp,gif); we never fetch a remote URL.detailsets the image's token budget:lowup to 70,auto(default) up to 280,highup to 1,120 tokens, depending on the aspect ratio, plus 2 marker tokens per image (a square image atautois 258 tokens in all). - Audio: base64 in
wav,mp3,flac,oggorm4a; 1 token per 40 ms (750 per 30 s), plus 2 marker tokens per 30 s segment. - Video: sent as frames you sample from the video, each with its time, read in order where you put them; every question shares the frames. Both models read video. The format is shared with accounts in the preview.
- Media count as content tokens: billed like text, and within the 4,096-token content limit of an input.
- The request body stays under 2 MB: about 30 s of 16 kHz WAV, much longer compressed audio, or a 1–2 MP JPEG.
- Models:
spinf-12breads images and audio;spinf-31b(on request) reads images but not audio. - Errors:
403 media_not_allowed(not enabled for your account),400 media_not_supported(audio sent tospinf-31b),400 invalid_media(the image or audio could not be decoded),400 unsupported_content(a remote image URL, or another part type).
Tips. Questions about audio read better when a short text part introduces the clip. Yes/no questions about
genre-like attributes of audio are weak: ask with a set of options instead (" jazz", " rock", " classical", …).
Prompt-packs
Add a ready-made, calibrated set of questions, like content moderation, with scoring.packs: it is scored on the same
content in the same call as your own queries. See Prompt-packs.
"scoring": {
"packs": [ { "id": "spinf/moderation" } ],
"queries": [ … your questions … ]
}
Response
The response of the "many contents" request above (a live call to spinf-12b):
{
"id": "req_01M3B149VXXH39YGQX5CEW3SY5",
"object": "score",
"created": 1790297909,
"model": "spinf-12b",
"system_fingerprint": "fp_87dd99dbf6",
"results": [
{ "input_id": "apple",
"queries": [
{ "id": "sector",
"combinations": [
{ "values": {},
"options": [
{ "text": " technology sector", "logprob": -8.439295, "p": 0.933484, "floor_p": 0.019076 },
{ "text": " energy sector", "logprob": -12.483436, "p": 0.016359, "floor_p": 0.41683 },
{ "text": " banking sector", "logprob": -11.363065, "p": 0.050157, "floor_p": 0.564094 } ],
"residual": 0.999768, "floor_residual": 0.999993 } ] },
{ "id": "dir",
"combinations": [
{ "values": { "company": "Apple", "metric": "revenue" },
"options": [
{ "text": " rise", "logprob": -1.776795, "p": 0.801008, "floor_p": 0.525006 },
{ "text": " fall", "logprob": -3.169399, "p": 0.198992, "floor_p": 0.474994 } ],
"residual": 0.788792, "floor_residual": 0.861084 },
{ "values": { "company": "Apple", "metric": "margin" },
"options": [
{ "text": " rise", "logprob": -2.467582, "p": 0.564992, "floor_p": 0.595533 },
{ "text": " fall", "logprob": -2.729028, "p": 0.435008, "floor_p": 0.404467 } ],
"residual": 0.849928, "floor_residual": 0.84605 } ] } ] },
{ "input_id": "exxon",
"queries": [
{ "id": "sector",
"combinations": [
{ "values": {},
"options": [
{ "text": " technology sector", "logprob": -10.925335, "p": 0.010058, "floor_p": 0.019076 },
{ "text": " energy sector", "logprob": -6.343364, "p": 0.982723, "floor_p": 0.41683 },
{ "text": " banking sector", "logprob": -11.256995, "p": 0.007219, "floor_p": 0.564094 } ],
"residual": 0.998211, "floor_residual": 0.999993 } ] } ] }
],
"warnings": [],
"usage": {
"prompt_tokens": 112, "completion_tokens": 0, "total_tokens": 112,
"prompt_tokens_details": { "content_tokens": 22, "query_tokens": 50, "floor_tokens": 40 },
"billed_tokens": 1000
}
}
The sector floor is the same for both inputs: it was computed once. The article lifts " rise" for Apple's revenue
from 0.53 to 0.80; for its margin, p (0.56) sits near the floor (0.60), so the content says little about it. The
sector residuals are close to 1 because its answers have two tokens each; p still separates them clearly.
| Field | Description |
|---|---|
results[] | One entry per input, in request order; then its queries, combinations and options, in request order. |
options[].p | The probability of this answer among the answers you listed (they sum to 1). |
options[].logprob | The model's log probability of this answer after the filled template: the exact string, its tokens' log probabilities summed, and its case variants added when case_insensitive. |
residual | 1 − the total probability of your answers: the mass on any other continuation. Mostly set by the template and the words; close to 1 for answers of several tokens, whose exact-string probability is small. Judge a question on p and the lift, and compare residuals only across contents under the same question. |
options[].floor_p, floor_residual | The same with empty content: the question's built-in bias. The lift p − floor_p is what the content says. null without the empty floor. |
warnings[] | low_option_mass when a question's one-token answers take less than 10% of the probability on average (mean residual over 0.9): check the words and the template. A hint, never an error. |
system_fingerprint | An opaque identifier of the engine build and configuration that produced the scores: it changes when anything that affects the scores changes. Compare it to know whether two responses are directly comparable. |
usage.prompt_tokens_details | content_tokens: each content once. query_tokens: per question, the filled template plus each answer's tokens after its first. floor_tokens: the same for the empty floor, once per distinct question in the call. |
usage.billed_tokens | prompt_tokens, with a minimum of 1,000 per call. |
Response headers: X-Request-Id, X-Spinf-Concurrency-Limit, X-Spinf-Concurrency-Remaining,
X-RateLimit-Limit, X-RateLimit-Remaining.
Billing
Each call is billed billed_tokens × price. A request needs at least $0.10 of credit to start, and its cost is
charged when the scores are returned; a failed or refused request is not charged. Requests running at the same time
can take the balance slightly below zero; new requests are then refused until you add credit.
Errors
Every error has the same shape:
{
"error": {
"type": "invalid_request",
"code": "invalid_template",
"message": "scoring.queries.0.template: {?} must end the template: text after it would never be read.",
"param": "scoring.queries.0.template"
},
"request_id": "req_01J8ZB2Q6X4M3T"
}
| Status | code | Meaning |
|---|---|---|
| 400 | invalid_json, invalid_field | Not JSON, or a field of the wrong type or shape. param names it. |
| 400 | invalid_template | The template has no {?}, more than one, text after it, a bad placeholder or more than 5. |
| 400 | unknown_placeholder, missing_value | combinations / values don't match the template's placeholders. |
| 400 | too_many_options, duplicate_option, option_too_long, option_boundary | Answers: more than 1,000, repeated, over 5 tokens, or not tokenizing cleanly after the template (check the spaces). |
| 400 | too_many_combinations, too_many_prompts | Over 1,000 combinations in a query, or 10,000 questions per content. |
| 400 | duplicate_id, unknown_query_id, empty_queries | Ids used twice, query_ids not in scoring.queries, an input with nothing to score. |
| 400 | content_too_long, template_too_long, unsupported_content | Content over 4,096 tokens, a filled template over 256, a remote image URL or an unknown part type. |
| 401 | missing_key, invalid_key, revoked_key, expired_key | Missing or invalid API key. |
| 402 | no_credit | Less than $0.10 of credit. Add credit in the console. |
| 403 | model_not_enabled, account_suspended | The model is not enabled for your account, or the account is suspended. |
| 403 | media_not_allowed | Image or audio input (preview) is not enabled for your account (contact us). |
| 400 | media_not_supported | The model does not read this kind of media: audio sent to spinf-31b. |
| 400 | invalid_media | An image or audio part cannot be decoded, or is not a valid data URL / base64. |
| 403 | endpoint_not_allowed | Your account is not cleared for the endpoint you named. |
| 404 | model_not_found, endpoint_not_found | Unknown model or endpoint. |
| 413 | body_too_large, request_too_large | Request body over 2 MB, or over 2M processed tokens. |
| 429 | concurrency_limit, rate_limit | Too many concurrent requests for your credit balance, or too many per minute. Retry after Retry-After seconds. |
| 500 | engine_error, internal_error | Scoring failed. The request was not charged. |
| 503 | warming_up, overloaded, endpoint_unavailable | Capacity is starting up or busy, or the endpoint you pinned is busy or down. Retry after Retry-After seconds. |
Prompt-pack errors (unknown_pack, pack_media_not_supported, pack_conflict, …) are listed in
Prompt-packs. Quote the request_id when you contact us about a request.
Other endpoints
GET https://api.spinf.com/v1/models(no key): the models, the open-weights model each is based on (base_model), their other accepted names (aliases), status, price and minimum per call.GET https://api.spinf.com/v1/packs(no key): the prompt-packs, their categories, default thresholds and decision modes.GET https://api.spinf.com/v1/packs/{id}(API key) downloads a pack file.GET https://api.spinf.com/v1/account(API key): the key's account: balance, concurrency and rate limits, models enabled.
Models
Both models are Google DeepMind's open-weights Gemma 4 models, optimized by spinf for scoring at scale.
| Model | Also accepted as | Based on | Status | Price |
|---|---|---|---|---|
spinf-12b | google/gemma-4-12B, gemma-4-12b | Gemma 4 12B | available (beta) | $0.09 / 1M input tokens |
spinf-31b | google/gemma-4-31B, gemma-4-31b | Gemma 4 31B | on request: contact sales to enable it on your account; text and images | contact sales |
The spinf id and the standard names are interchangeable, in any case: the response's model is the name you sent, and
usage and billing are the same. Gemma is a trademark of Google LLC; the Gemma 4 weights are released under the Apache
2.0 license.
Raw Markdown for agents: /docs/scoring-api.md · /llms.txt