Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/ENVIRONMENT_VARIABLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
23 changes: 19 additions & 4 deletions sdk/go/ai/infron_attribution.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 55 additions & 4 deletions sdk/go/ai/infron_attribution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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")

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
15 changes: 7 additions & 8 deletions sdk/go/ai/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading