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:
@@ -0,0 +1,253 @@
|
||||
package routetest
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"eai_agentplatform/backend/internal/config"
|
||||
)
|
||||
|
||||
// 这一组用例钉的是语音路由的通路测试。
|
||||
//
|
||||
// 背景:routetest 原先按 Category 分发,但只认 chat/embed/image/video,
|
||||
// audio 落到 default,于是「测试语音路由」会把一个 /chat/completions 请求
|
||||
// 打到 ASR 服务上。报回来的错(模型不存在、参数不合法)与真实原因
|
||||
// (走错接口)毫无关系,而这个红叉会让人去改一条本来没问题的路由。
|
||||
//
|
||||
// 所以第一条断言就是「打到了转写终点」——不是「没报错」。
|
||||
|
||||
// audioStub 起一个桩,记录收到的请求,能同时应答转写与 chat 两种终点。
|
||||
type audioStub struct {
|
||||
srv *httptest.Server
|
||||
audioCalls int
|
||||
chatCalls int
|
||||
lastPath string
|
||||
lastAuth string
|
||||
lastCt string
|
||||
lastFormFile string
|
||||
audioStatus int
|
||||
}
|
||||
|
||||
func newAudioStub(t *testing.T) *audioStub {
|
||||
t.Helper()
|
||||
s := &audioStub{audioStatus: http.StatusOK}
|
||||
s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
// 按后缀认转写终点,这样用例可以换一个 Endpoint 验证它确实来自配置。
|
||||
case strings.HasSuffix(r.URL.Path, "/transcriptions"):
|
||||
s.audioCalls++
|
||||
s.lastPath = r.URL.Path
|
||||
s.lastAuth = r.Header.Get("Authorization")
|
||||
s.lastCt = r.Header.Get("Content-Type")
|
||||
s.lastFormFile = formFileName(r)
|
||||
if s.audioStatus != http.StatusOK {
|
||||
w.WriteHeader(s.audioStatus)
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"no speech detected"}}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"text":"","segments":[],"duration":1.0}`))
|
||||
case strings.HasSuffix(r.URL.Path, "/chat/completions"):
|
||||
s.chatCalls++
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"pong"}}]}`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"error":"unexpected path"}`))
|
||||
}
|
||||
}))
|
||||
t.Cleanup(s.srv.Close)
|
||||
return s
|
||||
}
|
||||
|
||||
// formFileName 从 multipart 请求里取出文件部件的文件名。
|
||||
func formFileName(r *http.Request) string {
|
||||
mr, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if part.FormName() == "file" {
|
||||
return part.FileName()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// audioRoute 造一条指向桩的语音路由(回环地址 → IsLocalRoute 判为本地)。
|
||||
func audioRoute(s *audioStub) *config.RouteConfig {
|
||||
return &config.RouteConfig{
|
||||
RouteID: "audio_route_stub",
|
||||
Provider: "local_asr",
|
||||
Category: "audio",
|
||||
BaseURL: s.srv.URL,
|
||||
Endpoint: "/audio/transcriptions",
|
||||
FullURL: s.srv.URL + "/audio/transcriptions",
|
||||
Model: "large-v3",
|
||||
TimeoutSeconds: 30,
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudioRouteTestHitsTranscriptionsEndpoint 是最重要的一条:
|
||||
// audio 必须打到转写终点,绝不能落到 chat 的 default 分支。
|
||||
func TestAudioRouteTestHitsTranscriptionsEndpoint(t *testing.T) {
|
||||
stub := newAudioStub(t)
|
||||
res := Do(audioRoute(stub), &TestOptions{})
|
||||
|
||||
if stub.chatCalls != 0 {
|
||||
t.Errorf("语音路由被打了一个 chat 请求(%d 次)—— audio 落到了 default 分支", stub.chatCalls)
|
||||
}
|
||||
if stub.audioCalls != 1 {
|
||||
t.Fatalf("转写终点被打了 %d 次,期望 1 次", stub.audioCalls)
|
||||
}
|
||||
if !res.Succeeded || len(res.Items) != 1 || !res.Items[0].Ok {
|
||||
t.Fatalf("期望测试成功,实际 %+v", res.Items)
|
||||
}
|
||||
if !strings.HasPrefix(stub.lastCt, "multipart/form-data") {
|
||||
t.Errorf("Content-Type = %q,期望 multipart/form-data", stub.lastCt)
|
||||
}
|
||||
if stub.lastFormFile != "silence-1s.wav" {
|
||||
t.Errorf("文件部件名 = %q,期望 silence-1s.wav", stub.lastFormFile)
|
||||
}
|
||||
if !strings.Contains(res.Items[0].Summary, "静音") {
|
||||
t.Errorf("摘要没说明样本是静音,会被读成「转写质量已验证」:%q", res.Items[0].Summary)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudioRouteTestUsesConfiguredEndpoint 终点取配置里的 Endpoint。
|
||||
// 写死 /audio/transcriptions 会让这个可配置字段失效(云端中转路径未必一致)。
|
||||
func TestAudioRouteTestUsesConfiguredEndpoint(t *testing.T) {
|
||||
stub := newAudioStub(t)
|
||||
route := audioRoute(stub)
|
||||
route.Endpoint = "/v1/custom/transcriptions"
|
||||
|
||||
res := Do(route, &TestOptions{})
|
||||
if !res.Items[0].Ok {
|
||||
t.Fatalf("按 Endpoint 拼接的请求应当成功:%s", res.Items[0].Error)
|
||||
}
|
||||
if stub.lastPath != "/v1/custom/transcriptions" {
|
||||
t.Errorf("打到了 %q —— 终点没有取自配置的 Endpoint", stub.lastPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudioRouteTestSendsBearerForLocalRoute 本地服务没有密钥也要带鉴权头。
|
||||
//
|
||||
// serve.py 不校验密钥的值,但收不到 Authorization 就回 401。通路测试若跳过这个头,
|
||||
// 本地 ASR 会稳定测红 —— 而真实转写是通的(transcribe.go 里就写死 "local")。
|
||||
func TestAudioRouteTestSendsBearerForLocalRoute(t *testing.T) {
|
||||
stub := newAudioStub(t)
|
||||
route := audioRoute(stub)
|
||||
if route.APIKey != "" {
|
||||
t.Fatal("用例前提:这条路由没有密钥")
|
||||
}
|
||||
res := Do(route, &TestOptions{})
|
||||
|
||||
if !res.Items[0].Ok {
|
||||
t.Fatalf("本地无密钥路由应当能测通,实际:%s", res.Items[0].Error)
|
||||
}
|
||||
if stub.lastAuth != "Bearer local" {
|
||||
t.Errorf("Authorization = %q,期望 \"Bearer local\"", stub.lastAuth)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudioRouteTestLocalWinsOverProviderName 是「回环地址免密钥」这条规则
|
||||
// 在通路测试里也生效的证据 —— 也正是三份 requiresAPIKey 副本会走偏的那一格。
|
||||
//
|
||||
// 场景:内网有人用 OpenAI 兼容的壳把本地 ASR 包了一层,provider 于是写成 openai,
|
||||
// base_url 仍是 127.0.0.1。判据若看 provider 名(旧副本就是这么写的),
|
||||
// 就会报「API Key 未配置」;而真实调用根本不需要密钥。
|
||||
// 判据应当是「流量去哪」——回环地址就是不需要。
|
||||
func TestAudioRouteTestLocalWinsOverProviderName(t *testing.T) {
|
||||
stub := newAudioStub(t)
|
||||
route := audioRoute(stub)
|
||||
route.Provider = "openai" // 名字像云端,地址是回环
|
||||
res := Do(route, &TestOptions{})
|
||||
|
||||
if !res.Items[0].Ok {
|
||||
t.Fatalf("回环地址的路由不该因为 provider 名像云端就被要求密钥:%s", res.Items[0].Error)
|
||||
}
|
||||
if stub.audioCalls != 1 {
|
||||
t.Errorf("转写终点被打了 %d 次,期望 1 次", stub.audioCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudioRouteTestTreatsEmptyTextAsSuccess 静音样本转出空文本是**预期**,不是失败。
|
||||
//
|
||||
// 判成功只看「200 + 合法 JSON」。把空文本当失败,一条完全正常的本地 whisper
|
||||
// 路由(静音就返回 text="")会被测红,然后有人去改配置。
|
||||
func TestAudioRouteTestTreatsEmptyTextAsSuccess(t *testing.T) {
|
||||
stub := newAudioStub(t)
|
||||
res := Do(audioRoute(stub), &TestOptions{})
|
||||
|
||||
item := res.Items[0]
|
||||
if !item.Ok {
|
||||
t.Fatalf("空文本被当成了失败:%s", item.Error)
|
||||
}
|
||||
if !strings.Contains(item.OutputPreview, "静音样本") {
|
||||
t.Errorf("产物预览应说明空文本是静音所致,实际:%q", item.OutputPreview)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAudioRouteTestExplainsClientRejection 4xx 的报错要说清「可能是静音被拒」。
|
||||
// 否则读的人只会看到「返回 400」,然后把一条好路由当成坏的。
|
||||
func TestAudioRouteTestExplainsClientRejection(t *testing.T) {
|
||||
stub := newAudioStub(t)
|
||||
stub.audioStatus = http.StatusBadRequest
|
||||
res := Do(audioRoute(stub), &TestOptions{})
|
||||
|
||||
item := res.Items[0]
|
||||
if item.Ok {
|
||||
t.Fatal("400 不该算通过")
|
||||
}
|
||||
if !strings.Contains(item.Error, "静音") || !strings.Contains(item.Error, "不代表路由不可用") {
|
||||
t.Errorf("4xx 的报错没解释静音这一层:%q", item.Error)
|
||||
}
|
||||
if item.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("StatusCode = %d,期望 400", item.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSilentWAVIsWellFormed WAV 头写错的话服务端多半直接 400,
|
||||
// 上面那些用例会以「4xx」的形式红,而不会告诉你「样本本身不合法」。
|
||||
func TestSilentWAVIsWellFormed(t *testing.T) {
|
||||
const dataBytes = 16000 * 2 // 1 秒 × 16kHz × 16bit
|
||||
data := silentWAV(1000)
|
||||
if len(data) != 44+dataBytes {
|
||||
t.Errorf("长度 = %d,期望 %d(44 字节头 + 1 秒采样)", len(data), 44+dataBytes)
|
||||
}
|
||||
if string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" || string(data[36:40]) != "data" {
|
||||
t.Fatalf("RIFF/WAVE/data 标识不对:%q %q %q", data[0:4], data[8:12], data[36:40])
|
||||
}
|
||||
le := func(off int, n int) uint32 {
|
||||
var v uint32
|
||||
for i := 0; i < n; i++ {
|
||||
v |= uint32(data[off+i]) << (8 * i)
|
||||
}
|
||||
return v
|
||||
}
|
||||
if got := le(24, 4); got != 16000 {
|
||||
t.Errorf("采样率 = %d,期望 16000", got)
|
||||
}
|
||||
if got := le(22, 2); got != 1 {
|
||||
t.Errorf("声道数 = %d,期望 1(单声道)", got)
|
||||
}
|
||||
if got := le(34, 2); got != 16 {
|
||||
t.Errorf("位深 = %d,期望 16", got)
|
||||
}
|
||||
if got := le(40, 4); got != dataBytes {
|
||||
t.Errorf("data 块长度 = %d,期望 %d", got, dataBytes)
|
||||
}
|
||||
if got := le(4, 4); got != uint32(36+dataBytes) {
|
||||
t.Errorf("RIFF 长度 = %d,期望 %d", got, 36+dataBytes)
|
||||
}
|
||||
// 采样必须全零,否则「静音样本」这个名字就是假的。
|
||||
for i, b := range data[44:] {
|
||||
if b != 0 {
|
||||
t.Fatalf("第 %d 个采样字节非零 —— 这不是静音样本", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,11 @@ package routetest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -114,7 +116,10 @@ func testOne(route *config.RouteConfig, model string, prompt, size string) Resul
|
||||
item.Error = "base_url 未配置"
|
||||
return item
|
||||
}
|
||||
if requiresAPIKey(route) && strings.TrimSpace(route.APIKey) == "" {
|
||||
// 判据与健康探测、真实调用共用 config 的那一份:本包原先自己抄了一遍。
|
||||
// 两份在今天的配置上恰好同结论,但只有 config 那份知道「回环地址免密钥」;
|
||||
// 抄出来的第二份一旦漏跟,症状是「通路测试说缺密钥,真实调用却是通的」。
|
||||
if config.RequiresRouteAPIKey(route) && strings.TrimSpace(route.APIKey) == "" {
|
||||
item.Error = "API Key 未配置"
|
||||
return item
|
||||
}
|
||||
@@ -127,6 +132,11 @@ func testOne(route *config.RouteConfig, model string, prompt, size string) Resul
|
||||
testVideo(route, model, prompt, &item)
|
||||
case "embed":
|
||||
testEmbed(route, model, prompt, &item)
|
||||
case "audio":
|
||||
// 必须单独一支:audio 的终点是 /audio/transcriptions、请求体是 multipart,
|
||||
// 落到 default 会把一个 chat 请求打到 ASR 服务上,报回来的错
|
||||
// (模型不存在 / 参数不对)与真实原因(走错接口)毫无关系。
|
||||
testAudio(route, model, prompt, &item)
|
||||
default:
|
||||
testChat(route, model, prompt, &item)
|
||||
}
|
||||
@@ -374,6 +384,169 @@ func testVideo(route *config.RouteConfig, model, prompt string, item *ResultItem
|
||||
item.Error = "响应缺少 url / b64_json"
|
||||
}
|
||||
|
||||
// testAudio 语音转写类单模型测试。
|
||||
//
|
||||
// 拿一段**程序生成的静音 WAV** 去打真实的 /audio/transcriptions,验的是
|
||||
// 「这条路走不走得通」:终点对不对、密钥收不收、multipart 字段名对不对、
|
||||
// 返回的是不是转写那套结构。**不验转写质量** —— 静音样本本来就没什么可转的,
|
||||
// 把「没转出字」当失败会让一条好路由被测红。
|
||||
//
|
||||
// 所以判成功的标准只有一条:HTTP 200 且响应是合法 JSON。摘要里会写明
|
||||
// 「静音样本」,避免被读成「转写质量已验证」。
|
||||
//
|
||||
// 静音是现算的,不内嵌音频文件:P06.11 不允许引入第三方受限素材,
|
||||
// 而原型机的真实录音属于用户数据,更不能编进二进制。
|
||||
func testAudio(route *config.RouteConfig, model, prompt string, item *ResultItem) {
|
||||
// audio 的终点在配置里(route.Endpoint),不像 chat/embed/image 是固定路径。
|
||||
endpoint := strings.TrimSpace(route.Endpoint)
|
||||
if endpoint == "" {
|
||||
endpoint = "/audio/transcriptions"
|
||||
}
|
||||
sample := silentWAV(1000)
|
||||
item.RawRequest = fmt.Sprintf(
|
||||
`{"endpoint":%q,"model":%q,"file":"silence-1s.wav","bytes":%d,"note":"程序生成的静音样本,仅验证接口连通性"}`,
|
||||
endpoint, model, len(sample))
|
||||
|
||||
var out struct {
|
||||
Text string `json:"text"`
|
||||
Segments []struct {
|
||||
Speaker string `json:"speaker"`
|
||||
Text string `json:"text"`
|
||||
} `json:"segments"`
|
||||
Duration float64 `json:"duration"`
|
||||
Model string `json:"model"`
|
||||
Error any `json:"error"`
|
||||
}
|
||||
code, rawRes, err := httpPostFile(route, endpoint, "silence-1s.wav", sample, map[string]string{
|
||||
"model": model,
|
||||
}, &out)
|
||||
item.StatusCode = code
|
||||
item.RawResponse = truncate(rawRes, 4000)
|
||||
if err != nil {
|
||||
// 4xx 要单独说清楚:静音被拒是这一类接口的常见反应,不能让人以为
|
||||
// 路由坏了。真正的转写能力得用一份真实录音走一次语音转写技能才算数。
|
||||
if code >= 400 && code < 500 {
|
||||
item.Error = fmt.Sprintf("%v(样本是 1 秒静音,若服务方拒收无语音音频,此结果不代表路由不可用;"+
|
||||
"请用一份真实录音跑一次「语音转写」技能确认)", err)
|
||||
return
|
||||
}
|
||||
item.Error = err.Error()
|
||||
return
|
||||
}
|
||||
item.ModelEcho = firstNonEmpty(out.Model, model)
|
||||
item.Summary = fmt.Sprintf("转写接口应答正常(1 秒静音样本,返回 %d 段,未检验转写质量)", len(out.Segments))
|
||||
if strings.TrimSpace(out.Text) != "" {
|
||||
item.OutputPreview = truncate(out.Text, 2000)
|
||||
} else {
|
||||
item.OutputPreview = "(静音样本,文本为空属预期)"
|
||||
}
|
||||
}
|
||||
|
||||
// httpPostFile 发一次 multipart/form-data 的 POST。
|
||||
//
|
||||
// 鉴权头与真实转写**逐字对齐**:没有密钥时也要发 `Bearer local`。
|
||||
// 本地 serve.py 不校验密钥的值,但收不到这个头就回 401 —— 这里若图省事跳过,
|
||||
// 本地 ASR 在通路测试里会稳定报 401,而真实转写明明是通的。
|
||||
func httpPostFile(route *config.RouteConfig, path, filename string, content []byte, fields map[string]string, out any) (int, string, error) {
|
||||
buf := bytes.NewBuffer(nil)
|
||||
w := multipart.NewWriter(buf)
|
||||
for _, key := range sortedKeys(fields) {
|
||||
fw, err := w.CreateFormField(key)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("构建请求失败: %w", err)
|
||||
}
|
||||
if _, err := fw.Write([]byte(fields[key])); err != nil {
|
||||
return 0, "", fmt.Errorf("构建请求失败: %w", err)
|
||||
}
|
||||
}
|
||||
fw, err := w.CreateFormFile("file", filename)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("构建请求失败: %w", err)
|
||||
}
|
||||
if _, err := fw.Write(content); err != nil {
|
||||
return 0, "", fmt.Errorf("写入样本失败: %w", err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return 0, "", fmt.Errorf("构建请求失败: %w", err)
|
||||
}
|
||||
|
||||
fullURL := strings.TrimRight(route.BaseURL, "/") + path
|
||||
req, err := http.NewRequest(http.MethodPost, fullURL, buf)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("构造请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
authKey := route.APIKey
|
||||
if strings.TrimSpace(authKey) == "" {
|
||||
authKey = "local"
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+authKey)
|
||||
|
||||
client := &http.Client{Timeout: defaultTestTimeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("服务不可达: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return resp.StatusCode, "", fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
rawStr := string(data)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return resp.StatusCode, rawStr, fmt.Errorf("返回 %d: %s", resp.StatusCode, truncate(rawStr, 200))
|
||||
}
|
||||
if len(data) > 0 && out != nil {
|
||||
if err := json.Unmarshal(data, out); err != nil {
|
||||
return resp.StatusCode, rawStr, fmt.Errorf("响应解析失败: %w", err)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, rawStr, nil
|
||||
}
|
||||
|
||||
// silentWAV 生成一段 ms 毫秒的 16kHz 单声道 16bit 静音 WAV。
|
||||
// 标准 44 字节 RIFF 头 + 全零采样,任何 ASR 服务都认这个容器格式。
|
||||
func silentWAV(ms int) []byte {
|
||||
const sampleRate = 16000
|
||||
samples := sampleRate * ms / 1000
|
||||
dataSize := samples * 2
|
||||
|
||||
var buf bytes.Buffer
|
||||
write := func(v any) { _ = binary.Write(&buf, binary.LittleEndian, v) }
|
||||
buf.WriteString("RIFF")
|
||||
write(uint32(36 + dataSize))
|
||||
buf.WriteString("WAVEfmt ")
|
||||
write(uint32(16)) // fmt chunk 长度
|
||||
write(uint16(1)) // PCM
|
||||
write(uint16(1)) // 单声道
|
||||
write(uint32(sampleRate)) // 采样率
|
||||
write(uint32(sampleRate * 2)) // 字节率 = 采样率 × 声道 × 位深/8
|
||||
write(uint16(2)) // 块对齐 = 声道 × 位深/8
|
||||
write(uint16(16)) // 位深
|
||||
buf.WriteString("data")
|
||||
write(uint32(dataSize))
|
||||
buf.Write(make([]byte, dataSize))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]string) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DiscoverModels 拉取 provider 端 /models 获取可用模型列表(自动发现)。
|
||||
// 返回按字母排序的模型 ID 列表;不可达或失败时返回 nil + error。
|
||||
func DiscoverModels(route *config.RouteConfig) ([]string, error) {
|
||||
@@ -404,20 +577,6 @@ func MergeCandidates(known, discovered []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// requiresAPIKey 判断该路由是否必须携带 API Key(只有云端 provider 才强制)。
|
||||
// 本地服务(ollama / llamacpp 等)无需 key 也应能完成测试。
|
||||
func requiresAPIKey(route *config.RouteConfig) bool {
|
||||
if route == nil {
|
||||
return false
|
||||
}
|
||||
baseURL := strings.ToLower(strings.TrimSpace(route.BaseURL))
|
||||
if strings.Contains(baseURL, "openrouter.ai") || strings.Contains(baseURL, "openai.com") {
|
||||
return true
|
||||
}
|
||||
provider := strings.ToLower(strings.TrimSpace(route.Provider))
|
||||
return provider == "openrouter" || provider == "openai"
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if n <= 0 || len(s) <= n {
|
||||
|
||||
Reference in New Issue
Block a user