Files
Cyrene_v2/internal/config/config.go
T
AskaEth 8890c30bb5
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(model): 按 provider 形状独立适配推理开关 + per-persona 提供商链真接线(裁决 03.4.2/08.4.1/03.4.1 余项)
- 新增 reasoning_shape.go:**每个 provider 形状一个 resolver,只发自己形状的字段**
  · deepseek:thinking{type} + reasoning_effort(依据 DevDocs/reference/deepseek 官方本地副本)
  · vllm:chat_template_kwargs.enable_thinking · dashscope:顶层 enable_thinking(**只允许显式声明**)
  · openai:仅 reasoning_effort · anthropic:自己的 thinking{budget_tokens}(未改)
  · 未知 kind → WARN 且一个字段都不发(不许静默失效)
- 形状载体:models.json 组级 `reasoning.kind`(可热更新,未声明则不写该键)+ env `LLM_REASONING_KIND`
- 红线:`effort:none` 在只有布尔开关的形状上翻成**显式关思考**,绝不降成任何强度档
- 03.4.1 余项:`Request.PersonaID` + `RuntimeLLM.ForPersona(pid)` 视图;
  每 persona 协议一条链并消费 `RouteProviderForPersona`;`agent/request.go` 非流式路径同一钩子
- **生产装配(主会话补)**:默认 agent 与 personaAgentPool 都改用 `ForPersona(pid)` 视图——
  否则角色级模型/提供商路由在生产里永不被选中(形同虚设)
- 复核:build/vet 绿 + model/agent/config/cmd 包测试绿;5 条变异反证先红后还原
  (其中一条「红但没钉住」已改用例:通道读取加 2s 超时,避免整包 panic 掩盖信号)
- 未落:DashScope 的 auto 推断(缺官方依据,只允许显式声明);Qwen `/think` 前导消息
  (design/01 §2.1 与 design/11 §3 口径冲突,停手报告)
2026-10-04 12:57:55 +08:00

427 lines
21 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 config loads, defaults, and validates the Cyrene v2 configuration
// from environment variables and an optional .env file (development only).
//
// Every setting is sourced from an environment variable (no hard-coded values
// in code). Nested groups map to struct fields tagged with `env:"NAME"`.
// Missing required values cause Load/MustLoad to return an error so startup
// can fail fast.
package config
import (
"fmt"
"reflect"
"strings"
"time"
)
// Config is the root configuration tree.
type Config struct {
Env string `env:"ENV" default:"development"`
LogLevel string `env:"LOG_LEVEL" default:"debug"`
Timezone string `env:"TZ" default:"Asia/Shanghai"`
DataDir string `env:"DATA_DIR" default:"./data"`
AllowedOrigins []string `env:"ALLOWED_ORIGINS" default:"http://localhost:5173,http://localhost:3000"`
Server ServerConfig
Auth AuthConfig
DB DBConfig
LLM LLMConfig
Embedding EmbeddingConfig
IoT IoTConfig
Voice VoiceConfig
Background BackgroundConfig
Subagent SubagentConfig
PushOutlet PushOutletConfig
WSHub WSHubConfig
ExecSandbox ExecSandbox
MCP MCPConfig
Capacity CapacityConfig
Channels ChannelsConfig
}
// ServerConfig holds the single gateway listener settings.
type ServerConfig struct {
Host string `env:"HOST" default:"0.0.0.0"`
Port int `env:"PORT" default:"8080"`
}
// AuthConfig holds authentication and admin credential settings.
type AuthConfig struct {
JWTSecret string `env:"JWT_SECRET" validate:"required,min=32"`
AdminUser string `env:"ADMIN_USERNAME" default:"admin"`
AdminPass string `env:"ADMIN_PASSWORD" default:"admin12345"`
RegistrationEnabled bool `env:"REGISTRATION_ENABLED" default:"true"`
// RefreshTTL is how long an issued opaque refresh token stays valid. It is a
// Go duration string (e.g. "720h" = 30 days). Access tokens keep their 24h
// lifetime; the refresh token is the long-lived credential that renews them.
RefreshTTL time.Duration `env:"AUTH_REFRESH_TTL" default:"720h"`
// RefreshRotateDisabled opts out of single-use refresh rotation. Rotation is
// on by default (the safe default): a successful /auth/refresh revokes the
// presented token and returns a fresh one, so a replayed token fails. Set
// AUTH_REFRESH_ROTATE_DISABLED=true only for clients that cannot persist a
// rotated token. The field is negative-named so its zero value (an
// unset/undecoded config) means rotation is enabled.
RefreshRotateDisabled bool `env:"AUTH_REFRESH_ROTATE_DISABLED" default:"false"`
}
// DBConfig holds database connection settings. SQLite is the default; PGURL is
// the optional PostgreSQL+pgvector enhancement.
type DBConfig struct {
SQLitePath string `env:"DB_SQLITE_PATH" default:"./data/cyrene.db"`
PGURL string `env:"DB_PG_URL"`
}
// LLMConfig holds the multi-supplier LLM adapter settings. Provider selects the
// wire protocol; every provider has its own independent base/key/model/fallback
// so multiple vendors can coexist.
type LLMConfig struct {
// Provider declares which protocol to use: "openai" (OpenAI-compatible
// /chat/completions) or "anthropic" (Anthropic Messages /v1/messages).
Provider string `env:"LLM_PROVIDER" default:"openai"`
// OpenAI-compatible provider (default). Kept flat for backward compatibility
// with the historic LLM_BASE_URL/API_KEY/MODEL/FALLBACK_MODEL env names.
BaseURL string `env:"LLM_BASE_URL"`
APIKey string `env:"LLM_API_KEY"`
Model string `env:"LLM_MODEL"`
Fallback string `env:"LLM_FALLBACK_MODEL"`
// VisionModel is the dedicated model behind the vision tool (image
// understanding). It is the tool's model, not the main model's ability: the
// per-persona/per-request routing decision belongs to the vision Router, and
// the main model's own image input capability is declared by the active
// models.json group's multimodal flag (裁决 14.4.1/14.4.5)。
VisionModel string `env:"LLM_VISION_MODEL"`
// OCRModel is the dedicated model behind the OCR tool. Empty reuses
// VisionModel, so OCR keeps working without a second endpoint (裁决 14.4.1
// 的「独立视觉模型 + OCR 模型」)。
OCRModel string `env:"LLM_OCR_MODEL"`
// Local is the optional third degradation level (裁决 03.4.3): primary →
// fallback → local. It defaults to off because the current machine cannot run
// a local model comfortably; when off the chain keeps its two-level shape.
Local LocalLLMConfig
// FallbackMaxTokens caps the last-resort fallback model in a group's chain
// (primary → fallback). It is the final safety budget: it only applies when
// the chain actually has a fallback step, that group sets no per-group
// MaxTokens (models.json maxTokens) and the request carries no explicit
// budget. Zero disables the cap (the adapter default then applies).
FallbackMaxTokens int `env:"LLM_FALLBACK_MAX_TOKENS" default:"8192"`
// ReasoningKind declares this OpenAI-compatible endpoint's reasoning-switch
// wire shape (裁决 03.4.2/08.4.1: 不同提供商的开关形状不同,必须逐 provider
// 独立适配). Values mirror runtimeconfig.ReasoningKind*: "" or "auto" infers
// the vendor from the model name/base URL and sends nothing when the shape is
// ambiguous (the conservative default, so an existing .env is unchanged);
// "deepseek" / "openai" / "vllm" / "dashscope" select that endpoint's shape
// explicitly. It is the env-fallback twin of the models.json group's
// `reasoning.kind`, which is the primary carrier (hot-reloadable, panel-edited).
ReasoningKind string `env:"LLM_REASONING_KIND" default:""`
// Anthropic native provider, used when Provider=anthropic. Independent
// config block so each vendor is a self-contained config.
AnthropicBaseURL string `env:"ANTHROPIC_BASE_URL"`
AnthropicAPIKey string `env:"ANTHROPIC_API_KEY"`
AnthropicModel string `env:"ANTHROPIC_MODEL"`
AnthropicFallback string `env:"ANTHROPIC_FALLBACK_MODEL"`
}
// LocalLLMConfig configures the optional local model rung — the third level of
// the primary → fallback → local degradation chain (裁决 03.4.3). It is opt-in
// and defaults to off: a machine that cannot run a local model comfortably must
// not silently pay for one. It becomes an active chain step only when Enabled is
// true and both BaseURL and Model are set; anything less is reported and skipped
// rather than producing a half-configured step.
type LocalLLMConfig struct {
// Enabled turns the local rung on. Default false (project red line: 本地模型可开关,默认关).
Enabled bool `env:"LLM_LOCAL_ENABLED" default:"false"`
// Provider is the local endpoint's wire protocol: "openai" (llama.cpp /
// ollama / vLLM compatible endpoints) or "anthropic". Empty means openai.
Provider string `env:"LLM_LOCAL_PROVIDER" default:"openai"`
// BaseURL is the local endpoint's base URL (e.g. http://127.0.0.1:8080/v1).
BaseURL string `env:"LLM_LOCAL_BASE_URL"`
// APIKey is the local endpoint's key; empty is normal for a local server.
APIKey string `env:"LLM_LOCAL_API_KEY"`
// Model is the local model name. Without it the rung stays off.
Model string `env:"LLM_LOCAL_MODEL"`
}
// Configured reports whether the local rung has enough configuration to act as a
// chain step. Enabled alone is not enough: a missing endpoint or model must not
// become a step that can only fail.
func (l LocalLLMConfig) Configured() bool {
return l.Enabled && strings.TrimSpace(l.BaseURL) != "" && strings.TrimSpace(l.Model) != ""
}
// EmbeddingConfig holds the optional semantic embedding API settings. When
// unset, the runtime falls back to built-in n-gram/FTS vectors.
type EmbeddingConfig struct {
APIURL string `env:"EMBEDDING_API_URL"`
APIKey string `env:"EMBEDDING_API_KEY"`
Model string `env:"EMBEDDING_MODEL"`
}
// IoTConfig selects the IoT device provider.
type IoTConfig struct {
Provider string `env:"IOT_PROVIDER" default:"mock"`
}
// VoiceConfig holds TTS/STT provider settings and the Alibaba Bailian DashScope
// credentials/models that back the cloud rung of the voice chain.
type VoiceConfig struct {
// TTSProvider selects the non-cloud local TTS rung: "edge-tts" or "espeak".
TTSProvider string `env:"TTS_PROVIDER" default:"edge-tts"`
// STTProvider selects the cloud STT provider ("dashscope").
STTProvider string `env:"STT_PROVIDER" default:"dashscope"`
// DashscopeAPIKey is the Bailian API key backing STT + TTS. When empty the
// dashscope adapters return ErrNotConfigured and the chain degrades.
DashscopeAPIKey string `env:"DASHSCOPE_API_KEY"`
// DashscopeBaseURL is the Bailian API base; the STT/TTS service paths are
// appended to it.
DashscopeBaseURL string `env:"DASHSCOPE_BASE_URL" default:"https://dashscope.aliyuncs.com/api/v1"`
// STTModel is the DashScope speech-transcription model.
STTModel string `env:"VOICE_STT_MODEL" default:"qwen-audio-3.0-asr-flash"`
STTLanguage string `env:"VOICE_STT_LANGUAGE" default:"zh"`
// TTSModel is the DashScope speech-synthesis model. cosyvoice-v3.5-plus and
// cosyvoice-v2 reject the HTTP SpeechSynthesizer call (engine 418); verified
// working is cosyvoice-v3-flash.
TTSModel string `env:"VOICE_TTS_MODEL" default:"cosyvoice-v3-flash"`
// TTSVoiceID selects the synthesized timbre. Voices are model-scoped (a v3
// model needs a _v3 voice). After cloning a custom voice in Bailian
// (音色复刻) fill its voice_id here.
TTSVoiceID string `env:"VOICE_TTS_VOICE_ID" default:"longxiaochun_v3"`
TTSFormat string `env:"VOICE_TTS_FORMAT" default:"wav"`
TTSSampleRate int `env:"VOICE_TTS_SAMPLE_RATE" default:"24000"`
// VoiceTTSInstruction is the emotion/tone instruction driving a cloned/designed
// TTS voice (e.g. "语气开朗俏皮,像女朋友撒娇"). cosyvoice v3 system voices (the
// long*_v3 family) require a fixed instruction format, so a free-form instruction
// is never sent for them — only for a cloned/designed voice (声音复刻).
VoiceTTSInstruction string `env:"VOICE_TTS_INSTRUCTION" default:""`
// VoiceClonePrefix is the name prefix used when creating a custom voice (声音复刻
// create_voice). It must be ≤10 alphanumeric chars; the empty value falls back
// to "cyrene". target_model stays TTSModel (must match the synthesis model).
VoiceClonePrefix string `env:"VOICE_CLONE_PREFIX" default:"cyrene"`
}
// BackgroundConfig holds the autonomous thinking settings. QuietStartHour and
// QuietEndHour bound the do-not-disturb window in local clock hours; they are the
// single source shared by the backthink service and the channel bridge's
// minimal-disturbance quiet-hours gate (design/12 §6 策略五). Equal non-zero hours
// disable the window. The Push*/Kind fields tune the proactive-push frequency of
// design/11 §6 + design/12 §6 and are mapped into backthink.Config.
type BackgroundConfig struct {
Enabled bool `env:"ENABLE_BACKGROUND_THINKING" default:"true"`
IntervalSec int `env:"THINK_INTERVAL_SEC" default:"900"`
SilenceTimeout int `env:"THINK_SILENCE_TIMEOUT_SEC" default:"120"`
QuietStartHour int `env:"QUIET_START_HOUR" default:"23"`
QuietEndHour int `env:"QUIET_END_HOUR" default:"7"`
PushLimit int `env:"THINK_PUSH_LIMIT" default:"1"`
PushWindowSec int `env:"THINK_PUSH_WINDOW_SEC" default:"3600"`
PushCooldownSec int `env:"THINK_PUSH_COOLDOWN_SEC" default:"1800"`
RecentChatCooldownSec int `env:"THINK_RECENT_CHAT_COOLDOWN_SEC" default:"600"`
KindWindowSec int `env:"THINK_KIND_WINDOW_SEC" default:"7200"`
}
// SubagentConfig holds the sub-agent orchestration settings (design/05 §7).
type SubagentConfig struct {
// TimeoutSec bounds one sub-agent's whole run. It feeds the dispatch tool's
// default (orchestration.WithSpawnTimeout), overriding the scheduler's code
// default; a per-dispatch timeout_seconds argument still overrides it per call
// (裁决 02.4.4: agent_timeout 可配置,默认 10min,支持按调用覆盖). Zero or
// negative selects the code default (600s = 10 minutes).
TimeoutSec int `env:"SUBAGENT_TIMEOUT_SEC" default:"600"`
}
// PushOutletConfig configures the proactive push outlet (D1, design/12 §4/§6).
// Proactive messages are mirrored to the owner's self-hosted unified push
// service (primary); Getui (个推) is the deferred fallback for when that service
// is unavailable. The Getui* fields are reserved placeholders — the SDK is not
// integrated, so an unavailable primary logs the pending fallback rather than
// pretending to push.
type PushOutletConfig struct {
// Enabled turns the outlet hook on. When false no export is attempted.
Enabled bool `env:"PUSH_EXPORTER_ENABLED" default:"false"`
// URL is the self-hosted unified push service endpoint (HTTP POST JSON).
// Empty leaves the primary tier unconfigured.
URL string `env:"PUSH_EXPORTER_URL"`
// Token is sent as "Authorization: Bearer <token>"; it is never logged.
Token string `env:"PUSH_EXPORTER_TOKEN"`
// TimeoutSec bounds one export attempt. Zero uses the service default (5s).
TimeoutSec int `env:"PUSH_EXPORTER_TIMEOUT_SEC" default:"5"`
// Getui* are reserved for the fallback tier; the SDK is not integrated yet,
// so these values are read but unused (kept so the env contract is stable).
GetuiAppID string `env:"GETUI_APP_ID"`
GetuiAppKey string `env:"GETUI_APP_KEY"`
GetuiMasterSecret string `env:"GETUI_MASTER_SECRET"`
GetuiBaseURL string `env:"GETUI_BASE_URL"`
}
// WSHubConfig holds the WebSocket hub limits.
type WSHubConfig struct {
MaxConnections int `env:"WS_MAX_CONNECTIONS" default:"1000"`
SessionIdleTimeout int `env:"SESSION_IDLE_TIMEOUT_MIN" default:"30"`
}
// MCPConfig holds Model Context Protocol server settings. The reflection-based
// loader only supports scalars and []string, so servers are supplied as a JSON
// array string (ServersJSON, env MCP_SERVERS_JSON) and parsed at startup by the
// MCP wiring (cmd/cyrene/mcp.go).
type MCPConfig struct {
ServersJSON string `env:"MCP_SERVERS_JSON"`
}
// CapacityConfig configures the awakened-capacity decision layer
// (DevDocs/design/14-唤醒式能力渐进.md §4). It is opt-in: the loop exposes every
// tool until Enabled is true.
type CapacityConfig struct {
// Enabled turns on the capacity decider. When false the loop advertises every
// registered tool (pre-existing behavior). Note: it is wired at the loop
// periphery, not back-end (Invoke stays unblocked).
Enabled bool `env:"CAPACITY_ENABLED" default:"false"`
// MinConfidence is the wake threshold below which the state is forced toward
// the persona default (设计 §2.3 默认保守). Zero selects the code default 0.6.
MinConfidence float64 `env:"CAPACITY_MIN_CONFIDENCE" default:"0.6"`
// ToolSetsJSON, when non-empty, is a JSON map of state→[]tool name, e.g.
// {"quiet":["terminal","file"],"awake":["terminal","file","web-search"],
// "full":null}. A missing state selects the built-in default set; null ("full")
// means unrestricted. Parsed by the capacity wiring at startup (the
// reflection-based loader only supports scalars and []string), matching the
// MCP config pattern.
ToolSetsJSON string `env:"CAPACITY_TOOL_SETS"`
// L3Enabled turns on the model-assisted intent layer (design/14 §2.2 L3;
// 裁决 14.4.4, keys chosen as the landing suggestion the owner authorized).
// It costs one extra model call for an undecided turn, so it defaults to off:
// off means the layer never runs and L1/L2/L4 behave exactly as before.
L3Enabled bool `env:"CAPACITY_L3_ENABLED" default:"false"`
// L3CacheTTL is how long a model-assisted classification of the same turn text
// is reused (a Go duration string such as "5m"). Zero or unparsable disables
// reuse; empty selects the code default of 5m.
L3CacheTTL string `env:"CAPACITY_L3_CACHE_TTL" default:"5m"`
}
// MCPServer describes a single MCP server: a stdio subprocess (Command/Args) or
// an SSE endpoint (URL). Enabled gates activation at startup.
type MCPServer struct {
Name string `json:"name"`
Command string `json:"command"`
Args []string `json:"args"`
URL string `json:"url"`
Enabled bool `json:"enabled"`
}
// ChannelsConfig holds optional multi-account IM channel settings (design/16
// §4.6 §5). The single-account credentials are read from CHANNEL_<NAME>_* env
// vars in cmd/cyrene/channels.go; this block carries the optional multi-account
// registry plus the per-account allow/deny black-or-white lists via a JSON
// string (the reflection-based loader only supports scalars and []string, so a
// structured value is passed as JSON — matching the MCP and Capacity configs).
type ChannelsConfig struct {
// AccountsJSON is the JSON shape map[platform]→[]ChannelAccount, e.g.
// {"telegram":[{"id":"conf0","config":{"bot_token":"..."},
// "denyUsers":["12345"]}]}. Empty means the single-account env path applies.
AccountsJSON string `env:"CHANNELS_JSON"`
}
// ChannelAccount is one IM access config (one account) for a platform. It pairs
// an id (used to name the adapter instance and to build the platform:confId
// account key) with the platform-specific credential map and the allow/deny
// black-or-white lists applied at the channelbridge ingress (design/16 §4.6), so
// a blacklisted or non-whitelisted group/user never reaches the LLM.
type ChannelAccount struct {
// ID is the local config id, unique within its platform. It names the adapter
// instance and the platform:confId account key (design/16 §4.5 deviceID).
ID string `json:"id"`
// Config is the platform-specific credential/feature map decoded into the
// adapter's typed Config (bot_token, base_url, remote_url, ...). Missing keys
// fall back to the adapter's own zero values.
Config map[string]any `json:"config,omitempty"`
// AllowGroups, when non-empty, is a white list of group ids: a group message
// is dropped unless its GroupID is in this list.
AllowGroups []string `json:"allowGroups,omitempty"`
// DenyGroups is a black list of group ids: a group message whose GroupID is in
// this list is dropped at ingress.
DenyGroups []string `json:"denyGroups,omitempty"`
// AllowUsers, when non-empty, is a white list of user uids: a direct (private)
// message from a user not in this list is dropped.
AllowUsers []string `json:"allowUsers,omitempty"`
// DenyUsers is a black list of user uids: a direct message from a user in this
// list is dropped at ingress.
DenyUsers []string `json:"denyUsers,omitempty"`
// AdminIDs 是该账号声明的管理员 id 列表(裁决 06.4.2;design/08 §1.1、design/11 §0):
// 命中的 uid 才可能进 owner 车道,可声明多个账户。**适配器上报的角色字段不参与判主**
// ——群主/群管理员一律是 RoleMember,伪造 role=owner 无效。只有需要的渠道才声明;
// weixin-ilink 只允许与绑定微信号私聊,不需要该项。这些 id 会与 owner.yaml 的账号
// 白名单取并集,共同构成唯一的判主依据(见 runtimeconfig.AdminIDs)。
AdminIDs []string `json:"adminIds,omitempty"`
// GroupMsgPerMin caps group-chat inbound messages per minute for this account
// (design/12 §6 策略三). Zero follows the protective default (6); a negative
// value disables the group rate limit; a positive value pins it.
GroupMsgPerMin int `json:"groupMsgPerMin,omitempty"`
// MsgPerMin caps total inbound non-owner messages per minute for this account.
// Zero or negative disables it.
MsgPerMin int `json:"msgPerMin,omitempty"`
// MaxMessages is the total pending/active message cap for this account
// (design/12 §6 策略四). Zero follows the protective default (50); a negative
// value disables the cap; a positive value pins it. When the pending set nears
// this cap the review model prunes low-value messages.
MaxMessages int `json:"maxMessages,omitempty"`
// PersonaID is the role this account answers as (design/22 §2 方案 b: 渠道级绑定).
// It is a persona id from the persona library, not a display name. It only
// provides the role for the owner's private chat with this account and only when
// the main session binds no role of its own: a session binding wins over it, the
// global default (owner.yaml defaultPersona) is the final fallback, and a
// group/stranger conversation is always forced onto the default role
// (design/22 §7-4). Empty means "no channel-level binding".
PersonaID string `json:"personaId,omitempty"`
}
// Load reads configuration, merging an optional .env file (development only)
// with process environment variables. Process environment variables always take
// precedence over .env values, which take precedence over code defaults.
//
// Passing an empty envPath skips .env entirely. A missing .env file is not an
// error; only missing required values are.
func Load(envPath string) (*Config, error) {
fromEnv := map[string]string{}
if envPath != "" {
m, err := parseDotEnv(envPath)
if err != nil {
return nil, fmt.Errorf("config: read %s: %w", envPath, err)
}
fromEnv = m
}
cfg := &Config{}
if err := decodeValue(reflect.ValueOf(cfg), fromEnv); err != nil {
return nil, err
}
if errs := validateStruct(reflect.ValueOf(cfg).Elem()); len(errs) > 0 {
parts := make([]string, 0, len(errs))
for _, e := range errs {
parts = append(parts, e.Error())
}
return nil, fmt.Errorf("config: validation failed: %s", strings.Join(parts, "; "))
}
return cfg, nil
}
// MustLoad is Load that panics on error. It is intended for startup, matching
// the "schema validation fails at boot" rule.
func MustLoad(envPath string) *Config {
cfg, err := Load(envPath)
if err != nil {
panic(err)
}
return cfg
}