Merge pull request #73 from Autumn-27/fix/llm-test-verify-reply

fix(llm): 测试连接校验模型确有回复,并按配置的收发模式测
This commit is contained in:
Autumn authored and GitHub committed 2026-08-27 23:16:52 +08:00
commit 6b6b56e0a7
5 files changed
+62 -11

No files matched your search

+17 -5
View File
@@ -357,11 +357,12 @@ func quotaAwareHTTPClient(proxy string) (*http.Client, error) {
}
// TestConnection makes a minimal real completion to verify the provider/model/
// endpoint/key actually work. Returns the round-trip latency.
func TestConnection(ctx context.Context, c Config) (time.Duration, error) {
// endpoint/key actually work. Returns the round-trip latency and the model's
// reply text.
func TestConnection(ctx context.Context, c Config) (time.Duration, string, error) {
prov, err := c.NewProvider()
if err != nil {
return 0, err
return 0, "", err
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
@@ -371,7 +372,7 @@ func TestConnection(ctx context.Context, c Config) (time.Duration, error) {
// 就撞到输出上限(finish=length)、被截断,连接测试虽仍算通(err=nil)但显示成
// "已中断/length/resume" 一团糟。给足预算让它把 OK 干净吐完(finish=stop)。
// EscalateMaxTokens 保持 false:不因截断而抬额重试,避免 resume 循环空烧。
_, err = agentcore.Run(ctx, agentcore.Options{
reply, err := agentcore.Run(ctx, agentcore.Options{
Provider: prov,
SystemPrompt: []string{"你是连接测试。直接输出两个字符 OK 即可,不要思考、不要解释、不要别的。"},
PermissionMode: acperm.ModeBypass,
@@ -379,5 +380,16 @@ func TestConnection(ctx context.Context, c Config) (time.Duration, error) {
MaxTokens: 8192,
NonStreaming: !c.Stream, // 用该 profile 的真实收发模式做连接测试
}, "ping")
return time.Since(start), err
lat := time.Since(start)
if err != nil {
return lat, "", err
}
// err==nil 还不够:请求通了但模型一个字都不吐的情况真实存在(思考把预算烧光、
// 正文被安全策略吞掉、兼容层把 content 丢了)。这种配置在会话里就是"不回话",
// 测试却报成功——正是本项要消除的落差。没有可见正文一律判失败。
reply = strings.TrimSpace(reply)
if reply == "" {
return lat, "", fmt.Errorf("模型无回复内容(请求已通,但未返回任何文本)")
}
return lat, reply, nil
}
+23 -2
View File
@@ -1337,6 +1337,7 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
ThinkingType string `json:"thinking_type"`
ReasoningEffort string `json:"reasoning_effort"`
ProfileID *int64 `json:"profile_id"` // 测已存 profile 时传入:api_key 为空则用它存的 key
Streaming *bool `json:"streaming"` // 省略=流式,与保存 profile 时同一套默认
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, 400, err.Error())
@@ -1347,6 +1348,11 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
// reasoning_effort/thinking field fails the test too (no false "test ok, run 400").
cfg.ThinkingType = req.ThinkingType
cfg.ReasoningEffort = req.ReasoningEffort
// 同理,收发模式也照该 profile 的选择来:只支持其中一种通道的端点必须在这里就
// 暴露,而不是等会话里才发现"测试通过的配置根本跑不动"。
if req.Streaming != nil {
cfg.Stream = *req.Streaming
}
// API Key 解析优先级:表单输入 > 指定 profile 存的 key > 全局配置的 key。
// 已存 profile 的 key 不回传浏览器,所以测试已存配置时表单为空,需从 DB 取。
if cfg.APIKey == "" && req.ProfileID != nil {
@@ -1363,12 +1369,27 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"ok": false, "error": "未提供 API Key"})
return
}
lat, err := agent.TestConnection(r.Context(), cfg)
lat, reply, err := agent.TestConnection(r.Context(), cfg)
if err != nil {
writeJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
return
}
writeJSON(w, 200, map[string]any{"ok": true, "latency_ms": lat.Milliseconds(), "model": cfg.Model})
// 回传模型实际回复,让"测试通过"有据可查:看得见它确实说了话,而不只是 HTTP 200。
writeJSON(w, 200, map[string]any{
"ok": true, "latency_ms": lat.Milliseconds(), "model": cfg.Model, "reply": truncateReply(reply),
})
}
// truncateReply clips a connection-test reply for display. A model told to answer
// "OK" can still ramble (or think out loud); the UI only needs enough to show it
// really said something. Rune-based so multibyte text never splits mid-character.
func truncateReply(s string) string {
const max = 200
r := []rune(s)
if len(r) <= max {
return s
}
return string(r[:max]) + "…"
}
type createTaskReq struct {
+16 -2
View File
@@ -369,8 +369,22 @@ function ProfileSheet({
try {
// 用配置实际会跑的思考参数来测,这样不支持该字段的模型在这里就失败,
// 而不是等到跑任务时才炸。传 profile id:Key 输入框留空时用已存的 Key。
const r = await api.testLLM(format, model, baseUrl, apiKey, proxy, toStore(thinkingType), toStore(effort), profileId);
if (r.ok) toast.success(`连接成功 · ${r.latency_ms ?? "?"}ms · ${r.model ?? model}`);
const r = await api.testLLM(
format,
model,
baseUrl,
apiKey,
proxy,
toStore(thinkingType),
toStore(effort),
profileId,
streaming,
);
// 回复内容一并展示:看得见模型确实说了话,才算和会话里跑通是一回事。
if (r.ok)
toast.success(`连接成功 · ${r.latency_ms ?? "?"}ms · ${r.model ?? model}`, {
description: r.reply ? `回复:${r.reply}` : undefined,
});
else toast.error(`连接失败:${r.error ?? "未知"}`);
} catch (e) {
toast.error(`测试出错:${(e as Error).message}`);
+4 -1
View File
@@ -646,8 +646,10 @@ export const api = {
thinking_type = "",
reasoning_effort = "",
profile_id?: number,
streaming = true, // 用该配置真实的收发模式来测,别让"流式能通、非流式不通"漏到会话里
) =>
post<{ ok: boolean; error?: string; latency_ms?: number; model?: string }>("/llm/test", {
// reply = 模型实际回复(已截断);一个字都不回的配置后端直接判失败
post<{ ok: boolean; error?: string; latency_ms?: number; model?: string; reply?: string }>("/llm/test", {
provider,
model,
base_url,
@@ -656,6 +658,7 @@ export const api = {
thinking_type,
reasoning_effort,
profile_id,
streaming,
}),
llmProfiles: () => get<{ profiles: LLMProfile[] }>("/llm/profiles").then((r) => arr(r.profiles)),
saveLLMProfile: (p: {
+2 -1
View File
@@ -1155,7 +1155,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
}
if (path === "/llm" && m === "GET") return D.llmConfig;
if (path === "/llm" && m === "POST") return { ok: true };
if (path === "/llm/test") return { ok: true, latency_ms: 128, model: String(b.model ?? "claude-opus-4-8") };
if (path === "/llm/test")
return { ok: true, latency_ms: 128, model: String(b.model ?? "claude-opus-4-8"), reply: "OK" };
if (path === "/llm/profiles" && m === "GET") return { profiles: D.llmProfiles };
if (path === "/llm/profiles" && m === "POST") return { id: Number(b.id) || 3 };
if (path === "/llm/profiles/active") return { ok: true };