@@ -40,7 +40,7 @@ import { type CheckResult } from '@/lib/checks';
4040import { deepStripNul , stripNul } from '@/lib/jsonb-safe' ;
4141import { correlationIds } from '@/lib/correlation' ;
4242import { costForTokens } from '@/lib/finops' ;
43- import { GATEWAY_URL , gatewayHeaders } from '@/lib/gateway' ;
43+ import { GATEWAY_URL , gatewayAttribution , gatewayHeaders } from '@/lib/gateway' ;
4444import { screenGuardrail } from '@/lib/guardrail-seam' ;
4545import { emitSpan } from '@/lib/otel' ;
4646import { 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