Files
Cyrene_v2/internal/config/runtimeconfig/models.go
T
AskaEth 1ac02bd77e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway,admin-web): 面板可编辑 provider 推理形状 / 角色级路由 / multimodal 真语义(裁决 03.4.2/03.4.1)
- 后端:AdminLlmProvider 暴露 reasoning{kind}(恒下发,""=未声明=auto)、personaModels、personaProviders;
  顶层新增 reasoningKinds / personaProviderOptions 两个**取值域**(唯一来源= runtimeconfig 导出枚举,
  校验错误文案也由枚举拼接 ⇒ 不可能漂移)
- 读写共用同一映射(admin_llm_provider.go),避免「读侧少一个键 ⇒ 面板一保存就抹掉」
- 写侧语义:缺失=保留、给出=整块/整表替换;非法值一律 runtimeconfig.Group.Validate() ⇒ 400 bad_request
  (实测非法 kind 返回 400 且 models.json 完全不动,而非 500 静默失败)
- 前端:新增 LlmProviderRouting(推理形状下拉 + multimodal 开关 + 按角色路由编辑器);
  目录能力位改三态(true/false/未标注),「未标注」不再谎称「不支持」;目录/角色清单不可用时
  **不阻塞编辑**且与「字段为空」是不同状态;绝无 mock/sample 回退
- 复核:build/vet 绿 + gateway/config 包测试绿 + tsc 0 错 + vite build 成功;2 条变异反证(删 reasoning
  补丁 → 2 例红;删 Group.Validate → 该 400 用例红)已还原
- 注意(主会话已知):npm run build 把新 SPA 写进 frontend/web/dist,而运行中的二进制优先从磁盘读该目录
  ⇒ :10942 目前是「新面板 + 旧后端」,等阶段 5 全绿后按红线「先 build 到位、再一次 restart」切换
2026-10-04 13:06:54 +08:00

437 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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"
)
// ReasoningKind* 是 provider 级推理开关形状的合法取值(裁决 03.4.2 / 08.4.1:
// 不同提供商的思考开关形状各不相同,必须逐 provider 独立适配)。这些常量是
// 形状名的**唯一来源**:配置层(本包)校验它们,模型适配器按它们选 wire 字段,
// 面板按它们渲染下拉框。
//
// 取值刻意用**部署形态**命名(vllm / dashscope)而不是厂商名:同一家厂商的
// 模型在不同形态下的字段形状不同(vLLM 用 chat_template_kwargs.enable_thinking,
// DashScope 兼容模式用顶层 enable_thinking),形状由端点决定,不是由模型名决定。
const (
// ReasoningKindAuto 是缺省值:按模型名/BaseURL 推断厂商,形状有歧义时
// 不下发任何开关(只报告),因此它对旧配置零行为变化。
ReasoningKindAuto = "auto"
// ReasoningKindDeepSeek 是 DeepSeek:顶层 thinking:{type} + reasoning_effort。
ReasoningKindDeepSeek = "deepseek"
// ReasoningKindOpenAI 是 OpenAI 系:只有 reasoning_effort,无布尔开关。
ReasoningKindOpenAI = "openai"
// ReasoningKindVLLM 是 vLLM 类部署(承载 Qwen3 等):
// chat_template_kwargs.enable_thinking。
ReasoningKindVLLM = "vllm"
// ReasoningKindDashScope 是 DashScope 兼容模式:顶层 enable_thinking。
ReasoningKindDashScope = "dashscope"
)
// ReasoningKinds 返回本 build 接受的 reasoning.kind 取值全集(auto 在首位,
// 与面板下拉框的推荐顺序一致)。它是**校验与面板的同一来源**:管理端点把这份
// 清单随配置一起下发,面板据此渲染下拉框,因此「面板能选的」与「配置层接受的」
// 不可能漂移。空串(未声明)不在此列——它是「不声明形状」而不是一个形状名,
// 面板另行给出该选项。返回新切片,调用方改不动包内状态。
func ReasoningKinds() []string {
return []string{
ReasoningKindAuto,
ReasoningKindDeepSeek,
ReasoningKindOpenAI,
ReasoningKindVLLM,
ReasoningKindDashScope,
}
}
// ProviderNames 返回本 build 承认的 wire 协议名,用作 per-persona 路由
// (personaProviders)的合法值域。与面板共用同一来源,避免面板给出一个
// 配置层会拒收的协议名。
func ProviderNames() []string {
return []string{ProviderOpenAI, ProviderAnthropic}
}
// ReasoningOptions 是一个 provider 组的推理开关形状声明。它是「按提供商
// 独立适配」的配置载体(裁决 03.4.2 / 08.4.1):面板与 models.json 对每个
// 供应商各写一份,因此 vLLM 端点与 DashScope 端点可以同时存在、各自下发
// 自己形状的开关,而不是被套进一个「通用」形状。默认(零值)保守:不声明
// 形状就不下发开关,只报告——发错形状会 400,比不发更糟。
type ReasoningOptions struct {
// Kind 显式声明该端点的思考开关形状(见 ReasoningKind* 常量)。
// 空或 "auto" = 按模型名/BaseURL 推断厂商(形状有歧义时不下发)。
Kind string `json:"kind,omitempty"`
}
// 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"`
// MaxTokens is the group's own token budget, applied to every model in the
// group's fallback chain (primary and fallback). Zero means "unset": the
// last-resort fallback step then falls back to LLM_FALLBACK_MAX_TOKENS, and
// beyond that the adapter default applies.
MaxTokens int `json:"maxTokens,omitempty"`
// PersonaModels binds an individual persona to its own model name within this
// group (裁决 03.4.1: the "role" of per-role routing is the persona a turn
// answers as, not the LLM message role). The carrier is deliberately
// models.json rather than configs/personas/*.yaml, so the panel can edit the
// binding together with the rest of the provider configuration and no persona
// schema change is needed. An unknown persona id falls back to the group's
// default model, so a removed persona never breaks routing.
PersonaModels map[string]string `json:"personaModels,omitempty"`
// PersonaProviders binds an individual persona to another vendor's protocol
// (裁决 03.4.1 的 per-persona provider). Pair it with PersonaModels; an entry
// without a provider keeps this group's protocol.
PersonaProviders map[string]string `json:"personaProviders,omitempty"`
// Reasoning declares this provider/endpoint's reasoning-switch wire shape
// (裁决 03.4.2 / 08.4.1). It is a provider-level option because the shape
// belongs to the endpoint, not to a persona: every group carries its own, so
// two vendors with different switch shapes coexist without a shared guess.
// omitzero keeps an undeclared shape out of models.json entirely (an empty
// object would be schema noise for every group).
Reasoning ReasoningOptions `json:"reasoning,omitzero"`
}
// 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")
}
if g.MaxTokens < 0 {
return fmt.Errorf("maxTokens must be >= 0, got %d", g.MaxTokens)
}
if err := validatePersonaMap("personaModels", g.PersonaModels, false); err != nil {
return err
}
if err := validatePersonaMap("personaProviders", g.PersonaProviders, true); err != nil {
return err
}
return validateReasoning(g.Provider, g.Reasoning)
}
// validateReasoning checks a provider-level reasoning shape declaration. A blank
// or "auto" kind is always valid (the conservative default: infer from the model
// name/base URL, and send nothing when the shape is ambiguous). Any other kind
// must be a shape this build implements and has evidence for; and the
// openai-compatible shape names are rejected on an anthropic group, whose switch
// shape is defined by its own protocol — failing loudly beats a silently inert
// option.
func validateReasoning(provider string, r ReasoningOptions) error {
kind := strings.TrimSpace(r.Kind)
switch kind {
case "", ReasoningKindAuto, ReasoningKindDeepSeek, ReasoningKindOpenAI,
ReasoningKindVLLM, ReasoningKindDashScope:
default:
return fmt.Errorf("reasoning.kind %q must be one of %s", kind, strings.Join(ReasoningKinds(), "/"))
}
if provider == ProviderAnthropic && kind != "" && kind != ReasoningKindAuto {
return fmt.Errorf("reasoning.kind %q applies to the openai-compatible provider only", kind)
}
return nil
}
// validatePersonaMap checks a persona-keyed binding map: every key must be a
// non-empty persona id and every value a non-empty value. When providers is true
// the values must additionally be a known protocol, so a typo in a per-persona
// provider fails the group loudly instead of silently routing to the default.
func validatePersonaMap(field string, m map[string]string, providers bool) error {
for persona, value := range m {
if strings.TrimSpace(persona) == "" {
return fmt.Errorf("%s: persona id must not be empty", field)
}
if strings.TrimSpace(value) == "" {
return fmt.Errorf("%s: value for persona %q must not be empty", field, persona)
}
if providers && value != ProviderOpenAI && value != ProviderAnthropic {
return fmt.Errorf("%s: provider %q for persona %q must be openai or anthropic", field, value, persona)
}
}
return nil
}
// PersonaRoute returns the per-persona model/provider binding, if one is
// configured for persona. An empty model (or an unknown persona) is reported as
// ok=false so the caller keeps the group's default binding.
func (g Group) PersonaRoute(persona string) (model, provider string, ok bool) {
persona = strings.TrimSpace(persona)
if persona == "" {
return "", "", false
}
model = strings.TrimSpace(g.PersonaModels[persona])
if model == "" {
return "", "", false
}
return model, strings.TrimSpace(g.PersonaProviders[persona]), true
}
// 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,
ReasoningKind: strings.TrimSpace(g.Reasoning.Kind),
}
}
return config.LLMConfig{
Provider: ProviderOpenAI,
BaseURL: g.BaseURL,
APIKey: g.APIKey,
Model: g.Model,
Fallback: g.Fallback,
ReasoningKind: strings.TrimSpace(g.Reasoning.Kind),
}
}
// 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.
//
// Multimodal stays false here: the flat env block has no way to declare whether
// the main model accepts image input, and claiming a capability the endpoint may
// not have is worse than routing the image through the vision/OCR tool (which is
// exactly what the tool exists for). The declaration lives on the models.json
// group's `multimodal` flag, which the provider panel writes (design/06 §0 首条;
// 裁决 14.4.1). LLM_VISION_MODEL configures the vision *tool's* model, not the
// main model's ability.
//
// Reasoning.Kind is carried through so an env-only deployment can still declare
// its endpoint's switch shape (LLM_REASONING_KIND); its env default is blank,
// which keeps every existing deployment at "auto" (no new field is ever sent).
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,
Reasoning: ReasoningOptions{Kind: strings.TrimSpace(env.ReasoningKind)},
}}
}
return []Group{{
ID: ProviderOpenAI,
Provider: ProviderOpenAI,
BaseURL: env.BaseURL,
APIKey: env.APIKey,
Model: env.Model,
Fallback: env.Fallback,
Reasoning: ReasoningOptions{Kind: strings.TrimSpace(env.ReasoningKind)},
}}
}
// Find returns the group with the given id, if present. It is the read half of
// the admin read-modify-write merge, so a partial edit starts from the stored
// group instead of zero values.
func Find(groups []Group, id string) (Group, bool) {
if strings.TrimSpace(id) == "" {
return Group{}, false
}
for _, g := range groups {
if g.ID == id {
return g, true
}
}
return Group{}, false
}
// 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()
}