diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index aa6c0b97f..57e2d11ac 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -177,4 +177,4 @@ Attribution is sent as `HTTP-Referer` and `X-Title`: - `AGENTFIELD_INFRON_APP_NAME` (default: `AgentField AI`) - `AGENTFIELD_INFRON_ATTRIBUTION=false`: Disable Infron attribution headers. -When the `AGENTFIELD_INFRON_*` vars are unset, these OpenRouter attribution values are used as fallbacks, so a deployment that already declares its identity keeps it after switching gateways: `AGENTFIELD_OPENROUTER_SITE_URL`, `OR_SITE_URL`, `AGENTFIELD_OPENROUTER_APP_NAME`, `OR_APP_NAME`. If you do not want values configured for OpenRouter sent to Infron, set the `AGENTFIELD_INFRON_*` vars explicitly or disable attribution with `AGENTFIELD_INFRON_ATTRIBUTION=false`. +When the `AGENTFIELD_INFRON_*` vars are unset, these OpenRouter attribution values are used as fallbacks, so a deployment that already declares its identity keeps it after switching gateways: `AGENTFIELD_OPENROUTER_SITE_URL`, `OR_SITE_URL`, `AGENTFIELD_OPENROUTER_APP_NAME`, `OR_APP_NAME`. The opt-out travels with them: when `AGENTFIELD_OPENROUTER_ATTRIBUTION=false`, these values are not inherited and the Infron defaults apply instead. To control Infron attribution specifically, set the `AGENTFIELD_INFRON_*` vars explicitly or disable it with `AGENTFIELD_INFRON_ATTRIBUTION=false`. diff --git a/sdk/go/ai/infron_attribution.go b/sdk/go/ai/infron_attribution.go index ae0240a97..6a4d48cf3 100644 --- a/sdk/go/ai/infron_attribution.go +++ b/sdk/go/ai/infron_attribution.go @@ -45,18 +45,33 @@ func resolveInfronAttribution(siteURL, siteName string) (string, string, bool) { return "", "", false } + // The OpenRouter-scoped values are honored as fallbacks so a deployment + // keeps its declared identity when it moves gateways — but the opt-out + // travels with them: values a deployment suppressed for one vendor (often + // because they name internal hosts or products) must not be sent to + // another. + inheritedURL, inheritedName := "", "" + if openRouterAttributionEnabled() { + inheritedURL = firstNonEmpty( + os.Getenv("AGENTFIELD_OPENROUTER_SITE_URL"), + os.Getenv("OR_SITE_URL"), + ) + inheritedName = firstNonEmpty( + os.Getenv("AGENTFIELD_OPENROUTER_APP_NAME"), + os.Getenv("OR_APP_NAME"), + ) + } + resolvedURL := firstNonEmpty( siteURL, os.Getenv("AGENTFIELD_INFRON_SITE_URL"), - os.Getenv("AGENTFIELD_OPENROUTER_SITE_URL"), - os.Getenv("OR_SITE_URL"), + inheritedURL, defaultInfronSiteURL, ) resolvedName := firstNonEmpty( siteName, os.Getenv("AGENTFIELD_INFRON_APP_NAME"), - os.Getenv("AGENTFIELD_OPENROUTER_APP_NAME"), - os.Getenv("OR_APP_NAME"), + inheritedName, defaultInfronAppName, ) return resolvedURL, resolvedName, true diff --git a/sdk/go/ai/infron_attribution_test.go b/sdk/go/ai/infron_attribution_test.go index 7454f5026..6070add6f 100644 --- a/sdk/go/ai/infron_attribution_test.go +++ b/sdk/go/ai/infron_attribution_test.go @@ -154,6 +154,7 @@ func TestInfronAttributionFallsBackToExistingVars(t *testing.T) { t.Setenv("AGENTFIELD_INFRON_ATTRIBUTION", "") t.Setenv("AGENTFIELD_INFRON_SITE_URL", "") t.Setenv("AGENTFIELD_INFRON_APP_NAME", "") + t.Setenv("AGENTFIELD_OPENROUTER_ATTRIBUTION", "") t.Setenv("AGENTFIELD_OPENROUTER_SITE_URL", "https://legacy.example") t.Setenv("AGENTFIELD_OPENROUTER_APP_NAME", "Legacy App") @@ -164,6 +165,27 @@ func TestInfronAttributionFallsBackToExistingVars(t *testing.T) { assert.Equal(t, "Legacy App", header.Get("X-Title")) } +// The opt-out travels with the inherited values: attribution a deployment +// suppressed for OpenRouter — often because the site URL names an internal +// host — must not be sent to a different vendor either. The Infron defaults +// are used instead. +func TestInfronAttributionDoesNotInheritOptedOutValues(t *testing.T) { + t.Setenv("AGENTFIELD_INFRON_ATTRIBUTION", "") + t.Setenv("AGENTFIELD_INFRON_SITE_URL", "") + t.Setenv("AGENTFIELD_INFRON_APP_NAME", "") + t.Setenv("AGENTFIELD_OPENROUTER_ATTRIBUTION", "false") + t.Setenv("AGENTFIELD_OPENROUTER_SITE_URL", "https://internal-tools.corp.example") + t.Setenv("AGENTFIELD_OPENROUTER_APP_NAME", "Internal Risk Engine") + t.Setenv("OR_SITE_URL", "") + t.Setenv("OR_APP_NAME", "") + + header := http.Header{} + applyInfronAttributionHeaders(header, "", "") + + assert.Equal(t, defaultInfronSiteURL, header.Get("HTTP-Referer")) + assert.Equal(t, defaultInfronAppName, header.Get("X-Title")) +} + func TestApplyInfronAttributionHeadersDisabled(t *testing.T) { t.Setenv("AGENTFIELD_INFRON_ATTRIBUTION", "false") @@ -290,14 +312,15 @@ func TestResponseNormalizesTopLevelCost(t *testing.T) { assert.InDelta(t, 0.000002, *resp.Usage.Cost, 1e-12) } -func TestResponseNormalizeCreatesUsageWhenAbsent(t *testing.T) { +// A body carrying cost but no usage block must not have one fabricated for it: +// a synthesized Usage carries zero token counts that downstream consumers read +// as authoritative. +func TestResponseNormalizeDoesNotFabricateUsage(t *testing.T) { var resp Response require.NoError(t, json.Unmarshal([]byte(`{"cost": 0.5}`), &resp)) resp.normalizeNativeCost() - require.NotNil(t, resp.Usage) - require.NotNil(t, resp.Usage.Cost) - assert.InDelta(t, 0.5, *resp.Usage.Cost, 1e-12) + assert.Nil(t, resp.Usage) } // An explicit usage.cost is authoritative and must not be overwritten. @@ -347,3 +370,31 @@ func TestStreamChunkNormalizeNoopWithoutCost(t *testing.T) { assert.Nil(t, chunk.Usage) } + +// A cost-only chunk must not grow a fabricated zero-token Usage. Stream +// consumers accumulate last-usage-wins, so a synthesized Usage arriving after +// the real usage chunk would erase the real token counts. +func TestStreamChunkCostOnlyDoesNotFabricateUsage(t *testing.T) { + var usageChunk StreamChunk + require.NoError(t, json.Unmarshal( + []byte(`{"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}}`), + &usageChunk)) + usageChunk.normalizeNativeCost() + + var costChunk StreamChunk + require.NoError(t, json.Unmarshal([]byte(`{"cost": 0.00042, "choices": []}`), &costChunk)) + costChunk.normalizeNativeCost() + + assert.Nil(t, costChunk.Usage, "cost-only chunk must not synthesize usage") + + // The last-usage-wins accumulation every stream consumer performs. + var accumulated *Usage + for _, chunk := range []StreamChunk{usageChunk, costChunk} { + if chunk.Usage != nil { + accumulated = chunk.Usage + } + } + require.NotNil(t, accumulated) + assert.Equal(t, 10, accumulated.PromptTokens) + assert.Equal(t, 5, accumulated.CompletionTokens) +} diff --git a/sdk/go/ai/response.go b/sdk/go/ai/response.go index f76432df2..c48b9385c 100644 --- a/sdk/go/ai/response.go +++ b/sdk/go/ai/response.go @@ -29,13 +29,15 @@ type Response struct { // cost tracker reads as "price unknown" rather than "free" — usage is still // recorded, but silently with no cost and cost_source "" instead of // "provider". An explicit usage.cost always wins; this only fills a gap. +// +// The fold only happens into an existing usage block. Synthesizing one for a +// cost-only body would fabricate zero token counts that downstream consumers +// read as authoritative — on the streaming path a last-usage-wins accumulator +// would let such a chunk erase real counts from an earlier chunk. func (r *Response) normalizeNativeCost() { - if r == nil || r.Cost == nil { + if r == nil || r.Cost == nil || r.Usage == nil { return } - if r.Usage == nil { - r.Usage = &Usage{} - } if r.Usage.Cost == nil { cost := *r.Cost r.Usage.Cost = &cost @@ -121,12 +123,9 @@ type StreamChunk struct { // normalizeNativeCost folds a top-level chunk cost into Usage.Cost. See // Response.normalizeNativeCost. func (s *StreamChunk) normalizeNativeCost() { - if s == nil || s.Cost == nil { + if s == nil || s.Cost == nil || s.Usage == nil { return } - if s.Usage == nil { - s.Usage = &Usage{} - } if s.Usage.Cost == nil { cost := *s.Cost s.Usage.Cost = &cost