From 6323eaacbd4b066bcbf3fedf8f40bc5e406820a0 Mon Sep 17 00:00:00 2001 From: Merida Hou Date: Thu, 30 Jul 2026 15:06:28 -0700 Subject: [PATCH 1/2] feat(sdk/go/ai): support the Infron gateway Infron is an OpenAI-compatible inference gateway that serves the standard / ids, so a model moves across by prefix alone: infron/moonshotai/kimi-k2.6 routes the same model the bare id names. Follows the provider shape already in this package rather than inventing a new one: - infron_attribution.go mirrors the existing attribution helper. Infron accepts the same HTTP-Referer / X-Title pair, and the attribution env vars already configured for the existing gateway are honored as fallbacks, so a deployment that already declares itself as 'AgentField AI' keeps that identity after switching gateways. - Config gains IsInfron(); DefaultConfig() reads INFRON_API_KEY and points at https://llm.onerouter.pro/v1. - client.go attaches attribution on both the sync and streaming paths. - marshalRequest opts Infron into native usage accounting and strips the routing-only 'infron/' model prefix before the request goes out (stripInfronPrefix, mirroring the prefix handling on the media path). The gateway serves the bare id, so leaving the prefix on returns 'No available providers for model infron/...'. Only a copy of the Request is rewritten; the caller's Request is untouched. One real difference is handled rather than papered over: Infron returns the native cost at the top level of the body and of the final stream chunk, rather than nested under usage. Parsed naively that leaves Usage.Cost nil, which the cost tracker reads as 'price unknown' -- usage still recorded, but with no cost and an empty cost_source instead of 'provider'. Response/StreamChunk now carry the top-level field and normalizeNativeCost folds it into Usage.Cost, so every existing consumer keeps reading one place. An explicit usage.cost always wins. A gateway key that was already honored before Infron existed keeps precedence, so adding an Infron key never reroutes an existing deployment. llm.onerouter.pro is deliberately NOT added to vouchedRewriteDomains: max_tokens and max_completion_tokens behaved identically in probing and neither could be shown to be enforced, so the conservative legacy max_tokens path stays, per the reasoning already in that comment. --- docs/ENVIRONMENT_VARIABLES.md | 14 ++ sdk/go/ai/README.md | 30 +++ sdk/go/ai/client.go | 16 +- sdk/go/ai/config.go | 31 ++- sdk/go/ai/infron_attribution.go | 82 +++++++ sdk/go/ai/infron_attribution_test.go | 314 +++++++++++++++++++++++++++ sdk/go/ai/model_params.go | 32 ++- sdk/go/ai/response.go | 47 +++- 8 files changed, 555 insertions(+), 11 deletions(-) create mode 100644 sdk/go/ai/infron_attribution.go create mode 100644 sdk/go/ai/infron_attribution_test.go diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 8c56b05af..8d57ea2f8 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -164,3 +164,17 @@ OpenRouter attribution is request metadata, not authentication. AgentField SDKs - `AGENTFIELD_OPENROUTER_ATTRIBUTION=false`: Disable OpenRouter attribution headers/env propagation. Explicit SDK config or explicit request headers win over env defaults. `AGENTFIELD_API_KEY`, SDK `api_key` / `apiKey`, Go `WithAPIKey`, and the `X-API-Key` header are only for AgentField control-plane authentication and are not used for OpenRouter attribution. + +### Infron + +- `INFRON_API_KEY`: API key for the Infron gateway. When it is the only gateway key set, the Go SDK's `ai.DefaultConfig()` points at `https://llm.onerouter.pro/v1`. A gateway key already configured above keeps precedence, so adding this key never reroutes an existing deployment. + +Infron is OpenAI-compatible and serves the standard `/` ids, so a model moves across by prefix alone (`infron/moonshotai/kimi-k2.6`). The `infron/` prefix is a routing marker only and is stripped before the request is sent, since the gateway serves the bare id. + +Attribution is sent as `HTTP-Referer` and `X-Title`: + +- `AGENTFIELD_INFRON_SITE_URL` (default: `https://agentfield.ai`) +- `AGENTFIELD_INFRON_APP_NAME` (default: `AgentField AI`) +- `AGENTFIELD_INFRON_ATTRIBUTION=false`: Disable Infron attribution headers. + +When the `AGENTFIELD_INFRON_*` vars are unset, the attribution values documented above are used as fallbacks, so a deployment that already declares its identity keeps it after switching gateways. diff --git a/sdk/go/ai/README.md b/sdk/go/ai/README.md index c8b4e2162..ff5e5d42a 100644 --- a/sdk/go/ai/README.md +++ b/sdk/go/ai/README.md @@ -114,6 +114,36 @@ aiConfig := &ai.Config{ } ``` +### Infron Configuration + +Infron is an OpenAI-compatible gateway that serves the standard +`/` ids, so wiring it up is a base URL and a model prefix: + +```go +aiConfig := &ai.Config{ + APIKey: os.Getenv("INFRON_API_KEY"), + BaseURL: "https://llm.onerouter.pro/v1", + Model: "moonshotai/kimi-k2.6", // the id as published by the model vendor + SiteURL: "https://myapp.com", // app attribution + SiteName: "My AI App", +} +``` + +`ai.DefaultConfig()` picks this up from `INFRON_API_KEY` automatically. A +gateway key already present in the environment keeps precedence. + +An `infron/` model prefix is accepted as a routing marker for callers that +select a gateway by model string, and is stripped before the request goes out +(the gateway serves the bare id): + +```go +Model: "infron/moonshotai/kimi-k2.6" // sent as moonshotai/kimi-k2.6 +``` + +Note that Infron reports native cost at the top level of the body (and of the +final stream chunk) rather than nested under `usage.cost`. The SDK normalizes +both shapes into `Usage.Cost`, so cost tracking reads the same either way. + ## API Reference ### AI Client diff --git a/sdk/go/ai/client.go b/sdk/go/ai/client.go index 1545dd74e..0f2a349f7 100644 --- a/sdk/go/ai/client.go +++ b/sdk/go/ai/client.go @@ -110,9 +110,12 @@ func (c *Client) doRequest(ctx context.Context, req *Request) (*Response, error) } httpReq.Header.Set("Authorization", "Bearer "+apiKey) - // Add OpenRouter-specific headers if applicable - if c.config.IsOpenRouter() { + // Add gateway-specific attribution headers if applicable + switch { + case c.config.IsOpenRouter(): applyOpenRouterAttributionHeaders(httpReq.Header, c.config.SiteURL, c.config.SiteName) + case c.config.IsInfron(): + applyInfronAttributionHeaders(httpReq.Header, c.config.SiteURL, c.config.SiteName) } // Execute request @@ -142,6 +145,7 @@ func (c *Client) doRequest(ctx context.Context, req *Request) (*Response, error) if err := json.Unmarshal(respBody, &response); err != nil { return nil, fmt.Errorf("unmarshal response: %w", err) } + response.normalizeNativeCost() return &response, nil } @@ -207,9 +211,12 @@ func (c *Client) StreamComplete(ctx context.Context, prompt string, opts ...Opti httpReq.Header.Set("Authorization", "Bearer "+apiKey) httpReq.Header.Set("Accept", "text/event-stream") - // Add OpenRouter-specific headers if applicable - if c.config.IsOpenRouter() { + // Add gateway-specific attribution headers if applicable + switch { + case c.config.IsOpenRouter(): applyOpenRouterAttributionHeaders(httpReq.Header, c.config.SiteURL, c.config.SiteName) + case c.config.IsInfron(): + applyInfronAttributionHeaders(httpReq.Header, c.config.SiteURL, c.config.SiteName) } // Execute request @@ -288,6 +295,7 @@ func (d *SSEDecoder) Decode() (StreamChunk, error) { if err := json.Unmarshal([]byte(jsonData), &chunk); err != nil { continue // Skip malformed chunks } + chunk.normalizeNativeCost() return chunk, nil } diff --git a/sdk/go/ai/config.go b/sdk/go/ai/config.go index 62a22611f..8f3774958 100644 --- a/sdk/go/ai/config.go +++ b/sdk/go/ai/config.go @@ -7,6 +7,9 @@ import ( "time" ) +// defaultInfronBaseURL is the Infron gateway's OpenAI-compatible endpoint. +const defaultInfronBaseURL = "https://llm.onerouter.pro/v1" + // Config holds AI/LLM configuration for making API calls. type Config struct { // API Key for OpenAI or OpenRouter @@ -15,6 +18,7 @@ type Config struct { // BaseURL can be either OpenAI or OpenRouter endpoint // Default: https://api.openai.com/v1 // OpenRouter: https://openrouter.ai/api/v1 + // Infron: https://llm.onerouter.pro/v1 BaseURL string // Default model to use (e.g., "gpt-4o", "openai/gpt-4o" for OpenRouter) @@ -39,12 +43,23 @@ type Config struct { // DefaultConfig returns a Config with sensible defaults. // It reads from environment variables: // - OPENAI_API_KEY or OPENROUTER_API_KEY +// - INFRON_API_KEY // - AI_BASE_URL (defaults to OpenAI) // - AI_MODEL (defaults to gpt-4o) +// +// A gateway key that was already honored before Infron existed keeps +// precedence, so adding an Infron key to an existing environment never +// silently reroutes it. func DefaultConfig() *Config { apiKey := os.Getenv("OPENAI_API_KEY") baseURL := "https://api.openai.com/v1" + // Check for Infron configuration + if infronKey := os.Getenv("INFRON_API_KEY"); infronKey != "" { + apiKey = infronKey + baseURL = defaultInfronBaseURL + } + // Check for OpenRouter configuration if routerKey := os.Getenv("OPENROUTER_API_KEY"); routerKey != "" { apiKey = routerKey @@ -69,8 +84,11 @@ func DefaultConfig() *Config { MaxTokens: 4096, Timeout: 30 * time.Second, } - if cfg.IsOpenRouter() { + switch { + case cfg.IsOpenRouter(): cfg.SiteURL, cfg.SiteName, _ = resolveOpenRouterAttribution("", "") + case cfg.IsInfron(): + cfg.SiteURL, cfg.SiteName, _ = resolveInfronAttribution("", "") } return cfg } @@ -94,3 +112,14 @@ func (c *Config) IsOpenRouter() bool { return strings.Contains(strings.ToLower(c.BaseURL), "openrouter.ai") || strings.HasPrefix(strings.ToLower(c.Model), "openrouter/") } + +// IsInfron returns true if the base URL is for the Infron gateway. +// +// This is checked last everywhere it is used: the gateways this package +// already supported serve the same `/` ids, so an explicit +// "infron/" prefix is the only thing that distinguishes Infron by model alone, +// and a config that matches both keeps its previous meaning. +func (c *Config) IsInfron() bool { + return strings.Contains(strings.ToLower(c.BaseURL), "onerouter.pro") || + strings.HasPrefix(strings.ToLower(c.Model), infronModelPrefix) +} diff --git a/sdk/go/ai/infron_attribution.go b/sdk/go/ai/infron_attribution.go new file mode 100644 index 000000000..ae0240a97 --- /dev/null +++ b/sdk/go/ai/infron_attribution.go @@ -0,0 +1,82 @@ +package ai + +import ( + "net/http" + "os" + "strings" +) + +const ( + defaultInfronSiteURL = "https://agentfield.ai" + defaultInfronAppName = "AgentField AI" + + // infronModelPrefix marks a model as Infron-routed. It is stripped before + // the request goes out; see stripInfronPrefix. + infronModelPrefix = "infron/" +) + +// stripInfronPrefix removes the routing-only "infron/" prefix from a model +// name, mirroring the prefix handling this package already does on the media +// path. The gateway serves the bare `/` id, so the prefix must +// not reach the wire. +func stripInfronPrefix(model string) string { + if len(model) >= len(infronModelPrefix) && + strings.EqualFold(model[:len(infronModelPrefix)], infronModelPrefix) { + return model[len(infronModelPrefix):] + } + return model +} + +func infronAttributionEnabled() bool { + value := strings.TrimSpace(os.Getenv("AGENTFIELD_INFRON_ATTRIBUTION")) + if value == "" { + return true + } + switch strings.ToLower(value) { + case "0", "false", "no", "off": + return false + default: + return true + } +} + +func resolveInfronAttribution(siteURL, siteName string) (string, string, bool) { + if !infronAttributionEnabled() { + return "", "", false + } + + resolvedURL := firstNonEmpty( + siteURL, + os.Getenv("AGENTFIELD_INFRON_SITE_URL"), + os.Getenv("AGENTFIELD_OPENROUTER_SITE_URL"), + os.Getenv("OR_SITE_URL"), + defaultInfronSiteURL, + ) + resolvedName := firstNonEmpty( + siteName, + os.Getenv("AGENTFIELD_INFRON_APP_NAME"), + os.Getenv("AGENTFIELD_OPENROUTER_APP_NAME"), + os.Getenv("OR_APP_NAME"), + defaultInfronAppName, + ) + return resolvedURL, resolvedName, true +} + +// applyInfronAttributionHeaders sets the app-attribution headers on an Infron +// request. Infron is OpenAI-compatible and accepts the HTTP-Referer / X-Title +// pair this package already sends, so a deployment that already identifies +// itself as "AgentField AI" keeps doing so after switching gateways — the +// attribution values already configured for the existing gateway are honored +// as fallbacks precisely so nobody has to re-declare their identity to move. +func applyInfronAttributionHeaders(header http.Header, siteURL, siteName string) { + resolvedURL, resolvedName, ok := resolveInfronAttribution(siteURL, siteName) + if !ok { + return + } + if resolvedURL != "" { + header.Set("HTTP-Referer", resolvedURL) + } + if resolvedName != "" { + header.Set("X-Title", resolvedName) + } +} diff --git a/sdk/go/ai/infron_attribution_test.go b/sdk/go/ai/infron_attribution_test.go new file mode 100644 index 000000000..b441d2839 --- /dev/null +++ b/sdk/go/ai/infron_attribution_test.go @@ -0,0 +1,314 @@ +package ai + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsInfron(t *testing.T) { + tests := []struct { + name string + baseURL string + model string + expected bool + }{ + { + name: "Infron URL without trailing slash", + baseURL: "https://llm.onerouter.pro/v1", + expected: true, + }, + { + name: "Infron URL with trailing slash", + baseURL: "https://llm.onerouter.pro/v1/", + expected: true, + }, + { + name: "OpenAI URL", + baseURL: "https://api.openai.com/v1", + expected: false, + }, + { + name: "another gateway URL", + baseURL: "https://openrouter.ai/api/v1", + expected: false, + }, + { + name: "empty URL", + baseURL: "", + expected: false, + }, + { + name: "Infron model prefix", + baseURL: "https://api.openai.com/v1", + model: "infron/moonshotai/kimi-k2.6", + expected: true, + }, + { + // Gateways serve the same `/` ids, so a bare id + // must not be attributed to any one of them on its own. + name: "bare shared model id is not Infron", + baseURL: "https://api.openai.com/v1", + model: "moonshotai/kimi-k2.6", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{BaseURL: tt.baseURL, Model: tt.model} + assert.Equal(t, tt.expected, cfg.IsInfron()) + }) + } +} + +func TestDefaultConfigInfronKey(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "") + t.Setenv("OPENROUTER_API_KEY", "") + t.Setenv("INFRON_API_KEY", "infron-key") + t.Setenv("AI_BASE_URL", "") + t.Setenv("AI_MODEL", "") + + cfg := DefaultConfig() + + assert.Equal(t, "infron-key", cfg.APIKey) + assert.Equal(t, defaultInfronBaseURL, cfg.BaseURL) + assert.True(t, cfg.IsInfron()) + assert.Equal(t, defaultInfronSiteURL, cfg.SiteURL) + assert.Equal(t, defaultInfronAppName, cfg.SiteName) +} + +// An Infron key must never move an existing deployment off the gateway it +// already resolves to — that is the backwards-compatibility guarantee of this +// change. +func TestDefaultConfigExistingGatewayWinsOverInfron(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "") + t.Setenv("OPENROUTER_API_KEY", "existing-gateway-key") + t.Setenv("INFRON_API_KEY", "infron-key") + t.Setenv("AI_BASE_URL", "") + t.Setenv("AI_MODEL", "") + + cfg := DefaultConfig() + + assert.Equal(t, "existing-gateway-key", cfg.APIKey) + assert.Equal(t, "https://openrouter.ai/api/v1", cfg.BaseURL) + assert.True(t, cfg.IsOpenRouter()) +} + +func TestDefaultConfigInfronAttributionEnvOverrides(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "") + t.Setenv("OPENROUTER_API_KEY", "") + t.Setenv("INFRON_API_KEY", "infron-key") + t.Setenv("AI_BASE_URL", "") + t.Setenv("AI_MODEL", "") + t.Setenv("AGENTFIELD_INFRON_SITE_URL", "https://custom.example") + t.Setenv("AGENTFIELD_INFRON_APP_NAME", "Custom App") + + cfg := DefaultConfig() + + assert.Equal(t, "https://custom.example", cfg.SiteURL) + assert.Equal(t, "Custom App", cfg.SiteName) +} + +// A deployment that already declared its identity keeps it after switching +// gateways, so nobody has to re-declare to move. +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_SITE_URL", "https://legacy.example") + t.Setenv("AGENTFIELD_OPENROUTER_APP_NAME", "Legacy App") + + header := http.Header{} + applyInfronAttributionHeaders(header, "", "") + + assert.Equal(t, "https://legacy.example", header.Get("HTTP-Referer")) + assert.Equal(t, "Legacy App", header.Get("X-Title")) +} + +func TestApplyInfronAttributionHeadersDisabled(t *testing.T) { + t.Setenv("AGENTFIELD_INFRON_ATTRIBUTION", "false") + + header := http.Header{} + applyInfronAttributionHeaders(header, "https://example.com", "Example") + + assert.Empty(t, header.Get("HTTP-Referer")) + assert.Empty(t, header.Get("X-Title")) +} + +func TestApplyInfronAttributionHeadersDefaults(t *testing.T) { + t.Setenv("AGENTFIELD_INFRON_ATTRIBUTION", "") + t.Setenv("AGENTFIELD_INFRON_SITE_URL", "") + t.Setenv("AGENTFIELD_INFRON_APP_NAME", "") + t.Setenv("AGENTFIELD_OPENROUTER_SITE_URL", "") + t.Setenv("AGENTFIELD_OPENROUTER_APP_NAME", "") + 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")) +} + +// --------------------------------------------------------------------------- +// Model prefix stripping +// --------------------------------------------------------------------------- + +func TestStripInfronPrefix(t *testing.T) { + tests := []struct{ in, want string }{ + {"infron/moonshotai/kimi-k2.6", "moonshotai/kimi-k2.6"}, + {"INFRON/deepseek/deepseek-v4-flash", "deepseek/deepseek-v4-flash"}, + {"moonshotai/kimi-k2.6", "moonshotai/kimi-k2.6"}, + {"openrouter/moonshotai/kimi-k2.6", "openrouter/moonshotai/kimi-k2.6"}, + {"", ""}, + {"infron/", ""}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, stripInfronPrefix(tt.in), tt.in) + } +} + +// The prefix is a routing marker only. If it reaches the gateway the request +// is rejected with "No available providers for model infron/...". +func TestMarshalRequestStripsInfronPrefix(t *testing.T) { + client, err := NewClient(&Config{ + APIKey: "k", + BaseURL: defaultInfronBaseURL, + Model: "infron/moonshotai/kimi-k2.6", + }) + require.NoError(t, err) + + req := &Request{Model: "infron/moonshotai/kimi-k2.6"} + body, err := client.marshalRequest(req) + require.NoError(t, err) + + var wire map[string]any + require.NoError(t, json.Unmarshal(body, &wire)) + assert.Equal(t, "moonshotai/kimi-k2.6", wire["model"]) + + // The caller's Request must not be mutated. + assert.Equal(t, "infron/moonshotai/kimi-k2.6", req.Model) +} + +func TestMarshalRequestLeavesBareModelAlone(t *testing.T) { + client, err := NewClient(&Config{ + APIKey: "k", + BaseURL: defaultInfronBaseURL, + Model: "moonshotai/kimi-k2.6", + }) + require.NoError(t, err) + + body, err := client.marshalRequest(&Request{Model: "moonshotai/kimi-k2.6"}) + require.NoError(t, err) + + var wire map[string]any + require.NoError(t, json.Unmarshal(body, &wire)) + assert.Equal(t, "moonshotai/kimi-k2.6", wire["model"]) +} + +// Infron requests must carry the usage opt-in so responses report cost. +func TestMarshalRequestAddsUsageIncludeForInfron(t *testing.T) { + client, err := NewClient(&Config{ + APIKey: "k", + BaseURL: defaultInfronBaseURL, + Model: "moonshotai/kimi-k2.6", + }) + require.NoError(t, err) + + body, err := client.marshalRequest(&Request{Model: "moonshotai/kimi-k2.6"}) + require.NoError(t, err) + + var wire map[string]any + require.NoError(t, json.Unmarshal(body, &wire)) + usage, ok := wire["usage"].(map[string]any) + require.True(t, ok, "usage opt-in missing: %s", body) + assert.Equal(t, true, usage["include"]) +} + +// --------------------------------------------------------------------------- +// Top-level native cost normalization +// --------------------------------------------------------------------------- + +// Infron reports the native cost at the top level of the body rather than +// nested under usage. Both shapes must land in Usage.Cost so the cost tracker +// records cost_source "provider" either way. +func TestResponseNormalizesTopLevelCost(t *testing.T) { + body := `{ + "id": "chatcmpl-1", + "model": "deepseek/deepseek-v4-flash", + "cost": 0.000002, + "choices": [], + "usage": {"prompt_tokens": 12, "completion_tokens": 9, "total_tokens": 21} + }` + + var resp Response + require.NoError(t, json.Unmarshal([]byte(body), &resp)) + resp.normalizeNativeCost() + + require.NotNil(t, resp.Usage) + require.NotNil(t, resp.Usage.Cost) + assert.InDelta(t, 0.000002, *resp.Usage.Cost, 1e-12) +} + +func TestResponseNormalizeCreatesUsageWhenAbsent(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) +} + +// An explicit usage.cost is authoritative and must not be overwritten. +func TestResponseNormalizeDoesNotClobberUsageCost(t *testing.T) { + body := `{"cost": 9.99, "usage": {"cost": 0.25}}` + + var resp Response + require.NoError(t, json.Unmarshal([]byte(body), &resp)) + resp.normalizeNativeCost() + + require.NotNil(t, resp.Usage.Cost) + assert.InDelta(t, 0.25, *resp.Usage.Cost, 1e-12) +} + +func TestResponseNormalizeNoopWithoutCost(t *testing.T) { + var resp Response + require.NoError(t, json.Unmarshal([]byte(`{"usage": {"prompt_tokens": 3}}`), &resp)) + resp.normalizeNativeCost() + + require.NotNil(t, resp.Usage) + assert.Nil(t, resp.Usage.Cost, "nil cost means unknown, not free") +} + +// The streaming path carries the same shape on the final chunk. +func TestStreamChunkNormalizesTopLevelCost(t *testing.T) { + body := `{ + "id": "chatcmpl-2", + "object": "chat.completion.chunk", + "cost": 0.000003, + "choices": [], + "usage": {"prompt_tokens": 5, "completion_tokens": 10, "total_tokens": 15} + }` + + var chunk StreamChunk + require.NoError(t, json.Unmarshal([]byte(body), &chunk)) + chunk.normalizeNativeCost() + + require.NotNil(t, chunk.Usage) + require.NotNil(t, chunk.Usage.Cost) + assert.InDelta(t, 0.000003, *chunk.Usage.Cost, 1e-12) +} + +func TestStreamChunkNormalizeNoopWithoutCost(t *testing.T) { + var chunk StreamChunk + require.NoError(t, json.Unmarshal([]byte(`{"choices": []}`), &chunk)) + chunk.normalizeNativeCost() + + assert.Nil(t, chunk.Usage) +} diff --git a/sdk/go/ai/model_params.go b/sdk/go/ai/model_params.go index 1342dbb19..82c105c94 100644 --- a/sdk/go/ai/model_params.go +++ b/sdk/go/ai/model_params.go @@ -87,11 +87,15 @@ func isVouchedRewriteEndpoint(baseURL string) bool { // rewritten to max_completion_tokens. OpenRouter requests are opted into // native usage accounting so responses carry usage.cost. func (c *Client) marshalRequest(req *Request) ([]byte, error) { - // OpenRouter only reports the native cost of a call when the request - // carries {"usage": {"include": true}}; opt in here so both the sync and - // streaming paths get provider cost accounting. Requests that already set - // Usage explicitly are left alone. - if req.Usage == nil && c.config.IsOpenRouter() { + // Gateways only report the native cost of a call when the request carries + // {"usage": {"include": true}}; opt in here so both the sync and streaming + // paths get provider cost accounting. Requests that already set Usage + // explicitly are left alone. + // + // Infron returns that cost at the top level of the body rather than nested + // under usage, which Response and StreamChunk normalize on parse (see + // normalizeNativeCost). + if req.Usage == nil && (c.config.IsOpenRouter() || c.config.IsInfron()) { req.Usage = &RequestUsage{Include: true} } @@ -100,6 +104,24 @@ func (c *Client) marshalRequest(req *Request) ([]byte, error) { model = c.config.Model } + // The "infron/" prefix is a routing marker for callers that select a + // gateway by model string; the gateway itself serves the bare + // `/` id, so it must not reach the wire. Without this, + // "infron/moonshotai/kimi-k2.6" is rejected with "No available providers + // for model infron/moonshotai/kimi-k2.6". + // + // Only a non-empty req.Model is rewritten, and only on a copy: the caller's + // Request is left untouched, c.config.Model keeps what was configured, and + // IsInfron() still reports the truth. + if c.config.IsInfron() && req.Model != "" { + if stripped := stripInfronPrefix(req.Model); stripped != req.Model { + model = stripped + shadow := *req + shadow.Model = stripped + req = &shadow + } + } + // If the model needs max_completion_tokens and we have a max_tokens value, // serialize with the rewritten field name — but only for endpoints known // to understand it. diff --git a/sdk/go/ai/response.go b/sdk/go/ai/response.go index 14713018a..f76432df2 100644 --- a/sdk/go/ai/response.go +++ b/sdk/go/ai/response.go @@ -6,7 +6,7 @@ import ( "strings" ) -// Response represents the API response from OpenAI/OpenRouter. +// Response represents the API response from an OpenAI-compatible endpoint. type Response struct { ID string `json:"id"` Object string `json:"object"` @@ -14,6 +14,32 @@ type Response struct { Model string `json:"model"` Choices []Choice `json:"choices"` Usage *Usage `json:"usage,omitempty"` + + // Cost is the provider-native cost when the gateway reports it at the top + // level of the response body rather than nested under usage. Infron does + // this. Read Usage.Cost instead of this field — normalizeNativeCost folds + // one into the other so every consumer has a single place to look. + Cost *float64 `json:"cost,omitempty"` +} + +// normalizeNativeCost folds a top-level cost into Usage.Cost so cost tracking +// works identically across gateways. +// +// Without this, an Infron response parses with Usage.Cost == nil, which the +// 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. +func (r *Response) normalizeNativeCost() { + if r == nil || r.Cost == nil { + return + } + if r.Usage == nil { + r.Usage = &Usage{} + } + if r.Usage.Cost == nil { + cost := *r.Cost + r.Usage.Cost = &cost + } } // Choice represents a completion choice. @@ -86,6 +112,25 @@ type StreamChunk struct { // accounting (e.g. OpenRouter with usage.include, OpenAI with // stream_options.include_usage). Nil on ordinary content chunks. Usage *Usage `json:"usage,omitempty"` + + // Cost mirrors Response.Cost for the streaming path: Infron puts the + // native cost at the top level of the final chunk. Read Usage.Cost. + Cost *float64 `json:"cost,omitempty"` +} + +// normalizeNativeCost folds a top-level chunk cost into Usage.Cost. See +// Response.normalizeNativeCost. +func (s *StreamChunk) normalizeNativeCost() { + if s == nil || s.Cost == nil { + return + } + if s.Usage == nil { + s.Usage = &Usage{} + } + if s.Usage.Cost == nil { + cost := *s.Cost + s.Usage.Cost = &cost + } } // StreamDelta represents a delta in a streaming response. From 402294f9edee54aa8cc218d72b9d34a394dfd2fb Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 5 Aug 2026 13:08:37 -0400 Subject: [PATCH 2/2] fix(sdk/go/ai): keep OPENAI_API_KEY precedence over INFRON_API_KEY DefaultConfig applied the Infron block unconditionally, so an environment with OPENAI_API_KEY set and INFRON_API_KEY added resolved to the Infron key and base URL. That contradicts the guarantee stated in DefaultConfig's own doc comment and in ENVIRONMENT_VARIABLES.md, and it matters because spawned agent processes inherit the parent environment -- one exported INFRON_API_KEY would move every Go agent's traffic and credential to a different gateway. The existing precedence test cleared OPENAI_API_KEY on its first line, so it only exercised the OpenRouter branch and the gap passed CI green. Adds the regression test for the OpenAI case plus one pinning that Infron still applies when it is the only gateway key set, and names the OpenRouter attribution fallback vars in the docs so operators can audit what feeds the gateway. --- docs/ENVIRONMENT_VARIABLES.md | 4 ++-- sdk/go/ai/config.go | 9 +++++-- sdk/go/ai/infron_attribution_test.go | 35 ++++++++++++++++++++++++++++ 3 files changed, 44 insertions(+), 4 deletions(-) diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 8d57ea2f8..aa6c0b97f 100644 --- a/docs/ENVIRONMENT_VARIABLES.md +++ b/docs/ENVIRONMENT_VARIABLES.md @@ -167,7 +167,7 @@ Explicit SDK config or explicit request headers win over env defaults. `AGENTFIE ### Infron -- `INFRON_API_KEY`: API key for the Infron gateway. When it is the only gateway key set, the Go SDK's `ai.DefaultConfig()` points at `https://llm.onerouter.pro/v1`. A gateway key already configured above keeps precedence, so adding this key never reroutes an existing deployment. +- `INFRON_API_KEY`: API key for the Infron gateway. When it is the only gateway key set, the Go SDK's `ai.DefaultConfig()` points at `https://llm.onerouter.pro/v1` (`onerouter.pro` is the domain Infron serves its gateway from). `OPENAI_API_KEY` and `OPENROUTER_API_KEY` both keep precedence over it, so adding this key never reroutes an existing deployment. Infron is OpenAI-compatible and serves the standard `/` ids, so a model moves across by prefix alone (`infron/moonshotai/kimi-k2.6`). The `infron/` prefix is a routing marker only and is stripped before the request is sent, since the gateway serves the bare id. @@ -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, the attribution values documented above are used as fallbacks, so a deployment that already declares its identity keeps it after switching gateways. +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`. diff --git a/sdk/go/ai/config.go b/sdk/go/ai/config.go index 8f3774958..ef104969c 100644 --- a/sdk/go/ai/config.go +++ b/sdk/go/ai/config.go @@ -8,6 +8,8 @@ import ( ) // defaultInfronBaseURL is the Infron gateway's OpenAI-compatible endpoint. +// onerouter.pro is the domain Infron serves its gateway from; the two names +// refer to the same service, so grepping for either one should land here. const defaultInfronBaseURL = "https://llm.onerouter.pro/v1" // Config holds AI/LLM configuration for making API calls. @@ -54,8 +56,11 @@ func DefaultConfig() *Config { apiKey := os.Getenv("OPENAI_API_KEY") baseURL := "https://api.openai.com/v1" - // Check for Infron configuration - if infronKey := os.Getenv("INFRON_API_KEY"); infronKey != "" { + // Check for Infron configuration. Only when no direct-provider key is + // already set: an existing OPENAI_API_KEY keeps precedence so that adding + // INFRON_API_KEY to a configured environment cannot silently move traffic + // (and the credential) to a different gateway. + if infronKey := os.Getenv("INFRON_API_KEY"); infronKey != "" && apiKey == "" { apiKey = infronKey baseURL = defaultInfronBaseURL } diff --git a/sdk/go/ai/infron_attribution_test.go b/sdk/go/ai/infron_attribution_test.go index b441d2839..7454f5026 100644 --- a/sdk/go/ai/infron_attribution_test.go +++ b/sdk/go/ai/infron_attribution_test.go @@ -98,6 +98,41 @@ func TestDefaultConfigExistingGatewayWinsOverInfron(t *testing.T) { assert.True(t, cfg.IsOpenRouter()) } +// The guarantee in DefaultConfig's doc comment is that adding INFRON_API_KEY to +// an already-configured environment never reroutes it. OPENAI_API_KEY is such +// an environment, and it is the case the OpenRouter test above cannot cover +// because it clears the key. Agent processes inherit the parent environment, so +// a single exported INFRON_API_KEY reaching this branch would move every Go +// agent's traffic — and its credential — to a different gateway. +func TestDefaultConfigExistingOpenAIKeyWinsOverInfron(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "existing-openai-key") + t.Setenv("OPENROUTER_API_KEY", "") + t.Setenv("INFRON_API_KEY", "infron-key") + t.Setenv("AI_BASE_URL", "") + t.Setenv("AI_MODEL", "") + + cfg := DefaultConfig() + + assert.Equal(t, "existing-openai-key", cfg.APIKey) + assert.Equal(t, "https://api.openai.com/v1", cfg.BaseURL) + assert.False(t, cfg.IsInfron()) +} + +// Infron still applies when it is the only gateway key set. +func TestDefaultConfigInfronAppliesWhenNoDirectKey(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "") + t.Setenv("OPENROUTER_API_KEY", "") + t.Setenv("INFRON_API_KEY", "infron-key") + t.Setenv("AI_BASE_URL", "") + t.Setenv("AI_MODEL", "") + + cfg := DefaultConfig() + + assert.Equal(t, "infron-key", cfg.APIKey) + assert.Equal(t, defaultInfronBaseURL, cfg.BaseURL) + assert.True(t, cfg.IsInfron()) +} + func TestDefaultConfigInfronAttributionEnvOverrides(t *testing.T) { t.Setenv("OPENAI_API_KEY", "") t.Setenv("OPENROUTER_API_KEY", "")