From 13dde2da15e8e1cf136fe72642dac620a103cda5 Mon Sep 17 00:00:00 2001
From: BaaRai <130491490+BaaRai@users.noreply.github.com>
Date: Sun, 8 Nov 2026 17:11:43 +0100
Subject: [PATCH 1/2] feat(llm): optional custom User-Agent for LLM requests
Add an optional per-profile User-Agent that overrides the header on every
outbound LLM request. When empty (the default), the existing behaviour is
unchanged and the default User-Agent is left untouched.
The value is trimmed and validated: control characters / newlines (header
injection) and values longer than 256 bytes are rejected. Wired through the
profile config struct, the Postgres llm_profiles schema, the profile API/DTO,
the connection-test path, and the LLM profile form, following the existing
session-header field. Addresses part of #154; the fixed-full-URL mode is not
included.
---
CHANGELOG.md | 6 +++
agent/provider.go | 42 ++++++++++++++--
agent/provider_capture_test.go | 6 +--
agent/user_agent_test.go | 68 ++++++++++++++++++++++++++
db/config.go | 25 ++++++----
db/schema.sql | 4 ++
server/dto.go | 4 ++
server/server.go | 13 ++++-
server/server_mgmt.go | 8 +++
server/side_questions.go | 2 +-
web/src/app/(main)/system/llm/page.tsx | 19 +++++++
web/src/lib/api.ts | 3 ++
web/src/lib/types.ts | 2 +
13 files changed, 184 insertions(+), 18 deletions(-)
create mode 100644 agent/user_agent_test.go
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7bec55b..71b4785 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,12 @@
All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
+## [Unreleased]
+
+### Added
+
+- **Optional custom User-Agent for LLM requests**: an LLM profile can set a `User-Agent` value that overrides the header on every outbound LLM request; left empty, the default User-Agent is unchanged. The value is trimmed and rejected if it contains control characters or exceeds 256 bytes. Part of #154 (the fixed-full-URL mode is not included).
+
## [0.5.1] - 2026-11-05
Login rate limiting per client IP with trusted-proxy support, a 12-character minimum for new passwords, a loopback-only default for the binary, and the JWT signing key in its own Docker volume.
diff --git a/agent/provider.go b/agent/provider.go
index 005f542..98bd645 100644
--- a/agent/provider.go
+++ b/agent/provider.go
@@ -86,6 +86,10 @@ type Config struct {
// context via transcript.WithSessionID and read by the RoundTripper, so even a
// shared provider emits different header values per session.
SessionHeaderKey string
+ // UserAgent, when non-empty, overrides the User-Agent header on every outbound
+ // LLM request built from this config. Empty = don't set it, leaving the Go/Norma
+ // default UA untouched (the historical behavior). Validated by ValidateUserAgent.
+ UserAgent string
// Retry holds the retry parameters resolved for this config (profile override →
// global policy → built-in default, resolved server-side). See RetryConfig for
// the per-layer semantics; zero values = fully inherit the built-in defaults.
@@ -341,7 +345,7 @@ func (c Config) Provider() string {
// limiter lives on the single provider instance — so planner + all workers +
// main agent (which share this provider) are bounded by one shared rate limit.
func (c Config) NewProvider() (llm.Provider, error) {
- client, err := quotaAwareHTTPClient(c.Proxy, c.SessionHeaderKey)
+ client, err := quotaAwareHTTPClient(c.Proxy, c.SessionHeaderKey, c.UserAgent)
if err != nil {
return nil, err
}
@@ -440,6 +444,9 @@ type quotaAwareTransport struct {
// carries; its value is the session id read from the request context. Empty
// disables it. See Config.SessionHeaderKey.
sessionHeaderKey string
+ // userAgent, when non-empty, is set as the User-Agent header on every request.
+ // Empty leaves the default UA untouched. See Config.UserAgent.
+ userAgent string
}
func (t quotaAwareTransport) RoundTrip(req *http.Request) (*http.Response, error) {
@@ -452,6 +459,11 @@ func (t quotaAwareTransport) RoundTrip(req *http.Request) (*http.Response, error
req.Header.Set(t.sessionHeaderKey, sid)
}
}
+ // Custom User-Agent: override only when configured, so an empty value keeps the
+ // underlying transport's default UA exactly as before.
+ if t.userAgent != "" {
+ req.Header.Set("User-Agent", t.userAgent)
+ }
// When LLM recording is on, the Recorder puts a Capture on the context so the
// raw wire bodies can be persisted. This is the only layer that still sees
// them: norma builds the request body internally and decodes the SSE response
@@ -504,7 +516,7 @@ func requestBodySnapshot(req *http.Request) string {
return string(b)
}
-func quotaAwareHTTPClient(proxy, sessionHeaderKey string) (*http.Client, error) {
+func quotaAwareHTTPClient(proxy, sessionHeaderKey, userAgent string) (*http.Client, error) {
transport := http.DefaultTransport.(*http.Transport).Clone()
proxy = strings.TrimSpace(proxy)
if proxy == "" {
@@ -523,7 +535,31 @@ func quotaAwareHTTPClient(proxy, sessionHeaderKey string) (*http.Client, error)
}
transport.Proxy = http.ProxyURL(proxyURL)
}
- return &http.Client{Transport: quotaAwareTransport{base: transport, sessionHeaderKey: strings.TrimSpace(sessionHeaderKey)}}, nil
+ return &http.Client{Transport: quotaAwareTransport{base: transport, sessionHeaderKey: strings.TrimSpace(sessionHeaderKey), userAgent: strings.TrimSpace(userAgent)}}, nil
+}
+
+// MaxUserAgentLen bounds a custom User-Agent value. Generous for real UA strings
+// while keeping a pathological value out of every outbound request.
+const MaxUserAgentLen = 256
+
+// ValidateUserAgent trims a custom User-Agent and returns the cleaned value. An
+// empty/blank input is valid and means "no override" (keep the default UA). A
+// non-empty value is rejected when it exceeds MaxUserAgentLen or contains any
+// control character (including CR/LF, which would allow header injection).
+func ValidateUserAgent(raw string) (string, error) {
+ ua := strings.TrimSpace(raw)
+ if ua == "" {
+ return "", nil
+ }
+ if len(ua) > MaxUserAgentLen {
+ return "", fmt.Errorf("user agent too long: %d bytes (max %d)", len(ua), MaxUserAgentLen)
+ }
+ for _, r := range ua {
+ if r < 0x20 || r == 0x7f {
+ return "", fmt.Errorf("user agent contains a control character")
+ }
+ }
+ return ua, nil
}
// logTestConnection prints the raw HTTP status code(s) and response body of a
diff --git a/agent/provider_capture_test.go b/agent/provider_capture_test.go
index c097c21..f1733ac 100644
--- a/agent/provider_capture_test.go
+++ b/agent/provider_capture_test.go
@@ -21,7 +21,7 @@ func TestRoundTripCapturesRawBodies(t *testing.T) {
}))
defer srv.Close()
- client, err := quotaAwareHTTPClient("", "")
+ client, err := quotaAwareHTTPClient("", "", "")
if err != nil {
t.Fatalf("client: %v", err)
}
@@ -65,7 +65,7 @@ func TestRoundTripCaptures429BodyAlongsideQuotaRewrite(t *testing.T) {
}))
defer srv.Close()
- client, err := quotaAwareHTTPClient("", "")
+ client, err := quotaAwareHTTPClient("", "", "")
if err != nil {
t.Fatalf("client: %v", err)
}
@@ -106,7 +106,7 @@ func TestRoundTripWithoutCaptureIsUnchanged(t *testing.T) {
}))
defer srv.Close()
- client, err := quotaAwareHTTPClient("", "")
+ client, err := quotaAwareHTTPClient("", "", "")
if err != nil {
t.Fatalf("client: %v", err)
}
diff --git a/agent/user_agent_test.go b/agent/user_agent_test.go
new file mode 100644
index 0000000..8699be5
--- /dev/null
+++ b/agent/user_agent_test.go
@@ -0,0 +1,68 @@
+package agent
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// serveUA spins up an httptest server that records the User-Agent of the request
+// it receives, drives one request through a quotaAwareHTTPClient built with the
+// given userAgent, and returns what actually reached the wire.
+func serveUA(t *testing.T, userAgent string) string {
+ t.Helper()
+ got := make(chan string, 1)
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ got <- r.Header.Get("User-Agent")
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer srv.Close()
+
+ client, err := quotaAwareHTTPClient("", "", userAgent)
+ if err != nil {
+ t.Fatalf("quotaAwareHTTPClient: %v", err)
+ }
+ resp, err := client.Get(srv.URL)
+ if err != nil {
+ t.Fatalf("GET: %v", err)
+ }
+ resp.Body.Close()
+ return <-got
+}
+
+func TestUserAgentHeaderSetWhenConfigured(t *testing.T) {
+ const want = "artex-test-agent/1.2.3"
+ if got := serveUA(t, want); got != want {
+ t.Fatalf("User-Agent = %q, want %q", got, want)
+ }
+}
+
+func TestUserAgentHeaderDefaultWhenEmpty(t *testing.T) {
+ // Empty = no override: the Go client's default UA must reach the server unchanged.
+ got := serveUA(t, "")
+ if !strings.HasPrefix(got, "Go-http-client/") {
+ t.Fatalf("User-Agent = %q, want the default Go-http-client UA when unset", got)
+ }
+}
+
+func TestValidateUserAgentRejectsNewline(t *testing.T) {
+ if _, err := ValidateUserAgent("good/1.0\r\nX-Injected: 1"); err == nil {
+ t.Fatalf("expected a newline-containing user agent to be rejected")
+ }
+}
+
+func TestValidateUserAgentRejectsTooLong(t *testing.T) {
+ if _, err := ValidateUserAgent(strings.Repeat("a", MaxUserAgentLen+1)); err == nil {
+ t.Fatalf("expected a user agent longer than %d to be rejected", MaxUserAgentLen)
+ }
+}
+
+func TestValidateUserAgentTrimsAndAllowsEmpty(t *testing.T) {
+ if ua, err := ValidateUserAgent(" "); err != nil || ua != "" {
+ t.Fatalf("blank user agent: got (%q, %v), want (\"\", nil)", ua, err)
+ }
+ if ua, err := ValidateUserAgent(" my-app/1.0 "); err != nil || ua != "my-app/1.0" {
+ t.Fatalf("trim: got (%q, %v), want (\"my-app/1.0\", nil)", ua, err)
+ }
+}
diff --git a/db/config.go b/db/config.go
index a20977e..6c779cc 100644
--- a/db/config.go
+++ b/db/config.go
@@ -64,6 +64,10 @@ type LLMProfile struct {
// or sticky routing off a session-id header. "" = not sent. Maps to
// agent.Config.SessionHeaderKey.
SessionHeaderKey string `json:"session_header_key"`
+ // UserAgent, when non-empty, overrides the User-Agent header on every outbound
+ // request built from this profile. "" = don't override (keep the default UA);
+ // old profiles behave exactly as before. Maps to agent.Config.UserAgent.
+ UserAgent string `json:"user_agent"`
// Cost ledger: EUR per 1M tokens (0 = unset; cost is computed only when a
// price is set). Set through the same profile API/SaveProfile path.
PriceInputPerM float64 `json:"price_input_per_m,omitempty"`
@@ -89,8 +93,8 @@ type RetryOverride struct {
// profileCols is the read column list (hint variant, no api key) shared by the
// list query; profileColsKey is the same with api_key for the single-row loads.
const profileRetryCols = `COALESCE(retry_connect_attempts,0),COALESCE(retry_connect_interval_ms,0),COALESCE(retry_empty_attempts,0),COALESCE(retry_empty_interval_ms,0),COALESCE(retry_stream_attempts,0),COALESCE(retry_stream_interval_ms,0)`
-const profileCols = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key_hint,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,''),COALESCE(session_header_key,''),` + profileRetryCols + `,COALESCE(price_input_per_m,0),COALESCE(price_output_per_m,0),COALESCE(price_cache_read_per_m,0),COALESCE(price_cache_write_per_m,0)`
-const profileColsKey = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,''),COALESCE(session_header_key,''),` + profileRetryCols + `,COALESCE(price_input_per_m,0),COALESCE(price_output_per_m,0),COALESCE(price_cache_read_per_m,0),COALESCE(price_cache_write_per_m,0)`
+const profileCols = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key_hint,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,''),COALESCE(session_header_key,''),` + profileRetryCols + `,COALESCE(price_input_per_m,0),COALESCE(price_output_per_m,0),COALESCE(price_cache_read_per_m,0),COALESCE(price_cache_write_per_m,0),COALESCE(user_agent,'')`
+const profileColsKey = `id,name,format,COALESCE(base_url,''),COALESCE(proxy,''),model,COALESCE(api_key,''),rate_per_second,rate_per_minute,context_window_k,COALESCE(reasoning_effort,''),is_default,priority,pool_exclude,COALESCE(thinking_type,''),COALESCE(streaming,true),COALESCE(max_tokens,0),COALESCE(max_tokens_field,''),COALESCE(session_header_key,''),` + profileRetryCols + `,COALESCE(price_input_per_m,0),COALESCE(price_output_per_m,0),COALESCE(price_cache_read_per_m,0),COALESCE(price_cache_write_per_m,0),COALESCE(user_agent,'')`
// scanProfile reads one row in the profileCols / profileColsKey column order. The
// 7th column lands in APIKeyHint or APIKey depending on which list the caller used.
@@ -101,7 +105,8 @@ func scanProfile(sc interface{ Scan(...any) error }, into *string, p *LLMProfile
&p.Retry.Connect.Attempts, &p.Retry.Connect.IntervalMS,
&p.Retry.Empty.Attempts, &p.Retry.Empty.IntervalMS,
&p.Retry.Stream.Attempts, &p.Retry.Stream.IntervalMS,
- &p.PriceInputPerM, &p.PriceOutputPerM, &p.PriceCacheReadPerM, &p.PriceCacheWritePerM)
+ &p.PriceInputPerM, &p.PriceOutputPerM, &p.PriceCacheReadPerM, &p.PriceCacheWritePerM,
+ &p.UserAgent)
}
func (d *DB) ListProfiles() ([]*LLMProfile, error) {
@@ -180,27 +185,27 @@ func (d *DB) SaveProfile(p *LLMProfile) (int64, error) {
r := p.Retry.Clamped()
if p.ID == 0 {
var id int64
- err := d.QueryRow(`INSERT INTO llm_profiles(name,format,base_url,proxy,model,api_key,api_key_hint,rate_per_second,rate_per_minute,context_window_k,reasoning_effort,priority,pool_exclude,thinking_type,streaming,max_tokens,max_tokens_field,session_header_key,retry_connect_attempts,retry_connect_interval_ms,retry_empty_attempts,retry_empty_interval_ms,retry_stream_attempts,retry_stream_interval_ms)
-VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24) RETURNING id`,
+ err := d.QueryRow(`INSERT INTO llm_profiles(name,format,base_url,proxy,model,api_key,api_key_hint,rate_per_second,rate_per_minute,context_window_k,reasoning_effort,priority,pool_exclude,thinking_type,streaming,max_tokens,max_tokens_field,session_header_key,retry_connect_attempts,retry_connect_interval_ms,retry_empty_attempts,retry_empty_interval_ms,retry_stream_attempts,retry_stream_interval_ms,user_agent)
+VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25) RETURNING id`,
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.SessionHeaderKey,
- r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS).Scan(&id)
+ r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS, p.UserAgent).Scan(&id)
if err == nil {
_, err = d.saveProfilePrices(id, p)
}
return id, err
}
if p.APIKey == "" {
- _, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,rate_per_second=$6,rate_per_minute=$7,context_window_k=$8,reasoning_effort=$9,priority=$10,pool_exclude=$11,thinking_type=$12,streaming=$13,max_tokens=$14,max_tokens_field=$15,session_header_key=$16,retry_connect_attempts=$17,retry_connect_interval_ms=$18,retry_empty_attempts=$19,retry_empty_interval_ms=$20,retry_stream_attempts=$21,retry_stream_interval_ms=$22 WHERE id=$23`,
+ _, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,rate_per_second=$6,rate_per_minute=$7,context_window_k=$8,reasoning_effort=$9,priority=$10,pool_exclude=$11,thinking_type=$12,streaming=$13,max_tokens=$14,max_tokens_field=$15,session_header_key=$16,retry_connect_attempts=$17,retry_connect_interval_ms=$18,retry_empty_attempts=$19,retry_empty_interval_ms=$20,retry_stream_attempts=$21,retry_stream_interval_ms=$22,user_agent=$23 WHERE id=$24`,
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.SessionHeaderKey,
- r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS, p.ID)
+ r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS, p.UserAgent, p.ID)
if err == nil {
_, err = d.saveProfilePrices(p.ID, p)
}
return p.ID, err
}
- _, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,api_key=$6,api_key_hint=$7,rate_per_second=$8,rate_per_minute=$9,context_window_k=$10,reasoning_effort=$11,priority=$12,pool_exclude=$13,thinking_type=$14,streaming=$15,max_tokens=$16,max_tokens_field=$17,session_header_key=$18,retry_connect_attempts=$19,retry_connect_interval_ms=$20,retry_empty_attempts=$21,retry_empty_interval_ms=$22,retry_stream_attempts=$23,retry_stream_interval_ms=$24 WHERE id=$25`,
+ _, err := d.Exec(`UPDATE llm_profiles SET name=$1,format=$2,base_url=NULLIF($3,''),proxy=NULLIF($4,''),model=$5,api_key=$6,api_key_hint=$7,rate_per_second=$8,rate_per_minute=$9,context_window_k=$10,reasoning_effort=$11,priority=$12,pool_exclude=$13,thinking_type=$14,streaming=$15,max_tokens=$16,max_tokens_field=$17,session_header_key=$18,retry_connect_attempts=$19,retry_connect_interval_ms=$20,retry_empty_attempts=$21,retry_empty_interval_ms=$22,retry_stream_attempts=$23,retry_stream_interval_ms=$24,user_agent=$25 WHERE id=$26`,
p.Name, p.Format, p.BaseURL, p.Proxy, p.Model, p.APIKey, hint, p.RatePerSecond, p.RatePerMinute, p.ContextWindowK, p.ReasoningEffort, p.Priority, p.PoolExclude, p.ThinkingType, p.Streaming, p.MaxTokens, p.MaxTokensField, p.SessionHeaderKey,
- r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS, p.ID)
+ r.Connect.Attempts, r.Connect.IntervalMS, r.Empty.Attempts, r.Empty.IntervalMS, r.Stream.Attempts, r.Stream.IntervalMS, p.UserAgent, p.ID)
if err == nil {
_, err = d.saveProfilePrices(p.ID, p)
}
diff --git a/db/schema.sql b/db/schema.sql
index 5bb34e9..4d73a59 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -396,6 +396,8 @@ CREATE TABLE IF NOT EXISTS llm_profiles (
-- Custom session header: when non-empty, every request carries an HTTP header of this name whose value is the current session id
-- (chat session/worker intent). For gateways that do prompt caching/sticky routing by a session-id header. ''=do not send.
session_header_key TEXT NOT NULL DEFAULT '',
+ -- Custom User-Agent: when non-empty, every outbound LLM request overrides the User-Agent header with this value. ''=do not override (keep the default UA).
+ user_agent TEXT NOT NULL DEFAULT '',
-- Retry overrides: attempts 0=use global default / -1=disable / >0=use this value; interval 0=use default exponential backoff / >0=fixed milliseconds.
-- The three groups correspond to connect retry, empty-response retry, and same-provider safe-window retry; see the ALTER comment below.
retry_connect_attempts INTEGER NOT NULL DEFAULT 0,
@@ -436,6 +438,8 @@ ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_max_tokens_check
CHECK (max_tokens >= 0);
-- Custom session header name; backfills old databases. Default '' = do not send; old configs behave unchanged.
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS session_header_key TEXT NOT NULL DEFAULT '';
+-- Custom User-Agent; backfills old databases. Default '' = do not override (keep the default UA); old configs behave unchanged.
+ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS user_agent TEXT NOT NULL DEFAULT '';
-- Per-profile retry overrides (see the LLM retry design doc). Each of the three groups is a pair of
-- "attempts + fixed interval" with unified semantics: attempts 0=use the global default, -1=disable this layer's
diff --git a/server/dto.go b/server/dto.go
index f9511e5..feb490b 100644
--- a/server/dto.go
+++ b/server/dto.go
@@ -617,6 +617,9 @@ type LLMProfileDTO struct {
// Custom session header name: when non-empty, every request carries this HTTP header,
// its value = the current session/intent's session id. '' = do not send. For gateways that key prompt caching/sticky routing off a session-id header.
SessionHeaderKey string `json:"session_header_key"`
+ // Custom User-Agent: when non-empty, every outbound LLM request overrides its
+ // User-Agent header with this value. '' = do not override (keep the default UA).
+ UserAgent string `json:"user_agent"`
// This profile's retry overrides (connect / empty response / same-provider safe
// window). Per item attempts: 0=inherit the global policy | -1=disable that retry
// layer | >0=count; interval_ms: 0=default exponential backoff | >0=use that fixed millisecond interval. All zero = fully follow the global policy, i.e. the historical behavior.
@@ -644,6 +647,7 @@ func llmProfileDTO(p *db.LLMProfile) LLMProfileDTO {
MaxTokens: p.MaxTokens,
MaxTokensField: p.MaxTokensField,
SessionHeaderKey: p.SessionHeaderKey,
+ UserAgent: p.UserAgent,
Retry: p.Retry,
}
}
diff --git a/server/server.go b/server/server.go
index 48ce4a0..322dd80 100644
--- a/server/server.go
+++ b/server/server.go
@@ -355,6 +355,7 @@ func (s *Server) loadLLMConfig() (agent.Config, bool) {
cfg.Stream = p.Streaming
cfg.MaxTokens, cfg.MaxTokensField = p.MaxTokens, p.MaxTokensField
cfg.SessionHeaderKey = p.SessionHeaderKey
+ cfg.UserAgent = p.UserAgent
s.applyProfileRetry(&cfg, p)
// Ollama profiles run keyless (local server); the other formats need a key.
if cfg.APIKey == "" && cfg.NeedsAPIKey() {
@@ -381,6 +382,7 @@ func (s *Server) saveLLMConfig(cfg agent.Config) error {
var maxTokens int
var maxTokensField string
var sessionHeaderKey string
+ var userAgent string
var retry db.RetryOverride
if profs, _ := s.m.pg.ListProfiles(); profs != nil {
for _, p := range profs {
@@ -388,6 +390,7 @@ func (s *Server) saveLLMConfig(cfg agent.Config) error {
id, priority, poolExclude, streaming = p.ID, p.Priority, p.PoolExclude, p.Streaming
maxTokens, maxTokensField = p.MaxTokens, p.MaxTokensField
sessionHeaderKey = p.SessionHeaderKey
+ userAgent = p.UserAgent
retry = p.Retry
break
}
@@ -398,7 +401,7 @@ func (s *Server) saveLLMConfig(cfg agent.Config) error {
APIKey: cfg.APIKey, RatePerSecond: cfg.RatePerSecond, RatePerMinute: cfg.RatePerMinute,
ContextWindowK: cfg.ContextWindowK, ThinkingType: cfg.ThinkingType, ReasoningEffort: cfg.ReasoningEffort, IsDefault: true,
Priority: priority, PoolExclude: poolExclude, Streaming: streaming,
- MaxTokens: maxTokens, MaxTokensField: maxTokensField, SessionHeaderKey: sessionHeaderKey,
+ MaxTokens: maxTokens, MaxTokensField: maxTokensField, SessionHeaderKey: sessionHeaderKey, UserAgent: userAgent,
Retry: retry,
})
if err != nil {
@@ -528,6 +531,7 @@ func (s *Server) loadProfileConfig(id int64) (agent.Config, bool) {
cfg.Stream = p.Streaming
cfg.MaxTokens, cfg.MaxTokensField = p.MaxTokens, p.MaxTokensField
cfg.SessionHeaderKey = p.SessionHeaderKey
+ cfg.UserAgent = p.UserAgent
s.applyProfileRetry(&cfg, p)
if cfg.APIKey == "" && cfg.NeedsAPIKey() {
return cfg, false
@@ -1418,6 +1422,7 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
ProfileID *int64 `json:"profile_id"` // passed when testing a stored profile: if api_key is empty, its stored key is used
Streaming *bool `json:"streaming"` // omitted = streaming, the same defaults as when saving a profile
SessionHeaderKey string `json:"session_header_key"` // non-empty = the test also sends this custom session header, with a one-off random session id as the value
+ UserAgent string `json:"user_agent"` // non-empty = the test overrides the User-Agent header with this value (mirrors production)
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, 400, err.Error())
@@ -1477,6 +1482,12 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
if stored != nil && cfg.SessionHeaderKey == "" {
cfg.SessionHeaderKey = strings.TrimSpace(stored.SessionHeaderKey)
}
+ // The custom User-Agent follows the profile too, so the test request uses the
+ // exact UA a real conversation would; the form value wins, else the stored one.
+ cfg.UserAgent = strings.TrimSpace(req.UserAgent)
+ if stored != nil && cfg.UserAgent == "" {
+ cfg.UserAgent = strings.TrimSpace(stored.UserAgent)
+ }
// Keyless formats (Ollama) skip the key resolution entirely.
if cfg.NeedsAPIKey() {
// API key resolution precedence: form input > the named profile's stored key > the global config's key.
diff --git a/server/server_mgmt.go b/server/server_mgmt.go
index 5e07ffe..3a378df 100644
--- a/server/server_mgmt.go
+++ b/server/server_mgmt.go
@@ -1882,6 +1882,14 @@ func (s *Server) pgSaveProfile(w http.ResponseWriter, r *http.Request) {
if p.Format != "openai" || p.MaxTokensField != llm.MaxTokensFieldCompletion {
p.MaxTokensField = ""
}
+ // Custom User-Agent: trim and reject control characters / over-long values so a
+ // bad value can never inject a header or bloat every request. "" = no override.
+ ua, err := agent.ValidateUserAgent(p.UserAgent)
+ if err != nil {
+ writeErr(w, 400, err.Error())
+ return
+ }
+ p.UserAgent = ua
id, err := pg.SaveProfile(&p)
if err != nil {
writeErr(w, 500, err.Error())
diff --git a/server/side_questions.go b/server/side_questions.go
index 17f53a5..6b0ca08 100644
--- a/server/side_questions.go
+++ b/server/side_questions.go
@@ -41,7 +41,7 @@ type sideQuestionState struct {
}
func sideModel(cfg agent.Config, id int64, name string) sidequestion.Model {
- b, _ := json.Marshal([]any{cfg.Format, cfg.BaseURL, cfg.Model, cfg.ThinkingType, cfg.ReasoningEffort, cfg.Stream, cfg.MaxTokensField, cfg.SessionHeaderKey})
+ b, _ := json.Marshal([]any{cfg.Format, cfg.BaseURL, cfg.Model, cfg.ThinkingType, cfg.ReasoningEffort, cfg.Stream, cfg.MaxTokensField, cfg.SessionHeaderKey, cfg.UserAgent})
h := sha256.Sum256(b)
return sidequestion.Model{ProfileID: id, Name: name, Format: string(cfg.Format), Model: cfg.Model, Identity: hex.EncodeToString(h[:]), Streaming: cfg.Stream, WindowTokens: cfg.CompactionWindow()}
}
diff --git a/web/src/app/(main)/system/llm/page.tsx b/web/src/app/(main)/system/llm/page.tsx
index 316985d..86fe5e9 100644
--- a/web/src/app/(main)/system/llm/page.tsx
+++ b/web/src/app/(main)/system/llm/page.tsx
@@ -335,6 +335,7 @@ function ProfileSheet({
const [maxTokens, setMaxTokens] = React.useState("0"); // per-reply output cap; 0 = not sent
const [maxTokensField, setMaxTokensField] = React.useState(NONE); // which field name carries the cap; NONE = max_tokens
const [sessionHeaderKey, setSessionHeaderKey] = React.useState(""); // custom session header name; empty = not sent
+ const [userAgent, setUserAgent] = React.useState(""); // custom User-Agent; empty = keep the default UA
const [retry, setRetry] = React.useState
+ Overrides the User-Agent header on every outbound LLM request. Useful for gateways that route or allow-list by + User-Agent. Leave empty to keep the default User-Agent unchanged. +
+