Fire API — Reference Documentation
Version: 1.0.0
Base URL (prod): https://fire.prosaga.net
Base URL (test): https://fire.test1.prosaga.net
API prefix: /v1
Spec reference: RFC 9697 (The Covenant — AIGCSEP)
Tagging calls (tags)
Every /v1/chat, /v1/workflows/*, and /v2/flows/* call accepts an
optional tags: string[] param (each string ≤64 chars). Tags land in
prompt_logs.metadata->tags (jsonb) and are GIN-indexed
(prompt_logs_metadata_gin, jsonb_path_ops) for fast containment
lookups — metadata @> '{"tags": ["..."]}' — instead of a full scan.
Existing conventions already in production: flow_run:<id>, step:<name>
(multi-agent flow runs, see FlowSpecCompiler), <site>-compliance /
pre-gen (imgeng compliance checks).
Ticket linkage (T-107, added 2026-08-21): any agent making a Fire call
in service of a specific orchestration.tickets row should add
ticket:T-XXX as one more tag. This is how ticket→Fire-call cost and
efficiency accountability works — no sub-ticket per call, just a tag on
the call plus the index. Not enforced, purely additive; untagged calls
still log and bill normally.
Layer 2 — Workflows
Layer 1 (/v1/chat, /v1/image) is raw model access. Layer 2 is canned
server-side workflows composed from L1 calls — same auth, same logging,
same metering per call. Slugs resolve through the workflow registry:
POST /v1/workflows/{slug} Bearer fire_sk_* (per-workflow ability)
POST /v1/workflows/image (ability: image)
Full image-production pipeline: pre-gen compliance → IP sanitization → prompt expansion → per-model prompt rendering → parallel generation → server-side storage → post-gen compliance policy. Blocked images are deleted server-side and never returned.
Request:
{
"site": "isl", // saga|imagelab|isl|pornart — drives compliance policy
"scene": "...", "subjects": ["..."], "composition": null,
"tag_keys": [], "style_keys": [], "style_note": "",
"species_names": ["z-image-turbo"], // 1-8 models, parallel
"n": 1, "size": "1024x1024", // requested size — coerced per-species when unsupported, see images[].size
"explicitness": null, "user_id": null
}
Response (200):
{
"compliance_blocked": false,
"compliance_status": "passed", // or "ip_sanitized" (+ compliance_replacements — IP identities and/or age-ambiguous wording rewritten to unmistakable adult; value kept as "ip_sanitized" for back-compat)
"raw_prompt": "...", "scene_expanded": "...",
"scene_expanded_by_style": {"prose": "...", "tags": "...", "tags_subjects": ["..."]},
"billing": {"usd": 0.06215, "breakdown": {"images": 0.06, "prompt_expansion": 0.0012, "compliance_pre_gen": 0.0003, "compliance_post_gen": 0.00065}},
"images": [{
"species_name": "...", "display_name": "...", "provider": "...",
"cost_multiplier": 1.0, "prompt": "...", "ok": true,
"call_id": "9f2c1a7e...64-hex...",
"price": 0.06,
"size": "832x1216", "size_coercion": null,
"files": [{"path": "...", "url": "https://.../storage/...", "hash": "sha256", "price": 0.01,
"call_id": "9f2c1a7e...64-hex...",
"title": "Lighthouse in a storm", "auto_tags": ["lighthouse", "storm", "coast"],
"subjects": [{"ethnicity": "...", "hair": "...", "eyes": "...", "build": "...", "age_range": "...", "attire": "..."}],
"post_gen": {"action": "pass|flag", "category": null, "reason": null}}],
"attempts": [ /* one entry per generation attempt, incl. deletes/retries */ ]
}],
"unresolved": [{"species_name": "...", "error": "..."}]
}
images[].size / images[].size_coercion (added 2026-09-13, T-543) — the
requested size fans out across every species_names entry, but not every
model supports it exactly (a batch mixing SDXL-family and aspect-ratio-mode
models is common). Rather than dropping a model that can't render the exact
size (the prior behavior — 39% of ISL's sdxl/venice-sd35 slots were going
unused this way), Fire coerces to the nearest size that model actually
supports: nearest allowed size by area with orientation preserved (a
landscape request never becomes portrait) for a fixed-size model, nearest
declared ratio for an aspect-ratio model, or a scale-down to fit for a
dimension-range model. images[].size is always the size actually rendered;
size_coercion is null when it matches the request exactly, or
{"species_name", "requested_size", "coerced_size", "reason"} when it was
substituted — show this to the end user so they know what actually came
back. A model with no compatible size at all (e.g. a square-only model asked
for a non-square size) still lands in unresolved, unchanged from before.
images[].call_id (added 2026-09-07) identifies each model's generation
call — pass it to POST /v1/calls/{call_id}/report / /rate to attach
user feedback to that exact generation. files[].call_id is normally the
same value, but diverges when a blocked image was regenerated by the
retry-on-delete policy: the file's call_id then points at the retry call
that actually produced the delivered pixels. scene_expanded_by_style
(added 2026-09-07) shows what each prompt-style group of models was
actually fed (tag-conditioned models get a tag expansion, not prose).
tags_subjects (added 2026-09-10, T-434) is the tag-converted
subjects[] fed to tags-style deployments — previously subjects[] rendered
as raw prose next to a converted-tags scene for those models, which
tag-conditioned diffusers read as noise rather than a subject block.
Natural-language deployments still get subjects[] as prose (unaffected).
composition (added 2026-09-24, T-798, imgeng) — optional string, max
600 chars, capturing how 2+ subjects relate to each other or the frame
(positional/relational only — never subject appearance or environment; that
belongs in subjects[]/scene respectively). Fixes a real bug: with no
legal home for this content, it was getting smuggled into scene as
symmetry/mirroring language ("mirror image effect", "faces filling the
frame") that measurably caused 3 different models to render a phantom extra
face (imgeng's A/B evidence, T-798). Included in the same compliance text
surface as scene/subjects — no new compliance axis. Rendered
subjects-then-composition-then-scene for natural-language/hybrid
deployments (inside the high-attention window, attached to the people it
describes); for tags-style deployments (no booru vocabulary for relational
text) it rides the same front-loaded fragment slot as style_note, right
after the subject block. null/absent is a no-op — imgeng's doctrine
already leaves it null unless there are 2+ subjects and the relationship
actually matters.
files[].title / files[].auto_tags (added 2026-09-07, T-352; every site
2026-09-08, T-362; strengthened spec 2026-09-08, T-365) — a vision call
names and tags the actual generated pixels (not the prompt that asked for
them): title is 5-9 words naming the subject plus at least two
distinctive visual specifics (setting/lighting/palette/attire/mood/medium)
— generic patterns like "Woman on Balcony" are explicitly banned, since a
stranger must be able to pick the exact image out of a grid of similar ones
by title alone. auto_tags is 6-12 lowercase tags spanning subject,
physical details, setting elements, lighting, palette, style/medium, and
mood. Sites with real compliance axes get this from the same PostGenCheck
call; sites with empty ComplianceConfig::postGen() axes (currently
saga/imagelab) get a separate lightweight naming-only vision call
instead (no risk classification to piggyback on) — every site names every
image either way. Both are null/[] only if that vision call itself
fails, or on a delete-actioned image (the file never reaches a caller).
files[].subjects (added 2026-09-09, T-426) — one entry per visible
person from the same vision call: ethnicity, hair, eyes, build,
age_range, and attire, each present only when the model actually
supplied a value (no blank/"unknown" placeholders). On pornart only,
entries also carry explicitness (one of clothed/suggestive/
topless/nude/explicit) — a discovery-critical attribute on an adult
catalog, omitted everywhere else. Capped at 6 entries. [] on no people
visible, a failed vision call, or a delete-actioned image. Schema
mirrors imgeng's VisionNamer exactly so files[].subjects feeds
imgeng's image_generations.vision_subjects column with no imgeng-side
change needed.
files[].price is the customer-facing charged amount (renamed from
files[].cost 2026-07-17 — same reason as /v1/image above: the old
field was raw provider COGS, not what should be charged to an end user).
If you're consuming this workflow to decide what to bill your own users,
make sure you're reading price, not a cached expectation of cost.
billing / images[].price (added 2026-09-25, T-823) — every model
call this workflow makes on your behalf is billed to your token, not just
the generations: pre-gen compliance, prompt expansion (one call per
prompt-style group present), the post-gen vision pass on every delivered
image, and any compliance retry. billing.usd is the total charged for the
request; billing.breakdown splits it into images (all generation
attempts, including deleted/retried ones), prompt_expansion,
compliance_pre_gen and compliance_post_gen. images[].price is the
total charged for that element's generation attempts (original call plus
any retry); files[].price is the charge of the specific call that produced
that file. A compliance_blocked 422 still carries billing — the
classifier call that blocked it was a real call. If you bill your own users
off this workflow, billing.usd is the number to pass through.
Blocked (422): compliance_blocked: true + compliance_category,
compliance_reason, strikable, chargeable, promo (reserved).
The compliance text is assembled server-side — clients cannot understate
what gets checked.
POST /v1/workflows/moderate-image (ability: image)
T-554: the same post-gen compliance classifier /v1/workflows/image runs
on every generation, exposed directly for an image Fire didn't generate
itself (e.g. a user upload) — image in, verdict out. Review-only: nothing
is written to Fire's storage. The classifier call is billed to your token
like any other call (T-823, 2026-09-25 — before that it was only
attributed in the logs, never charged) and the charge comes back as
price.usd.
Request: site: saga|imagelab|isl|pornart, image: {b64} | {url} (exactly
one of b64/url).
Response: action: pass|delete|flag, category, reason, title,
auto_tags, subjects, price: {usd} — otherwise identical in shape to each element's post_gen
object in /v1/workflows/image's response (see PostGenCheck::run()'s
docblock for the full semantics of each field, including the per-site
retry_on_delete policy note — this endpoint does not retry; a delete
verdict is just reported, the caller decides what to do with the source
image).
POST /v1/workflows/article (ability: chat)
Source item + seed (JP's angle) → drafted article in house voice, staged as a draft artifact in the ContentStore.
Request: site, source_title, source_url?, source_name?, source_text?, seed?, type: article|social, topic?, user_id?
Response: artifact_id, site, type, model, has_seed, body, meta — meta.price.usd
is the total charged price across every internal call the workflow made
(draft + image concepts + each image).
No-seed drafts are remixes: they can be drafted but must not be published.
v2 — Multi-agent flows (added 2026-07-21)
A third layer alongside L1 (raw calls) and L2 (fixed, code-defined
workflows above): a data-driven, persisted, resumable DAG of agent
calls, defined per-request or by a saved slug. Built for flows like
"send a prompt to 2 hot agents in parallel (one told to be contrarian),
feed both outputs to a single cool synthesizer" — with a real pause point
for a human to weigh in mid-flow. Every step's model call goes through the
same FirePipeline used everywhere else, so billing/logging/pricing
tiers apply automatically, per step.
Execution is always queued — POST v2/flows/run and
POST v2/flows/runs/{run}/resume return 202 immediately and a queue
worker runs the flow; there is no synchronous "wait for the result" mode.
Clients always poll GET v2/flows/runs/{run} for status, whether the run
is mid-chain, paused at a human gate, or already finished. All v2/*
routes require the flow token ability.
Flow spec shape
A spec is a list of steps, each either kind: agent_call (a model
call — agent_config is a saved slug, an inline
{species_name, system_prompt, temperature, max_tokens, options} object,
or an override-merge object — see below) or kind: human_gate
(pauses the run; context is resolved and shown to the human, who
supplies human_input on resume). depends_on is a list of other steps'
step_keys; a step only runs once every dependency is completed.
Exactly one step must be is_output: true — its result becomes the run's
final output. {{token.path}} templates in prompt.messages[].content
resolve against input.* (the caller's seed variables) and any completed
step's {{step_key.output.content}} / {{step_key.human_input.*}} — a
referenced token that isn't in context fails the run loudly rather than
silently substituting an empty string, so a template referencing an
"optional" input field requires the caller to still pass that key (empty
string/array is fine — just don't omit it).
agent_config override-merge (added 2026-08-03): {"slug": "hot", "temperature": 0.95} —
loads the named fire_agent_configs row as the base and layers any of
species_name/system_prompt/temperature/max_tokens/options found
alongside slug on top of it. Lets a saved flow definition keep its
per-role defaults in the DB while a specific run still tunes a knob
per-call, without falling back to a fully inline (DB-bypassing) object.
Worked example — 2 hot agents (one contrarian) → human review → 1 cool synthesizer:
{
"steps": [
{ "step_key": "hot_1", "kind": "agent_call", "agent_config": "hot-agreeable",
"depends_on": [], "is_output": false,
"prompt": { "messages": [{"role": "user", "content": "{{input.prompt_package}}"}] } },
{ "step_key": "hot_2", "kind": "agent_call", "agent_config": "hot-contrarian",
"depends_on": [], "is_output": false,
"prompt": { "messages": [{"role": "user", "content": "{{input.prompt_package}}"}] } },
{ "step_key": "review", "kind": "human_gate", "depends_on": ["hot_1", "hot_2"],
"prompt_for_human": "Compare the two takes before synthesis.",
"context": { "hot_1": "{{hot_1.output.content}}", "hot_2": "{{hot_2.output.content}}" } },
{ "step_key": "cool_synth", "kind": "agent_call", "agent_config": "cool-synthesizer",
"depends_on": ["hot_1", "hot_2", "review"], "is_output": true,
"prompt": { "messages": [{"role": "user", "content":
"Original: {{input.prompt_package}}\n\nAgreeable: {{hot_1.output.content}}\n\nContrarian: {{hot_2.output.content}}\n\nHuman guidance: {{review.human_input.note}}\n\nWrite the final synthesized answer."}] } }
]
}
Two independent ready steps in the same pass (like hot_1/hot_2 here)
run through one FirePipeline::runParallel() call; a single ready step
runs through FirePipeline::run().
Agent configs — /v2/agent-configs (CRUD)
A reusable, named agent persona: model + temperature + system prompt
injection — exactly what a flow step's agent_config slug resolves to.
POST {slug, label, species_name, system_prompt?, temperature?, max_tokens?, options?}.
Unscoped by user for now (single-owner deployment); DELETE soft-deactivates
rather than removing, since past runs may still reference the id.
Flow definitions — /v2/flows (CRUD, slug-keyed)
A reusable, named flow template — POST {slug, label, description?, spec, is_public?}.
Every store/update runs spec through the same DAG validator a run does
(422 with a details array on an invalid graph — unknown depends_on,
a cycle, wrong number of is_output steps, an unresolvable
agent_config). Editing a definition's spec bumps its version; a run
already in flight keeps its own snapshot from when it started, so a later
edit never mutates it.
Visibility and ownership (added 2026-08-03): a flow you create is
owned by your token's fire_account and defaults to is_public: true —
"discoverable unless marked private" (JP's design call). GET /v2/flows
lists every public flow plus any private ones your account owns; a
private flow another account owns is invisible to GET /v2/flows,
GET /v2/flows/{slug}, and un-runnable (POST /v2/flows/{slug}/run
404s — existence is never leaked). PUT/DELETE require ownership
regardless of visibility — a public flow owned by someone else is
readable and runnable but not editable (403 FORBIDDEN, not 404, since
you can already see it exists). A handful of flows predate this model
(triad and the four Content Studio v1 flows below) and have no owner at
all — they stay public and runnable by everyone as before, but are now
uneditable via the API by any token (403 for anyone, not just
non-owners) — same discipline as L2's fixed, code-defined workflows.
GET /v1/capabilities's flows.catalog lists every public flow's
slug/label/description with no auth required, for pre-token discovery.
Custom flow quota (added 2026-08-03) — external (paying/non-zero-markup)
accounts are capped at pricing_config('fire_api', 'max_custom_flows_external')
active flow definitions, default 10. POST /v2/flows past the cap
returns 422 FLOW_QUOTA_EXCEEDED; deactivating one (DELETE /v2/flows/{slug})
frees a slot. Internal (0%-markup) accounts — saga/imagelab/isl/pornart,
ad-hoc ops tokens — are exempt, same internal/external split
FireBillingService already uses for billing tiers. Change the cap
without a deploy: PricingConfig::set('fire_api', 'max_custom_flows_external', N).
POST v2/flows/{slug}/run (preferred)
(Added 2026-08-03.) A saved fire_flow_definitions.slug is the
endpoint — the same shape as L1's POST /v1/workflows/{workflow}, one
level up. No route or controller change to add a new callable flow, just
a DB insert.
curl -X POST https://fire.test1.prosaga.net/v2/flows/copy-shape/run \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"input": {"essay": "...", "style_guide": "...", "required_keywords": []}}'
Body: {"input": {...}, "conversation_id": null, "user_message": null} —
same fields as below, minus flow_slug/flow (the URL already picked
the flow). 404 FLOW_NOT_FOUND if the slug doesn't resolve to an active
definition. Response 202, same envelope as POST v2/flows/run below.
POST v2/flows/run (body-routed — flow_slug deprecated)
{ "flow_slug": "hot-cool-review", "input": {"prompt_package": "..."}, "conversation_id": null, "user_message": null }
or, ad-hoc without saving anything — not deprecated, this is the only way to run an unsaved/one-off spec:
{ "flow": { "steps": [...] }, "input": {...} }
conversation_id (optional, FK to the memory-engine's memory_conversations)
attaches the run to an ongoing Saga chat conversation — the caller's
user_message is persisted as the user's turn immediately, and only the
flow's is_output step's result is mirrored back as the assistant's turn
on completion (intermediate hot-agent branches never enter the visible
chat log; the full trace stays inspectable via GET .../runs/{run}).
Response 202: {"run_id", "status": "pending", ...}.
flow_slug in the body is deprecated in favor of
POST v2/flows/{slug}/run above — a response triggered by flow_slug
carries a Deprecation: true header and a
Link: </v2/flows/{slug}/run>; rel="successor-version" header pointing
at the replacement call. The flow (inline spec) body param is unaffected
and has no deprecation path, since an unsaved spec has no slug to move to.
GET v2/flows/runs/{run} / GET v2/flows/runs
{
"run_id": 123, "status": "awaiting_human", "input": {...}, "output": null, "error": null,
"total_cost_usd": 0.0041,
"steps": [
{"step_key": "hot_1", "kind": "agent_call", "status": "completed",
"output": {"content": "...", "model": "...", "provider": "...", "usage": {"input": 120, "output": 340}, "meta": {"price": {"usd": 0.0012}}}},
{"step_key": "review", "kind": "human_gate", "status": "awaiting_human",
"context": {"hot_1": "...", "hot_2": "..."}, "prompt_for_human": "...", "human_input": null}
]
}
Each agent_call step's output uses the same shape as a
/v1/chat/parallel result item. GET v2/flows/runs lists a token's own
runs, filterable by conversation_id/status — it never includes
per-step detail regardless of verbosity (use GET .../runs/{run} for
that).
?verbosity=compact (default full) — added 2026-07-21 for UIs that
only render the final answer and don't want to pay bandwidth for every
hot-agent branch on each poll. Drops each step's output/context/
prompt_for_human/human_input, keeping only step_key, kind,
status, depends_on, error — enough to render a progress indicator
without the content. The run-level output/error/total_cost_usd are
never trimmed. Also accepted on POST v2/flows/run and
POST .../resume, which return the same envelope. This only affects
the HTTP response — every step's full output is always persisted
(fire_flow_run_steps.output) and every underlying model call is always
logged (prompt_logs) regardless of this flag; nothing is lost, only the
response payload shrinks.
POST v2/flows/runs/{run}/resume
{ "step_key": "review", "human_input": {"note": "go with the contrarian take"} }
409 FLOW_RESUME_CONFLICT if the run isn't awaiting_human or step_key
doesn't match the step currently gating it. On success the run resumes
executing (queued) from that point.
Not built yet (reserved)
kind: tool_call is a recognized step kind so a future spec can declare
one without a migration, but the engine currently rejects it if a run
ever reaches it. No automatic memory-context injection into flow steps,
no step-level retry, no SSE (polling only). A flow currently supports one
human gate blocking progress at a time.
Content Studio v1 — 4 flows (added 2026-08-03)
The multi-agent content-shaping pipeline spec'd by modpx (orchestration
messages 750/767) for essays/articles, built as four independent flow
definitions rather than a new composition layer above flows — each is
already independently callable via POST v2/flows/{slug}/run, and a
caller enters/exits the pipeline by choosing which slug(s) to call and
threading one flow's output into the next call's input, not through
any special "pipeline" runtime. This is deliberate: POST v2/flows/run
already accepts a fully inline spec and {slug}/run already lets any
saved flow act as its own endpoint, so "enter/exit anywhere" needed zero
new engine code — see the override-merge note above for the one actual
engine change this pipeline required.
| Slug | Steps | Input | Output |
|---|---|---|---|
thesis-check |
1 (thesis_check) |
{thesis_or_concept, essay, sources: [{title,url,excerpt}]} |
{verdict, confidence, synopsis, per_source: [{source, stance, note}]} (as JSON text in output.content) |
copy-shape |
5 (hot+cold parallel → assimilate → editor → final) |
{essay, style_guide, required_keywords: []} |
Final rewritten article body |
image-list |
2 (draft_list → expand) |
{essay_or_copy, style_cues, style_guide} |
List of image-generation-ready prompts, one per image, with placement |
format-output |
1 (format_output) |
{shaped_copy, image_list, sources: [{source,stance,note}], output_format_spec} |
Fully formatted piece, sources section reproduced verbatim from input.sources |
Composition is the caller's job, by design: thesis-check has no
web-search/retrieval (matches the tool_call-not-implemented limit
above) — the caller (currently content) gathers real sources and
passes them in. format-output's sources input is meant to be
thesis-check's own per_source array passed through unmodified — the
content-format-output agent config is explicitly instructed to
reproduce it verbatim, never re-derive citations from its own memory.
copy-shape refines an existing draft, it does not synthesize one from
a raw source stub — callers own initial synthesis before calling it.
All 9 backing fire_agent_configs rows are prefixed content-*
(content-thesis-check, content-copyshape-{hot,cold,assimilate,editor,final},
content-imagelist-{draft,expand}, content-format-output). Every
temperature/max_tokens/system_prompt is DB-configurable per the
override-merge mechanism above — no code change needed to retune a role.
Issuing tokens
# CLI wrapper (splits comma scopes):
/var/www/saga/scripts/fire-token "name" image,read --json
# Artisan (comma or repeated flags both work):
php artisan fire:token name --abilities=image,read
Purpose
Fire is a model-agnostic AI inference pipeline. It abstracts providers (EdenAI, Anthropic, OpenAI, Venice, etc.) behind a unified API keyed on a biological taxonomy. Every inference call is logged to prompt_logs and metered to fire_usage — these operations occur in a finally block and cannot be bypassed.
This document is the authoritative bootstrap reference. The live service exposes the same information via GET /v1/capabilities (no auth required), which a client should query first. This file exists as a fallback and for first-contact bootstrapping before a token is issued.
Architecture — Three Planes
┌─────────────────────────────────────────────────────┐
│ Governance Control Plane (GCP) │
│ Authority, compliance, emergency stop │
│ STATUS: hooks defined, not yet deployed │
│ POST /v1/admin/freeze (→ 501) │
│ POST /v1/admin/stop (→ 501) │
├─────────────────────────────────────────────────────┤
│ Service Control Plane (SCP) │
│ Telemetry, diagnostics, usage, discovery │
│ GET /v1/capabilities (no auth) │
│ GET /v1/status (Bearer: read) │
│ GET /v1/models (Bearer: read) │
│ GET /v1/usage (Bearer: read) │
├─────────────────────────────────────────────────────┤
│ Data Plane (DP) │
│ Inference, payload exchange │
│ POST /v1/chat (Bearer: chat) │
│ POST /v1/image (Bearer: image) │
└─────────────────────────────────────────────────────┘
Cross-plane messaging is restricted. The GCP is not visible to AI instances or their tokens — it is reserved for governance authority credentials (not yet implemented).
Authentication
All Data Plane and SCP endpoints (except /capabilities) require a Bearer token.
Token format
fire_sk_<40 random alphanumeric characters>
Tokens are stored as SHA-256 hashes. The raw token is shown once at issuance and never retrievable again. Treat it like a private key.
Token abilities
| Ability | Grants access to |
|---|---|
* |
All endpoints |
chat |
POST /v1/chat (text inference) |
image |
POST /v1/image (image generation) |
speech |
POST /v1/speech (text-to-speech) |
transcribe |
POST /v1/transcribe (speech-to-text) |
decide |
POST /v1/decide (typed decisions — yes/no, choice, score with probabilities) |
read |
GET /v1/status, /models, /usage |
A token with abilities: ["chat", "image", "read"] can use all non-governance endpoints.
Authorization header
Authorization: Bearer fire_sk_<token>
Issuing Tokens
Tokens are issued on the server running Fire. Two methods:
Method 1 — script (recommended)
# Human-readable output
/var/www/saga/scripts/fire-token "My Agent"
# Scoped abilities
/var/www/saga/scripts/fire-token "Read Monitor" read
/var/www/saga/scripts/fire-token "Chat App" chat,read
# Machine-readable JSON (pipe-friendly)
/var/www/saga/scripts/fire-token "My Agent" --json
# Capture token into a variable
TOKEN=$(/var/www/saga/scripts/fire-token "My Agent" --json | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
Method 2 — artisan directly
cd /var/www/saga
# Human output
php artisan fire:token "My Agent"
# Scoped abilities (one --abilities flag per ability)
php artisan fire:token "Chat App" --abilities=chat --abilities=read
# JSON output
php artisan fire:token "My Agent" --json
# Capture with Python
TOKEN=$(php artisan fire:token "Agent" --json | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
# Capture with jq
TOKEN=$(php artisan fire:token "Agent" --json | jq -r .token)
JSON output schema
{
"token": "fire_sk_...",
"token_id": 1,
"name": "My Agent",
"abilities": ["*"],
"issued_at": "2026-05-31T23:00:00.000000Z"
}
Endpoints
GET /v1/capabilities
Service discovery document. No authentication required. A client that knows nothing about this service except the base URL should call this first.
Response: Full three-plane description, all active models, request/response schemas, cost model, and guarantees.
curl https://fire.test1.prosaga.net/v1/capabilities
Key fields in response:
{
"service_id": "/v1",
"version": "1.0.0",
"planes": { "data": {...}, "service_control": {...}, "governance": {...} },
"models": [ { "species_name": "claude-sonnet-4-5", "is_default": true, ... } ],
"account_setup": { "url": "https://test1.prosaga.net/portal", "auth": "Google OAuth", "note": "..." },
"guarantees": { "logging": "mandatory", "token_accounting": "mandatory", "bypass_possible": false },
"covenant_level": 0
}
account_setup (added 2026-08-03) — a caller with no token yet (human
or an agent acting for one) has exactly one on-ramp: /portal
(Google OAuth → self-provisioned $0 fire_accounts row → mint a token →
top up via Stripe Checkout). This is deliberately the only path with no
programmatic equivalent — Stripe does not support headless/API-only card
collection, so real billing always requires a browser at least once. The
one fully-API alternative, POST /v1/billing/tier/redeem, still requires
JP to have minted a code first; it's not open self-service. Also present
on GET /v1/billing/packs (also no-auth) for the same reason. planes.flows.catalog
(also new) lists every public flow's slug/label/description with no auth
required — see "Flow definitions" above.
GET /v1/status
Service health. Requires read ability.
curl -H "Authorization: Bearer $TOKEN" \
https://fire.test1.prosaga.net/v1/status
Response:
{
"status": "operational",
"timestamp": "2026-05-31T23:28:23.973475Z",
"planes": {
"data": "operational",
"service_control": "operational",
"governance": "not_deployed"
},
"providers": [
{ "name": "edenai", "status": "active" }
],
"last_15min": {
"requests": 42,
"errors": 0,
"error_rate_pct": 0.0
}
}
GET /v1/models
All onboarded model deployments. Requires read ability.
(Changed 2026-07-18.) Only onboarded models are listed — models whose
cost data, liveness, and published purpose have passed the onboarding
checklist (fire:onboard; re-verified weekly by fire:audit-models).
Models that exist in the taxonomy but haven't been onboarded are hidden
entirely and refused if requested by name.
curl -H "Authorization: Bearer $TOKEN" \
https://fire.test1.prosaga.net/v1/models
Response:
{
"count": 40,
"models": [
{
"species_name": "claude-sonnet-4-5",
"display_name": "Claude Sonnet 4.5",
"family": "anthropic",
"genus": "claude",
"provider": "edenai",
"remote_slug": "anthropic/claude-sonnet-4-5",
"is_default": true,
"capabilities": { "context_window": 200000, "supports_streaming": false },
"purpose": "Balanced flagship for reasoning, writing, and coding; the default general-purpose choice.",
"strengths": ["reasoning", "coding", "long-context", "instruction-following"],
"price": { "input_per_1k": 0.0, "output_per_1k": 0.0, "markup_pct": 25, "synced_at": "..." }
},
...
]
}
The species_name value is what you pass as species_name in a chat request to select a specific model.
Requesting a species that exists but is not onboarded returns
404 PROVIDER_UNAVAILABLE with a description saying the model is not
currently offered; an unknown species says so explicitly.
GET /v1/usage
Token usage for the authenticated token. Requires read ability.
curl -H "Authorization: Bearer $TOKEN" \
https://fire.test1.prosaga.net/v1/usage
Response:
{
"token_name": "My Agent",
"abilities": ["chat", "read"],
"last_used": "2026-05-31T23:10:00.000000Z",
"totals": {
"input_tokens": 1234,
"output_tokens": 567,
"total_tokens": 1801,
"requests": 42,
"errors": 0,
"spend_usd": 0.000351
},
"daily": [
{
"date": "2026-05-31T00:00:00.000000Z",
"input_tokens": 1234,
"output_tokens": 567,
"total_tokens": 1801,
"request_count": 42,
"error_count": 0,
"cost_usd": "0.000351"
}
]
}
spend_usd / cost_usd accumulate the charged price (the same figure
returned as meta.price.usd on each call) — never raw provider COGS.
POST /v1/chat
Submit a chat completion. Requires chat ability. All calls are logged and metered — this cannot be disabled.
curl -X POST https://fire.test1.prosaga.net/v1/chat \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"messages": [
{ "role": "user", "content": "Hello" }
]
}'
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
messages |
array | yes | Conversation history. Each item: { "role": "user"|"assistant", "content": "..." } |
system_prompt |
string | no | Instruction prepended before the conversation |
species_name |
string | no | Which model to use. Omit for default (currently claude-sonnet-4-5). See /models for valid values. |
temperature |
float | no | 0.0–1.0. Default: 0.7 |
max_tokens |
integer | no | 64–8192. Default: 1024 |
Reasoning-family models may raise this. Models tagged
reasoning/deep-reasoning(seestrengthsonGET /v1/models) spend part ofmax_tokenson hidden reasoning before any visible output — Fire raises a too-lowmax_tokensto a per-species floor (currently 1024) before calling the provider, rather than silently billing you for an empty response (found 2026-07-21 —gpt-5-mini/o3-miniactually failed onboarding on this exact symptom). The floor never lowers your request, only raises it;prompt_logs.max_tokensrecords what was actually sent.
Multi-turn conversation:
{
"messages": [
{ "role": "user", "content": "What is 2+2?" },
{ "role": "assistant", "content": "4" },
{ "role": "user", "content": "Multiply that by 3." }
],
"temperature": 0.3
}
Selecting a specific model:
{
"messages": [{ "role": "user", "content": "Explain quantum entanglement." }],
"species_name": "gpt-4o",
"max_tokens": 2048
}
Response:
{
"content": "Hi! How can I help you today?",
"tool_calls": null,
"model": "Claude Sonnet 4.5",
"provider": "edenai",
"call_id": "9f2c1a7e...64-hex-chars...b3",
"usage": {
"input": 13,
"output": 9
},
"meta": {
"price": { "usd": 0.00033375 },
"call_id": "9f2c1a7e...64-hex-chars...b3"
}
}
meta.price.usd is the only cost figure this API ever returns — the actual
amount charged against your account, per your token's markup tier (see
Billing & Pricing Tiers, below). It is present on every response from
every Data Plane and Workflow endpoint, always, even when zero. Fire's
internal provider cost (COGS) is never included in any response body —
only prompt_logs.provider_cost_usd, an internal-only column.
call_id (added 2026-09-02, replaces the raw log_id) is an opaque
64-hex-char handle identifying this specific call. Every endpoint that
performs an inference returns one (chat, stream, parallel, image, upscale,
speech — and each flow step's output.meta.call_id). Keep it if you may
want to report a problem with or rate the call later — the upcoming
POST /v1/calls/{call_id}/report and /rate endpoints key on it.
log_id is no longer returned.
/v1/chat/stream (SSE) carries the same shape in its final event:
data: {"type":"message_done","model":"...","provider":"...","call_id":"...","usage":{...},"meta":{"price":{"usd":0.0},"call_id":"..."}}
/v1/chat/parallel carries it per-item inside results[].
Streaming degradation (2026-07-17): most EdenAI-proxied models do not
support true token streaming. When the upstream returns a non-SSE JSON
body instead of a stream, Fire emits the full completion as a single
token event followed by a normal message_done (with real usage and
price) — the SSE contract is preserved, delivery is just not incremental.
Usage, logging, and billing are identical either way.
POST /v1/image
Generate images from a text prompt. Requires image ability. Routes through EdenAI's /v2/image/generation endpoint to the configured image model — or, when images is present (see below), EdenAI's /v3/images/edits endpoint instead.
curl -X POST https://fire.test1.prosaga.net/v1/image \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"prompt": "A photorealistic logo for a fintech startup, dark background, gold accents"
}'
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
prompt |
string | yes | Text description of the image. Max 2000 characters. |
species_name |
string | no | Image model to use (e.g. imagen-4, gpt-image-1, sdxl). Omit for default. |
n |
integer | no | Number of images to generate. 1–4. Default: 1. |
size |
string | no | Resolution. One of 256x256, 512x512, 1024x1024, 1024x1536, 1536x1024. Default: 1024x1024. |
images |
array | no | (added 2026-08-13) Reference/input images — edit or combine existing photos instead of generating from scratch (e.g. "make a bio image from these 2 photos"). 1–16 items; each model's real ceiling is capabilities.max_reference_images on /v1/models (16 for gpt-image-1/1.5, 6 for the Venice multi-image edit models, 1 for qwen-edit-uncensored) — sending more returns 502 PROVIDER_UNAVAILABLE with the cap in the message. Each item is an https:// URL string, a data:image/...;base64,... data URL, raw base64 (assumed PNG), or {"url": "..."}. Only models with capabilities.supports_reference_images can be resolved when this is present — see below. |
Available image models (query /v1/models — filter by functions containing image_generation / image_edit; the list below is illustrative, not exhaustive):
species_name |
Display name | Provider slug | Reference images? |
|---|---|---|---|
imagen-4 |
Imagen 4 | google/imagen-4.0-generate-001 |
no |
gpt-image-1 |
GPT Image 1 | openai/gpt-image-1 |
yes (up to 16, content-moderated) |
gpt-image-1-5 |
GPT Image 1.5 | openai/gpt-image-1.5 |
yes (up to 16, content-moderated) |
sdxl |
Stable Diffusion XL | stabilityai/stable-diffusion-xl-1024-v1-0 |
no |
qwen-image-3-edit |
Qwen Image 3 Edit | venice/qwen-image-3-edit |
edit-only, up to 6, uncensored |
qwen-image-3-pro-edit |
Qwen Image 3 Pro Edit | venice/qwen-image-3-pro-edit |
edit-only, up to 6, uncensored |
seedream-v4-edit |
Seedream V4 Edit | venice/seedream-v4-edit |
edit-only, up to 6, uncensored |
seedream-v5-lite-edit |
Seedream V5 Lite Edit | venice/seedream-v5-lite-edit |
edit-only, up to 6, uncensored |
seedream-v5-pro-edit |
Seedream V5 Pro Edit | venice/seedream-v5-pro-edit |
edit-only, up to 6, uncensored, premium tier |
qwen-edit-uncensored |
Qwen Edit Uncensored | venice/qwen-edit-uncensored |
edit-only, 1 image, uncensored |
Edit-only species (capabilities.reference_images_required) have no text-to-image path at all — calling one without images returns 502 PROVIDER_UNAVAILABLE. The first image is the base; further images are layers/references the prompt can refer to.
Reference-image example — combine two existing photos into one:
curl -X POST https://fire.test1.prosaga.net/v1/image \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"prompt": "Combine these two photos into one professional headshot-style bio image.",
"species_name": "gpt-image-1",
"images": [
"data:image/jpeg;base64,'"$(base64 -w0 photo1.jpg)"'",
"data:image/jpeg;base64,'"$(base64 -w0 photo2.jpg)"'"
]
}'
If species_name is omitted while images is present, Fire picks the
default reference-image-capable model rather than the overall default —
don't assume it's the same model a plain text-to-image call would use.
Requesting a species_name that doesn't support reference images (e.g.
sdxl) with images present returns 404 PROVIDER_UNAVAILABLE rather
than silently ignoring the images.
Response:
{
"images": [
{
"b64": "iVBORw0KGgo...",
"url": "https://cdn.edenai.co/..."
}
],
"model": "Imagen 4",
"provider": "edenai",
"call_id": "9f2c1a7e...64-hex-chars...b3",
"meta": {
"price": { "usd": 0.04 },
"call_id": "9f2c1a7e...64-hex-chars...b3"
}
}
Each image object has b64 (base64-encoded PNG) and/or url (hosted URL) — which fields are populated depends on the provider. EdenAI typically returns both.
There is no top-level cost field (removed 2026-07-17 — it was the
adapter's raw provider COGS, not the marked-up price; see meta.price.usd
above and Billing & Pricing Tiers, below).
Error responses:
404 PROVIDER_UNAVAILABLE— no active image deployment found for the requestedspecies_name(also returned whenimagesis present but the requested/default model doesn't support reference images)422 INVALID_REFERENCE_IMAGE— animagesentry isn't a valid URL,data:URL, or base64 string502 PROVIDER_UNAVAILABLE— upstream provider rejected the request (e.g. content policy, invalid prompt)500 PRICING_TIER_UNAVAILABLE— your token can't resolve a billing tier (see below); the provider is never called402 INSUFFICIENT_BALANCE— your paying account (markup_pct > 0) has no remaining prepaid balance; checked before the provider is called (applies to every billable endpoint, not just this one)
POST /v1/transcribe
Speech-to-text transcription of a single audio clip. Requires transcribe
ability. Routes through EdenAI's /v3/audio/transcriptions endpoint (added
T-770/T-771, 2026-09-22 — Fire's /v1/speech is text-to-speech only; this
was the first audio-in-text-out path Fire ever had).
Long-form audio (e.g. a 90-minute lecture) must be chunked by the caller
into clips under the underlying provider's file-size limit (~25MB) — Fire does no chunking/stitching of its own, matching how
/v1/chat doesn't manage context windows for the caller either.
curl -X POST https://fire.test1.prosaga.net/v1/transcribe \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"audio": "data:audio/mpeg;base64,'"$(base64 -w0 clip.mp3)"'"
}'
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
audio |
string | yes | Source audio: an https:// URL, a data:audio/...;base64,... data URL, raw base64 (mime is sniffed from the bytes), or {"url": "..."}. |
species_name |
string | no | Transcription model to use. Omit for default (whisper-large-v3-turbo). |
language |
string | no | ISO-639-1 language hint (e.g. en, fr). Omit to let the provider auto-detect. |
Available transcription models (query /v1/models — filter by
capabilities.modality == "transcription"):
species_name |
Display name | Provider slug | Rate |
|---|---|---|---|
whisper-large-v3-turbo |
Whisper Large v3 Turbo (Groq) | groq/whisper-large-v3-turbo |
$0.00001111/sec ($0.00067/min) — default; sub-second on short clips |
whisper-1 was retired 2026-10-07 (EdenAI removed it; T-1104) — naming it
now returns 404.
Response:
{
"text": "This is the transcribed text.",
"language": null,
"duration": null,
"model": "Whisper Large v3 Turbo (Groq)",
"provider": "edenai",
"call_id": "9f2c1a7e...64-hex-chars...b3",
"meta": {
"price": { "usd": 0.0001 },
"call_id": "9f2c1a7e...64-hex-chars...b3"
}
}
language and duration are passed through when the provider reports
them; the Groq default does not, so expect null.
duration is the audio's length in seconds as reported by the provider,
not the wall-clock time the call took.
Error responses:
404 PROVIDER_UNAVAILABLE— no active transcription deployment found for the requestedspecies_name422 INVALID_AUDIO—audioisn't a valid URL,data:URL, or base64 string502 PROVIDER_UNAVAILABLE— upstream provider rejected the request500 PRICING_TIER_UNAVAILABLE/402 INSUFFICIENT_BALANCE— same billing gates as every other endpoint
POST /v1/decide
Typed decisions, not prose. Ask one or more named questions about a piece of
state (a string, or a JSON object/array of text) and get back, per question,
a value and the probability behind it. Requires decide ability. Added
T-1164 (2026-10-09); default model is Jev (TypeSafe), served through
EdenAI's decision endpoint.
Use it where you would otherwise coax a chat model into "answer only yes or
no" or "reply with one of these labels": routing, moderation, classification,
scoring, validation of another model's output. One call answers every
question in the request in one pass, in well under a second, for roughly
$0.00002 of provider cost. Text/JSON input only — no images or audio. Context
is ~64k tokens per request (32k for state plus the longest question).
curl -X POST https://fire.test1.prosaga.net/v1/decide \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"state": "My invoice shows $49 but I only bought the $19 plan. Fix it or I cancel.",
"questions": {
"intent": {"type": "choice", "instructions": "What is the customer asking for?",
"criteria": {"billing_dispute": "Disputes a charge or amount",
"cancellation": "Wants to cancel",
"question": "General question"}},
"is_angry": {"type": "noul", "instructions": "Is the customer frustrated or threatening to leave?"},
"urgency": {"type": "score", "instructions": "How urgent is this?",
"criteria": ["not urgent", "somewhat urgent", "urgent", "critical"]}
}
}'
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
state |
string | object | array | yes | The thing being judged. A string, or a JSON object/array of text values. |
questions |
object | yes | question_name → question. 1–32 per call. Names: 1–64 chars of letters, digits, _ . -. |
species_name |
string | no | Decision model to use. Omit for default (jev). |
Question shapes (questions.<name>):
type |
instructions |
criteria |
Answer |
|---|---|---|---|
noul |
optional string — the yes/no question | optional | {"type":"noul","noul":0.98} — probability of "yes" |
choice |
optional string | required object {label: description}, 1–255 labels |
{"type":"choice","choice":"billing_dispute","confidence":1.0,"probabilities":{"billing_dispute":1.0,"cancellation":0.0,"question":0.0}} |
score |
optional string | required ordered array of 2–10 level descriptions, index 0 = lowest | {"type":"score","score":2.14,"confidence":0.8,"legend":["not urgent",…],"probabilities":[0.0,0.03,0.8,0.17]} — score is the probability-weighted position on the rubric; legend/probabilities are arrays indexed by level |
Fire validates every question before the provider is called, so a malformed question costs nothing. Unknown keys inside a question are dropped, not forwarded.
Available decision models (query /v1/models?function=decision):
species_name |
Display name | Provider slug | Rate |
|---|---|---|---|
jev |
Jev (TypeSafe) | typesafe/jev-latest |
$0.042 per million input tokens; output free — default |
Response:
{
"answers": {
"intent": {"type": "choice", "choice": "billing_dispute", "confidence": 1.0,
"probabilities": {"billing_dispute": 1.0, "cancellation": 0.0, "question": 0.0}},
"is_angry": {"type": "noul", "noul": 0.98},
"urgency": {"type": "score", "score": 2.14, "confidence": 0.8,
"legend": ["not urgent", "somewhat urgent", "urgent", "critical"],
"probabilities": [0.0, 0.03, 0.8, 0.17]}
},
"usage": {"input_tokens": 427, "output_tokens": 78},
"model": "Jev (TypeSafe)",
"provider": "edenai",
"call_id": "9f2c1a7e...64-hex-chars...b3",
"meta": {
"price": { "usd": 0.000018 },
"call_id": "9f2c1a7e...64-hex-chars...b3"
}
}
Billing is cost-plus on the provider's inline cost (input tokens only), same
rule as speech/transcribe/upscale. Logged as call_type: decision with the
full request and the answers JSON, so POST /v1/calls/{call_id}/report
accepts issue_type ∈ wrong_answer | miscalibrated | ignored_criteria | missing_answer | other.
Error responses:
404 PROVIDER_UNAVAILABLE— no onboarded decision deployment for the requestedspecies_name(a chat model's name here is a 404, not a fallback)422 INVALID_STATE—stateempty or not a string/object/array422 INVALID_QUESTIONS— a question failed validation (the description names it), or the provider rejected the shape502 PROVIDER_UNAVAILABLE— upstream provider error500 PRICING_TIER_UNAVAILABLE/402 INSUFFICIENT_BALANCE— same billing gates as every other endpoint
POST /v1/admin/freeze (stub)
RFC 9697 §6.6 governance hook. Freeze a token or user account.
Status: GCP not deployed. Returns 501 with the hook schema.
Requires a token with the govern ability (added 2026-07-18 — the admin
surface is authenticated even while stubbed).
{
"error_code": "GCP_NOT_DEPLOYED",
"description": "Governance Control Plane hook 'freeze' is defined but not yet deployed.",
"hook": "freeze",
"rfc_ref": "RFC 9697 §6.6",
"schema": {
"subject_type": "token|user",
"subject_id": "string",
"reason": "string",
"authority": "string — issuer identity"
}
}
POST /v1/admin/stop (stub)
RFC 9697 §6.6 governance hook. Emergency stop.
Status: GCP not deployed. Returns 501 with the hook schema.
{
"error_code": "GCP_NOT_DEPLOYED",
"schema": {
"level": "0 (suspend) | 1 (terminate) | 2 (halt scope)",
"scope": "string — governed container path",
"reason": "string",
"issued_by": "string — authority identity",
"expires": "ISO8601 timestamp | null"
}
}
Error Codes
All error responses use this envelope:
{
"error_code": "AUTH_FAILURE",
"description": "Human-readable explanation."
}
| Code | HTTP | Meaning |
|---|---|---|
AUTH_FAILURE |
401 | Missing, malformed, or insufficiently-scoped token |
REVOKED_CREDENTIAL |
401 | Token is revoked or expired |
PROVIDER_UNAVAILABLE |
404/502 | Model not found or upstream provider error |
PRICING_TIER_UNAVAILABLE |
500 | Token can't resolve a billing markup tier — checked before any provider call, so no spend occurs (see Billing & Pricing Tiers) |
CALL_NOT_FOUND |
404 | /v1/calls/{call_id}/... — no call with that call_id belongs to your account (unknown and foreign call_ids are deliberately indistinguishable) |
UNKNOWN_DIMENSION |
422 | /v1/calls/{call_id}/rate — unrecognized rating dimension slug (pass it in tags[] to propose it instead) |
INSUFFICIENT_BALANCE |
402 | Paying account (nonzero markup) has no remaining prepaid balance — checked before any provider call, so no spend occurs. Internal 0%-markup accounts are exempt. Top up via /v1/billing/checkout. |
WORKFLOW_FAILED |
502 | An L2 workflow failed for a reason other than billing/compliance |
GCP_NOT_DEPLOYED |
501 | Governance hook called before GCP is live |
POLICY_BLOCK |
(future) | GCP rule blocked the request |
LIMIT_EXCEEDED |
(future) | Rate or token budget exhausted |
Validation errors (missing required fields, type mismatches) return HTTP 422 with Laravel's standard validation JSON.
POST /v1/calls/{call_id}/report · POST /v1/calls/{call_id}/rate
(added 2026-09-02) Per-call feedback, keyed on the call_id every
inference response returns. Any valid token works; the call must belong to
your own account (any token on the account may report/rate calls made by
any of its tokens) — unknown and foreign call_ids both return
404 CALL_NOT_FOUND, indistinguishably. Both endpoints return
202 {"status": "received", "call_id": "..."}.
Report a problem — every report is recorded as model evidence and triaged (a real ticket lands in front of a human/agent; no dead-letter box):
curl -X POST https://fire.test1.prosaga.net/v1/calls/$CALL_ID/report \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"description": "Cited a paper that does not exist.", "issue_type": "hallucination"}'
Base fields for every call type: description (required, ≤4000 chars),
category (optional freeform, ≤100), reporter_type (optional,
customer|agent, default customer — added 2026-09-12 so an
autonomous agent relaying dissatisfaction a user voiced but wouldn't have
filed themselves, e.g. imgeng's Distiller, is tagged distinctly in
model_feedback (source: agent_reported vs customer_reported) and on
the filed ticket (reporter_type: agent). Never set this for a report a
human actually submitted themselves — it must stay a faithful record of a
user's own words, not the agent's opinion). issue_type (required) branches
on the call's type (what the call actually was, not the model):
| call_type | issue_type values |
extra optional fields |
|---|---|---|
chat |
hallucination refusal wrong_format off_topic slow other |
expected, excerpt (≤2000 each) |
image_generation |
wrong_subject artifact nsfw_false_positive nsfw_false_negative aspect_ratio other |
region (≤100) |
image_edit |
same as image_generation + reference_ignored |
region |
upscale |
artifact wrong_dimensions quality other |
— |
speech |
mispronunciation wrong_voice garbled other |
timestamp_ms |
Rate a call — 1–5, optionally on a named dimension (default overall):
curl -X POST https://fire.test1.prosaga.net/v1/calls/$CALL_ID/rate \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"score": 4.5, "dimension": "prompt_adherence", "tags": ["oil emulations"], "comment": "great texture"}'
Seeded dimensions: overall, prompt_adherence, speed, value
(universal); image_quality, edit_fidelity, voice_quality
(per-modality). An unrecognized dimension slug is a
422 UNKNOWN_DIMENSION; unknown tags[] entries (≤10, ≤50 chars each) are
accepted and propose new dimensions for curation — use tags to suggest
vocabulary, the dimension field to rate on established vocabulary.
Ratings feed per-model aggregates and the upcoming rankings endpoints
(sample counts always ride along with scores).
Rate limits: 30 reports/min, 60 ratings/min.
GET /v1/models/rankings
(added 2026-09-02) Model rankings by rating dimension — derived hourly
from customer rating events, aggregated per species. Requires read
ability. sample_count always accompanies every score: a 4.8-from-3 is
visibly not a 4.4-from-900.
Query params (all optional): dimension (slug, default overall),
modality (text|image|audio — matches what the model produces),
function (chat|image_generation|image_edit|upscale|speech),
tier (nano|fast|standard|frontier), min_samples (int, default
1), sort (score|value). "Best in class" = dimension + facet filters.
Each ranking row: rank, species_name, display_name, provider,
tier, modality, functions, score (1–5 mean), sample_count,
method, price ({usd, basis} — a single published scalar:
per_1k_tokens_combined for text, per_image flat tier for image;
null where no honest flat price exists), and value (score ÷ price,
null rather than guessed when price is null). Only currently-sellable
(onboarded, active) models with at least one rating appear.
GET /v1/models/rankings/dimensions lists the active rating vocabulary.
GET /v1/models also now returns per-model modality (in/out sets),
functions, and a sparse ratings summary ({dimension_slug: {score, sample_count}}, null until rated), and accepts ?modality= /
?function= filters alongside the existing ?tier=.
Billing & Pricing Tiers
(Added 2026-07-17.) Every fire_api_tokens row belongs to a
fire_accounts row, and every account carries an explicit markup_pct.
There is no global default markup fallback for a request that's actually
billed against a token — if a token has no fire_account_id, or its
account has no markup_pct, the request fails closed with
PRICING_TIER_UNAVAILABLE before the provider is ever called, so a
misconfigured token can never cause spend with no way to bill it.
- Internal use (Fire's own apps — saga, imagelab, isl, pornart, and
ad-hoc ops/test tokens minted via
fire:tokenwithout--account) is billed at a 0% markup — customer-facing price equals raw provider cost. This is the default for any new token unless--account=<id>is given explicitly. - The retail/published tier — accounts whose
markup_pctmatches the global rate inpricing_config(fire_api.markup_pct, currently 25%) are billed off a published, frozen price list instead of live cost-plus math: text uses the per-model rate inmodel_deployments.api_config.published_price(refreshed daily byfire:sync-price-list); images use a flat per-image rate ($0.06 standard / $0.22 premium — seeFireBillingService::calculateImageCost()) multiplied by images actually returned (n=4→ four billable images; a call that produced no images charges nothing). This exists so consumer-facing customers see one stable, simple number rather than price moving between calls or 13 different per-model rates. One addition on top of the flat rate (T-823, 2026-09-25): reference-image edits on models that charge per input image (the Venice qwen-image-3 / seedream-v5-pro edit models, $0.00345 per image beyond the included count) bill that overage at cost + markup — at 50% that's ~$0.0052 per extra reference.capabilities.max_reference_imageson/v1/modelstells you the ceiling; the per-callmeta.price.usdtells you what it cost. - Every model call is billed (T-823). L2 workflows make several calls
per request on your behalf — compliance classification, prompt
expansion, post-gen vision naming/tagging, compliance retries — and each
one is charged to your token at your tier, exactly like the generation.
/v1/workflows/imagereports the total asbilling.usdwith a breakdown;/v1/workflows/moderate-imagereportsprice.usd. Flow runs already billed every step and tool call individually. - Any other nonzero tier is true cost-plus-markup — for both text
and images:
provider_cost × (1 + markup_pct/100), computed fresh every call, ignoring the published price list. This is the tier for accounts that want transparent pass-through pricing instead of the retail rate — see Infra tier & tier codes below for the first one. - Balance enforcement (added 2026-07-17): paying accounts are prepaid.
A nonzero-markup account with no remaining
balance_usdgets402 INSUFFICIENT_BALANCEbefore any provider call. One in-flight call may take a balance slightly negative (deduction happens post-call); the next call is then refused. - Issue a token against a specific account:
php artisan fire:token "name" --account=<fire_accounts.id>. Omit--accountfor the 0%-markup internal tier.
To create a new paying account, insert a fire_accounts row with an
explicit markup_pct — there is no default; a null value on that column
is exactly what triggers PRICING_TIER_UNAVAILABLE for any token attached
to it.
Infra tier & tier codes (added 2026-07-20)
JP's call: infra clients (sibling MP projects, etc.) are billed real
provider cost + a flat 20% markup — fire_accounts.markup_pct = 20,
which the cost-plus rule above applies automatically since 20 ≠ the 25%
global/retail rate. No published-price-list involvement; text and images
both compute fresh off real cost every call.
Distribution is self-service via a redeemable code, mirroring PornArt's existing promo-code pattern:
# JP mints a code (unlimited uses unless --uses=N is given)
php artisan fire:tier-code 20 --label=infra --note="for FireScout"
# Client redeems it — no auth required, they have no token yet
curl -X POST https://fire.test1.prosaga.net/v1/billing/tier/redeem \
-H "Content-Type: application/json" \
-d '{"code":"HX5DGKDW","name":"FireScout","email":"firescout@modpx.dev"}'
# → {"token": "fire_sk_...", "account_id": 6, "markup_pct": 20, "tier": "infra"}
Redemption finds-or-creates a fire_accounts row by email and mints a
token on it. An existing account's markup_pct is never changed by a
code redemption — if the email already has an account, redeeming just
mints another token on its existing tier; the code's tier only applies
when the account is created fresh. fire_tier_codes rows track
max_redemptions/redemption_count/expires_at/is_active, same shape
as pornart_promo_codes.
Topping up a balance (Stripe)
GET /v1/billing/packs— no auth, lists the available credit packs (pricing_config.fire_api.credit_packs) plus the sameaccount_setupblockGET /v1/capabilitiescarries — this is the most likely place a caller with no token yet lands when it discovers it needs to pay.GET /v1/billing/balance,POST /v1/billing/checkout— token auth (readability), the latter creates a Stripe Checkout Session and returns itsurl.POST /fire/stripe/webhook— Stripe deliverscheckout.session.completedhere; on success it credits the account and writes afire_credit_transactionsrow (type=topup,reference= the Stripe session id).
Two real bugs found and fixed 2026-07-19 running the first-ever
end-to-end checkout test (previously nothing had verified this path beyond
code review): (1) the webhook route's Route::withoutMiddleware did not
actually exempt it from CSRF in this app — every real Stripe delivery
would have 419'd; fixed via bootstrap/app.php's validateCsrfTokens(except:)
list. (2) independently, the handler read metadata with (array) $session->metadata, which doesn't work on Stripe SDK objects (data sits
behind magic getters) — fire_account_id/credit_usd always read as
empty, so the handler silently no-opped and returned 200 OK on every
completed payment without ever crediting the account. Fixed with
$session->metadata->toArray(). No account had ever received a real
automated Stripe topup before this fix.
Idempotency (2026-07-20): FireAccount::credit() dedupes on
(type=topup, reference) inside its existing account-row lock, so a
webhook redelivery/retry for the same checkout session can't double-credit.
A partial unique index on fire_credit_transactions.reference (topups
only) backstops this at the DB level. Manual admin topups (no reference,
via /jp/fire/billing) are unaffected and can still repeat.
Model Taxonomy
Models are organized in a six-level biological hierarchy:
Kingdom: ai (implicit — not stored)
Phylum: silicon
Class: transformer
Order: autoregressive
Family: <creator> (anthropic, openai, google, meta, mistral, ...)
Genus: <line> (claude, gpt, gemini, llama, ...)
Species: <model> (claude-sonnet-4-5, gpt-4o, ...)
A deployment connects a species to a provider with a remote_slug (the string sent to the provider's API). The same species can have deployments on multiple providers. Each endpoint resolves the best active deployment for the requested species_name.
Species capabilities are stored in the capabilities jsonb column. The modality key distinguishes model types:
modality value |
Endpoint |
|---|---|
| (absent) | POST /v1/chat (text LLMs) |
image |
POST /v1/image (image generation) |
To enumerate all available species: GET /v1/models.
Bootstrap Sequence for a New Agent
The minimal sequence to go from zero to running a chat:
# Step 0 — (server-side, once) issue yourself a token. An external
# caller with no server access instead reads capabilities.account_setup
# (step 1) and gets a token by visiting that URL — there is no headless
# equivalent, see "account_setup" below.
TOKEN=$(/var/www/saga/scripts/fire-token "My Agent" --json | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
# Step 1 — discover the service
curl https://fire.test1.prosaga.net/v1/capabilities | python3 -m json.tool
# Step 2 — check operational status
curl -H "Authorization: Bearer $TOKEN" \
https://fire.test1.prosaga.net/v1/status
# Step 3 — enumerate available models
curl -H "Authorization: Bearer $TOKEN" \
https://fire.test1.prosaga.net/v1/models | \
python3 -c "import sys,json; [print(m['species_name'], '-', m['display_name']) for m in json.load(sys.stdin)['models']]"
# Step 4 — chat with the default model
curl -X POST https://fire.test1.prosaga.net/v1/chat \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Hello, what can you do?"}]}'
# Step 5 — check token usage
curl -H "Authorization: Bearer $TOKEN" \
https://fire.test1.prosaga.net/v1/usage
Notes on Logging and Accountability
Every call on every Data Plane endpoint writes a row to prompt_logs inside a finally block — it fires whether the call succeeds or throws, and there is no parameter to suppress it. What is always on that row (the audit trail): model, call type, token counts, duration, the price you were charged, any error, and the call_id.
What is opt-in (T-1279, 2026-10-13): the content — system prompt, messages, response. Fire follows the aggregator posture (OpenRouter, Eden, Venice store no content unless asked): a new account's prompts and responses are discarded after the call. Each account chooses, in the Portal:
content_logging |
What Fire keeps |
|---|---|
off (default) |
Metadata only. |
private |
Prompts/responses stored for your own dashboard and debugging; pruned after content_retention_days (default 30; "forever" is allowed). |
improve |
As private, and you allow Fire to use your inputs/outputs to improve Fire — evaluation, routing quality. Never sold or shared with third parties. |
- Per call,
X-Fire-Content-Logging: off(or"log_content": falsein the body) withholds that one call's content even on an opted-in account. The override can only reduce what is stored. - Every response says what happened:
meta.content_loggedistrueorfalse. - Fire's own traffic — 0%-markup internal accounts and ops probes — is always fully logged.
GET /v1/capabilities→privacydescribes the current policy; the human-readable version is at/privacy.
Token accounting in fire_usage is updated atomically via PostgreSQL upsert in the same finally block.
If you are running an agent that calls this API, every call is on the record — what the record contains is up to the account.
Vault Broker (added 2026-07-18)
ANY /v1/vault/{service}/{path} — credential-brokered pass-through to
external services (Vultr, Guardian, Forward Email, …). The caller's Fire
token is the badge; the vault holds the credential; the broker performs
the upstream call. Agents never see or handle the key. Per AIGCSEP
(covenant.to): "AI agents never hold secrets."
- Requires token ability
vault:{service}(e.g.vault:vultr). 403 otherwise; denials are audit-logged. - The request is forwarded verbatim — method, path, query, body, content
type — with the service credential injected server-side (bearer, header,
query-param, or basic per the service's proxy_config). The upstream
response returns verbatim with an
X-Vault-Brokered: 1header. - Every call is logged to
vault_logs(Service-plane telemetry): caller token, method, path, status, latency, request/response bodies (64KB cap). The injected credential never appears in logs or error messages. vultris designated master-only (by convention, surfaced in 403 descriptions) — infrastructure control belongs to the master agent.- Errors:
404 VAULT_SERVICE_UNKNOWN,403 FORBIDDEN,500 VAULT_MISCONFIGURED,502 VAULT_UPSTREAM_UNREACHABLE.
# Example: list DNS domains through the broker (no Vultr key in sight)
curl -s https://fire.test1.prosaga.net/v1/vault/vultr/v2/domains \
-H "Authorization: Bearer $TOKEN" # token minted with --abilities=vault:vultr
Console: /firecontrol/vault — every vault entry with masked values,
burn toggle (flag a key compromised/due for rotation; clears when the key
is updated), key updates, new entries, and recent brokered activity.
Model Onboarding (added 2026-07-18)
Design principle: accountability — Fire only sells what it has verified. Every claim about a model (price, capability, purpose) must be backed by recorded evidence.
- A deployment becomes sellable when
fire:onboardpasses its checklist and stampsmodel_deployments.onboarded_at. Required checks: a live call succeeds right now; token usage is reported (text); cost evidence exists (provider-reported inline cost or a verified rate); a published sell price exists (text); the species declares apurpose. Recorded non-fatal checks: JSON-mode support, cost-vs-rate drift, per-model size policy. - Non-onboarded models are hidden from
/v1/modelsand/v1/capabilitiesand refused when requested by name on any Data Plane endpoint or the image workflow. - Verdicts promote across environments with
fire:sync-catalog(run from test1 over the prod DB tunnel) — the models and provider accounts are identical, so a new environment does not re-buy the sweep; it gets the synced catalog plus a two-call smoke, then earns its own ongoing evidence through the weekly audit. fire:audit-modelsre-runs the checklist weekly against every onboarded deployment (scheduled inroutes/console.php). A model that has died upstream is un-onboarded automatically; softer regressions are recorded and reported for a human decision.- Every check run — pass or fail — writes a
model_feedbackrow (sourceonboardingoraudit). The same table accumulates post-gen compliance verdicts against generating models (sourcecompliance) and is the intended sink for site image ratings (sourcerating) — the performance evidence that will eventually drive default model selection in Layers 1 and 2.