Files
sundynix-agentix/sundynix-dispatcher/cmd/dispatcher/main.go
T
Blizzard 46ef3df221 feat(security): LLM api_key 端到端加密(AES-256-GCM,磁盘+线缆均密文)
新增 sundynix-shared/secrets:AES-256-GCM,密钥由 SUNDYNIX_SECRET_KEY 经
SHA-256 派生;密文带 enc:1: 版本前缀,历史明文行自动透传(下次保存升级为密文)。

- 网关 SaveModel 加密落库;ListModels/TestModel 解密后脱敏/探测;
  空或脱敏占位的 api_key 视为「未改」→ 沿用库内既有密文(不二次加密)。
- 密文经 NATS 原样下发;消费方解密集中在 bus 层 decryptConfig
  (RequestConfig + SubscribeConfigUpdated)→ dispatcher/mcp-go 零改动。
- secrets.MustHaveKeyInProd():生产未设 SUNDYNIX_SECRET_KEY 直接 fatal,
  gateway/dispatcher/mcp-go 启动各调一次(三服务须配相同密钥)。
- 修复 store.SaveModel 整行 Save 把 active 清零的旧 bug:改 Select(...).Updates
  只覆盖可编辑列,改 key 不再顺手取消模型激活。
- secrets 单测:往返/空串/随机 nonce/错密钥 fail-closed/历史明文透传。
- production_readiness.md 2.1 更新为「已落地」。

验证:DB 列由明文 sk-…(35) → 密文 enc:1:…(90);dispatcher 从密文广播解密后
model config set;真实任务 √256→16、12×12→144 打通 DeepSeek(非降级桩)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 10:36:34 +08:00

80 lines
2.8 KiB
Go

// Command dispatcher 启动 sundynix-dispatcher —— 第 4 层 AI Agent 调度集群。
package main
import (
"context"
"encoding/json"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/sundynix/sundynix-dispatcher/internal/eino"
"github.com/sundynix/sundynix-dispatcher/internal/harness"
"github.com/sundynix/sundynix-dispatcher/internal/llm"
dnats "github.com/sundynix/sundynix-dispatcher/internal/nats"
"github.com/sundynix/sundynix-shared/secrets"
)
func main() {
secrets.MustHaveKeyInProd() // 生产须配 SUNDYNIX_SECRET_KEY 以解密下发的 api_key 密文
natsURL := envOr("NATS_URL", "nats://localhost:4222")
pool := llm.NewPool() // LLM Pool: vLLM / Ollama 集群
breaker := harness.NewCircuitBreaker() // Harness: 熔断降级中心
// Harness: LLM 自动化评测(规则 + LLM-as-judge,模型就绪时启用)。
eval := harness.NewEvaluator(pool.Ready, func(ctx context.Context, sys, user string) (string, error) {
return pool.Chat(ctx, []llm.ChatMessage{{Role: "system", Content: sys}, {Role: "user", Content: user}})
})
sub := dnats.MustConnect(natsURL)
defer sub.Close()
// 配置控制面:先订阅热更新,再后台重试拉初始配置(容忍 gateway 晚于本服务启动,
// 避免一次性请求扑空后只能干等热更新 → 降级桩跑全程)。
if _, err := sub.SubscribeModelConfigUpdated(pool.SetConfig); err != nil {
log.Printf("[dispatcher] subscribe model config: %v", err)
}
go sub.FetchModelConfigWithRetry(context.Background(), pool.SetConfig)
// sub 同时作为 Token 回流(TokenSink)、MCP 工具调用(ToolCaller)、执行事件(ExecSink)与任务状态回写(StatusSink)出口。
orch, err := eino.NewOrchestrator(pool, breaker, eval, sub, sub, sub, sub)
if err != nil {
log.Fatalf("[dispatcher] build eino graph: %v", err)
}
// 健康心跳:dispatcher 无 HTTP/工具端点,挂一个 NATS 应答让管理端「服务状态」探到它在线。
startedAt := time.Now()
if unsub, herr := sub.ServeHealth(func() []byte {
data, _ := json.Marshal(map[string]any{
"ok": true,
"service": "dispatcher",
"model": pool.ModelName(),
"ready": pool.Ready(),
"uptime_s": int(time.Since(startedAt).Seconds()),
})
return data
}); herr != nil {
log.Printf("[dispatcher] serve health: %v", herr)
} else {
defer func() { _ = unsub() }()
}
// 监听退出信号,优雅停止消费。
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
log.Println("[dispatcher] consuming sundynix.tasks.* (Ctrl-C to quit)")
if err := sub.ConsumeTasks(ctx, orch.Handle); err != nil && err != context.Canceled {
log.Fatalf("[dispatcher] exit: %v", err)
}
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}