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
@@ -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