mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
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:
1 parent
b513b656aa
commit
4319fe413f
10 files changed
+166
-36
No files matched your search
+26
-4
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 每次启动都执行,故迁移必须只跑一次:仅当该列尚不存在时才回填,
|
||||
|
||||
+22
-18
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
@@ -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({
|
||||
</p>
|
||||
</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">
|
||||
<Label htmlFor="p-api-key">API Key</Label>
|
||||
<Input
|
||||
|
||||
@@ -723,6 +723,7 @@ export const api = {
|
||||
streaming?: boolean; // true(默认)=流式 | false=非流式
|
||||
max_tokens?: number; // 单次回复输出上限;0=不发送,由服务端默认值决定
|
||||
max_tokens_field?: string; // ""=max_tokens(默认) | "max_completion_tokens"(仅 openai 格式)
|
||||
session_header_key?: string; // 非空=每次请求带该 HTTP 头,头值=当前会话 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) }),
|
||||
|
||||
@@ -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 轮询(故障转移)----
|
||||
|
||||
Reference in new issue
Block a user