Skip to content

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/ask does 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 history field 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 Claude
saiku.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-openai
saiku.ai.ask.endpoint = https://<resource>.openai.azure.com/openai/deployments/<deployment>/chat/completions?api-version=2024-02-15-preview
saiku.ai.ask.model = <deployment>
# env AZURE_OPENAI_API_KEY = <azure-key>
# Optional, all providers
saiku.ai.ask.model = claude-sonnet-4-7 | gpt-4o-mini | ...
saiku.ai.ask.endpoint = https://my.openai-compatible.host/v1/chat/completions
saiku.ai.ask.apiKey = ... # explicit override of the env var

Provider defaults:

ProviderDefault modelEndpoint
anthropicclaude-sonnet-4-6https://api.anthropic.com/v1/messages
openaigpt-4o-minihttps://api.openai.com/v1/chat/completions
azure-openai(no default)(no default — refuses to construct without an explicit deployment URL)

Docker

Terminal window
docker run -d --name saiku \
-e ANTHROPIC_API_KEY=sk-ant-... \
-e JAVA_OPTS='-Dsaiku.ai.ask.provider=anthropic' \
ghcr.io/spiculedata/saiku:latest

Kubernetes

Use a Secret for the key, a ConfigMap for the provider name:

apiVersion: v1
kind: Secret
metadata:
name: saiku-ai
type: Opaque
stringData:
ANTHROPIC_API_KEY: sk-ant-...
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: saiku
spec:
template:
spec:
containers:
- name: saiku
envFrom:
- secretRef:
name: saiku-ai
env:
- name: JAVA_OPTS
value: -Dsaiku.ai.ask.provider=anthropic

BYOLLM — 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.

ShapeProviderEndpoint formatAuth header
Azure OpenAI Serviceazure-openaihttps://<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)openaiany URL that speaks OpenAI’s Chat Completions APIAuthorization: Bearer <key>
AWS Bedrock via LiteLLM frontopenaiLiteLLM 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

  1. Deploy an OpenAI-compatible model to your Azure OpenAI resource. Note the resource name and deployment name.

  2. Pull a key from Azure Portal → your resource → Keys and Endpoint.

  3. Set on the launcher:

    saiku.ai.ask.provider = azure-openai
    saiku.ai.ask.endpoint = https://my-resource.openai.azure.com/openai/deployments/my-deployment/chat/completions?api-version=2024-02-15-preview
    saiku.ai.ask.model = my-deployment
    # env AZURE_OPENAI_API_KEY = <azure-api-key>
  4. 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.

litellm-config.yaml
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: true
Terminal window
litellm --config litellm-config.yaml --port 4000

Then on the Saiku side:

saiku.ai.ask.provider = openai
saiku.ai.ask.endpoint = http://litellm.internal:4000/v1/chat/completions
saiku.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 = openai
saiku.ai.ask.endpoint = https://my.vllm.internal/v1/chat/completions
saiku.ai.ask.model = mistralai/Mistral-7B-Instruct-v0.3
# env OPENAI_API_KEY = <any-value-your-server-accepts>

The endpoint

POST /saiku/api/ai/ask

Request

{
"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

HTTPWhen
200Translation succeeded; response carries the executed result (or VALIDATION_ERROR for the user to self-correct).
200 + degraded:trueTranslation failed at the provider layer — transport error, model refused, or parse error. reason carries the explanation.
400Missing question or cube in the body.
503 + degraded:trueProvider is noop (not configured) — reason explains how to enable.

Examples

Off-by-default — clear feedback

Terminal window
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

Terminal window
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

Terminal window
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."}
]
}
EOF

The 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: model
data: {"model":"claude-sonnet-4-6"}
event: intent
data: {"kind":"INSIGHT"}
event: chunk
data: {"delta":"Store "}
event: chunk
data: {"delta":"Sales trended up 12% week-on-week."}
event: final
data: {"degraded":false,"model":"claude-sonnet-4-6","insight":{"markdown":"Store Sales trended up 12% week-on-week."}}
EventWhenPayload
modelAlways fires first when the provider returned a model id{"model": "<id>"}
intentAfter tool routing, before payload{"kind": "QUERY" | "INSIGHT" | "VIEW_CHANGE"}
chunkZero or more times for prose-carrying intents (INSIGHT, VIEW_CHANGE.reason){"delta": "<word-boundary chunk>"} — concatenating deltas is lossless
finalAlways fires last on successComplete AskResponse envelope — same shape as sync /ai/ask
errorProvider 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 };
}

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 AiQueryRequest JSON Schema as the structured-output tool’s input_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 paramRequiredNotes
cubeIdyesconnection/catalog/schema/cube
dimensionyesDimension name
hierarchynoHierarchy name (defaults to the dimension’s default)
levelyesLevel to search within
qnoSearch string; omit to page the first members
limitnoMax hits (default 20)
Terminal window
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".

Terminal window
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.

Terminal window
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/ask turn.
  • 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/ask translates 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.