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
14 changes: 14 additions & 0 deletions docs/ENVIRONMENT_VARIABLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<provider>/<model>` 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`.
30 changes: 30 additions & 0 deletions sdk/go/ai/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,36 @@ aiConfig := &ai.Config{
}
```

### Infron Configuration

Infron is an OpenAI-compatible gateway that serves the standard
`<provider>/<model>` 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
Expand Down
16 changes: 12 additions & 4 deletions sdk/go/ai/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
36 changes: 35 additions & 1 deletion sdk/go/ai/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
}
Expand All @@ -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 `<provider>/<model>` 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)
}
82 changes: 82 additions & 0 deletions sdk/go/ai/infron_attribution.go
Original file line number Diff line number Diff line change
@@ -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 `<provider>/<model>` 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)
}
}
Loading
Loading