feat(config): 运行时配置体系①——LLM 多厂商独立组落 models.json + 面板落盘 + 热更新
- 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:
1 parent
8cde0162e6
commit
2d7151f979
16 files changed
+1079
-108
No files matched your search
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user