@saptarishi/cds-plugin-llm
v2.38.0
Published
LLM-agnostic AI platform for SAP CAP. 11 providers behind one API (Anthropic, Google Gemini, AWS Bedrock, Azure OpenAI, Ollama, Groq, Fireworks, DeepSeek, Mistral, OpenAI-compatible, SAP Generative AI Hub) with 30+ middleware primitives spanning cost/resi
Maintainers
Keywords
Readme
cds-plugin-llm
LLM-agnostic AI platform for SAP CAP. 11 providers behind one unified interface — Anthropic, Google Gemini, AWS Bedrock, Azure OpenAI, Ollama, Groq, Fireworks, DeepSeek, Mistral, any OpenAI-compatible endpoint, and SAP Generative AI Hub — plus 30+ middleware primitives spanning cost, resilience, security, observability, compliance, RAG, and CI eval.
Status: stable (v2.0.0). Formal API stability contract in force for the 2.x line — see MIGRATION.md. 11 providers implemented; 2188 automated tests on Node 20 + 22 in CI; zero known bugs, zero deprecated APIs; TypeScript definitions ship in the package.
What it is
A CAP service kind that turns cds.connect.to('llm') into a working LLM client — with one unified interface (chat, stream, embed, batch) that speaks to any of eleven backends. Swapping backends is a config change, not a code change.
Beyond the provider abstraction, the package ships a comprehensive middleware platform: cost + budget guards, cost forecasting, circuit breakers, bulkheads (with latency- and quota-driven adaptive tuners), retries, deadlines, provider fallback, region failover, prompt-injection detection, PII redaction, content-safety classifiers, structured-output validators, response scoring, multi-model consensus voting, prompt-cache metrics, response caches (exact + semantic + pluggable-vector-store), embedding dedup, JSON logs, OTel spans, Prometheus metrics, tenant isolation, distributed locks, compliance audit trails, and more. See the Middleware catalog below.
The package also ships first-class helpers for RAG orchestration (ragChain), LLM-as-judge scoring (llmJudge), CI eval harnesses (promptRegression, lintPrompt), and offline batch workflows (runBatch, waitForBatch) — plus a saptarishi-llm CLI with subcommands for chat/stream/embed/verify/init/mcp/chain-visualize/doctor/export-dashboard/lint-prompts and more.
Complementary to @cap-js/ai, which focuses on value-help recommendations and SAP AI Core integration. This plugin fills the more general "I need a CAP-idiomatic way to call LLMs, with a local development story and multiple provider options" gap.
Middleware catalog
Every primitive below is a shipping middleware (or top-level helper) with dedicated per-section documentation, tests, TypeScript types, and a config://<primitive> MCP resource for live introspection. All are stable in the 2.x line — see MIGRATION.md.
| Group | Primitives |
| --- | --- |
| Cost | usageMetering · usageMeteringToCap · costBudget · costGuard · costForecast · quotaManager (v2.23.0 — per-user USD quota with warnings) · inMemoryQuotaStore · costOverrunPredictor (v2.33.0 — calendar-window spend projection) · startOfMonth / endOfMonth (+day / quarter helpers) · adaptiveMaxTokens · estimateCost · promptCacheStats |
| Resilience | retryOnRateLimit · circuitBreaker · bulkhead · adaptiveBulkhead · adaptiveRateLimit · clientSideRateLimit (v2.26.0 — proactive N-per-window throttle) · deadline · gracePeriod (v2.28.0 — soft-deadline warnings + optional hard timeout) · chatWithFallback · regionFailover · autoRetry · providerHealthProbe · autoContinue · idempotency · distributedLock · speculativeHedge (v2.12.0 — staggered parallel hedges for tail latency) · retryBudget (v2.15.0 — SRE-style global retry cap) |
| Security | guardrails · promptInjectionGuard · piiRedact · reversibleTokenization (v2.13.0 — round-trip PII replacement) · tokenizePII / detokenizePII · PII_PATTERNS · safetyClassifier · sensitiveDataAudit · requestSigning (v2.21.0 — HMAC receipts + verifyReceiptChain) · responseSigning (v2.32.0 — HMAC responses + verifyResponseSignature) · emptyResponseDetector (v2.37.0 — catch empty/refusal responses with auto-retry) |
| Observability | jsonLog · otel · otelSpans · promMetrics · prometheusHandler · traceCorrelation · healthHandler · replayBuffer · retryAfterPropagation · providerHealthAggregate (v2.31.0 — unified provider health score) · latencyHistogram (v2.35.0 — per-dimension p50/p95/p99 with Prometheus export) |
| Testing | chaosInjector (v2.11.0 — deterministic seeded fault injection; test-only, refuses to construct without opt-in) |
| Routing | modelRouter · tenantIsolate · costAwareRouter (v2.10.0 — cheap-first with quality escalation) · fairShareScheduler (v2.14.0 — per-tenant WRR admission control) · semanticRouter (v2.16.0 — embedding-based route selection) · providerLoadBalancer (v2.17.0 — rotate across N credentials of same kind) · multimodalRouter (v2.25.0 — capability-aware routing by attachment type) |
| Caching | responseCache (exact + semantic) · semanticCache (pluggable vector store) · cosineSimilarity · embeddingDedup · requestCoalescer (in-flight dedup) · fuzzyDedup (v2.38.0 — near-duplicate detection via Jaccard-trigram / Levenshtein, no embedder required) · inMemoryFuzzyStore |
| Contract / GitOps | structuredOutputValidator · structuredOutputRepair (v2.9.0 — multi-strategy repair) · jsonAutoFix · functionCallArbitrator (v2.18.0 — tool-call allowlist + validation) · normalizeToolShape · normalizeToolList · contentLengthGate (v2.36.0 — pre-flight size validation) · defaultTokenEstimator · schemas (Invoice, PurchaseOrder, SupplierRisk, ContractSummary, ExpenseReport, EmailDraft) · validateMiddlewareOrder · chainSnapshot · chainDiff · preflight · capabilities (+ PROVIDER_CAPABILITY_MATRIX, MODEL_CAPABILITY_OVERRIDES) |
| Prompts | PromptRegistry · builtInPrompts · gitPromptRegistry (v2.1.0 — Git-backed prompt-as-code) · promptVersionPin (v2.30.0 — canary/rollback for prompt templates) · PromptVersionRegistry |
| Long-context | compactHistory · sessionContextStore (v2.19.0 — per-session history with prune / summarize) · inMemorySessionStore |
| Streaming | wrapStreamCompletion · hasStreamCompletion · streamThrottle · streamAggregator (v2.24.0 — coalesce per-token chunks for smoother UI) |
| RAG + Eval | ragChain · llmJudge / judgeMany · promptRegression / loadFixtures / formatRegressionReport · lintPrompt / lintPrompts / formatLintReport · scoreResponse · consensusVoting · promptExperiment (v2.20.0 — live A/B testing with 95% CI winner detection) · responseRevision (v2.29.0 — quality-driven re-ask loop) · userFeedbackAggregator (v2.34.0 — human thumbs/stars aggregation) |
| Bulk workflows | runBatch · waitForBatch · batchAggregator (v2.27.0 — window-based pooling of concurrent LLM calls) |
| Multimodal helpers | imageFromFile / pdfFromUrl / audioFromBase64 / uploadPdfFromUrl / etc. |
| Agent orchestration | runTools · streamTools · Agent · runAgents · streamAgents · autoToolChain (v2.22.0 — cascading tool loop with cycle detection) |
| Error taxonomy | LLMError base + errorRegistry with 18 stable codes (CIRCUIT_OPEN, BUDGET_EXCEEDED, PROMPT_INJECTION, SAFETY_CLASSIFIER_BLOCKED, ALL_REGIONS_FAILED, ...) |
Every primitive is composable via llm.use(mw) Koa-style — outermost first. resilience.bundle() wires the full resilience stack (retry → breaker → bulkhead → deadline → probes → tuner) with one call. chainSnapshot(llm) extracts the live config for GitOps drift detection; chainDiff(baseline, live) diffs; validateMiddlewareOrder(chain) warns on suspicious orderings.
Architecture
Your CAP handler / OData action
│
│ cds.connect.to('llm') → { chat, stream, embed, batch }
↓
┌─────────────────────────────────────────────────────────────┐
│ Middleware chain (llm.use(...), Koa-style, outer → inner) │
│ │
│ otelSpans / traceCorrelation / jsonLog │
│ → promptCacheStats │
│ → modelRouter │
│ → embeddingDedup │
│ → safetyClassifier │
│ → autoContinue │
│ → promptInjectionGuard │
│ → guardrails │
│ → piiRedact │
│ → sensitiveDataAudit │
│ → costGuard / costBudget │
│ → adaptiveMaxTokens │
│ → idempotency / distributedLock │
│ → circuitBreaker │
│ → bulkhead │
│ → retryOnRateLimit │
│ → responseCache │
│ → structuredOutputValidator
│ → replayBuffer │
│ → (provider) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ LLMService (base class): retries · structured-output │
│ parsing · unified chunk shape · batch API dispatch │
└───────────────────┬─────────────────────────────────────────┘
│
▼
┌─── AnthropicLLM ────┬─── AzureOpenAILLM ──┬─── GeminiLLM ─┐
│ OpenAICompatible │ GroqLLM │ BedrockLLM │
│ FireworksLLM │ DeepSeekLLM │ MistralLLM │
│ OllamaLLM │ GenAIHubLLM │ │
└─────────────────────┴─────────────────────┴───────────────┘The middleware sandwich is fully composable — every layer is optional, layer order is validated by validateMiddlewareOrder, and the full snapshot is exportable for GitOps drift detection via chainSnapshot. Each layer exposes an asMcpResource() handler for live observability.
- No CDS entities or served OData surface — this is a client library, not an OData service.
cds.connect.to('llm')returns the provider instance directly. - Provider selection at connect time via
cds.requires.llm.kind— profile-aware, so[development],[production],[genai-hub], etc. can each point at a different backend. - Provider inheritance:
GroqLLMServiceandGenAIHubLLMServiceboth extendOpenAICompatibleLLMService(they speak the OpenAI/chat/completionsshape); the latter adds OAuth + resource-group headers on top.
Install
npm install @saptarishi/cds-plugin-llmOptional peer dep for the Anthropic path: @anthropic-ai/sdk (installed automatically as a dependency).
TypeScript: full type definitions ship in the package (lib/index.d.ts). No @types/* package needed.
CLI: a saptarishi-llm executable ships with the package. Use it via npx without installing globally, or install once with npm i -g @saptarishi/cds-plugin-llm. See the CLI section.
Configure
Add to your CAP app's package.json under cds.requires:
{
"cds": {
"requires": {
"llm": {
"[development]": { "kind": "llm-groq", "modelId": "llama-3.3-70b-versatile" },
"[ollama]": { "kind": "llm-ollama", "modelId": "qwen2.5:14b" },
"[production]": { "kind": "llm-genai-hub", "credentials": { "deploymentId": "..." } }
}
}
}
}Set the appropriate env var (see .env.example):
ANTHROPIC_API_KEYforllm-anthropicOLLAMA_BASE_URLforllm-ollama(defaults tohttp://localhost:11434)GROQ_API_KEYforllm-groqOPENAI_API_KEY+OPENAI_BASE_URLforllm-openai-compatible
Providers
| Kind | Backend | Cost to test | Status |
|---|---|---|---|
| llm-anthropic | Claude via Anthropic API | Pennies per call | Working |
| llm-ollama | Local Ollama daemon | Free | Working |
| llm-groq | Groq's hosted Llama/Mixtral/Qwen (sub-second inference) | Generous free tier | Working |
| llm-openai-compatible | Any endpoint speaking OpenAI's /chat/completions (OpenAI, Together, Fireworks, DeepSeek direct, LM Studio, LocalAI...) | Varies | Working |
| llm-azure-openai | Azure OpenAI Service (per-deployment URL + api-key header) | Paid via Azure subscription | Working (mock-verified end-to-end) |
| llm-gemini (new in 1.19.0) | Google Gemini via Google AI Studio (chat, streaming, tools, vision, embeddings) | Free tier + paid per token | Working (mock-verified end-to-end) |
| llm-bedrock (new in 1.19.0) | AWS Bedrock Converse API (chat, streaming, tools, vision) + Titan/Cohere embed. Uses @aws-sdk/client-bedrock-runtime (optional peer). | Paid via AWS Bedrock | Working (mock-verified end-to-end) |
| llm-fireworks (new in 1.23.0) | Fireworks AI — hosted OSS models (Llama, Qwen, Mixtral, DeepSeek) behind an OpenAI-compatible endpoint | Pay-per-token via Fireworks | Working (mock-verified end-to-end) |
| llm-deepseek (new in 1.23.0) | DeepSeek — direct API access to DeepSeek-V3 (chat) and DeepSeek-R1 (reasoning) | Very cheap per-token | Working (mock-verified end-to-end) |
| llm-mistral (new in 1.23.0) | Mistral AI — Mistral Large, Codestral, and the open-weights family | Pay-per-token via Mistral | Working (mock-verified end-to-end) |
| llm-genai-hub | SAP AI Core / Generative AI Hub | Paid (extended plan) | Spec-compliant · mock-verified end-to-end · live verify open (community help welcome) |
Stability
- Semantic Versioning.
1.xwill preserve the public API contract documented inlib/index.d.ts. Breaking changes require a major version bump. - What's covered: all exported symbols in
lib/index.js—LLMServiceand 5 provider subclasses,chat/stream/embedshapes,ContentBlockunion (text / image / document), message shapes, tool call shapes, stream chunk shapes, image / PDF helpers, and provider option fields (kind,modelId,credentials,retries,responseCache). - What's not covered: provider-native
rawresponse objects (shape follows the upstream API), CDS kind config field names (which follow CAP conventions), and behavior of individual model IDs (upstream provider concern). - Deprecation policy: any deprecated field or method will be marked in the JSDoc + CHANGELOG for at least one minor release before removal, and only removed in a subsequent major version.
- Full history in
CHANGELOG.md.
Use
Standard CAP idiom — cds.connect.to():
const cds = require('@sap/cds');
module.exports = class ProcurementService extends cds.ApplicationService {
async init() {
const llm = await cds.connect.to('llm');
this.on('summarizePO', async (req) => {
const { poId } = req.data;
const po = await SELECT.one.from('PurchaseOrders').where({ ID: poId });
const { text } = await llm.chat({
system: 'You summarize purchase orders for approvers in 2 sentences.',
messages: [{ role: 'user', content: JSON.stringify(po) }],
cache: true, // Anthropic-only: caches the system prompt
});
return text;
});
return super.init();
}
};Structured outputs (new in v0.3.0)
Pass a JSON schema via format and get a parsed .data field back:
const { data, usage } = await llm.chat({
system: 'Assess supplier invoice risk for AP triage.',
messages: [{ role: 'user', content: invoiceJson }],
format: {
type: 'object',
properties: {
risk: { type: 'string', enum: ['low', 'medium', 'high'] },
rationale: { type: 'string' },
},
required: ['risk', 'rationale'],
additionalProperties: false,
},
});
console.log(data.risk); // 'high'
console.log(data.rationale); // 'Amount over 100k EUR without matched PO...'Under the hood:
- Anthropic: uses
output_config.format(native JSON schema) - OpenAI-compatible / Groq: uses
response_format: { type: 'json_object' }and prepends the schema to the system prompt for broadest model coverage - Ollama: uses native
formatfield (schema-strict on recent Ollama versions) - Base class post-parses
.textinto.datauniformly; falls back to first-{...}-block extraction if the model wrapped the JSON in prose
Tool use / function calling (new in v0.3.0)
Pass a unified tool schema; get normalized toolCalls back:
const turn1 = await llm.chat({
system: 'Help procurement approvers. Use tools to fetch data.',
messages: [{ role: 'user', content: 'Fetch PO 4500000123' }],
tools: [{
name: 'get_purchase_order',
description: 'Fetch a purchase order by its 10-digit ID',
input_schema: {
type: 'object',
properties: { purchaseOrderId: { type: 'string' } },
required: ['purchaseOrderId'],
},
}],
});
if (turn1.toolCalls?.length) {
const call = turn1.toolCalls[0]; // { id, name, input }
const result = await fetchPO(call.input.purchaseOrderId); // your app logic
// Feed the result back for turn 2
const turn2 = await llm.chat({
system: '...',
messages: [
{ role: 'user', content: 'Fetch PO 4500000123' },
{ role: 'assistant', toolCalls: turn1.toolCalls },
{ role: 'tool', tool_call_id: call.id, content: JSON.stringify(result) },
],
tools: [...],
});
console.log(turn2.text); // model's final answer
}Works across providers with matching { id, name, input } shape. Individual model quality varies for multi-tool scenarios — llama-3.3-70b on Groq is solid for single-tool cases; Claude and qwen2.5 are more reliable for chained tool use.
Tool runner — automatic multi-turn loop (new in v1.1.0)
For agent-style code that would otherwise write the "call → execute → feed back → repeat" loop by hand, runTools() wraps the pattern:
const { runTools } = require('@saptarishi/cds-plugin-llm');
const result = await runTools({
llm,
system: 'You help procurement approvers.',
messages: [{ role: 'user', content: 'Fetch PO 4500000123 and summarize it.' }],
tools: [{
name: 'get_purchase_order',
description: 'Fetch a PO by 10-digit ID',
input_schema: {
type: 'object',
properties: { purchaseOrderId: { type: 'string' } },
required: ['purchaseOrderId'],
},
run: async ({ purchaseOrderId }) =>
await SELECT.one.from('PurchaseOrders').where({ ID: purchaseOrderId }),
}],
maxSteps: 10,
});
result.text // final assistant answer
result.steps // number of chat() calls made
result.toolCalls // [{ id, name, input, result, isError }, ...] — every call executed
result.usage // aggregated tokens across all turnsWhat it handles for you:
- Executes every tool call in every turn (multiple in one turn all run)
- Appends assistant +
toolmessages correctly (matches the format both Anthropic and OpenAI-compat expect) - Catches tool exceptions and surfaces them as
tool_resultwithis_error: trueso the model can recover - Rejects with a clear message when an unknown tool name is called
maxStepssafety cap (default 10) — throws if the model loops forever- Optional
onStep({ step, response })callback to observe each turn
Streaming (new in v0.6.0)
Get tokens as they arrive from the model instead of waiting for the full response:
for await (const chunk of llm.stream({
system: 'You are a poet.',
messages: [{ role: 'user', content: 'Write a haiku about SAP procurement.' }],
})) {
if (chunk.type === 'text_delta') {
process.stdout.write(chunk.text);
}
if (chunk.type === 'done') {
console.log(`\n[${chunk.usage.output_tokens} tokens, stopReason: ${chunk.stopReason}]`);
}
}Chunk types:
| type | payload | when |
|---|---|---|
| text_delta | { text } — incremental piece of text | fires per token/token-group as the model generates |
| done | { text, usage, stopReason, model } — accumulated text + final metadata | once at the end |
Wire-shape parsing per provider:
- Anthropic: uses the SDK's
messages.stream()— SSE under the hood, events adapted to unified chunks - OpenAI-compatible / Groq / GenAI Hub: parses SSE (
data: {json}\n\n) from the/chat/completionsstreaming endpoint, addsstream_options: {include_usage: true}sousagepopulates on thedonechunk - Ollama: parses NDJSON from
/api/chat, emitsdonewhen the stream's final message carriesdone:true
Retries are not applied to streams (partial-response semantics are unclear). If a stream fails mid-way, the caller sees the error thrown from the generator.
Try the demo:
node scripts/stream-demo.js "Explain streaming LLM responses in 3 sentences."Embeddings (expanded in v0.7.0)
const { embeddings } = await llm.embed({
input: ['first document', 'second document', 'third'],
model: 'text-embedding-3-small', // optional; falls back to configured modelId
});
// embeddings is number[][] — one vector per input stringSupported providers:
- Ollama —
mxbai-embed-large,nomic-embed-text,all-minilm, any embedding model you've pulled - OpenAI-compatible (including Groq, Together AI, DeepSeek, LM Studio) —
text-embedding-3-small,text-embedding-3-large,text-embedding-ada-002, provider-specific models - Anthropic: not supported (no first-party embeddings)
- GenAI Hub: needs a separate embedding-model deployment; not yet plumbed (planned for 0.9)
Single string or array of strings both work. Returns { embeddings: number[][], model: string } — the outer array always matches the input length.
Vision / multimodal input (new in v0.5.0)
Pass images inline as content blocks. Works across all providers with vision-capable models (Claude 3.5+, GPT-4o, Groq's llama-3.2-*-vision, Ollama's llava / moondream / llama3.2-vision).
const { imageFromFile, imageFromUrl, imageFromBase64 } = require('@saptarishi/cds-plugin-llm');
// Load from disk
const image = await imageFromFile('/tmp/scanned-invoice.png');
// Or from a URL (Anthropic + OpenAI-compat; Ollama needs base64)
const image = imageFromUrl('https://example.com/invoice.png');
// Or from base64 data you already have
const image = imageFromBase64(base64Data, 'image/png');
const { data } = await llm.chat({
model: 'gpt-4o', // or claude-opus-4-7, llama-3.2-11b-vision-preview, llava, ...
system: 'Extract structured data from scanned invoices.',
messages: [{
role: 'user',
content: [
image,
{ type: 'text', text: 'Return the vendor, invoice number, and line items.' },
],
}],
format: {
type: 'object',
properties: {
vendor: { type: 'string' },
invoiceNumber: { type: 'string' },
lineItems: {
type: 'array',
items: {
type: 'object',
properties: {
description: { type: 'string' },
quantity: { type: 'number' },
unitPrice: { type: 'number' },
},
},
},
},
},
});Wire-shape translation is provider-aware:
- Anthropic: native content blocks (source can be
urlorbase64) - OpenAI-compatible / Groq:
image_urlblocks with data URLs for base64 - Ollama: text goes in
content, images extracted toimages: [base64, ...](Ollama does not accept URLs — useimageFromFile()orimageFromBase64())
PDF documents (v0.8.0 · expanded in v0.9.0)
Pass PDF documents inline. Full native support on Anthropic (Claude 3.5+ parses text + visuals in one pass). Since v0.9.0 OpenAI-compat providers accept base64 PDFs too via the file content-block shape — works on GPT-4o and newer OpenAI models. Groq and other OpenAI-compat providers that don't accept files will 400 upstream.
const { pdfFromFile, pdfFromUrl, pdfFromBase64 } = require('@saptarishi/cds-plugin-llm');
const pdf = await pdfFromFile('/tmp/scanned-invoice.pdf');
// or: const pdf = pdfFromUrl('https://example.com/invoice.pdf');
// or: const pdf = pdfFromBase64(base64Data);
const { data } = await llm.chat({
model: 'claude-opus-4-7',
system: 'Extract structured data from scanned invoices.',
messages: [{
role: 'user',
content: [
pdf,
{ type: 'text', text: 'Return vendor, invoice number, line items.' },
],
}],
format: { /* JSON schema */ },
});Provider notes:
- Anthropic — native, both base64 and URL sources
- OpenAI-compat (GPT-4o+) — base64 works out of the box. URL PDFs (new in v1.14.0) via the Files API: call
uploadPdfFromUrl(url, {apiKey})first — it fetches, uploads to/v1/files, and returns a document block withsource.type='file_id'that the provider translates to{type:'file', file:{file_id}}. - Groq / other OpenAI-compat that don't accept files — base64 will 400 upstream;
/v1/filesisn't exposed souploadPdfFromUrlwill also 404 there - Ollama — no PDF support; render pages to images via
pdftoppm(poppler) and pass to a vision model likellavaorllama3.2-vision
URL PDFs on OpenAI (v1.14.0+)
const { uploadPdfFromUrl } = require('@saptarishi/cds-plugin-llm');
const doc = await uploadPdfFromUrl('https://example.com/contract.pdf', {
apiKey: process.env.OPENAI_API_KEY,
// baseUrl: 'https://api.openai.com/v1' (default)
// purpose: 'user_data' (default)
// filename: <inferred from URL basename, .pdf appended if missing>
});
await openai.chat({
messages: [{ role: 'user', content: [doc, { type: 'text', text: 'Summarize' }] }],
});The returned document block has source: { type: 'file_id', file_id: 'file-xxx' }. Reuse the block across multiple chat calls to avoid re-uploading. OpenAI retains uploaded files by account — bring your own retention policy.
SAP Generative AI Hub setup
The llm-genai-hub kind targets a deployment in your BTP AI Core instance. Prerequisites:
- Provision AI Core — BTP Cockpit → Service Marketplace → AI Core → extended plan (free plan does not include Generative AI Hub).
- Create a resource group (or use
default). - Deploy a model via SAP AI Launchpad,
ai-api-cli, or the SDK — e.g.gpt-4o,mistral-large-instruct,claude-3-5-sonnet. Note the deployment ID. - Configure the plugin — three ways depending on where your CAP app runs.
On BTP Cloud Foundry (recommended)
Bind the AI Core service instance to your CAP app:
cf bind-service <your-app> <ai-core-instance>
cf restage <your-app>Then set only the deployment ID (credentials auto-discovered from VCAP_SERVICES):
cf set-env <your-app> AICORE_DEPLOYMENT_ID <deployment-id>
cf set-env <your-app> AICORE_RESOURCE_GROUP default # optional; defaults to 'default'In package.json:
{
"cds": { "requires": { "llm": {
"[production]": { "kind": "llm-genai-hub", "modelId": "gpt-4o" }
}}}
}On Kyma
Attach the service binding manifest, then set the same env vars via a ConfigMap or Secret. The VCAP_SERVICES layout is preserved by the SBO (Service Binding Operator).
Local dev pointing at a BTP-hosted AI Core
Extract the service key JSON from BTP Cockpit (Service Instance → Service Keys → View). Put values in .env:
AICORE_API_URL=https://api.ai.prod.eu-central-1.aws.ml.hana.ondemand.com
AICORE_AUTH_URL=https://<subaccount>.authentication.<region>.hana.ondemand.com
AICORE_CLIENT_ID=sb-...
AICORE_CLIENT_SECRET=...
AICORE_DEPLOYMENT_ID=abc123 # chat model deployment
AICORE_EMBEDDING_DEPLOYMENT_ID=def456 # optional; enables llm.embed()
AICORE_MODEL=gpt-4oOr pass explicitly in package.json:
{
"cds": { "requires": { "llm": {
"[genai-hub]": {
"kind": "llm-genai-hub",
"modelId": "gpt-4o",
"credentials": {
"aiCoreUrl": "https://api.ai.prod.eu-central-1.aws.ml.hana.ondemand.com",
"tokenUrl": "https://<subaccount>.authentication.<region>.hana.ondemand.com",
"clientId": "sb-...",
"clientSecret": "...",
"deploymentId": "abc123",
"resourceGroup": "default"
}
}
}}}
}What it handles for you
- OAuth2 client-credentials flow against XSUAA
- Token caching + refresh (60s before expiry)
AI-Resource-Groupheader- Deployment-based inference endpoint construction
VCAP_SERVICES.aicoreauto-discovery when the service is bound
Known limitations (v0.4.0)
- OpenAI-shape only. Deployments that expose the OpenAI
/chat/completionsshape (GPT, Mistral, Llama, Gemini, and Anthropic-via-shim) work. Native Anthropic-shape deployments (Claude via/invoke) are not yet supported — use thellm-anthropickind directly for Claude. - Not yet live-verified. Built to the SAP-documented API contract and unit-tested against mocks. Live verification against an AI Core
extendeddeployment is the next contribution wanted.
Automatic retries (new in v0.3.0)
Every chat() and embed() call is wrapped with exponential-backoff retry on 429 / 5xx responses. Honors Retry-After headers. Configurable per-call or globally:
// Per-call override
await llm.chat({ messages: [...], retries: { max: 5, baseMs: 1000, maxMs: 30000 } });
// Or via cds.requires.llm config:
{ "cds": { "requires": { "llm": {
"kind": "llm-groq",
"retries": { "max": 5 }
}}}}Middleware / interceptors (new in v1.2.0)
Register hooks around every chat / stream / embed call. Koa-style compose — outermost first, next() returns the next middleware's result (or the provider's response). Middleware may inspect or transform the request AND the response, share state via ctx.meta, or short-circuit by returning without calling next().
const llm = await cds.connect.to('llm');
// 1. Logging + duration
llm.use(async (ctx, next) => {
const start = Date.now();
const res = await next();
console.log(`[${ctx.method}] ${Date.now() - start}ms`);
return res;
});
// 2. Cost tracking (aggregate tokens across every call)
const totals = { in: 0, out: 0 };
llm.use(async (ctx, next) => {
const res = await next();
totals.in += res?.usage?.input_tokens ?? 0;
totals.out += res?.usage?.output_tokens ?? 0;
return res;
});
// 3. Auto-injected system prompt suffix
llm.use(async (ctx, next) => {
if (ctx.method === 'chat' && ctx.request.system) {
ctx.request.system += '\n\nBe concise. If uncertain, say so.';
}
return next();
});
// 4. Streams: wrap the iterator to observe each chunk
llm.use(async (ctx, next) => {
if (ctx.method !== 'stream') return next();
const inner = await next();
return (async function* () {
for await (const chunk of inner) {
if (chunk.type === 'text_delta') myLiveUI.append(chunk.text);
yield chunk;
}
})();
});Context object:
ctx.method—'chat'|'stream'|'embed'ctx.request— mutable request options (modify beforenext()to affect the provider call)ctx.meta— scratchpad for cross-middleware state (e.g. timing marks, request IDs)
Notes:
- Middleware runs around the response cache, retries, and format-parsing — those are internal concerns your middleware can observe. Cache hits arrive at your middleware with
cached: trueset. - Calling
next()more than once from the same middleware throws. - Errors propagate up the chain.
- For streams,
next()returns an async iterable. To observe/transform chunks, wrap it into a new async generator.
Built-in middleware (new in v1.3.0)
Two production-oriented middlewares ship in the box: rate limiting and OpenTelemetry tracing.
rateLimit — token-bucket limiter
const { rateLimit } = require('@saptarishi/cds-plugin-llm');
const llm = await cds.connect.to('llm');
// Global: 60 requests burst, refill at 1/s
llm.use(rateLimit({ capacity: 60, refillPerSecond: 1 }));
// Per-user: keyed off ctx.meta.user (populate this from an earlier middleware
// that inspects your CAP request)
llm.use(rateLimit({
capacity: 10,
refillPerSecond: 0.2,
keyFn: (ctx) => ctx.meta.user ?? 'anon',
mode: 'wait', // 'throw' (default) or 'wait' — pause instead of erroring
}));When mode: 'throw' and the bucket is empty, the middleware throws an Error with code: 'RATE_LIMITED' and retryAfterMs so you can surface a proper 429 to your caller. Buckets are in-process — for multi-instance CF apps that need a shared counter, back with Redis via your own middleware.
otel — OpenTelemetry spans
const { trace } = require('@opentelemetry/api');
const { otel } = require('@saptarishi/cds-plugin-llm');
llm.use(otel({
tracer: trace.getTracer('cap-app'),
systemAttribute: 'anthropic', // sets gen_ai.system on every span
}));Emits one span per chat / stream / embed call. Attributes follow the emerging GenAI semantic conventions where possible: gen_ai.system, gen_ai.operation.name, gen_ai.request.model, gen_ai.response.model, gen_ai.usage.input_tokens / output_tokens, gen_ai.response.stop_reason. Plus a few plugin-specific ones: llm.cached, llm.tool_calls.count, llm.stream.chunks, llm.embed.count. Duck-typed against @opentelemetry/api — no hard dependency, works with any object exposing startSpan().
Stream spans stay open through the whole iterator (span ends on the done chunk, on early break, or on error — never leaks).
redisRateLimit — shared bucket across CF instances (new in v1.4.0)
The in-process rateLimit is fine for single-instance apps. For multi-instance CF deployments where a shared quota must be enforced globally, back the bucket with Redis.
const Redis = require('ioredis');
const { redisRateLimit } = require('@saptarishi/cds-plugin-llm');
llm.use(redisRateLimit({
redis: new Redis(process.env.REDIS_URL),
capacity: 60,
refillPerSecond: 1,
keyFn: (ctx) => ctx.meta.user ?? 'anon',
keyPrefix: 'ratelimit:llm:', // default 'saptarishi:llm:rl:'
mode: 'throw', // 'throw' | 'wait'
}));Uses an atomic Lua EVAL so two instances checking the bucket at the same time cannot both succeed when only one token is left. Duck-typed client — any object with an eval(script, numKeys, ...args) promise API satisfies (works with ioredis and node-redis v4+ out of the box). On BTP, bind a Redis service to your CF app and pull the URL from VCAP_SERVICES.
CLI (new in v1.5.0)
A saptarishi-llm executable ships with the package. Handy for provider health checks in CI, quick prompt experiments from the shell, pipelining embeddings into a downstream tool, or scaffolding a fresh CAP project pre-wired to the plugin (v1.6.0).
npx @saptarishi/cds-plugin-llm --help
# or install globally
npm install -g @saptarishi/cds-plugin-llm
saptarishi-llm --helpCommands
saptarishi-llm chat -p "explain SAP CAP in one sentence"
saptarishi-llm stream -p "write a haiku about procurement"
saptarishi-llm embed -p "purchase order for steel coils" --json
saptarishi-llm verify --provider anthropic
saptarishi-llm providersProvider selection
--provider <kind> or SAPTARISHI_LLM_PROVIDER env var. Same five kinds as the CAP plugin: anthropic, ollama, groq, openai-compatible, genai-hub.
Credentials come from env vars (never CLI flags — avoids leaking secrets into shell history):
ANTHROPIC_API_KEY=sk-ant-... saptarishi-llm chat -p "hello"
OLLAMA_URL=http://192.168.5.13:11434 saptarishi-llm chat --provider ollama -p "hello"
GROQ_API_KEY=gsk-... saptarishi-llm verify --provider groqInput sources
Prompt can come from --prompt / -p, --file / -f, stdin, or a positional arg. Multiple sources concatenate with a blank line between them.
echo "summarize this" | saptarishi-llm chat -f contract.pdf.txt
saptarishi-llm embed -p "one\n---\ntwo\n---\nthree" # 3 vectors from 1 callCI health checks
verify connects, runs a tiny probe, reports latency, and exits 0 on success / 1 on unexpected reply / 1 on error. Drop it in a nightly workflow to catch expired credentials or endpoint outages before your CAP app does.
saptarishi-llm verify --provider genai-hub --jsonExpose as an MCP server (new in v1.7.0)
saptarishi-llm mcp runs a Model Context Protocol server over stdio that exposes the configured provider as tools any MCP client can call. Register it in Claude Desktop, Cursor, Zed, or any other MCP-capable client and those clients gain a chat / embed / verify / list_providers tool backed by your provider config — with all its middleware, caching, rate limits, and OTel tracing.
The point: one MCP server = "the sanctioned way to call an LLM at MyCompany". Centralized credentials, centralized policy, developer productivity everywhere.
Claude Desktop (~/Library/Application Support/Claude/claude_desktop_config.json on macOS):
{
"mcpServers": {
"saptarishi-llm": {
"command": "npx",
"args": ["-y", "@saptarishi/cds-plugin-llm", "mcp"],
"env": {
"SAPTARISHI_LLM_PROVIDER": "groq",
"GROQ_API_KEY": "gsk-..."
}
}
}
}Tools exposed:
| Tool | Purpose |
|------|---------|
| chat | Send a prompt, return text. { prompt, system?, maxTokens? } |
| embed | Embed input(s) into vectors. { input: string \| string[] } |
| verify | Tiny probe against the provider. Returns { ok, latencyMs, model, text }. |
| list_providers | Enumerate every supported provider kind with default models. |
Uses a hand-rolled MCP implementation (2024-11-05 spec) over stdio JSON-RPC 2.0 — zero new dependencies. Full protocol coverage: initialize, tools/list, tools/call, resources/list, resources/read, resources/subscribe, resources/unsubscribe, prompts/list, prompts/get, ping, notifications. Tool errors surface as result.isError: true per spec so the model can recover, not as JSON-RPC errors.
Resources exposed (readable via resources/read):
| URI | Description |
|-----|-------------|
| config://active-provider | Current provider kind + model + middleware count. |
| config://supported-providers | Every provider kind + default model. |
Prompts registered (invokable via prompts/get) — see Prompt-template registry below for the full built-in list.
Resource templates (new in v1.9.0; parametrized URIs discoverable via resources/templates/list):
| URI template | What clients can read |
|---|---|
| provider://{kind} | Default model for a specific provider kind (provider://groq → { kind: 'groq', defaultModel: 'llama-3.3-70b-versatile' }). |
| prompt://{name} | Metadata (arguments, description) for a registered prompt template. To render, use prompts/get. |
Scaffold a fresh CAP project (new in v1.6.0)
init creates a fully-wired CAP app in seconds — no manual package.json editing, no CDS boilerplate:
npx @saptarishi/cds-plugin-llm init joule-demo --provider groq
cd joule-demo
cp .env.example .env # then fill in real credentials
npm install
cds watchThen:
curl 'http://localhost:4004/ai/chat(prompt='"'"'hello'"'"')'Generated:
joule-demo/
├── package.json # cds.requires.llm pointing at chosen provider
├── srv/
│ ├── ai-service.cds # service AIService { chat(prompt), summarize(text) }
│ └── ai-service.js # handlers using cds.connect.to('llm')
│ # + SSE streaming endpoint at POST /stream/chat
├── .env.example # provider-specific env vars
├── .gitignore # excludes .env, node_modules/, gen/
└── README.md # how to runStreaming out of the box (v1.9.0+): the generated srv/ai-service.js registers POST /stream/chat inline, streaming tokens as they arrive:
curl -N -X POST http://localhost:4004/stream/chat \
-H 'content-type: application/json' \
-d '{"prompt":"write a haiku about SAP CAP"}'Flags:
--provider <kind>—anthropic(default) |ollama|groq|openai-compatible|genai-hub--model <id>— override the default model for the chosen provider--force— overwrite a non-empty target directory--dry-run— print the file list without writing anything
Prompt-template registry (new in v1.8.0)
Register named prompt templates once, invoke them by name from any CAP handler, and automatically expose them over MCP so external clients (Claude Desktop, Cursor, Zed) can invoke them too. Same registry, three surfaces.
const { PromptRegistry, builtInPrompts } = require('@saptarishi/cds-plugin-llm');
const prompts = new PromptRegistry()
.registerAll(builtInPrompts()) // 5 built-ins
.register({ // your own
name: 'invoice_dispute_response',
description: 'Draft a courteous response to an invoice dispute.',
arguments: [
{ name: 'dispute', required: true, description: 'The dispute text' },
{ name: 'tone', required: false, description: 'formal | friendly' },
],
render: ({ dispute, tone = 'formal' }) => ({
system: `You are AP support. Reply in a ${tone} tone. Never admit liability.`,
messages: [{ role: 'user', content: dispute }],
}),
});
// From a CAP handler:
const req = prompts.render('invoice_dispute_response', { dispute });
const res = await llm.chat({ ...req, maxTokens: 512 });Built-ins (builtInPrompts()):
| Name | Purpose |
|------|---------|
| summarize | Condense text to N sentences (text, sentences?). |
| extract_json | Extract structured JSON against a schema (text, schema). |
| classify | Assign one label from a set (text, labels). |
| translate | Translate to a target language (text, targetLanguage). |
| procurement_risk_scorer | SAP-flavored risk analyst prompt (text). |
Templates auto-appear as MCP prompts when you run saptarishi-llm mcp. Claude Desktop shows them as slash-commands the user can pick.
Load templates from a folder (new in v1.9.0)
Instead of registering templates inline, drop *.mjs or *.js files into a directory and load them at boot:
prompts/
├── invoice_dispute.mjs # export default { name, render, ... }
├── kpi_extractor.mjs # export default { ... }
└── shared.mjs # export const foo = ...; export const bar = ...await registry.loadFromDir('./prompts');Or expose the whole folder via MCP without writing any code:
saptarishi-llm mcp --prompts-dir ./prompts
# or: SAPTARISHI_LLM_PROMPTS_DIR=./prompts saptarishi-llm mcpThree export conventions handled in one scan: export default <template>, export default [<t1>, <t2>], or named exports.
Add --watch-prompts and the server hot-reloads templates when files change — iterate without restart (new in v1.10.0):
saptarishi-llm mcp --prompts-dir ./prompts --watch-promptsGit-backed prompt registry (new in v2.1.0)
gitPromptRegistry extends PromptRegistry with a loader that pulls templates from a Git repository, caches them locally, and optionally polls for updates. Prompt changes go through PR review separately from code deploys — a real prompt-as-code workflow.
const { gitPromptRegistry } = require('@saptarishi/cds-plugin-llm');
const registry = await gitPromptRegistry({
url: 'https://github.com/your-org/prompts.git',
branch: 'main',
subdir: 'templates', // optional — load only a subfolder
pollMs: 60_000, // poll every 60s for new commits
onUpdate: (info) => cds.log('llm:prompts').info('reloaded', info),
});
// Same API as PromptRegistry:
const req = registry.render('invoice_dispute_response', { dispute });- Exposes
config://git-prompt-registry(URL, branch, current SHA, refresh timestamp, counters) — live-visible in your MCP client. - Pin to a specific
ref(SHA / tag) for immutable prompts in prod; usebranchfor rolling updates in staging. - Works with any prompt directory the shipped
loadFromDirscanner accepts.
HTTP+SSE transport (new in v1.10.0)
By default saptarishi-llm mcp speaks stdio (for Claude Desktop / Cursor / Zed as a local subprocess). Add --http and it runs as a network service instead — the same MCP server, addressable over HTTP. Deploy to CF, put behind an auth proxy, share with a team.
saptarishi-llm mcp --http --port 3333 --host 0.0.0.0
# → MCP HTTP+SSE listening on http://0.0.0.0:3333/sse
# → GET /health for liveness/session countWire protocol (MCP 2024-11-05 SSE spec):
| Method | Path | Purpose |
|---|---|---|
| GET | /sse | Client opens SSE stream. Server sends event: endpoint\ndata: /messages?sessionId=<uuid>. Server replies to that session flow back on this stream. |
| POST | /messages?sessionId=<uuid> | Client sends a single JSON-RPC message. Server acknowledges 202; reply arrives on the SSE stream. |
| GET | /health | { server, version, transport, sessions } — plug into monitoring. |
Multi-session — N concurrent clients each get their own session and stream. Graceful shutdown on SIGINT/SIGTERM.
Bearer-token auth (new in v1.11.0)
Deploying --http to anything beyond 127.0.0.1 should require a credential. Set --auth-token (or SAPTARISHI_LLM_MCP_TOKEN env) and every /sse and /messages request must send Authorization: Bearer <token>:
saptarishi-llm mcp --http --host 0.0.0.0 --port 3333 \
--auth-token "$(openssl rand -hex 32)"Behavior:
- Missing / wrong token →
401 UnauthorizedwithWWW-Authenticate: Bearer realm="mcp". /healthstays public so load balancers can probe without credentials. Its response now includesauthRequired: <bool>so clients know whether they need a token.- Constant-time comparison on the byte-by-byte match to avoid trivial timing side-channels.
- Binding to a non-loopback host without a token prints a loud stderr warning — no hard failure (upstream proxies may legitimately terminate auth), but the intent is impossible to miss.
Client-side example (curl):
curl -N -H 'Authorization: Bearer <token>' http://host:3333/sseJWT / JWKS validation (new in v1.16.0)
For real enterprise deployments, validate signed JWTs against your IdP's JWKS endpoint. Works with SAP XSUAA, Auth0, Okta, Azure AD, Google, Keycloak, AWS Cognito, and any other standards-compliant IdP.
npm install jose # optional peer dep, only needed for JWT auth
saptarishi-llm mcp --http --host 0.0.0.0 \
--jwks-url https://tenant.authentication.us10.hana.ondemand.com/token_keys \
--jwt-issuer https://tenant.authentication.us10.hana.ondemand.com \
--jwt-audience sb-my-cap-app!t12345Behavior:
- Each request's
Authorization: Bearer <jwt>is verified against the JWKS (signature,exp,iat,nbf, optionaliss/aud). - JWKS cached + auto-refreshed on unknown
kidbyjose. - Any failure — bad signature, expired, wrong issuer, wrong audience, unknown key — collapses to a uniform 401. Never leaks which check failed (avoids oracle attacks; same rationale as bearer-token constant-time compare).
On BTP with an XSUAA service binding, use the values from VCAP_SERVICES.xsuaa[0].credentials:
--jwks-url <url>/token_keys(where<url>= credentials.url)--jwt-issuer <url>--jwt-audience credentials.xsappname
Programmatic use — build your own verifier for custom flows (introspection endpoints, mTLS metadata → role mapping, JWT + local RBAC check):
const { createHttpTransport, createJwtVerifier } = require('@saptarishi/cds-plugin-llm');
const jwtVerifier = createJwtVerifier({ jwksUrl, issuer, audience });
createHttpTransport({
server,
authTokenVerifier: async (token) => {
const claims = await jwtVerifier(token);
if (!claims) return null;
// Extra check: reject unless the client has the 'llm-user' scope
if (!claims.scope?.includes('llm-user')) return null;
return claims;
},
});Progress notifications (new in v1.12.0)
Long-running tools can push notifications/progress back to the client instead of leaving them hanging silently. Tool handlers grow an optional second argument — a ctx object with reportProgress(current, total?):
server.registerTool({
name: 'batch_summarize',
description: 'Summarize N documents',
inputSchema: { type: 'object', properties: { docs: { type: 'array', items: { type: 'string' } } } },
handler: async ({ docs }, ctx) => {
const summaries = [];
for (let i = 0; i < docs.length; i++) {
summaries.push(await llm.chat({ /* ... */ }));
ctx.reportProgress(i + 1, docs.length);
}
return summaries;
},
});When the client calls this tool with _meta.progressToken: 'x', each reportProgress(...) becomes a server-pushed notification carrying that token. Delivered on both transports — stdio writes to the same stdout; HTTP+SSE pushes on the requesting session's SSE stream. Existing tools that ignore the second arg keep working (backwards-compatible).
Resource subscriptions (new in v1.17.0)
Complements the list-changed notifications from v1.13.0 with per-URI push updates. Use list_changed when the set of resources shifts, resources/subscribe when a specific resource's content changes.
// client -> server
{ "jsonrpc": "2.0", "id": 1, "method": "resources/subscribe",
"params": { "uri": "prompt://summarize" } }
// server -> client (later, when that prompt is hot-reloaded)
{ "jsonrpc": "2.0", "method": "notifications/resources/updated",
"params": { "uri": "prompt://summarize" } }- Per-connection state — session A subscribing never affects session B. Cleaned up when the SSE stream / stdio pipe closes.
- Both transports — stdio writes updates to stdout on the reply stream; HTTP+SSE pushes to the subscribing session's stream only.
- Unknown URIs rejected at subscribe time so client typos surface immediately. Matches both static resources and template URIs (
prompt://{name},provider://{kind}). resources/unsubscribeis idempotent — safe to call for URIs you never subscribed to.
--watch-prompts now fires resources/updated for every subscribed prompt://<name> URI on hot-reload (in addition to the existing prompts/list_changed broadcast). Pin a client to a specific prompt and every edit refreshes without a resources/read.
For third-party transports / programmatic MCP setups, MCPServer exposes:
notifyResourceUpdated(uri)— broadcast to every subscriber ofuri. Silent no-op with none.subscribedUris(prefix?)— distinct URIs any connected client subscribed to, optionally prefix-filtered.
Per-session provider overrides (new in v1.18.0)
One saptarishi-llm mcp process, multiple named provider configs behind a single MCP endpoint. Clients pick which one to use per session or per tool call. Real enterprise use: one authenticated endpoint, different agents hit different backends — DevOps on cheap, compliance reviewer on smart, local dev on local. Credentials centralized server-side; agents never touch API keys.
saptarishi-llm mcp --http --host 0.0.0.0 \
--providers-config ./providers.json// providers.json — chmod 600 and keep out of git
{
"cheap": {
"kind": "groq",
"model": "llama-3.1-8b-instant",
"credentials": { "apiKey": "gsk_..." }
},
"smart": {
"kind": "anthropic",
"model": "claude-opus-4-7",
"credentials": { "apiKey": "sk-ant-..." }
},
"local": {
"kind": "ollama",
"model": "qwen2.5:14b",
"credentials": { "baseUrl": "http://localhost:11434" }
}
}Resolution order per tool call: arguments.provider → sessionState.provider (from initialize._meta.provider) → top-level default.
Session default — client declares once at initialize; every subsequent call on that session uses it:
{ "jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": { "protocolVersion": "2024-11-05",
"_meta": { "provider": "smart" } } }Per-call override — bypass the session default one call at a time:
{ "jsonrpc": "2.0", "id": 2, "method": "tools/call",
"params": { "name": "chat",
"arguments": { "prompt": "dispatch this", "provider": "cheap" } } }Discovery — list_providers tool and config://providers resource both dump the alias list (kind + model per alias; credentials never returned). Unknown aliases surface as tool-result errors with the configured list so the model can self-correct on the next turn.
Boot-time validation — every alias is instantiated + init()'d at server startup. Bad credentials or unknown kinds die on saptarishi-llm mcp, not on the first client call.
Response caching (new in v0.9.0)
Opt-in per-instance LRU cache with TTL. Skips tool-use calls (side-effects) and streaming (partial responses). Hits return the same ChatResponse shape with cached: true set.
{
"cds": { "requires": { "llm": {
"kind": "llm-groq",
"modelId": "llama-3.3-70b-versatile",
"responseCache": true // defaults: 5min TTL, 100 entries
// or: "responseCache": { "ttlMs": 600000, "maxEntries": 500 }
}}}
}Cache key is a SHA-1 of the request's stable JSON representation (model + maxTokens + system + messages + tools + format + thinking). Requests with any of those fields differing miss the cache.
const r1 = await llm.chat({ messages: [{ role: 'user', content: 'hi' }] });
r1.cached // undefined (miss + fresh call)
const r2 = await llm.chat({ messages: [{ role: 'user', content: 'hi' }] });
r2.cached // true (hit — no upstream call)
llm.responseCache.hits // 1
llm.responseCache.misses // 1
llm.responseCache.size() // 1Common wins:
- Same PO summarized twice (approver reopens the review) — instant, zero tokens
- Batch classification with duplicate inputs — deduplicates automatically
- Load-testing — dev-env queries hit cache after the first pass
Semantic response cache (new in v2.7.0)
semanticCache is a middleware-form cache keyed by embedding similarity, not byte-equality — useful when the same question keeps showing up in slightly different wording. It's separate from the built-in responseCache (which is exact-match + LRU). Bring your own embedder and vector store; an in-memory linear-scan store is provided so you can wire it up with zero infra.
const { semanticCache, inMemorySemanticStore } = require('@saptarishi/cds-plugin-llm');
llm.use(semanticCache({
embedder: async (text) => (await llm.embed({ input: text })).embeddings[0],
store: inMemorySemanticStore({ maxEntries: 1000, ttlMs: 3600_000 }),
threshold: 0.92, // cosine similarity → hit
keyPrefix: `tenant:${tenantId}:`,
shouldCache: (_ctx, r) => r?.status !== 'error',
onHit: (i) => cds.log('llm:sc').info('hit', i),
}));Highlights:
- Fail-open. Any exception from the embedder or store falls through to
next()— a broken cache never takes the request path down. - Multi-tenant safe.
keyPrefixis post-filtered by the middleware even if the store ignores the hint, so hits cannot leak across tenants. - Swap the store for prod. The interface is three async methods (
get,put,findSimilar) — implement against pgvector, Redis, Pinecone, Weaviate, etc. - MCP resource at
config://semantic-cacheexposeshitRate, hit/miss/store counts, threshold, and last similarity for live dashboards.
Composition rule: wrap semanticCache outside bulkhead/retry (cache hits shouldn't burn concurrency slots or retry budget) and inside guardrails/promptInjectionGuard (don't cache answers whose inputs were rejected).
Fuzzy dedup (new in v2.38.0)
fuzzyDedup is near-duplicate request detection using cheap character-level similarity — Jaccard on character trigrams (default, no library deps) or normalized Levenshtein. Cache-like semantics: on match, returns the prior response without calling the provider. No embedder required, so it complements (rather than competes with) semanticCache (v2.7.0). Ideal for support-ticket dedup + spam prevention where users retype the same question with typos or slight rewording.
const { fuzzyDedup, inMemoryFuzzyStore } = require('@saptarishi/cds-plugin-llm');
llm.use(fuzzyDedup({
similarityKind: 'jaccard-trigram', // or 'levenshtein'
threshold: 0.85,
store: inMemoryFuzzyStore({ maxEntries: 5000, ttlMs: 24 * 3600_000 }),
minKeyLength: 8,
onHit: (i) => cds.log('llm:fuzzy').info('near-dup', i),
}));Fills the third slot in the dedup story alongside existing primitives:
requestCoalescer(v2.8.0) — BYTE-IDENTICAL, IN-FLIGHTresponseCache(v0.9.0) — BYTE-IDENTICAL, AT-RESTsemanticCache(v2.7.0) — EMBEDDING-SIMILAR (requires embedder)fuzzyDedup(v2.38.0, this) — CHARACTER-SIMILAR (no embedder)
Highlights:
- Two-phase matching — exact-key hit via
store.getfirst (fast), thenstore.findSimilarfuzzy scan only as needed.stats.exactHitsandstats.fuzzyHitsare broken out separately so operators can see how much lift comes from typo-level matching vs plain repeats. - Two algorithms —
jaccard-trigram(O(n+m), scales to a few thousand chars) orlevenshtein(O(n*m), catches char-level typos more precisely — cap prompt length in practice). - In-memory reference store ships with LRU eviction (
maxEntries) and optional TTL — same{ get, put, findSimilar }contract used by any custom store (Redis, PostgreSQL, etc.). - Multi-tenant safe via
keyPrefix— namespace one shared store across many dedup instances; post-filter enforces isolation even if the store ignored the hint. minKeyLength: 8default skips trivially-short prompts ("hi","ok") where every prompt would collide.- Fail-open on store errors — increments
storeErrors, passes through; cache degrades to no-op rather than blocking. - Standalone
jaccardTrigram(a, b),normalizedLevenshtein(a, b),levenshteinDistance(a, b),trigrams(text)exported for custom similarity strategies. - MCP resource:
config://fuzzy-dedup.
Composition rules: compose OUTSIDE requestCoalescer (v2.8.0) so byte-identical concurrent requests coalesce first, and OUTSIDE responseCache (v0.9.0) so exact repeats short-circuit before the fuzzy scan runs at all. Compose with semanticCache (v2.7.0) when both are available — fuzzyDedup catches typos + trivial rewordings cheaply; the semantic layer catches genuine paraphrases (higher recall, embedder cost). Order dedup → quota/cost primitives so fuzzy hits never accrue cost against quotaManager (v2.23.0) or costOverrunPredictor (v2.33.0).
Empty-response detector (new in v2.37.0)
emptyResponseDetector catches broken model responses (empty string, whitespace-only, single-char replies, refusal patterns like "I can't help with that") BEFORE they reach the caller. Common failure modes caught: provider hiccups (empty payload), safety-filter or guardrail evasion that returned "", or soft-refusals on legitimate requests.
const { emptyResponseDetector } = require('@saptarishi/cds-plugin-llm');
llm.use(emptyResponseDetector({
minChars: 10,
onEmpty: 'retry', // 'throw' | 'retry' | 'log'
maxRetries: 1,
onDetected: (i) => cds.log('llm:empty').warn('empty response', i),
}));Distinct from other quality primitives:
structuredOutputRepair(v2.9.0) — SCHEMA-driven repairresponseRevision(v2.29.0) — RUBRIC-driven re-askemptyResponseDetector(v2.37.0) — EMPTY / REFUSAL detection
Built-in refusal patterns (6 anchored-to-start regexes): I can't/cannot/won't help/assist, I'm unable/not able/sorry to, Sorry, but I can't, I must decline/refuse, This request cannot be, As an AI assistant, I can't. Anchored to start so mid-response mentions don't false-positive.
Highlights:
- Custom detection: pass
detectEmpty(result)for full override; returntrue/falseor{ empty, reason }. Returnnullto fall back to defaults. - Reason-aware retry prompt: refusal detection asks the model to explain WHY rather than refuse outright; too-short asks for a substantive answer.
- Per-reason stats (
byReason) — dashboard breakdowns acrosstoo-short/whitespace/refusal-pattern/ custom. - Fail-safe custom detector — exceptions fall back to default detection.
- MCP resource:
config://empty-response-detector.
Compose with responseRevision (v2.29.0) OUTSIDE this detector — first check for emptiness; if substantive but low-scoring, then revise. Two-stage quality gate.
Content-length gate (new in v2.36.0)
contentLengthGate is pre-flight size validation: reject (or truncate) prompts that exceed a per-model token budget BEFORE they hit the provider. Prevents 400 errors on over-limit contexts + saves tokens on obviously-too-large inputs. Distinct from time-based limiters (deadline, gracePeriod) — this is size-based.
const { contentLengthGate } = require('@saptarishi/cds-plugin-llm');
llm.use(contentLengthGate({
modelLimits: {
'gpt-4o': 128_000,
'gpt-4o-mini': 128_000,
'claude-opus-4-7': 200_000,
default: 64_000,
},
overageMode: 'truncate-oldest', // 'throw' | 'truncate-oldest' | 'log'
onOverage: (i) => cds.log('llm:size').warn('over-limit', i),
}));Three-way overage policy:
'throw'(default) — raisesContentLengthExceededError(codeCONTENT_LENGTH_EXCEEDED) with.tokens,.chars,.limitTokens,.model. Fail-fast.'truncate-oldest'— drops oldest messages until under limit, preservingsystem+ latest user message by default (preserveSystem/preserveLatestUseropt-out).'log'— pass through unmodified withonOverageobservability; let the provider decide (useful for shadow deployments before enforcing).
Highlights:
- GPT-family token heuristic by default (
char / 4); users with real tokenizers passtokenEstimator(text) → tokens. - Per-model limits with
defaultfallback; unknown models pass through unchecked withunknownModelCountcounter for observability. - Original request restored after truncation-mode calls — caller state unmodified, only the downstream provider saw the trimmed version.
overageRate()returnsoverageCount / totalCallsfor real-time tuning.- MCP resource:
config://content-length-gate.
Composition rule: put contentLengthGate outside providers so the check runs before any network work. Compose with compactHistory (v1.91.0) as complementary primitives — compact keeps multi-turn conversations bounded via LLM summarization; gate catches single-turn over-limit as the hard backstop.
Latency histogram (new in v2.35.0)
latencyHistogram tracks per-dimension latency distributions using Prometheus-style bucketed counts (bucket counts + sum + count — not raw samples). Reports p50/p95/p99 percentiles cheaply. O(1) memory per dimension regardless of sample count. Complements providerHealthAggregate (v2.31.0 — single p95 verdict) with the full percentile shape + exportable Prometheus format.
const { latencyHistogram } = require('@saptarishi/cds-plugin-llm');
const hist = latencyHistogram({
dimensionsOf: (ctx, result) => ({ model: result?.model ?? 'unknown' }),
overThresholdMs: 15_000, // p95 threshold
onOverThreshold: (i) => cds.log('llm:slo').warn('p95 breach', i),
});
llm.use(hist);
// Query percentiles:
const p = hist.getPercentiles({ model: 'gpt-4o' });
// → { count: 145, sum: 92500, mean: 638, p50: 500, p95: 2500, p99: 5000 }
// Export for Prometheus scraping:
const prom = hist.prometheusHistograms('llm_latency_ms');
// llm_latency_ms_bucket{model="gpt-4o",le="500"} 87
// llm_latency_ms_bucket{model="gpt-4o",le="1000"} 120
// ...
// llm_latency_ms_sum{model="gpt-4o"} 92500
// llm_latency_ms_count{model="gpt-4o"} 145Highlights:
- Prometheus-canonical bucket layout by default:
[5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000]ms. Users with different SLOs pass a custombucketsarray. - Percentile computation via cumulative-count walk — O(buckets) regardless of sample count.
- Filtered percentiles —
getPercentiles({ model: 'gpt-4o' })aggregates matching dimensions. - Custom percentile arrays —
getPercentiles(filter, [10, 90, 99.9])for tail-latency analysis. - Rising-edge over-threshold callback rearms on recovery so subsequent breaches also fire.
- Fail-safe: dimens
