diff --git a/docs/lua-adapters-en.md b/docs/lua-adapters-en.md index bc62f84..6fa8560 100644 --- a/docs/lua-adapters-en.md +++ b/docs/lua-adapters-en.md @@ -55,6 +55,7 @@ state may be shared across workers (`log` to stdout is the only side effect). | `version` | string | no | Version, shown in the WebUI | | `endpoint` | string | no | Upstream path, default `/chat/completions`; overridable by `source.endpoint` / `source.image_endpoint` | | `headers` | table | no | Static default request headers; used as fallback when no `build_headers` hook is defined | +| `transform_error(status, body)` | function | no | Error-response condensing: return a one-line reason; on nil / absence clients uniformly receive `unknown error` (raw body goes to server logs only) | These fields are extracted statically at load time (compile-once); reading them never occupies a pooled worker. @@ -147,6 +148,27 @@ end ## 4. Optional Hooks +### `transform_error(status, body) -> string | nil` + +Condense this source's error response into a one-line reason. Every upstream +formats errors differently — that is adapter territory: all built-in adapters +implement their own envelope parsing (zen's `{error={type,message}}`, +Anthropic's `{type="error",error={...}}`, Gemini's +`{error={code,message,status}}`, Ollama's string `{error="..."}`, etc.). +When the hook is absent or returns nil the core does not guess: clients get +`api error : unknown error` and the raw body is logged server-side only. + +```lua +function adapter.transform_error(status, body) + local ok, resp = pcall(json.decode, body) + if not ok or type(resp) ~= "table" then return nil end + if resp.error and resp.error.type == "FreeUsageLimitError" then + return "zen free pool quota exhausted" + end + return resp.error and resp.error.message or nil +end +``` + ### `build_headers(meta) -> table` Dynamically generate / sign request headers (e.g. KimiCode's HMAC signature). If the diff --git a/docs/lua-adapters.md b/docs/lua-adapters.md index 6bd14c3..c0faa9c 100644 --- a/docs/lua-adapters.md +++ b/docs/lua-adapters.md @@ -50,6 +50,7 @@ return adapter | `version` | string | 否 | 版本号,用于 WebUI 展示 | | `endpoint` | string | 否 | 上游请求路径,默认 `/chat/completions`;可被 `source.endpoint` / `source.image_endpoint` 覆盖 | | `headers` | table | 否 | 静态默认请求头;若未定义 `build_headers` 钩子则作为请求头回退 | +| `transform_error(status, body)` | function | 否 | 错误响应收敛:返回一行短原因;返回 nil / 未定义时客户端统一收到 `unknown error`(原始响应体只进服务端日志) | 这些字段在加载时静态提取(compile-once),之后读它们不会占用池内 worker。 @@ -138,6 +139,26 @@ end ## 4. 可选钩子 +### `transform_error(status, body) -> string | nil` + +把该源特有的错误响应收敛成一行短原因。每个上游的错误格式不同——这是适配器层 +的职责:内置各适配器均实现了自己的信封解析(如 zen 的 +`{error={type,message}}`、Anthropic 的 `{type="error",error={...}}`、Gemini 的 +`{error={code,message,status}}`、Ollama 的字符串 `{error="..."}` 等)。 +未定义本钩子或返回 nil 时,核心不猜测格式,客户端统一收到 +`api error : unknown error`,原始响应体仅记录在服务端日志。 + +```lua +function adapter.transform_error(status, body) + local ok, resp = pcall(json.decode, body) + if not ok or type(resp) ~= "table" then return nil end + if resp.error and resp.error.type == "FreeUsageLimitError" then + return "zen free pool quota exhausted" + end + return resp.error and resp.error.message or nil +end +``` + ### `build_headers(meta) -> table` 动态生成/签名请求头(如 KimiCode 的 HMAC 签名)。若脚本未定义此函数,Go 层回退使用静态 diff --git a/internal/lua/adapters/agentrouter.lua b/internal/lua/adapters/agentrouter.lua index a6193a8..549fbbe 100644 --- a/internal/lua/adapters/agentrouter.lua +++ b/internal/lua/adapters/agentrouter.lua @@ -108,4 +108,19 @@ function adapter.transform_stream_chunk(raw_chunk) return json.encode(unified) end -return adapter \ No newline at end of file +-- 错误收敛:前置 WAF 会返回整页 HTML(阿里云盾),JSON 时为 new-api 风格 +function adapter.transform_error(status, body) + local low = string.lower(body or "") + if string.sub(low, 1, 9) == " 0: - var es string - if json.Unmarshal(env.Error, &es) == nil { - msg = es - } else { - var obj struct { - Message string `json:"message"` - } - if json.Unmarshal(env.Error, &obj) == nil { - msg = obj.Message - } - } - case env.Message != "": - msg = env.Message - case env.Msg != "": - msg = env.Msg - } - if msg != "" { - return fmt.Sprintf("api error %d: %s", status, oneLineStr(msg, 160)) - } - } - return fmt.Sprintf("api error %d: %s", status, oneLineStr(b, 160)) -} - func oneLineStr(s string, n int) string { s = strings.Join(strings.Fields(s), " ") if len(s) > n { @@ -977,6 +936,20 @@ func oneLineStr(s string, n int) string { } return s } +// apiErrReason builds the client-facing reason for a non-200 upstream +// response: the adapter's optional transform_error hook wins (per-source +// protocol knowledge lives in Lua), otherwise clients get a uniform +// "unknown error" while the raw body stays in the server log for debugging. +func (p *Provider) apiErrReason(status int, raw string) string { + if reason, ok, err := p.vm.TransformError(p.adapter, status, raw); err == nil && ok { + if trimmed := strings.TrimSpace(reason); trimmed != "" { + return fmt.Sprintf("api error %d: %s", status, oneLineStr(trimmed, 200)) + } + } + log.Printf("[provider] unhandled upstream error body (adapter %q lacks transform_error): status=%d body=%.300s", + p.adapter, status, oneLineStr(raw, 300)) + return fmt.Sprintf("api error %d: unknown error", status) +} // Image generates images via /v1/images/generations. Same scheduling-state // accounting as Chat: fail fast on busy, record per (source, model). @@ -1015,7 +988,7 @@ func (p *Provider) Image(ctx context.Context, req *types.ImageGenRequest) (*type } if status != 200 { p.ReportStatus(model, status) - return nil, fmt.Errorf("image api error %d: %s", status, truncate(raw, 500)) + return nil, fmt.Errorf("%s", p.apiErrReason(status, raw)) } var out types.UnifiedResponse // try adapter transform_response; if missing, parse standard openai image format diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index d24a342..8cfe89e 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "net/http/httptest" + "os" "path/filepath" "strings" "sync/atomic" @@ -539,29 +540,61 @@ func TestStandardSSEChunkFinishReason(t *testing.T) { } } -func TestShortAPIError(t *testing.T) { - cases := []struct{ name, body, want string }{ - {"openai-style envelope", `{"error":{"message":"Allocated quota exceeded","type":"invalid_request_error","code":"insufficient_quota"}}`, "api error 429: Allocated quota exceeded"}, - {"nested console envelope", `{"error":{"type":"server_error","message":"Error from provider (Console): Upstream request failed: Endpoint is unavailable."}}`, "api error 503: Error from provider (Console): Upstream request failed: Endpoint is unavailable."}, - {"string error", `{"error":"boom"}`, "api error 500: boom"}, - {"html waf page", "405", "api error 405: upstream returned an HTML error page"}, - {"plain text body", "service unavailable", "api error 503: service unavailable"}, +// TestAdapterHookCondensesError: the adapter transform_error hook owns the +// per-source error format; its reason must reach the client verbatim. +func TestAdapterHookCondensesError(t *testing.T) { + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(503) + fmt.Fprint(w, `{"error":{"type":"FreeUsageLimitError","message":"Rate limit exceeded. Please try again later."}}`) + })) + defer up.Close() + p := newTestProvider(t, src("mock", up.URL, "opencode", "m")) + _, err := p.Chat(context.Background(), &types.ChatRequest{ + Model: "m", + Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}}, + }) + if err == nil || !strings.Contains(err.Error(), "zen free pool quota exhausted") { + t.Fatalf("adapter hook reason must surface, got %v", err) } - for _, c := range cases { - if got := shortAPIError(statusFor(c.want), c.body); got != c.want { - t.Fatalf("%s: got %q want %q", c.name, got, c.want) - } + if strings.Contains(err.Error(), "Rate limit exceeded") { + t.Fatalf("raw upstream body must not leak past the hook: %v", err) } } -func statusFor(want string) int { - switch { - case strings.Contains(want, "429"): - return 429 - case strings.Contains(want, "503"): - return 503 - case strings.Contains(want, "405"): - return 405 +// TestUnknownErrorFallbackWithoutHook: an adapter without transform_error +// gets the uniform core fallback; the raw body must not leak to clients. +func TestUnknownErrorFallbackWithoutHook(t *testing.T) { + dir := filepath.Join(t.TempDir(), "adapters") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + minimal := `return { name="custom", endpoint="/chat/completions", + transform_request=function(raw) return raw end, + transform_response=function(raw) return raw end } +` + if err := os.WriteFile(filepath.Join(dir, "custom.lua"), []byte(minimal), 0o644); err != nil { + t.Fatal(err) + } + vm := lua.NewVM(dir) + if err := vm.Start(); err != nil { + t.Fatalf("vm: %v", err) + } + defer vm.Stop() + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + fmt.Fprint(w, `{"weird":{"shape":["no","message"]}}`) + })) + defer up.Close() + p := New(src("mock", up.URL, "custom", "m"), vm) + _, err := p.Chat(context.Background(), &types.ChatRequest{ + Model: "m", + Messages: []types.ChatMessage{{Role: "user", Content: types.StringContent("x")}}, + }) + if err == nil || !strings.Contains(err.Error(), "unknown error") { + t.Fatalf("hook-less adapter must fall back to unknown error, got %v", err) + } + if strings.Contains(err.Error(), "shape") { + t.Fatalf("raw body must not leak: %v", err) } - return 500 }