AI
AI Grounding and Verification
How /v1/ai gets its numbers from deterministic currency tools, why data is canonical, how to verify convert and history results against REST, and what is not auditable yet.
/v1/ai is grounded in tools, not in the model’s memory. The model reads your
question, chooses tool calls, and writes prose from the results. The numbers come
from CurrencyCore’s deterministic engine. The prose can still be wrong.
The practical rule: treat data as canonical and treat answer as a summary
that a human should read, not as a source of record.
How grounding works
- Your question is classified and sent to the language model with a fixed set of currency tools.
- The model chooses a tool and arguments. Examples: a conversion with an amount and date, a multi-currency rate list, a history window, a PPP country pair.
- CurrencyCore runs the tool against the same rate, history, and PPP data that powers the REST endpoints. The result is a structured object.
- The model sees a compacted copy of that result and writes a plain-text
answer. - The full structured result is returned in
data, without the internal currency-resolution step.
This is why the response usually contains a number that matches REST. The prompt requires a tool for real numbers, so an invented rate is unlikely, but this does not make the prose infallible.
The deterministic tools cover:
- Conversion and single-pair rates.
- Multi-currency rate lists.
- Historical trend, comparison, movers, and report analysis.
- PPP factor analysis over time.
- Volatility and stability.
- Correlation, drawdown, safe haven, mean reversion, risk-adjusted return, and strengthening streaks.
Each tool returns a structured object. The model never receives a database
handle; it receives the result object, and the same object (minus the internal
currency-resolution step) is what appears in data.
Data is canonical, prose is not
| Risk | What can happen | How to handle it |
|---|---|---|
| Rounding | The answer says “about 5,255” while result is 5255.4. | Use data[].result and data[].rate for calculations. |
| Direction wording | The model says a currency “rose” when it weakened, especially across changePct and strengthenedPct. | Use strengthenedPct from data; positive means stronger against the base. |
| Unit wording | The model expresses a rate in the inverse direction from what you expected. | Rates are target units per one source unit. Check rate and amount. |
| Date wording | The answer says “today” while asOf is the last trading day. | Show asOf, not the request time. |
| Selection | The answer names a currency or country that is not in data. | Render from data; do not parse names out of prose. |
| Truncation | A long report stops mid-thought. | The server trims to a sentence boundary, but a report can still be incomplete. Use REST for full detail. |
| Model choice | The same question can produce different prose on different runs or providers. | Do not snapshot answer text in tests. Assert on data. |
The model runs at a low temperature, not at zero. The numbers are deterministic when the inputs are; the wording is not.
Verify a conversion
A convert datum carries everything you need to re-run the calculation:
{
"from": "EUR",
"to": "INR",
"amount": 50,
"result": 5255.4,
"rate": 105.108,
"pppApplied": false,
"asOf": "2026-11-10"
}
Query /v1/convert with the same from, to,
amount, and date: asOf:
GET /v1/convert?from=EUR&to=INR&amount=50&date=2026-11-10
Compare results[].result and results[].rate. They should match within
rounding. A small difference can come from a different rounding step, but a large
difference means you should not trust the AI response for that request.
The ./ai-types import below refers to the types on
AI response schema. Copy them into a local module;
there is no published client type package for /v1/ai.
import type { AiConvertDatum } from "./ai-types";
const CLOSE_ENOUGH = 0.0001;
function close(a: number, b: number, relativeTolerance = CLOSE_ENOUGH): boolean {
if (!Number.isFinite(a) || !Number.isFinite(b)) return false;
const scale = Math.max(Math.abs(a), Math.abs(b), 1);
return Math.abs(a - b) <= scale * relativeTolerance;
}
export async function verifyConvert(
datum: AiConvertDatum,
key: string,
): Promise<{ ok: boolean; restResult?: number; restRate?: number; reason?: string }> {
if (datum.pppApplied) {
return {
ok: false,
reason: "PPP conversions need the country-aware /v1/convert?ppp=true form.",
};
}
const url = new URL("https://api.currency-core.com/v1/convert");
url.searchParams.set("from", datum.from);
url.searchParams.set("to", datum.to);
url.searchParams.set("amount", String(datum.amount));
url.searchParams.set("date", datum.asOf);
const res = await fetch(url, {
headers: { Authorization: `Bearer ${key}` },
signal: AbortSignal.timeout(20_000),
});
if (!res.ok) return { ok: false, reason: `HTTP ${res.status}` };
const body = await res.json();
const row = body.results?.find((r: { to: string }) => r.to === datum.to);
if (!row) return { ok: false, reason: `No ${datum.to} row in /v1/convert` };
const ok = close(row.result, datum.result) && close(row.rate, datum.rate);
return {
ok,
restResult: row.result,
restRate: row.rate,
...(ok ? {} : { reason: "Result or rate differs beyond tolerance." }),
};
}
For a PPP conversion, verify against
/v1/convert?ppp=true with
the same currency:country pairs. Remember that the AI convert datum’s rate is
the PPP ratio (result / amount), not the market FX rate.
Verify a history analysis
A history-analysis datum carries base, from, to, and one row per currency
with changePct and strengthenedPct. Re-run
/v1/history/analysis with the same base,
window, and currency list.
import type { AiHistoryAnalysisDatum } from "./ai-types";
function samePercent(a: number | null, b: number | null, tolerance = 0.5): boolean {
if (a == null || b == null) return a === b;
return Math.abs(a - b) <= tolerance;
}
export async function verifyHistory(
datum: AiHistoryAnalysisDatum,
key: string,
): Promise<{ ok: boolean; mismatches: string[] }> {
const url = new URL("https://api.currency-core.com/v1/history/analysis");
url.searchParams.set("base", datum.base);
url.searchParams.set("from", datum.from);
url.searchParams.set("to", datum.to);
url.searchParams.set("currencies", datum.results.map((r) => r.currency).join(","));
const res = await fetch(url, {
headers: { Authorization: `Bearer ${key}` },
signal: AbortSignal.timeout(20_000),
});
if (!res.ok) return { ok: false, mismatches: [`HTTP ${res.status}`] };
const body = await res.json();
const restByCode = new Map<string, { changePct: number | null; strengthenedPct: number | null }>(
(body.results ?? []).map((r: { currency: string; changePct: number | null; strengthenedPct: number | null }) => [
r.currency,
r,
]),
);
const mismatches: string[] = [];
for (const row of datum.results) {
const rest = restByCode.get(row.currency);
if (!rest) {
mismatches.push(`${row.currency}: missing from /v1/history/analysis`);
continue;
}
if (!samePercent(row.changePct, rest.changePct)) {
mismatches.push(`${row.currency}: changePct ${row.changePct} vs ${rest.changePct}`);
}
if (!samePercent(row.strengthenedPct, rest.strengthenedPct)) {
mismatches.push(
`${row.currency}: strengthenedPct ${row.strengthenedPct} vs ${rest.strengthenedPct}`,
);
}
}
return { ok: mismatches.length === 0, mismatches };
}
Notes:
- The AI engine often omits
statseven though the REST endpoint computes them by default. Compare only the fields present indata. - A recorded rate date can differ by a carry-back day around weekends and holidays. The percentages should still match for the same window.
- If the AI answer used a universe (a movers ranking) rather than a named list,
query REST in movers mode and compare the currencies that appear in
data. - Growth+ is required for both the AI analysis and the REST analysis endpoint.
Verify the semantics, not just the number
A number can be arithmetically correct and still be misread. Check these fields:
| Field | What to confirm |
|---|---|
asOf | The rate reference date, not the time of the request. Values roll forward from the last trading day. |
from and to (history) | The requested window. You may need REST’s startDate and endDate to see the actual carry-back snapshot dates. |
from and to (PPP) | Years, not dates. |
projected (PPP) | true means the end factor is an IMF projection, not actual data. Do not present it as observed. |
strengthenedPct | The direction figure for a currency against its base. Positive means stronger. |
changePct (history) | The direction of the quoted rate. For a non-USD currency it usually has the opposite sign from strengthenedPct. |
crisis (safe haven) | The reference crisis behind crisisReturnPct. Do not generalize it. |
bandPct (stability) | The band used for withinBand. Do not assume 5 percent. |
maxDrawdownPct | A peak-to-trough decline, usually negative or zero. Do not present it as a return. |
observations | The sample size behind a volatility or correlation row. A small sample is less reliable. |
Current limitations
- No citations. The response does not link a number to the REST endpoint or the underlying fixing. The mapping in AI response schema is your guide for which endpoint to check.
- No tool metadata.
toolCallsis a count. The response does not name the tools, their arguments, or their durations. - No outcome or kind. A
200can be an answer, a clarification, or a refusal. Inspectansweranddata. - No usage block. Token counts are not returned.
- No streaming. You cannot verify a partial answer.
- No guarantee of prose accuracy. The tools are deterministic; the model’s description of them is not audited sentence by sentence.
- No forecast data in AI answers. Projected PPP values can appear in a PPP
analysis row and are flagged with
projected, but the assistant does not forecast FX rates.
When a number matters, verify it. When it does not, still display asOf so the
user knows how fresh the rate is.