feat(api): 完善调用诊断与低代码结果(by AI)

This commit is contained in:
Star 2026-08-21 14:42:12 +08:00
parent bb3c68f56c
commit 79138526b2
9 changed files with 124 additions and 27 deletions

View File

@ -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)

View File

@ -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 会直接返回错误。

View File

@ -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

View File

@ -224,5 +224,6 @@ type Result struct {
Data any
Code string
Error string
Timing map[string]any
receivedBytes int64
}

View File

@ -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) {

View File

@ -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) {

View File

@ -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 注册动态签名逻辑

View File

@ -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)
}
}
}

View File

@ -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.