diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d71dbd..1448638 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## v1.5.8 (2026-08-19) - **Action YAML**: 增加无状态解析、保注释 Token 补丁和 JSON 到 YAML 转换能力,不绑定存储或内置第三方服务定义。 +- **可选 Timing**: `CallOptions.Timing` 按需返回毫秒级 `total`,流式调用额外返回首个响应数据块的 `firstToken`。 +- **JavaScript 结果一致性**: `api.Call` 的完整结果统一投影为 lower-camel Map,避免 Go 结构体字段名泄露到低代码层。 - **依赖对齐**: 升级 `file`、`id`、`rand` 和 `shell` 到当前稳定补丁版本。 ## v1.5.7 (2026-08-17) diff --git a/README.md b/README.md index 60ba769..2dcb387 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ go get apigo.cc/go/api * `ConfigurableAction`:提供硬编码的默认参数或元数据。 * `URLAction` / `MethodAction`:动态指定 Endpoint 和 HTTP 方法。 * `ValidatableAction`:业务参数自校验。 -* `CallOptions`:提供内部 Token、超时、临时配置覆盖、Logger 与流式回调。 -* `Result`:统一返回 `ok/statusCode/headers/data/code/error`,业务响应保留在 `data`。 +* `CallOptions`:提供内部 Token、超时、临时配置覆盖、Logger、流式回调与可选 Timing。 +* `Result`:统一返回 `ok/statusCode/headers/data/code/error`,开启 Timing 时额外返回 `timing`;业务响应保留在 `data`。 * `RegisterAction` / `RemoveAction`:动态注册和热更新数据驱动的 Action。 * `RegisterJSSigner` / `RemoveSigner`:管理 JavaScript Signer。 * `RegisterJSFilter` / `RemoveFilter`:管理可复用的 JavaScript 请求/响应 Filter。 @@ -77,6 +77,8 @@ result, err := api.CallBy("openai", payload, &api.CallOptions{ `tokens` 未配置或为空时无需 Token。`tokens/enabled/test/logging` 与原配置中的密文字段不能通过 `options.config` 覆盖。流式调用使用 `go/http.ManualDo`,`OnHeaders` 与 `OnDone` 接收 lower-camel map,`OnData` 接收原始字节块。 +调用时设置 `CallOptions.Timing=true` 可获得毫秒级粗粒度诊断信息:所有调用返回 `timing.total`,流式调用还返回从调用开始到首个响应数据块的 `timing.firstToken`。默认不采集也不返回 Timing;`firstToken` 表示首个网络数据块,并非协议解析后的精确模型 Token。 + Action 只使用 `filters: ["name"]` 这一种形式。每个 Filter 都会收到前置 `request` 以及后置 `headers/chunk/result/done` 事件,并自行根据 `event` 决定是否处理。流式 Filter 应通过输入/输出的 `state` 保存单次调用状态,不能假设 HTTP chunk 与 SSE 或 JSONL 消息边界一致。JavaScript Filter 的 `chunk` 是 UTF-8 字符串;Go Filter 仍接收原始 `[]byte`,二进制响应不应挂载文本型 JavaScript Filter。 动态 Action 可通过 `extends` 继承另一个 Action;父配置先合并,子配置深度覆盖。继承在每次调用时解析,因此父 Action 热更新会立即作用于子 Action。循环继承或不存在的父 Action 会直接返回错误。 diff --git a/TEST.md b/TEST.md index 9c8d229..8efbb9a 100644 --- a/TEST.md +++ b/TEST.md @@ -28,8 +28,8 @@ ### 5. 动态策略与统一结果 (`TestTokenAndOverridePolicy`, `TestResultRulesAndConfigOverride`) 验证 Action 内部 Token、控制字段黑名单、密文字段不可覆盖、URL/Method 临时覆盖,以及 `codeFields/successCodes/errorFields` 对统一结果的判断。 -### 6. 流式调用 (`TestStreamPreservesChunkOrder`) -使用 `ManualDo` 验证流式 Body 的分块顺序、同步回调、完成事件和统一结果状态。 +### 6. 流式调用与可选 Timing (`TestStreamPreservesChunkOrder`, `TestOptionalTiming`) +使用 `ManualDo` 验证流式 Body 的分块顺序、同步回调、完成事件和统一结果状态;验证 Timing 默认关闭,开启后返回总耗时和流式首个响应数据块耗时。 ### 7. 动态 Action 继承与统一 Filter 管线 @@ -47,7 +47,7 @@ 使用 `go test -bench=. ./...` 评估框架调用阶段的开销。 > **基准**: Darwin / Apple M3 Max -* `BenchmarkCallEngineLogic-16`:约 **115.5 ns/op**, **80 B/op**, **2 allocs/op**。 +* `BenchmarkCallEngineLogic-16`:约 **117.2 ns/op**, **80 B/op**, **2 allocs/op**。 该指标证明引擎的参数合并、注入及校验流程具有极高的运行效率和极小的内存逃逸。 ## 🚀 运行测试 @@ -61,5 +61,5 @@ go test -bench=. ./... ``` --- -最后测试日期:2026-08-19 +最后测试日期:2026-08-21 状态:PASS diff --git a/action.go b/action.go index b98b7e7..73d1d48 100644 --- a/action.go +++ b/action.go @@ -224,5 +224,6 @@ type Result struct { Data any Code string Error string + Timing map[string]any receivedBytes int64 } diff --git a/engine.go b/engine.go index 6065e4b..d5ca202 100644 --- a/engine.go +++ b/engine.go @@ -25,6 +25,12 @@ func Call(action Action, options ...*CallOptions) (*Result, error) { opts := firstOptions(options) started := time.Now() result := &Result{Headers: map[string]string{}} + finishTiming := func() {} + if opts.Timing { + result.Timing = map[string]any{"unit": "ms"} + finishTiming = func() { result.Timing["total"] = time.Since(started).Milliseconds() } + defer finishTiming() + } actionConfig, _ := GetActionConfig(action.ActionName()) if ca, ok := action.(ConfigurableAction); ok { MergeMap(actionConfig, ca.Config()) @@ -91,6 +97,9 @@ func Call(action Action, options ...*CallOptions) (*Result, error) { actionConfig = filteredConfig } } + if cast.Bool(actionConfig["llm"]) { + sanitizeLLMPayload(httpReq.Payload) + } if err := sign(cast.String(actionConfig["signer"]), httpReq, actionConfig); err != nil { result.Error = "sign failed: " + err.Error() return result, errors.New(result.Error) @@ -104,7 +113,7 @@ func Call(action Action, options ...*CallOptions) (*Result, error) { client := gohttp.NewClient(timeout) defer client.Destroy() if opts.Stream != nil { - err = callStream(client, httpReq, payload, opts.Stream, filters, action.ActionName(), opts.Context, result) + err = callStream(client, httpReq, payload, opts.Stream, filters, action.ActionName(), opts.Context, result, started) } else { err = callBuffered(client, httpReq, payload, result) if err == nil { @@ -127,6 +136,7 @@ func Call(action Action, options ...*CallOptions) (*Result, error) { } } if err == nil && opts.Stream != nil && opts.Stream.OnDone != nil { + finishTiming() err = opts.Stream.OnDone(resultMap(result)) if err != nil { result.Ok = false @@ -140,6 +150,16 @@ func Call(action Action, options ...*CallOptions) (*Result, error) { return result, nil } +func sanitizeLLMPayload(payload any) { + values, ok := payload.(map[string]any) + if !ok { + return + } + for _, field := range []string{"name", "title", "extends", "abstract", "creation", "providerProtocol", "url", "host", "baseUrl", "path", "method", "format", "signer", "key", "filters", "requestSchema", "responseSchema", "test", "models", "defaultModel", "reasoningSupported"} { + delete(values, field) + } +} + func applyActionTraits(action Action, config map[string]any) { if value, ok := action.(URLAction); ok && value.GetURL() != "" { config["url"] = value.GetURL() @@ -251,7 +271,7 @@ func callBuffered(client *gohttp.Client, req *HttpRequest, payload any, result * return nil } -func callStream(client *gohttp.Client, req *HttpRequest, payload any, stream *StreamOptions, filters *filterPipeline, action string, ctx context.Context, result *Result) error { +func callStream(client *gohttp.Client, req *HttpRequest, payload any, stream *StreamOptions, filters *filterPipeline, action string, ctx context.Context, result *Result, started time.Time) error { res := client.ManualDo(req.Method, req.Url, payload, headerSlice(req)...) if res.Error != nil { result.Error = res.Error.Error() @@ -278,18 +298,25 @@ func callStream(client *gohttp.Client, req *HttpRequest, payload any, stream *St buf := make([]byte, 32*1024) for { n, readErr := res.Response.Body.Read(buf) - if n > 0 && stream.OnData != nil { - result.receivedBytes += int64(n) - filtered, filterErr := filters.apply(ctx, "chunk", map[string]any{"action": action, "chunk": append([]byte(nil), buf[:n]...), "drop": false}) - if filterErr != nil { - result.Error = filterErr.Error() - return filterErr + if n > 0 { + if result.Timing != nil { + if _, exists := result.Timing["firstToken"]; !exists { + result.Timing["firstToken"] = time.Since(started).Milliseconds() + } } - chunk := filteredChunk(filtered["chunk"]) - if !cast.Bool(filtered["drop"]) && len(chunk) > 0 { - if err := invokeStreamCallback(func() error { return stream.OnData(chunk) }); err != nil { - result.Error = err.Error() - return err + result.receivedBytes += int64(n) + if stream.OnData != nil { + filtered, filterErr := filters.apply(ctx, "chunk", map[string]any{"action": action, "chunk": append([]byte(nil), buf[:n]...), "drop": false}) + if filterErr != nil { + result.Error = filterErr.Error() + return filterErr + } + chunk := filteredChunk(filtered["chunk"]) + if !cast.Bool(filtered["drop"]) && len(chunk) > 0 { + if err := invokeStreamCallback(func() error { return stream.OnData(chunk) }); err != nil { + result.Error = err.Error() + return err + } } } } @@ -305,10 +332,17 @@ func callStream(client *gohttp.Client, req *HttpRequest, payload any, stream *St } func resultMap(result *Result) map[string]any { - return map[string]any{ + if result == nil { + return map[string]any{"ok": false, "statusCode": 0, "headers": map[string]string{}, "data": nil, "code": "", "error": "empty API result"} + } + output := map[string]any{ "ok": result.Ok, "statusCode": result.StatusCode, "headers": result.Headers, "data": result.Data, "code": result.Code, "error": result.Error, } + if result.Timing != nil { + output["timing"] = result.Timing + } + return output } func invokeStreamCallback(callback func() error) (err error) { diff --git a/engine_test.go b/engine_test.go index 7e66675..59f96cc 100644 --- a/engine_test.go +++ b/engine_test.go @@ -63,6 +63,27 @@ func TestResultRulesAndConfigOverride(t *testing.T) { if receivedPath != "/v1/new" || receivedMethod != "PUT" { t.Fatalf("override reached %s %s", receivedMethod, receivedPath) } + if result.Timing != nil { + t.Fatalf("timing must be omitted by default: %#v", result.Timing) + } +} + +func TestOptionalTiming(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + name := registerTestAction(t, map[string]any{"url": server.URL, "method": "GET"}) + result, err := api.CallBy(name, nil, &api.CallOptions{Timing: true}) + if err != nil { + t.Fatal(err) + } + if result.Timing["unit"] != "ms" { + t.Fatalf("unexpected timing unit: %#v", result.Timing) + } + if _, ok := result.Timing["total"].(int64); !ok { + t.Fatalf("timing total is missing: %#v", result.Timing) + } } func TestStreamPreservesChunkOrder(t *testing.T) { @@ -78,7 +99,7 @@ func TestStreamPreservesChunkOrder(t *testing.T) { name := registerTestAction(t, map[string]any{"url": server.URL, "method": "GET"}) var got bytes.Buffer var done bool - result, err := api.CallBy(name, nil, &api.CallOptions{Stream: &api.StreamOptions{ + result, err := api.CallBy(name, nil, &api.CallOptions{Timing: true, Stream: &api.StreamOptions{ OnData: func(data []byte) error { _, _ = got.Write(data); return nil }, OnDone: func(result map[string]any) error { done, _ = result["ok"].(bool); return nil }, }}) @@ -88,6 +109,9 @@ func TestStreamPreservesChunkOrder(t *testing.T) { if !result.Ok || !done || !reflect.DeepEqual(got.Bytes(), bytes.Join(chunks, nil)) { t.Fatalf("stream mismatch: ok=%v done=%v body=%q", result.Ok, done, got.String()) } + if _, ok := result.Timing["firstToken"].(int64); !ok { + t.Fatalf("stream first-token timing is missing: %#v", result.Timing) + } } func TestUnifiedFilterLifecycle(t *testing.T) { diff --git a/js_export.go b/js_export.go index 9b0727e..90572ed 100644 --- a/js_export.go +++ b/js_export.go @@ -14,7 +14,7 @@ func init() { "RegisterAction": RegisterAction, "RegisterSigner": registerSigner, "Encrypt": Encrypt, - }) + }, "SetConfig", "RegisterAction", "RegisterSigner") } // call 提供给 JS 的私有入口 @@ -28,11 +28,11 @@ func call(ctx context.Context, name string, payload any, options ...*CallOptions } if err != nil { if res != nil { - return res, nil + return resultMap(res), nil } return nil, jsmod.MakeError(err) } - return res, nil + return resultMap(res), nil } func actionUsesDataResult(name string) bool { @@ -55,11 +55,18 @@ func projectDataResult(result *Result) any { if _, exists := output["error"]; !exists { output["error"] = result.Error } + if result.Timing != nil { + output["timing"] = result.Timing + } return output } - return map[string]any{ + output := map[string]any{ "ok": result.Ok, "code": result.Code, "error": result.Error, "result": result.Data, } + if result.Timing != nil { + output["timing"] = result.Timing + } + return output } // registerSigner 允许从 JS 注册动态签名逻辑 diff --git a/js_export_internal_test.go b/js_export_internal_test.go index 0e7ba0a..65b44d5 100644 --- a/js_export_internal_test.go +++ b/js_export_internal_test.go @@ -1,9 +1,13 @@ package api -import "testing" +import ( + "testing" + + "apigo.cc/go/jsmod" +) func TestProjectDataResult(t *testing.T) { - result := &Result{Ok: true, StatusCode: 200, Headers: map[string]string{"X-Test": "ignored"}, Data: map[string]any{"result": "hello"}} + result := &Result{Ok: true, StatusCode: 200, Headers: map[string]string{"X-Test": "ignored"}, Data: map[string]any{"result": "hello"}, Timing: map[string]any{"unit": "ms", "total": int64(12)}} projected := projectDataResult(result).(map[string]any) if projected["ok"] != true || projected["result"] != "hello" || projected["code"] != "" || projected["error"] != "" { t.Fatalf("unexpected projected result: %#v", projected) @@ -11,6 +15,19 @@ func TestProjectDataResult(t *testing.T) { if _, exists := projected["headers"]; exists { t.Fatalf("transport headers leaked into projected result: %#v", projected) } + if projected["timing"].(map[string]any)["total"] != int64(12) { + t.Fatalf("timing was not projected: %#v", projected) + } +} + +func TestResultMapUsesLowerCamelKeys(t *testing.T) { + mapped := resultMap(&Result{Ok: true, StatusCode: 200, Timing: map[string]any{"unit": "ms"}}) + if mapped["ok"] != true || mapped["statusCode"] != 200 || mapped["timing"] == nil { + t.Fatalf("unexpected result map: %#v", mapped) + } + if _, exists := mapped["Ok"]; exists { + t.Fatalf("Go field name leaked into result map: %#v", mapped) + } } func TestActionUsesInheritedDataResult(t *testing.T) { @@ -22,3 +39,12 @@ func TestActionUsesInheritedDataResult(t *testing.T) { t.Fatal("child Action did not inherit resultMode=data") } } + +func TestJSFilterRegistrationIsUnsafe(t *testing.T) { + module := jsmod.GetModules()["api"] + for _, name := range []string{"SetConfig", "RegisterAction", "RegisterSigner"} { + if module == nil || !module.UnsafeList[name] { + t.Fatalf("JS API mutation %s must be blocked in safe mode", name) + } + } +} diff --git a/options.go b/options.go index 9e922ec..317402e 100644 --- a/options.go +++ b/options.go @@ -15,6 +15,7 @@ type CallOptions struct { Timeout time.Duration Logger *log.Logger Stream *StreamOptions + Timing bool } // StreamOptions receives the upstream response synchronously on the calling goroutine.