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 轮询(故障转移)----