SmartGateSmartGate

AI Financial Analysis for Agents: From Audit Log to Report

AI financial analysis with agents is an accounting problem before it is a model problem. financial ai agent draws 590 searches a month in the US, langchain mcp and openai mcp 480 each, and almost none of the pages answering those phrases can say what one tool call cost, for which team, over which window.

Short answer: AI financial analysis with agents is an accounting problem before it is a model problem. financial ai agent draws 590 searches a month in the US, langchain mcp and openai mcp 480 each, and almost none of the pages answering those phrases can say what one tool call cost, for which team, over which window. The seven functions quoted below are that missing half as shipped code: tenant attribution, a compression payload builder, a fetch pipeline that prices tokens before they reach context, the operator's usage table, URL normalisation, a proxy allowlist, and a budget key.

Key takeaways

  • Attribution starts at the webhook. readTeamIdFromCustomData turns two spellings of a tenant id from an untrusted event payload into one string, so a spend line belongs to a team instead of to "unknown".
  • Compression sends a request, never a measurement. process_sequence_data stamps a rate into the payload it builds; what was saved has to be measured where the compression happened.
  • A fetch has a price before it has an answer. fetch estimates markdown and raw-HTML tokens from length for every URL it reads, and checks its cache before it touches the network.
  • The operator's view is a table with a written empty state. DeveloperDataTable makes a caller say why a cost table is blank: "no calls this window" and "the query broke" are different answers in a finance review.
  • Config differences are invoice differences. trimTrailingSlash collapses a trailing slash so one MCP endpoint is one URL, and filterPythonResponseHeaders allowlists two response headers across the retrieval proxy.
  • Budgets are keyed by team and by month. monthKey joins an audit row to the budget row it was charged against, which is why the month boundary is an argument and not the server clock.
  • Do this next: try to answer "what did this cost inside the window this report claims, and which team pays for it" with one query against your own audit table. If it needs a join nobody wrote, port these seven functions before you add another model.

The short version, for whoever signs off on the spend

Two questions arrive together when a finance team adopts agents: whether the model gets the analysis right, and whether anyone can say what that analysis cost, for which client, in which period, against which limit. The second one stalls rollouts. A chat integration answers it by accident, because a human watches the invoice; a fleet of agents reading filings does not. Every function below sits on a path the call already takes, the only position from which the accounting is complete rather than reconstructed.

The same shape of problem shows up outside finance: an agent that reads filings, papers and threads before answering is an MCP for research and decisions workflow, and it needs the same ledger for the same reason.

Why AI financial analysis needs a ledger, not a model

The published evidence for AI in finance is strong enough that model quality is no longer the interesting question. IBM's write-up of AI in financial planning and analysis reports an Institute for Business Value survey in which organisations deploying AI end to end reached top-quartile ROI (IBM). GPT-4 given standardised, anonymous financial statements predicts the direction of future earnings changes more accurately than human analysts, and the paper rules out training-set recall as the explanation (Financial Statement Analysis with Large Language Models).

The counterweight is arithmetic: FinQA, the first expert-written question-answering dataset over real filings, found pre-trained models "fall far short of expert humans" at multi-step reasoning over financial documents (FinQA). Push the two findings together and a production system appears: a capable reasoner on a retrieval path, and a report behind both stating what was retrieved, what was compressed and what it cost. Google Cloud lists "transparency and compliance" among the five areas of AI value in finance (Google Cloud); that transparency is an audit trail with arithmetic in it - the half this page is about.

How long that trail has to be kept, what each row must carry and how it is exported for someone who did not build the system is the evidence discipline on AI compliance.

readTeamIdFromCustomData: whose spend does this financial AI agent carry?

# lib/billing/paddle-webhook-parse.ts — source lines 17–23 (readTeamIdFromCustomData)
function readTeamIdFromCustomData(
  customData: Record<string, unknown> | null | undefined,
): string {
  if (!customData || typeof customData !== "object") return "";
  const teamId = customData.teamId ?? customData.team_id;
  return typeof teamId === "string" ? teamId : "";
}

Attribution begins outside the agent loop, in the event that created or changed the subscription. Three decisions keep the accounting honest. It accepts teamId and team_id because two producers write the same field, so an event from either path resolves to one team rather than two. It narrows with typeof teamId === "string", because the payload is typed Record<string, unknown> - a numeric or nested id is rejected rather than reaching a report as a number. And it returns the empty string rather than null, so callers test one falsy value.

That empty string is the important one. A missing tenant id is not a crash; it is a spend line no team owns, and in a financial AI agent's report it is the difference between "4,100 for the analytics team" and "4,100, owner unknown". The function does not validate the id against a team table, which is the caller's job. The pattern generalises to every other attribution dimension - route, tool, project, cost centre - arriving from an untrusted field and wanting one normalising reader and one falsy default.

What happens to a field like that before it is trusted — how the key on the call is checked and how a payload is masked before it reaches a log — is the request-time policy on secure prompt handling in AI applications.

process_sequence_data: what an LLM financial analysis compresses first

# backend/smartgate/modules/context_gate/utils.py — source lines 212–229 (process_sequence_data)
def process_sequence_data(rate, start, end, sequence, is_dict=False):
    res = f'{start}"'
    n = len(sequence)
    if not is_dict:
        for i, item in enumerate(sequence):
            item = str(item)
            res += f"</llmlingua><llmlingua, rate={rate}>{item}</llmlingua><llmlingua, compress=False>"
            if i != n - 1:
                res += '", "'
    else:
        for i, (k, v) in enumerate(sequence.items()):
            item = f"{k}: {v}"
            item.replace('"', "'")
            res += f"</llmlingua><llmlingua, rate={rate}>{item}</llmlingua><llmlingua, compress=False>"
            if i != n - 1:
                res += '", "'
    res += f'"{end}, </llmlingua>'
    return res

Compression is part of the pitch for LLM financial analysis: a filing set is far larger than a context window, so the input is rewritten before the model sees it. LLMLingua and its successors do the rewriting by marking spans with rate instructions (LLMLingua, LLMLingua-2), and this builder shapes the payload those markers need: each item is wrapped in an <llmlingua, rate=...> span, closed with an <llmlingua, compress=False> guard that protects the separator the model counts items by, and the sequence is framed by a start and an end token supplied by the caller.

Two details decide whether the numbers that come out of it can be reported. The rate is a request - "compress this to 3x" - not a measurement, so a savings figure built from it is an estimate until the upstream request proves otherwise. The dict branch also holds a live bug worth knowing before you reuse it: item.replace('"', "'") is called without assigning the result, and Python strings are immutable, so a value containing a double quote reaches the compressed payload unchanged. Harmless for a prompt, expensive when the payload is a JSON fragment you later parse.

fetch: the LangChain MCP tool call is priced before it reaches context

# backend/smartgate/modules/fetch/algorithm.py — source lines 288–326 (fetch)
async def fetch(self, url: str, timeout: int = 30) -> dict:
        """主入口 — 完整抓取管线。"""
        cached = self._cache_get(url, timeout)
        if cached is not None:
            return cached

        # Step 1: 智能请求
        raw_html = await self._smart_request(url, timeout)

        # Step 2: 元数据提取 (在清洗前, 从原始 HTML 提取)
        metadata = self._extract_metadata(raw_html)

        # Step 3: 内容清洗
        clean_html = self._clean_html(raw_html)

        # Step 4: HTML→Markdown
        markdown = self.converter.convert(clean_html)

        # Step 5: 质量评分
        score = self._quality_score(markdown, metadata)

        md_chars = len(markdown)
        html_chars = len(clean_html)
        md_tokens_est = max(1, md_chars // 4) if md_chars else 0
        html_tokens_est = max(1, html_chars // 4) if html_chars else 0

        result = {
            "url": url,
            "markdown": markdown,
            "metadata": metadata,
            "quality_score": round(score, 2),
            "content_length": md_chars,
            "html_chars": html_chars,
            "md_chars": md_chars,
            "md_tokens_est": md_tokens_est,
            "html_tokens_est": html_tokens_est,
        }
        self._cache_put(url, timeout, result)
        return result

A LangChain MCP tool call that reads a page ends up in a function shaped like this one (LangChain MCP), and the five steps it runs in order are the five things a cost report has to account for. The cache lookup comes first: a URL already fetched inside the timeout window returns before any network work, the single largest lever on a fetching agent's bill. Metadata extraction runs on the raw HTML before cleaning, because the tags it needs are the ones the cleaner removes. Cleaning and HTML-to-Markdown conversion follow, then a quality score the caller can use to reject a page that converted badly.

The token arithmetic is the part that belongs in an accounting page. A fetch has no upstream token number, so the pipeline derives one from length - md_tokens_est = max(1, md_chars // 4) - for the cleaned HTML and for the markdown, which lets a report show the reduction conversion already achieved. The max(1, ...) guard keeps an empty document from costing zero and a division by zero out of the report. The honest caveat is that chars // 4 is a rule of thumb, not the provider's tokenizer (tiktoken is a real one), so mixing it with billed tokens in one column is a category error - label it.

A page a fetch returns is untrusted content entering the model's context, so the boundary around the call cannot be the prompt — the runtime surface on AI agent security.

mcp vs a2a: two protocols, two different accounting problems

The section above assumed one machine talking to a tool. mcp vs a2a is the question of what happens when the unit of work is another agent. Model Context Protocol defines a client-server conversation between a model and the tools, resources and prompts a server exposes (MCP specification, Anthropic's introduction). Agent2Agent defines delegation between agents that may belong to different vendors: discovery through an agent card, a task as the unit of work, and a lifecycle long enough for work that outlives one request (What is A2A, A2A and MCP).

No code slice is quoted here: the matcher's candidates for this section were not unique, so it is written from the two specifications. Both use a JSON-RPC-shaped transport; what separates them is where the tokens are visible, which is what a cost report cares about.

MCP A2A
Who calls whom A model client calls a tool server One agent delegates a task to another agent
Discovery Server exposes tools, resources, prompts Peer agent card describes skills and endpoint
Unit of work A tool invocation with a response A task with a lifecycle and a result
Where the tokens are visible In your audit log, per call Only in the delegate's own accounting, if it reports at all
What a cost report can claim Measured spend, per team and route The part of the work that came back to you

Read the last row as a warning: an MCP call routed through a gateway is one you can price, while an A2A task handed to an agent outside your infrastructure is a subcontract with a black box inside it - your audit trail records what came back, not what it cost the other side. Budget delegated work as an allowance per task unless the delegate publishes its own usage.

DeveloperDataTable: the OpenAI MCP session as the operator sees it

# components/dashboard/developers/data-table.tsx — source lines 13–40 (DeveloperDataTable)
function DeveloperDataTable({
  empty,
  emptyMessage,
  minWidthClass = "min-w-[52rem]",
  children,
  className,
}: Props) {
  if (empty) {
    return (
      <div className="flex h-full min-h-[12rem] items-center justify-center text-sm text-muted-foreground">
        {emptyMessage}
      </div>
    );
  }

  return (
    <div
      className={cn(
        "thin-scrollbar h-full min-h-0 flex-1 overflow-x-auto overflow-y-auto",
        className,
      )}
    >
      <table className={cn("w-full border-collapse text-xs", minWidthClass)}>
        {children}
      </table>
    </div>
  );
}

When an OpenAI client connects to a remote MCP server (remote MCP tools), the person who owns the bill sees a table, and this component is its frame. It exists because a cost table has two states that look alike and mean opposite things. The empty branch renders a written emptyMessage instead of an empty table body, so "no calls in this window" is a sentence an operator wrote rather than a blank region a reader interprets. The default minWidthClass of min-w-[52rem] is the other half: a usage table with a token count, a route, a tool and a timestamp cannot compress into a phone width without turning digits into ellipses, so it scrolls horizontally inside the thin-scrollbar container instead of shrinking its columns. children stay with the caller, so each screen owns its columns.

The reporting consequence is narrower than "we have a dashboard": a token total is only auditable next to the window it was measured over and the failure state of the query behind it, which is why the empty message is required here and a hard-coded fallback in most dashboards.

Making an empty state part of the component's contract is one instance of the wider practice — documenting the tool surface an agent consumes so the reference cannot drift from the code — on API documentation best practices for AI tools.

trimTrailingSlash: OpenClaw config resolves to one MCP base URL

# lib/connect/resolve-endpoints.ts — source lines 13–15 (trimTrailingSlash)
function trimTrailingSlash(url: string): string {
  return url.replace(/\/$/, "");
}

Configuring an agent to talk to an MCP endpoint means writing a base URL in a file, and the documented examples for OpenClaw do exactly that (OpenClaw MCP tools, gateway config). Two operators writing https://example.com/api/mcp and https://example.com/api/mcp/ into two config files have written two strings, and depending on how the client joins them to a path and how the edge matches routes, they may be two endpoints. This function makes them one: the anchored pattern /\/$/ removes a single trailing slash, and the caller appends its own separators afterwards.

Know its limits. It does not touch the scheme, so http:// and https:// still differ - a financial reason to keep both spellings out of production config. It does not collapse interior duplicates, so /api//mcp survives. And it is not safe on an origin-only value of exactly /, which reduces to the empty string; a caller that concatenates naively then builds a relative URL. A slash-only edit is nearly invisible in an audit: calls recorded against the old spelling stop matching the new one, and the month's spend reads as two smaller tenants.

filterPythonResponseHeaders: the proxy hop in a RAG architecture

# lib/connect/python-proxy.ts — source lines 35–42 (filterPythonResponseHeaders)
function filterPythonResponseHeaders(upstream: Headers): Headers {
  const out = new Headers();
  const contentType = upstream.get("Content-Type");
  if (contentType) out.set("Content-Type", contentType);
  const cacheControl = upstream.get("Cache-Control");
  if (cacheControl) out.set("Cache-Control", cacheControl);
  return out;
}

Most retrieval deployments end up with the shape this function implies: a JavaScript edge or Next.js route accepts the request, forwards it to a Python service that does the embedding and search, and returns the result to the app. The proxy hop is where response headers leak, so this function builds a fresh Headers object and copies across exactly two - Content-Type, because the caller has to know whether the body is JSON, and Cache-Control, because that header decides whether a retrieval result may be reused.

An allowlist ages better than a denylist: every header the upstream adds in a later release - a server banner, a framework debug marker, a tracing id that encodes an internal hostname - is absent at the edge until somebody adds it on purpose. The two omissions are deliberate as well. A copied Content-Length or Content-Encoding would contradict the body the proxy re-encodes, and ETag and Vary are dropped, the line here that deserves an audit check: if retrieval answers are cached at the CDN, the cache key has to carry the tenant, or a shared cache keyed only on the query can hand one team's chunks to another team's prompt.

Hop What crosses What must never cross
Model client to proxy The query, the tenant credential Upstream service keys
Proxy to Python service A narrowed request, one trace id Client cookies and origin headers
Python service to proxy Content-Type, Cache-Control, the body Server banners, debug markers, internal hostnames
Proxy to app The body plus those two headers Anything the app did not ask to cache

monthKey: VS Code MCP sessions and the monthly budget key

# lib/usage/index.ts — source lines 17–19 (monthKey)
function monthKey(teamId: string, date: Date): string {
  return usageMonthKey(teamId, date);
}

An agent loop can be started from an editor binding: VS Code's Copilot chat connects to MCP servers declared per workspace (VS Code MCP servers), which is how one developer's afternoon of prompting becomes tomorrow's invoice line. The budget meant to stop it is stored under a key, and this is the key: a team and a date in, one usage-month identifier out. The module it delegates to is marked server-only and wrapped in React's cache, so the same request does not compute the key twice, and a client bundle that imports it fails the build instead of shipping a key derivation to the browser.

The design decision to argue about is that the function takes a Date argument rather than reading the clock, so the month boundary is the caller's choice. That makes the boundary testable and lets a report re-price history, but it leaves the window a budget resets in and the window an invoice is issued in as two definitions of "this month" until somebody writes the third argument down. The key is also the join between an audit row and a budget row: change its shape and last quarter's usage stops matching.

Where the ceiling behind that key is actually enforced, and which credential the spend is attributed to, is the control plane on AI agent governance.

How SmartGate compares

The comparison is not about which model answers best; it is about who can produce the numbers above, from what source.

Where the numbers come from What it costs you What it cannot answer
Managed FinOps or BI tool Billing exports and tags, reconciled after the fact A subscription, per seat or per spend Anything the provider did not bill, and per-call attribution inside a request
Agent framework plus your own analytics Whatever the framework chose to log Engineer time, and a second system to keep correct Cross-team rollups, and traffic that never went through the framework
Hand-rolled scripts over the audit table Your SQL, your definitions Cheap to start, expensive to keep consistent Agreement between two dashboards built by two teams
Gateway with the report path inside it (SmartGate) The same per-call records the gateway already writes Platform fee, plus a share only once measured savings pass a threshold Provider features that were never routed through the gateway
Do nothing Spend you cannot attribute to a team, an agent or a route Nothing today Every question a controller asks about why the number moved

Test your own invoice against pay for the platform, share only when you save: Free is $0 with 2M tokens a month, Pro starts at $18, Teams at $55, Enterprise is a contract conversation, and the savings share is charged only once measured savings clear a threshold. SmartGate describes itself as the MCP-native algorithm gateway for token control, traffic shaping and agent audit; the slogan behind this report path is blunter - Agent loops don't warn. They bill. The pricing page states the terms, and the code states how the number is produced.

How to get started

  1. Fix the tenant key first. Read the owning team off the event payload that already carries it, normalise two spellings into one string, and never let a missing id become a silent zero.
  2. Separate measured from estimated. Record avoided or compressed tokens as a measurement where the upstream response proves it, and as an estimate in its own field everywhere else.
  3. Price a fetch before it runs. Estimate tokens from length for both the raw and the converted document, cache first, and report the conversion delta rather than one number.
  4. Decide where the hops are. Write down which headers and fields cross each proxy boundary in your retrieval path, and allowlist rather than forward.
  5. Make the budget key explicit. One key shape per team and per month, with the date passed in rather than read from the clock.
  6. Read the cost page next. The same attribution pattern is written out in optimize AI agent execution cost; the tool surface an agent calls is in the MCP server reference and the docs; the delegation question continues in MCP vs A2A protocol and the RAG architecture diagram.

Start on the free tier - 2M tokens a month, all seven smart_* tools (smart_fetch, smart_search, smart_context_gate, smart_dedup, smart_budget_guard, smart_memory, smart_pipe), no card: start free. Audit records, retention and the compliance view are described under audit and compliance, and an OpenClaw client is configured per OpenClaw MCP configuration.

Frequently Asked Questions

Is this a data analysis product or an infrastructure product?

Infrastructure. These functions do not interpret a filing or build a forecast; they decide what the report about those calls contains, which is the layer every forecast depends on.

Do these functions only make sense inside a gateway?

No. The shapes are portable - a tenant reader, a length-based token estimate, a header allowlist, a budget key. A gateway's advantage is that per-call records already exist, so the report is a query rather than a reconstruction.

Why is an A2A task harder to price than an MCP call?

Because the tokens are spent in someone else's accounting. MCP keeps the tool invocation inside a boundary you can log; an A2A task's internal cost is visible only to the delegate, so budget it as an allowance per task.

What should a compression rate be reported as?

A request, until the upstream response proves otherwise. The rate is what you asked for; the avoided-token figure that reaches a controller has to be measured where the compression happened.

Why does the retriever proxy copy only two response headers?

Content-Type and Cache-Control are the two the app acts on. Everything else is either meaningless downstream or a leak of implementation detail, and an allowlist keeps a header added in a later release from appearing at the edge by default.

Do I need a warehouse to do this?

No, but you need one record per call. A table, a log with a query layer, or the gateway's own records all work; provider invoices do not, because they carry no route or avoided-token dimension.

Limitations and what this does not do

  • A compression rate is not a saving. process_sequence_data writes the rate you asked for, and no function here counts the tokens a provider then did not charge.
  • One branch of that builder does nothing. The dict path calls replace without assigning the result, so quote normalisation is a no-op; treat it as a defect to fix, not behaviour.
  • Token estimates are length rules. chars // 4 is a convention, not a tokenizer; mixing it with billed tokens in one column is a category error.
  • Delegated work escapes the audit. An A2A task's internal spend is invisible unless the peer reports its own usage.
  • The proxy drops Vary and ETag. Caching downstream has to key on the tenant itself, or a shared cache can serve one team's retrieved chunks to another team's request.
  • The month boundary is a caller's decision. monthKey takes a date instead of reading the clock, so two callers can disagree about which month a call belongs to.
  • Config normalisation stops at the slash. trimTrailingSlash leaves the scheme and interior duplicates alone, and returns an empty string for a bare slash.
  • One section is not code-backed. mcp vs a2a had no unique symbol candidate in the matcher, so it is written from the two protocol specifications instead of quoted code.
  • This is not a model-quality claim. Nothing here benchmarks whether an agent's financial conclusions are correct.

Sources

Method note

The code in this article is not transcribed. Each block was cut directly out of the slice body returned by the slice API and re-asserted byte-for-byte as a substring of that body before publication; the first line inside every fence records the file and the exact source lines. The symbols were pinned by whole-name containment (rule A level 2) and confirmed by the service's slot-proof endpoint. One of the eight planned sections - mcp vs a2a - returned no unique symbol candidate, so it is written from the two published protocol specifications; nothing was invented to fill the gap. Demand figures are measured search volume for the US and English, recorded in this project's search_volume.json; every citation above was verified live.

Slice provenance

# SERP keyword Symbol File Source lines How it was pinned sha256(12)
1 readTeamIdFromCustomData financial ai agent readTeamIdFromCustomData lib/billing/paddle-webhook-parse.ts 17–23 rule A L0 → remote symbol candidates → slot-proof ba36bb4a0fbb
2 process_sequence_data llm financial analysis process_sequence_data backend/smartgate/modules/context_gate/utils.py 212–229 rule A L0 → remote symbol candidates → slot-proof ec1e132ac1a9
3 fetch langchain mcp fetch backend/smartgate/modules/fetch/algorithm.py 288–326 rule A L0 → remote symbol candidates → slot-proof aae86f23fdbd
4 DeveloperDataTable openai mcp DeveloperDataTable components/dashboard/developers/data-table.tsx 13–40 rule A L0 → remote symbol candidates → slot-proof bfd37906170d
5 trimTrailingSlash openclaw config trimTrailingSlash lib/connect/resolve-endpoints.ts 13–15 rule A L0 → remote symbol candidates → slot-proof bb917f740aff
6 filterPythonResponseHeaders rag architecture diagram filterPythonResponseHeaders lib/connect/python-proxy.ts 35–42 rule A L0 → remote symbol candidates → slot-proof 8a73db46ea21
7 monthKey vscode mcp monthKey lib/usage/index.ts 17–19 rule A L0 → remote symbol candidates → slot-proof 191059294aa0

Every fenced block above was cut out of the slice body and re-asserted against that body byte-for-byte before publication. 7 of 8 sections pinned, 0 abstentions, 1 section written from published specifications instead of a slice.