From 4319fe413f6ec4cb29564335d13ef32314a9fd5f Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Mon, 7 Sep 2026 11:21:50 -0400 Subject: [PATCH] =?UTF-8?q?feat(llm):=20=E6=AF=8F=E4=B8=AA=20LLM=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=94=AF=E6=8C=81=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E5=A4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 session_header_key:非空时每次 LLM 请求带上该 HTTP 头,头值为当前 运行的 session id(chat 会话=conv-,worker=exp-worker-i 等)。用于某些按 session-id 头做提示缓存/粘性路由的网关。 实现:在已有的 quotaAwareTransport 里从请求 context 读取 session id (transcript.WithSessionID 挂上的)并注入,无需改 norma;同一共享 provider 也能按会话发出不同头值。DB 加 session_header_key 列(带旧库迁移),前端 LLM 配置弹窗新增输入框。 --- agent/provider.go | 30 ++++++++++-- agent/provider_capture_test.go | 6 +-- agent/session_header_test.go | 66 ++++++++++++++++++++++++++ db/config.go | 26 ++++++---- db/schema.sql | 5 ++ server/dto.go | 40 +++++++++------- server/server.go | 6 ++- web/src/app/(main)/system/llm/page.tsx | 19 ++++++++ web/src/lib/api.ts | 1 + web/src/lib/types.ts | 3 ++ 10 files changed, 166 insertions(+), 36 deletions(-) create mode 100644 agent/session_header_test.go diff --git a/agent/provider.go b/agent/provider.go index 554d9f2..f0447b7 100644 --- a/agent/provider.go +++ b/agent/provider.go @@ -25,6 +25,7 @@ import ( "github.com/Autumn-27/norma/compaction" "github.com/Autumn-27/norma/llm" acperm "github.com/Autumn-27/norma/permission" + "github.com/Autumn-27/norma/transcript" ) // Config describes the LLM backend resolved from the environment. @@ -67,6 +68,12 @@ type Config struct { // OpenAI 推理模型(o 系列/GPT-5)只认后者,收到 max_tokens 会直接报 // unsupported_parameter;而多数兼容网关只认前者,故不做自动推断,交由用户按端点选。 MaxTokensField string + // SessionHeaderKey,非空时,让每次 LLM 请求带上一个自定义 HTTP 头,头名为该值、 + // 头值为【当前会话的 session id】(chat 会话=conv-,worker=exp-worker-i + // 等,见 WorkerSessionID)。用于某些按 session-id 头做提示缓存/粘性路由的网关。 + // 空 = 不发送。值由 transcript.WithSessionID 挂在请求 context 上,由 RoundTripper + // 读取填入,因此同一共享 provider 也能按会话发出不同的头值。 + SessionHeaderKey string } // 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 + // 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) + client, err := quotaAwareHTTPClient(c.Proxy, c.SessionHeaderKey) if err != nil { return nil, err } @@ -292,9 +299,24 @@ func IsQuotaExhaustedMessage(message string) bool { // retry loop treats every 429 as transient; normalizing only that response to // 402 lets a task router fail over immediately while retaining the original // 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) { + // 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 // 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 @@ -347,7 +369,7 @@ func requestBodySnapshot(req *http.Request) string { return string(b) } -func quotaAwareHTTPClient(proxy string) (*http.Client, error) { +func quotaAwareHTTPClient(proxy, sessionHeaderKey string) (*http.Client, error) { transport := http.DefaultTransport.(*http.Transport).Clone() proxy = strings.TrimSpace(proxy) if proxy == "" { @@ -366,7 +388,7 @@ func quotaAwareHTTPClient(proxy string) (*http.Client, error) { } 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 diff --git a/agent/provider_capture_test.go b/agent/provider_capture_test.go index 514b2c7..c097c21 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/session_header_test.go b/agent/session_header_test.go new file mode 100644 index 0000000..ea007ae --- /dev/null +++ b/agent/session_header_test.go @@ -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) + } +} diff --git a/db/config.go b/db/config.go index 0e5ff68..2c04436 100644 --- a/db/config.go +++ b/db/config.go @@ -55,19 +55,25 @@ type LLMProfile struct { // reasoning tokens plus visible output. Ignored by anthropic and // openai-responses, which name the field themselves. 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 // 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 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 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,''),COALESCE(session_header_key,'')` // 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. 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, &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) { @@ -140,18 +146,18 @@ func (d *DB) SaveProfile(p *LLMProfile) (int64, error) { } 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) -VALUES ($1,$2,NULLIF($3,''),NULLIF($4,''),$5,NULLIF($6,''),NULLIF($7,''),$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) 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) + 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,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, p.SessionHeaderKey).Scan(&id) 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 WHERE id=$16`, - 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) + _, 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.SessionHeaderKey, p.ID) 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`, - 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) + _, 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.SessionHeaderKey, p.ID) return p.ID, err } diff --git a/db/schema.sql b/db/schema.sql index fc6ac4c..1638074 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -321,6 +321,9 @@ CREATE TABLE IF NOT EXISTS llm_profiles ( -- 发 max_tokens 会被 unsupported_parameter 拒绝。 -- anthropic(max_tokens 必填)与 openai-responses(max_output_tokens)自带字段名,不受此值影响。 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(), 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 ADD CONSTRAINT llm_profiles_max_tokens_check CHECK (max_tokens >= 0); +-- 自定义会话头名;补旧库。默认 '' = 不发送,旧配置行为不变。 +ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS session_header_key TEXT NOT NULL DEFAULT ''; -- 思考开关字段 thinking_type,从旧的单一 reasoning_effort 语义一次性拆分而来。 -- schema.sql 每次启动都执行,故迁移必须只跑一次:仅当该列尚不存在时才回填, diff --git a/server/dto.go b/server/dto.go index 6cbf1f7..596b808 100644 --- a/server/dto.go +++ b/server/dto.go @@ -587,28 +587,32 @@ type LLMProfileDTO struct { // (''=max_tokens | 'max_completion_tokens',仅 openai 格式有意义)。 MaxTokens int `json:"max_tokens"` MaxTokensField string `json:"max_tokens_field"` + // 自定义会话头名:非空时每次请求带该 HTTP 头,头值=当前会话/意图的 session id。 + // ''=不发送。用于按 session-id 头做提示缓存/粘性路由的网关。 + SessionHeaderKey string `json:"session_header_key"` } func llmProfileDTO(p *db.LLMProfile) LLMProfileDTO { return LLMProfileDTO{ - ID: i64s(p.ID), - Name: p.Name, - Format: p.Format, - BaseURL: p.BaseURL, - Proxy: p.Proxy, - Model: p.Model, - APIKeyHint: p.APIKeyHint, - RatePerSecond: p.RatePerSecond, - RatePerMinute: p.RatePerMinute, - ContextWindowK: p.ContextWindowK, - ThinkingType: p.ThinkingType, - ReasoningEffort: p.ReasoningEffort, - IsDefault: p.IsDefault, - Priority: p.Priority, - PoolExclude: p.PoolExclude, - Streaming: p.Streaming, - MaxTokens: p.MaxTokens, - MaxTokensField: p.MaxTokensField, + ID: i64s(p.ID), + Name: p.Name, + Format: p.Format, + BaseURL: p.BaseURL, + Proxy: p.Proxy, + Model: p.Model, + APIKeyHint: p.APIKeyHint, + RatePerSecond: p.RatePerSecond, + RatePerMinute: p.RatePerMinute, + ContextWindowK: p.ContextWindowK, + ThinkingType: p.ThinkingType, + ReasoningEffort: p.ReasoningEffort, + IsDefault: p.IsDefault, + Priority: p.Priority, + PoolExclude: p.PoolExclude, + Streaming: p.Streaming, + MaxTokens: p.MaxTokens, + MaxTokensField: p.MaxTokensField, + SessionHeaderKey: p.SessionHeaderKey, } } diff --git a/server/server.go b/server/server.go index 0e44d38..6fc4354 100644 --- a/server/server.go +++ b/server/server.go @@ -313,6 +313,7 @@ func (s *Server) loadLLMConfig() (agent.Config, bool) { cfg.ReasoningEffort = p.ReasoningEffort cfg.Stream = p.Streaming cfg.MaxTokens, cfg.MaxTokensField = p.MaxTokens, p.MaxTokensField + cfg.SessionHeaderKey = p.SessionHeaderKey if cfg.APIKey == "" { return cfg, false } @@ -336,11 +337,13 @@ func (s *Server) saveLLMConfig(cfg agent.Config) error { streaming := true // 旧库/新建默认流式 var maxTokens int var maxTokensField string + var sessionHeaderKey string if profs, _ := s.m.pg.ListProfiles(); profs != nil { for _, p := range profs { if p.Name == "default" { id, priority, poolExclude, streaming = p.ID, p.Priority, p.PoolExclude, p.Streaming maxTokens, maxTokensField = p.MaxTokens, p.MaxTokensField + sessionHeaderKey = p.SessionHeaderKey break } } @@ -350,7 +353,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, + MaxTokens: maxTokens, MaxTokensField: maxTokensField, SessionHeaderKey: sessionHeaderKey, }) if err != nil { return err @@ -508,6 +511,7 @@ func (s *Server) loadProfileConfig(id int64) (agent.Config, bool) { cfg.ReasoningEffort = p.ReasoningEffort cfg.Stream = p.Streaming cfg.MaxTokens, cfg.MaxTokensField = p.MaxTokens, p.MaxTokensField + cfg.SessionHeaderKey = p.SessionHeaderKey if cfg.APIKey == "" { return cfg, false } diff --git a/web/src/app/(main)/system/llm/page.tsx b/web/src/app/(main)/system/llm/page.tsx index 7a0e35a..099bcb0 100644 --- a/web/src/app/(main)/system/llm/page.tsx +++ b/web/src/app/(main)/system/llm/page.tsx @@ -324,6 +324,7 @@ function ProfileSheet({ const [streaming, setStreaming] = React.useState(true); // true=流式(默认);false=非流式 const [maxTokens, setMaxTokens] = React.useState("0"); // 单次回复输出上限;0=不发送 const [maxTokensField, setMaxTokensField] = React.useState(NONE); // 上限用哪个字段名;NONE=max_tokens + const [sessionHeaderKey, setSessionHeaderKey] = React.useState(""); // 自定义会话头名;空=不发送 const [testing, setTesting] = React.useState(false); const [saving, setSaving] = React.useState(false); const [models, setModels] = React.useState([]); @@ -351,6 +352,7 @@ function ProfileSheet({ setStreaming(profile?.streaming ?? true); setMaxTokens(String(profile?.max_tokens ?? 0)); setMaxTokensField(fromStore(profile?.max_tokens_field)); + setSessionHeaderKey(profile?.session_header_key ?? ""); setApiKey(""); setKeyHint(profile?.api_key_hint ?? ""); setModels([]); @@ -437,6 +439,7 @@ function ProfileSheet({ // 字段名开关只对 openai(Chat Completions) 有意义,其它格式一律回落到默认; // 后端也会再做一次同样的归一化,这里只是别让 UI 送出自相矛盾的值。 max_tokens_field: format === "openai" ? toStore(maxTokensField) : "", + session_header_key: sessionHeaderKey.trim(), }); if (isNew) toast.success(`已新建:${name.trim()}(在卡片上「设为激活」以启用)`); else toast.success(profile?.is_default ? "已保存,激活配置即时生效,无需重启" : "已保存"); @@ -570,6 +573,22 @@ function ProfileSheet({

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

+ 填写头名后,每次请求都会带上这个 HTTP 头,头值自动填为{" "} + 当前会话的 session id(chat 会话如 conv-12、worker 如 exp3-worker-i87)。用于按 session-id + 头做提示缓存 / 粘性路由的网关;同一会话多轮稳定、不同会话互不相同。留空则不发送。 +

+
+
post<{ id: number }>("/llm/profiles", p), deleteLLMProfile: (id: string) => del<{ deleted: number }>(`/llm/profiles/${id}`), activateLLMProfile: (id: string) => post<{ ok: boolean }>("/llm/profiles/active", { id: Number(id) }), diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 55b0ec8..7dc44cc 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -913,6 +913,9 @@ export interface LLMProfile { // 上限用哪个请求字段名,仅 format="openai" 有意义: // ""=max_tokens(默认) | "max_completion_tokens"(OpenAI 推理模型只认它) max_tokens_field?: string; + // 自定义会话头名:非空时每次请求带该 HTTP 头,头值=当前会话/意图的 session id。 + // ""=不发送。用于按 session-id 头做提示缓存/粘性路由的网关。 + session_header_key?: string; } // ---- LLM 轮询(故障转移)----