mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +08:00
Merge pull request #73 from Autumn-27/fix/llm-test-verify-reply
fix(llm): 测试连接校验模型确有回复,并按配置的收发模式测
This commit is contained in:
5 files changed
+62
-11
No files matched your search
+17
-5
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in new issue
Block a user