diff --git a/agent/provider.go b/agent/provider.go index 14b0ddd..42bc42f 100644 --- a/agent/provider.go +++ b/agent/provider.go @@ -466,6 +466,12 @@ func TestConnection(ctx context.Context, c Config) (time.Duration, string, error // context 里找到这个 Capture 并填入每次 HTTP 尝试的状态码与 body。 ctx, capt := llmrec.NewCapture(ctx) defer logTestConnection(c, capt) + // 连接测试是一条单发路径,不经过 agentcore 的会话循环,因此没人往 context 上挂 + // session id。对配了 SessionHeaderKey 的端点(如 opencode zen 强制要求 + // x-opencode-session 头,缺了直接 400 MissingSessionID),这会导致"对话正常、 + // 点击测试却 400"的落差。这里补挂一个一次性随机 session id,让测试与真实对话走同 + // 一套发头逻辑;未配 SessionHeaderKey 的端点不读它,无副作用。 + ctx = transcript.WithSessionID(ctx, "conntest-"+transcript.NewSessionID()) start := time.Now() // MaxTokens 要给足:推理模型(如 deepseek-v4-pro)在给出答案前会先产出一大段 // 思考(实测对一句 "ping" 也能烧 ~2900 token)。若只给 32,模型会一直卡在"思考阶段" diff --git a/server/server.go b/server/server.go index 0b0b6fc..efc49bd 100644 --- a/server/server.go +++ b/server/server.go @@ -1393,15 +1393,16 @@ func (s *Server) setLLM(w http.ResponseWriter, r *http.Request) { // testLLM makes a real minimal completion to verify the config works. func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) { var req struct { - Provider string `json:"provider"` - Model string `json:"model"` - BaseURL string `json:"base_url"` - Proxy string `json:"proxy"` - APIKey string `json:"api_key"` - ThinkingType string `json:"thinking_type"` - ReasoningEffort string `json:"reasoning_effort"` - ProfileID *int64 `json:"profile_id"` // 测已存 profile 时传入:api_key 为空则用它存的 key - Streaming *bool `json:"streaming"` // 省略=流式,与保存 profile 时同一套默认 + Provider string `json:"provider"` + Model string `json:"model"` + BaseURL string `json:"base_url"` + Proxy string `json:"proxy"` + APIKey string `json:"api_key"` + ThinkingType string `json:"thinking_type"` + ReasoningEffort string `json:"reasoning_effort"` + ProfileID *int64 `json:"profile_id"` // 测已存 profile 时传入:api_key 为空则用它存的 key + Streaming *bool `json:"streaming"` // 省略=流式,与保存 profile 时同一套默认 + SessionHeaderKey string `json:"session_header_key"` // 非空=测试时也带该自定义会话头,值为一次性随机 session id } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, 400, err.Error()) @@ -1417,11 +1418,21 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) { if req.Streaming != nil { cfg.Stream = *req.Streaming } + // 自定义会话头名照该配置来:非空则测试请求也发这个头(值为一次性随机 session id, + // 见 TestConnection)。opencode zen 等强制要求 x-opencode-session 的端点,缺了它 + // 直接 400,必须在测试路径上也带上,否则"对话通、测试 400"。 + cfg.SessionHeaderKey = req.SessionHeaderKey // API Key 解析优先级:表单输入 > 指定 profile 存的 key > 全局配置的 key。 // 已存 profile 的 key 不回传浏览器,所以测试已存配置时表单为空,需从 DB 取。 - if cfg.APIKey == "" && req.ProfileID != nil { + // 会话头名同理:表单未带时用已存 profile 的值兜底。 + if req.ProfileID != nil && (cfg.APIKey == "" || cfg.SessionHeaderKey == "") { if p, err := s.m.pg.ProfileByID(*req.ProfileID); err == nil && p != nil { - cfg.APIKey = p.APIKey + if cfg.APIKey == "" { + cfg.APIKey = p.APIKey + } + if cfg.SessionHeaderKey == "" { + cfg.SessionHeaderKey = p.SessionHeaderKey + } } } if cfg.APIKey == "" { diff --git a/web/src/app/(main)/system/llm/page.tsx b/web/src/app/(main)/system/llm/page.tsx index 7dc4052..1f4427c 100644 --- a/web/src/app/(main)/system/llm/page.tsx +++ b/web/src/app/(main)/system/llm/page.tsx @@ -400,6 +400,7 @@ function ProfileSheet({ toStore(effort), profileId, streaming, + sessionHeaderKey.trim(), ); // 回复内容一并展示:看得见模型确实说了话,才算和会话里跑通是一回事。 if (r.ok) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 349e4fa..48f4777 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -784,6 +784,7 @@ export const api = { reasoning_effort = "", profile_id?: number, streaming = true, // 用该配置真实的收发模式来测,别让"流式能通、非流式不通"漏到会话里 + session_header_key = "", // 非空=测试请求也带该自定义会话头(值为一次性 session id) ) => // reply = 模型实际回复(已截断);一个字都不回的配置后端直接判失败 post<{ ok: boolean; error?: string; latency_ms?: number; model?: string; reply?: string }>("/llm/test", { @@ -796,6 +797,7 @@ export const api = { reasoning_effort, profile_id, streaming, + session_header_key, }), llmProfiles: () => get<{ profiles: LLMProfile[] }>("/llm/profiles").then((r) => arr(r.profiles)), saveLLMProfile: (p: {