feat(config): 运行时配置体系①——LLM 多厂商独立组落 models.json + 面板落盘 + 热更新
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- runtimeconfig/models.go: models.json 承载 [{id,provider,baseUrl,apiKey,model,fallback?,multimodal?}] 多厂商独立组(首项=生效)
- LoadModels 优先文件/回退env(GroupFromEnv), SaveModels(0600+tmp原子写), Activate/Upsert/MaskedKey脱敏
- 面板 POST /admin/llm 落盘 models.json(取代temporary内存) + GET 列组(脱敏)
- runtime/model/runtime.go: RuntimeLLM(adapter+router) atomic快照可热替换, Reload 重建不中断进行中请求; per-role路由用接口
- wire 用 NewRuntimeLLM(adapter+router), 保留 a.modelAdapter(=runtime stack, 审查模型随热更新)
- .env 退兜底(.env.example注明); apiKey不打印/Config()脱敏/热更新不中断
- 全仓 go build/vet/test 绿(54包)+交叉编译+前端build绿(79模块); api-client v0.15已同步
- 遗留: vision ModelInfo 仍读静态env(后续纳入); 保存组=生效组(第一版语义)
This commit is contained in:
Aska committed 2026-09-07 13:07:58 +08:00
1 parent 8cde0162e6
commit 2d7151f979
16 files changed
+1079 -108

No files matched your search

+263
View File
@@ -0,0 +1,263 @@
// Package runtimeconfig loads and persists the runtime-editable provider
// configuration: the independent LLM vendor groups in models.json (design/04
// §3.1). The .env LLM block is the final fallback; a present-and-valid
// models.json takes precedence and is hot-reloaded at runtime without a restart.
//
// models.json is gitignored (local runtime config) and holds API keys, so this
// package never logs a key and never exposes one un-masked in a Config().
package runtimeconfig
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// Provider names recognized in models.json. They mirror the model adapter
// factory's protocol names so a group maps directly to a wire protocol.
const (
ProviderOpenAI = "openai"
ProviderAnthropic = "anthropic"
// modelsFileName is the models.json basename stored under the data dir.
modelsFileName = "models.json"
)
// Group is one independent multi-supplier LLM configuration. Each vendor's
// base/key/model/fallback is a self-contained unit, so multiple vendors coexist
// and Provider selects the active wire protocol.
type Group struct {
ID string `json:"id"`
Provider string `json:"provider"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Model string `json:"model"`
Fallback string `json:"fallback,omitempty"`
Multimodal bool `json:"multimodal,omitempty"`
}
// ErrInvalidGroup is returned when a group fails schema validation.
var ErrInvalidGroup = errors.New("runtimeconfig: invalid model group")
// DefaultPath returns the models.json path under dataDir.
func DefaultPath(dataDir string) string {
return filepath.Join(dataDir, modelsFileName)
}
// Validate checks the group's required fields. It fails loudly so a bad group
// never silently produces a half-configured adapter.
func (g Group) Validate() error {
if strings.TrimSpace(g.ID) == "" {
return fmt.Errorf("id is required")
}
if g.Provider != ProviderOpenAI && g.Provider != ProviderAnthropic {
return fmt.Errorf("provider %q must be openai or anthropic", g.Provider)
}
if strings.TrimSpace(g.BaseURL) == "" {
return fmt.Errorf("baseUrl is required")
}
if strings.TrimSpace(g.Model) == "" {
return fmt.Errorf("model is required")
}
return nil
}
// ToLLMConfig maps a group to the flat LLMConfig consumed by the adapter
// factory. The group's provider selects which config block is populated.
func (g Group) ToLLMConfig() config.LLMConfig {
if g.Provider == ProviderAnthropic {
return config.LLMConfig{
Provider: ProviderAnthropic,
AnthropicBaseURL: g.BaseURL,
AnthropicAPIKey: g.APIKey,
AnthropicModel: g.Model,
AnthropicFallback: g.Fallback,
}
}
return config.LLMConfig{
Provider: ProviderOpenAI,
BaseURL: g.BaseURL,
APIKey: g.APIKey,
Model: g.Model,
Fallback: g.Fallback,
}
}
// MaskedKey returns a redacted form of the API key for display/logging. It never
// reveals more than the trailing 4 characters, so a key never lands verbatim in
// a log line or a wire response.
func (g Group) MaskedKey() string {
k := strings.TrimSpace(g.APIKey)
if k == "" {
return ""
}
if len(k) <= 4 {
return "****"
}
return "****" + k[len(k)-4:]
}
// LoadModels reads path and returns the effective LLM groups. When the file is
// absent it synthesizes a single group from env (the .env fallback). A
// present-but-invalid file falls back to env and returns a non-nil warning so
// the caller can log it; the returned groups are always non-empty.
func LoadModels(path string, env config.LLMConfig) ([]Group, error) {
files, readErr := loadFile(path)
if readErr == nil {
return files, nil
}
// A missing file is the normal case (gitignored runtime config); only a
// present-but-invalid file warrants a warning to the caller.
var warn error
if !os.IsNotExist(readErr) {
warn = fmt.Errorf("runtimeconfig: %s: %w", path, readErr)
}
return GroupFromEnv(env), warn
}
// loadFile decodes and validates the models.json array. A nil/blank file, a
// JSON decode error or an empty array all return an error so the caller falls
// back to env.
func loadFile(path string) ([]Group, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if len(strings.TrimSpace(string(raw))) == 0 {
return nil, fmt.Errorf("%w: empty file", ErrInvalidGroup)
}
var groups []Group
dec := json.NewDecoder(strings.NewReader(string(raw)))
if err := dec.Decode(&groups); err != nil {
return nil, fmt.Errorf("%w: decode: %v", ErrInvalidGroup, err)
}
if len(groups) == 0 {
return nil, fmt.Errorf("%w: no groups", ErrInvalidGroup)
}
for i, g := range groups {
if err := g.Validate(); err != nil {
return nil, fmt.Errorf("%w: group %d: %v", ErrInvalidGroup, i, err)
}
}
return groups, nil
}
// GroupFromEnv synthesizes a single group from the active env LLM block: the
// anthropic block when LLM_PROVIDER=anthropic, otherwise the flat OpenAI-compatible
// block. It is the .env fallback used when models.json is absent or invalid.
func GroupFromEnv(env config.LLMConfig) []Group {
if env.Provider == ProviderAnthropic {
return []Group{{
ID: ProviderAnthropic,
Provider: ProviderAnthropic,
BaseURL: env.AnthropicBaseURL,
APIKey: env.AnthropicAPIKey,
Model: env.AnthropicModel,
Fallback: env.AnthropicFallback,
Multimodal: env.VisionModel != "",
}}
}
return []Group{{
ID: ProviderOpenAI,
Provider: ProviderOpenAI,
BaseURL: env.BaseURL,
APIKey: env.APIKey,
Model: env.Model,
Fallback: env.Fallback,
Multimodal: env.VisionModel != "",
}}
}
// Upsert returns a copy of groups with g inserted or replaced in place by ID,
// preserving order. A blank ID is assigned a generated id so a panel save
// without an explicit id still produces a stable, addressable group.
func Upsert(groups []Group, g Group) []Group {
if strings.TrimSpace(g.ID) == "" {
g.ID = NewID()
}
out := make([]Group, 0, len(groups)+1)
replaced := false
for _, cur := range groups {
if cur.ID == g.ID {
out = append(out, g)
replaced = true
continue
}
out = append(out, cur)
}
if !replaced {
out = append(out, g)
}
return out
}
// Activate returns groups with g promoted to the front (index 0 = the group a
// new request uses by default). An existing group with the same id is replaced
// and re-prepended, so a panel save both updates the group and makes it the live
// provider. A blank ID is assigned a generated id.
func Activate(groups []Group, g Group) []Group {
if strings.TrimSpace(g.ID) == "" {
g.ID = NewID()
}
out := make([]Group, 0, len(groups)+1)
out = append(out, g)
for _, cur := range groups {
if cur.ID == g.ID {
continue
}
out = append(out, cur)
}
return out
}
// SaveModels writes groups as a pretty-printed JSON array to path with mode
// 0600 (the file holds API keys). It writes to a temp file then renames so a
// concurrent reader never sees a partial file. Every group is validated first.
func SaveModels(path string, groups []Group) error {
dir := filepath.Dir(path)
if dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("runtimeconfig: mkdir: %w", err)
}
}
for _, g := range groups {
if err := g.Validate(); err != nil {
return fmt.Errorf("runtimeconfig: save: %w", err)
}
}
raw, err := json.MarshalIndent(groups, "", " ")
if err != nil {
return fmt.Errorf("runtimeconfig: marshal: %w", err)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return fmt.Errorf("runtimeconfig: write %s: %w", tmp, err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("runtimeconfig: rename: %w", err)
}
return nil
}
// NewID returns a 16-byte hex id, used for a group saved without an explicit id.
func NewID() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("id-%d", newTimeUnixNano())
}
return hex.EncodeToString(b)
}
// newTimeUnixNano returns the current unix-nano as an integer, as a fallback id
// source when the crypto RNG fails.
func newTimeUnixNano() int64 {
return time.Now().UnixNano()
}
@@ -0,0 +1,175 @@
package runtimeconfig
import (
"os"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
func envCfg() config.LLMConfig {
return config.LLMConfig{
Provider: ProviderOpenAI,
BaseURL: "https://api.openai.com/v1",
APIKey: "sk-env",
Model: "gpt-4o",
Fallback: "gpt-4o-mini",
}
}
func writeModelsFile(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "models.json")
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write models.json: %v", err)
}
return path
}
func TestLoadModels_FileValid(t *testing.T) {
path := writeModelsFile(t, `[
{"id":"openai","provider":"openai","baseUrl":"https://api.openai.com/v1","apiKey":"sk-1","model":"gpt-4o","fallback":"gpt-4o-mini","multimodal":true}
]`)
groups, warn := LoadModels(path, envCfg())
if warn != nil {
t.Fatalf("warn = %v, want nil", warn)
}
if len(groups) != 1 {
t.Fatalf("len(groups) = %d, want 1", len(groups))
}
g := groups[0]
if g.ID != "openai" || g.Provider != ProviderOpenAI || g.Model != "gpt-4o" || !g.Multimodal {
t.Fatalf("group = %+v, want openai/gpt-4o/multimodal", g)
}
}
func TestLoadModels_MissingFileFallsBackEnv(t *testing.T) {
path := filepath.Join(t.TempDir(), "nonexistent.json")
groups, warn := LoadModels(path, envCfg())
if warn != nil {
t.Fatalf("warn = %v, want nil (missing file is the normal case)", warn)
}
if len(groups) != 1 || groups[0].Model != "gpt-4o" || groups[0].APIKey != "sk-env" {
t.Fatalf("groups = %+v, want env fallback gpt-4o/sk-env", groups)
}
}
func TestLoadModels_InvalidFallsBackEnvWithWarn(t *testing.T) {
path := writeModelsFile(t, `{ not json`)
groups, warn := LoadModels(path, envCfg())
if warn == nil {
t.Fatal("warn = nil, want a warning for an invalid file")
}
if len(groups) != 1 || groups[0].APIKey != "sk-env" {
t.Fatalf("groups = %+v, want env fallback", groups)
}
}
func TestLoadModels_EmptyArrayFallsBackEnv(t *testing.T) {
path := writeModelsFile(t, `[]`)
groups, warn := LoadModels(path, envCfg())
if warn == nil {
t.Fatal("warn = nil, want a warning for an empty file")
}
if len(groups) != 1 {
t.Fatalf("groups = %+v, want env fallback single group", groups)
}
}
func TestValidate(t *testing.T) {
valid := Group{ID: "a", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "m"}
if err := valid.Validate(); err != nil {
t.Fatalf("valid group: %v", err)
}
cases := []Group{
{ID: "", Provider: ProviderOpenAI, BaseURL: "https://x", Model: "m"},
{ID: "a", Provider: "bogus", BaseURL: "https://x", Model: "m"},
{ID: "a", Provider: ProviderOpenAI, BaseURL: "", Model: "m"},
{ID: "a", Provider: ProviderOpenAI, BaseURL: "https://x", Model: ""},
}
for i, c := range cases {
if err := c.Validate(); err == nil {
t.Fatalf("case %d failed to validate: %+v", i, c)
}
}
}
func TestToLLMConfig(t *testing.T) {
g := Group{ID: "a", Provider: ProviderOpenAI, BaseURL: "https://x", APIKey: "sk", Model: "m", Fallback: "f"}
cfg := g.ToLLMConfig()
if cfg.Provider != ProviderOpenAI || cfg.BaseURL != "https://x" || cfg.APIKey != "sk" || cfg.Model != "m" || cfg.Fallback != "f" {
t.Fatalf("ToLLMConfig = %+v", cfg)
}
ag := Group{ID: "b", Provider: ProviderAnthropic, BaseURL: "https://y", APIKey: "sk2", Model: "claude"}
acfg := ag.ToLLMConfig()
if acfg.Provider != ProviderAnthropic || acfg.AnthropicBaseURL != "https://y" || acfg.AnthropicModel != "claude" {
t.Fatalf("anthropic ToLLMConfig = %+v", acfg)
}
}
func TestMaskedKey(t *testing.T) {
empty := Group{APIKey: ""}.MaskedKey()
if empty != "" {
t.Fatalf("empty key masked = %q, want empty", empty)
}
short := Group{APIKey: "abc"}.MaskedKey()
if short != "****" {
t.Fatalf("short key masked = %q, want ****", short)
}
long := Group{APIKey: "sk-abcdef1234"}.MaskedKey()
if long != "****1234" || strings.Contains(long, "sk-") {
t.Fatalf("masked = %q, want ****1234 without the prefix", long)
}
}
func TestActivate(t *testing.T) {
base := []Group{
{ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://a", Model: "gpt-4o"},
{ID: "anthropic", Provider: ProviderAnthropic, BaseURL: "https://b", Model: "claude"},
}
// New group is prepended (becomes the active index 0).
out := Activate(base, Group{ID: "deepseek", Provider: ProviderOpenAI, BaseURL: "https://c", Model: "ds"})
if len(out) != 3 || out[0].ID != "deepseek" || out[1].ID != "openai" || out[2].ID != "anthropic" {
t.Fatalf("Activate new = %+v", out)
}
// An existing group is replaced and promoted to the front.
out2 := Activate(out, Group{ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://a2", Model: "gpt-4o-new"})
if len(out2) != 3 || out2[0].ID != "openai" || out2[0].Model != "gpt-4o-new" || out2[1].ID != "deepseek" {
t.Fatalf("Activate existing = %+v", out2)
}
}
func TestSaveLoadRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "models.json")
in := []Group{
{ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://a", APIKey: "sk-secret", Model: "gpt-4o", Multimodal: true},
{ID: "anthropic", Provider: ProviderAnthropic, BaseURL: "https://b", APIKey: "sk-ant", Model: "claude"},
}
if err := SaveModels(path, in); err != nil {
t.Fatalf("SaveModels: %v", err)
}
groups, warn := LoadModels(path, envCfg())
if warn != nil {
t.Fatalf("warn = %v", warn)
}
if len(groups) != 2 || groups[0].APIKey != "sk-secret" || groups[1].APIKey != "sk-ant" {
t.Fatalf("round trip = %+v", groups)
}
// The key stays in the file (it is the runtime config), but never leaks into
// the masked view.
if got := groups[0].MaskedKey(); got != "****cret" {
t.Fatalf("masked key = %q, want ****cret", got)
}
}
func TestSaveModels_RejectsInvalid(t *testing.T) {
path := filepath.Join(t.TempDir(), "models.json")
if err := SaveModels(path, []Group{{ID: "", Provider: "x", BaseURL: "https://a", Model: "m"}}); err == nil {
t.Fatal("expected error saving an invalid group")
}
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
t.Fatalf("file should not be written when validation fails")
}
}
+4 -3
View File
@@ -38,7 +38,7 @@ type Agent struct {
tools *tool.Dispatcher
guards *guardrail.Pipeline
adapter model.Adapter
router *model.Router
router model.ModelRouter
recorder model.Recorder
handoff *Handoff
opts Options
@@ -86,8 +86,9 @@ func (a *Agent) Guardrails() *guardrail.Pipeline { return a.guards }
// Adapter returns the injected model adapter.
func (a *Agent) Adapter() model.Adapter { return a.adapter }
// Router returns the injected per-role router, or nil.
func (a *Agent) Router() *model.Router { return a.router }
// Router returns the injected per-role router, or nil. The concrete implementation
// may be a *model.Router or the hot-swappable model.RuntimeLLM.
func (a *Agent) Router() model.ModelRouter { return a.router }
// Model returns the configured model name. Empty means the injected Router or
// the adapter's own default decides.
+4 -2
View File
@@ -40,8 +40,10 @@ type Option func(*Agent)
// optional per-role Router. When router is non-nil it overrides the Request's
// Model from the request contents — which is how vision requests are routed to
// the vision model — otherwise the agent's configured Model or the adapter
// default applies.
func WithModel(adapter model.Adapter, router *model.Router) Option {
// default applies. router is a model.ModelRouter (implemented by *model.Router
// and by the hot-swappable model.RuntimeLLM) so a reloaded provider can swap it
// without rebuilding the agent.
func WithModel(adapter model.Adapter, router model.ModelRouter) Option {
return func(a *Agent) {
a.adapter = adapter
a.router = router
+177
View File
@@ -0,0 +1,177 @@
package model
import (
"context"
"errors"
"io"
"log/slog"
"sync/atomic"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
// ModelRouter is the routing contract the agent (and loop) depend on. *Router
// implements it; RuntimeLLM implements it too, so a hot-swappable provider can
// serve as both the adapter and the router without a separate router reference
// to keep in sync.
type ModelRouter interface {
RouteRequest(req Request) (model string, needsVision bool)
}
// runtimeChainSnapshot is an immutable, self-contained LLM stack snapshot. It is
// stored and swapped atomically: a request that already captured a snapshot
// keeps using that exact chain+router for its whole call, so a reload never
// interrupts an in-flight request.
type runtimeChainSnapshot struct {
chain *Chain
router *Router
group runtimeconfig.Group
groups []runtimeconfig.Group
}
// RuntimeLLM is the hot-swappable LLM provider stack for the runtime. It
// implements Adapter (delegating to the current chain) and ModelRouter
// (delegating to the current router). A panel save reloads models.json, rebuilds
// a fresh chain+router and atomically swaps it in: new calls use the new stack
// while calls already in flight finish on the old one.
type RuntimeLLM struct {
path string
env config.LLMConfig
log *slog.Logger
current atomic.Value // *runtimeChainSnapshot
}
// NewRuntimeLLM builds the provider stack from models.json at path, falling back
// to env. It loads the initial snapshot so the runtime is usable immediately.
// log may be nil (a discard logger is used).
func NewRuntimeLLM(path string, env config.LLMConfig, log *slog.Logger) *RuntimeLLM {
if log == nil {
log = slog.New(slog.NewTextHandler(io.Discard, nil))
}
m := &RuntimeLLM{path: path, env: env, log: logger.WithSource(log, "model")}
m.loadInitial()
return m
}
// Path returns the models.json path this manager reads and reloads from.
func (m *RuntimeLLM) Path() string { return m.path }
// Groups returns a copy of the current loaded groups; the active group (the one
// a new request uses by default) is index 0.
func (m *RuntimeLLM) Groups() []runtimeconfig.Group {
src := m.load().groups
out := make([]runtimeconfig.Group, len(src))
copy(out, src)
return out
}
// Group returns the active group (index 0).
func (m *RuntimeLLM) Group() runtimeconfig.Group { return m.load().group }
// Current returns the current chain and router. The returned chain stays valid
// for calls already in flight; new calls pick up the latest swap.
func (m *RuntimeLLM) Current() (*Chain, *Router) {
snap := m.load()
return snap.chain, snap.router
}
// Reload re-reads models.json (falling back to env) and atomically rebuilds the
// in-use chain+router. In-flight calls keep the previous snapshot.
func (m *RuntimeLLM) Reload() error {
groups, warn := runtimeconfig.LoadModels(m.path, m.env)
if warn != nil {
m.log.Warn("models.json reload fell back to env", "error", warn)
}
return m.swap(groups)
}
// Provider returns the active group's provider protocol.
func (m *RuntimeLLM) Provider() string { return m.load().group.Provider }
// Model returns the active group's primary model name.
func (m *RuntimeLLM) Model() string { return m.load().group.Model }
// Name implements Adapter, returning the active group id.
func (m *RuntimeLLM) Name() string { return m.load().group.ID }
// Chat implements Adapter, delegating to the current chain. A request that began
// before a reload completes on the snapshot it captured. When no model is
// configured (nil chain) it returns ErrNoModel rather than panicking.
func (m *RuntimeLLM) Chat(ctx context.Context, req Request, opts Options) (Response, error) {
chain := m.load().chain
if chain == nil {
return Response{}, ErrNoModel
}
return chain.Chat(ctx, req, opts)
}
// ChatStream implements Adapter.
func (m *RuntimeLLM) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) {
chain := m.load().chain
if chain == nil {
return Response{}, ErrNoModel
}
return chain.ChatStream(ctx, req, onEvent)
}
// RouteRequest implements ModelRouter, delegating to the current router.
func (m *RuntimeLLM) RouteRequest(req Request) (string, bool) {
r := m.load().router
if r == nil {
return "", false
}
return r.RouteRequest(req)
}
// loadInitial builds the first snapshot; a build failure leaves a nil chain so
// calls report a clear no-model error instead of panicking.
func (m *RuntimeLLM) loadInitial() {
groups, warn := runtimeconfig.LoadModels(m.path, m.env)
if warn != nil {
m.log.Warn("models.json load fell back to env", "error", warn)
}
_ = m.swap(groups)
}
// swap rebuilds and atomically stores a snapshot for groups. A blank slice falls
// back to the env-synthesized group so there is always at least one.
func (m *RuntimeLLM) swap(groups []runtimeconfig.Group) error {
if len(groups) == 0 {
groups = runtimeconfig.GroupFromEnv(m.env)
}
snap, err := buildSnapshot(groups, m.env)
if err != nil {
return err
}
m.current.Store(snap)
return nil
}
// load returns the current snapshot, or an empty (nil chain) one when the
// manager was never initialized.
func (m *RuntimeLLM) load() *runtimeChainSnapshot {
if v := m.current.Load(); v != nil {
return v.(*runtimeChainSnapshot)
}
return &runtimeChainSnapshot{}
}
// buildSnapshot turns the active group (index 0) into a chain+router. A group
// without a base URL/model yields a nil chain (no model configured), surfaced as
// ErrNoModel by the adapter methods. The Router keeps the env vision model so a
// dedicated vision model survives a reload that only changes the vendor group.
func buildSnapshot(groups []runtimeconfig.Group, env config.LLMConfig) (*runtimeChainSnapshot, error) {
active := groups[0]
cfg := active.ToLLMConfig()
chain, err := NewChatChain(cfg)
if err != nil && !errors.Is(err, ErrNoModel) {
return nil, err
}
router := NewRouter(DefaultModelForProvider(cfg),
WithVisionModel(env.VisionModel),
WithDefaultProvider(cfg.Provider),
)
return &runtimeChainSnapshot{chain: chain, router: router, group: active, groups: groups}, nil
}
+148
View File
@@ -0,0 +1,148 @@
package model
import (
"context"
"path/filepath"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
func rtEnv() config.LLMConfig {
// Env fallback matches "openai default" (provider not anthropic).
return config.LLMConfig{
Provider: ProviderOpenAI,
BaseURL: "https://api.openai.com/v1",
APIKey: "sk-env",
Model: "gpt-4o",
}
}
func writeRTFile(t *testing.T, groups []runtimeconfig.Group) string {
t.Helper()
path := filepath.Join(t.TempDir(), "models.json")
if err := runtimeconfig.SaveModels(path, groups); err != nil {
t.Fatalf("SaveModels: %v", err)
}
return path
}
func TestRuntimeLLM_EnvFallback(t *testing.T) {
// Missing file → env fallback provider/model.
m := NewRuntimeLLM(filepath.Join(t.TempDir(), "nope.json"), rtEnv(), nil)
if m.Provider() != ProviderOpenAI || m.Model() != "gpt-4o" {
t.Fatalf("fallback Provider=%q Model=%q, want openai/gpt-4o", m.Provider(), m.Model())
}
chain, router := m.Current()
if router == nil {
t.Fatal("router is nil after env fallback")
}
if chain == nil {
// No real endpoint is fine; the adapter should report ErrNoModel, not panic.
if _, err := m.Chat(context.Background(), Request{Messages: nil}, Options{}); err != ErrNoModel {
t.Fatalf("Chat on empty config err = %v, want ErrNoModel", err)
}
}
}
func TestRuntimeLLM_FileLoad(t *testing.T) {
path := writeRTFile(t, []runtimeconfig.Group{
{ID: "deepseek", Provider: ProviderOpenAI, BaseURL: "https://api.deepseek.com/v1", APIKey: "sk-ds", Model: "deepseek-chat"},
})
m := NewRuntimeLLM(path, rtEnv(), nil)
if m.Provider() != ProviderOpenAI || m.Model() != "deepseek-chat" {
t.Fatalf("Provider=%q Model=%q, want openai/deepseek-chat", m.Provider(), m.Model())
}
if m.Group().ID != "deepseek" {
t.Fatalf("Group().ID = %q, want deepseek", m.Group().ID)
}
}
func TestRuntimeLLM_ReloadReflectsNewProviderModel(t *testing.T) {
path := writeRTFile(t, []runtimeconfig.Group{
{ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://api.openai.com/v1", APIKey: "sk-1", Model: "gpt-4o"},
})
m := NewRuntimeLLM(path, rtEnv(), nil)
if m.Model() != "gpt-4o" {
t.Fatalf("before reload Model = %q, want gpt-4o", m.Model())
}
// Rewrite the file with an anthropic provider/model, then reload.
if err := runtimeconfig.SaveModels(path, []runtimeconfig.Group{
{ID: "claude", Provider: ProviderAnthropic, BaseURL: "https://api.anthropic.com", APIKey: "sk-ant", Model: "claude-sonnet-4"},
}); err != nil {
t.Fatalf("SaveModels: %v", err)
}
if err := m.Reload(); err != nil {
t.Fatalf("Reload: %v", err)
}
if m.Provider() != ProviderAnthropic || m.Model() != "claude-sonnet-4" {
t.Fatalf("after reload Provider=%q Model=%q, want anthropic/claude-sonnet-4", m.Provider(), m.Model())
}
chain, router := m.Current()
if router == nil || chain == nil {
t.Fatal("after reload Current() returned nil chain/router")
}
}
func TestRuntimeLLM_ImplementsAdapterAndRouter(t *testing.T) {
path := writeRTFile(t, []runtimeconfig.Group{
{ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://x", APIKey: "sk", Model: "m"},
})
m := NewRuntimeLLM(path, rtEnv(), nil)
var _ Adapter = m
var _ ModelRouter = m
if m.Name() != "openai" {
t.Fatalf("Name = %q, want openai", m.Name())
}
modelName, _ := m.RouteRequest(Request{})
if modelName != "m" {
t.Fatalf("RouteRequest = %q, want the active model m", modelName)
}
}
func TestRuntimeLLM_ConcurrentReadDuringReloadNoPanic(t *testing.T) {
path := writeRTFile(t, []runtimeconfig.Group{
{ID: "openai", Provider: ProviderOpenAI, BaseURL: "https://api.openai.com/v1", APIKey: "sk-1", Model: "gpt-4o"},
})
m := NewRuntimeLLM(path, rtEnv(), nil)
const readers = 8
var wg sync.WaitGroup
stop := make(chan struct{})
for i := 0; i < readers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
_ = m.Provider()
_ = m.Model()
chain, router := m.Current()
if router == nil {
t.Error("router is nil during concurrent read")
return
}
if chain == nil {
// A nil chain means the config had no base/model; our test file has one,
// so a swap must always produce a chain. Guard anyway.
t.Error("chain is nil during concurrent read")
return
}
}
}()
}
// Reload a number of times while the readers spin.
for i := 0; i < 200; i++ {
if err := m.Reload(); err != nil {
t.Fatalf("reload %d: %v", i, err)
}
}
close(stop)
wg.Wait()
}
+40 -28
View File
@@ -11,6 +11,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
@@ -47,6 +48,7 @@ type policyRequest struct {
type AdminLlmProvider struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
BaseURL string `json:"baseUrl"`
Model string `json:"model"`
Multimodal bool `json:"multimodal"`
@@ -164,39 +166,49 @@ func (s *Server) adminListIoT(w http.ResponseWriter, r *http.Request) {
}
// adminGetLlm returns the LLM provider/model configuration read from the live
// config. API keys are never returned (the wire shape carries no key field), so
// the handler leaks nothing sensitive.
// provider stack (models.json, falling back to .env). API keys are never
// returned — the wire shape carries no key field — so the handler leaks nothing
// sensitive.
func (s *Server) adminGetLlm(w http.ResponseWriter, r *http.Request) {
cfg := s.cfg.LLM
providers := []AdminLlmProvider{
{
ID: "openai",
Name: "OpenAI",
BaseURL: cfg.BaseURL,
Model: cfg.Model,
Multimodal: cfg.VisionModel != "",
Enabled: cfg.Provider != "anthropic",
Fallback: cfg.Fallback,
},
{
ID: "anthropic",
Name: "Anthropic",
BaseURL: cfg.AnthropicBaseURL,
Model: cfg.AnthropicModel,
Multimodal: false,
Enabled: cfg.Provider == "anthropic",
Fallback: cfg.AnthropicFallback,
},
groups := s.currentLlmGroups()
providers := make([]AdminLlmProvider, 0, len(groups))
for i, g := range groups {
providers = append(providers, AdminLlmProvider{
ID: g.ID,
Name: providerDisplayName(g),
Provider: g.Provider,
BaseURL: g.BaseURL,
Model: g.Model,
Multimodal: g.Multimodal,
Enabled: i == 0,
Fallback: g.Fallback,
})
}
// Fold in providers added at runtime via the admin "add provider" write.
// They are process-lifetime and not bound to the model router, so the panel
// reflects them but the response marks nothing else.
s.llmMu.RLock()
providers = append(providers, s.llmExtra...)
s.llmMu.RUnlock()
writeJSON(w, http.StatusOK, AdminLlmConfig{Providers: providers})
}
// currentLlmGroups returns the provider groups to display: the live stack's
// groups when one is wired, otherwise the env-synthesized single group.
func (s *Server) currentLlmGroups() []runtimeconfig.Group {
if s.llm != nil {
return s.llm.Groups()
}
return runtimeconfig.GroupFromEnv(s.cfg.LLM)
}
// providerDisplayName returns a friendly label for a provider group, using the
// vendor name for the two known protocols and the group id otherwise.
func providerDisplayName(g runtimeconfig.Group) string {
switch g.Provider {
case runtimeconfig.ProviderOpenAI:
return "OpenAI"
case runtimeconfig.ProviderAnthropic:
return "Anthropic"
default:
return g.ID
}
}
// adminListAutomations lists automations and reminders (admin read).
func (s *Server) adminListAutomations(w http.ResponseWriter, r *http.Request) {
out := make([]AdminAutomationRecord, 0)
@@ -2,10 +2,7 @@ package gateway
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
@@ -14,6 +11,7 @@ import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
@@ -35,40 +33,92 @@ const (
llmProbePrompt = "Reply with the single word: OK"
)
// llmProviderRequest is the POST /api/v1/admin/llm body for adding a provider.
// llmProviderRequest is the POST /api/v1/admin/llm body for adding/updating a
// provider group. Name is a client-side display label (not persisted — the
// models.json schema carries id/provider/baseUrl/apiKey/model/fallback/multimodal).
type llmProviderRequest struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
BaseURL string `json:"baseUrl"`
APIKey string `json:"apiKey"`
Model string `json:"model"`
Multimodal bool `json:"multimodal"`
Fallback string `json:"fallback"`
}
// adminAddLlmProvider records a provider for the process lifetime, reflected in
// the GET /admin/llm listing. It is not bound to the model router (config is
// immutable after startup), so it is reported as temporary in the response.
// adminAddLlmProvider saves a provider group to models.json (read-modify-write,
// promoting it to the active group) and hot-reloads the running LLM stack so the
// new provider/model takes effect without a restart (design/04 §3.1). The API key
// is persisted to the gitignored file but never returned or logged.
func (s *Server) adminAddLlmProvider(w http.ResponseWriter, r *http.Request) {
var req llmProviderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
return
}
if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Model) == "" {
if strings.TrimSpace(req.Model) == "" || strings.TrimSpace(req.BaseURL) == "" {
s.auditClaims(r, "llm.addProvider", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name and model are required"))
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "baseUrl and model are required"))
return
}
provider := req.Provider
if provider == "" {
provider = model.ProviderOpenAI
}
if provider != model.ProviderOpenAI && provider != model.ProviderAnthropic {
s.auditClaims(r, "llm.addProvider", "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid provider"))
return
}
if s.llm == nil {
s.auditClaims(r, "llm.addProvider", "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_unavailable", "LLM 配置服务未注入"))
return
}
id := req.ID
if id == "" {
id = newIDHex()
id = runtimeconfig.NewID()
}
// Promote the saved group to the active (index 0) slot, then persist and
// hot-reload so Current() reflects the new provider/model immediately.
groups := runtimeconfig.Activate(s.llm.Groups(), runtimeconfig.Group{
ID: id,
Provider: provider,
BaseURL: req.BaseURL,
APIKey: req.APIKey,
Model: req.Model,
Fallback: req.Fallback,
Multimodal: req.Multimodal,
})
if err := runtimeconfig.SaveModels(s.llm.Path(), groups); err != nil {
s.auditClaims(r, "llm.addProvider", "error")
s.log.Error("llm save models", "path", s.llm.Path(), "error", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_persist_failed", "failed to save models.json"))
return
}
if err := s.llm.Reload(); err != nil {
s.auditClaims(r, "llm.addProvider", "error")
s.log.Error("llm reload", "error", err)
writeError(w, apperr.Wrap(apperr.ErrInternal, "llm_reload_failed", "saved but failed to hot-reload"))
return
}
saved := AdminLlmProvider{
ID: id,
Name: providerDisplayName(runtimeconfig.Group{Provider: provider}),
Provider: provider,
BaseURL: req.BaseURL,
Model: req.Model,
Multimodal: req.Multimodal,
Enabled: id == s.llm.Group().ID,
Fallback: req.Fallback,
}
provider := AdminLlmProvider{ID: id, Name: req.Name, BaseURL: req.BaseURL, Model: req.Model, Multimodal: req.Multimodal, Fallback: req.Fallback}
s.llmMu.Lock()
s.llmExtra = append(s.llmExtra, provider)
s.llmMu.Unlock()
s.auditClaims(r, "llm.addProvider", "ok")
writeJSON(w, http.StatusCreated, map[string]any{"provider": provider, "temporary": true})
writeJSON(w, http.StatusCreated, map[string]any{
"provider": saved,
"persisted": true,
"path": s.llm.Path(),
})
}
// llmTestRequest is the POST /api/v1/admin/llm/test body.
@@ -267,12 +317,3 @@ func describeIdentity(a adapter.Actor, _ *user.OwnerProfile) string {
return "未命中主人白名单 → 按陌生人处理(default deny,记忆只读不写)"
}
}
// newIDHex returns a 16-byte hex id.
func newIDHex() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("id-%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
@@ -6,14 +6,19 @@ import (
"io"
"log/slog"
"net/http"
"path/filepath"
"strings"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/projectindex"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
func gwTestLogger() *slog.Logger {
@@ -209,6 +214,125 @@ func TestAdminIndexRagUnwired501(t *testing.T) {
}
}
// newLlmRuntimeForGateway builds a hot-swappable RuntimeLLM pointing at a fresh
// temp models.json, seeded by the env config. It is the real stack the admin
// write endpoint persists to and reloads.
func newLlmRuntimeForGateway(t *testing.T, modelsPath string, env config.LLMConfig) *model.RuntimeLLM {
t.Helper()
return model.NewRuntimeLLM(modelsPath, env, gwTestLogger())
}
func TestAdminAddLlmProviderPersistsAndReloads(t *testing.T) {
t.Parallel()
modelsPath := filepath.Join(t.TempDir(), "models.json")
env := config.LLMConfig{Provider: "openai", BaseURL: "https://api.openai.com/v1", APIKey: "sk-env", Model: "gpt-4o"}
runtime := newLlmRuntimeForGateway(t, modelsPath, env)
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
map[string]any{"id": "deepseek", "provider": "openai", "baseUrl": "https://api.deepseek.com/v1", "apiKey": "sk-ds", "model": "deepseek-chat", "multimodal": true}, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
var res struct {
Provider AdminLlmProvider `json:"provider"`
Persisted bool `json:"persisted"`
Path string `json:"path"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &res); err != nil {
t.Fatalf("decode add: %v", err)
}
if !res.Persisted || res.Path != modelsPath || res.Provider.Model != "deepseek-chat" {
t.Fatalf("add res = %+v", res)
}
// models.json now holds the saved group (promoted to index 0 = active).
groups, warn := runtimeconfig.LoadModels(modelsPath, env)
if warn != nil {
t.Fatalf("load models warn = %v", warn)
}
if len(groups) == 0 || groups[0].ID != "deepseek" || groups[0].APIKey != "sk-ds" {
t.Fatalf("file groups = %+v, want deepseek first with sk-ds", groups)
}
// The running stack reflects the new active provider/model (hot reload).
if runtime.Provider() != "openai" || runtime.Model() != "deepseek-chat" {
t.Fatalf("runtime Provider=%q Model=%q, want openai/deepseek-chat", runtime.Provider(), runtime.Model())
}
// GET /admin/llm reflects the saved group and never leaks any API key.
gResp, gBody := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/llm", nil, tok)
if gResp.StatusCode != http.StatusOK {
t.Fatalf("get status = %d, want 200", gResp.StatusCode)
}
var list AdminLlmConfig
if err := json.Unmarshal(decodeBody(t, gBody)["data"], &list); err != nil {
t.Fatalf("decode list: %v", err)
}
if len(list.Providers) == 0 || list.Providers[0].Model != "deepseek-chat" {
t.Fatalf("list = %+v, want deepseek-chat first", list.Providers)
}
if strings.Contains(string(gBody), "sk-ds") || strings.Contains(string(gBody), "sk-env") {
t.Fatalf("API key leaked in GET /admin/llm: %s", gBody)
}
}
func TestAdminAddLlmProviderSwitchProvider(t *testing.T) {
t.Parallel()
modelsPath := filepath.Join(t.TempDir(), "models.json")
env := config.LLMConfig{Provider: "openai", BaseURL: "https://x", APIKey: "sk", Model: "m"}
runtime := newLlmRuntimeForGateway(t, modelsPath, env)
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// Saving an anthropic group makes it the active provider/model.
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
map[string]any{"provider": "anthropic", "baseUrl": "https://api.anthropic.com", "apiKey": "sk-ant", "model": "claude-sonnet-4"}, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
if runtime.Provider() != "anthropic" || runtime.Model() != "claude-sonnet-4" {
t.Fatalf("runtime Provider=%q Model=%q, want anthropic/claude-sonnet-4", runtime.Provider(), runtime.Model())
}
// The saved group is active (index 0) and anthropic.
if runtime.Group().Provider != "anthropic" {
t.Fatalf("active group = %+v, want anthropic", runtime.Group())
}
}
func TestAdminAddLlmProviderRejectsBadInput(t *testing.T) {
t.Parallel()
modelsPath := filepath.Join(t.TempDir(), "models.json")
runtime := newLlmRuntimeForGateway(t, modelsPath, config.LLMConfig{})
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{LLM: runtime})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
// Missing model.
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
map[string]any{"baseUrl": "https://x"}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("missing model status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
// Invalid provider.
resp2, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
map[string]any{"provider": "bogus", "baseUrl": "https://x", "model": "m"}, tok)
if resp2.StatusCode != http.StatusBadRequest {
t.Fatalf("invalid provider status = %d, want 400", resp2.StatusCode)
}
}
func TestAdminAddLlmProviderUnwired501(t *testing.T) {
t.Parallel()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) // no LLM stack wired
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, _ := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/llm",
map[string]any{"baseUrl": "https://x", "model": "m"}, tok)
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("unwired status = %d, want 500", resp.StatusCode)
}
}
func TestAdminListSessionsHistoryFirst(t *testing.T) {
t.Parallel()
h := newHistoryStoreForGateway(t)
+9 -7
View File
@@ -25,7 +25,6 @@ import (
"net"
"net/http"
"strconv"
"sync"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
@@ -219,6 +218,10 @@ type Deps struct {
// RAG backs the admin knowledge-base RAG module. Optional; list/search
// return empty when unset, and write endpoints report a clear 501.
RAG RagStore
// LLM is the hot-swappable provider stack (models.json, design/04 §3.1).
// Optional: when nil the admin /llm endpoints fall back to the env config so
// pre-existing gateway tests keep working.
LLM *model.RuntimeLLM
// AuditStore persists the admin audit trail (observability → audit). When nil
// the gateway degrades to an in-memory store so the endpoint still returns
// data; the composition root injects NewSQLiteAuditStore over a.db for real
@@ -263,12 +266,10 @@ type Server struct {
voice VoiceService
streaming bool
frontend fs.FS
// llmMu guards llmExtra, the process-lifetime providers added at runtime via
// the admin "add provider" write. They are reflected in GET /admin/llm but are
// not bound to the model router (config is immutable after startup), so they
// are reported as temporary.
llmMu sync.RWMutex
llmExtra []AdminLlmProvider
// llm is the hot-swappable provider stack (models.json). When nil the
// admin /llm endpoints fall back to the env config. It is optional so tests
// that do not wire it keep working.
llm *model.RuntimeLLM
webhooks map[string]http.Handler
log *slog.Logger
hub *hub
@@ -304,6 +305,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
obs: newObservability(deps),
streaming: deps.Streaming,
frontend: deps.Frontend,
llm: deps.LLM,
webhooks: deps.Webhooks,
log: sourcedLog(deps.Log),
}