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] 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(ZERO_OVERRIDE); // retry override for this profile; all 0 = follow the global const [testing, setTesting] = React.useState(false); const [saving, setSaving] = React.useState(false); @@ -366,6 +367,7 @@ function ProfileSheet({ setMaxTokens(String(profile?.max_tokens ?? 0)); setMaxTokensField(fromStore(profile?.max_tokens_field)); setSessionHeaderKey(profile?.session_header_key ?? ""); + setUserAgent(profile?.user_agent ?? ""); setRetry(profile?.retry ?? ZERO_OVERRIDE); setApiKey(""); setKeyHint(profile?.api_key_hint ?? ""); @@ -412,6 +414,7 @@ function ProfileSheet({ profileId, streaming, sessionHeaderKey.trim(), + userAgent.trim(), ); // Show the reply too: only when the model demonstrably spoke is this the same as working inside a session. if (r.ok) @@ -455,6 +458,7 @@ function ProfileSheet({ // and the backend re-applies the same normalization; this just keeps the UI from sending contradictory values. max_tokens_field: format === "openai" ? toStore(maxTokensField) : "", session_header_key: sessionHeaderKey.trim(), + user_agent: userAgent.trim(), retry, }); if (isNew) toast.success(`Created: ${name.trim()} (use "Set as active" on its card to enable it)`); @@ -613,6 +617,21 @@ function ProfileSheet({

+
+ + setUserAgent(e.target.value)} + /> +

+ 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. +

+
+