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

  1. Your question is classified and sent to the language model with a fixed set of currency tools.
  2. 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.
  3. CurrencyCore runs the tool against the same rate, history, and PPP data that powers the REST endpoints. The result is a structured object.
  4. The model sees a compacted copy of that result and writes a plain-text answer.
  5. 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

RiskWhat can happenHow to handle it
RoundingThe answer says “about 5,255” while result is 5255.4.Use data[].result and data[].rate for calculations.
Direction wordingThe model says a currency “rose” when it weakened, especially across changePct and strengthenedPct.Use strengthenedPct from data; positive means stronger against the base.
Unit wordingThe 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 wordingThe answer says “today” while asOf is the last trading day.Show asOf, not the request time.
SelectionThe answer names a currency or country that is not in data.Render from data; do not parse names out of prose.
TruncationA 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 choiceThe 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 stats even though the REST endpoint computes them by default. Compare only the fields present in data.
  • 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:

FieldWhat to confirm
asOfThe 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.
strengthenedPctThe 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.
maxDrawdownPctA peak-to-trough decline, usually negative or zero. Do not present it as a return.
observationsThe 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. toolCalls is a count. The response does not name the tools, their arguments, or their durations.
  • No outcome or kind. A 200 can be an answer, a clarification, or a refusal. Inspect answer and data.
  • 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.