Skip to content

Commit b5083eb

Browse files
feat(finops): attribute governed agent-run inference to its tenant
OUTCOME: an operator can see AI usage and cost for GOVERNED AGENT/APP RUNS in the org-scoped Insights/FinOps surfaces. Before this, agent traffic shipped without an org, so those surfaces read ZERO for it — 'what does our AI cost per department' was unanswerable for exactly the runs that do the enterprise's work. runAgent already resolved the run's tenant (attribution.org); it just never reached the gateway. Threaded orgId through compose() and makeGovernedPlanner() into gatewayAnswer(), which now stamps attribution with the SHARED pure gatewayAttribution() helper (same one chat/stream uses — one attribution rule, not two). Both the linear compose path and the autonomous ReAct planner are covered. Suite 5079 pass / 0 fail; coverage green.
1 parent 8b1b091 commit b5083eb

1 file changed

Lines changed: 12 additions & 9 deletions

File tree

src/lib/agentrun.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import { type CheckResult } from '@/lib/checks';
4040
import { deepStripNul, stripNul } from '@/lib/jsonb-safe';
4141
import { correlationIds } from '@/lib/correlation';
4242
import { costForTokens } from '@/lib/finops';
43-
import { GATEWAY_URL, gatewayHeaders } from '@/lib/gateway';
43+
import { GATEWAY_URL, gatewayAttribution, gatewayHeaders } from '@/lib/gateway';
4444
import { screenGuardrail } from '@/lib/guardrail-seam';
4545
import { emitSpan } from '@/lib/otel';
4646
import { auditEnforcement } from '@/lib/pipeline-contract';
@@ -133,6 +133,7 @@ async function gatewayAnswer(
133133
system: string,
134134
model: string,
135135
caller?: string,
136+
orgId?: string,
136137
): Promise<string | null> {
137138
const body = {
138139
model,
@@ -164,11 +165,12 @@ async function gatewayAnswer(
164165
try {
165166
const res = await fetch(`${GATEWAY_URL}/v1/chat/completions`, {
166167
method: 'POST',
167-
// x-offgrid-user attributes the agent run's gateway spend to the invoking user (captured
168-
// as `caller` in the gateway's OpenSearch log) for per-user FinOps.
168+
// Attribution: the invoking user AND the tenant org, so this run's gateway spend lands
169+
// ATTRIBUTED on the observability doc. Without the org the doc is invisible to the org-scoped
170+
// FinOps/Insights surfaces, i.e. agent traffic reads as zero cost (G-GATEWAY-INDEX-ORG).
169171
headers: gatewayHeaders({
170172
'content-type': 'application/json',
171-
...(caller ? { 'x-offgrid-user': caller } : {}),
173+
...gatewayAttribution({ userId: caller, orgId }),
172174
}),
173175
body: JSON.stringify(body),
174176
signal: AbortSignal.timeout(20000),
@@ -198,6 +200,7 @@ async function compose(
198200
hits: RetrievalHit[],
199201
agent: AgentDef,
200202
caller?: string,
203+
orgId?: string,
201204
): Promise<string> {
202205
const context = hits.map((h, i) => `[${i + 1}] ${h.title}: ${h.snippet}`).join('\n');
203206
const system = systemFor(agent);
@@ -206,7 +209,7 @@ async function compose(
206209
const cacheKey = `${model}\n${system}\n${query}\n${context}`;
207210
const cached = await cacheLookup(cacheKey);
208211
if (cached.hit && cached.answer) return cached.answer;
209-
const answer = await gatewayAnswer(query, context, system, model, caller);
212+
const answer = await gatewayAnswer(query, context, system, model, caller, orgId);
210213
if (answer) {
211214
await cacheStore(cacheKey, answer);
212215
return answer;
@@ -266,12 +269,12 @@ async function resolveAgentToolCatalog(agent: AgentDef, orgId: string): Promise<
266269
// FinOps attribution) as a normal answer. It builds the ReAct prompt (pure), calls the gateway, and
267270
// parses the reply into an action. On an empty/unparseable reply it FINISHES with a best-effort note
268271
// rather than looping blindly — the loop's budget still bounds it either way.
269-
function makeGovernedPlanner(model: string, system: string, caller?: string) {
272+
function makeGovernedPlanner(model: string, system: string, caller?: string, orgId?: string) {
270273
return async (input: PlanInput) => {
271274
const prompt = buildPlannerPrompt(input);
272275
// The planner reply is NOT the final answer, so it must not be cached under the compose key;
273276
// call the gateway directly with the ReAct system + prompt.
274-
const reply = await gatewayAnswer(input.goal, prompt, system, model, caller);
277+
const reply = await gatewayAnswer(input.goal, prompt, system, model, caller, orgId);
275278
const action = reply ? parseAgentAction(reply) : null;
276279
if (action) return action;
277280
// No usable action — end the loop honestly with whatever the model said (or a fallback).
@@ -1041,7 +1044,7 @@ export async function runAgent(
10411044
const loop = await runAgentLoop({
10421045
goal: `${modelQuery}${context}`,
10431046
tools: toolCatalog,
1044-
planNext: makeGovernedPlanner(loopModel, loopSystem, caller),
1047+
planNext: makeGovernedPlanner(loopModel, loopSystem, caller, attribution.org),
10451048
callTool: makeGovernedToolExecutor(orgId, agent.id, caller, mark, runEgress, actorRole),
10461049
maxIterations: budget,
10471050
});
@@ -1055,7 +1058,7 @@ export async function runAgent(
10551058
t,
10561059
);
10571060
} else {
1058-
answer = await compose(modelQuery, routed.hits, agent, caller);
1061+
answer = await compose(modelQuery, routed.hits, agent, caller, attribution.org);
10591062
mark('answer', 'compose', answer.slice(0, 120), [], t);
10601063
}
10611064

0 commit comments

Comments
 (0)