AI Ask API
The AI Ask API takes a plain-English question, asks the configured LLM provider to fill in a typed query against the live cube schema, runs that query through the same converter the typed AI Query API uses, and returns the executed result + the generated MDX + the structured request the model emitted.
It’s the natural-language layer sitting on top of the typed agent
surface. Where the typed API assumes the caller speaks
AiQueryRequest, the ask API lets a human say “show sales by country
last quarter” and get back a fully-executed cellset with the model’s
translation visible alongside it.
This powers the workspace AI Query drawer — the ⚡ button in the toolbar opens a chat panel where users type questions and click Edit in canvas to drop the result into the active query tab.
When to use it
The ask API is the right surface when:
- End users will type questions — the typed AI Query API expects an agent that already knows the cube schema; the ask API hides that step behind an LLM.
- You want one round-trip per question —
/ai/askdoes translation + execution + envelope shaping in a single call so the client doesn’t have to chain two APIs. - You’re embedding a chat-style surface — the multi-turn
historyfield lets follow-up questions resolve against earlier turns.
When not to use it:
- Agents on the MCP server — agents prefer the typed surface because they’re already good at filling in structured fields from a JSON Schema. The ask layer is for humans.
- Authoring cubes from a sample warehouse — use the AI inference API, which is designed for the design-time flow.
Activation
Two properties + one env var per provider. Both can be set on the JVM or in the deployment’s properties file consumed by Spring.
# Anthropic Claudesaiku.ai.ask.provider = anthropic# env ANTHROPIC_API_KEY = sk-ant-...
# OpenAI — or any OpenAI-compatible host (vLLM, Ollama, Together, LiteLLM)saiku.ai.ask.provider = openai# env OPENAI_API_KEY = sk-...
# Azure OpenAI Service (saiku#1431). See BYOLLM below.saiku.ai.ask.provider = azure-openaisaiku.ai.ask.endpoint = https://<resource>.openai.azure.com/openai/deployments/<deployment>/chat/completions?api-version=2024-02-15-previewsaiku.ai.ask.model = <deployment># env AZURE_OPENAI_API_KEY = <azure-key>
# Optional, all providerssaiku.ai.ask.model = claude-sonnet-4-7 | gpt-4o-mini | ...saiku.ai.ask.endpoint = https://my.openai-compatible.host/v1/chat/completionssaiku.ai.ask.apiKey = ... # explicit override of the env varProvider defaults:
| Provider | Default model | Endpoint |
|---|---|---|
anthropic | claude-sonnet-4-6 | https://api.anthropic.com/v1/messages |
openai | gpt-4o-mini | https://api.openai.com/v1/chat/completions |
azure-openai | (no default) | (no default — refuses to construct without an explicit deployment URL) |
Docker
docker run -d --name saiku \ -e ANTHROPIC_API_KEY=sk-ant-... \ -e JAVA_OPTS='-Dsaiku.ai.ask.provider=anthropic' \ ghcr.io/spiculedata/saiku:latestKubernetes
Use a Secret for the key, a ConfigMap for the provider name:
apiVersion: v1kind: Secretmetadata: name: saiku-aitype: OpaquestringData: ANTHROPIC_API_KEY: sk-ant-...---apiVersion: apps/v1kind: Deploymentmetadata: name: saikuspec: template: spec: containers: - name: saiku envFrom: - secretRef: name: saiku-ai env: - name: JAVA_OPTS value: -Dsaiku.ai.ask.provider=anthropicBYOLLM — bring your own model (saiku#1431)
Enterprise deployments often can’t send prompts to Anthropic or OpenAI directly — the LLM has to run inside the customer’s VPC or behind their existing procurement. The ask layer covers the three most common BYOLLM shapes without baking any heavy deps into Saiku.
| Shape | Provider | Endpoint format | Auth header |
|---|---|---|---|
| Azure OpenAI Service | azure-openai | https://<resource>.openai.azure.com/openai/deployments/<deployment>/chat/completions?api-version=<v> | api-key: <key> |
| Self-hosted / OpenAI-compat proxy (vLLM, Ollama, LiteLLM, Together, Groq) | openai | any URL that speaks OpenAI’s Chat Completions API | Authorization: Bearer <key> |
| AWS Bedrock via LiteLLM front | openai | LiteLLM in front of Bedrock (https://litellm.internal/v1/chat/completions) | Authorization: Bearer <litellm-key> |
The native azure-openai adapter takes care of the two Azure-specific
things (deployment-name-in-URL and api-key header) so operators
don’t have to run a translation proxy for the most common case.
Azure OpenAI activation
-
Deploy an OpenAI-compatible model to your Azure OpenAI resource. Note the resource name and deployment name.
-
Pull a key from Azure Portal → your resource → Keys and Endpoint.
-
Set on the launcher:
saiku.ai.ask.provider = azure-openaisaiku.ai.ask.endpoint = https://my-resource.openai.azure.com/openai/deployments/my-deployment/chat/completions?api-version=2024-02-15-previewsaiku.ai.ask.model = my-deployment# env AZURE_OPENAI_API_KEY = <azure-api-key> -
Boot the launcher. INFO line at startup confirms the wiring:
INFO o.s.s.o.a.a.NlAskProviderFactory - AI ask provider: azure-openai (deployment/model=my-deployment, endpoint=https://…)
AWS Bedrock via LiteLLM
The recommended pattern for Bedrock is to run LiteLLM
as an OpenAI-compatible front and hit Saiku’s existing openai
provider at LiteLLM. LiteLLM handles SigV4 signing upstream so Saiku
doesn’t need the AWS SDK on its classpath.
model_list: - model_name: claude-sonnet-3-5 litellm_params: model: bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0 aws_region_name: us-east-1
litellm_settings: drop_params: truelitellm --config litellm-config.yaml --port 4000Then on the Saiku side:
saiku.ai.ask.provider = openaisaiku.ai.ask.endpoint = http://litellm.internal:4000/v1/chat/completionssaiku.ai.ask.model = claude-sonnet-3-5# env OPENAI_API_KEY = <any-value-litellm-passes-through>Same trick works for any Bedrock model (Titan, Cohere, Mistral, Llama) and for GCP Vertex AI, Databricks Model Serving, and every other provider LiteLLM covers.
Self-hosted models
Any host that speaks OpenAI’s Chat Completions API is a drop-in. The common ones:
- vLLM — self-hosted GPU inference; OpenAI-compat out of the box.
- Ollama — local dev; enable the OpenAI-compat adapter with
OLLAMA_HOST=0.0.0.0. - Together and Groq — SaaS OpenAI-compat endpoints for self-service model hosting.
- LiteLLM — proxy that fronts anything (Bedrock, Vertex, Azure, Anthropic, hundreds of others) as OpenAI-compat.
saiku.ai.ask.provider = openaisaiku.ai.ask.endpoint = https://my.vllm.internal/v1/chat/completionssaiku.ai.ask.model = mistralai/Mistral-7B-Instruct-v0.3# env OPENAI_API_KEY = <any-value-your-server-accepts>The endpoint
POST /saiku/api/ai/askRequest
{ "question": "show sales by country last quarter", "cube": { "connectionName": "foodmart", "catalog": "FoodMart", "schema": "FoodMart", "cubeName": "Sales" }, "history": [ { "role": "user", "content": "earlier question" }, { "role": "assistant", "content": "earlier summary" } ]}history is optional. When supplied, prior (user, assistant) turns
are sent back to the model so follow-ups like “now break it down by
region” resolve against the earlier question. System prompts are
controlled by the provider — callers cannot inject them.
Response
{ "degraded": false, "model": "claude-sonnet-4-6", "request": { /* the structured AiQueryRequest the model emitted */ }, "response": { /* the full AiQueryResponse — same shape as /ai/query */ }, "generatedMdx": "SELECT NON EMPTY ... FROM [Sales]"}request is the structured query the model produced. Hand it to
POST /ai/query verbatim to re-execute, or expose
it to the user as “the typed query behind your question.”
response is the executed result, same shape as the typed AI Query
API returns. If the model emitted a request the schema rejects,
response.status is VALIDATION_ERROR and the body carries field +
available candidates — the workspace drawer renders those as
clickable chips so the user can self-correct.
generatedMdx is a convenience mirror of
response.metadata.generatedMdx.
Status codes
| HTTP | When |
|---|---|
200 | Translation succeeded; response carries the executed result (or VALIDATION_ERROR for the user to self-correct). |
200 + degraded:true | Translation failed at the provider layer — transport error, model refused, or parse error. reason carries the explanation. |
400 | Missing question or cube in the body. |
503 + degraded:true | Provider is noop (not configured) — reason explains how to enable. |
Examples
Off-by-default — clear feedback
curl -sS -X POST -H 'Content-Type: application/json' \ -u admin:admin \ http://localhost:8080/saiku/api/ai/ask \ -d '{"question":"show sales by country","cube":{"connectionName":"foodmart","catalog":"FoodMart","schema":"FoodMart","cubeName":"Sales"}}'
# {# "degraded": true,# "reason": "AI ask is not configured. Set saiku.ai.ask.provider..."# }Happy path — full round-trip
curl -sS -X POST -H 'Content-Type: application/json' \ -u admin:admin \ http://localhost:8080/saiku/api/ai/ask \ -d '{"question":"show sales by country","cube":{"connectionName":"foodmart","catalog":"FoodMart","schema":"FoodMart","cubeName":"Sales"}}' \ | jq '{model, generatedMdx, rows: .response.totalRows}'
# {# "model": "claude-sonnet-4-6",# "generatedMdx": "SELECT NON EMPTY {[Measures].[Store Sales]} ON COLUMNS, NON EMPTY {[Customers].[Country].Members} ON ROWS FROM [Sales]",# "rows": 3# }Follow-up — multi-turn
curl -sS -X POST -H 'Content-Type: application/json' \ -u admin:admin \ http://localhost:8080/saiku/api/ai/ask \ -d @- <<'EOF'{ "question": "now break it down by quarter", "cube": {"connectionName":"foodmart","catalog":"FoodMart","schema":"FoodMart","cubeName":"Sales"}, "history": [ {"role":"user","content":"show sales by country"}, {"role":"assistant","content":"3 rows returned."} ]}EOFThe model sees the prior turn and resolves “by quarter” as also crossed against country — same shape as the first answer with one extra hierarchy.
Streaming variant — POST /ai/ask/stream (saiku#1433)
Companion endpoint that speaks Server-Sent Events so embedded chat surfaces can render progress as it arrives rather than block on the full round-trip. Same request body, same auth, same rate limits, same envelope shape — the response is a stream of typed events instead of one JSON blob.
Event schema
event: modeldata: {"model":"claude-sonnet-4-6"}
event: intentdata: {"kind":"INSIGHT"}
event: chunkdata: {"delta":"Store "}
event: chunkdata: {"delta":"Sales trended up 12% week-on-week."}
event: finaldata: {"degraded":false,"model":"claude-sonnet-4-6","insight":{"markdown":"Store Sales trended up 12% week-on-week."}}| Event | When | Payload |
|---|---|---|
model | Always fires first when the provider returned a model id | {"model": "<id>"} |
intent | After tool routing, before payload | {"kind": "QUERY" | "INSIGHT" | "VIEW_CHANGE"} |
chunk | Zero or more times for prose-carrying intents (INSIGHT, VIEW_CHANGE.reason) | {"delta": "<word-boundary chunk>"} — concatenating deltas is lossless |
final | Always fires last on success | Complete AskResponse envelope — same shape as sync /ai/ask |
error | Provider transport / parse / auth failure | {"reason": "<explanation>"} — followed by a final with degraded:true |
Client examples
EventSource is GET-only, so POST bodies go through fetch +
manual event parsing:
const res = await fetch('/rest/saiku/api/ai/ask/stream', { method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream', }, credentials: 'include', body: JSON.stringify({ question, cube }),});
const reader = res.body!.getReader();const decoder = new TextDecoder();let buffer = '';let markdown = '';
while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // Events are separated by a blank line (double-newline). const events = buffer.split('\n\n'); buffer = events.pop() ?? ''; for (const raw of events) { const evt = parseSseEvent(raw); if (evt.event === 'chunk') { markdown += JSON.parse(evt.data).delta; render(markdown); } else if (evt.event === 'final') { const full = JSON.parse(evt.data); renderFinal(full); } else if (evt.event === 'error') { showError(JSON.parse(evt.data).reason); } }}
function parseSseEvent(raw: string) { let event = 'message', data = ''; for (const line of raw.split('\n')) { if (line.startsWith('event: ')) event = line.slice(7); else if (line.startsWith('data: ')) data += (data ? '\n' : '') + line.slice(6); } return { event, data };}Any SSE client works — this endpoint returns a stream of typed
events, not the Vercel-flavoured chunks the AI SDK expects natively,
so you’d write a small adapter that translates our chunk events
into whatever your rendering layer consumes.
async function* saikuAskStream(body: unknown) { const res = await fetch('/rest/saiku/api/ai/ask/stream', { method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream' }, credentials: 'include', body: JSON.stringify(body), }); // Yield each delta as the caller consumes them. for await (const evt of parseSseStream(res.body!)) { if (evt.event === 'chunk') yield JSON.parse(evt.data).delta as string; }}Handy for local debugging:
curl -N -X POST -u admin:admin \ -H 'content-type: application/json' \ -H 'accept: text/event-stream' \ http://localhost:8080/rest/saiku/api/ai/ask/stream \ -d '{"question":"what is trending?","cube":{...}}'The -N disables curl’s buffering so events flush as they arrive.
Nginx / reverse proxy
Set X-Accel-Buffering: no (Saiku already sends this) and disable
proxy buffering on your reverse proxy so events flush end-to-end:
location /rest/saiku/api/ai/ask/stream { proxy_pass http://saiku:8080; proxy_buffering off; proxy_set_header Connection ''; proxy_http_version 1.1; chunked_transfer_encoding off;}What the model sees vs doesn’t
The ask layer sends the model only:
- The active cube’s
AiSchema— serialised JSON of measures, dimensions, hierarchies, levels, and sample members. - The
AiQueryRequestJSON Schema as the structured-output tool’sinput_schema. - The user’s question and any conversation history the caller attached.
The model is forced via tool_choice to call the emit_query tool
with a valid AiQueryRequest shape. Raw prose responses are rejected
as degraded.
It never sees:
- Other cubes the user can access — only the one named in the request.
- The underlying SQL or warehouse credentials.
- Conversation history of other users.
- The HTTP request, session cookies, or any header the caller sent.
This is the same isolation model the MCP server and typed AI Query API use, applied one layer earlier.
Companion endpoints
The ask endpoint sits alongside a few typed helpers on the same
/rest/saiku/api/ai/* surface. Agents use them to resolve names before
asking, run a simulation, or help an operator lock down governance.
GET /ai/members/search — resolve a member name
Before referencing a member in a query or filter, look up its exact unique name with a case-insensitive substring search. Mirrors the OSSIE-side values search.
| Query param | Required | Notes |
|---|---|---|
cubeId | yes | connection/catalog/schema/cube |
dimension | yes | Dimension name |
hierarchy | no | Hierarchy name (defaults to the dimension’s default) |
level | yes | Level to search within |
q | no | Search string; omit to page the first members |
limit | no | Max hits (default 20) |
curl -sS -u admin:admin \ 'http://localhost:8080/saiku/api/ai/members/search?cubeId=foodmart/FoodMart/FoodMart/Sales&dimension=Product&level=Product%20Family&q=drink&limit=10'# → [ { "uniqueName": "[Product].[Drink]", "caption": "Drink", ... }, … ]POST /ai/scenario/whatif — write-back simulation
Set one member’s measure to a target value, re-run the query inside a
fresh Mondrian scenario, and get actual vs what-if for every sibling
and total as the engine re-allocates the delta. The cube must declare
enableScenarios="true".
curl -sS -X POST -H 'Content-Type: application/json' \ -u admin:admin \ http://localhost:8080/saiku/api/ai/scenario/whatif \ -d '{ "connection": "foodmart", "cube": "Sales", "level": "[Product].[Product Family]", "measure": "Store Sales", "member": "[Product].[Drink]", "value": 50000, "policy": "EQUAL_ALLOCATION" }'level and member are MDX unique names; value is the new absolute
measure value for member; policy defaults to EQUAL_ALLOCATION.
GET /ai/schema/{cubeId}/pii-suggestions — governance helper
Admin-only. Scans the described cube for columns that match a PII
pattern but aren’t yet annotated pii=true, so an operator can lock
down PII redaction without grepping logs.
Deliberately separate from the agent-facing schema view — agents never
see the operator’s draft policy. Returns an empty list (not a 404) for a
cube that hasn’t been described yet.
curl -sS -u admin:admin \ http://localhost:8080/saiku/api/ai/schema/foodmart/FoodMart/FoodMart/Sales/pii-suggestions# → { "suggestions": [ { "field": "customer_email", "reason": "matches email pattern" }, … ] }Skills — admin-authored workflows
Skills are markdown files with YAML frontmatter under
saiku-home/skills/ that any /ai/ask turn can invoke by name
(/weekly-rollup for Q4) or by natural-language match. Full reference
in Agent Skills.
Agent Spaces — persona-scoped asks
Spaces are named admin-authored personas (“Sales Analyst”, “Finance
Ops”) that scope a whole ask surface — system prompt, cube allowlist,
skill allowlist, suggested prompts. POST /ai/spaces/{id}/ask uses
the same envelope as this endpoint with server-side scope enforcement
on top. Full reference in Agent Spaces.
Where to go next
- Agent Skills — admin-authored markdown workflows
discoverable from every
/ai/askturn. - Agent Spaces — personas that pin the cube allowlist, filter the skill catalogue, and inject a system prompt.
- Agent evals — YAML ground-truth harness for catching AI-ask regressions when the LLM provider, model, or prompt changes.
- AI Query API — the typed surface that
/ai/asktranslates into. Useful when you want an agent (not a human) to author the request. - MCP server — the same typed surface exposed as MCP tools for Claude Desktop / Cursor / Cline agents.
- Authentication — Bearer tokens, rate limits, error shapes.