mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 00:48:00 +00:00
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:
@ -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 <status>: 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<string,string>`
|
||||
|
||||
Dynamically generate / sign request headers (e.g. KimiCode's HMAC signature). If the
|
||||
|
||||
@ -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 <status>: 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<string,string>`
|
||||
|
||||
动态生成/签名请求头(如 KimiCode 的 HMAC 签名)。若脚本未定义此函数,Go 层回退使用静态
|
||||
|
||||
@ -108,4 +108,19 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- 错误收敛:前置 WAF 会返回整页 HTML(阿里云盾),JSON 时为 new-api 风格
|
||||
function adapter.transform_error(status, body)
|
||||
local low = string.lower(body or "")
|
||||
if string.sub(low, 1, 9) == "<!doctype" or string.find(low, "<html", 1, true) then
|
||||
return "blocked by AgentRouter WAF"
|
||||
end
|
||||
local ok, resp = pcall(json.decode, body)
|
||||
if not ok or type(resp) ~= "table" then return nil end
|
||||
local e = resp.error
|
||||
if type(e) == "table" and type(e.message) == "string" then
|
||||
return e.message
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return adapter
|
||||
@ -180,4 +180,15 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
return ""
|
||||
end
|
||||
|
||||
-- 错误收敛:Anthropic 信封 {type:"error", error:{type, message}}
|
||||
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.type == "error" and type(resp.error) == "table"
|
||||
and type(resp.error.message) == "string" then
|
||||
return resp.error.message
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return adapter
|
||||
|
||||
@ -127,4 +127,15 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- 错误收敛:DeepSeek 走标准 OpenAI 信封 {error:{message,...}}
|
||||
function adapter.transform_error(status, body)
|
||||
local ok, resp = pcall(json.decode, body)
|
||||
if not ok or type(resp) ~= "table" then return nil end
|
||||
local e = resp.error
|
||||
if type(e) == "table" and type(e.message) == "string" then
|
||||
return e.message
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return adapter
|
||||
|
||||
@ -152,4 +152,20 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- 错误收敛:Gemini REST 信封 {error:{code, message, status}}
|
||||
function adapter.transform_error(status, body)
|
||||
local ok, resp = pcall(json.decode, body)
|
||||
if not ok or type(resp) ~= "table" then return nil end
|
||||
local e = resp.error
|
||||
if type(e) == "table" then
|
||||
if type(e.message) == "string" then
|
||||
if type(e.status) == "string" then
|
||||
return e.status .. ": " .. e.message
|
||||
end
|
||||
return e.message
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return adapter
|
||||
|
||||
@ -108,4 +108,15 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- 错误收敛:GitHub Models 走标准 OpenAI 信封 {error:{message,...}}
|
||||
function adapter.transform_error(status, body)
|
||||
local ok, resp = pcall(json.decode, body)
|
||||
if not ok or type(resp) ~= "table" then return nil end
|
||||
local e = resp.error
|
||||
if type(e) == "table" and type(e.message) == "string" then
|
||||
return e.message
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return adapter
|
||||
|
||||
@ -107,4 +107,15 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- 错误收敛:Groq 走标准 OpenAI 信封 {error:{message,...}}
|
||||
function adapter.transform_error(status, body)
|
||||
local ok, resp = pcall(json.decode, body)
|
||||
if not ok or type(resp) ~= "table" then return nil end
|
||||
local e = resp.error
|
||||
if type(e) == "table" and type(e.message) == "string" then
|
||||
return e.message
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return adapter
|
||||
|
||||
@ -140,4 +140,15 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- 错误收敛:kimi 网关为 OpenAI 风格 {error:{message,...}}
|
||||
function adapter.transform_error(status, body)
|
||||
local ok, resp = pcall(json.decode, body)
|
||||
if not ok or type(resp) ~= "table" then return nil end
|
||||
local e = resp.error
|
||||
if type(e) == "table" and type(e.message) == "string" then
|
||||
return e.message
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return adapter
|
||||
@ -107,4 +107,14 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- 错误收敛:Mistral 用顶层 {message="...", type="..."}(非嵌套 error)
|
||||
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 type(resp.message) == "string" then
|
||||
return resp.message
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return adapter
|
||||
|
||||
@ -131,4 +131,15 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- 错误收敛:Ollama 常见 {error:"..."} 字符串(新版本也有对象形态)
|
||||
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 type(resp.error) == "string" then return resp.error end
|
||||
if type(resp.error) == "table" and type(resp.error.message) == "string" then
|
||||
return resp.error.message
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
return adapter
|
||||
|
||||
@ -111,4 +111,16 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- 错误收敛:标准 OpenAI 信封 {error:{message,...}}
|
||||
function adapter.transform_error(status, body)
|
||||
local ok, resp = pcall(json.decode, body)
|
||||
if not ok or type(resp) ~= "table" then return nil end
|
||||
local e = resp.error
|
||||
if type(e) == "table" and type(e.message) == "string" then
|
||||
return e.message
|
||||
end
|
||||
if type(e) == "string" then return e end
|
||||
return nil
|
||||
end
|
||||
|
||||
return adapter
|
||||
|
||||
@ -183,4 +183,17 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- 错误收敛(可选钩子):zen 错误信封固定为 {error={type,message}};
|
||||
-- 免费池限流 FreeUsageLimitError 单独标注。返回 nil 走通用兜底。
|
||||
function adapter.transform_error(status, body)
|
||||
local ok, resp = pcall(json.decode, body)
|
||||
if not ok or type(resp) ~= "table" then return nil end
|
||||
local e = resp.error
|
||||
if type(e) ~= "table" then return nil end
|
||||
if e.type == "FreeUsageLimitError" then
|
||||
return "zen free pool quota exhausted"
|
||||
end
|
||||
return e.message
|
||||
end
|
||||
|
||||
return adapter
|
||||
|
||||
@ -105,4 +105,19 @@ function adapter.transform_stream_chunk(raw_chunk)
|
||||
return json.encode(unified)
|
||||
end
|
||||
|
||||
-- 错误收敛:sensenova 为 OpenAI 风格 {error:{message,...}};
|
||||
-- 配额类错误单独点出便于客户端识别重置周期。
|
||||
function adapter.transform_error(status, body)
|
||||
local ok, resp = pcall(json.decode, body)
|
||||
if not ok or type(resp) ~= "table" then return nil end
|
||||
local e = resp.error
|
||||
if type(e) ~= "table" then return nil end
|
||||
if status == 429 and type(e.code) == "string"
|
||||
and e.code == "insufficient_quota" then
|
||||
return "workspace quota exhausted (resets periodically)"
|
||||
end
|
||||
if type(e.message) == "string" then return e.message end
|
||||
return nil
|
||||
end
|
||||
|
||||
return adapter
|
||||
|
||||
@ -407,6 +407,46 @@ func (v *VM) BuildHeaders(name string, meta map[string]interface{}) (map[string]
|
||||
return headers, nil
|
||||
}
|
||||
|
||||
// TransformError executes the optional adapter.transform_error(status, body)
|
||||
// hook: per-source condensing of an upstream error response into a short
|
||||
// client-facing reason. ok=false means the adapter defines no hook (or it
|
||||
// failed) — the caller falls back to the generic Go-side condenser.
|
||||
func (v *VM) TransformError(name string, status int, body string) (reason string, ok bool, err error) {
|
||||
p := v.pool(name)
|
||||
if p == nil {
|
||||
return "", false, fmt.Errorf("adapter %s not loaded", name)
|
||||
}
|
||||
w, err := p.acquire()
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
defer p.release(w)
|
||||
L := w.L
|
||||
|
||||
L.SetTop(0)
|
||||
defer L.SetTop(0)
|
||||
L.GetGlobal(adapterGlobal)
|
||||
if L.IsNil(-1) {
|
||||
return "", false, nil
|
||||
}
|
||||
L.GetField(-1, "transform_error")
|
||||
if !L.IsFunction(-1) {
|
||||
return "", false, nil
|
||||
}
|
||||
L.SetTop(0)
|
||||
L.GetGlobal(adapterGlobal)
|
||||
L.GetField(-1, "transform_error")
|
||||
L.PushInteger(int64(status))
|
||||
L.PushString(body)
|
||||
if callErr := L.Call(2, 1); callErr != nil {
|
||||
return "", false, fmt.Errorf("transform_error: %w", callErr)
|
||||
}
|
||||
if L.Type(-1) != golua.LUA_TSTRING {
|
||||
return "", false, nil
|
||||
}
|
||||
return L.ToString(-1), true, nil
|
||||
}
|
||||
|
||||
func (v *VM) Endpoint(name string) string {
|
||||
p := v.pool(name)
|
||||
if p == nil {
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
@ -434,7 +435,7 @@ func (p *Provider) probeModels(ctx context.Context) (bool, string) {
|
||||
case resp.StatusCode == 404 || resp.StatusCode == 405:
|
||||
return false, ""
|
||||
default:
|
||||
return false, shortAPIError(resp.StatusCode, string(raw))
|
||||
return false, p.apiErrReason(resp.StatusCode, string(raw))
|
||||
}
|
||||
}
|
||||
|
||||
@ -471,7 +472,7 @@ func (p *Provider) probeChat(ctx context.Context) (bool, string) {
|
||||
} else if status == 200 {
|
||||
ok = true
|
||||
} else {
|
||||
msg = shortAPIError(status, raw)
|
||||
msg = p.apiErrReason(status, raw)
|
||||
}
|
||||
} else {
|
||||
msg = err.Error()
|
||||
@ -717,7 +718,7 @@ func (p *Provider) Chat(ctx context.Context, req *types.ChatRequest) (*types.Uni
|
||||
}
|
||||
if status != 200 {
|
||||
p.ReportStatus(model, status)
|
||||
return nil, fmt.Errorf("%s", shortAPIError(status, raw))
|
||||
return nil, fmt.Errorf("%s", p.apiErrReason(status, raw))
|
||||
}
|
||||
unified, err := p.vm.Transform(p.adapter, "transform_response", raw)
|
||||
if err != nil {
|
||||
@ -793,7 +794,7 @@ func (p *Provider) ChatStream(ctx context.Context, req *types.ChatRequest) (<-ch
|
||||
sel.resp.Body.Close()
|
||||
p.ReportStatus(model, sel.resp.StatusCode)
|
||||
p.Release()
|
||||
return nil, fmt.Errorf("%s", shortAPIError(sel.resp.StatusCode, string(raw)))
|
||||
return nil, fmt.Errorf("%s", p.apiErrReason(sel.resp.StatusCode, string(raw)))
|
||||
}
|
||||
go func() {
|
||||
defer p.Release()
|
||||
@ -928,48 +929,6 @@ func errorOnlyChunk(ck types.UnifiedChunk) bool {
|
||||
ck.ReasoningContent == "" && ck.Usage == nil
|
||||
}
|
||||
|
||||
// shortAPIError condenses an upstream error response into its human reason:
|
||||
// JSON envelopes contribute their error/message field, HTML pages (WAF
|
||||
// blocklists) collapse to a marker, anything else is capped as-is. Raw
|
||||
// bodies must not leak through errors to clients.
|
||||
func shortAPIError(status int, body string) string {
|
||||
b := strings.TrimSpace(body)
|
||||
low := strings.ToLower(b)
|
||||
if strings.HasPrefix(low, "<!doctype") || strings.Contains(low, "<html") {
|
||||
return fmt.Sprintf("api error %d: upstream returned an HTML error page", status)
|
||||
}
|
||||
var env struct {
|
||||
Error json.RawMessage `json:"error"`
|
||||
Message string `json:"message"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(b), &env); err == nil {
|
||||
msg := ""
|
||||
switch {
|
||||
case len(env.Error) > 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
|
||||
|
||||
@ -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"},
|
||||
}
|
||||
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)
|
||||
// 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)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user