feat(asr): 本地语音转写接入为一级路由 + 并行工作流合并提交

按用户指示做**一包提交**,不按工作流拆分。本提交刻意混合了多条并行线:

  · 本地 ASR 接管:audio 成为与 chat/embed/image/video 同等的路由类别
    (IsLocalRoute 单一判据、audio 健康探测、default_audio_route、
    auto 占位、GET /api/ai/routes/audio、回退云端时界面明示「音频已出网」)
  · LLM 调用层:ctx 贯穿、ToolCall/ToolSchema、EmptyCompletionError /
    TransientUpstreamError(按错误类型而非文案判重试)
  · 编排 Agent:general_assistant orchestrate/persistence/spec_driver
  · 联网搜索:internal/search(playwright)
  · 网盘:backend + 前端
  · 前端 UI:导航/路由/工作台若干页
  · 交付文档:DELIVERY.md / AR04 / 部署文档的「无 Python」表述据实改写,
    新增 eai_agentplatform-asr.service、asr.env、clonezilla-cleanup 清 ~/asr-poc

不分拆的原因:dev 早期,粒度不该打断工作节奏。且实测过——这些改动
**在编译上是同一个单元**(llm.go 的 ctx 签名变更牵动 12 个调用点,
chat_message.go 的 ctx 改动又与编排重写同处一个 hunk),拆出来的中间态编不过。
详见 TOP_CODING_RULES.md G14.5 与 bugs_and_errors.md E09。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-26 22:21:39 +08:00
co-authored by Claude Code
parent e73169df50
commit c1af86c934
192 changed files with 18046 additions and 392 deletions
@@ -0,0 +1,182 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestMain 定位 backend-go 目录并切换工作目录,保证 config/ai_config.json 与
// config/ai_secrets.json 在测试进程内可被发现(configDir() 的回退逻辑依赖 CWD/config)。
//
// go test 默认把工作目录设为包目录,因此在 internal/config 下直接跑会找不到
// ai_config.json —— 切到 backend-go 再运行,模拟真实进程环境。
func TestMain(m *testing.M) {
if base := locateBackendGo(); base != "" {
_ = os.Chdir(base)
}
os.Exit(m.Run())
}
// locateBackendGo 从当前工作目录向上探测,找到含 config/ai_config.json 的目录
// (即 backend-go);找不到返回空串。
func locateBackendGo() string {
dir, err := os.Getwd()
if err != nil {
return ""
}
for {
if _, err := os.Stat(filepath.Join(dir, "config", "ai_config.json")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}
// TestGetAudioRouteResolves 断言语音转写路由能解析成一条真正的 ASR 路由:
// category 必须是 audio,终点必须是 /audio/transcriptions(而不是 chat 的
// /chat/completions),且密钥必须已从 ai_secrets.json 注入。
func TestGetAudioRouteResolves(t *testing.T) {
rc, err := GetAudioRoute("audio_transcribe")
if err != nil {
t.Fatalf("GetAudioRoute(audio_transcribe) 报错: %v", err)
}
if rc.Category != "audio" {
t.Errorf("Category = %q,期望 audio", rc.Category)
}
if rc.Model == "" {
t.Error("Model 为空 —— 会把空模型名发给 ASR 服务")
}
if !strings.HasSuffix(rc.FullURL, "/audio/transcriptions") {
t.Errorf("FullURL = %q,期望以 /audio/transcriptions 结尾", rc.FullURL)
}
if strings.Contains(rc.FullURL, "/chat/completions") {
t.Errorf("FullURL = %q 指到了 chat 接口 —— 音频必须走 ASR 接口", rc.FullURL)
}
// 密钥只对**非本地**路由要求:本机服务(回环地址)本来就不需要密钥,
// 判据与运行期共用 IsLocalRoute。这里原来是无条件断言,于是这条用例的
// 红绿取决于跑测试的机器上 8090 有没有在监听 —— 本地 ASR 一启动,
// 解析结果变成 audio_route_local_whisper,断言就红。那不是「发现了问题」,
// 是把「机器状态」当成了「代码错误」。实测过:起个只回 200 的桩就翻红。
if !IsLocalRoute(rc) {
if rc.APIKey == "" {
t.Error("APIKey 为空 —— 会像旧实现那样带着占位密钥去请求,必然 401")
}
if rc.APIKey == "placeholder" {
t.Error("APIKey 是占位值 placeholder —— 正是旧实现的三处写死之一")
}
}
t.Logf("解析结果: route=%s provider=%s model=%s url=%s timeout=%ds is_local=%v",
rc.RouteID, rc.Provider, rc.Model, rc.FullURL, rc.TimeoutSeconds, IsLocalRoute(rc))
}
// TestAudioRouteCapabilityIsDeclared 钉住「这次转写有没有说话人标签」是个常量。
//
// 为什么必须单独钉:audio_transcribe 现在指向 audio_route_auto,auto 会按此刻
// 哪台服务活着挑一条路由。若不加约束,本地 ASR 一挂就会退到
// audio_route_siliconflow_qwen3(不支持说话人分离)—— 于是「有没有标签」随
// 进程启停变化:第 1 步说要区分说话人、第 2 步落到的路由不输出标签,下游
// 第 3 步硬失败;更坏的是闸门读到 false 后放行,模型猜的身份进正式纪要。
//
// 所以断言两件事,而且都**不依赖本机 8090 起没起**:
// 1. auto 解析出的路由,说话人能力必须与声明路由(default_audio_route)一致;
// 2. 声明路由本身必须存在且能解析。
func TestAudioRouteCapabilityIsDeclared(t *testing.T) {
declared, err := GetDeclaredAudioRoute()
if err != nil {
t.Fatalf("声明路由(default_audio_route)解析失败:%v", err)
}
resolved, err := GetAudioRoute("audio_transcribe")
if err != nil {
t.Fatalf("GetAudioRoute(audio_transcribe) 报错: %v", err)
}
if resolved.SupportsSpeakers != declared.SupportsSpeakers {
t.Errorf("auto 解析到 %s(supports_speakers=%v),而声明路由 %s 是 %v:"+
"说话人能力被静默换掉了", resolved.RouteID, resolved.SupportsSpeakers,
declared.RouteID, declared.SupportsSpeakers)
}
if resolved.Category != "audio" {
t.Errorf("auto 解析到 %s,分类是 %q —— 不是音频路由", resolved.RouteID, resolved.Category)
}
t.Logf("声明路由=%s(speakers=%v)auto 解析=%s",
declared.RouteID, declared.SupportsSpeakers, resolved.RouteID)
}
// TestAudioFallbackKeepsSpeakerCapability 钉住回退链不会换掉说话人能力。
//
// 回退是为了「这条路此刻不行」,不是为了「悄悄换个能力」。链上一条
// supports_speakers 不同的路由,等于让一次网络抖动改掉整个任务的下游行为。
func TestAudioFallbackKeepsSpeakerCapability(t *testing.T) {
primary, err := GetDeclaredAudioRoute()
if err != nil {
t.Fatalf("声明路由解析失败:%v", err)
}
chain := GetFallbackAudioRoutes(primary.RouteID)
if len(chain) == 0 {
// 没有配回退不是错误(云端可能本就没开通),但要说清「本地挂了没有退路」。
t.Logf("路由 %s 没有配置回退链:本地不可用时这次转写会直接失败", primary.RouteID)
return
}
for _, r := range chain {
if r.Category != "audio" {
t.Errorf("回退链里的 %s 分类是 %q —— 会把音频发到非 ASR 终点", r.RouteID, r.Category)
}
if r.SupportsSpeakers != primary.SupportsSpeakers {
t.Errorf("回退链里的 %s 的 supports_speakers=%v,与主路由 %s 的 %v 不同:"+
"回退会静默换掉说话人能力", r.RouteID, r.SupportsSpeakers,
primary.RouteID, primary.SupportsSpeakers)
}
t.Logf("回退: %s (%s)", r.RouteID, r.Model)
}
}
// TestGetAudioRouteDoesNotFallBackToChat 是本包最重要的一条断言。
//
// GetRoute 的回退链会在找不到路由时静默返回 default_route(一条 chat 路由)。
// 对音频而言这是最坏的失败方式:拿 /chat/completions 去打 ASR 模型,报错会指向
// 模型或密钥,与真实原因(路由配错)毫无关系。GetAudioRoute 必须直接报错。
func TestGetAudioRouteDoesNotFallBackToChat(t *testing.T) {
// 传一个确实存在于 chat_routes 的 id:它不是音频路由,必须被拒绝,
// 而不是"找不到就拿个默认的凑合"。
rc, err := GetAudioRoute("chat_route_lmuai_deepseek_v4_flash")
if err == nil {
t.Fatalf("把 chat 路由当音频路由返回了(route=%s url=%s)—— 必须报错",
rc.RouteID, rc.FullURL)
}
if rc != nil {
t.Errorf("报错时仍返回了非 nil 的 RouteConfig: %+v", rc)
}
t.Logf("按预期拒绝: %v", err)
// 配错的路由名同样必须报错,且不得回退到 default_route。
if _, err := GetAudioRoute("audio_route_does_not_exist"); err == nil {
t.Error("不存在的音频路由名没有报错 —— 回退链没有被拦住")
}
}
// TestGetRoutesByCategoryAudio 断言 audio 类别可被枚举(管理/诊断页面用),
// 且枚举出的每一条都真的是音频路由。
func TestGetRoutesByCategoryAudio(t *testing.T) {
routes, err := GetRoutesByCategory("audio")
if err != nil {
t.Fatalf("GetRoutesByCategory(audio) 报错: %v", err)
}
if len(routes) == 0 {
t.Fatal("audio 类别下没有任何路由")
}
for _, rc := range routes {
if rc.Category != "audio" {
t.Errorf("路由 %s 的 Category = %q,期望 audio", rc.RouteID, rc.Category)
}
if !strings.HasSuffix(rc.FullURL, "/audio/transcriptions") {
t.Errorf("路由 %s 的 FullURL = %q,不是 ASR 终点", rc.RouteID, rc.FullURL)
}
t.Logf("音频路由: %s (%s)", rc.RouteID, rc.Model)
}
}
@@ -24,6 +24,7 @@ type Config struct {
KBDataDir string
KnowledgeSourceDir string
TrainingMaterialsDir string
NetdiskDataDir string // 网盘物理文件根目录(独立于知识库媒体)
// 定期备份。SQLite 是单文件、又没有任何外部主从,坏一份就是全丢,
// 所以默认开着:启动补一次,之后每 BackupIntervalHours 小时一次,保留最近 N 份。
@@ -67,6 +68,7 @@ func Load() *Config {
KBDataDir: getenv("KB_DATA_DIR", filepath.Join(baseDir, "data", "kb_data")),
KnowledgeSourceDir: getenv("KNOWLEDGE_SOURCE_DIR", filepath.Join(assetRootDir, "knowledge", "source")),
TrainingMaterialsDir: getenv("TRAINING_MATERIALS_DIR", filepath.Join(assetRootDir, "training", "materials")),
NetdiskDataDir: getenv("NETDISK_DATA_DIR", filepath.Join(baseDir, "data", "netdisk")),
// 备份目录必须落在 data/ 下:systemd 单元是 ProtectSystem=strict,
// 只有 data/ 可写(ReadWritePaths),换到别处会静默失败。
@@ -3,6 +3,7 @@ package config
import (
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"sort"
@@ -27,6 +28,10 @@ type RouteInfo struct {
Description string `json:"description,omitempty"`
ShortRouteName string `json:"short_route_name,omitempty"`
ShortModelName string `json:"short_model_name,omitempty"`
// SupportsSpeakers 仅 audio_routes 有意义:该 ASR 模型是否输出说话人分离。
// 由配置显式声明而非按模型名猜——第 1 步要据此告诉用户「这次转写会不会有说话人标签」,
// 猜错就是对着用户说了句假话。
SupportsSpeakers bool `json:"supports_speakers,omitempty"`
}
// RouteConfig 运行时完整路由配置(合并 secrets 后)
@@ -40,25 +45,33 @@ type RouteConfig struct {
APIKey string
MaxTokens int
Temperature float64
TimeoutSeconds int // HTTP 超时(秒),0 表示使用默认
Category string // chat / embed / image
TimeoutSeconds int // HTTP 超时(秒),0 表示使用默认
Category string // chat / embed / image / video / audio
Description string
ShortRouteName string
ShortModelName string
// SupportsSpeakers 见 RouteInfo 上的同名说明(仅 audio 类别有意义)。
SupportsSpeakers bool
}
// AIConfig 顶层结构(支持分类路由)
type AIConfig struct {
Version string `json:"version"`
Description string `json:"description"`
DefaultRoute string `json:"default_route"`
DefaultEmbedRoute string `json:"default_embed_route"`
Version string `json:"version"`
Description string `json:"description"`
DefaultRoute string `json:"default_route"`
DefaultEmbedRoute string `json:"default_embed_route"`
// DefaultAudioRoute 语音转写的兜底路由,参与 AutoAudioRouteID 的「优先」排序
// (见 route_health.go 的 pickBestHealthyRoute)。出厂指向本地,即「优先本地路由」。
DefaultAudioRoute string `json:"default_audio_route,omitempty"`
AgentRoutes map[string]string `json:"agent_routes"`
ChatRoutes map[string]RouteInfo `json:"chat_routes"`
EmbedRoutes map[string]RouteInfo `json:"embed_routes"`
ImageRoutes map[string]RouteInfo `json:"image_routes"`
VideoRoutes map[string]RouteInfo `json:"video_routes"`
FallbackRoutes map[string][]string `json:"fallback_routes"`
// AudioRoutes 语音转写(ASR)路由。与上面四类的区别是它**不走 chat/completions**:
// endpoint 是 /audio/transcriptions,请求体是 multipart 而非 JSON。
AudioRoutes map[string]RouteInfo `json:"audio_routes"`
FallbackRoutes map[string][]string `json:"fallback_routes"`
// 兼容旧版平铺 routes(若有则回退)
Routes map[string]RouteInfo `json:"routes,omitempty"`
}
@@ -67,7 +80,7 @@ type AIConfig struct {
type AISecrets struct {
VECTORENGINE_API_KEY string `json:"VECTORENGINE_API_KEY,omitempty"`
OPENROUTER_API_KEY string `json:"OPENROUTER_API_KEY,omitempty"`
SILICONFLOW_API_KEY string `json:"SILICONFLOW_API_KEY,omitempty"`
SILICONFLOW_API_KEY string `json:"SILICONFLOW_API_KEY,omitempty"`
VOLCES_API_KEY string `json:"VOLCES_API_KEY,omitempty"`
LMUAI_API_KEY string `json:"LMUAI_API_KEY,omitempty"`
ALIYUN_API_KEY string `json:"ALIYUN_API_KEY,omitempty"`
@@ -76,6 +89,9 @@ type AISecrets struct {
ANTHROPIC_API_KEY string `json:"ANTHROPIC_API_KEY,omitempty"`
OPENAI_API_KEY string `json:"OPENAI_API_KEY,omitempty"`
BRAVE_SEARCH_API_KEY string `json:"BRAVE_SEARCH_API_KEY,omitempty"`
// LOCAL_ASR_API_KEY 走本地回环的 ASR 服务本就不需要鉴权,这里只是给
// 「密钥状态」面板一个可显示的条目;留空也能正常工作(见 IsLocalRoute)。
LOCAL_ASR_API_KEY string `json:"LOCAL_ASR_API_KEY,omitempty"`
}
// PlatformConfig 平台静态配置
@@ -96,7 +112,7 @@ type PlatformConfig struct {
var ProviderSecretKey = map[string]string{
"vectorengine": "VECTORENGINE_API_KEY",
"openrouter": "OPENROUTER_API_KEY",
"siliconflow": "SILICONFLOW_API_KEY",
"siliconflow": "SILICONFLOW_API_KEY",
"volces": "VOLCES_API_KEY",
"lmuai": "LMUAI_API_KEY",
"aliyun": "ALIYUN_API_KEY",
@@ -105,18 +121,58 @@ var ProviderSecretKey = map[string]string{
"remove_bg": "REMOVE_BG_API_KEY",
"anthropic": "ANTHROPIC_API_KEY",
"openai": "OPENAI_API_KEY",
"local_asr": "LOCAL_ASR_API_KEY",
}
// ProviderDefaultBaseURL provider 默认 base_url(当 secrets 未提供时)
var ProviderDefaultBaseURL = map[string]string{
"ollama": "http://127.0.0.1:11434/v1",
"llamacpp": "http://127.0.0.1:8080/v1",
"llamacpp": "http://127.0.0.1:8080/v1",
"openrouter": "https://openrouter.ai/api/v1",
"siliconflow": "https://api.siliconflow.cn/v1",
"siliconflow": "https://api.siliconflow.cn/v1",
"openai": "https://api.openai.com/v1",
"vectorengine": "https://api.vectorengine.ai/v1",
"volces": "https://ark.cn-beijing.volces.com/api/v3",
"lmuai": "https://api.lmuai.com/v1",
"local_asr": "http://127.0.0.1:8090/v1",
}
// IsLocalRoute 判断这条路由的流量是不是只在本机打转。
//
// 判据是 **base_url 的 host 是不是回环地址**,而不是 provider 名或一张白名单:
// 要保护的正是「字节到底去了哪」,那就直接看它去哪,别用间接指标去猜。
// 副产品是本机 llama.cpp / Ollama 也会被判为本地 —— 这是对的。
//
// 一处实现三处共用(P06.18 的教训:判定逻辑各写一份必然走偏):
// - buildRouteConfig:本地路由不要求配密钥
// - RequiresRouteAPIKey:健康探测与通路测试同上
// - audio_transcribe:据此在产物里标注「音频是否出网」
func IsLocalRoute(route *RouteConfig) bool {
if route == nil {
return false
}
return isLoopbackURL(route.BaseURL)
}
// isLoopbackURL 解析 URL 取 host,判断是否为回环地址。
// 解析不了(空串、缺 scheme)一律当**非**本地 —— 判不准的时候按「会出网」处理,
// 宁可多要一次确认,也不要漏报一次出网。
func isLoopbackURL(raw string) bool {
raw = strings.TrimSpace(raw)
if raw == "" {
return false
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return false
}
host := u.Hostname() // 自动剥掉端口,也自动处理 [::1] 的方括号
switch strings.ToLower(host) {
case "127.0.0.1", "localhost", "::1":
return true
}
// 127.0.0.0/8 整段都是回环,不只 .0.1
return strings.HasPrefix(host, "127.")
}
// ──────────────────────────────────────────────
@@ -213,7 +269,8 @@ func LoadAIConfig(forceReload ...bool) (*AIConfig, error) {
// 注意:本文件及 config/ai_config.json 保持多行、带缩进的普通格式。
// 严禁把 JSON 或本函数折叠成单行——单行会让格式错误难定位、diff 难读。
// (历史教训:ai_config.json 曾因缺一个右花括号导致整个 AI 路由不可用,
// 且文件原本被压成单行 5KB,错误极难排查。)
//
// 且文件原本被压成单行 5KB,错误极难排查。)
func ValidateAIConfig() error {
aiCfg, err := LoadAIConfig()
if err != nil {
@@ -231,9 +288,29 @@ func ValidateAIConfig() error {
return fmt.Errorf("默认路由 %q 不可用: %w", id, err)
}
}
// 校验 default_audio_route。不校验的话写错一个字母也能启动,然后
// audio_route_auto 会静默按延迟挑一条 —— 「优先本地」就这么没了,
// 而界面上看不出任何异常(这正是 G02 说的「配置缺失即报错」要拦的)。
if id := strings.TrimSpace(aiCfg.DefaultAudioRoute); id != "" && id != AutoAudioRouteID {
if _, err := GetAudioRoute(id); err != nil {
return fmt.Errorf("默认语音路由 %q 不可用: %w", id, err)
}
}
// 校验 agent_routes 中每个映射目标均可解析
for agent, rid := range aiCfg.AgentRoutes {
if _, err := GetRoute(rid); err != nil {
var err error
if agent == "audio_transcribe" {
// 转写这一条必须落在 audio_routes 里。用 GetRoute 校验它等于没校验:
// GetRoute 找不到会回退 default_route,于是一条拼错的音频路由能被
// 一条 chat 路由「校验通过」,直到运行时才发现拿 ASR 请求打了聊天接口。
//
// 只认这一个精确名字,不认 audio_ 前缀:audio_transcribe_llm 是**逐字稿
// 加工**(chat 路由),跟 ASR 是两回事,按前缀一刀切会把它一起判错。
_, err = GetAudioRoute(rid)
} else {
_, err = GetRoute(rid)
}
if err != nil {
return fmt.Errorf("agent[%q] -> 路由 %q 不可用: %w", agent, rid, err)
}
}
@@ -315,6 +392,12 @@ func GetRoute(agentOrRouteID string) (*RouteConfig, error) {
if routeID == AutoEmbedRouteID {
return resolveAutoRoute("embed")
}
// audio 的 auto 也认,别让 GetRoute("audio_route_auto") 走到下面去 ——
// findRoute 找不到它会回退 default_route,于是「音频路由」变成一条 chat 路由。
// 认它之后,这个 id 在任何入口都不会被解错(正经取音频路由仍推荐 GetAudioRoute)。
if routeID == AutoAudioRouteID {
return resolveAutoRoute("audio")
}
// 2) 在分类路由中查找
info, category, found := findRoute(aiCfg, routeID)
@@ -330,6 +413,118 @@ func GetRoute(agentOrRouteID string) (*RouteConfig, error) {
}
// 4) 从 secrets 注入 base_url / api_key
return buildRouteConfig(routeID, info, category)
}
// GetAudioRoute 解析语音转写(ASR)路由。
//
// 与 GetRoute 的唯一区别,也是它存在的全部理由:**只**在 audio_routes 里找,
// 找不到就报错,**绝不回退 default_route**。GetRoute 的回退链是为 chat 设计的,
// 拿它取音频路由会在配置写错时静默返回一条 chat 路由,然后用 /chat/completions
// 去打 ASR 模型 —— 报错信息会指向模型或密钥,与真实原因(路由配错)毫无关系。
func GetAudioRoute(agentOrRouteID string) (*RouteConfig, error) {
aiCfg, err := LoadAIConfig()
if err != nil {
return nil, fmt.Errorf("AI 配置加载失败: %w", err)
}
routeID := agentOrRouteID
if r, ok := aiCfg.AgentRoutes[agentOrRouteID]; ok {
routeID = r
}
// auto 也走 resolveAutoRoute,但传的是 "audio" —— 返回的一定是 audio 路由,
// 不会顺手把 chat 路由塞进来。
if routeID == AutoAudioRouteID {
return resolveAutoRoute("audio")
}
info, ok := aiCfg.AudioRoutes[routeID]
if !ok {
return nil, fmt.Errorf(
"音频路由 %q(agent %q)未在 ai_config.json 的 audio_routes 中定义;语音转写不回退默认 chat 路由",
routeID, agentOrRouteID)
}
return buildRouteConfig(routeID, info, "audio")
}
// GetRouteForCategory 按分类解析路由 id。
//
// 存在的唯一理由:audio 必须走 GetAudioRoute。GetRoute 找不到路由时会回退
// default_route,那条链是为 chat 设计的 —— 拿它解析音频 id,写错一个字母就会
// 静默返回一条 chat 路由,而症状是「拿 /chat/completions 去打 ASR 服务」,
// 报错指向模型或密钥,与真实原因(路由配错)毫无关系。
//
// 一处实现,三个调用方(auto 选路的兜底、路由列表的 auto 项、健康探测的过滤),
// 各写一份必然走偏。
func GetRouteForCategory(routeID, category string) (*RouteConfig, error) {
if category == "audio" {
return GetAudioRoute(routeID)
}
return GetRoute(routeID)
}
// GetDeclaredAudioRoute 取「按配置声明该用的」那条音频路由,**不看健康状态**。
//
// 与 GetAudioRoute 的区别只有这一点,但它是必需的:audio_transcribe 现在指向
// auto,而 auto 会按此刻哪台服务活着挑一条。对「真的去转写」这是对的;对
// 「这次转写有没有说话人标签」这种**能力问题**就不行了 —— 第 1 步(确认范围)、
// 第 2 步(转写)、第 5/6 步(闸门)是三次独立解析,中间隔着几分钟,
// 用 auto 会出现「第 1 步说要区分说话人、第 2 步落到一条不区分的路由」,
// 或者更糟:闸门以为没有标签而放行,模型猜的身份就这样进了正式纪要。
//
// 声明值(default_audio_route)是这一问的权威答案,而且它不随进程启停变化。
// 配套约束见 route_health.go 的 resolveAutoRoute:auto 只在与声明路由**同样的
// 说话人能力**的候选里挑,所以「声明值」和「实际跑的那条」在这个维度上恒等。
//
// 没声明(留空或写成 auto)时退回 GetAudioRoute —— 此时确实没有声明可依。
func GetDeclaredAudioRoute() (*RouteConfig, error) {
aiCfg, err := LoadAIConfig()
if err != nil {
return nil, fmt.Errorf("AI 配置加载失败: %w", err)
}
id := strings.TrimSpace(aiCfg.DefaultAudioRoute)
if id == "" || id == AutoAudioRouteID {
return GetAudioRoute("audio_transcribe")
}
return GetAudioRoute(id)
}
// GetFallbackAudioRoutes 取音频路由的回退链(主路由不可用时依次尝试)。
//
// 为什么不用 GetFallbackRoutes:它内部用 GetRoute(fid),而 GetRoute 找不到时
// 会回退 default_route —— 一条拼错的 audio 回退 id 会被静默换成一条 chat 路由,
// 直到 TranscribeBytes 的分类检查才报错,报错文案指向「分类不对」而不是
// 「配置写错了」。这与 GetAudioRoute 当初存在的理由完全同构。
//
// 额外过滤掉**说话人能力不同**的候选:回退是为了「这条路由此刻不行」,
// 不是为了「悄悄换掉能力」。默认路由带说话人分离时退到一条不带的,
// 下游第 3 步会硬失败,而闸门还会因为读到 false 而放行。宁可不回退。
func GetFallbackAudioRoutes(primaryRouteID string) []*RouteConfig {
aiCfg, err := LoadAIConfig()
if err != nil {
return nil
}
primary, err := GetAudioRoute(primaryRouteID)
if err != nil {
return nil
}
var result []*RouteConfig
for _, fid := range aiCfg.FallbackRoutes[primaryRouteID] {
r, err := GetAudioRoute(fid)
if err != nil {
continue
}
if r.SupportsSpeakers != primary.SupportsSpeakers {
continue
}
result = append(result, r)
}
return result
}
// buildRouteConfig 把 RouteInfo 组装成 RouteConfig,并注入 base_url / api_key。
func buildRouteConfig(routeID string, info RouteInfo, category string) (*RouteConfig, error) {
baseURL := ""
apiKey := ""
secrets, _ := LoadAISecrets()
@@ -353,20 +548,21 @@ func GetRoute(agentOrRouteID string) (*RouteConfig, error) {
fullURL := strings.TrimRight(baseURL, "/") + info.Endpoint
rc := &RouteConfig{
RouteID: routeID,
Provider: info.Provider,
Model: info.Model,
BaseURL: baseURL,
Endpoint: info.Endpoint,
FullURL: fullURL,
APIKey: apiKey,
MaxTokens: info.MaxTokens,
Temperature: info.Temperature,
TimeoutSeconds: info.TimeoutSeconds,
Category: category,
Description: info.Description,
ShortRouteName: info.ShortRouteName,
ShortModelName: info.ShortModelName,
RouteID: routeID,
Provider: info.Provider,
Model: info.Model,
BaseURL: baseURL,
Endpoint: info.Endpoint,
FullURL: fullURL,
APIKey: apiKey,
MaxTokens: info.MaxTokens,
Temperature: info.Temperature,
TimeoutSeconds: info.TimeoutSeconds,
Category: category,
Description: info.Description,
ShortRouteName: info.ShortRouteName,
ShortModelName: info.ShortModelName,
SupportsSpeakers: info.SupportsSpeakers,
}
if rc.MaxTokens <= 0 {
rc.MaxTokens = 2048
@@ -399,6 +595,11 @@ func findRoute(cfg *AIConfig, routeID string) (RouteInfo, string, bool) {
return info, "video", true
}
}
if cfg.AudioRoutes != nil {
if info, ok := cfg.AudioRoutes[routeID]; ok {
return info, "audio", true
}
}
// 兼容旧版平铺 routes
if cfg.Routes != nil {
if info, ok := cfg.Routes[routeID]; ok {
@@ -415,8 +616,8 @@ func getSecretByField(s *AISecrets, field string) string {
return s.VECTORENGINE_API_KEY
case "OPENROUTER_API_KEY":
return s.OPENROUTER_API_KEY
case "SILICONFLOW_API_KEY":
return s.SILICONFLOW_API_KEY
case "SILICONFLOW_API_KEY":
return s.SILICONFLOW_API_KEY
case "VOLCES_API_KEY":
return s.VOLCES_API_KEY
case "LMUAI_API_KEY":
@@ -480,6 +681,8 @@ func GetRoutesByCategory(category string) ([]*RouteConfig, error) {
routeMap = aiCfg.ImageRoutes
case "video":
routeMap = aiCfg.VideoRoutes
case "audio":
routeMap = aiCfg.AudioRoutes
default:
return nil, fmt.Errorf("未知路由分类: %s", category)
}
@@ -492,7 +695,15 @@ func GetRoutesByCategory(category string) ([]*RouteConfig, error) {
var result []*RouteConfig
for _, rid := range keys {
r, err := GetRoute(rid)
// audio 走 GetAudioRoute:GetRoute 找不到时会回退 default_route,
// 那条链是为 chat 设计的,会把一条 chat 路由当成音频路由返回。
var r *RouteConfig
var err error
if category == "audio" {
r, err = GetAudioRoute(rid)
} else {
r, err = GetRoute(rid)
}
if err != nil {
continue
}
@@ -15,11 +15,25 @@ import (
const (
AutoChatRouteID = "chat_route_auto"
AutoEmbedRouteID = "embed_route_auto"
AutoAudioRouteID = "audio_route_auto"
defaultAIRouteProbeInterval = 30 * time.Minute
defaultAIRouteProbeTimeout = 20 * time.Second
)
// IsAutoRouteID 判断这个 id 是不是「自动选择」占位符。
//
// 它解析出来的永远**不是**自己:resolveAutoRoute 会换成此刻健康的那条具体路由。
// 于是任何「解析结果应当等于请求的 id」的校验都必须先放行这一类 id,
// 否则通路测试里选「自动」就会报「路由不存在」。
func IsAutoRouteID(routeID string) bool {
switch routeID {
case AutoChatRouteID, AutoEmbedRouteID, AutoAudioRouteID:
return true
}
return false
}
type RouteHealth struct {
AIRouteID string `json:"ai_route_id"`
Category string `json:"category"`
@@ -55,6 +69,7 @@ func StartAIRouteHealthLoop(interval time.Duration) {
func RefreshAIRouteHealthNow() {
refreshAIRouteHealthForCategory("chat")
refreshAIRouteHealthForCategory("embed")
refreshAIRouteHealthForCategory("audio")
}
func GetRouteHealth(routeID string) (RouteHealth, bool) {
@@ -77,12 +92,35 @@ func resolveAutoRoute(category string) (*RouteConfig, error) {
}
defaultRouteID := getDefaultRouteIDForCategory(category)
// audio 的能力约束:只在**说话人能力与声明路由相同**的候选里挑。
//
// 默认路由(本地 whisper+pyannote)带说话人分离,而它一旦不健康,
// 现有排序会把候选里唯一还活着的 audio_route_siliconflow_qwen3 选出来 ——
// 那条不支持说话人分离。这不是「降级可用」:下游第 3 步会硬失败,
// 而闸门读到 supports_speakers=false 后直接放行,模型猜的身份就进了正式纪要。
// 宁可这次转写失败(用户看得见,可以去修本地服务),也不要静默换掉能力。
if category == "audio" && defaultRouteID != "" {
if declared, err := GetAudioRoute(defaultRouteID); err == nil && declared != nil {
kept := routes[:0:0]
for _, r := range routes {
if r != nil && r.SupportsSpeakers == declared.SupportsSpeakers {
kept = append(kept, r)
}
}
if len(kept) > 0 {
routes = kept
}
}
}
best := pickBestHealthyRoute(routes, defaultRouteID)
if best != nil {
return best, nil
}
if defaultRouteID != "" {
if route, err := GetRoute(defaultRouteID); err == nil && route != nil {
// 按分类取:GetRoute 对 audio 会回退到 chat 的 default_route,
// 于是「音频路由」会变成一条 chat 路由返回给调用方。
if route, err := GetRouteForCategory(defaultRouteID, category); err == nil && route != nil {
return route, nil
}
}
@@ -180,6 +218,13 @@ func getDefaultRouteIDForCategory(category string) string {
if aiCfg.DefaultEmbedRoute != AutoEmbedRouteID {
return aiCfg.DefaultEmbedRoute
}
case "audio":
// 「优先本地路由」就是靠这一支实现的:default_audio_route 出厂指向本地,
// pickBestHealthyRoute 先按 isDefault 排序,本地健康就赢;本地不健康时才
// 轮到云端。不新造机制,与 chat/embed 同一套。
if aiCfg.DefaultAudioRoute != AutoAudioRouteID {
return aiCfg.DefaultAudioRoute
}
}
return ""
}
@@ -203,7 +248,7 @@ func probeRoute(route *RouteConfig) RouteHealth {
status.LastError = "base_url 未配置"
return status
}
if requiresRouteAPIKey(route) && strings.TrimSpace(route.APIKey) == "" {
if RequiresRouteAPIKey(route) && strings.TrimSpace(route.APIKey) == "" {
status.LastError = "API Key 未配置"
return status
}
@@ -213,6 +258,8 @@ func probeRoute(route *RouteConfig) RouteHealth {
switch route.Category {
case "embed":
err = probeEmbedRoute(client, route)
case "audio":
err = probeAudioRoute(client, route)
default:
err = probeChatRoute(client, route)
}
@@ -268,6 +315,55 @@ func probeEmbedRoute(client *http.Client, route *RouteConfig) error {
return nil
}
// probeAudioRoute 探 ASR 路由。
//
// 探的是 `GET {base_url}/models`,**不是**「发一小段音频试转」:转写要跑 GPU、
// 分钟级、在云端还计费,30 分钟一轮的巡检绝不能这么干。/v1/models 是 OpenAI
// 兼容服务的标准发现端点(本地 serve.py 与云端中转都实现了),几毫秒就回来。
//
// 404/405 不算失败:那只说明这个服务没实现 /v1/models,不代表它不能转写。
// 此时只断言「连得上」,并在注释里说清楚这一步验到哪为止 —— 宁可弱一点,
// 也不能因为探测手段缺失把一条好路由判死。判死的代价是回退链上少一个候选:
// 本机 ASR 一挂,就再没有云端可退了(这正是「自动回退云端」要保住的东西)。
func probeAudioRoute(client *http.Client, route *RouteConfig) error {
if strings.TrimSpace(route.BaseURL) == "" {
return fmt.Errorf("base_url 未配置")
}
url := strings.TrimRight(strings.TrimSpace(route.BaseURL), "/") + "/models"
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
if strings.TrimSpace(route.APIKey) != "" {
req.Header.Set("Authorization", "Bearer "+route.APIKey)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("服务不可达: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("读取响应失败: %w", err)
}
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed {
// 只验到「连得上」,没验转写能力。不撒谎说探过了。
return nil
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("返回 %d: %s", resp.StatusCode, truncateProbeText(string(data), 160))
}
// 200 必须是 JSON:公网门户/劫持页也会回 200,回 HTML 就说明没打到真服务。
var out struct {
Data []json.RawMessage `json:"data"`
}
if err := json.Unmarshal(data, &out); err != nil {
return fmt.Errorf("响应不是 JSON: %w", err)
}
return nil
}
func doRouteProbeRequest(client *http.Client, route *RouteConfig, body any, out any) error {
raw, err := json.Marshal(body)
if err != nil {
@@ -300,10 +396,26 @@ func doRouteProbeRequest(client *http.Client, route *RouteConfig, body any, out
return nil
}
func requiresRouteAPIKey(route *RouteConfig) bool {
// RequiresRouteAPIKey 这条路由是否必须带密钥(探测与调用共用同一个判据)。
//
// 回环路由直接免检:本机服务(本地 ASR、本地 llama.cpp)绑在 127.0.0.1 上,
// 本来就不对外,要密钥是无意义的门槛。判据与转写侧共用 IsLocalRoute,
// 不各写一份 —— 两份判定走偏时的症状是「探测说健康、转写却报缺密钥」。
//
// 注意**没有**改成「非本地一律要密钥」:那会顺带把 siliconflow / volces 这些
// 本来不检查的路由也变成必须配密钥,属于本次改动之外的回归。
//
// 导出是因为 routetest 与 ai/llm 各抄了一份同样的逻辑。三份在今天的配置上
// 恰好同结论(本地路由的 provider 不是 openai/openrouter),但只有这一份知道
// 「回环地址免密钥」——另两份一旦需要这条规则就得再改一遍,漏改的那份会以
// 「测试说缺密钥、真实调用却通」的形式表现,最难查。
func RequiresRouteAPIKey(route *RouteConfig) bool {
if route == nil {
return false
}
if IsLocalRoute(route) {
return false
}
baseURL := strings.ToLower(strings.TrimSpace(route.BaseURL))
if strings.Contains(baseURL, "openrouter.ai") || strings.Contains(baseURL, "openai.com") {
return true
@@ -0,0 +1,77 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// TestValidateAIConfigRejectsBrokenAudioRoutes 钉住「配错的路由在启动时就报错」。
//
// 为什么值得钉:这两种写错都**不会**在运行期报出像样的错。
// - default_audio_route 拼错:auto 会退化成按延迟挑一条,「优先本地」无声失效;
// - audio_transcribe 指到 chat 路由:拿 ASR 请求打聊天接口,报错指向模型或密钥。
//
// GetRoute 的兜底回退(找不到就返回 default_route)本来是这两个的帮凶 ——
// 它让「一条拼错的音频路由」能被一条 chat 路由校验通过。
//
// 在临时目录里改一份配置副本,不碰线上那份(与 audio_route_test.go 的 TestMain
// 配合:它已把 CWD 切到 backend-go,这里靠 Chdir + ResetCache 换到临时配置)。
func TestValidateAIConfigRejectsBrokenAudioRoutes(t *testing.T) {
real, err := os.ReadFile("config/ai_config.json")
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
mutate func(map[string]any)
wantErr bool
}{
{"default_audio_route 拼错", func(m map[string]any) {
m["default_audio_route"] = "audio_route_local_whispper"
}, true},
{"agent audio_transcribe 指向 chat", func(m map[string]any) {
m["agent_routes"].(map[string]any)["audio_transcribe"] = "chat_route_lmuai_deepseek_v4_flash"
}, true},
{"原样", func(map[string]any) {}, false},
} {
t.Run(tc.name, func(t *testing.T) {
var m map[string]any
if err := json.Unmarshal(real, &m); err != nil {
t.Fatal(err)
}
tc.mutate(m)
raw, _ := json.Marshal(m)
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "config"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "config", "ai_config.json"), raw, 0o644); err != nil {
t.Fatal(err)
}
// ai_secrets.json 也带上,否则云端路由会被判成「缺密钥」
sec, _ := os.ReadFile("config/ai_secrets.json")
_ = os.WriteFile(filepath.Join(dir, "config", "ai_secrets.json"), sec, 0o644)
prev, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
ResetCache()
got := ValidateAIConfig()
_ = os.Chdir(prev)
ResetCache()
os.RemoveAll(dir)
t.Logf("%s → err=%v", tc.name, got)
if tc.wantErr && got == nil {
t.Errorf("期望报错,却校验通过")
}
if !tc.wantErr && got != nil {
t.Errorf("期望通过,却报错:%v", got)
}
})
}
}