feat(llm): 模型配置新增输出上限,支持 max_completion_tokens 字段名
llm_profiles 加两列:max_tokens(单次回复输出上限,0=不发送该字段,由服务端 默认值决定)与 max_tokens_field(上限发哪个键)。后者仅对 format=openai 生效: 空(默认)= max_tokens;'max_completion_tokens' = 新键。OpenAI 推理模型 (o 系列/GPT-5)拒收 max_tokens、只认新键,而多数兼容网关反过来只认旧键, 故做成显式开关而非自动推断。anthropic 与 openai-responses 自带字段名, 存下来只会误导,保存时一并清空。 顺带补上一处从未接通的链路:planner/worker/chat/mainagent/goals 五个 agentcore.Options 都没设过 MaxTokens,恒为 0——openai 系因 omitempty 压根 不发上限,anthropic 走 norma 的 8192 兜底。光加配置项而不接这条线,用户在 后台填了数字也不会生效,故本次一并接通,取值与 streaming 一样按 profile 每轮解析,故障转移切换后下一轮即生效。 task_llm.go 里 nonStreaming() 的来源优先级(agent 绑定 → 任务链激活 profile → 全局)抽成共用的 activeCfg(),避免 maxTokens() 再抄一份同样的三级回退。 norma 升到 v0.3.3(该版本提供 Config.MaxTokensField)。go.mod 只动 norma 这 一行:仓库此前未 tidy 过,跑 go mod tidy 会顺带升 x/crypto、x/net 等一批无关 依赖,不混进本次改动。 迁移:新列走 ADD COLUMN IF NOT EXISTS + 默认 0/'',旧库升级后行为完全不变; CHECK 约束限定取值,每次启动先删后建保持幂等。
This commit is contained in:
1 parent
338e77c064
commit
7cc467c159
20 files changed
+363
-32
No files matched your search
@@ -32,6 +32,7 @@ type ChatAgent struct {
|
|||||||
webSearch WebSearchOpts
|
webSearch WebSearchOpts
|
||||||
guard *guard.Guard // optional; nil disables intercept hooks for chat
|
guard *guard.Guard // optional; nil disables intercept hooks for chat
|
||||||
nonStreamingFn func() bool // resolver: use non-streaming (Complete) path? (nil = streaming)
|
nonStreamingFn func() bool // resolver: use non-streaming (Complete) path? (nil = streaming)
|
||||||
|
maxTokensFn func() int // resolver: per-reply output cap (nil/0 = send no cap)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewChatAgent(prov llm.Provider, model, workDir string, tx *transcript.Store, window int) *ChatAgent {
|
func NewChatAgent(prov llm.Provider, model, workDir string, tx *transcript.Store, window int) *ChatAgent {
|
||||||
@@ -44,6 +45,17 @@ func (c *ChatAgent) SetNonStreaming(fn func() bool) { c.nonStreamingFn = fn }
|
|||||||
|
|
||||||
func (c *ChatAgent) nonStreaming() bool { return c.nonStreamingFn != nil && c.nonStreamingFn() }
|
func (c *ChatAgent) nonStreaming() bool { return c.nonStreamingFn != nil && c.nonStreamingFn() }
|
||||||
|
|
||||||
|
// SetMaxTokens wires a resolver for the per-reply output cap. nil/unset or 0 =
|
||||||
|
// send no cap and let the endpoint decide. Read per run, like nonStreaming.
|
||||||
|
func (c *ChatAgent) SetMaxTokens(fn func() int) { c.maxTokensFn = fn }
|
||||||
|
|
||||||
|
func (c *ChatAgent) maxTokens() int {
|
||||||
|
if c.maxTokensFn == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return c.maxTokensFn()
|
||||||
|
}
|
||||||
|
|
||||||
// SetProxy points the chat agent's WebFetch/Bash at the recording proxy plus the
|
// SetProxy points the chat agent's WebFetch/Bash at the recording proxy plus the
|
||||||
// CA cert it trusts (empty addr = direct). Kept for parity with the other agents.
|
// CA cert it trusts (empty addr = direct). Kept for parity with the other agents.
|
||||||
func (c *ChatAgent) SetProxy(addr, caCert string) { c.proxyAddr, c.proxyCACert = addr, caCert }
|
func (c *ChatAgent) SetProxy(addr, caCert string) { c.proxyAddr, c.proxyCACert = addr, caCert }
|
||||||
@@ -135,6 +147,7 @@ func (c *ChatAgent) Chat(ctx context.Context, agentKey, sessionID, message strin
|
|||||||
// (自定义 agent 各自一份;留空/0 用通用默认:10 轮)。
|
// (自定义 agent 各自一份;留空/0 用通用默认:10 轮)。
|
||||||
Settlement: wrapupSettlement(agentKey, nil),
|
Settlement: wrapupSettlement(agentKey, nil),
|
||||||
NonStreaming: c.nonStreaming(), // 该 profile 选非流式时走 Provider.Complete
|
NonStreaming: c.nonStreaming(), // 该 profile 选非流式时走 Provider.Complete
|
||||||
|
MaxTokens: c.maxTokens(), // 0 = 不发上限,由服务端默认值决定
|
||||||
}
|
}
|
||||||
if c.guard != nil {
|
if c.guard != nil {
|
||||||
opts.Hooks = c.guard.Hooks()
|
opts.Hooks = c.guard.Hooks()
|
||||||
|
|||||||
+5
-3
@@ -98,13 +98,14 @@ func DecomposeGoals(ctx context.Context, prov llm.Provider, dataDir, goalText, d
|
|||||||
if prov == nil {
|
if prov == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return DecomposeGoalsWithProvider(ctx, prov, dataDir, goalText, desc, as, ts, taskID, false, emit)
|
return DecomposeGoalsWithProvider(ctx, prov, dataDir, goalText, desc, as, ts, taskID, false, 0, emit)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DecomposeGoalsWithProvider is the task-runtime variant used when a task has an
|
// DecomposeGoalsWithProvider is the task-runtime variant used when a task has an
|
||||||
// ordered provider chain. It preserves the same tools and write behavior while
|
// ordered provider chain. It preserves the same tools and write behavior while
|
||||||
// letting the caller own provider selection/failover.
|
// letting the caller own provider selection/failover. maxTokens is the profile's
|
||||||
func DecomposeGoalsWithProvider(ctx context.Context, prov llm.Provider, dataDir, goalText, desc string, as *db.AssetStore, ts *db.ExplorationStore, taskID int64, nonStreaming bool, emit func(db.Activity)) []GoalSpec {
|
// per-reply output cap (0 = send none).
|
||||||
|
func DecomposeGoalsWithProvider(ctx context.Context, prov llm.Provider, dataDir, goalText, desc string, as *db.AssetStore, ts *db.ExplorationStore, taskID int64, nonStreaming bool, maxTokens int, emit func(db.Activity)) []GoalSpec {
|
||||||
if prov == nil {
|
if prov == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -147,6 +148,7 @@ func DecomposeGoalsWithProvider(ctx context.Context, prov llm.Provider, dataDir,
|
|||||||
// 3 步(抽约束 → 登记范围 → 拆目标)各需一次工具调用,给足回合避免收尾前漏调 set_goals。
|
// 3 步(抽约束 → 登记范围 → 拆目标)各需一次工具调用,给足回合避免收尾前漏调 set_goals。
|
||||||
MaxTurns: 8,
|
MaxTurns: 8,
|
||||||
NonStreaming: nonStreaming, // 该 profile 选非流式时走 Provider.Complete
|
NonStreaming: nonStreaming, // 该 profile 选非流式时走 Provider.Complete
|
||||||
|
MaxTokens: maxTokens, // 0 = 不发上限,由服务端默认值决定
|
||||||
}, userMsg, captureEmit)
|
}, userMsg, captureEmit)
|
||||||
// set_goals persisted the goals directly; read them back so the caller sees what
|
// set_goals persisted the goals directly; read them back so the caller sees what
|
||||||
// was written (empty slice ⇒ the LLM produced nothing ⇒ caller falls back).
|
// was written (empty slice ⇒ the LLM produced nothing ⇒ caller falls back).
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type MainAgent struct {
|
|||||||
workDir string // shared work dir (surfaced in prompt as artifact-output target)
|
workDir string // shared work dir (surfaced in prompt as artifact-output target)
|
||||||
steerWork func(intentID int64, msg string) error // engine callback: steer a running work (nil = off)
|
steerWork func(intentID int64, msg string) error // engine callback: steer a running work (nil = off)
|
||||||
nonStreamingFn func() bool // resolver: use non-streaming (Complete) path? (nil = streaming)
|
nonStreamingFn func() bool // resolver: use non-streaming (Complete) path? (nil = streaming)
|
||||||
|
maxTokensFn func() int // resolver: per-reply output cap (nil/0 = send no cap)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetNonStreaming wires a resolver deciding whether runs use the non-streaming
|
// SetNonStreaming wires a resolver deciding whether runs use the non-streaming
|
||||||
@@ -37,6 +38,17 @@ func (m *MainAgent) SetNonStreaming(fn func() bool) { m.nonStreamingFn = fn }
|
|||||||
|
|
||||||
func (m *MainAgent) nonStreaming() bool { return m.nonStreamingFn != nil && m.nonStreamingFn() }
|
func (m *MainAgent) nonStreaming() bool { return m.nonStreamingFn != nil && m.nonStreamingFn() }
|
||||||
|
|
||||||
|
// SetMaxTokens wires a resolver for the per-reply output cap. nil/unset or 0 =
|
||||||
|
// send no cap and let the endpoint decide. Read per run, like nonStreaming.
|
||||||
|
func (m *MainAgent) SetMaxTokens(fn func() int) { m.maxTokensFn = fn }
|
||||||
|
|
||||||
|
func (m *MainAgent) maxTokens() int {
|
||||||
|
if m.maxTokensFn == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return m.maxTokensFn()
|
||||||
|
}
|
||||||
|
|
||||||
func NewMainAgent(prov llm.Provider, model, workDir string, tx *transcript.Store, window, maxTurns int) *MainAgent {
|
func NewMainAgent(prov llm.Provider, model, workDir string, tx *transcript.Store, window, maxTurns int) *MainAgent {
|
||||||
return &MainAgent{prov: prov, model: model, workDir: workDir, tx: tx, window: window, maxTurns: maxTurns}
|
return &MainAgent{prov: prov, model: model, workDir: workDir, tx: tx, window: window, maxTurns: maxTurns}
|
||||||
}
|
}
|
||||||
@@ -137,6 +149,7 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, t
|
|||||||
// 命中预算(步数)→ SDK 跑收尾:向用户输出一句进展总结。Prompt 与收尾轮数可后台编辑(默认 10 轮)。
|
// 命中预算(步数)→ SDK 跑收尾:向用户输出一句进展总结。Prompt 与收尾轮数可后台编辑(默认 10 轮)。
|
||||||
Settlement: wrapupSettlement("mainagent", nil),
|
Settlement: wrapupSettlement("mainagent", nil),
|
||||||
NonStreaming: m.nonStreaming(), // 该 profile 选非流式时走 Provider.Complete
|
NonStreaming: m.nonStreaming(), // 该 profile 选非流式时走 Provider.Complete
|
||||||
|
MaxTokens: m.maxTokens(), // 0 = 不发上限,由服务端默认值决定
|
||||||
}
|
}
|
||||||
if m.tx != nil { // persist raw human↔AI conversation; one accumulating file per task
|
if m.tx != nil { // persist raw human↔AI conversation; one accumulating file per task
|
||||||
opts.Transcript = m.tx
|
opts.Transcript = m.tx
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type Planner struct {
|
|||||||
workDir string // shared work dir (surfaced in prompt as artifact-output target)
|
workDir string // shared work dir (surfaced in prompt as artifact-output target)
|
||||||
injectConstraints func() bool // resolver: inject task operation constraints into system prompt? (nil = yes)
|
injectConstraints func() bool // resolver: inject task operation constraints into system prompt? (nil = yes)
|
||||||
nonStreamingFn func() bool // resolver: use non-streaming (Complete) path? (nil = streaming)
|
nonStreamingFn func() bool // resolver: use non-streaming (Complete) path? (nil = streaming)
|
||||||
|
maxTokensFn func() int // resolver: per-reply output cap (nil/0 = send no cap)
|
||||||
|
|
||||||
// todos keeps ONE plan-scratchpad per task (keyed by exploration id) so the
|
// todos keeps ONE plan-scratchpad per task (keyed by exploration id) so the
|
||||||
// planner's multi-step plan survives across wake-ups — each Plan() is a fresh
|
// planner's multi-step plan survives across wake-ups — each Plan() is a fresh
|
||||||
@@ -55,6 +56,17 @@ func (p *Planner) SetNonStreaming(fn func() bool) { p.nonStreamingFn = fn }
|
|||||||
|
|
||||||
func (p *Planner) nonStreaming() bool { return p.nonStreamingFn != nil && p.nonStreamingFn() }
|
func (p *Planner) nonStreaming() bool { return p.nonStreamingFn != nil && p.nonStreamingFn() }
|
||||||
|
|
||||||
|
// SetMaxTokens wires a resolver for the per-reply output cap. nil/unset or 0 =
|
||||||
|
// send no cap and let the endpoint decide. Read per run, like nonStreaming.
|
||||||
|
func (p *Planner) SetMaxTokens(fn func() int) { p.maxTokensFn = fn }
|
||||||
|
|
||||||
|
func (p *Planner) maxTokens() int {
|
||||||
|
if p.maxTokensFn == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return p.maxTokensFn()
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Planner) compactionWindow() int {
|
func (p *Planner) compactionWindow() int {
|
||||||
if p.windowFn != nil {
|
if p.windowFn != nil {
|
||||||
return p.windowFn()
|
return p.windowFn()
|
||||||
@@ -366,6 +378,7 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts
|
|||||||
// clamped(被任务 deadline 夹逼)时改用 PromptByReason(见 wrapupSettlementForTask)。
|
// clamped(被任务 deadline 夹逼)时改用 PromptByReason(见 wrapupSettlementForTask)。
|
||||||
Settlement: settle,
|
Settlement: settle,
|
||||||
NonStreaming: p.nonStreaming(), // 该 profile 选非流式时走 Provider.Complete
|
NonStreaming: p.nonStreaming(), // 该 profile 选非流式时走 Provider.Complete
|
||||||
|
MaxTokens: p.maxTokens(), // 0 = 不发上限,由服务端默认值决定
|
||||||
}
|
}
|
||||||
if p.tx != nil { // persist raw LLM conversation; one accumulating file per task's planner
|
if p.tx != nil { // persist raw LLM conversation; one accumulating file per task's planner
|
||||||
opts.Transcript = p.tx
|
opts.Transcript = p.tx
|
||||||
|
|||||||
@@ -58,6 +58,15 @@ type Config struct {
|
|||||||
// 某些网关糟糕的 SSE 实现(空帧、思考字段丢帧),代价是失去运行中的实时进度/实时
|
// 某些网关糟糕的 SSE 实现(空帧、思考字段丢帧),代价是失去运行中的实时进度/实时
|
||||||
// token 计数。映射为 agentcore.Options.NonStreaming = !Stream。
|
// token 计数。映射为 agentcore.Options.NonStreaming = !Stream。
|
||||||
Stream bool
|
Stream bool
|
||||||
|
// MaxTokens 是单次回复的输出上限(token)。0 = 不发送该字段,由服务端默认值决定
|
||||||
|
// (历史行为)。与 ContextWindowK 不同:后者是模型总容量,只在本地用来算压缩阈值,
|
||||||
|
// 不出现在请求里;本值随每次请求发出。映射为 agentcore.Options.MaxTokens。
|
||||||
|
MaxTokens int
|
||||||
|
// MaxTokensField 选择 MaxTokens 用哪个请求字段名,仅对 format=openai 生效:
|
||||||
|
// "" = max_tokens(默认); "max_completion_tokens" = 新字段。
|
||||||
|
// OpenAI 推理模型(o 系列/GPT-5)只认后者,收到 max_tokens 会直接报
|
||||||
|
// unsupported_parameter;而多数兼容网关只认前者,故不做自动推断,交由用户按端点选。
|
||||||
|
MaxTokensField string
|
||||||
}
|
}
|
||||||
|
|
||||||
// compaction window resolution bounds (in K tokens). Below the floor the
|
// compaction window resolution bounds (in K tokens). Below the floor the
|
||||||
@@ -230,6 +239,9 @@ func (c Config) NewProvider() (llm.Provider, error) {
|
|||||||
// 可只发 thinking.type、只发 effort、都发、或都不发。
|
// 可只发 thinking.type、只发 effort、都发、或都不发。
|
||||||
lc.ThinkingType = c.ThinkingType
|
lc.ThinkingType = c.ThinkingType
|
||||||
lc.ReasoningEffort = c.ReasoningEffort
|
lc.ReasoningEffort = c.ReasoningEffort
|
||||||
|
// 输出上限的字段名选择(空 = 用 max_tokens)。上限的「值」不在这里:它每轮随
|
||||||
|
// agentcore.Options.MaxTokens 走,provider 只决定把它塞进哪个键。
|
||||||
|
lc.MaxTokensField = c.MaxTokensField
|
||||||
if c.RatePerSecond > 0 || c.RatePerMinute > 0 {
|
if c.RatePerSecond > 0 || c.RatePerMinute > 0 {
|
||||||
lc.RateLimit = &llm.RateLimit{PerSecond: c.RatePerSecond, PerMinute: c.RatePerMinute}
|
lc.RateLimit = &llm.RateLimit{PerSecond: c.RatePerSecond, PerMinute: c.RatePerMinute}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ type Worker struct {
|
|||||||
// path. Read per run so a profile/task toggle takes effect without rebuilding
|
// path. Read per run so a profile/task toggle takes effect without rebuilding
|
||||||
// the agent. nil = streaming (default).
|
// the agent. nil = streaming (default).
|
||||||
nonStreamingFn func() bool
|
nonStreamingFn func() bool
|
||||||
|
// maxTokensFn resolves the per-reply output cap in tokens, on the same
|
||||||
|
// per-run basis. nil or 0 = send no cap and let the endpoint decide.
|
||||||
|
maxTokensFn func() int
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorkerSessionID returns the stable transcript key used by a worker intent.
|
// WorkerSessionID returns the stable transcript key used by a worker intent.
|
||||||
@@ -100,6 +103,17 @@ func (w *Worker) SetNonStreaming(fn func() bool) { w.nonStreamingFn = fn }
|
|||||||
|
|
||||||
func (w *Worker) nonStreaming() bool { return w.nonStreamingFn != nil && w.nonStreamingFn() }
|
func (w *Worker) nonStreaming() bool { return w.nonStreamingFn != nil && w.nonStreamingFn() }
|
||||||
|
|
||||||
|
// SetMaxTokens wires a resolver for the per-reply output cap. nil/unset or 0 =
|
||||||
|
// send no cap and let the endpoint decide. Read per run, like nonStreaming.
|
||||||
|
func (w *Worker) SetMaxTokens(fn func() int) { w.maxTokensFn = fn }
|
||||||
|
|
||||||
|
func (w *Worker) maxTokens() int {
|
||||||
|
if w.maxTokensFn == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return w.maxTokensFn()
|
||||||
|
}
|
||||||
|
|
||||||
// SetConstraintInject wires a resolver deciding whether this task's operation
|
// SetConstraintInject wires a resolver deciding whether this task's operation
|
||||||
// constraints get injected into the worker system prompt. nil = inject (default).
|
// constraints get injected into the worker system prompt. nil = inject (default).
|
||||||
func (w *Worker) SetConstraintInject(fn func() bool) { w.injectConstraints = fn }
|
func (w *Worker) SetConstraintInject(fn func() bool) { w.injectConstraints = fn }
|
||||||
@@ -386,6 +400,7 @@ func (w *Worker) execute(ctx context.Context, name string, taskID int64, as *db.
|
|||||||
Compaction: compactionConfig(w.compactionWindow()), // long tool-heavy runs stay within the window
|
Compaction: compactionConfig(w.compactionWindow()), // long tool-heavy runs stay within the window
|
||||||
Todos: actool.NewTodoStore(), // 会话级临时待办(TodoWrite),纯规划用,退出即丢
|
Todos: actool.NewTodoStore(), // 会话级临时待办(TodoWrite),纯规划用,退出即丢
|
||||||
NonStreaming: w.nonStreaming(), // 该 profile 选非流式时走 Provider.Complete
|
NonStreaming: w.nonStreaming(), // 该 profile 选非流式时走 Provider.Complete
|
||||||
|
MaxTokens: w.maxTokens(), // 0 = 不发上限,由服务端默认值决定
|
||||||
}
|
}
|
||||||
if hooks != nil { // typed-nil guard: only set when concrete (avoids harness panic)
|
if hooks != nil { // typed-nil guard: only set when concrete (avoids harness panic)
|
||||||
opts.Hooks = hooks
|
opts.Hooks = hooks
|
||||||
|
|||||||
+22
-10
@@ -44,18 +44,30 @@ type LLMProfile struct {
|
|||||||
// Provider.Complete). Non-streaming sidesteps flaky gateway SSE at the cost of
|
// Provider.Complete). Non-streaming sidesteps flaky gateway SSE at the cost of
|
||||||
// live in-run progress. Maps to agent.Config.Stream.
|
// live in-run progress. Maps to agent.Config.Stream.
|
||||||
Streaming bool `json:"streaming"`
|
Streaming bool `json:"streaming"`
|
||||||
|
// MaxTokens caps a single reply's output in tokens. 0 = send no cap and let
|
||||||
|
// the endpoint's own default apply (the historical behaviour). Unlike
|
||||||
|
// ContextWindowK — the model's total capacity, used locally to size compaction
|
||||||
|
// — this value travels with every request.
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
// MaxTokensField picks the request key carrying MaxTokens, for format
|
||||||
|
// "openai" only: "" = max_tokens (default); "max_completion_tokens" = the
|
||||||
|
// newer key, which OpenAI's reasoning models require and whose budget covers
|
||||||
|
// reasoning tokens plus visible output. Ignored by anthropic and
|
||||||
|
// openai-responses, which name the field themselves.
|
||||||
|
MaxTokensField string `json:"max_tokens_field"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)`
|
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)`
|
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,'')`
|
||||||
|
|
||||||
// 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) ListProfiles() ([]*LLMProfile, error) {
|
func (d *DB) ListProfiles() ([]*LLMProfile, error) {
|
||||||
@@ -128,18 +140,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)
|
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) RETURNING id`,
|
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).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).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 WHERE id=$14`,
|
_, 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.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.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 WHERE id=$16`,
|
_, 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.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.ID)
|
||||||
return p.ID, err
|
return p.ID, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestProfileMaxTokensRoundTrip pins the output-cap columns through the whole
|
||||||
|
// read/write surface: both column lists (list vs keyed loads) must carry them,
|
||||||
|
// and an update must not drop them. A miscounted column list silently shifts
|
||||||
|
// every later Scan target, so this is the test that catches it.
|
||||||
|
func TestProfileMaxTokensRoundTrip(t *testing.T) {
|
||||||
|
d, err := Open(testDSN(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||||
|
}
|
||||||
|
// Close via Cleanup, registered FIRST so it runs LAST: cleanups are LIFO, and a
|
||||||
|
// plain `defer d.Close()` would fire before them — the row-deleting cleanups
|
||||||
|
// would then run against a closed pool and silently leave test rows behind.
|
||||||
|
t.Cleanup(func() { d.Close() })
|
||||||
|
|
||||||
|
id, err := d.SaveProfile(&LLMProfile{
|
||||||
|
Name: "t-maxtok", Format: "openai", Model: "m", APIKey: "k",
|
||||||
|
MaxTokens: 8192, MaxTokensField: "max_completion_tokens",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { d.Exec(`DELETE FROM llm_profiles WHERE id=$1`, id) })
|
||||||
|
|
||||||
|
// Keyed single-row load (profileColsKey).
|
||||||
|
p, err := d.ProfileByID(id)
|
||||||
|
if err != nil || p == nil {
|
||||||
|
t.Fatalf("ProfileByID: %v, p=%v", err, p)
|
||||||
|
}
|
||||||
|
if p.MaxTokens != 8192 || p.MaxTokensField != "max_completion_tokens" {
|
||||||
|
t.Fatalf("keyed load: max_tokens=%d field=%q", p.MaxTokens, p.MaxTokensField)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List load (profileCols — the hint variant, a separate column list).
|
||||||
|
ps, err := d.ListProfiles()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var found *LLMProfile
|
||||||
|
for _, x := range ps {
|
||||||
|
if x.ID == id {
|
||||||
|
found = x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found == nil {
|
||||||
|
t.Fatal("profile missing from ListProfiles")
|
||||||
|
}
|
||||||
|
if found.MaxTokens != 8192 || found.MaxTokensField != "max_completion_tokens" {
|
||||||
|
t.Fatalf("list load: max_tokens=%d field=%q", found.MaxTokens, found.MaxTokensField)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update with a blank key takes the "keep existing key" UPDATE branch, which
|
||||||
|
// has its own column list and is the easiest one to forget.
|
||||||
|
p.APIKey = ""
|
||||||
|
p.MaxTokens = 4096
|
||||||
|
p.MaxTokensField = ""
|
||||||
|
if _, err := d.SaveProfile(p); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
after, err := d.ProfileByID(id)
|
||||||
|
if err != nil || after == nil {
|
||||||
|
t.Fatalf("reload: %v", err)
|
||||||
|
}
|
||||||
|
if after.MaxTokens != 4096 || after.MaxTokensField != "" {
|
||||||
|
t.Fatalf("after update: max_tokens=%d field=%q", after.MaxTokens, after.MaxTokensField)
|
||||||
|
}
|
||||||
|
if after.APIKey != "k" {
|
||||||
|
t.Fatalf("blank key on update must keep the stored one, got %q", after.APIKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A profile saved without touching the new fields must read back as "no cap,
|
||||||
|
// classic field name" — the pre-feature behaviour old rows also get.
|
||||||
|
func TestProfileMaxTokensDefaults(t *testing.T) {
|
||||||
|
d, err := Open(testDSN(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||||
|
}
|
||||||
|
// Close via Cleanup, registered FIRST so it runs LAST: cleanups are LIFO, and a
|
||||||
|
// plain `defer d.Close()` would fire before them — the row-deleting cleanups
|
||||||
|
// would then run against a closed pool and silently leave test rows behind.
|
||||||
|
t.Cleanup(func() { d.Close() })
|
||||||
|
|
||||||
|
id, err := d.SaveProfile(&LLMProfile{Name: "t-maxtok-default", Format: "openai", Model: "m", APIKey: "k"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { d.Exec(`DELETE FROM llm_profiles WHERE id=$1`, id) })
|
||||||
|
|
||||||
|
p, err := d.ProfileByID(id)
|
||||||
|
if err != nil || p == nil {
|
||||||
|
t.Fatalf("ProfileByID: %v", err)
|
||||||
|
}
|
||||||
|
if p.MaxTokens != 0 || p.MaxTokensField != "" {
|
||||||
|
t.Fatalf("defaults: max_tokens=%d field=%q, want 0 and \"\"", p.MaxTokens, p.MaxTokensField)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -312,6 +312,15 @@ CREATE TABLE IF NOT EXISTS llm_profiles (
|
|||||||
pool_exclude BOOLEAN NOT NULL DEFAULT false,
|
pool_exclude BOOLEAN NOT NULL DEFAULT false,
|
||||||
-- streaming=true(默认)走流式 SSE;false 走真·非流式(stream:false,一次性 JSON)。
|
-- streaming=true(默认)走流式 SSE;false 走真·非流式(stream:false,一次性 JSON)。
|
||||||
streaming BOOLEAN NOT NULL DEFAULT true,
|
streaming BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
-- 单次回复的输出上限(token)。0=不发送该字段,由服务端默认值决定——保持既有行为。
|
||||||
|
-- 与 context_window_k(模型总容量,仅本地用于压缩阈值)是两回事:本值会随请求发出。
|
||||||
|
max_tokens INTEGER NOT NULL DEFAULT 0,
|
||||||
|
-- 输出上限用哪个请求字段名,仅对 format='openai' 生效:
|
||||||
|
-- '' = max_tokens(默认,兼容绝大多数网关)
|
||||||
|
-- 'max_completion_tokens' = 新字段;OpenAI 推理模型(o 系列/GPT-5)只认它,
|
||||||
|
-- 发 max_tokens 会被 unsupported_parameter 拒绝。
|
||||||
|
-- anthropic(max_tokens 必填)与 openai-responses(max_output_tokens)自带字段名,不受此值影响。
|
||||||
|
max_tokens_field 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()
|
||||||
);
|
);
|
||||||
@@ -330,6 +339,18 @@ ALTER TABLE llm_profiles DROP CONSTRAINT IF EXISTS llm_profiles_format_check;
|
|||||||
ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_format_check
|
ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_format_check
|
||||||
CHECK (format IN ('openai','anthropic','openai-responses'));
|
CHECK (format IN ('openai','anthropic','openai-responses'));
|
||||||
|
|
||||||
|
-- 输出上限及其字段名;补旧库。默认 0 / '' = 不发送上限、沿用 max_tokens 字段名,
|
||||||
|
-- 旧配置行为完全不变。
|
||||||
|
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS max_tokens INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS max_tokens_field TEXT NOT NULL DEFAULT '';
|
||||||
|
-- 同 format:先删再建,保证每次启动幂等。
|
||||||
|
ALTER TABLE llm_profiles DROP CONSTRAINT IF EXISTS llm_profiles_max_tokens_field_check;
|
||||||
|
ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_max_tokens_field_check
|
||||||
|
CHECK (max_tokens_field IN ('','max_completion_tokens'));
|
||||||
|
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);
|
||||||
|
|
||||||
-- 思考开关字段 thinking_type,从旧的单一 reasoning_effort 语义一次性拆分而来。
|
-- 思考开关字段 thinking_type,从旧的单一 reasoning_effort 语义一次性拆分而来。
|
||||||
-- schema.sql 每次启动都执行,故迁移必须只跑一次:仅当该列尚不存在时才回填,
|
-- schema.sql 每次启动都执行,故迁移必须只跑一次:仅当该列尚不存在时才回填,
|
||||||
-- 否则每次启动都会把用户后来手动设的组合覆盖回去。旧 reasoning_effort 语义:
|
-- 否则每次启动都会把用户后来手动设的组合覆盖回去。旧 reasoning_effort 语义:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module github.com/Autumn-27/artex
|
|||||||
go 1.26.3
|
go 1.26.3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Autumn-27/norma v0.3.2
|
github.com/Autumn-27/norma v0.3.3
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/klauspost/compress v1.18.6
|
github.com/klauspost/compress v1.18.6
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
github.com/Autumn-27/norma v0.3.2 h1:3gW84SbSrz8luW8kbi2tv3O7CLdRMQvyylCHj1QUdNk=
|
github.com/Autumn-27/norma v0.3.2 h1:3gW84SbSrz8luW8kbi2tv3O7CLdRMQvyylCHj1QUdNk=
|
||||||
github.com/Autumn-27/norma v0.3.2/go.mod h1:BudWrf7iHl96FgHqFr/HwN3mKYmGjJDt+xL2ZHXfFZw=
|
github.com/Autumn-27/norma v0.3.2/go.mod h1:BudWrf7iHl96FgHqFr/HwN3mKYmGjJDt+xL2ZHXfFZw=
|
||||||
|
github.com/Autumn-27/norma v0.3.3 h1:nppsOFjg+h7vj1H+IH67wj378Spj32jSAqxnG+XqLts=
|
||||||
|
github.com/Autumn-27/norma v0.3.3/go.mod h1:BudWrf7iHl96FgHqFr/HwN3mKYmGjJDt+xL2ZHXfFZw=
|
||||||
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
|
||||||
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||||
|
|||||||
@@ -583,6 +583,10 @@ type LLMProfileDTO struct {
|
|||||||
// 收发模式:true=流式(SSE) | false=非流式。没有 omitempty —— false 必须出现在
|
// 收发模式:true=流式(SSE) | false=非流式。没有 omitempty —— false 必须出现在
|
||||||
// 响应里,否则前端读不到「非流式」,开关会回落成默认的流式。
|
// 响应里,否则前端读不到「非流式」,开关会回落成默认的流式。
|
||||||
Streaming bool `json:"streaming"`
|
Streaming bool `json:"streaming"`
|
||||||
|
// 单次回复输出上限(0=不发送,由服务端默认值决定),以及它用哪个请求字段名
|
||||||
|
// (''=max_tokens | 'max_completion_tokens',仅 openai 格式有意义)。
|
||||||
|
MaxTokens int `json:"max_tokens"`
|
||||||
|
MaxTokensField string `json:"max_tokens_field"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func llmProfileDTO(p *db.LLMProfile) LLMProfileDTO {
|
func llmProfileDTO(p *db.LLMProfile) LLMProfileDTO {
|
||||||
@@ -603,6 +607,8 @@ func llmProfileDTO(p *db.LLMProfile) LLMProfileDTO {
|
|||||||
Priority: p.Priority,
|
Priority: p.Priority,
|
||||||
PoolExclude: p.PoolExclude,
|
PoolExclude: p.PoolExclude,
|
||||||
Streaming: p.Streaming,
|
Streaming: p.Streaming,
|
||||||
|
MaxTokens: p.MaxTokens,
|
||||||
|
MaxTokensField: p.MaxTokensField,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -384,7 +384,7 @@ func (s *Server) createGoals(ctx context.Context, t *Task, emit func(db.Activity
|
|||||||
taskID, _ := strconv.ParseInt(t.ID, 10, 64)
|
taskID, _ := strconv.ParseInt(t.ID, 10, 64)
|
||||||
s.engine.BeginLLMCall(t.ID)
|
s.engine.BeginLLMCall(t.ID)
|
||||||
goalRuntime := s.agentsForTask(t).runtime
|
goalRuntime := s.agentsForTask(t).runtime
|
||||||
decomposed := agent.DecomposeGoalsWithProvider(ctx, goalRuntime, s.m.dir, t.Goal, t.Description, as, t.Store, taskID, goalRuntime.nonStreaming(), emit)
|
decomposed := agent.DecomposeGoalsWithProvider(ctx, goalRuntime, s.m.dir, t.Goal, t.Description, as, t.Store, taskID, goalRuntime.nonStreaming(), goalRuntime.maxTokens(), emit)
|
||||||
s.engine.EndLLMCall(t.ID)
|
s.engine.EndLLMCall(t.ID)
|
||||||
for _, g := range decomposed {
|
for _, g := range decomposed {
|
||||||
if strings.TrimSpace(g.Text) != "" {
|
if strings.TrimSpace(g.Text) != "" {
|
||||||
|
|||||||
+20
-3
@@ -312,6 +312,7 @@ func (s *Server) loadLLMConfig() (agent.Config, bool) {
|
|||||||
cfg.ThinkingType = p.ThinkingType
|
cfg.ThinkingType = p.ThinkingType
|
||||||
cfg.ReasoningEffort = p.ReasoningEffort
|
cfg.ReasoningEffort = p.ReasoningEffort
|
||||||
cfg.Stream = p.Streaming
|
cfg.Stream = p.Streaming
|
||||||
|
cfg.MaxTokens, cfg.MaxTokensField = p.MaxTokens, p.MaxTokensField
|
||||||
if cfg.APIKey == "" {
|
if cfg.APIKey == "" {
|
||||||
return cfg, false
|
return cfg, false
|
||||||
}
|
}
|
||||||
@@ -327,16 +328,19 @@ func (s *Server) saveLLMConfig(cfg agent.Config) error {
|
|||||||
// (anthropic / openai / openai-responses), matching the DB CHECK constraint.
|
// (anthropic / openai / openai-responses), matching the DB CHECK constraint.
|
||||||
format := cfg.Provider()
|
format := cfg.Provider()
|
||||||
var id int64
|
var id int64
|
||||||
// agent.Config carries no failover fields, so carry the stored ones forward —
|
// 这个 legacy 端点的请求体不含轮询/收发/输出上限参数,故把库里已存的值原样带回 —
|
||||||
// otherwise this legacy endpoint would silently reset the profile's priority,
|
// 否则每次保存都会把 profile 的 priority、pool_exclude、streaming 以及输出上限
|
||||||
// pool_exclude, and streaming to zero values on every save.
|
// (max_tokens / max_tokens_field)悄悄重置成零值。
|
||||||
var priority int
|
var priority int
|
||||||
var poolExclude bool
|
var poolExclude bool
|
||||||
streaming := true // 旧库/新建默认流式
|
streaming := true // 旧库/新建默认流式
|
||||||
|
var maxTokens int
|
||||||
|
var maxTokensField 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
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -346,6 +350,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,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -396,6 +401,7 @@ func (s *Server) buildPlannerWorker(pinID *int64, gProv llm.Provider, gCfg agent
|
|||||||
wk.SetWebSearch(s.webSearchFor("worker"))
|
wk.SetWebSearch(s.webSearchFor("worker"))
|
||||||
wk.SetConstraintInject(s.constraintInjectWorker) // 操作约束注入 worker(可配置,默认开;每轮读)
|
wk.SetConstraintInject(s.constraintInjectWorker) // 操作约束注入 worker(可配置,默认开;每轮读)
|
||||||
wk.SetNonStreaming(nonStreamingResolver(wCfg)) // 该 profile 选非流式时走 Provider.Complete
|
wk.SetNonStreaming(nonStreamingResolver(wCfg)) // 该 profile 选非流式时走 Provider.Complete
|
||||||
|
wk.SetMaxTokens(maxTokensResolver(wCfg)) // 单次回复输出上限(0 = 不发)
|
||||||
pProv, pCfg := s.providerForAgent("planner", pinID, gProv, gCfg)
|
pProv, pCfg := s.providerForAgent("planner", pinID, gProv, gCfg)
|
||||||
pl := agent.NewPlanner(pProv, pCfg.Model, s.m.dir, tx, pCfg.CompactionWindow(), s.agentMaxTurns("planner"))
|
pl := agent.NewPlanner(pProv, pCfg.Model, s.m.dir, tx, pCfg.CompactionWindow(), s.agentMaxTurns("planner"))
|
||||||
pl.SetKillWork(s.engine.KillWork) // planner kill_work → terminate a running work
|
pl.SetKillWork(s.engine.KillWork) // planner kill_work → terminate a running work
|
||||||
@@ -404,6 +410,7 @@ func (s *Server) buildPlannerWorker(pinID *int64, gProv llm.Provider, gCfg agent
|
|||||||
pl.SetWebSearch(s.webSearchFor("planner"))
|
pl.SetWebSearch(s.webSearchFor("planner"))
|
||||||
pl.SetConstraintInject(s.constraintInjectPlanner) // 操作约束注入 planner(可配置,默认开;每轮读)
|
pl.SetConstraintInject(s.constraintInjectPlanner) // 操作约束注入 planner(可配置,默认开;每轮读)
|
||||||
pl.SetNonStreaming(nonStreamingResolver(pCfg)) // 该 profile 选非流式时走 Provider.Complete
|
pl.SetNonStreaming(nonStreamingResolver(pCfg)) // 该 profile 选非流式时走 Provider.Complete
|
||||||
|
pl.SetMaxTokens(maxTokensResolver(pCfg)) // 单次回复输出上限(0 = 不发)
|
||||||
return pl, wk
|
return pl, wk
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,6 +422,12 @@ func nonStreamingResolver(cfg agent.Config) func() bool {
|
|||||||
return func() bool { return nonStreaming }
|
return func() bool { return nonStreaming }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxTokensResolver mirrors nonStreamingResolver for the per-reply output cap.
|
||||||
|
func maxTokensResolver(cfg agent.Config) func() int {
|
||||||
|
maxTokens := cfg.MaxTokens
|
||||||
|
return func() int { return maxTokens }
|
||||||
|
}
|
||||||
|
|
||||||
// applyLLM (re)builds the planner/worker/main-agent from cfg and installs them on
|
// applyLLM (re)builds the planner/worker/main-agent from cfg and installs them on
|
||||||
// the running engine as the GLOBAL active pair. Safe to call at runtime (UI configures LLM).
|
// the running engine as the GLOBAL active pair. Safe to call at runtime (UI configures LLM).
|
||||||
func (s *Server) applyLLM(cfg agent.Config) error {
|
func (s *Server) applyLLM(cfg agent.Config) error {
|
||||||
@@ -459,6 +472,7 @@ func (s *Server) applyLLM(cfg agent.Config) error {
|
|||||||
s.mainAgent.SetWebSearch(s.webSearchFor("mainagent"))
|
s.mainAgent.SetWebSearch(s.webSearchFor("mainagent"))
|
||||||
s.mainAgent.SetSteerWork(s.engine.SteerWork) // steer_work:人对运行中 work 实时纠偏
|
s.mainAgent.SetSteerWork(s.engine.SteerWork) // steer_work:人对运行中 work 实时纠偏
|
||||||
s.mainAgent.SetNonStreaming(nonStreamingResolver(mCfg))
|
s.mainAgent.SetNonStreaming(nonStreamingResolver(mCfg))
|
||||||
|
s.mainAgent.SetMaxTokens(maxTokensResolver(mCfg))
|
||||||
// chat agent serves MANY custom agents by key → it holds the GLOBAL opts
|
// chat agent serves MANY custom agents by key → it holds the GLOBAL opts
|
||||||
// (backend/key) and gates Enabled per-conversation-agent at Chat time. 对话始终用激活配置。
|
// (backend/key) and gates Enabled per-conversation-agent at Chat time. 对话始终用激活配置。
|
||||||
s.chatAgent = agent.NewChatAgent(prov, cfg.Model, s.m.dir, tx, win) // chat page runner
|
s.chatAgent = agent.NewChatAgent(prov, cfg.Model, s.m.dir, tx, win) // chat page runner
|
||||||
@@ -466,6 +480,7 @@ func (s *Server) applyLLM(cfg agent.Config) error {
|
|||||||
s.chatAgent.SetWebSearch(s.m.WebSearchOpts())
|
s.chatAgent.SetWebSearch(s.m.WebSearchOpts())
|
||||||
s.chatAgent.SetGuard(s.chatGuard())
|
s.chatAgent.SetGuard(s.chatGuard())
|
||||||
s.chatAgent.SetNonStreaming(nonStreamingResolver(cfg))
|
s.chatAgent.SetNonStreaming(nonStreamingResolver(cfg))
|
||||||
|
s.chatAgent.SetMaxTokens(maxTokensResolver(cfg))
|
||||||
s.llmProv = prov
|
s.llmProv = prov
|
||||||
s.llmCfg = cfg
|
s.llmCfg = cfg
|
||||||
s.llmOn = true
|
s.llmOn = true
|
||||||
@@ -492,6 +507,7 @@ func (s *Server) loadProfileConfig(id int64) (agent.Config, bool) {
|
|||||||
cfg.ThinkingType = p.ThinkingType
|
cfg.ThinkingType = p.ThinkingType
|
||||||
cfg.ReasoningEffort = p.ReasoningEffort
|
cfg.ReasoningEffort = p.ReasoningEffort
|
||||||
cfg.Stream = p.Streaming
|
cfg.Stream = p.Streaming
|
||||||
|
cfg.MaxTokens, cfg.MaxTokensField = p.MaxTokens, p.MaxTokensField
|
||||||
if cfg.APIKey == "" {
|
if cfg.APIKey == "" {
|
||||||
return cfg, false
|
return cfg, false
|
||||||
}
|
}
|
||||||
@@ -644,6 +660,7 @@ func (s *Server) chatAgentForProfile(id int64) *agent.ChatAgent {
|
|||||||
ca.SetWebSearch(s.m.WebSearchOpts())
|
ca.SetWebSearch(s.m.WebSearchOpts())
|
||||||
ca.SetGuard(s.chatGuard())
|
ca.SetGuard(s.chatGuard())
|
||||||
ca.SetNonStreaming(nonStreamingResolver(cfg))
|
ca.SetNonStreaming(nonStreamingResolver(cfg))
|
||||||
|
ca.SetMaxTokens(maxTokensResolver(cfg))
|
||||||
s.profMu.Lock()
|
s.profMu.Lock()
|
||||||
if ex := s.profChatAgents[id]; ex != nil { // lost the race → keep the winner
|
if ex := s.profChatAgents[id]; ex != nil { // lost the race → keep the winner
|
||||||
ca = ex
|
ca = ex
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
|
|
||||||
"github.com/Autumn-27/artex/agent"
|
"github.com/Autumn-27/artex/agent"
|
||||||
"github.com/Autumn-27/artex/db"
|
"github.com/Autumn-27/artex/db"
|
||||||
|
"github.com/Autumn-27/norma/llm"
|
||||||
"github.com/Autumn-27/norma/skill"
|
"github.com/Autumn-27/norma/skill"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1804,6 +1805,15 @@ func (s *Server) pgSaveProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
p := body.LLMProfile
|
p := body.LLMProfile
|
||||||
p.APIKey = body.APIKey
|
p.APIKey = body.APIKey
|
||||||
p.Streaming = body.Streaming == nil || *body.Streaming
|
p.Streaming = body.Streaming == nil || *body.Streaming
|
||||||
|
// 输出上限:负数无意义,归零(= 不发送该字段)。字段名开关只有 Chat Completions
|
||||||
|
// 用得上——anthropic 与 openai-responses 各自定死了字段名,存下来只会误导后续读者,
|
||||||
|
// 故非 openai 格式一律清空。未知取值同样清空,避免把 DB CHECK 的报错甩给用户。
|
||||||
|
if p.MaxTokens < 0 {
|
||||||
|
p.MaxTokens = 0
|
||||||
|
}
|
||||||
|
if p.Format != "openai" || p.MaxTokensField != llm.MaxTokensFieldCompletion {
|
||||||
|
p.MaxTokensField = ""
|
||||||
|
}
|
||||||
id, err := pg.SaveProfile(&p)
|
id, err := pg.SaveProfile(&p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, 500, err.Error())
|
writeErr(w, 500, err.Error())
|
||||||
|
|||||||
+29
-14
@@ -139,34 +139,46 @@ func (r *taskLLMRuntime) current() (*Task, int64, int64, llm.Provider, error) {
|
|||||||
return t, 0, pt.LLMChainRevision, prov, nil
|
return t, 0, pt.LLMChainRevision, prov, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// nonStreaming reports whether the task's currently-active LLM source is set to
|
// activeCfg resolves the task's currently-active LLM config, mirroring current()'s
|
||||||
// non-streaming. It mirrors current()'s source precedence (agent binding →
|
// source precedence (agent binding → active chain profile → global). Read-only and
|
||||||
// active chain profile → global) but reads only the streaming flag. Read-only
|
// best-effort: ok=false when nothing resolves, leaving the per-setting fallback to
|
||||||
// and best-effort: any resolution error falls back to streaming (false), the
|
// the caller. If failover switches profiles, the change takes effect on the next
|
||||||
// safe default. If failover switches to a profile with a different streaming
|
// agent run (a fresh Session is built per run in captureRun).
|
||||||
// setting, the change takes effect on the next agent run (a fresh Session is
|
func (r *taskLLMRuntime) activeCfg() (agent.Config, bool) {
|
||||||
// built per run in captureRun).
|
|
||||||
func (r *taskLLMRuntime) nonStreaming() bool {
|
|
||||||
taskNum, err := parseTaskID(r.taskID)
|
taskNum, err := parseTaskID(r.taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return agent.Config{}, false
|
||||||
}
|
}
|
||||||
pt, err := r.s.m.pg.GetTask(taskNum)
|
pt, err := r.s.m.pg.GetTask(taskNum)
|
||||||
if err != nil || pt == nil {
|
if err != nil || pt == nil {
|
||||||
return false
|
return agent.Config{}, false
|
||||||
}
|
}
|
||||||
if _, cfg, ok := r.s.agentBindingProvider(r.agentKey); ok {
|
if _, cfg, ok := r.s.agentBindingProvider(r.agentKey); ok {
|
||||||
return !cfg.Stream
|
return cfg, true
|
||||||
}
|
}
|
||||||
if len(pt.LLMProfileIDs) > 0 && pt.ActiveLLMProfileID != nil {
|
if len(pt.LLMProfileIDs) > 0 && pt.ActiveLLMProfileID != nil {
|
||||||
if _, cfg, ok := r.s.providerForProfile(*pt.ActiveLLMProfileID); ok {
|
if _, cfg, ok := r.s.providerForProfile(*pt.ActiveLLMProfileID); ok {
|
||||||
return !cfg.Stream
|
return cfg, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, cfg, ok := r.s.globalProvider(); ok {
|
if _, cfg, ok := r.s.globalProvider(); ok {
|
||||||
return !cfg.Stream
|
return cfg, true
|
||||||
}
|
}
|
||||||
return false
|
return agent.Config{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// nonStreaming reports whether the task's currently-active LLM source is set to
|
||||||
|
// non-streaming. Unresolvable → streaming (false), the safe default.
|
||||||
|
func (r *taskLLMRuntime) nonStreaming() bool {
|
||||||
|
cfg, ok := r.activeCfg()
|
||||||
|
return ok && !cfg.Stream
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxTokens returns the currently-active source's per-reply output cap.
|
||||||
|
// Unresolvable → 0, i.e. send no cap, matching the pre-setting behaviour.
|
||||||
|
func (r *taskLLMRuntime) maxTokens() int {
|
||||||
|
cfg, _ := r.activeCfg() // 未解析出配置时是零值 0
|
||||||
|
return cfg.MaxTokens
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseTaskID(id string) (int64, error) {
|
func parseTaskID(id string) (int64, error) {
|
||||||
@@ -531,6 +543,7 @@ func (s *Server) agentsForTask(t *Task) *taskAgentBundle {
|
|||||||
wk := agent.NewWorker(workerRuntime, "task-router", s.m.dir, tx, window, s.agentMaxTurns("worker"))
|
wk := agent.NewWorker(workerRuntime, "task-router", s.m.dir, tx, window, s.agentMaxTurns("worker"))
|
||||||
wk.SetCompactionWindowResolver(workerRuntime.CompactionWindow)
|
wk.SetCompactionWindowResolver(workerRuntime.CompactionWindow)
|
||||||
wk.SetNonStreaming(workerRuntime.nonStreaming) // 按任务当前激活 profile 的流式开关(每轮读)
|
wk.SetNonStreaming(workerRuntime.nonStreaming) // 按任务当前激活 profile 的流式开关(每轮读)
|
||||||
|
wk.SetMaxTokens(workerRuntime.maxTokens) // 同上,输出上限也跟随当前激活 profile
|
||||||
wk.SetRunTimeout(time.Duration(s.agentRunSeconds("worker")) * time.Second)
|
wk.SetRunTimeout(time.Duration(s.agentRunSeconds("worker")) * time.Second)
|
||||||
wk.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
|
wk.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
|
||||||
wk.SetMemory(memory.NewStore(filepath.Join(s.m.dir, "memory")))
|
wk.SetMemory(memory.NewStore(filepath.Join(s.m.dir, "memory")))
|
||||||
@@ -539,6 +552,7 @@ func (s *Server) agentsForTask(t *Task) *taskAgentBundle {
|
|||||||
pl := agent.NewPlanner(plannerRuntime, "task-router", s.m.dir, tx, plannerRuntime.CompactionWindow(), s.agentMaxTurns("planner"))
|
pl := agent.NewPlanner(plannerRuntime, "task-router", s.m.dir, tx, plannerRuntime.CompactionWindow(), s.agentMaxTurns("planner"))
|
||||||
pl.SetCompactionWindowResolver(plannerRuntime.CompactionWindow)
|
pl.SetCompactionWindowResolver(plannerRuntime.CompactionWindow)
|
||||||
pl.SetNonStreaming(plannerRuntime.nonStreaming)
|
pl.SetNonStreaming(plannerRuntime.nonStreaming)
|
||||||
|
pl.SetMaxTokens(plannerRuntime.maxTokens)
|
||||||
pl.SetKillWork(s.engine.KillWork)
|
pl.SetKillWork(s.engine.KillWork)
|
||||||
pl.SetSteerWork(s.engine.SteerWork)
|
pl.SetSteerWork(s.engine.SteerWork)
|
||||||
pl.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
|
pl.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
|
||||||
@@ -547,6 +561,7 @@ func (s *Server) agentsForTask(t *Task) *taskAgentBundle {
|
|||||||
main := agent.NewMainAgent(mainRuntime, "task-router", s.m.dir, tx, mainRuntime.CompactionWindow(), s.agentMaxTurns("mainagent"))
|
main := agent.NewMainAgent(mainRuntime, "task-router", s.m.dir, tx, mainRuntime.CompactionWindow(), s.agentMaxTurns("mainagent"))
|
||||||
main.SetCompactionWindowResolver(mainRuntime.CompactionWindow)
|
main.SetCompactionWindowResolver(mainRuntime.CompactionWindow)
|
||||||
main.SetNonStreaming(mainRuntime.nonStreaming)
|
main.SetNonStreaming(mainRuntime.nonStreaming)
|
||||||
|
main.SetMaxTokens(mainRuntime.maxTokens)
|
||||||
main.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
|
main.SetProxy(s.m.ProxyAddr(), s.m.ProxyCACert())
|
||||||
main.SetWebSearch(s.webSearchFor("mainagent"))
|
main.SetWebSearch(s.webSearchFor("mainagent"))
|
||||||
main.SetSteerWork(s.engine.SteerWork) // steer_work:人对运行中 work 实时纠偏
|
main.SetSteerWork(s.engine.SteerWork) // steer_work:人对运行中 work 实时纠偏
|
||||||
|
|||||||
@@ -41,6 +41,18 @@ const THINKING_TYPES: { value: string; label: string }[] = [
|
|||||||
{ value: "disabled", label: "关闭" },
|
{ value: "disabled", label: "关闭" },
|
||||||
{ value: "enabled", label: "开启" },
|
{ value: "enabled", label: "开启" },
|
||||||
];
|
];
|
||||||
|
// 输出上限用哪个请求字段名(仅 openai 格式有意义)。NONE ↔ "" 走同一套哨兵转换。
|
||||||
|
const MAX_TOKENS_FIELDS: { value: string; label: string }[] = [
|
||||||
|
{ value: NONE, label: "max_tokens(默认)" },
|
||||||
|
{ value: "max_completion_tokens", label: "max_completion_tokens" },
|
||||||
|
];
|
||||||
|
// 另外两种格式各自定死了字段名,选项对它们无意义,说明文案里直接讲清楚。
|
||||||
|
const MAX_TOKENS_FIELD_HINTS: Record<string, string> = {
|
||||||
|
openai:
|
||||||
|
"上限发哪个键。max_tokens 是默认,绝大多数兼容网关只认它;OpenAI 官方推理模型(o 系列 / GPT-5)反过来只认 max_completion_tokens,收到 max_tokens 会直接报 unsupported_parameter。",
|
||||||
|
anthropic: "仅 openai 格式可选。Anthropic 的字段名固定为 max_tokens。",
|
||||||
|
"openai-responses": "仅 openai 格式可选。Responses API 的字段名固定为 max_output_tokens。",
|
||||||
|
};
|
||||||
const EFFORT_LEVELS: { value: string; label: string }[] = [
|
const EFFORT_LEVELS: { value: string; label: string }[] = [
|
||||||
{ value: NONE, label: "不发送(默认)" },
|
{ value: NONE, label: "不发送(默认)" },
|
||||||
{ value: "low", label: "low" },
|
{ value: "low", label: "low" },
|
||||||
@@ -310,6 +322,8 @@ function ProfileSheet({
|
|||||||
const [priority, setPriority] = React.useState("0"); // 轮询顺位;越大越先
|
const [priority, setPriority] = React.useState("0"); // 轮询顺位;越大越先
|
||||||
const [poolExclude, setPoolExclude] = React.useState(false);
|
const [poolExclude, setPoolExclude] = React.useState(false);
|
||||||
const [streaming, setStreaming] = React.useState(true); // true=流式(默认);false=非流式
|
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 [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[]>([]);
|
||||||
@@ -335,6 +349,8 @@ function ProfileSheet({
|
|||||||
setPriority(String(profile?.priority ?? 0));
|
setPriority(String(profile?.priority ?? 0));
|
||||||
setPoolExclude(profile?.pool_exclude ?? false);
|
setPoolExclude(profile?.pool_exclude ?? false);
|
||||||
setStreaming(profile?.streaming ?? true);
|
setStreaming(profile?.streaming ?? true);
|
||||||
|
setMaxTokens(String(profile?.max_tokens ?? 0));
|
||||||
|
setMaxTokensField(fromStore(profile?.max_tokens_field));
|
||||||
setApiKey("");
|
setApiKey("");
|
||||||
setKeyHint(profile?.api_key_hint ?? "");
|
setKeyHint(profile?.api_key_hint ?? "");
|
||||||
setModels([]);
|
setModels([]);
|
||||||
@@ -417,6 +433,10 @@ function ProfileSheet({
|
|||||||
priority: Number(priority) || 0,
|
priority: Number(priority) || 0,
|
||||||
pool_exclude: poolExclude,
|
pool_exclude: poolExclude,
|
||||||
streaming,
|
streaming,
|
||||||
|
max_tokens: Math.max(0, Number(maxTokens) || 0),
|
||||||
|
// 字段名开关只对 openai(Chat Completions) 有意义,其它格式一律回落到默认;
|
||||||
|
// 后端也会再做一次同样的归一化,这里只是别让 UI 送出自相矛盾的值。
|
||||||
|
max_tokens_field: format === "openai" ? toStore(maxTokensField) : "",
|
||||||
});
|
});
|
||||||
if (isNew) toast.success(`已新建:${name.trim()}(在卡片上「设为激活」以启用)`);
|
if (isNew) toast.success(`已新建:${name.trim()}(在卡片上「设为激活」以启用)`);
|
||||||
else toast.success(profile?.is_default ? "已保存,激活配置即时生效,无需重启" : "已保存");
|
else toast.success(profile?.is_default ? "已保存,激活配置即时生效,无需重启" : "已保存");
|
||||||
@@ -629,6 +649,52 @@ function ProfileSheet({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 rounded-lg border p-3">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="grid gap-0.5">
|
||||||
|
<Label htmlFor="p-max-tokens" className="text-sm">
|
||||||
|
输出上限 · max tokens
|
||||||
|
</Label>
|
||||||
|
<p className="text-muted-foreground text-xs">
|
||||||
|
单次回复最多生成多少 token,随每次请求发出。0(默认)= 不发送该字段,由服务端默认值决定。
|
||||||
|
这与上面的「上下文窗口」是两回事:那是模型总容量,只在本地用来算压缩阈值。
|
||||||
|
设太小会让推理模型在思考阶段就被截断,一个字答案都出不来。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id="p-max-tokens"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="w-28 shrink-0"
|
||||||
|
value={maxTokens}
|
||||||
|
onChange={(e) => setMaxTokens(e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-4 border-t pt-3">
|
||||||
|
<div className="grid gap-0.5">
|
||||||
|
<Label className="text-sm">上限字段名</Label>
|
||||||
|
<p className="text-muted-foreground text-xs">{MAX_TOKENS_FIELD_HINTS[format]}</p>
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={format === "openai" ? maxTokensField : NONE}
|
||||||
|
onValueChange={setMaxTokensField}
|
||||||
|
disabled={format !== "openai"}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-56 shrink-0">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MAX_TOKENS_FIELDS.map((o) => (
|
||||||
|
<SelectItem key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 rounded-lg border p-3">
|
<div className="grid gap-3 rounded-lg border p-3">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="grid gap-0.5">
|
<div className="grid gap-0.5">
|
||||||
|
|||||||
@@ -721,6 +721,8 @@ export const api = {
|
|||||||
priority?: number; // 轮询顺位,越大越先用
|
priority?: number; // 轮询顺位,越大越先用
|
||||||
pool_exclude?: boolean; // true=不作为故障转移目标
|
pool_exclude?: boolean; // true=不作为故障转移目标
|
||||||
streaming?: boolean; // true(默认)=流式 | false=非流式
|
streaming?: boolean; // true(默认)=流式 | false=非流式
|
||||||
|
max_tokens?: number; // 单次回复输出上限;0=不发送,由服务端默认值决定
|
||||||
|
max_tokens_field?: string; // ""=max_tokens(默认) | "max_completion_tokens"(仅 openai 格式)
|
||||||
}) => 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) }),
|
||||||
|
|||||||
@@ -3017,6 +3017,9 @@ export const llmProfiles: LLMProfile[] = [
|
|||||||
is_default: true,
|
is_default: true,
|
||||||
priority: 0,
|
priority: 0,
|
||||||
pool_exclude: false,
|
pool_exclude: false,
|
||||||
|
// anthropic 的字段名固定,故 max_tokens_field 恒为空。
|
||||||
|
max_tokens: 0,
|
||||||
|
max_tokens_field: "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "2",
|
id: "2",
|
||||||
@@ -3033,6 +3036,9 @@ export const llmProfiles: LLMProfile[] = [
|
|||||||
is_default: false,
|
is_default: false,
|
||||||
priority: 10,
|
priority: 10,
|
||||||
pool_exclude: false,
|
pool_exclude: false,
|
||||||
|
// openai 格式 + 推理模型:上限走 max_completion_tokens。
|
||||||
|
max_tokens: 8192,
|
||||||
|
max_tokens_field: "max_completion_tokens",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -904,6 +904,12 @@ export interface LLMProfile {
|
|||||||
pool_exclude?: boolean;
|
pool_exclude?: boolean;
|
||||||
// true(默认)= 流式(SSE) | false = 真·非流式(stream:false,一次性返回)。
|
// true(默认)= 流式(SSE) | false = 真·非流式(stream:false,一次性返回)。
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
|
// 单次回复的输出上限(token)。0 = 不发送该字段,由服务端默认值决定。
|
||||||
|
// 注意与 context_window_k 区分:后者是模型总容量,只在本地用于压缩阈值。
|
||||||
|
max_tokens?: number;
|
||||||
|
// 上限用哪个请求字段名,仅 format="openai" 有意义:
|
||||||
|
// ""=max_tokens(默认) | "max_completion_tokens"(OpenAI 推理模型只认它)
|
||||||
|
max_tokens_field?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- LLM 轮询(故障转移)----
|
// ---- LLM 轮询(故障转移)----
|
||||||
|
|||||||
Reference in new issue
Block a user