feat(adapter): move per-source error condensing into transform_error hooks

Every upstream formats errors differently, which is adapter territory:
the protocol gains an optional transform_error(status, body) hook and all
built-in adapters implement their own envelope parsing (zen free-pool
labels, anthropic/gemini/ollama/mistral shapes, sensenova quota notes,
agentrouter WAF pages). The core keeps a single uniform fallback: when no
hook yields a reason clients get "api error <status>: unknown error" and
the raw body goes to server logs only.
This commit is contained in:
JianFeeeee
2026-08-24 19:17:36 +08:00
parent 6eac80bc6c
commit b2183df1e8
17 changed files with 305 additions and 69 deletions

View File

@ -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", "<!doctypehtml><html lang=\"zh-cn\"><title>405</title></html>", "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
}