DEV Community

lizer yang for SmartGate

Posted on Originally published at smartgate.network

AI Financial Analysis for Agents: From Audit Log to Report

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.

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 : "";
}
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

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(/\/$/, "");
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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.

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.

FAQ

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 L2 → slot-proof ba36bb4a0fbb
2 process_sequence_data llm financial analysis process_sequence_data backend/smartgate/modules/context_gate/utils.py 212–229 rule A L2 → slot-proof ec1e132ac1a9
3 fetch langchain mcp fetch backend/smartgate/modules/fetch/algorithm.py 288–326 rule A L2 → slot-proof aae86f23fdbd
4 DeveloperDataTable openai mcp DeveloperDataTable components/dashboard/developers/data-table.tsx 13–40 rule A L2 → slot-proof bfd37906170d
5 trimTrailingSlash openclaw config trimTrailingSlash lib/connect/resolve-endpoints.ts 13–15 rule A L2 → slot-proof bb917f740aff
6 filterPythonResponseHeaders rag architecture diagram filterPythonResponseHeaders lib/connect/python-proxy.ts 35–42 rule A L2 → slot-proof 8a73db46ea21
7 monthKey vscode mcp monthKey lib/usage/index.ts 17–19 rule A L2 → 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.

Top comments (0)