/api/v2/cortex/v1 is OpenAI-wire compatible, so it is a client you can wrap. The AI SQL functions (AI_COMPLETE, AI_EMBED, AI_SUMMARIZE and the rest) run inside the warehouse, where there is no client to wrap at all.
They need two different halves of the SDK, and this split is the thing to get right before anything else:
Live path
Wrap an OpenAI client pointed at your Cortex REST endpoint. Bills as calls happen.
Backfill path
Read Snowflake’s own
ACCOUNT_USAGE views. The only way to bill the AI SQL functions.Live path
There is no Cortex client to wrap. You wrapOpenAI pointed at your account’s Cortex endpoint, and the base_url is load-bearing: an OpenAI-shaped endpoint says nothing about whose tokens these are, so the path is what marks the call as Snowflake.
The SDK matches the path
/api/v2/cortex/ and stamps those events provider: "snowflake". It matches the path and not the snowflakecomputing.com host on purpose, because the same host serves /api/v2/statements and every other Snowflake API, none of which is model inference. Point the client anywhere else and the call ships as plain OpenAI.
Cortex reports cached tokens additively, the opposite of OpenAI’s convention. A cached call reads
prompt_tokens: 7, cached_tokens: 8745, completion_tokens: 6, total_tokens: 8758, so the cached block sits outside prompt_tokens rather than inside it. The SDK reconciles on Snowflake’s convention, not OpenAI’s. Caching only happens when you send an explicit cache_control content part.Attribution
The live path attributes like any other wrapper:sdk.wrap(client, subscription="sub_acme"), a per-call override, or the SDK’s default subscription. Cortex has no request-tag header to carry it.
Backfill path
For the AI SQL functions there is nothing to instrument, so read Snowflake’s own views instead. This is the only path that bills them.The Python import is
from lago_agent_sdk.gateway.snowflake import SnowflakeSource, since the gateway package itself re-exports nothing. In JavaScript the subpath export does: import { SnowflakeSource } from "lago-agent-sdk/gateway". The pure extract_snowflake_functions_log, extract_snowflake_rest_log and resolve_snowflake_subscription functions stay available from lago_agent_sdk.gateway.adapters if you already have rows from your own warehouse job.backfill_snowflake returns {"tokens": n, "skipped": n}, and there cannot be more counts than that. There is no cost: Snowflake meters Cortex in credits against a rate card that exists in no view, so everything on this path bills as token counts. CREDITS is read into the event’s extras as evidence, never as a billing input.
Options:
role_names and user_id stay out of the default deliberately, and the reason is measured rather than stylistic. Every live row carries both, so a default including them can never fall through: default_subscription becomes dead code and every untagged row bills to a Snowflake role name instead. Lago accepts events for a subscription that does not exist with a 200, so the revenue parks under an id nobody bills and nothing anywhere reports it. A skip is counted and recoverable. A wrong subscription is neither.
Idempotency
Every event id is{prefix}_{kind}_{subscription}_{row_id}, derived from the source row and scoped by the subscription actually billed, so re-running a window has Lago reject the duplicates rather than double-bill.
The REST view gets one guarantee the functions view cannot: a live Cortex call’s x-snowflake-request-id response header is byte-identical to the REQUEST_ID that call lands under in the view, and both paths build the key through the same helper. So a REST backfill of already-billed traffic dedups instead of double-reporting. That protection holds only with the default event_id_prefix and the same resolved subscription, and it does not cover a cache-creation call’s cached block, which the wire reports as a read and the view reports as a write. The call’s input and output still dedup.
What gets captured
FromCORTEX_REST_API_USAGE_HISTORY, tagged api: "snowflake_cortex_rest":
From
CORTEX_AI_FUNCTIONS_USAGE_HISTORY, tagged api: "snowflake_cortex_functions":
Both are stamped
provider: "snowflake". TOKENS on the REST view is never mapped to a token field: it is the sum, so mapping it would bill 8,758 tokens for 7 real input tokens and re-bill the cached block a second time. Unmapped keys on either view land in extras under a dotted key rather than vanishing, so a new counter surfaces on its own.
Each event also carries the grouping key of the view it came from, FUNCTION_NAME plus MODEL_NAME for functions rows and INFERENCE_REGION for REST, so a GROUP BY on the view and the same grouping in Lago line up for reconciliation.
Known limits
Everything bills as token counts, never priced.provider="snowflake" is deliberately unmatchable against the price sources. Snowflake bills Cortex in credits at a per-credit rate that depends on edition, region and contract, published in no API and no view, and the credit tables that do exist are warehouse-level rather than per-request. You set the rate in Lago. A customer running pricing_mode="price" globally still gets token events here, with no price-miss error, because a structural absence of a rate card is not a lookup failure.
Five of the six AI SQL functions report one number. AI_COMPLETE reports input and output. AI_SUMMARIZE, AI_TRANSLATE, AI_SENTIMENT, AI_CLASSIFY and AI_EMBED report total alone. The count is exact, but the split is not reported, so those rows bill their true total under input and carry a metrics_total_only marker. input was chosen over output because it is right by construction for AI_EMBED, close for the classifiers, and errs toward under-billing rather than over-billing on the two that genuinely generate.
The functions view has no cache and no reasoning metric at all, across every row captured. Thinking tokens on the REST view sit inside output.
A query spanning more than one hour is deferred, not guessed at. These views are hour-bucketed, and IS_COMPLETED means “did the query finish in this window”, so a query running 5:30 to 8:30 writes four rows sharing one QUERY_ID. Whether each row’s METRICS is incremental or cumulative is unmeasured, and guessing over-bills by 2.5x or under-bills by 76%. Those rows are counted in skipped, reported through on_error, and listed on source.deferred_rows. Every query yet observed on a real account finished inside one bucket.
The window is whole closed hours only, and the reader keeps no cursor. Both bounds are floored to the hour and the current hour is excluded, because billing a bucket before it closes burns that row’s idempotency key and the correction is then rejected as a duplicate. Pass a window comfortably wider than your run interval. The views also lag three to five minutes.
Events stamped before the subscription started are accepted and silently never billed. Lago draws that boundary, not the SDK, so a window reaching back past a subscription’s start reports its rows as billed while nothing lands in usage. Start backfills at the subscription’s start date.
A SQL warehouse costs roughly 1,500x the model-serving usage it reports on. Read one wide window per run. Never poll in a loop.
A failed Cortex call produces no row on either view, measured with a same-batch control where a 403 and a 400 were driven alongside a success and only the success appeared.
Four things block a first-time account setup and none of them says so clearly. Model access moved to RBAC, so a role needs
GRANT APPLICATION ROLE SNOWFLAKE."CORTEX-MODEL-ROLE-ALL" or it can call zero models. A PAT’s ROLE_RESTRICTION is a quoted literal and therefore case-sensitive. A warehouse with AUTO_RESUME = FALSE fails every statement with “warehouse is suspended”, which reads like a privilege error. And a PAT cannot authenticate without an active network policy. Error code 003001 covers four distinct causes, so it is not diagnostic on its own.Next steps
Billing
Why Cortex bills in tokens, and how to set the rate in Lago.
Configuration reference
Every config knob, plus
emit() and its idempotency arguments.