feat(llm): 每个 LLM 配置支持自定义会话头

新增 session_header_key:非空时每次 LLM 请求带上该 HTTP 头,头值为当前
运行的 session id(chat 会话=conv-<id>,worker=exp<x>-worker-i<intent>
等)。用于某些按 session-id 头做提示缓存/粘性路由的网关。

实现:在已有的 quotaAwareTransport 里从请求 context 读取 session id
(transcript.WithSessionID 挂上的)并注入,无需改 norma;同一共享 provider
也能按会话发出不同头值。DB 加 session_header_key 列(带旧库迁移),前端
LLM 配置弹窗新增输入框。
This commit is contained in:
Autumn-27 committed 2026-09-07 11:21:50 -04:00
1 parent b513b656aa
commit 4319fe413f
10 files changed
+166 -36

No files matched your search

+26 -4
View File
@@ -25,6 +25,7 @@ import (
"github.com/Autumn-27/norma/compaction" "github.com/Autumn-27/norma/compaction"
"github.com/Autumn-27/norma/llm" "github.com/Autumn-27/norma/llm"
acperm "github.com/Autumn-27/norma/permission" acperm "github.com/Autumn-27/norma/permission"
"github.com/Autumn-27/norma/transcript"
) )
// Config describes the LLM backend resolved from the environment. // Config describes the LLM backend resolved from the environment.
@@ -67,6 +68,12 @@ type Config struct {
// OpenAI 推理模型(o 系列/GPT-5)只认后者,收到 max_tokens 会直接报 // OpenAI 推理模型(o 系列/GPT-5)只认后者,收到 max_tokens 会直接报
// unsupported_parameter;而多数兼容网关只认前者,故不做自动推断,交由用户按端点选。 // unsupported_parameter;而多数兼容网关只认前者,故不做自动推断,交由用户按端点选。
MaxTokensField string MaxTokensField string
// SessionHeaderKey,非空时,让每次 LLM 请求带上一个自定义 HTTP 头,头名为该值、
// 头值为【当前会话的 session id】(chat 会话=conv-<id>,worker=exp<x>-worker-i<intent>
// 等,见 WorkerSessionID)。用于某些按 session-id 头做提示缓存/粘性路由的网关。
// 空 = 不发送。值由 transcript.WithSessionID 挂在请求 context 上,由 RoundTripper
// 读取填入,因此同一共享 provider 也能按会话发出不同的头值。
SessionHeaderKey string
} }
// compaction window resolution bounds (in K tokens). Below the floor the // compaction window resolution bounds (in K tokens). Below the floor the
@@ -224,7 +231,7 @@ func (c Config) Provider() string {
// limiter lives on the single provider instance — so planner + all workers + // limiter lives on the single provider instance — so planner + all workers +
// main agent (which share this provider) are bounded by one shared rate limit. // main agent (which share this provider) are bounded by one shared rate limit.
func (c Config) NewProvider() (llm.Provider, error) { func (c Config) NewProvider() (llm.Provider, error) {
client, err := quotaAwareHTTPClient(c.Proxy) client, err := quotaAwareHTTPClient(c.Proxy, c.SessionHeaderKey)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -292,9 +299,24 @@ func IsQuotaExhaustedMessage(message string) bool {
// retry loop treats every 429 as transient; normalizing only that response to // retry loop treats every 429 as transient; normalizing only that response to
// 402 lets a task router fail over immediately while retaining the original // 402 lets a task router fail over immediately while retaining the original
// response body for provider-specific classification and audit logs. // response body for provider-specific classification and audit logs.
type quotaAwareTransport struct{ base http.RoundTripper } type quotaAwareTransport struct {
base http.RoundTripper
// sessionHeaderKey, when non-empty, is the HTTP header name each request
// carries; its value is the session id read from the request context. Empty
// disables it. See Config.SessionHeaderKey.
sessionHeaderKey string
}
func (t quotaAwareTransport) RoundTrip(req *http.Request) (*http.Response, error) { func (t quotaAwareTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Custom session-id header: name is user-configured, value is THIS run's
// session id (norma stashes it on the context via transcript.WithSessionID).
// Stable across a session's turns and distinct across sessions — exactly what
// a session-keyed prompt cache wants. Skipped when no session id is present.
if t.sessionHeaderKey != "" {
if sid := transcript.SessionIDFrom(req.Context()); sid != "" {
req.Header.Set(t.sessionHeaderKey, sid)
}
}
// When LLM recording is on, the Recorder puts a Capture on the context so the // 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 // 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 // them: norma builds the request body internally and decodes the SSE response
@@ -347,7 +369,7 @@ func requestBodySnapshot(req *http.Request) string {
return string(b) return string(b)
} }
func quotaAwareHTTPClient(proxy string) (*http.Client, error) { func quotaAwareHTTPClient(proxy, sessionHeaderKey string) (*http.Client, error) {
transport := http.DefaultTransport.(*http.Transport).Clone() transport := http.DefaultTransport.(*http.Transport).Clone()
proxy = strings.TrimSpace(proxy) proxy = strings.TrimSpace(proxy)
if proxy == "" { if proxy == "" {
@@ -366,7 +388,7 @@ func quotaAwareHTTPClient(proxy string) (*http.Client, error) {
} }
transport.Proxy = http.ProxyURL(proxyURL) transport.Proxy = http.ProxyURL(proxyURL)
} }
return &http.Client{Transport: quotaAwareTransport{base: transport}}, nil return &http.Client{Transport: quotaAwareTransport{base: transport, sessionHeaderKey: strings.TrimSpace(sessionHeaderKey)}}, nil
} }
// logTestConnection prints the raw HTTP status code(s) and response body of a // logTestConnection prints the raw HTTP status code(s) and response body of a
+3 -3
View File
@@ -21,7 +21,7 @@ func TestRoundTripCapturesRawBodies(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
client, err := quotaAwareHTTPClient("") client, err := quotaAwareHTTPClient("", "")
if err != nil { if err != nil {
t.Fatalf("client: %v", err) t.Fatalf("client: %v", err)
} }
@@ -65,7 +65,7 @@ func TestRoundTripCaptures429BodyAlongsideQuotaRewrite(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
client, err := quotaAwareHTTPClient("") client, err := quotaAwareHTTPClient("", "")
if err != nil { if err != nil {
t.Fatalf("client: %v", err) t.Fatalf("client: %v", err)
} }
@@ -106,7 +106,7 @@ func TestRoundTripWithoutCaptureIsUnchanged(t *testing.T) {
})) }))
defer srv.Close() defer srv.Close()
client, err := quotaAwareHTTPClient("") client, err := quotaAwareHTTPClient("", "")
if err != nil { if err != nil {
t.Fatalf("client: %v", err) t.Fatalf("client: %v", err)
} }
+66
View File
@@ -0,0 +1,66 @@
package agent
import (
"context"
"io"
"net/http"
"strings"
"testing"
"github.com/Autumn-27/norma/transcript"
)
// fakeRT records the request it saw and returns a minimal 200 response.
type fakeRT struct{ seen *http.Request }
func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) {
f.seen = req
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
}, nil
}
func newReq(ctx context.Context) *http.Request {
req, _ := http.NewRequestWithContext(ctx, "POST", "https://api.example.com/v1/messages", strings.NewReader("{}"))
return req
}
func TestSessionHeaderInjectedFromContext(t *testing.T) {
base := &fakeRT{}
rt := quotaAwareTransport{base: base, sessionHeaderKey: "x-session-id"}
ctx := transcript.WithSessionID(context.Background(), "conv-42")
if _, err := rt.RoundTrip(newReq(ctx)); err != nil {
t.Fatalf("RoundTrip: %v", err)
}
if got := base.seen.Header.Get("x-session-id"); got != "conv-42" {
t.Fatalf("x-session-id = %q, want conv-42", got)
}
}
func TestSessionHeaderSkippedWhenKeyEmpty(t *testing.T) {
base := &fakeRT{}
rt := quotaAwareTransport{base: base} // no key configured
ctx := transcript.WithSessionID(context.Background(), "conv-42")
if _, err := rt.RoundTrip(newReq(ctx)); err != nil {
t.Fatalf("RoundTrip: %v", err)
}
// The header name is whatever the user would have set; with no key, nothing
// session-related is added. Assert the common key stays absent.
if got := base.seen.Header.Get("x-session-id"); got != "" {
t.Fatalf("unexpected session header %q with empty key", got)
}
}
func TestSessionHeaderSkippedWhenNoSessionID(t *testing.T) {
base := &fakeRT{}
rt := quotaAwareTransport{base: base, sessionHeaderKey: "x-session-id"}
// Context carries no session id (transcript persistence off).
if _, err := rt.RoundTrip(newReq(context.Background())); err != nil {
t.Fatalf("RoundTrip: %v", err)
}
if got := base.seen.Header.Get("x-session-id"); got != "" {
t.Fatalf("x-session-id = %q, want empty when no session id on context", got)
}
}
+16 -10
View File
@@ -55,19 +55,25 @@ type LLMProfile struct {
// reasoning tokens plus visible output. Ignored by anthropic and // reasoning tokens plus visible output. Ignored by anthropic and
// openai-responses, which name the field themselves. // openai-responses, which name the field themselves.
MaxTokensField string `json:"max_tokens_field"` MaxTokensField string `json:"max_tokens_field"`
// SessionHeaderKey, when non-empty, names a custom HTTP header sent on every
// request built from this profile; its value is the current run's session id
// (chat conversation / worker intent). For gateways that key prompt caching
// or sticky routing off a session-id header. "" = not sent. Maps to
// agent.Config.SessionHeaderKey.
SessionHeaderKey string `json:"session_header_key"`
} }
// profileCols is the read column list (hint variant, no api key) shared by the // 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. // list query; profileColsKey is the same with api_key for the single-row loads.
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,'')` 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,'')`
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,'')` 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,'')`
// scanProfile reads one row in the profileCols / profileColsKey column order. The // 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. // 7th column lands in APIKeyHint or APIKey depending on which list the caller used.
func scanProfile(sc interface{ Scan(...any) error }, into *string, p *LLMProfile) error { func scanProfile(sc interface{ Scan(...any) error }, into *string, p *LLMProfile) error {
return sc.Scan(&p.ID, &p.Name, &p.Format, &p.BaseURL, &p.Proxy, &p.Model, into, return sc.Scan(&p.ID, &p.Name, &p.Format, &p.BaseURL, &p.Proxy, &p.Model, into,
&p.RatePerSecond, &p.RatePerMinute, &p.ContextWindowK, &p.ReasoningEffort, &p.IsDefault, &p.Priority, &p.PoolExclude, &p.ThinkingType, &p.Streaming, &p.RatePerSecond, &p.RatePerMinute, &p.ContextWindowK, &p.ReasoningEffort, &p.IsDefault, &p.Priority, &p.PoolExclude, &p.ThinkingType, &p.Streaming,
&p.MaxTokens, &p.MaxTokensField) &p.MaxTokens, &p.MaxTokensField, &p.SessionHeaderKey)
} }
func (d *DB) ListProfiles() ([]*LLMProfile, error) { func (d *DB) ListProfiles() ([]*LLMProfile, error) {
@@ -140,18 +146,18 @@ func (d *DB) SaveProfile(p *LLMProfile) (int64, error) {
} }
if p.ID == 0 { if p.ID == 0 {
var id int64 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) 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)
VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) RETURNING id`, VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,NULLIF($18,'')) 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).Scan(&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).Scan(&id)
return id, err return id, err
} }
if p.APIKey == "" { 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 WHERE id=$16`, _, 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=NULLIF($16,'') WHERE id=$17`,
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.ID) 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, p.ID)
return p.ID, err 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 WHERE id=$18`, _, 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=NULLIF($18,'') WHERE id=$19`,
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.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, p.ID)
return p.ID, err return p.ID, err
} }
+5
View File
@@ -321,6 +321,9 @@ CREATE TABLE IF NOT EXISTS llm_profiles (
-- 发 max_tokens 会被 unsupported_parameter 拒绝。 -- 发 max_tokens 会被 unsupported_parameter 拒绝。
-- anthropic(max_tokens 必填)与 openai-responses(max_output_tokens)自带字段名,不受此值影响。 -- anthropic(max_tokens 必填)与 openai-responses(max_output_tokens)自带字段名,不受此值影响。
max_tokens_field TEXT NOT NULL DEFAULT '', max_tokens_field TEXT NOT NULL DEFAULT '',
-- 自定义会话头:非空时每次请求带一个该名字的 HTTP 头,头值=当前运行的 session id
-- (chat 会话/worker 意图)。用于某些按 session-id 头做提示缓存/粘性路由的网关。''=不发送。
session_header_key TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now() updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
@@ -350,6 +353,8 @@ ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_max_tokens_field_check
ALTER TABLE llm_profiles DROP CONSTRAINT IF EXISTS llm_profiles_max_tokens_check; ALTER TABLE llm_profiles DROP CONSTRAINT IF EXISTS llm_profiles_max_tokens_check;
ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_max_tokens_check ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_max_tokens_check
CHECK (max_tokens >= 0); CHECK (max_tokens >= 0);
-- 自定义会话头名;补旧库。默认 '' = 不发送,旧配置行为不变。
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS session_header_key TEXT NOT NULL DEFAULT '';
-- 思考开关字段 thinking_type,从旧的单一 reasoning_effort 语义一次性拆分而来。 -- 思考开关字段 thinking_type,从旧的单一 reasoning_effort 语义一次性拆分而来。
-- schema.sql 每次启动都执行,故迁移必须只跑一次:仅当该列尚不存在时才回填, -- schema.sql 每次启动都执行,故迁移必须只跑一次:仅当该列尚不存在时才回填,
+22 -18
View File
@@ -587,28 +587,32 @@ type LLMProfileDTO struct {
// (''=max_tokens | 'max_completion_tokens',仅 openai 格式有意义)。 // (''=max_tokens | 'max_completion_tokens',仅 openai 格式有意义)。
MaxTokens int `json:"max_tokens"` MaxTokens int `json:"max_tokens"`
MaxTokensField string `json:"max_tokens_field"` MaxTokensField string `json:"max_tokens_field"`
// 自定义会话头名:非空时每次请求带该 HTTP 头,头值=当前会话/意图的 session id。
// ''=不发送。用于按 session-id 头做提示缓存/粘性路由的网关。
SessionHeaderKey string `json:"session_header_key"`
} }
func llmProfileDTO(p *db.LLMProfile) LLMProfileDTO { func llmProfileDTO(p *db.LLMProfile) LLMProfileDTO {
return LLMProfileDTO{ return LLMProfileDTO{
ID: i64s(p.ID), ID: i64s(p.ID),
Name: p.Name, Name: p.Name,
Format: p.Format, Format: p.Format,
BaseURL: p.BaseURL, BaseURL: p.BaseURL,
Proxy: p.Proxy, Proxy: p.Proxy,
Model: p.Model, Model: p.Model,
APIKeyHint: p.APIKeyHint, APIKeyHint: p.APIKeyHint,
RatePerSecond: p.RatePerSecond, RatePerSecond: p.RatePerSecond,
RatePerMinute: p.RatePerMinute, RatePerMinute: p.RatePerMinute,
ContextWindowK: p.ContextWindowK, ContextWindowK: p.ContextWindowK,
ThinkingType: p.ThinkingType, ThinkingType: p.ThinkingType,
ReasoningEffort: p.ReasoningEffort, ReasoningEffort: p.ReasoningEffort,
IsDefault: p.IsDefault, IsDefault: p.IsDefault,
Priority: p.Priority, Priority: p.Priority,
PoolExclude: p.PoolExclude, PoolExclude: p.PoolExclude,
Streaming: p.Streaming, Streaming: p.Streaming,
MaxTokens: p.MaxTokens, MaxTokens: p.MaxTokens,
MaxTokensField: p.MaxTokensField, MaxTokensField: p.MaxTokensField,
SessionHeaderKey: p.SessionHeaderKey,
} }
} }
+5 -1
View File
@@ -313,6 +313,7 @@ func (s *Server) loadLLMConfig() (agent.Config, bool) {
cfg.ReasoningEffort = p.ReasoningEffort cfg.ReasoningEffort = p.ReasoningEffort
cfg.Stream = p.Streaming cfg.Stream = p.Streaming
cfg.MaxTokens, cfg.MaxTokensField = p.MaxTokens, p.MaxTokensField cfg.MaxTokens, cfg.MaxTokensField = p.MaxTokens, p.MaxTokensField
cfg.SessionHeaderKey = p.SessionHeaderKey
if cfg.APIKey == "" { if cfg.APIKey == "" {
return cfg, false return cfg, false
} }
@@ -336,11 +337,13 @@ func (s *Server) saveLLMConfig(cfg agent.Config) error {
streaming := true // 旧库/新建默认流式 streaming := true // 旧库/新建默认流式
var maxTokens int var maxTokens int
var maxTokensField string var maxTokensField string
var sessionHeaderKey string
if profs, _ := s.m.pg.ListProfiles(); profs != nil { if profs, _ := s.m.pg.ListProfiles(); profs != nil {
for _, p := range profs { for _, p := range profs {
if p.Name == "default" { if p.Name == "default" {
id, priority, poolExclude, streaming = p.ID, p.Priority, p.PoolExclude, p.Streaming id, priority, poolExclude, streaming = p.ID, p.Priority, p.PoolExclude, p.Streaming
maxTokens, maxTokensField = p.MaxTokens, p.MaxTokensField maxTokens, maxTokensField = p.MaxTokens, p.MaxTokensField
sessionHeaderKey = p.SessionHeaderKey
break break
} }
} }
@@ -350,7 +353,7 @@ func (s *Server) saveLLMConfig(cfg agent.Config) error {
APIKey: cfg.APIKey, RatePerSecond: cfg.RatePerSecond, RatePerMinute: cfg.RatePerMinute, APIKey: cfg.APIKey, RatePerSecond: cfg.RatePerSecond, RatePerMinute: cfg.RatePerMinute,
ContextWindowK: cfg.ContextWindowK, ThinkingType: cfg.ThinkingType, ReasoningEffort: cfg.ReasoningEffort, IsDefault: true, ContextWindowK: cfg.ContextWindowK, ThinkingType: cfg.ThinkingType, ReasoningEffort: cfg.ReasoningEffort, IsDefault: true,
Priority: priority, PoolExclude: poolExclude, Streaming: streaming, Priority: priority, PoolExclude: poolExclude, Streaming: streaming,
MaxTokens: maxTokens, MaxTokensField: maxTokensField, MaxTokens: maxTokens, MaxTokensField: maxTokensField, SessionHeaderKey: sessionHeaderKey,
}) })
if err != nil { if err != nil {
return err return err
@@ -508,6 +511,7 @@ func (s *Server) loadProfileConfig(id int64) (agent.Config, bool) {
cfg.ReasoningEffort = p.ReasoningEffort cfg.ReasoningEffort = p.ReasoningEffort
cfg.Stream = p.Streaming cfg.Stream = p.Streaming
cfg.MaxTokens, cfg.MaxTokensField = p.MaxTokens, p.MaxTokensField cfg.MaxTokens, cfg.MaxTokensField = p.MaxTokens, p.MaxTokensField
cfg.SessionHeaderKey = p.SessionHeaderKey
if cfg.APIKey == "" { if cfg.APIKey == "" {
return cfg, false return cfg, false
} }
+19
View File
@@ -324,6 +324,7 @@ function ProfileSheet({
const [streaming, setStreaming] = React.useState(true); // true=流式(默认);false=非流式 const [streaming, setStreaming] = React.useState(true); // true=流式(默认);false=非流式
const [maxTokens, setMaxTokens] = React.useState("0"); // 单次回复输出上限;0=不发送 const [maxTokens, setMaxTokens] = React.useState("0"); // 单次回复输出上限;0=不发送
const [maxTokensField, setMaxTokensField] = React.useState(NONE); // 上限用哪个字段名;NONE=max_tokens const [maxTokensField, setMaxTokensField] = React.useState(NONE); // 上限用哪个字段名;NONE=max_tokens
const [sessionHeaderKey, setSessionHeaderKey] = React.useState(""); // 自定义会话头名;空=不发送
const [testing, setTesting] = React.useState(false); const [testing, setTesting] = React.useState(false);
const [saving, setSaving] = React.useState(false); const [saving, setSaving] = React.useState(false);
const [models, setModels] = React.useState<string[]>([]); const [models, setModels] = React.useState<string[]>([]);
@@ -351,6 +352,7 @@ function ProfileSheet({
setStreaming(profile?.streaming ?? true); setStreaming(profile?.streaming ?? true);
setMaxTokens(String(profile?.max_tokens ?? 0)); setMaxTokens(String(profile?.max_tokens ?? 0));
setMaxTokensField(fromStore(profile?.max_tokens_field)); setMaxTokensField(fromStore(profile?.max_tokens_field));
setSessionHeaderKey(profile?.session_header_key ?? "");
setApiKey(""); setApiKey("");
setKeyHint(profile?.api_key_hint ?? ""); setKeyHint(profile?.api_key_hint ?? "");
setModels([]); setModels([]);
@@ -437,6 +439,7 @@ function ProfileSheet({
// 字段名开关只对 openai(Chat Completions) 有意义,其它格式一律回落到默认; // 字段名开关只对 openai(Chat Completions) 有意义,其它格式一律回落到默认;
// 后端也会再做一次同样的归一化,这里只是别让 UI 送出自相矛盾的值。 // 后端也会再做一次同样的归一化,这里只是别让 UI 送出自相矛盾的值。
max_tokens_field: format === "openai" ? toStore(maxTokensField) : "", max_tokens_field: format === "openai" ? toStore(maxTokensField) : "",
session_header_key: sessionHeaderKey.trim(),
}); });
if (isNew) toast.success(`已新建:${name.trim()}(在卡片上「设为激活」以启用)`); if (isNew) toast.success(`已新建:${name.trim()}(在卡片上「设为激活」以启用)`);
else toast.success(profile?.is_default ? "已保存,激活配置即时生效,无需重启" : "已保存"); else toast.success(profile?.is_default ? "已保存,激活配置即时生效,无需重启" : "已保存");
@@ -570,6 +573,22 @@ function ProfileSheet({
</p> </p>
</div> </div>
<div className="grid gap-2">
<Label htmlFor="p-session-header">自定义会话头(可选)</Label>
<Input
id="p-session-header"
className="font-mono"
placeholder="如 x-session-id(留空=不发送)"
value={sessionHeaderKey}
onChange={(e) => setSessionHeaderKey(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
填写头名后,每次请求都会带上这个 HTTP 头,头值自动填为{" "}
<b>当前会话的 session id</b>(chat 会话如 conv-12、worker 如 exp3-worker-i87)。用于按 session-id
头做提示缓存 / 粘性路由的网关;同一会话多轮稳定、不同会话互不相同。留空则不发送。
</p>
</div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="p-api-key">API Key</Label> <Label htmlFor="p-api-key">API Key</Label>
<Input <Input
+1
View File
@@ -723,6 +723,7 @@ export const api = {
streaming?: boolean; // true(默认)=流式 | false=非流式 streaming?: boolean; // true(默认)=流式 | false=非流式
max_tokens?: number; // 单次回复输出上限;0=不发送,由服务端默认值决定 max_tokens?: number; // 单次回复输出上限;0=不发送,由服务端默认值决定
max_tokens_field?: string; // ""=max_tokens(默认) | "max_completion_tokens"(仅 openai 格式) max_tokens_field?: string; // ""=max_tokens(默认) | "max_completion_tokens"(仅 openai 格式)
session_header_key?: string; // 非空=每次请求带该 HTTP 头,头值=当前会话 session id;""=不发送
}) => post<{ id: number }>("/llm/profiles", p), }) => post<{ id: number }>("/llm/profiles", p),
deleteLLMProfile: (id: string) => del<{ deleted: number }>(`/llm/profiles/${id}`), deleteLLMProfile: (id: string) => del<{ deleted: number }>(`/llm/profiles/${id}`),
activateLLMProfile: (id: string) => post<{ ok: boolean }>("/llm/profiles/active", { id: Number(id) }), activateLLMProfile: (id: string) => post<{ ok: boolean }>("/llm/profiles/active", { id: Number(id) }),
+3
View File
@@ -913,6 +913,9 @@ export interface LLMProfile {
// 上限用哪个请求字段名,仅 format="openai" 有意义: // 上限用哪个请求字段名,仅 format="openai" 有意义:
// ""=max_tokens(默认) | "max_completion_tokens"(OpenAI 推理模型只认它) // ""=max_tokens(默认) | "max_completion_tokens"(OpenAI 推理模型只认它)
max_tokens_field?: string; max_tokens_field?: string;
// 自定义会话头名:非空时每次请求带该 HTTP 头,头值=当前会话/意图的 session id。
// ""=不发送。用于按 session-id 头做提示缓存/粘性路由的网关。
session_header_key?: string;
} }
// ---- LLM 轮询(故障转移)---- // ---- LLM 轮询(故障转移)----