Generation & traces¶
Ask a question, get a verified, cited answer
POST /v1/generate plans evidence under a token budget, generates an answer from that plan and nothing else, verifies every claim in the draft against the planned spans, and persists the whole assembly as an immutable trace. This page is the wire contract for the endpoint, the SSE event stream, and the trace it leaves behind.
Normative specs
Evidence planning: memory-planner. Prompt assembly, streaming, and cost caps: generation-orchestration. Claim decomposition and scoring: claim-verification. Implementation: core-engine/src/api/v1.rs (post_generate, get_trace), core-engine/src/planner/, core-engine/src/llm/, core-engine/src/verify/.
POST /v1/generate¶
Auth: x-api-key.
{
"question": "Which open risks have severity 5 or higher?",
"query": {"find": "RISK", "where": {"status": {"$eq": "open"}}},
"semanticModel": "text-embedding-3-small",
"k": 16,
"expandDepth": 1,
"atValidTime": null,
"atTxTime": null,
"budgetTokens": null,
"model": null,
"maxTokens": null,
"temperature": 0.2,
"stream": false,
"allowUngrounded": false
}
| Field | Type | Default | Notes |
|---|---|---|---|
question | string | required | The user's question. Also the text embedded for semantic recall, when semanticModel is set. Empty (after trim) is 400 PLAN_INVALID. |
query | query-DSL object | none | Structured recall leg: any valid query-DSL document. |
semanticModel | string | none | Semantic recall leg: a registered embedding model name. Requires a configured [embedding] endpoint; 501 UNIMPLEMENTED otherwise. |
k | integer | 16 | Semantic top-k. |
expandDepth | integer | 1 | Hop expansion (0-2) from recalled roots. |
atValidTime | RFC3339 string or µs integer | now | Valid-time coordinate for recall. |
atTxTime | RFC3339 string or µs integer | now | Transaction-time coordinate for recall. |
budgetTokens | integer | planner default | Token budget for evidence packing (planner default 6000, auto-scaled by model context; see Memory planner). |
model | string | llm.model_default | Generation model. Validated against the operator allowlist; an unlisted model is 400 MODEL_NOT_ALLOWED. |
maxTokens | integer | llm.max_output_tokens | Output-token ask, clamped to the operator ceiling. |
temperature | number | 0.2 | Must be in [0, 2], else 400 PLAN_INVALID. |
stream | boolean | false | Server-Sent Events instead of one JSON response. |
allowUngrounded | boolean | false | Proceed with an empty evidence plan instead of 422 when recall finds nothing. |
At least one of query / semanticModel is typically supplied; either can be used alone or both together (their recalled evidence is unioned before packing).
Non-stream response (200)¶
{
"draft": "Two open risks have severity 5 or higher: ...",
"model": "claude-sonnet-4-5",
"traceId": "0197f2a1-...",
"promptModel": "v1",
"planModel": "v1",
"planHash": "b3:7f2a...",
"promptHash": "b3:9c11...",
"citations": [
{"marker": "S1", "sourceId": "...", "sourceVersionTx": ..., "start": 120, "end": 340}
],
"plan": {
"budget": 6000,
"used": 812,
"spans": 4,
"stats": { "...": "..." }
},
"usage": {"inputTokens": 1204, "outputTokens": 96},
"verification": {
"status": "verified",
"claims": [
{
"text": "Supplier delay has severity 7.",
"draftSpan": [0, 31],
"verdict": "supported",
"score": 0.91,
"bestSpan": {"sourceId": "...", "sourceVersionTx": ..., "start": 120, "end": 210},
"candidatesConsidered": 3,
"citedMarkers": ["S1"],
"numericGated": false
}
]
}
}
| Field | Notes |
|---|---|
draft | The delivered answer text. If verify.policy = redact capped an unsupported claim, this is the redacted text; the trace keeps the original. |
model | The model actually used (request model, or llm.model_default). |
traceId | UUIDv7. Pass to GET /v1/trace/:id. |
promptModel / planModel | Versioned formula identifiers, currently both "v1". A future formula change bumps these, never silently. |
planHash | BLAKE3 of the evidence plan's canonical serialization. Identical inputs always produce an identical planHash, across runs and platforms. |
promptHash | Hash of the assembled, citation-marked prompt. |
citations | The [S{n}] markers the prompt offered the model, each bound to a plan span. |
plan.budget / plan.used | Token budget and tokens actually packed. |
plan.spans | Count of spans selected into the plan. |
plan.stats | Planner recall/packing statistics (candidates considered, per-source caps, stranded budget, etc). |
usage | Provider-billed token counts. |
verification | See Verification result shape below. |
Verification result shape¶
{
"status": "verified",
"claims": [
{
"text": "...",
"draftSpan": [0, 31],
"verdict": "supported",
"score": 0.91,
"bestSpan": {"sourceId": "...", "sourceVersionTx": 1751500000000000, "start": 120, "end": 210},
"candidatesConsidered": 3,
"citedMarkers": ["S1"],
"numericGated": false
}
]
}
Each claim carries a banded verdict: supported (score >= 0.75 by default), partial ([0.50, 0.75)), or unsupported (< 0.50). score is the geometric fusion of embedding similarity and IDF-weighted lexical coverage against the best-matching plan span (bestSpan); numericGated: true marks a claim whose asserted number, date, or percentage had no match in the span, capping its score deep into unsupported regardless of topical similarity. citedMarkers are the [S{n}] markers the draft cited for that claim, hints only, an incorrect citation does not rescue an unsupported claim.
When decomposition fails, or the claims and plan spans were embedded under inconsistent models, verification degrades instead of lying:
The response is still delivered; it is simply never presented as verified when it wasn't.
Streaming (stream: true)¶
The response becomes text/event-stream. Event names, in order:
sequenceDiagram
autonumber
participant C as Client
participant Core as telha-core
C->>Core: POST /v1/generate (stream true)
loop while tokens arrive
Core-->>C: event chunk (data delta)
end
Note over Core: stream ends, verification runs
Core-->>C: event verification (status, claims)
Core-->>C: event done (traceId, usage, hashes, citations) | Event | Data | Notes |
|---|---|---|
chunk | {"delta": "..."} | One text increment as the provider streams it. |
verification | Same shape as the non-stream verification field | Necessarily trails the stream: verdicts can only be computed once the full draft exists. Clients see draft text live; the badge/caveat UI paints after. |
done | {"traceId": ..., "usage": {"inputTokens": ..., "outputTokens": ...}, "planHash": ..., "promptHash": ..., "citations": [...]} | Terminal event on success. |
error | {"message": "..."} | A mid-stream provider failure. The stream ends immediately after; nothing is ever re-spliced into a second attempt once the first chunk has been delivered. |
const res = await fetch("http://127.0.0.1:7625/v1/generate", {
method: "POST",
headers: { "x-api-key": key, "Content-Type": "application/json" },
body: JSON.stringify({ question: "...", stream: true }),
});
for await (const event of parseSSE(res.body!)) {
if (event.event === "chunk") process.stdout.write(JSON.parse(event.data).delta);
if (event.event === "done") console.log(JSON.parse(event.data).traceId);
}
Failure modes¶
| Status | Code | Meaning |
|---|---|---|
400 | PLAN_INVALID | Empty question, or temperature outside [0, 2]. |
400 | MODEL_NOT_ALLOWED | model is not in the operator's llm.models_allowed allowlist. |
400 | QUERY_INVALID | The query leg failed query-DSL validation. |
400 | UNKNOWN_MODEL | semanticModel names a model not registered with telha vector register-model. |
400 | VECTOR_INVALID | The semantic recall leg was malformed. |
401 | UNAUTHORIZED | Missing or invalid API key. |
422 | INSUFFICIENT_EVIDENCE | Recall produced zero evidence spans. Pass "allowUngrounded": true to generate anyway (the model is instructed to flag everything). |
429 | TENANT_BUDGET_EXCEEDED | The tenant's token cap (daily or monthly) is exhausted; the message carries the reset time. The cap is a pre-flight reservation: estimated usage is reserved before the provider call and reconciled after, so a tenant at the edge of its cap cannot fire one arbitrarily large request past it. |
501 | UNIMPLEMENTED | Generation ([llm]) or the embedding endpoint is not configured on this server. |
502 | PROVIDER_ERROR / EMBEDDING_FAILED | The LLM or embedding provider returned an error. |
503 | PROVIDER_UNAVAILABLE | A transient provider failure; retries (up to 2, jittered) were already exhausted. Retries only ever happen before the first chunk is delivered, a mid-stream failure is never retried, since that would splice two different generations under one verification pass. |
GET /v1/trace/:id¶
Every generation persists a GenerationTrace record. Auth: x-api-key; traces are tenant-scoped, a trace id from another tenant is 404 NOT_FOUND.
{
"kind": "generation",
"queryId": "0197f2a1-...",
"request": {
"question": "Which open risks have severity 5 or higher?",
"model": "claude-sonnet-4-5",
"semanticModel": "text-embedding-3-small",
"k": 16,
"expandDepth": 1,
"budgetTokens": null,
"allowUngrounded": false,
"stream": false
},
"planHash": "b3:7f2a...",
"promptHash": "b3:9c11...",
"models": {
"gen": "claude-sonnet-4-5",
"decompose": "claude-sonnet-4-5",
"embed": "text-embedding-3-small",
"scoreModel": "v1",
"planModel": "v1",
"promptModel": "v1"
},
"plan": {
"budget": 6000,
"used": 812,
"spans": [
{
"sourceId": "...", "sourceVersionTx": 1751500000000000,
"start": 120, "end": 210,
"kind": "paragraph", "class": "truth",
"weight": 0.84, "tokens": 42
}
],
"stats": { "...": "..." }
},
"draft": "Two open risks have severity 5 or higher: ...",
"claims": [
{
"text": "Supplier delay has severity 7.",
"draftSpan": [0, 31],
"verdict": "supported",
"score": 0.91,
"bestSpan": {"sourceId": "...", "sourceVersionTx": 1751500000000000, "start": 120, "end": 210},
"candidatesConsidered": 3,
"citedMarkers": ["S1"],
"numericGated": false
}
],
"verification": "verified",
"usageInputTokens": 1204,
"usageOutputTokens": 96,
"ts": 1751500002000000
}
The trace is the full request summary (no secrets, ever), the plan that was selected (every span, its structural kind, its truth/cache class, and its packing weight), the prompt hash, every decomposed claim with its verdict and best-matching span, and the model lineage that produced each step. Span coordinates in the trace resolve back to exact source bytes through the same canonical-slice API that ingestion writes against, so a claim's bestSpan is always one lookup away from the literal text that grounded it. spans marked class: "cache" are derived artifacts (never truth); verification never treats them as evidence for a claim.
verification on the trace is a short string: "verified" or "unavailable: <reason>". plan.stats and per-span weights are the answer to "why didn't it cite X", rejected-by-budget or capped-by-source-diversity spans show up there even when they never made the prompt.
404 NOT_FOUND means no such trace exists for this tenant (wrong id, or a trace belonging to a different tenant).
Related¶
- Ingestion - how spans (the evidence unit) get created.
- REST reference - every
/v1endpoint. - Errors - the full error code reference.
- Concepts › Verified answers - the plan/generate/verify loop, explained.
- Architecture › Memory planner - recall and budgeted evidence packing.
- Architecture › Generation orchestration - prompt assembly, streaming, cost caps.
- Architecture › Claim verification - decomposition, matching, and verdict scoring in full.