LLM interpretations (multilingual, tiered)¶
Natural-language interpretations built on top of the deterministic Vedic engine. The LLM never sees a chart "raw" — it receives a structured JSON context (Complete Factor, Theme Probability, transit contacts, period modifiers) and is instructed to ground every claim in those numbers.
Two axes of control:
language— one of ten ISO-639-1 codes; the model produces the response in the matching language and native script.tier—basic/standard/premium. Concrete models are a deployment decision; the API never reveals them.
Languages¶
| Code | Language | Script |
|---|---|---|
en |
English | Latin |
hi |
Hindi (हिन्दी) | Devanagari |
ta |
Tamil (தமிழ்) | Tamil |
te |
Telugu (తెలుగు) | Telugu |
kn |
Kannada (ಕನ್ನಡ) | Kannada |
ml |
Malayalam (മലയാളം) | Malayalam |
mr |
Marathi (मराठी) | Devanagari |
bn |
Bengali (বাংলা) | Bengali |
gu |
Gujarati (ગુજરાતી) | Gujarati |
es |
Spanish (Español) | Latin |
Tiers¶
| Tier | Voice | Output budget | Typical use |
|---|---|---|---|
basic |
Concise — 3-5 short paragraphs | ~700 tokens | Quick chat replies, push notifications |
standard |
Substantive — 6-10 paragraphs, every non-obvious claim grounded | ~1500 tokens | Default UI experience |
premium |
Nuanced senior-Jyotisha voice — 12-20 paragraphs cross-referencing the engine | ~3000 tokens | Premium reports, depth analysis |
Pricing per interpretation scales roughly 1× / 8× / 50× across the three tiers; pick by use-case.
POST /v1/llm/theme/{theme}¶
Per-life-area interpretation grounded in a Complete Factor + Theme Probability.
| Param | Type | Notes |
|---|---|---|
chart_id |
string | An existing chart from POST /v1/charts. |
language |
enum | One of the 10 language codes. |
tier |
enum | basic / standard / premium. Default: standard. |
at |
ISO datetime | Optional — fold transit context into the interpretation. |
The path {theme} is one of the 133 classical
significators — career,
marriage_for_man, wealth, mother, longevity, ...
curl -X POST -H "Authorization: Bearer $TOKEN" \
"https://api.astrolinkers.com/v1/llm/theme/career\
?chart_id=$CHART&language=en&tier=standard&at=2026-11-15T12:00:00Z"
{
"interpretation_type": "theme",
"language": "en",
"tier": "standard",
"content": "Your career theme is centred on a strong Saturn karaka in the 10th house...\n\n...",
"engine_context": {
"theme": "career",
"complete_factor": { /* karaka, essentials, arudhas */ },
"probability": { /* 7 sources + failed conditions */ },
"top_transit_contacts": [ /* 5 strongest */ ]
},
"input_tokens": 920,
"output_tokens": 1480,
"latency_ms": 4200
}
POST /v1/llm/chart-reading¶
Full-chart synthesis — meta-factors (planets driving multiple themes), top yogas, corrected planet natures, thematic snapshot of the five headline life areas.
curl -X POST -H "Authorization: Bearer $TOKEN" \
"https://api.astrolinkers.com/v1/llm/chart-reading\
?chart_id=$CHART&language=hi&tier=premium"
Premium tier in Hindi will produce a multi-paragraph reading in Devanagari with cross-references to the engine's findings.
POST /v1/llm/dasha-forecast¶
Per-moment forecast for the current MD/AD/PD chain. Folds in:
- Period modifiers (which planets are amplified by the current dasha lords).
- Transit modifiers at
at(SAV + Bhinnashtaka weighted).
curl -X POST -H "Authorization: Bearer $TOKEN" \
"https://api.astrolinkers.com/v1/llm/dasha-forecast\
?chart_id=$CHART&language=en&at=2027-03-01T12:00:00Z&tier=premium"
POST /v1/llm/muhurta-reasoning¶
Electional reasoning — given a date window, the LLM explains the top muhurta candidates ranked by tara + day-lord.
curl -X POST -H "Authorization: Bearer $TOKEN" \
"https://api.astrolinkers.com/v1/llm/muhurta-reasoning\
?chart_id=$CHART&language=ta&tier=standard\
&window_start=2026-12-01T00:00:00Z&window_end=2026-12-07T23:59:00Z\
&interval_minutes=180&top_n=5"
Returns the top candidates with a Tamil-language explanation of why each moment ranks where it does.
Response shape (all four endpoints)¶
{
"interpretation_type": "theme | chart_reading | dasha_forecast | muhurta",
"language": "en",
"tier": "standard",
"content": "Natural-language interpretation text...",
"engine_context": { /* the structured engine output the LLM was given */ },
"input_tokens": 920,
"output_tokens": 1480,
"latency_ms": 4200
}
engine_context is identical to what the LLM saw. Use it for
auditing: every claim in content should be traceable to a value
in engine_context.
Rate limits per tier¶
Every mutating /v1/llm/... endpoint (sync + stream) is protected
by a per-tenant token bucket. Each tier has its own bucket:
exhausting your basic quota does not block premium calls,
and one customer's traffic never affects another's.
| Tier | Burst (capacity) | Sustained refill | Effective ceiling |
|---|---|---|---|
basic |
30 | 0.5 req/s | ~1 800 req/h |
standard |
10 | 0.1 req/s | ~360 req/h |
premium |
5 | 0.02 req/s | ~72 req/h |
Defaults are deployment-configurable; the numbers above match the
out-of-the-box Settings. List / read / usage-summary endpoints are
not rate-limited (they don't call the LLM).
When the bucket is empty the API returns HTTP 429 with the
standard error envelope plus a Retry-After header:
HTTP/2 429
Retry-After: 46
Content-Type: application/json
{
"error": {
"code": "llm_tier_rate_limited",
"message": "Llm tier rate limited",
"details": {
"reason": "llm_tier_rate_limited",
"tier": "premium",
"retry_after_seconds": 46
}
}
}
Retry-After is in seconds, rounded up to the nearest whole
second so the value is a safe lower bound.
Response cache¶
Identical requests within a TTL window do not call the LLM again — the original response is replayed from the database. The cache key is a deterministic SHA-256 over:
- tenant id, chart id;
- interpretation type + (for
theme) the theme; - language, tier;
- request parameters (
at,window_start,window_end,interval_minutes,top_n— whichever apply).
Sync responses gain a cached: true field on cache hit; the
streaming variant replays a cached row as exactly one meta event
+ one delta containing the full content + one
done with cached: true so the SSE contract is identical to
a fresh call.
Default TTL: 24 hours. Configurable server-side via
LLM_CACHE_TTL_SECONDS (0 disables the cache entirely).
Bypassing the cache¶
Add ?fresh=true to force a new LLM call:
curl -X POST -H "Authorization: Bearer $TOKEN" \
"https://api.astrolinkers.com/v1/llm/theme/career\
?chart_id=$CHART&language=en&tier=premium&fresh=true"
The new response is also persisted, so subsequent non-fresh calls
hit it (the cache is refreshed, not just bypassed).
What invalidates a cached entry¶
- TTL expiry — older than
LLM_CACHE_TTL_SECONDS. - Any difference in the cache-key fields: a new language, tier, or
changed
at/window all miss the cache and produce a fresh row. - The cache schema version is bumped server-side when the prompt template or engine output shape changes meaningfully; every entry becomes ineligible until the next request refreshes it.
Usage summary¶
GET /v1/llm/usage-summary aggregates call count, token usage and
USD spend over an arbitrary window. Tenant-scoped, with optional
filters and a single break-down dimension.
| Query | Type | Notes |
|---|---|---|
from |
ISO datetime | Inclusive lower bound. |
to |
ISO datetime | Exclusive upper bound. Must be strictly after from. |
chart_id |
string | Optional — usage for one chart. |
interpretation_type |
enum | Optional — theme / chart_reading / dasha_forecast / muhurta. |
language |
enum | Optional — one of the 10 codes. |
tier |
enum | Optional — basic / standard / premium. |
group_by |
enum | none (default), interpretation_type, tier, language, day (UTC). |
curl -H "Authorization: Bearer $TOKEN" \
"https://api.astrolinkers.com/v1/llm/usage-summary\
?from=2026-05-18T00:00:00Z&to=2026-05-19T00:00:00Z&group_by=tier"
{
"from_": "2026-05-18T00:00:00Z",
"to": "2026-05-19T00:00:00Z",
"group_by": "tier",
"total": {
"label": null,
"call_count": 5,
"input_tokens": 6047,
"output_tokens": 4696,
"cost_usd": 0.006763
},
"breakdown": [
{ "label": "basic", "call_count": 3, "input_tokens": 3388, "output_tokens": 975, "cost_usd": 0.001482 },
{ "label": "premium", "call_count": 1, "input_tokens": 1178, "output_tokens": 3054, "cost_usd": 0.003169 },
{ "label": "standard", "call_count": 1, "input_tokens": 1481, "output_tokens": 667, "cost_usd": 0.002112 }
]
}
The total bucket aggregates everything in the window before the
break-down is applied, so it equals the sum of the breakdown
buckets when no extra filter is in play.
Use cases:
- Monthly bill statements (
group_by=day, sumcost_usd). - Capacity planning (
group_by=tier). - Per-chart cost analysis (
chart_id=…,group_by=interpretation_type). - Language mix audit (
group_by=language).
Notes:
- Cached responses are recorded with the original call's tokens and cost so usage analytics reflect actual spend — replays do not inflate the total.
- Aggregation runs as a single
GROUP BYserver-side; window scans are bounded by the(owner_tenant_id, interpretation_type, created_at)index.
Persistence and re-reading past interpretations¶
Every successful LLM interpretation — sync or streamed — is saved
server-side. The tenant can list and re-read past interpretations
without paying for another LLM round-trip, and every row carries
the same engine_context so the audit trail stays intact.
A row is written only after the call succeeds:
- Sync calls write the row after the LLM response lands; the
response body now includes
interpretation_id. - Streamed calls accumulate deltas; on the terminal
doneevent the row is written and its id arrives in the samedonepayload. - A streaming
errorevent leaves no row behind — half-streams never reach the table.
GET /v1/llm/interpretations¶
List the tenant's stored interpretations, newest first.
| Query | Type | Default | Notes |
|---|---|---|---|
chart_id |
string | — | Only rows for one chart. |
interpretation_type |
enum | — | theme / chart_reading / dasha_forecast / muhurta. |
language |
enum | — | One of the 10 ISO codes. |
tier |
enum | — | basic / standard / premium. |
limit |
int | 50 | 1..200. |
offset |
int | 0 | For pagination. |
curl -H "Authorization: Bearer $TOKEN" \
"https://api.astrolinkers.com/v1/llm/interpretations\
?chart_id=$CHART&interpretation_type=theme&limit=10"
{
"items": [
{
"id": "019e3d37-bd30-7973-920e-ba3030aca3d4",
"chart_id": "019e3cdd-…",
"interpretation_type": "theme",
"theme": "career",
"language": "en",
"tier": "basic",
"content": "**Career Theme Analysis…**",
"engine_context": { /* same shape the LLM was given */ },
"request_params": { "at": null },
"input_tokens": 1130,
"output_tokens": 256,
"latency_ms": 12300,
"cost_usd": 0.00044,
"created_at": "2026-05-18T22:31:33Z"
}
],
"total": 1,
"limit": 10,
"offset": 0
}
total is the unpaginated count of matches so the UI can render
correct pagination.
GET /v1/llm/interpretations/{interpretation_id}¶
Read one record. Cross-tenant ids return 404.
curl -H "Authorization: Bearer $TOKEN" \
"https://api.astrolinkers.com/v1/llm/interpretations/019e3d37-…"
Returns one row in the same shape as a list.items[] element above.
Schema fields¶
| Field | Notes |
|---|---|
id |
UUIDv7 (sortable by time). |
chart_id |
The chart the interpretation belongs to. |
interpretation_type |
theme / chart_reading / dasha_forecast / muhurta. |
theme |
Populated only for interpretation_type == "theme"; one of the 133 significators. |
language / tier |
The original request parameters. |
content |
Full natural-language output as the LLM produced it. |
engine_context |
The structured engine JSON that the LLM saw — every claim in content should trace back here. |
request_params |
Endpoint-specific parameters (at, window_start, window_end, interval_minutes, top_n). |
input_tokens / output_tokens / latency_ms / cost_usd |
Usage accounting from the underlying provider. |
created_at |
UTC timestamp when the row was written. |
Tenant isolation¶
Every read query is scoped to the requesting tenant's id. List filters apply on top of tenant scoping — a tenant cannot see another tenant's interpretations even with a known id.
Why this is in the API¶
- Re-reading is free: the LLM is not called again.
engine_contextis preserved with the response, so an audit of any past interpretation is one GET away.- Cost analytics: sum
cost_usdover a window for a per-tenant spend report. - UI history: list interpretations per chart so users can scroll back through their previous readings.
Streaming (Server-Sent Events)¶
Every interpretation endpoint has a …/stream sibling that returns
its output as a text/event-stream. The UI gets the structured
engine context immediately (so it can render grounded blocks while
the user waits), then tokens flow in as the LLM produces them.
The four streaming endpoints:
| Streaming endpoint | Sync sibling |
|---|---|
POST /v1/llm/theme/{theme}/stream |
/v1/llm/theme/{theme} |
POST /v1/llm/chart-reading/stream |
/v1/llm/chart-reading |
POST /v1/llm/dasha-forecast/stream |
/v1/llm/dasha-forecast |
POST /v1/llm/muhurta-reasoning/stream |
/v1/llm/muhurta-reasoning |
All query parameters from the sync endpoint apply unchanged.
Event taxonomy¶
Each event is a named SSE event with a JSON payload.
-
event: meta— sent once, first. Carries the deterministic engine output that the LLM is about to interpret. Render it immediately so the user sees grounded numbers before any token arrives. -
event: delta— one per incremental token chunk. Concatenating everydelta.contentreconstructs the full interpretation. -
event: done— terminal. Carries the usage summary so you can display cost / token counts. -
event: error— terminal alternative. Sent if the active model fails mid-stream (after at least one delta has already flowed) or if the entire fallback chain is exhausted before any token reaches the caller.
A stream is guaranteed to end with exactly one terminal event
(done or error); your consumer can stop as soon as either
arrives.
Fallback semantics¶
The router tries the policy's primary model first, then walks down the fallback chain on retryable failures (timeout, rate-limit, 5xx, circuit-open). For streaming there is one important rule:
Fallback only happens before the first
deltareaches the client. Once content has begun to flow, the active model is locked in — a mid-stream failure surfaces as a terminalerrorevent and the client must retry from scratch.
This is unavoidable: an HTTP byte that has already left the wire cannot be unsent, and splicing two models' partial outputs together would corrupt the response.
Client example (browser / fetch)¶
The browser's built-in EventSource only supports GET, so for the
POST-based streaming endpoints use fetch + the
ReadableStream
API. A minimal consumer:
async function streamInterpretation({ url, token, onMeta, onDelta, onDone, onError }) {
const resp = await fetch(url, {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Accept": "text/event-stream",
},
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE events are separated by blank lines.
let sep;
while ((sep = buffer.indexOf("\n\n")) !== -1) {
const block = buffer.slice(0, sep);
buffer = buffer.slice(sep + 2);
const lines = block.split("\n");
const kind = lines.find(l => l.startsWith("event: "))?.slice(7);
const data = lines.find(l => l.startsWith("data: "))?.slice(6);
if (!kind || !data) continue;
const payload = JSON.parse(data);
if (kind === "meta") onMeta?.(payload);
if (kind === "delta") onDelta?.(payload.content);
if (kind === "done") return onDone?.(payload);
if (kind === "error") return onError?.(payload.error);
}
}
}
A non-streaming reference (curl):
curl -N -X POST -H "Authorization: Bearer $TOKEN" \
-H "Accept: text/event-stream" \
"https://api.astrolinkers.com/v1/llm/chart-reading/stream\
?chart_id=$CHART&language=en&tier=standard"
curl -N disables output buffering so each event prints as it
arrives.
Operational notes¶
- The response sets
Cache-Control: no-cache, no-transformandX-Accel-Buffering: noso reverse proxies do not buffer the stream. - If your client disconnects mid-stream the server cancels the upstream LLM call and stops charging tokens — useful when the user navigates away.
- Streaming and sync endpoints share the same per-tenant cost cap
(
llm_cost_cap_per_hour_usd). A request that would exceed the cap is rejected with402 / 429before the stream opens, so the caller never sees a half-stream that fails to bill.
Architecture (for the curious)¶
- Provider-agnostic — the underlying router accepts any provider
implementing
LLMProviderProtocol; switching from OpenRouter to a direct vendor is a configuration change, not code. - Tier → model mapping lives in
config/llm_policy.yamlserver-side; never echoed in the API. Tier labels (basic,standard,premium) are stable; the model behind a tier can rotate as better/cheaper options emerge. - Fallback chains — each tier has 2 fallback models. If the primary errors or the circuit breaker is open, the request rolls forward to the next without surfacing the failure.
- Cost guard — the per-tenant rolling LLM spend cap
(
llm_cost_cap_per_hour_usd) applies to every interpretation. A tenant that hits the cap gets a 402 / 429 with aretry_afterhint. - Prompt redaction — user-supplied text is sanitised before it reaches the provider (PII / secret patterns stripped).
What this does not replace¶
The existing /v1/interpretations endpoint is
template-driven (deterministic, no LLM round-trip). Use it when you
need:
- Predictable, reproducible output bytes (caching, deduplication).
- No external-LLM dependency.
- Per-skill statements with feedback / accuracy tracking.
Use /v1/llm/... when you need:
- Natural-language synthesis grounded in engine math.
- Multilingual output.
- Tier-controlled depth.