diff --git a/docs/ENVIRONMENT_VARIABLES.md b/docs/ENVIRONMENT_VARIABLES.md index 8c56b05af..aa6c0b97f 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` (`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. + +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, 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/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..ef104969c 100644 --- a/sdk/go/ai/config.go +++ b/sdk/go/ai/config.go @@ -7,6 +7,11 @@ import ( "time" ) +// 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. type Config struct { // API Key for OpenAI or OpenRouter @@ -15,6 +20,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 +45,26 @@ 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. 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 + } + // Check for OpenRouter configuration if routerKey := os.Getenv("OPENROUTER_API_KEY"); routerKey != "" { apiKey = routerKey @@ -69,8 +89,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 +117,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..7454f5026 --- /dev/null +++ b/sdk/go/ai/infron_attribution_test.go @@ -0,0 +1,349 @@ +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()) +} + +// 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", "") + 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.