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/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-<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
@@ -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
+3 -3
View File
@@ -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)
}
+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)
}
}