Developers

API reference

Rendered from API.md · updated 2026-10-14. Discovery is public: GET /v1/capabilities returns every endpoint, model and policy as JSON with no auth.

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:

# 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 (see strengths on GET /v1/models) spend part of max_tokens on hidden reasoning before any visible output — Fire raises a too-low max_tokens to 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-mini actually failed onboarding on this exact symptom). The floor never lowers your request, only raises it; prompt_logs.max_tokens records 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 requested species_name (also returned when images is present but the requested/default model doesn't support reference images)
  • 422 INVALID_REFERENCE_IMAGE — an images entry isn't a valid URL, data: URL, or base64 string
  • 502 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 called
  • 402 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 requested species_name
  • 422 INVALID_AUDIO — audio isn't a valid URL, data: URL, or base64 string
  • 502 PROVIDER_UNAVAILABLE — upstream provider rejected the request
  • 500 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 requested species_name (a chat model's name here is a 404, not a fallback)
  • 422 INVALID_STATE — state empty or not a string/object/array
  • 422 INVALID_QUESTIONS — a question failed validation (the description names it), or the provider rejected the shape
  • 502 PROVIDER_UNAVAILABLE — upstream provider error
  • 500 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:token without --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_pct matches the global rate in pricing_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 in model_deployments.api_config.published_price (refreshed daily by fire:sync-price-list); images use a flat per-image rate ($0.06 standard / $0.22 premium — see FireBillingService::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_images on /v1/models tells you the ceiling; the per-call meta.price.usd tells 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/image reports the total as billing.usd with a breakdown; /v1/workflows/moderate-image reports price.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_usd gets 402 INSUFFICIENT_BALANCE before 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 --account for 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 same account_setup block GET /v1/capabilities carries — 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 (read ability), the latter creates a Stripe Checkout Session and returns its url.
  • POST /fire/stripe/webhook — Stripe delivers checkout.session.completed here; on success it credits the account and writes a fire_credit_transactions row (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": false in 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_logged is true or false.
  • Fire's own traffic — 0%-markup internal accounts and ops probes — is always fully logged.
  • GET /v1/capabilities → privacy describes 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: 1 header.
  • 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.
  • vultr is 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:onboard passes its checklist and stamps model_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 a purpose. Recorded non-fatal checks: JSON-mode support, cost-vs-rate drift, per-model size policy.
  • Non-onboarded models are hidden from /v1/models and /v1/capabilities and 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-models re-runs the checklist weekly against every onboarded deployment (scheduled in routes/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_feedback row (source onboarding or audit). The same table accumulates post-gen compliance verdicts against generating models (source compliance) and is the intended sink for site image ratings (source rating) — the performance evidence that will eventually drive default model selection in Layers 1 and 2.