Why: Masks must not be the only copy of a key, and personal instructions, lore hits, and candidate replies have to reach the model without rewriting the system prefix or deleting history. Evidence: go test ./internal/store ./internal/ctxasm ./internal/settings ./internal/runtime ./internal/server. Migration v16 upgrades and rolls back. Startup snapshots settings before moving raw keys. Impact: One labeled key is active per service. Excluding a message hides its whole run from the model. Regenerating appends another candidate and rewinds files to the user turn. User slash commands expand to prompt text and do not fire on their own. Refs: docs/records/2026-10-04-sillytavern-review.md
103 lines
3.1 KiB
Go
103 lines
3.1 KiB
Go
package runtime
|
||
|
||
import (
|
||
"strings"
|
||
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// SecretLookup 由组合根注入。返回该服务当前生效的明文;没有独立密钥时 ok 为 false,调用方用设置里的旧字段。
|
||
var SecretLookup func(owner, service string) (string, bool)
|
||
|
||
// LookupSecret 给组合根以外的调用方使用。owner 为空时用 local。
|
||
func LookupSecret(owner, service, fallback string) string {
|
||
return overlayKey(owner, service, fallback)
|
||
}
|
||
|
||
func overlayKey(owner, service, fallback string) string {
|
||
if SecretLookup == nil {
|
||
return fallback
|
||
}
|
||
if owner == "" {
|
||
owner = "local"
|
||
}
|
||
if v, ok := SecretLookup(owner, service); ok && strings.TrimSpace(v) != "" {
|
||
return v
|
||
}
|
||
return fallback
|
||
}
|
||
|
||
// 模型路由解析(4.7 / 4.9 覆盖链):设置层 defaults → system → user → project,
|
||
// 画像 ModelRoute 为会话内最高层覆盖(kind 为空回落设置层主模型)。
|
||
// 端点与密钥按 kind 独立配置,轻量档空则回落主档。
|
||
|
||
// ContextInjection 是一条条件注入,由 runmgr 从存储读出,组装时再转换成 ctxasm 的形状。
|
||
type ContextInjection struct {
|
||
Keys []string
|
||
Secondary string
|
||
Logic string
|
||
Content string
|
||
Constant bool
|
||
ScanDepth int
|
||
BudgetChars int
|
||
}
|
||
|
||
// ResolvedModel 是一次路由解析的结果。
|
||
type ResolvedModel struct {
|
||
Kind string // chat | lightweight
|
||
Model string
|
||
Base string
|
||
Key string
|
||
}
|
||
|
||
// ResolveChatModel 解析主对话模型(画像 route > 设置层)。
|
||
func ResolveChatModel(prof core.AgentProfile, eff core.EffectiveSettings) ResolvedModel {
|
||
m := eff.GetString("models.chat.model", "")
|
||
if prof.Model.Chat != "" {
|
||
m = prof.Model.Chat
|
||
}
|
||
return ResolvedModel{Kind: "chat", Model: m,
|
||
Base: eff.GetString("models.chat.baseUrl", ""),
|
||
Key: overlayKey(prof.Owner, "models.chat", eff.GetString("models.chat.apiKey", ""))}
|
||
}
|
||
|
||
// ChatFallbacks 解析 models.chat.fallbacks。默认空,不改变现有模型选择。
|
||
func ChatFallbacks(eff core.EffectiveSettings) []string {
|
||
raw := strings.TrimSpace(eff.GetString("models.chat.fallbacks", ""))
|
||
if raw == "" {
|
||
return nil
|
||
}
|
||
var out []string
|
||
seen := map[string]bool{}
|
||
for _, part := range strings.Split(raw, ",") {
|
||
name := strings.TrimSpace(part)
|
||
if name == "" || seen[name] {
|
||
continue
|
||
}
|
||
seen[name] = true
|
||
out = append(out, name)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ResolveLightweightModel 解析轻量模型:画像 route > 设置层轻量档 > 回落主档。
|
||
func ResolveLightweightModel(prof core.AgentProfile, eff core.EffectiveSettings) ResolvedModel {
|
||
m := eff.GetString("models.lightweight.model", "")
|
||
base := eff.GetString("models.lightweight.baseUrl", "")
|
||
key := overlayKey(prof.Owner, "models.lightweight", eff.GetString("models.lightweight.apiKey", ""))
|
||
if prof.Model.Lightweight != "" {
|
||
m = prof.Model.Lightweight
|
||
}
|
||
if m == "" || base == "" {
|
||
// 回落主档(模型与端点分别回落,允许轻量仅覆盖模型名)。
|
||
chat := ResolveChatModel(prof, eff)
|
||
if m == "" {
|
||
m = chat.Model
|
||
}
|
||
if base == "" {
|
||
base, key = chat.Base, chat.Key
|
||
}
|
||
}
|
||
return ResolvedModel{Kind: "lightweight", Model: m, Base: base, Key: key}
|
||
}
|