mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
merge: sync official main into PR branch
This commit is contained in:
commit
d803ae2b11
23 files changed
+1325
-68
No files matched your search
+2
-1
@@ -286,7 +286,8 @@
|
||||
- [@Autumn-27](https://github.com/Autumn-27)
|
||||
- [@neouks](https://github.com/neouks)
|
||||
|
||||
[Unreleased]: https://github.com/Autumn-27/ARTEX/compare/v0.3.4...HEAD
|
||||
[Unreleased]: https://github.com/Autumn-27/ARTEX/compare/v0.3.5...HEAD
|
||||
[0.3.5]: https://github.com/Autumn-27/ARTEX/compare/v0.3.4...v0.3.5
|
||||
[0.3.4]: https://github.com/Autumn-27/ARTEX/compare/v0.3.3...v0.3.4
|
||||
[0.3.3]: https://github.com/Autumn-27/ARTEX/compare/v0.3.2...v0.3.3
|
||||
[0.3.2]: https://github.com/Autumn-27/ARTEX/compare/v0.3.1...v0.3.2
|
||||
+5
-1
@@ -242,7 +242,10 @@ const plannerDefaultTmpl = `你是一个授权渗透测试系统的"规划者"
|
||||
|
||||
任务目标:{{.Goal}}
|
||||
|
||||
⚠️ 最重要的原则:**这一轮生成 0 个意图是完全正常、而且是最常见的结果。** 你不是"每次都要产出意图"的机器。绝大多数唤醒,frontier 里已有的意图已经覆盖了所有已知方向,你应当什么都不加、直接结束。重复/换措辞地生成已经存在的意图是严重错误。
|
||||
⚠️ **生成 0 个意图是合法、正常的结果,但它有【具体的正当理由】,不是"少派更稳"的默认姿态。** 只在下列情况才该产出 0 个意图:
|
||||
- **已覆盖**:你想到的方向都已被 open / running / recent_done 意图覆盖——重复或换措辞地再生成已存在的意图是严重错误;或
|
||||
- **等待依赖**:下一步依赖【当前正在运行的 work】的产出,而产出还没出来——此时应【等这些 work 跑完、图更新后的下一次唤醒再规划】,现在硬派会让下游 worker 拿不到还不存在的前置而空转。
|
||||
反过来:若确有【未被任何意图覆盖、且不依赖在跑 work】的新方向,或【目标尚未达成且范围内仍有未测面】,就【不要】因为"0 意图常见"而收手——该派就派。别把 0 意图当成偷懒的默认。
|
||||
|
||||
决策流程(每次唤醒):
|
||||
1. **完整态势已直接附在本提示下方(就是 graph_overview 的返回,无需再调它)**:task(**原始任务标题+目标**,即根节点)、资产计数、goals 及其状态、open/running/recent_done 意图、sites_without_endpoints、findings(**确认漏洞**数)、facts(**探索事实/结论**数,与漏洞是两类)、recent_facts(最近事实的 {id, summary, confidence?},含"端口关闭/不可注入"等**否定结论**——据此别再为已探明的死路生成意图;但 confidence=inferred 的否定结论只是【推断】、证据弱,别当铁案,若该方向对目标很关键值得派一条复核意图)。**这里的探索节点(goals/意图/facts/findings)都只含本任务的**(绝不会有别的任务的目标);**资产图则全局共享**(多任务同一份,资产计数是全局在范围内的数据,非本任务独有)。需要更深的细节时才按需调:list_facts 分页列事实(最新在前,默认 20 条,可传 q 关键词过滤、before 翻页,返回带 total/has_more)、list_findings 列全部漏洞、**node_detail(id)** 取某条的完整证据/详情(列表/recent_facts 只给摘要)。
|
||||
@@ -250,6 +253,7 @@ const plannerDefaultTmpl = `你是一个授权渗透测试系统的"规划者"
|
||||
- sites_without_endpoints / findings——"哪些方向【可能】需要探索"。
|
||||
- 只有需要某一片的细节时,才**按需**调 list_assets(pull 模式:可用 q 关键字搜索,可叠加 type/company_id/task_id 过滤,分页 limit/offset;或用 id/ids 直接取)、asset_neighbors、list_findings。资产图全局共享,别默认拉全量。资产中可能包含非本次任务涉及到的资产,所以需要主要出现非本次任务相关的资产时忽略这些资产。
|
||||
2. 判目标(核心职责):graph_overview 的 goals 字段已含目标与状态;对已被某发现/事实证明的未达成目标,调 prove_goal(goal_id,evidence_id,reason) 标记 met。**当你用 prove_goal 标记的这一个恰好是最后一个未完成目标时,系统会自动判定整个任务完成**——收官完全由逐个 prove_goal 驱动,你无需、也没有别的“一键完成”手段。
|
||||
⚠️ **量化验收核对(严禁提前盖章)**:若某目标含【可量化的验收条件】(如"资产测试覆盖度达到 X%"、"拿到 N 个 flag"、"获得某权限"),prove_goal **前【必须】核对本提示上方 graph_overview 里的实测值**(coverage.pct、findings 计数等):实测【未达标】就【禁止】prove_goal,改为派意图补足差距——**【不得】以"主要部分已完成/大体达成/核心目标已拿下"为由提前标 met**。例:目标要求覆盖度 100%、而 graph_overview 实测 coverage.pct=40%,则该目标【未达成】,继续派补测意图,不许 prove_goal。
|
||||
2.5. **(可选,仅限开局、极轻量)探测理解**:你具备 Bash 等执行能力,它的**唯一正当用途**是——当**图里几乎还没有事实**(recent_facts 基本为空、任务刚开始)、仅凭态势无法把初始意图描述具体时,对目标做**极少量、只读**的探测(如 1–2 次 curl 看首页/指纹),据此产出更精准的**初始意图**。
|
||||
⚠️ **牢记你的身份边界:你是"规划者",不是"执行者"。你在这里做的一切都只为【生成/说清意图】,绝不是【在 plan 里把活干了】。** 探测的**唯一合法产物是一句更精准的意图描述**——绝不能是漏洞的发现、验证、利用,也不能是端点/目录/参数的枚举结果。任何"我顺手把这个也测了/确认了"的念头都是越界:那是 worker 在 work 阶段该做的事,你只需把它**写成一个意图派下去**。
|
||||
**三条硬性边界,务必守住**:
|
||||
|
||||
+53
-2
@@ -19,6 +19,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/llmrec"
|
||||
"github.com/Autumn-27/norma/agentcore"
|
||||
"github.com/Autumn-27/norma/compaction"
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
@@ -131,6 +132,12 @@ func FromEnv() (Config, bool) {
|
||||
if c.Model == "" {
|
||||
c.Model = "gpt-4o"
|
||||
}
|
||||
case "openai-responses":
|
||||
c.Format = llm.FormatOpenAIResponses
|
||||
c.APIKey = oaiKey
|
||||
if c.Model == "" {
|
||||
c.Model = "gpt-5"
|
||||
}
|
||||
default:
|
||||
c.Format = llm.FormatAnthropic
|
||||
c.APIKey = anthKey
|
||||
@@ -164,6 +171,13 @@ func ConfigFrom(provider, model, baseURL, apiKey, proxy string) Config {
|
||||
if c.Model == "" {
|
||||
c.Model = "gpt-4o"
|
||||
}
|
||||
case "openai-responses":
|
||||
c.Format = llm.FormatOpenAIResponses
|
||||
// provider appends "/responses"; tolerate a full endpoint URL.
|
||||
c.BaseURL = strings.TrimRight(strings.TrimSuffix(c.BaseURL, "/responses"), "/")
|
||||
if c.Model == "" {
|
||||
c.Model = "gpt-5"
|
||||
}
|
||||
default:
|
||||
c.Format = llm.FormatAnthropic
|
||||
// provider appends "/v1/messages".
|
||||
@@ -187,8 +201,11 @@ func isFalsy(s string) bool {
|
||||
|
||||
// Provider returns the short provider name ("anthropic"/"openai").
|
||||
func (c Config) Provider() string {
|
||||
if c.Format == llm.FormatOpenAI {
|
||||
switch c.Format {
|
||||
case llm.FormatOpenAI:
|
||||
return "openai"
|
||||
case llm.FormatOpenAIResponses:
|
||||
return "openai-responses"
|
||||
}
|
||||
return "anthropic"
|
||||
}
|
||||
@@ -265,10 +282,25 @@ func IsQuotaExhaustedMessage(message string) bool {
|
||||
type quotaAwareTransport struct{ base http.RoundTripper }
|
||||
|
||||
func (t quotaAwareTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
// When LLM recording is on, the Recorder puts a Capture on the context so the
|
||||
// raw wire bodies can be persisted. This is the only layer that still sees
|
||||
// them: norma builds the request body internally and decodes the SSE response
|
||||
// before either reaches the recorder.
|
||||
capt := llmrec.CaptureFrom(req.Context())
|
||||
capt.SetRequest(requestBodySnapshot(req))
|
||||
|
||||
resp, err := t.base.RoundTrip(req)
|
||||
if err != nil || resp == nil || resp.StatusCode != http.StatusTooManyRequests {
|
||||
if err != nil || resp == nil {
|
||||
return resp, err
|
||||
}
|
||||
// Tee rather than read: a 200 is an SSE stream that must keep streaming. The
|
||||
// 429 branch below reads through this wrapper, so its body lands in the
|
||||
// capture before being replaced.
|
||||
resp.Body = capt.TeeResponse(resp.StatusCode, resp.Body)
|
||||
|
||||
if resp.StatusCode != http.StatusTooManyRequests {
|
||||
return resp, nil
|
||||
}
|
||||
body, readErr := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
@@ -283,6 +315,25 @@ func (t quotaAwareTransport) RoundTrip(req *http.Request) (*http.Response, error
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// requestBodySnapshot copies an outgoing request body without consuming it.
|
||||
// norma builds every model request from a *bytes.Reader, so net/http populates
|
||||
// GetBody and the copy has no effect on what gets sent.
|
||||
func requestBodySnapshot(req *http.Request) string {
|
||||
if req.GetBody == nil {
|
||||
return ""
|
||||
}
|
||||
rc, err := req.GetBody()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer rc.Close()
|
||||
b, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func quotaAwareHTTPClient(proxy string) (*http.Client, error) {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
proxy = strings.TrimSpace(proxy)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/llmrec"
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
)
|
||||
|
||||
// End-to-end through a real norma provider: the Capture rides the context into
|
||||
// norma, survives its internal request building, and comes back holding the
|
||||
// exact bytes buildBody() put on the wire. This is the load-bearing assumption
|
||||
// of the whole feature — norma must propagate the caller's context down to
|
||||
// http.NewRequestWithContext.
|
||||
func TestCapturePropagatesThroughNormaProvider(t *testing.T) {
|
||||
sse := strings.Join([]string{
|
||||
`event: message_start`,
|
||||
`data: {"type":"message_start","message":{"id":"msg_1","usage":{"input_tokens":11,"output_tokens":1}}}`,
|
||||
``,
|
||||
`event: content_block_delta`,
|
||||
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}`,
|
||||
``,
|
||||
`event: message_delta`,
|
||||
`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":7}}`,
|
||||
``,
|
||||
`event: message_stop`,
|
||||
`data: {"type":"message_stop"}`,
|
||||
``,
|
||||
}, "\n")
|
||||
|
||||
// Record exactly what the server receives, so the capture can be compared
|
||||
// against it byte for byte rather than merely spot-checked for fields.
|
||||
var gotPath, serverSaw string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
b, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
t.Errorf("server read body: %v", err)
|
||||
}
|
||||
serverSaw = string(b)
|
||||
w.Header().Set("content-type", "text/event-stream")
|
||||
_, _ = io.WriteString(w, sse)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := Config{
|
||||
Format: llm.FormatAnthropic,
|
||||
BaseURL: srv.URL,
|
||||
APIKey: "test-key",
|
||||
Model: "claude-test",
|
||||
}
|
||||
prov, err := cfg.NewProvider()
|
||||
if err != nil {
|
||||
t.Fatalf("NewProvider: %v", err)
|
||||
}
|
||||
|
||||
ctx, capt := llmrec.NewCapture(context.Background())
|
||||
req := llm.CompletionRequest{
|
||||
System: []string{"you are a scanner"},
|
||||
Messages: []llm.Message{{Role: "user", Content: []llm.ContentBlock{{Type: "text", Text: "go"}}}},
|
||||
Tools: []llm.ToolSchema{{
|
||||
Name: "bash",
|
||||
Description: "run a shell command",
|
||||
InputSchema: map[string]any{"type": "object", "properties": map[string]any{"command": map[string]any{"type": "string"}}},
|
||||
}},
|
||||
MaxTokens: 1024,
|
||||
}
|
||||
|
||||
var text strings.Builder
|
||||
for ev, err := range prov.Stream(ctx, req) {
|
||||
if err != nil {
|
||||
t.Fatalf("stream: %v", err)
|
||||
}
|
||||
if ev.Type == llm.SETextDelta {
|
||||
text.WriteString(ev.Text)
|
||||
}
|
||||
}
|
||||
if text.String() != "hello" {
|
||||
t.Fatalf("stream text=%q, capture interfered with delivery", text.String())
|
||||
}
|
||||
if gotPath != "/v1/messages" {
|
||||
t.Fatalf("path=%q", gotPath)
|
||||
}
|
||||
|
||||
// The raw request must be what norma actually sent, not the recorder's
|
||||
// re-serialization — which is exactly why it carries fields the normalized
|
||||
// view drops.
|
||||
raw := capt.RawRequest()
|
||||
if raw == "" {
|
||||
t.Fatal("no raw request captured — context did not reach the transport")
|
||||
}
|
||||
// The load-bearing claim: what we stored equals, byte for byte, what the
|
||||
// server received — not merely "has the right fields".
|
||||
if raw != serverSaw {
|
||||
t.Fatalf("captured request != what the server received:\n got: %q\nsaw: %q", raw, serverSaw)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &body); err != nil {
|
||||
t.Fatalf("raw request is not valid JSON: %v\n%s", err, raw)
|
||||
}
|
||||
if body["model"] != "claude-test" {
|
||||
t.Errorf("model=%v want claude-test (absent from CompletionRequest)", body["model"])
|
||||
}
|
||||
if body["stream"] != true {
|
||||
t.Errorf("stream=%v want true", body["stream"])
|
||||
}
|
||||
// The full tool schema is the headline gain: the normalized view keeps names only.
|
||||
tools, _ := body["tools"].([]any)
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("tools=%v", body["tools"])
|
||||
}
|
||||
tool, _ := tools[0].(map[string]any)
|
||||
if tool["description"] != "run a shell command" {
|
||||
t.Errorf("tool description missing: %v", tool)
|
||||
}
|
||||
if tool["input_schema"] == nil {
|
||||
t.Errorf("tool input_schema missing: %v", tool)
|
||||
}
|
||||
|
||||
// And the response is the untouched SSE frames, including the events the
|
||||
// recorder never turns into stored output.
|
||||
if capt.RawResponse() != sse {
|
||||
t.Errorf("RawResponse mismatch:\n got: %q\nwant: %q", capt.RawResponse(), sse)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/llmrec"
|
||||
)
|
||||
|
||||
// The transport is the only layer that still sees the wire bodies: norma builds
|
||||
// the request body internally and decodes the SSE response before the recorder
|
||||
// gets it. This checks the round trip preserves both directions untouched.
|
||||
func TestRoundTripCapturesRawBodies(t *testing.T) {
|
||||
const sse = "event: message_start\ndata: {\"type\":\"message_start\"}\n\nevent: message_stop\ndata: {}\n\n"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("content-type", "text/event-stream")
|
||||
_, _ = io.WriteString(w, sse)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client, err := quotaAwareHTTPClient("")
|
||||
if err != nil {
|
||||
t.Fatalf("client: %v", err)
|
||||
}
|
||||
|
||||
const reqBody = `{"model":"claude","messages":[{"role":"user","content":"hi"}],"tools":[{"name":"t","input_schema":{}}]}`
|
||||
req, err := http.NewRequest(http.MethodPost, srv.URL, strings.NewReader(reqBody))
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
ctx, capt := llmrec.NewCapture(req.Context())
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
got, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
if string(got) != sse {
|
||||
t.Fatal("capture altered the response delivered to norma")
|
||||
}
|
||||
if capt.RawRequest() != reqBody {
|
||||
t.Fatalf("RawRequest()=%q want %q", capt.RawRequest(), reqBody)
|
||||
}
|
||||
if capt.RawResponse() != sse {
|
||||
t.Fatalf("RawResponse()=%q want the raw SSE frames", capt.RawResponse())
|
||||
}
|
||||
}
|
||||
|
||||
// A 429 body is read and replaced in-place by the quota check. Capturing must
|
||||
// still see it, and the replacement body must remain readable downstream.
|
||||
func TestRoundTripCaptures429BodyAlongsideQuotaRewrite(t *testing.T) {
|
||||
const body = `{"error":{"message":"insufficient_quota"}}`
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
_, _ = io.WriteString(w, body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client, err := quotaAwareHTTPClient("")
|
||||
if err != nil {
|
||||
t.Fatalf("client: %v", err)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, srv.URL, strings.NewReader("{}"))
|
||||
if err != nil {
|
||||
t.Fatalf("request: %v", err)
|
||||
}
|
||||
ctx, capt := llmrec.NewCapture(req.Context())
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Quota exhaustion is normalized to 402 so the router fails over.
|
||||
if resp.StatusCode != http.StatusPaymentRequired {
|
||||
t.Fatalf("status=%d want 402", resp.StatusCode)
|
||||
}
|
||||
if capt.RawResponse() != body {
|
||||
t.Fatalf("RawResponse()=%q want %q", capt.RawResponse(), body)
|
||||
}
|
||||
rest, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read replaced body: %v", err)
|
||||
}
|
||||
if string(rest) != body {
|
||||
t.Fatalf("replaced body=%q want it still readable", rest)
|
||||
}
|
||||
}
|
||||
|
||||
// Recording off = no Capture on the context. The transport must behave exactly
|
||||
// as before, including the quota rewrite.
|
||||
func TestRoundTripWithoutCaptureIsUnchanged(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, "ok")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
client, err := quotaAwareHTTPClient("")
|
||||
if err != nil {
|
||||
t.Fatalf("client: %v", err)
|
||||
}
|
||||
resp, err := client.Post(srv.URL, "application/json", strings.NewReader("{}"))
|
||||
if err != nil {
|
||||
t.Fatalf("post: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
got, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if string(got) != "ok" {
|
||||
t.Fatalf("body=%q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
)
|
||||
|
||||
// TestConfigFromOpenAIResponses locks the openai-responses format wiring:
|
||||
// ConfigFrom resolves the Responses format, strips a full /responses endpoint
|
||||
// back to the API base, and Provider() round-trips the short name. NewProvider
|
||||
// must build a working provider for it.
|
||||
func TestConfigFromOpenAIResponses(t *testing.T) {
|
||||
c := ConfigFrom("openai-responses", "gpt-5", "https://gw.example/v1/responses", "sk-x", "")
|
||||
if c.Format != llm.FormatOpenAIResponses {
|
||||
t.Fatalf("format=%v, want FormatOpenAIResponses", c.Format)
|
||||
}
|
||||
if c.BaseURL != "https://gw.example/v1" {
|
||||
t.Fatalf("base_url=%q, want the /responses suffix stripped", c.BaseURL)
|
||||
}
|
||||
if c.Provider() != "openai-responses" {
|
||||
t.Fatalf("Provider()=%q", c.Provider())
|
||||
}
|
||||
if !c.Stream { // default streaming preserved
|
||||
t.Fatal("Stream should default true")
|
||||
}
|
||||
if _, err := c.NewProvider(); err != nil {
|
||||
t.Fatalf("NewProvider: %v", err)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -169,7 +169,7 @@ const workerDefaultTmpl = `你是一个授权渗透测试系统的"执行者"(wo
|
||||
|
||||
可用工具:
|
||||
- insert_assets:登记新资产(资产图)。**你只传原始信息,key 与父子关联由代码算**:新接口→传完整 url+method(代码自动建 domain→site→endpoint、自动抽 URL 里的 query 参数,body/header 参数放 params);指纹→type=tech,name=技术名,on_url=站点地址,props填{version,category}。多个资产放 assets 数组一次批量登记。属性写在资产自己的 props 上,探索结论不要写这里。
|
||||
- record_fact:把探索【事实/结论】写入探索图并连到意图(传 intent_id)。正向/否定结论、观察、判断用它;**一次探索的多个观察汇总成一条事实**(summary 总结一句话 + detail 放细节),不要一个属性一条。真有多条不同结论才用 facts 数组。**只写你真实看到的**:给 evidence(一行关键证据:命令+关键输出,简洁,别粘大段——细节在 detail)、标 confidence(observed 直接看到 / inferred 推断);**否定结论**(不可注入/端口关闭等)尤其要给证据、证据弱就标 inferred,别让错的否定误导规划者放弃方向。
|
||||
- record_fact:把探索【事实/结论】写入探索图并连到意图(传 intent_id)。正向/否定结论、观察、判断用它;**一次探索的多个观察汇总成一条事实**(summary 总结一句话 + detail 放细节),不要一个属性一条。真有多条不同结论才用 facts 数组。**只写你真实看到的**:给 evidence(一行关键证据:命令+关键输出,简洁,别粘大段——细节在 detail)、标 confidence(observed 直接看到 / inferred 推断);**否定结论的证据门槛**(不可注入/端口关闭/无登录入口等【可能让规划者放弃一整条方向】的结论):下结论前先确认你已【穷尽这条意图内的合理手段】(换编码/换参数/换路径/换方法);手段没走完、或证据只是"看起来像/大概率如此",一律标 confidence=inferred。**宁可标 inferred 让规划者复核,也别用一个轻率的 observed 否定把一整条路线焊死**——尤其任务早期,一个错误的 observed 否定会把整个任务带偏、且后续很难自己扳回来。
|
||||
- report_finding:确认漏洞 → 记录(含 PoC,传 intent_id=你领到的意图id)。**只有你在本次运行里真实触发过该漏洞、拿到了可复现的证据(请求/响应或命令输出)才用它。** 严禁把下列当作已确认漏洞上报:仅凭版本号/指纹匹配到某 CVE、仅凭"参数看起来可注入"、仅凭外部漏洞库/更新日志/代码 diff 推断。**不要用查 CVE 库或"对比补丁版本"替代实际触发。** 触发不了但确有嫌疑,就用 record_fact 记一条 confidence=inferred 的事实(描述嫌疑点+为何未能触发),交给规划者派后续意图,别硬记成 finding。
|
||||
- list_assets(查询资产,非探索节点) / asset_neighbors / list_facts(探索事实,分页最新在前;默认 20 条,可传 q 关键词过滤、before 翻页) / list_findings(漏洞) / node_detail(探索节点 id,非资产 id):按需查上下文。
|
||||
|
||||
|
||||
+21
-7
@@ -144,6 +144,13 @@ type LLMRecord struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
RequestBody string `json:"request_body,omitempty"`
|
||||
ResponseBody string `json:"response_body,omitempty"`
|
||||
// RawRequest / RawResponse are the untouched HTTP bodies exchanged with the
|
||||
// provider — the request as buildBody() sent it (full tool schemas included)
|
||||
// and the raw SSE frames. RequestBody/ResponseBody above are the normalized
|
||||
// view, which drops tool schemas and tool_use blocks entirely. Empty for
|
||||
// records written before this was added, or when the call never reached HTTP.
|
||||
RawRequest string `json:"raw_request,omitempty"`
|
||||
RawResponse string `json:"raw_response,omitempty"`
|
||||
}
|
||||
|
||||
const llmRecordsSchema = `
|
||||
@@ -163,7 +170,9 @@ CREATE TABLE IF NOT EXISTS llm_records (
|
||||
status TEXT,
|
||||
error TEXT,
|
||||
request_body TEXT,
|
||||
response_body TEXT
|
||||
response_body TEXT,
|
||||
raw_request TEXT,
|
||||
raw_response TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_records_ts ON llm_records(ts);
|
||||
CREATE INDEX IF NOT EXISTS idx_llm_records_session ON llm_records(session_id);
|
||||
@@ -174,6 +183,8 @@ const llmRecordsMigrate = `
|
||||
ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS task_id TEXT;
|
||||
ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS worker TEXT;
|
||||
ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS profile_name TEXT;
|
||||
ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS raw_request TEXT;
|
||||
ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS raw_response TEXT;
|
||||
`
|
||||
|
||||
// EnsureLLMRecordsTable creates the llm_records table if it does not exist.
|
||||
@@ -188,11 +199,12 @@ func (d *DB) EnsureLLMRecordsTable() error {
|
||||
// InsertLLMRecord stores one LLM call record.
|
||||
func (d *DB) InsertLLMRecord(r *LLMRecord) error {
|
||||
_, err := d.Exec(`
|
||||
INSERT INTO llm_records(model, profile_name, session_id, task_id, worker, latency_ms, input_tokens, output_tokens, cache_read, cache_write, status, error, request_body, response_body)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
|
||||
INSERT INTO llm_records(model, profile_name, session_id, task_id, worker, latency_ms, input_tokens, output_tokens, cache_read, cache_write, status, error, request_body, response_body, raw_request, raw_response)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`,
|
||||
r.Model, nullIfEmpty(r.ProfileName), r.SessionID, nullIfEmpty(r.TaskID), nullIfEmpty(r.Worker),
|
||||
r.LatencyMs, r.InputTokens, r.OutputTokens, r.CacheRead, r.CacheWrite,
|
||||
r.Status, nullIfEmpty(r.Error), nullIfEmpty(r.RequestBody), nullIfEmpty(r.ResponseBody))
|
||||
r.Status, nullIfEmpty(r.Error), nullIfEmpty(r.RequestBody), nullIfEmpty(r.ResponseBody),
|
||||
nullIfEmpty(r.RawRequest), nullIfEmpty(r.RawResponse))
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -305,19 +317,21 @@ func (d *DB) DeleteLLMRecords(task string) (int64, error) {
|
||||
// GetLLMRecord returns a single LLM record with full request/response bodies.
|
||||
func (d *DB) GetLLMRecord(id int64) (*LLMRecord, error) {
|
||||
var r LLMRecord
|
||||
var reqBody, respBody sql.NullString
|
||||
var reqBody, respBody, rawReq, rawResp sql.NullString
|
||||
err := d.QueryRow(`SELECT id, ts, COALESCE(model,''), COALESCE(profile_name,''), COALESCE(session_id,''), COALESCE(task_id,''), COALESCE(worker,''),
|
||||
COALESCE(latency_ms,0), COALESCE(input_tokens,0), COALESCE(output_tokens,0), COALESCE(cache_read,0), COALESCE(cache_write,0),
|
||||
COALESCE(status,''), COALESCE(error,''), request_body, response_body
|
||||
COALESCE(status,''), COALESCE(error,''), request_body, response_body, raw_request, raw_response
|
||||
FROM llm_records WHERE id=$1`, id).
|
||||
Scan(&r.ID, &r.Ts, &r.Model, &r.ProfileName, &r.SessionID, &r.TaskID, &r.Worker, &r.LatencyMs,
|
||||
&r.InputTokens, &r.OutputTokens, &r.CacheRead, &r.CacheWrite, &r.Status, &r.Error,
|
||||
&reqBody, &respBody)
|
||||
&reqBody, &respBody, &rawReq, &rawResp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.RequestBody = reqBody.String
|
||||
r.ResponseBody = respBody.String
|
||||
r.RawRequest = rawReq.String
|
||||
r.RawResponse = rawResp.String
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The raw-body columns were added after release, so existing installs only get
|
||||
// them through llmRecordsMigrate. This runs the real migration against the dev
|
||||
// PG on a table stripped back to its pre-upgrade shape, inside a transaction
|
||||
// that is always rolled back — PG does transactional DDL, so nothing persists.
|
||||
func TestLLMRecordsMigrateAddsRawColumnsToOldTable(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
if err := d.EnsureLLMRecordsTable(); err != nil {
|
||||
t.Fatalf("ensure: %v", err)
|
||||
}
|
||||
|
||||
tx, err := d.Begin()
|
||||
if err != nil {
|
||||
t.Fatalf("begin: %v", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck // the test never commits
|
||||
|
||||
// Roll the table back to how a pre-upgrade install looks.
|
||||
if _, err := tx.Exec(`ALTER TABLE llm_records DROP COLUMN IF EXISTS raw_request, DROP COLUMN IF EXISTS raw_response`); err != nil {
|
||||
t.Fatalf("simulate old table: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(llmRecordsMigrate); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
for _, col := range []string{"raw_request", "raw_response"} {
|
||||
var n int
|
||||
if err := tx.QueryRow(
|
||||
`SELECT count(*) FROM information_schema.columns
|
||||
WHERE table_name='llm_records' AND column_name=$1`, col).Scan(&n); err != nil {
|
||||
t.Fatalf("inspect %s: %v", col, err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("column %s missing after migrate", col)
|
||||
}
|
||||
}
|
||||
|
||||
// Re-running must stay a no-op (the migration runs on every startup).
|
||||
if _, err := tx.Exec(llmRecordsMigrate); err != nil {
|
||||
t.Fatalf("migrate is not idempotent: %v", err)
|
||||
}
|
||||
|
||||
// An insert carrying raw bodies must round-trip through the migrated table.
|
||||
var got string
|
||||
if err := tx.QueryRow(
|
||||
`INSERT INTO llm_records(model, raw_request, raw_response) VALUES ('m','{"a":1}','data: x')
|
||||
RETURNING raw_response`).Scan(&got); err != nil {
|
||||
t.Fatalf("insert into migrated table: %v", err)
|
||||
}
|
||||
if got != "data: x" {
|
||||
t.Errorf("raw_response=%q want %q", got, "data: x")
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -291,7 +291,7 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
CREATE TABLE IF NOT EXISTS llm_profiles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
format TEXT NOT NULL CHECK (format IN ('openai','anthropic')),
|
||||
format TEXT NOT NULL CHECK (format IN ('openai','anthropic','openai-responses')),
|
||||
base_url TEXT,
|
||||
proxy TEXT,
|
||||
model TEXT NOT NULL,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package llmrec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type captureContextKey struct{}
|
||||
|
||||
// Capture collects the untouched wire bodies of one logical LLM call. The
|
||||
// Recorder creates it and puts it on the context; the HTTP transport that norma
|
||||
// dials through (agent.quotaAwareTransport) finds it there and fills it in.
|
||||
//
|
||||
// This exists because everything the Recorder itself sees is already normalized:
|
||||
// llm.CompletionRequest is re-serialized rather than the body buildBody() sent,
|
||||
// and the response arrives as decoded StreamEvents, not the SSE frames. For
|
||||
// debugging a live provider, the bytes on the wire are the only ground truth.
|
||||
//
|
||||
// norma's doStream retries the request-establishment phase, so one Stream can
|
||||
// issue several HTTP attempts. Each attempt is kept: the discarded ones (see
|
||||
// norma/llm/retry.go, which closes non-final bodies unread) are exactly what
|
||||
// makes rate-limit and gateway failures diagnosable.
|
||||
//
|
||||
// The transport writes from norma's stream-reading goroutine while the Recorder
|
||||
// snapshots at stream end, so all state is mutex-guarded.
|
||||
type Capture struct {
|
||||
mu sync.Mutex
|
||||
request string
|
||||
attempts []*attempt
|
||||
}
|
||||
|
||||
type attempt struct {
|
||||
status int
|
||||
body strings.Builder
|
||||
}
|
||||
|
||||
// NewCapture returns a context carrying a fresh Capture, plus the Capture itself.
|
||||
func NewCapture(ctx context.Context) (context.Context, *Capture) {
|
||||
c := &Capture{}
|
||||
return context.WithValue(ctx, captureContextKey{}, c), c
|
||||
}
|
||||
|
||||
// CaptureFrom returns the Capture attached to ctx, or nil when raw capture is
|
||||
// off. Callers must tolerate nil — recording is a toggle, and non-recorded
|
||||
// providers dial through the same transport.
|
||||
func CaptureFrom(ctx context.Context) *Capture {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
c, _ := ctx.Value(captureContextKey{}).(*Capture)
|
||||
return c
|
||||
}
|
||||
|
||||
// SetRequest stores the outgoing request body. Retries re-send identical bytes,
|
||||
// so only the first attempt's body is kept.
|
||||
func (c *Capture) SetRequest(body string) {
|
||||
if c == nil || body == "" {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.request == "" {
|
||||
c.request = body
|
||||
}
|
||||
}
|
||||
|
||||
// TeeResponse opens a new attempt and wraps rc so everything read from it is
|
||||
// mirrored into that attempt. It tees rather than reads because a successful
|
||||
// response is an SSE stream that must keep streaming to the caller.
|
||||
func (c *Capture) TeeResponse(status int, rc io.ReadCloser) io.ReadCloser {
|
||||
if c == nil || rc == nil {
|
||||
return rc
|
||||
}
|
||||
a := &attempt{status: status}
|
||||
c.mu.Lock()
|
||||
c.attempts = append(c.attempts, a)
|
||||
c.mu.Unlock()
|
||||
return &teeBody{rc: rc, c: c, a: a}
|
||||
}
|
||||
|
||||
// RawRequest returns the request body as sent, or "" if nothing was captured.
|
||||
func (c *Capture) RawRequest() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.request
|
||||
}
|
||||
|
||||
// RawResponse returns the response bytes as received. A single attempt yields
|
||||
// the untouched original (copy-pasteable straight into a replay); multiple
|
||||
// attempts are concatenated behind per-attempt header lines so a retry sequence
|
||||
// stays readable.
|
||||
func (c *Capture) RawResponse() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
switch len(c.attempts) {
|
||||
case 0:
|
||||
return ""
|
||||
case 1:
|
||||
return c.attempts[0].body.String()
|
||||
}
|
||||
var b strings.Builder
|
||||
for i, a := range c.attempts {
|
||||
fmt.Fprintf(&b, "===== attempt %d/%d — HTTP %d =====\n", i+1, len(c.attempts), a.status)
|
||||
body := a.body.String()
|
||||
b.WriteString(body)
|
||||
if !strings.HasSuffix(body, "\n") {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// teeBody mirrors reads into a Capture attempt, guarded by the Capture's mutex
|
||||
// so a snapshot taken mid-stream never races the writer.
|
||||
type teeBody struct {
|
||||
rc io.ReadCloser
|
||||
c *Capture
|
||||
a *attempt
|
||||
}
|
||||
|
||||
func (t *teeBody) Read(p []byte) (int, error) {
|
||||
n, err := t.rc.Read(p)
|
||||
if n > 0 {
|
||||
t.c.mu.Lock()
|
||||
t.a.body.Write(p[:n])
|
||||
t.c.mu.Unlock()
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (t *teeBody) Close() error { return t.rc.Close() }
|
||||
@@ -0,0 +1,103 @@
|
||||
package llmrec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCaptureFromNilAndMissing(t *testing.T) {
|
||||
var nilCtx context.Context // the transport reaches here on any unrecorded call
|
||||
if CaptureFrom(nilCtx) != nil {
|
||||
t.Fatal("nil context should yield no capture")
|
||||
}
|
||||
if CaptureFrom(context.Background()) != nil {
|
||||
t.Fatal("bare context should yield no capture")
|
||||
}
|
||||
}
|
||||
|
||||
// A nil *Capture is the "recording off" path: the transport calls these on every
|
||||
// request, so they must all be no-ops rather than panics.
|
||||
func TestNilCaptureIsInert(t *testing.T) {
|
||||
var c *Capture
|
||||
c.SetRequest("body")
|
||||
if got := c.RawRequest(); got != "" {
|
||||
t.Fatalf("RawRequest()=%q want empty", got)
|
||||
}
|
||||
if got := c.RawResponse(); got != "" {
|
||||
t.Fatalf("RawResponse()=%q want empty", got)
|
||||
}
|
||||
rc := io.NopCloser(strings.NewReader("x"))
|
||||
if c.TeeResponse(200, rc) != rc {
|
||||
t.Fatal("nil capture must pass the body through untouched")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureSingleAttemptKeepsBytesVerbatim(t *testing.T) {
|
||||
ctx, c := NewCapture(context.Background())
|
||||
if CaptureFrom(ctx) != c {
|
||||
t.Fatal("capture not retrievable from its own context")
|
||||
}
|
||||
c.SetRequest(`{"model":"x"}`)
|
||||
// Retries re-send identical bytes; only the first is kept.
|
||||
c.SetRequest(`{"model":"ignored"}`)
|
||||
|
||||
const sse = "event: message_start\ndata: {\"type\":\"message_start\"}\n\nevent: message_stop\ndata: {}\n\n"
|
||||
body := c.TeeResponse(200, io.NopCloser(strings.NewReader(sse)))
|
||||
got, err := io.ReadAll(body)
|
||||
if err != nil {
|
||||
t.Fatalf("read: %v", err)
|
||||
}
|
||||
if string(got) != sse {
|
||||
t.Fatal("tee altered the stream delivered to the caller")
|
||||
}
|
||||
if err := body.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
if c.RawRequest() != `{"model":"x"}` {
|
||||
t.Fatalf("RawRequest()=%q", c.RawRequest())
|
||||
}
|
||||
// A lone attempt must stay byte-identical — no headers, no framing added, so
|
||||
// it can be replayed as-is.
|
||||
if c.RawResponse() != sse {
|
||||
t.Fatalf("RawResponse()=%q want verbatim SSE", c.RawResponse())
|
||||
}
|
||||
}
|
||||
|
||||
// norma's doStream discards the bodies of retried attempts (retry.go closes them
|
||||
// unread), so the capture is the only place a 429 body survives.
|
||||
func TestCaptureRetriedAttemptsAreAllKept(t *testing.T) {
|
||||
_, c := NewCapture(context.Background())
|
||||
|
||||
first := c.TeeResponse(429, io.NopCloser(strings.NewReader(`{"error":"rate_limit"}`)))
|
||||
if _, err := io.ReadAll(first); err != nil {
|
||||
t.Fatalf("read first: %v", err)
|
||||
}
|
||||
second := c.TeeResponse(200, io.NopCloser(strings.NewReader("data: ok\n")))
|
||||
if _, err := io.ReadAll(second); err != nil {
|
||||
t.Fatalf("read second: %v", err)
|
||||
}
|
||||
|
||||
raw := c.RawResponse()
|
||||
if !strings.Contains(raw, `{"error":"rate_limit"}`) {
|
||||
t.Fatalf("dropped the retried attempt body: %q", raw)
|
||||
}
|
||||
if !strings.Contains(raw, "data: ok") {
|
||||
t.Fatalf("dropped the final attempt body: %q", raw)
|
||||
}
|
||||
if !strings.Contains(raw, "attempt 1/2 — HTTP 429") || !strings.Contains(raw, "attempt 2/2 — HTTP 200") {
|
||||
t.Fatalf("attempts not delimited: %q", raw)
|
||||
}
|
||||
if strings.Index(raw, "rate_limit") > strings.Index(raw, "data: ok") {
|
||||
t.Fatal("attempts stored out of order")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureEmptyWhenNoHTTPHappened(t *testing.T) {
|
||||
_, c := NewCapture(context.Background())
|
||||
if c.RawRequest() != "" || c.RawResponse() != "" {
|
||||
t.Fatal("unused capture should be empty")
|
||||
}
|
||||
}
|
||||
+27
-13
@@ -110,9 +110,15 @@ func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.S
|
||||
}
|
||||
|
||||
// Serialize the request only when storing bodies (this is the expensive part).
|
||||
// Alongside it, attach a Capture so the HTTP transport can hand back the
|
||||
// untouched wire bodies — the normalized view below cannot reconstruct them
|
||||
// (tool schemas are dropped, tool_use blocks never reach this layer, and the
|
||||
// SSE framing is already decoded). See capture.go.
|
||||
reqBody := ""
|
||||
var capt *Capture
|
||||
if recordBodies {
|
||||
reqBody = r.serializeRequest(req)
|
||||
ctx, capt = NewCapture(ctx)
|
||||
}
|
||||
|
||||
return func(yield func(llm.StreamEvent, error) bool) {
|
||||
@@ -133,7 +139,7 @@ func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.S
|
||||
r.recordUsage(taskID, expID, worker, usage, int(time.Since(start).Milliseconds()), status)
|
||||
// Heavy trace row — only when body recording is on.
|
||||
if recordBodies {
|
||||
r.record(req, session, taskID, worker, reqBody, start, textBuf.String(), thinkingBuf.String(), usage, stopReason, err)
|
||||
r.record(req, session, taskID, worker, reqBody, capt, start, textBuf.String(), thinkingBuf.String(), usage, stopReason, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +186,7 @@ func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.S
|
||||
}
|
||||
|
||||
// Complete implements the atomic completion path while preserving the same
|
||||
// usage metering and optional body recording semantics as Stream.
|
||||
// usage metering, normalized body recording and raw wire capture as Stream.
|
||||
func (r *Recorder) Complete(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) {
|
||||
recordBodies := r.enabled == nil || r.enabled()
|
||||
start := time.Now()
|
||||
@@ -193,8 +199,10 @@ func (r *Recorder) Complete(ctx context.Context, req llm.CompletionRequest) (llm
|
||||
}
|
||||
|
||||
reqBody := ""
|
||||
var capt *Capture
|
||||
if recordBodies {
|
||||
reqBody = r.serializeRequest(req)
|
||||
ctx, capt = NewCapture(ctx)
|
||||
}
|
||||
|
||||
msg, stopReason, usage, err := r.inner.Complete(ctx, req)
|
||||
@@ -204,20 +212,21 @@ func (r *Recorder) Complete(ctx context.Context, req llm.CompletionRequest) (llm
|
||||
}
|
||||
r.recordUsage(taskID, expID, worker, usage, int(time.Since(start).Milliseconds()), status)
|
||||
if recordBodies {
|
||||
var textBuf, thinkingBuf strings.Builder
|
||||
for _, block := range msg.Content {
|
||||
switch block.Type {
|
||||
case llm.BlockText:
|
||||
textBuf.WriteString(block.Text)
|
||||
case llm.BlockThinking:
|
||||
thinkingBuf.WriteString(block.Thinking)
|
||||
}
|
||||
}
|
||||
r.record(req, session, taskID, worker, reqBody, start, textBuf.String(), thinkingBuf.String(), usage, stopReason, err)
|
||||
r.record(req, session, taskID, worker, reqBody, capt, start, msg.Text(), thinkingText(msg), usage, stopReason, err)
|
||||
}
|
||||
return msg, stopReason, usage, err
|
||||
}
|
||||
|
||||
func thinkingText(msg llm.Message) string {
|
||||
var b strings.Builder
|
||||
for _, block := range msg.Content {
|
||||
if block.Type == llm.BlockThinking && block.Thinking != "" {
|
||||
b.WriteString(block.Thinking)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// recordUsage appends one lightweight metering row to llm_usage (no bodies). Skips
|
||||
// zero-token calls with no model, which carry nothing worth metering.
|
||||
func (r *Recorder) recordUsage(taskID string, expID int64, worker string, usage llm.Usage, latencyMs int, status string) {
|
||||
@@ -249,7 +258,7 @@ func (r *Recorder) recordUsage(taskID string, expID int64, worker string, usage
|
||||
// record persists one LLM call to PostgreSQL before the provider stream returns.
|
||||
// Keeping the write inside the owning task operation means task deletion can
|
||||
// drain calls and then remove records without a late async insert recreating one.
|
||||
func (r *Recorder) record(req llm.CompletionRequest, session, taskID, worker, reqBody string, start time.Time, text, thinking string, usage llm.Usage, stopReason string, streamErr error) {
|
||||
func (r *Recorder) record(req llm.CompletionRequest, session, taskID, worker, reqBody string, capt *Capture, start time.Time, text, thinking string, usage llm.Usage, stopReason string, streamErr error) {
|
||||
latency := int(time.Since(start).Milliseconds())
|
||||
status := "ok"
|
||||
errMsg := ""
|
||||
@@ -295,6 +304,11 @@ func (r *Recorder) record(req llm.CompletionRequest, session, taskID, worker, re
|
||||
Error: errMsg,
|
||||
RequestBody: reqBody,
|
||||
ResponseBody: string(respBody),
|
||||
// Raw wire bodies; empty when the transport did not fill the Capture
|
||||
// (e.g. a provider dialing through a client without the capture hook, or
|
||||
// a call that failed before any HTTP request went out).
|
||||
RawRequest: capt.RawRequest(),
|
||||
RawResponse: capt.RawResponse(),
|
||||
}
|
||||
|
||||
if err := r.pg.InsertLLMRecord(rec); err != nil {
|
||||
|
||||
Executable
+235
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# ARTEX 管理员密码重置脚本
|
||||
#
|
||||
# 登录用户名固定为 ARTEX;密码以 bcrypt 哈希存放在数据库 settings 表的
|
||||
# auth.password_hash 键。本脚本连上数据库后,用 pgcrypto 在库内生成 bcrypt 哈希
|
||||
# 并写回该键——与后端登录校验(golang.org/x/crypto/bcrypt)完全兼容。
|
||||
#
|
||||
# 两种部署:
|
||||
# local (默认)—— 宿主机直接用 psql 连数据库。连接信息按以下优先级获取:
|
||||
# 命令行参数 > --dsn/$ARTEX_PG_DSN > config.json 的 database.*
|
||||
# docker —— 通过 `docker compose exec`(或 `docker exec`)在 postgres
|
||||
# 容器内执行 psql(compose 默认不对宿主暴露 5432,故走容器内)。
|
||||
#
|
||||
# 用法示例:
|
||||
# ./reset-password.sh # 本地,自动读 config.json/环境,交互输入新密码
|
||||
# ./reset-password.sh -p 'NewPass!' # 本地,直接给定新密码
|
||||
# ./reset-password.sh --dsn postgres://u:p@h:5432/artex
|
||||
# ./reset-password.sh -H 127.0.0.1 -P 5433 -U autopentest -W pass -d artex
|
||||
# ./reset-password.sh -m docker # docker 部署(读 .env 的 POSTGRES_*)
|
||||
# ./reset-password.sh -m docker -c pg容器名 --exec docker
|
||||
#
|
||||
# 安全:新密码经环境变量 + psql \getenv 传入(不进入进程 argv),并用 :'var'
|
||||
# 自动转义(防 SQL 注入);数据库密码经 PGPASSWORD 传递,同样不进 argv。
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
PASS_KEY="auth.password_hash"
|
||||
BCRYPT_COST=10
|
||||
|
||||
MODE="" # local | docker(空=自动判定)
|
||||
DSN=""
|
||||
HOST="" PORT="" USER="" DBPASS="" DBNAME="" SSLMODE=""
|
||||
CONFIG=""
|
||||
CONTAINER="" # docker 模式的 postgres 服务/容器名(默认 postgres)
|
||||
EXEC_KIND="" # compose | docker(docker 模式下用哪种 exec;空=自动)
|
||||
NEWPASS=""
|
||||
ASSUME_YES=0
|
||||
|
||||
die() { echo "错误:$*" >&2; exit 1; }
|
||||
info() { echo "· $*" >&2; }
|
||||
|
||||
usage() { sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'; exit 0; }
|
||||
|
||||
# ---- 参数解析 -------------------------------------------------------------
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-m|--mode) MODE="${2:-}"; shift 2 ;;
|
||||
--dsn) DSN="${2:-}"; shift 2 ;;
|
||||
-H|--host) HOST="${2:-}"; shift 2 ;;
|
||||
-P|--port) PORT="${2:-}"; shift 2 ;;
|
||||
-U|--user) USER="${2:-}"; shift 2 ;;
|
||||
-W|--db-password) DBPASS="${2:-}"; shift 2 ;;
|
||||
-d|--dbname) DBNAME="${2:-}"; shift 2 ;;
|
||||
--sslmode) SSLMODE="${2:-}"; shift 2 ;;
|
||||
--config) CONFIG="${2:-}"; shift 2 ;;
|
||||
-c|--container) CONTAINER="${2:-}"; shift 2 ;;
|
||||
--exec) EXEC_KIND="${2:-}"; shift 2 ;;
|
||||
-p|--new-password) NEWPASS="${2:-}"; shift 2 ;;
|
||||
-y|--yes) ASSUME_YES=1; shift ;;
|
||||
-h|--help) usage ;;
|
||||
*) die "未知参数:$1(-h 查看用法)" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---- 从 config.json 读取 database.*(仅 local 模式、且未显式给出连接时)-----
|
||||
# 优先用 python3 解析(健壮);缺 python3 时退回 grep(config.json 为规整分字段)。
|
||||
read_config_json() {
|
||||
local path="$1"
|
||||
[[ -f "$path" ]] || return 1
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 - "$path" <<'PY'
|
||||
import json, sys
|
||||
try:
|
||||
d = json.load(open(sys.argv[1])).get("database", {})
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
# 支持直接给 dsn,或分字段
|
||||
if d.get("dsn"):
|
||||
print("DSN\t" + d["dsn"]); sys.exit(0)
|
||||
for k in ("host","port","user","password","dbname","sslmode"):
|
||||
if d.get(k) is not None:
|
||||
print(k.upper() + "\t" + str(d[k]))
|
||||
PY
|
||||
else
|
||||
# 极简后备:逐键 grep(值为字符串或数字)
|
||||
local k
|
||||
for k in host port user password dbname sslmode; do
|
||||
local v
|
||||
v=$(grep -oE "\"$k\"[[:space:]]*:[[:space:]]*(\"[^\"]*\"|[0-9]+)" "$path" 2>/dev/null \
|
||||
| head -1 | sed -E "s/.*:[[:space:]]*//; s/^\"//; s/\"$//") || true
|
||||
[[ -n "$v" ]] && echo -e "${k^^}\t$v"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
apply_config_fields() {
|
||||
local line key val
|
||||
while IFS=$'\t' read -r key val; do
|
||||
[[ -z "$key" ]] && continue
|
||||
case "$key" in
|
||||
DSN) [[ -z "$DSN" ]] && DSN="$val" ;;
|
||||
HOST) [[ -z "$HOST" ]] && HOST="$val" ;;
|
||||
PORT) [[ -z "$PORT" ]] && PORT="$val" ;;
|
||||
USER) [[ -z "$USER" ]] && USER="$val" ;;
|
||||
PASSWORD) [[ -z "$DBPASS" ]] && DBPASS="$val" ;;
|
||||
DBNAME) [[ -z "$DBNAME" ]] && DBNAME="$val" ;;
|
||||
SSLMODE) [[ -z "$SSLMODE" ]] && SSLMODE="$val" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# ---- 自动判定模式 ---------------------------------------------------------
|
||||
if [[ -z "$MODE" ]]; then
|
||||
if [[ -n "$DSN$HOST$USER$DBNAME" || -n "${ARTEX_PG_DSN:-}" || -f "${CONFIG:-config.json}" ]]; then
|
||||
MODE="local"
|
||||
elif command -v docker >/dev/null 2>&1 && [[ -f docker-compose.yml ]]; then
|
||||
MODE="docker"
|
||||
else
|
||||
MODE="local"
|
||||
fi
|
||||
fi
|
||||
info "部署模式:$MODE"
|
||||
|
||||
# ---- 采集新密码 -----------------------------------------------------------
|
||||
if [[ -z "$NEWPASS" ]]; then
|
||||
read -r -s -p "输入新密码(用户名固定为 ARTEX):" NEWPASS; echo >&2
|
||||
[[ -n "$NEWPASS" ]] || die "密码不能为空"
|
||||
read -r -s -p "再次输入以确认:" NEWPASS2; echo >&2
|
||||
[[ "$NEWPASS" == "$NEWPASS2" ]] || die "两次输入不一致"
|
||||
fi
|
||||
[[ -n "$NEWPASS" ]] || die "密码不能为空"
|
||||
|
||||
# 通过环境变量把密码交给 psql(\getenv 读取,不进入 argv/ps)
|
||||
export ARTEX_RESET_NEWPASS="$NEWPASS"
|
||||
|
||||
# 库内生成 bcrypt 并 upsert;密码用 :'newpw' 自动转义。CREATE EXTENSION 幂等,
|
||||
# 若数据库角色无建扩展权限会在此报错(提示见下方 run 的失败分支)。
|
||||
SQL=$(cat <<SQL
|
||||
\\set ON_ERROR_STOP on
|
||||
\\getenv newpw ARTEX_RESET_NEWPASS
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
INSERT INTO settings(key, value)
|
||||
VALUES ('$PASS_KEY', crypt(:'newpw', gen_salt('bf', $BCRYPT_COST)))
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now();
|
||||
SQL
|
||||
)
|
||||
|
||||
# ---- 执行 -----------------------------------------------------------------
|
||||
if [[ "$MODE" == "local" ]]; then
|
||||
# 连接信息优先级:命令行 > --dsn/$ARTEX_PG_DSN > config.json
|
||||
if [[ -z "$DSN" && -z "$HOST$USER$DBNAME" ]]; then
|
||||
[[ -n "${ARTEX_PG_DSN:-}" ]] && DSN="$ARTEX_PG_DSN"
|
||||
fi
|
||||
if [[ -z "$DSN" && -z "$HOST$USER$DBNAME" ]]; then
|
||||
cfg="${CONFIG:-config.json}"
|
||||
if [[ -f "$cfg" ]]; then
|
||||
info "从 $cfg 读取数据库配置"
|
||||
apply_config_fields < <(read_config_json "$cfg")
|
||||
fi
|
||||
fi
|
||||
|
||||
command -v psql >/dev/null 2>&1 || die "本机未找到 psql(请安装 postgresql-client,或改用 -m docker)"
|
||||
|
||||
declare -a PSQL_ARGS=()
|
||||
if [[ -n "$DSN" ]]; then
|
||||
PSQL_ARGS=("$DSN")
|
||||
target="$DSN"
|
||||
else
|
||||
[[ -n "$USER" ]] || die "缺少数据库用户(-U)或有效的 config.json/DSN"
|
||||
[[ -n "$DBNAME" ]] || die "缺少数据库名(-d)或有效的 config.json/DSN"
|
||||
HOST="${HOST:-127.0.0.1}"; PORT="${PORT:-5432}"; SSLMODE="${SSLMODE:-disable}"
|
||||
PSQL_ARGS=(-h "$HOST" -p "$PORT" -U "$USER" -d "$DBNAME")
|
||||
[[ -n "$SSLMODE" ]] && export PGSSLMODE="$SSLMODE"
|
||||
[[ -n "$DBPASS" ]] && export PGPASSWORD="$DBPASS"
|
||||
target="$USER@$HOST:$PORT/$DBNAME"
|
||||
fi
|
||||
|
||||
info "目标数据库:$target"
|
||||
if [[ "$ASSUME_YES" -ne 1 ]]; then
|
||||
read -r -p "确认在该库重置 ARTEX 密码?[y/N] " ans
|
||||
[[ "$ans" == "y" || "$ans" == "Y" ]] || die "已取消"
|
||||
fi
|
||||
|
||||
if ! printf '%s\n' "$SQL" | psql "${PSQL_ARGS[@]}" -v ON_ERROR_STOP=1 -q >/dev/null; then
|
||||
die "写入失败。若报 pgcrypto 权限/缺失,请用具备建扩展权限的角色,或先手动执行 CREATE EXTENSION pgcrypto。"
|
||||
fi
|
||||
|
||||
else
|
||||
# ---- docker ----
|
||||
command -v docker >/dev/null 2>&1 || die "未找到 docker"
|
||||
CONTAINER="${CONTAINER:-postgres}"
|
||||
|
||||
# 选择 exec 方式:优先 docker compose exec(服务名),否则 docker exec(容器名)
|
||||
if [[ -z "$EXEC_KIND" ]]; then
|
||||
if docker compose version >/dev/null 2>&1 && [[ -f docker-compose.yml ]]; then
|
||||
EXEC_KIND="compose"
|
||||
else
|
||||
EXEC_KIND="docker"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 容器内的 psql 凭据:优先命令行,其次 .env 的 POSTGRES_*,再退回 compose 默认(artex)
|
||||
if [[ -f .env ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
set -a; . ./.env; set +a
|
||||
fi
|
||||
DUSER="${USER:-${POSTGRES_USER:-artex}}"
|
||||
DNAME="${DBNAME:-${POSTGRES_DB:-artex}}"
|
||||
[[ -n "$DBPASS" ]] && export PGPASSWORD="$DBPASS"
|
||||
[[ -z "${PGPASSWORD:-}" && -n "${POSTGRES_PASSWORD:-}" ]] && export PGPASSWORD="$POSTGRES_PASSWORD"
|
||||
|
||||
info "目标:容器 $CONTAINER 内 psql -U $DUSER -d $DNAME(exec=$EXEC_KIND)"
|
||||
if [[ "$ASSUME_YES" -ne 1 ]]; then
|
||||
read -r -p "确认在该容器数据库重置 ARTEX 密码?[y/N] " ans
|
||||
[[ "$ans" == "y" || "$ans" == "Y" ]] || die "已取消"
|
||||
fi
|
||||
|
||||
# -e 只带名字不带值 → 从当前环境继承,密码不出现在 docker 命令 argv 里。
|
||||
declare -a EXEC_CMD
|
||||
if [[ "$EXEC_KIND" == "compose" ]]; then
|
||||
EXEC_CMD=(docker compose exec -T -e ARTEX_RESET_NEWPASS -e PGPASSWORD "$CONTAINER"
|
||||
psql -U "$DUSER" -d "$DNAME" -v ON_ERROR_STOP=1 -q)
|
||||
else
|
||||
EXEC_CMD=(docker exec -i -e ARTEX_RESET_NEWPASS -e PGPASSWORD "$CONTAINER"
|
||||
psql -U "$DUSER" -d "$DNAME" -v ON_ERROR_STOP=1 -q)
|
||||
fi
|
||||
|
||||
if ! printf '%s\n' "$SQL" | "${EXEC_CMD[@]}" >/dev/null; then
|
||||
die "写入失败。请确认容器名(-c)、数据库账号(.env 的 POSTGRES_*),以及角色有 pgcrypto 权限。"
|
||||
fi
|
||||
fi
|
||||
|
||||
unset ARTEX_RESET_NEWPASS
|
||||
echo "✓ 已重置 ARTEX 管理员密码。请用用户名 ARTEX + 新密码登录(无需重启服务)。"
|
||||
@@ -0,0 +1,133 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/llmrec"
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
"github.com/Autumn-27/norma/transcript"
|
||||
)
|
||||
|
||||
// Full chain against a real PG: Recorder → norma provider → capturing transport
|
||||
// → llm_records. The unit tests cover each hop; this proves the raw bodies
|
||||
// actually survive all the way into the row an operator opens when debugging.
|
||||
func TestRecorderPersistsRawWireBodies(t *testing.T) {
|
||||
m, err := NewManager(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer m.Close()
|
||||
pg := m.PG()
|
||||
if err := pg.EnsureLLMRecordsTable(); err != nil {
|
||||
t.Fatalf("ensure table: %v", err)
|
||||
}
|
||||
|
||||
sse := strings.Join([]string{
|
||||
`event: message_start`,
|
||||
`data: {"type":"message_start","message":{"id":"msg_raw","usage":{"input_tokens":5,"output_tokens":1}}}`,
|
||||
``,
|
||||
`event: content_block_delta`,
|
||||
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"probing"}}`,
|
||||
``,
|
||||
// A tool_use block: the normalized response body drops these entirely, so
|
||||
// its presence in raw_response is the whole point of the feature.
|
||||
`event: content_block_start`,
|
||||
`data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_raw","name":"bash"}}`,
|
||||
``,
|
||||
`event: content_block_delta`,
|
||||
`data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"command\":\"id\"}"}}`,
|
||||
``,
|
||||
`event: message_delta`,
|
||||
`data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":9}}`,
|
||||
``,
|
||||
`event: message_stop`,
|
||||
`data: {"type":"message_stop"}`,
|
||||
``,
|
||||
}, "\n")
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("content-type", "text/event-stream")
|
||||
_, _ = io.WriteString(w, sse)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := agent.Config{Format: llm.FormatAnthropic, BaseURL: srv.URL, APIKey: "k", Model: "claude-raw-test"}
|
||||
inner, err := cfg.NewProvider()
|
||||
if err != nil {
|
||||
t.Fatalf("NewProvider: %v", err)
|
||||
}
|
||||
const session = "exp999999-rawtest"
|
||||
rec := llmrec.Wrap(inner, pg, cfg.Model, "raw-test-profile", "", "", func() bool { return true })
|
||||
|
||||
// Clean up whatever this test writes, whether or not it passes. Must be a
|
||||
// defer registered after `defer m.Close()` (LIFO puts it first) — a
|
||||
// t.Cleanup would run after the manager already closed the pool.
|
||||
defer func() {
|
||||
if _, err := pg.Exec(`DELETE FROM llm_records WHERE session_id = $1`, session); err != nil {
|
||||
t.Logf("cleanup: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx := transcript.WithSessionID(context.Background(), session)
|
||||
for _, err := range rec.Stream(ctx, llm.CompletionRequest{
|
||||
System: []string{"system prompt"},
|
||||
Messages: []llm.Message{{Role: "user", Content: []llm.ContentBlock{{Type: "text", Text: "go"}}}},
|
||||
Tools: []llm.ToolSchema{{
|
||||
Name: "bash",
|
||||
Description: "run a shell command",
|
||||
InputSchema: map[string]any{"type": "object"},
|
||||
}},
|
||||
MaxTokens: 512,
|
||||
}) {
|
||||
if err != nil {
|
||||
t.Fatalf("stream: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var id int64
|
||||
if err := pg.QueryRow(`SELECT id FROM llm_records WHERE session_id=$1 ORDER BY id DESC LIMIT 1`, session).Scan(&id); err != nil {
|
||||
t.Fatalf("no record written: %v", err)
|
||||
}
|
||||
got, err := pg.GetLLMRecord(id)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLLMRecord: %v", err)
|
||||
}
|
||||
|
||||
if got.RawResponse != sse {
|
||||
t.Errorf("raw_response is not the wire bytes:\n got: %q\nwant: %q", got.RawResponse, sse)
|
||||
}
|
||||
// The tool call is absent from the normalized body but present in the raw one
|
||||
// — the specific gap this feature closes.
|
||||
if strings.Contains(got.ResponseBody, "toolu_raw") {
|
||||
t.Error("normalized body unexpectedly contains the tool_use block; test no longer proves the gap")
|
||||
}
|
||||
if !strings.Contains(got.RawResponse, "toolu_raw") {
|
||||
t.Error("raw_response lost the tool_use block")
|
||||
}
|
||||
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal([]byte(got.RawRequest), &body); err != nil {
|
||||
t.Fatalf("raw_request is not valid JSON: %v\n%s", err, got.RawRequest)
|
||||
}
|
||||
if body["model"] != "claude-raw-test" {
|
||||
t.Errorf("raw_request model=%v", body["model"])
|
||||
}
|
||||
tools, _ := body["tools"].([]any)
|
||||
if len(tools) != 1 {
|
||||
t.Fatalf("raw_request tools=%v", body["tools"])
|
||||
}
|
||||
if tool, _ := tools[0].(map[string]any); tool["input_schema"] == nil {
|
||||
t.Errorf("raw_request lost the tool schema: %v", tool)
|
||||
}
|
||||
// The normalized request keeps tool names only, so the schema is unique to raw.
|
||||
if strings.Contains(got.RequestBody, "input_schema") {
|
||||
t.Error("normalized request unexpectedly carries schemas; test no longer proves the gap")
|
||||
}
|
||||
}
|
||||
@@ -573,6 +573,66 @@ func (s *Server) reseedMainAgentPrompt() {
|
||||
log.Printf("[prompts] mainagent 提示词已追加新默认版本(加入目标达成后反问建目标,一次性)")
|
||||
}
|
||||
|
||||
// reseedPlannerPrompt 把 planner 提示词刷成【当前代码默认】——默认正文重写了「0 意图」的正当理由
|
||||
// (从笼统"最常见"改为"仅在已覆盖/等待在跑 work 依赖时才 0 意图")、并新增「量化验收核对」(覆盖度等
|
||||
// 可量化目标未达标禁止 prove_goal)。SeedPromptIfEmpty 首插入only,旧库已有版本收不到,故用版本管理
|
||||
// 【追加一个新版本】并切过去(ResetPromptToDefault),旧版本仍保留在历史里,用户若自定义过可从版本记录
|
||||
// 找回。settings flag 守卫 → 只做一次。全新库无需处理(SeedPromptIfEmpty 已 seed 最新默认)。与
|
||||
// reseedGoalsPrompt 完全同构。
|
||||
func (s *Server) reseedPlannerPrompt() {
|
||||
const flag = "planner_prompt_zerointent_acceptance_v1"
|
||||
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
|
||||
return
|
||||
}
|
||||
defer func() { _ = s.m.pg.SetSetting(flag, "true") }() // 无论成功与否只尝试一次
|
||||
a, err := s.m.pg.GetAgentByKey("planner")
|
||||
if err != nil || a == nil {
|
||||
return // 全新库尚未建 agent 行时,seedPrompts 会直接 seed 最新默认,无需此迁移
|
||||
}
|
||||
tmpl := agent.BuiltinPromptSeeds()["planner"]
|
||||
if tmpl == "" {
|
||||
return
|
||||
}
|
||||
// 全新库 seedPrompts 已 seed 最新默认 → 当前版本已等于代码默认,不必再追加重复版本。
|
||||
if cur, err := s.m.pg.CurrentPrompt(a.ID); err == nil && cur == tmpl {
|
||||
return
|
||||
}
|
||||
if _, err := s.m.pg.ResetPromptToDefault(a.ID, tmpl); err != nil {
|
||||
log.Printf("[prompts] planner 提示词重刷为新默认失败: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("[prompts] planner 提示词已追加新默认版本(重写0意图理由+加量化验收核对,一次性)")
|
||||
}
|
||||
|
||||
// reseedWorkerPrompt 把 worker 提示词刷成【当前代码默认】——默认正文强化了「否定结论的证据门槛」
|
||||
// (未穷尽手段/证据弱一律标 inferred,别用轻率 observed 否定焊死路线)。SeedPromptIfEmpty 首插入only,
|
||||
// 旧库已有版本收不到,故用版本管理【追加一个新版本】并切过去,旧版本仍保留在历史里可找回。settings flag
|
||||
// 守卫 → 只做一次。全新库无需处理。与 reseedGoalsPrompt 完全同构。
|
||||
func (s *Server) reseedWorkerPrompt() {
|
||||
const flag = "worker_prompt_negfact_threshold_v1"
|
||||
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
|
||||
return
|
||||
}
|
||||
defer func() { _ = s.m.pg.SetSetting(flag, "true") }() // 无论成功与否只尝试一次
|
||||
a, err := s.m.pg.GetAgentByKey("worker")
|
||||
if err != nil || a == nil {
|
||||
return // 全新库尚未建 agent 行时,seedPrompts 会直接 seed 最新默认,无需此迁移
|
||||
}
|
||||
tmpl := agent.BuiltinPromptSeeds()["worker"]
|
||||
if tmpl == "" {
|
||||
return
|
||||
}
|
||||
// 全新库 seedPrompts 已 seed 最新默认 → 当前版本已等于代码默认,不必再追加重复版本。
|
||||
if cur, err := s.m.pg.CurrentPrompt(a.ID); err == nil && cur == tmpl {
|
||||
return
|
||||
}
|
||||
if _, err := s.m.pg.ResetPromptToDefault(a.ID, tmpl); err != nil {
|
||||
log.Printf("[prompts] worker 提示词重刷为新默认失败: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("[prompts] worker 提示词已追加新默认版本(加否定结论证据门槛,一次性)")
|
||||
}
|
||||
|
||||
// seedReporterAgent 预置一个「报告撰写」自定义 agent(builtin=false,可在 UI 编辑/删除):
|
||||
// 绑定 update_finding_report + 任务查询工具,并挂一个「report_finding 被调用即触发」的
|
||||
// 触发器 —— 每登记一个漏洞就唤起它写详细报告。一次性(settings flag 守卫):用户删掉后不再重建。
|
||||
|
||||
+2
-3
@@ -323,10 +323,9 @@ func (s *Server) loadLLMConfig() (agent.Config, bool) {
|
||||
|
||||
// saveLLMConfig persists the LLM config as the active "default" profile in PG.
|
||||
func (s *Server) saveLLMConfig(cfg agent.Config) error {
|
||||
// cfg.Provider() already returns one of the three valid format strings
|
||||
// (anthropic / openai / openai-responses), matching the DB CHECK constraint.
|
||||
format := cfg.Provider()
|
||||
if format != "anthropic" {
|
||||
format = "openai"
|
||||
}
|
||||
var id int64
|
||||
// agent.Config carries no failover fields, so carry the stored ones forward —
|
||||
// otherwise this legacy endpoint would silently reset the profile's priority,
|
||||
|
||||
@@ -1926,11 +1926,13 @@ func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var candidates []candidate
|
||||
switch provider {
|
||||
case "openai":
|
||||
case "openai", "openai-responses":
|
||||
// Responses API shares the OpenAI model list at /v1/models; tolerate a full
|
||||
// endpoint URL of either format.
|
||||
if baseURL == "" {
|
||||
baseURL = "https://api.openai.com/v1"
|
||||
}
|
||||
b := strings.TrimRight(strings.TrimSuffix(baseURL, "/chat/completions"), "/")
|
||||
b := strings.TrimRight(strings.TrimSuffix(strings.TrimSuffix(baseURL, "/chat/completions"), "/responses"), "/")
|
||||
candidates = append(candidates, candidate{b + "/models", bearerHdr()})
|
||||
default: // anthropic
|
||||
if baseURL == "" {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ArrowUpIcon,
|
||||
Bot,
|
||||
ChevronDownIcon,
|
||||
ListChecksIcon,
|
||||
Loader2Icon,
|
||||
MoreHorizontalIcon,
|
||||
PaperclipIcon,
|
||||
@@ -763,6 +764,7 @@ const ConversationItem = React.memo(function ConversationItem({
|
||||
onCancelRename,
|
||||
onTogglePinned,
|
||||
onDelete,
|
||||
selectionMode,
|
||||
selectedForDelete,
|
||||
onSelectedForDeleteChange,
|
||||
}: {
|
||||
@@ -778,6 +780,7 @@ const ConversationItem = React.memo(function ConversationItem({
|
||||
onCancelRename: () => void;
|
||||
onTogglePinned: (conversation: Conversation) => void;
|
||||
onDelete: (id: number) => void;
|
||||
selectionMode: boolean;
|
||||
selectedForDelete: boolean;
|
||||
onSelectedForDeleteChange: (id: number, checked: boolean) => void;
|
||||
}) {
|
||||
@@ -800,12 +803,14 @@ const ConversationItem = React.memo(function ConversationItem({
|
||||
active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedForDelete}
|
||||
onCheckedChange={(checked) => onSelectedForDeleteChange(conv.id, checked === true)}
|
||||
aria-label={`选择对话「${conv.title || "新对话"}」`}
|
||||
className="ml-1 shrink-0"
|
||||
/>
|
||||
{selectionMode && (
|
||||
<Checkbox
|
||||
checked={selectedForDelete}
|
||||
onCheckedChange={(checked) => onSelectedForDeleteChange(conv.id, checked === true)}
|
||||
aria-label={`选择对话「${conv.title || "新对话"}」`}
|
||||
className="ml-1 shrink-0"
|
||||
/>
|
||||
)}
|
||||
{renaming ? (
|
||||
<input
|
||||
ref={renameInputRef}
|
||||
@@ -907,6 +912,10 @@ export default function ChatPage() {
|
||||
const [renamingId, setRenamingId] = React.useState<number | null>(null);
|
||||
const [renameText, setRenameText] = React.useState("");
|
||||
const [selectedConversationIds, setSelectedConversationIds] = React.useState<Set<number>>(() => new Set());
|
||||
// selectionMode gates the multi-select UI: off by default (clean list, no
|
||||
// checkboxes); the header "多选" button turns it on, "完成" turns it off and
|
||||
// clears the selection.
|
||||
const [selectionMode, setSelectionMode] = React.useState(false);
|
||||
const [bulkDeleteOpen, setBulkDeleteOpen] = React.useState(false);
|
||||
const [bulkDeleting, setBulkDeleting] = React.useState(false);
|
||||
const [visibleConversationCount, setVisibleConversationCount] = React.useState(CONVERSATION_LIST_PAGE);
|
||||
@@ -998,6 +1007,11 @@ export default function ChatPage() {
|
||||
setSelectedConversationIds(checked ? new Set(convs.map((conversation) => conversation.id)) : new Set());
|
||||
}
|
||||
|
||||
function exitSelectionMode() {
|
||||
setSelectionMode(false);
|
||||
setSelectedConversationIds(new Set());
|
||||
}
|
||||
|
||||
const del = React.useCallback(
|
||||
async (id: number) => {
|
||||
try {
|
||||
@@ -1046,6 +1060,9 @@ export default function ChatPage() {
|
||||
toast.error(`${failed.length} 个对话删除失败:${details}${failed.length > 3 ? " 等" : ""}`);
|
||||
}
|
||||
setBulkDeleteOpen(false);
|
||||
// Fully successful → return to the clean list; keep selection mode on if
|
||||
// some failed so the user can retry the remaining ones.
|
||||
if (failed.length === 0) setSelectionMode(false);
|
||||
reloadConvs();
|
||||
} catch (error) {
|
||||
toast.error(`批量删除失败:${(error as Error).message}`);
|
||||
@@ -1100,29 +1117,48 @@ export default function ChatPage() {
|
||||
<Button size="sm" className="w-full" onClick={() => setSelectedId(null)}>
|
||||
<PlusIcon /> 新建对话
|
||||
</Button>
|
||||
{convs.length > 0 && (
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Checkbox
|
||||
checked={conversationHeaderChecked}
|
||||
onCheckedChange={(checked) => toggleAllConversations(checked === true)}
|
||||
aria-label="选择全部对话"
|
||||
/>
|
||||
<span className="text-muted-foreground min-w-0 flex-1 text-xs tabular-nums">
|
||||
{selectedConversationCount > 0 ? `已选 ${selectedConversationCount} 个` : `共 ${convs.length} 个`}
|
||||
</span>
|
||||
{selectedConversationCount > 0 && (
|
||||
{convs.length > 0 &&
|
||||
(selectionMode ? (
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Checkbox
|
||||
checked={conversationHeaderChecked}
|
||||
onCheckedChange={(checked) => toggleAllConversations(checked === true)}
|
||||
aria-label="选择全部对话"
|
||||
/>
|
||||
<span className="text-muted-foreground min-w-0 flex-1 text-xs tabular-nums">
|
||||
{selectedConversationCount > 0 ? `已选 ${selectedConversationCount} 个` : `共 ${convs.length} 个`}
|
||||
</span>
|
||||
{selectedConversationCount > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={bulkDeleting}
|
||||
onClick={() => setBulkDeleteOpen(true)}
|
||||
>
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
删除
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" onClick={exitSelectionMode}>
|
||||
完成
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<span className="text-muted-foreground min-w-0 flex-1 text-xs tabular-nums">
|
||||
共 {convs.length} 个
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
disabled={bulkDeleting}
|
||||
onClick={() => setBulkDeleteOpen(true)}
|
||||
variant="ghost"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setSelectionMode(true)}
|
||||
>
|
||||
<Trash2Icon data-icon="inline-start" />
|
||||
删除
|
||||
<ListChecksIcon data-icon="inline-start" />
|
||||
多选
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ScrollArea type="auto" className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!">
|
||||
<div className="flex min-w-0 flex-col gap-0.5 p-2">
|
||||
@@ -1142,6 +1178,7 @@ export default function ChatPage() {
|
||||
onCancelRename={cancelRename}
|
||||
onTogglePinned={togglePinned}
|
||||
onDelete={del}
|
||||
selectionMode={selectionMode}
|
||||
selectedForDelete={selectedConversationIds.has(c.id)}
|
||||
onSelectedForDeleteChange={toggleConversationSelected}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
Loader2Icon,
|
||||
XIcon,
|
||||
Trash2Icon,
|
||||
CopyIcon,
|
||||
CheckIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -74,6 +76,47 @@ function tryFormatJSON(s: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// 复制当前框内文本的小按钮。复制成功后短暂显示对勾。text 为空/仅占位符时禁用。
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
React.useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
|
||||
|
||||
const disabled = !text;
|
||||
const copy = async () => {
|
||||
if (disabled) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
// navigator.clipboard 在非安全上下文(如 http 局域网)不可用,回退到 execCommand。
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = text;
|
||||
ta.style.position = "fixed";
|
||||
ta.style.opacity = "0";
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand("copy"); } catch { /* 忽略:不支持则静默 */ }
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
setCopied(true);
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5 shrink-0"
|
||||
disabled={disabled}
|
||||
title={copied ? "已复制" : "复制内容"}
|
||||
onClick={copy}
|
||||
>
|
||||
{copied ? <CheckIcon className="size-3 text-emerald-600" /> : <CopyIcon className="size-3" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const PAGE_SIZES = [25, 50, 100];
|
||||
|
||||
export default function LLMRecordsPage() {
|
||||
@@ -96,6 +139,21 @@ export default function LLMRecordsPage() {
|
||||
const [selected, setSelected] = React.useState<LLMRecordItem | null>(null);
|
||||
const [detail, setDetail] = React.useState<LLMRecordDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = React.useState(false);
|
||||
// 归一化视图 / HTTP 原文视图。原文是排查 provider 侧问题的唯一依据:归一化视图
|
||||
// 不含工具 schema,响应里也没有 tool_use 块。
|
||||
const [rawView, setRawView] = React.useState(false);
|
||||
|
||||
const hasRaw = !!(detail?.raw_request || detail?.raw_response);
|
||||
// 开关保持用户选择,但切到一条无原文的旧记录时自动落回解析视图,而不是显示空白。
|
||||
const showRaw = rawView && hasRaw;
|
||||
// 原文请求体是 JSON,pretty-print 只改排版不改语义,便于阅读;原文响应是 SSE
|
||||
// 帧,tryFormatJSON 解析失败会原样返回,故两边共用一个函数即可。
|
||||
const reqText = showRaw
|
||||
? detail?.raw_request && tryFormatJSON(detail.raw_request)
|
||||
: detail?.request_body && tryFormatJSON(detail.request_body);
|
||||
const respText = showRaw
|
||||
? detail?.raw_response
|
||||
: detail?.response_body && tryFormatJSON(detail.response_body);
|
||||
|
||||
// Per-task delete (task picker + confirm dialog)
|
||||
const [tasks, setTasks] = React.useState<LLMTask[]>([]);
|
||||
@@ -427,10 +485,22 @@ export default function LLMRecordsPage() {
|
||||
) : (
|
||||
<Badge variant="destructive" className="text-xs">Error</Badge>
|
||||
)}
|
||||
{/* 原文视图开关。旧记录没有原文,此时禁用而非静默回退,避免看着像
|
||||
「原文与解析一致」。 */}
|
||||
<Button
|
||||
variant={showRaw ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className="ml-auto h-7 shrink-0 text-xs"
|
||||
disabled={!hasRaw}
|
||||
title={hasRaw ? "查看与 provider 实际收发的 HTTP 原文" : "该记录录制于此功能上线前,无原文"}
|
||||
onClick={() => setRawView((v) => !v)}
|
||||
>
|
||||
原文
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-auto size-7 shrink-0"
|
||||
className="size-7 shrink-0"
|
||||
onClick={() => setSelected(null)}
|
||||
>
|
||||
<XIcon />
|
||||
@@ -439,8 +509,9 @@ export default function LLMRecordsPage() {
|
||||
{/* Request / Response split */}
|
||||
<div className="grid min-h-0 flex-1 grid-cols-2 divide-x">
|
||||
<div className="flex min-h-0 min-w-0 flex-col">
|
||||
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
Request
|
||||
<div className="flex items-center gap-2 border-b py-0.5 pr-1.5 pl-3 text-[11px] font-medium text-muted-foreground">
|
||||
<span>Request{showRaw && " · 原文"}</span>
|
||||
<CopyButton text={reqText || ""} />
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{detailLoading ? (
|
||||
@@ -450,14 +521,15 @@ export default function LLMRecordsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
|
||||
{detail?.request_body ? tryFormatJSON(detail.request_body) : "(空)"}
|
||||
{reqText || "(空)"}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-0 min-w-0 flex-col">
|
||||
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground">
|
||||
Response
|
||||
<div className="flex items-center gap-2 border-b py-0.5 pr-1.5 pl-3 text-[11px] font-medium text-muted-foreground">
|
||||
<span>Response{showRaw && " · 原文(SSE)"}</span>
|
||||
<CopyButton text={respText || ""} />
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{detailLoading ? (
|
||||
@@ -470,7 +542,7 @@ export default function LLMRecordsPage() {
|
||||
"p-3 font-mono text-xs break-all whitespace-pre-wrap",
|
||||
selected.status !== "ok" && "text-red-600 dark:text-red-400",
|
||||
)}>
|
||||
{detail?.response_body ? tryFormatJSON(detail.response_body) : "(空)"}
|
||||
{respText || "(空)"}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -296,7 +296,7 @@ function ProfileSheet({
|
||||
}) {
|
||||
const isNew = !profile;
|
||||
const [name, setName] = React.useState("");
|
||||
const [format, setFormat] = React.useState<"anthropic" | "openai">("anthropic");
|
||||
const [format, setFormat] = React.useState<"anthropic" | "openai" | "openai-responses">("anthropic");
|
||||
const [model, setModel] = React.useState("");
|
||||
const [baseUrl, setBaseUrl] = React.useState("");
|
||||
const [proxy, setProxy] = React.useState("");
|
||||
@@ -321,7 +321,9 @@ function ProfileSheet({
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(profile?.name ?? "");
|
||||
setFormat(profile?.format === "openai" ? "openai" : "anthropic");
|
||||
setFormat(
|
||||
profile?.format === "openai" || profile?.format === "openai-responses" ? profile.format : "anthropic",
|
||||
);
|
||||
setModel(profile?.model ?? "");
|
||||
setBaseUrl(profile?.base_url ?? "");
|
||||
setProxy(profile?.proxy ?? "");
|
||||
@@ -462,13 +464,14 @@ function ProfileSheet({
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>格式</Label>
|
||||
<Select value={format} onValueChange={(v) => setFormat(v as "anthropic" | "openai")}>
|
||||
<Select value={format} onValueChange={(v) => setFormat(v as "anthropic" | "openai" | "openai-responses")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择格式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="anthropic">Anthropic</SelectItem>
|
||||
<SelectItem value="openai">OpenAI</SelectItem>
|
||||
<SelectItem value="openai">OpenAI (Chat Completions)</SelectItem>
|
||||
<SelectItem value="openai-responses">OpenAI (Responses API)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -3766,5 +3766,41 @@ export function llmRecordDetail(id: number, records = llmRecords) {
|
||||
null,
|
||||
2,
|
||||
),
|
||||
// HTTP 原文:请求含被归一化视图丢弃的完整工具 schema,响应为原始 SSE 帧。
|
||||
raw_request: JSON.stringify({
|
||||
model: item.model,
|
||||
max_tokens: 8192,
|
||||
stream: true,
|
||||
system: [{ type: "text", text: "你是一个授权渗透测试系统的「执行者」…(省略)", cache_control: { type: "ephemeral" } }],
|
||||
messages: [{ role: "user", content: "开始执行 system 提示里的这条意图:只做它、只产生事实、做完即停。" }],
|
||||
tools: [
|
||||
{
|
||||
name: "bash",
|
||||
description: "在目标环境中执行一条 shell 命令并返回其输出。",
|
||||
input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] },
|
||||
},
|
||||
],
|
||||
thinking: { type: "enabled" },
|
||||
}),
|
||||
raw_response: [
|
||||
`event: message_start`,
|
||||
`data: {"type":"message_start","message":{"id":"msg_01mock","model":"${item.model}","usage":{"input_tokens":${item.input_tokens},"output_tokens":1}}}`,
|
||||
``,
|
||||
`event: content_block_delta`,
|
||||
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"对 search?q= 做注入探测。"}}`,
|
||||
``,
|
||||
`event: content_block_start`,
|
||||
`data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01mock","name":"bash"}}`,
|
||||
``,
|
||||
`event: content_block_delta`,
|
||||
`data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\\"command\\":\\"curl -s 'https://www.acme.com/search?q=1%27'\\"}"}}`,
|
||||
``,
|
||||
`event: message_delta`,
|
||||
`data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":${item.output_tokens}}}`,
|
||||
``,
|
||||
`event: message_stop`,
|
||||
`data: {"type":"message_stop"}`,
|
||||
``,
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
@@ -852,7 +852,7 @@ export interface Settings {
|
||||
export interface LLMProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
format: "openai" | "anthropic";
|
||||
format: "openai" | "anthropic" | "openai-responses";
|
||||
base_url?: string;
|
||||
proxy?: string;
|
||||
model: string;
|
||||
@@ -1163,6 +1163,11 @@ export interface LLMRecordItem {
|
||||
export interface LLMRecordDetail extends LLMRecordItem {
|
||||
request_body: string;
|
||||
response_body: string;
|
||||
// provider 实际收发的 HTTP 原文:请求为 buildBody() 发出的完整 body(含工具
|
||||
// schema),响应为原始 SSE 帧。上面的 request_body/response_body 是归一化视图,
|
||||
// 丢弃了工具 schema 与 tool_use 块。旧记录为空。
|
||||
raw_request?: string;
|
||||
raw_response?: string;
|
||||
}
|
||||
|
||||
// One distinct task with its LLM-record count (task picker on the records page).
|
||||
|
||||
Reference in new issue
Block a user